Files
classhelper-island-refactor…/out/vol4-消息课程与编辑器(08-09).md
T
ClassIsland Prompt Kit 22c1784bab feat: ClassIsland / ClassHelper 灵动岛提示词套件
- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
2026-11-12 21:59:58 +08:00

862 lines
105 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 8. 消息、提醒、课程提醒、强提醒特效、天气与课件覆盖(ClassHelper 落地规格)
本节是「消息与课程」这一部分在 **ClassHelper 现有仓库内** 的落地规格。前提口径(与 parts-r/01、parts-r/02 保持一致):
- 默认风格 `appearance.family = 'classisland'`(对应配置键 `islandAppearance.style`,落盘字段以 parts-r/05 为准):`fixed` **不再是胶囊+详情卡**,而是 **ClassIsland 风格信息行**——默认 1 行 = 日期 + 课程表,行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条;消息到来时**在同一行内**展开 ClassIsland 风格气泡。原「胶囊 + 详情卡」只在 `family='winisland'` 兼容形态保留(老用户升级默认 `winisland`,新安装默认 `classisland`)。
- `custom` 常驻:保留现有 `schemes/rows/nodes/expandedRows` 数据模型与编辑器,渲染与交互按 ClassIsland 规格重构;消息入口仍是铃铛 + 共用消息面板。
- 数据不迁移:通知/作业/叫人/课表/课件/天气全部沿用 ClassHelper 现有服务端与本地链路(`IslandNotification`、`ScheduleDto`、`IslandTimePointDto`、`CoursewareTransfer`、`IslandWeather`)。
- 裁决优先级:**ClassHelper 业务语义优先,ClassIsland 视觉表现优先**;逐项裁决见 8.12。
### 8.1 数据契约(复用为主,最小扩展)
#### 8.1.1 现有类型必须原样复用
| 类型 | 位置 | 用途 |
|---|---|---|
| `NotificationPriority = 'LOW'\|'NORMAL'\|'HIGH'\|'URGENT'` | `packages/shared/src/types.ts:30` | 业务优先级(四档) |
| `PRIORITY_RANK` | `packages/shared/src/constants.ts:203-208` | 排序与合并共用的权重(LOW 0 / NORMAL 1 / HIGH 2 / URGENT 3) |
| `IslandNotification` | `packages/shared/src/types.ts:1131-1143` | 岛消息载荷:`id/title/content/priority/createdAt/courseName/teacherName/kind/subtitle` |
| `IslandNotificationKind = 'notification'\|'homework'\|'call'` | `packages/shared/src/types.ts:1111` | 消息类型 |
| `IslandState` | `packages/shared/src/types.ts:1198-1230` | `coursewareTransfer/mode/lessonReminder/active/queued/inClass/currentPeriodEnd/reason/listExpanded/maxCardHeight/updatedAt` |
| `IslandLessonReminder` | `packages/shared/src/types.ts:1114-1128` | `kind: prepare/on-class/break/ending/in-class` 等字段 |
| `LessonReminderSettings` | `packages/shared/src/island-layout.ts:141-158` | 五类提醒开关 + `prepareSeconds/endingSeconds` |
| `ScheduleDto` | `packages/shared/src/types.ts:545-564` | `dayOfWeek`(1=周一…7=周日)/`startTime`/`endTime`/`weekParity`/`course` |
| `IslandTimePointDto` | `packages/shared/src/class-plan.ts:76-87` | `timeType/name/courseName/startTime/endTime/isHideDefault` |
| `CoursewareTransfer` | `packages/shared/src/courseware.ts:82-94` | `stage/receivedBytes/totalBytes/progress/error` |
| `IslandWeather` | `packages/shared/src/island-layout.ts:38-44` | `temperature/code/windSpeed/fetchedAt/stale` |
#### 8.1.2 必须新增/扩展的字段(只加不改,保证迁移可回滚)
1. `IslandState` 新增:
- `family: 'classisland' | 'winisland'`:由外观下发给渲染进程,决定渲染形态;
- `pendingStaged: number`:上课期间被暂存的消息数(用于行内角标/铃铛计数);
- `bubblePhase: 'hidden' | 'mask' | 'overlay' | 'closing'`:气泡阶段(仅 `fixed` 使用,见 8.4);
- `bubbleDeadline: number`:当前气泡阶段结束时间戳(毫秒),供渲染进程做进度条与自动收起(避免渲染进程自己计时导致主/渲染漂移)。
2. `IslandNotification` 新增可选 `level?: 'emergency'|'high'|'normal'|'low'`:由 `priority` 映射而来(URGENT→emergency、HIGH→high、NORMAL→normal、LOW→low),用于 ClassIsland 三级排序键的中间键;**不改变服务端 DTO**,只在主进程派生。
3. `IslandLessonReminder` 新增 `progress: number`(0–1,课程进度)与 `leftSeconds: number`(整数秒,供徽标直接渲染),由主进程计算下发,渲染进程不得自行 `Date.now()` 推算(防止跨进程时钟漂移与系统时间跳变)。
4. `islandLayout` 新增 `version` 与 `family`(见 parts-r/05);旧 `residentEnabled` 保留为兼容字段。
#### 8.1.3 通知 DTO → 岛载荷的映射(单一入口)
必须在主进程的推送链路(现有 `pushNotification` 的调用方)集中实现,禁止各处自行拼装:
```
toIslandNotification(dto: NotificationDto, kindHint?): IslandNotification {
id: dto.id,
title: dto.title,
content: dto.content,
priority: dto.priority,
level: MAP[dto.priority],
createdAt: dto.createdAt,
kind: kindHint ?? 'notification', // 作业=homework;叫人=call(保留现有双播合并)
courseName: dto.course?.name ?? null,
teacherName: dto.teacher?.name ?? null,
subtitle: dto.subtitle ?? null
}
```
- 作业消息 `kind='homework'`,`id` 为本地合成的 `homework-<id>`(`packages/desktop-client/src/main/island.ts:1061-1065`),**不得**拿它去调「标为已读」接口。
- 叫人消息 `kind='call'`,标题由服务端 `buildCallTitle` 生成(`packages/shared/src/constants.ts:571-575`:`请 XXX 同学找 XXX 老师`)。
### 8.2 去重、合并与排序(ClassHelper 业务规则,必须保留)
1. **按 id 去重**:同一 id 只保留一条,`pendingNotifications()` 以 Map 去重(`packages/desktop-client/src/main/island.ts:738-749`;渲染侧同口径 `src/island/IslandApp.vue:86-97`)。
2. **通知/叫人双播合并**:`createCall` 同时发 `notification:new` 与 `call:new`,同 id 两条到达时必须**合并而非覆盖**:`kind` 取更特殊的(call 优先),`priority` 取更高(`PRIORITY_RANK` 比较)(`island.ts:713-724`)。这是硬性要求,否则会出现「叫人胶囊显示成新消息、上课被当普通通知隐藏」。
3. **排序口径**:`PRIORITY_RANK` 降序 → `createdAt` 降序(新的在前)(`island.ts:744-748`)。**不得**改用 ClassIsland 的三级键打乱业务优先级——ClassIsland 的「提供方优先级」在 ClassHelper 中没有对应概念,只有服务端单通道消息,因此**本重构的排序键固定为(业务优先级 → 时间)**;若要引入 ClassIsland 的「通知等级」作为第二键,仅在与时间同序时作为稳定排序的次级键,不得跨优先级反转。
4. **未读与已读**:未读数以服务端为准(`GET /notifications/unread-count`,`docs/app/notifications.md:45-47`);岛内「标为已读 / 全部已读」必须调用既有 `markRead/markAllRead` 处理器,成功后通知中心列表同步(`island.ts:1044-1070`;`packages/desktop-client/src/renderer/stores/notifications.ts:102-122`)。
5. **实时撤回**:收到 `notification:deleted` 后必须从岛队列与当前展示中移除(`island.ts:1210-1216`),缓存与通知列表同步清理(`docs/app/notifications.md:34`)。
6. **本机作业指纹**:本机刚录的作业通过指纹避免重复提醒自己——这是既有规则,必须保留(`docs/app/island.md:25`);实现位置在推送入口,不在岛渲染层。
### 8.3 队列与展示状态机(三形态对照 + 伪代码)
`IslandState` 的状态字段固定为:`mode(hidden|pill|expanded)`、`active`、`queued`、`inClass`、`currentPeriodEnd`、`reason(new|after-class|urgent|call|closing)`、`listExpanded`、`maxCardHeight` + 新增 `bubblePhase/pendingStaged`。
#### 8.3.1 三种形态的展示规则对照
| 场景 | `fixed` + `classisland`(默认) | `fixed` + `winisland`(兼容) | `custom` |
|---|---|---|---|
| 空闲 | 信息行常驻(日期+课程表),无消息时不显示气泡 | 隐藏或细缝(现有规则) | 常驻组件行 |
| 课间普通通知/作业 | 行内气泡自动展开(遮罩→正文),可点开应用 | 胶囊,点击详情 | 铃铛 + 未处理数量,点击面板 |
| 课堂普通通知/作业 | **暂存**:不展开气泡,行内显示「N 条暂存」角标(muted),下课补弹 | 暂存,下课补弹(现有) | 铃铛提示,允许主动查看 |
| 普通叫人(课间) | 行内气泡自动展开(kind=call 样式) | 胶囊(可点) | 铃铛 |
| 普通叫人(课堂) | **保持可见的「叫人」chip,不自动展开**;点击后展开气泡 | 保留可点胶囊,不自动展开 | 铃铛,主动查看 |
| 紧急通知/紧急叫人 | **立即展开气泡**(无视上课),并触发强提醒特效 | 立即展开 | 铃铛 + 提示,用户主动展开(可自动提示) |
| 处理完成 | 气泡收回,信息行恢复 | 队列清空后隐藏/细缝 | 回常态行 |
| 课件传输 | 覆盖整岛内容(信息行也覆盖),见 8.10 | 同 | 同 |
#### 8.3.2 `pushNotification` 分支(必须逐条实现,以现有实现为基底)
现实现(`packages/desktop-client/src/main/island.ts:794-867`)分支顺序不得打乱,只在「展示方式」处按形态分流:
```
push(raw, ctx):
state.listExpanded = false
n = mergeNotification(findKnown(raw.id), raw)
inClass = ctx.inClass ?? state.inClass; state.inClass = inClass
if (ctx.currentPeriodEnd !== undefined) state.currentPeriodEnd = ...
if (ctx.preview) → 预览分支(30s 自收,失焦不收起)
if (isImmediate(n)): // priority === 'URGENT'(含紧急叫人)
正在展示的其它消息回队列
activate(n, n.kind==='call' ? 'call' : 'urgent', 'expanded')
→ classisland: bubblePhase='mask',并触发出特效
else if (inClass && n.kind==='call'):
activate(n, 'call', 'pill') // classisland: 只显示 chip,不展开气泡
else if (inClass):
queued = [..., n]; mode='hidden'(classisland: 保持行可见,pendingStaged++)
else:
上一条 active 回队列;queued 追加 n;activate(n, 'new', 'pill')
→ classisland: bubblePhase='mask',行内自动展开气泡
```
#### 8.3.3 消息动作表(渲染 → 主进程,`island.ts:929-1077`)
| 动作 | 语义 | 备注 |
|---|---|---|
| `expand` | 展开当前/最新消息 | `classisland`:进入气泡;`winisland`:展开详情卡;`custom`:打开消息面板 |
| `collapse` | 收起 | 上课时普通通知直接隐藏;紧急/叫人回缩为 chip/胶囊 |
| `expand-list` | 多条时「展开更多」 | 由 `islandListLayout` 决定行数与提示(见 8.6.3) |
| `dismiss` | 单条「知道了」 | 不改已读 |
| `dismiss-all` | 整批「知道了」 | 不改已读 |
| `mark-read` | 标为已读 | 调后端,失败要回滚 UI 状态 |
| `mark-all-read` | 整批已读 | 过滤掉 `kind==='homework'` 的本地 id |
| `open-app` | 打开应用内通知页 | 列表行点击也走这里 |
#### 8.3.4 自动收起超时(必须原值保留,`island.ts:124-132`)
| 形态 | 超时 |
|---|---|
| `pill`(胶囊/chip) | 15 000ms |
| 课后补发展开 | 14 000ms |
| 课后补发胶囊 | 25 000ms |
| 展开(普通) | 20 000ms |
| 紧急 | 45 000ms |
| 叫人 | 90 000ms |
| 收回动画 `CLOSE_ANIMATION_MS` | 360ms(随 `speed` 缩放,`island.ts:121`) |
**收起后的关键规则**(`island.ts:1266-1296`):只要还有未处理消息(`active` 或 `queued` 非空),收起后必须保持 chip/胶囊/行内角标,不得直接消失;彻底隐藏只在「知道了 / 已读 / 队列清空 / 进入上课」时发生。
### 8.4 ClassIsland 风格气泡(`fixed` 默认形态,两阶段)
#### 8.4.1 阶段机(必须由主进程状态驱动,渲染进程只渲染)
```
bubblePhase: hidden → mask →(有正文)overlay → closing → hidden
└(无正文/正文时长为 0)→ closing → hidden
```
- 进入 `mask`:行仍为 40 高,气泡在同一行内展开;遮罩背景为强调色,斜切遮罩扫入,遮罩文字(标题/徽标)延迟入场。
- `mask` 时长:默认 3 000ms;若该消息带正文且正文有明确 `EndTime`/时长,则按正文时长决定(不得把正文时长叠加到遮罩上导致总时长翻倍)。
- 进入 `overlay`:遮罩退出、正文淡入;底部显示**剩余时间进度条**(见 8.4.4)。
- `closing`:正文/遮罩退出(200ms),随后窗口/行恢复;`reason='closing'` 时行内不得显示任何「新消息」文案(防闪烁,沿用现有 `isClosing` 语义 `src/island/IslandApp.vue:73-80`)。
- 任一阶段都可被新消息打断:紧急消息直接覆盖当前气泡(当前消息回队列),普通消息按队列顺序等待。
#### 8.4.2 行内气泡几何(像素)
- 行高固定 **40**;行圆角 **8**(`--ci-radius`);行背景不透明度 **0.5**(只作用背景层)。
- 气泡内容内边距:水平 **12**(ClassIsland 行内容盒 `Margin="12 0"`);遮罩文字层内边距 **12 0**;遮罩文字图标 20×20、间距 8。
- 气泡最大高度 = 行高 40(正文单行/双行截断,超出省略);多条列表形态时高度由 `islandListLayout` 计算(见 8.6.3)。
- 气泡底部进度条:高 **3px**、贴底、圆角 1.5、`transform-origin:left`、`scaleX(progress)`、值过渡 **50ms linear**(ClassIsland 3px 规格;现有课程提醒条是 2px,两者不得混用,见 8.8)。
- 遮罩背景:强调色实底(`--island-accent`,默认 `#0a84ff`);文字色 `#fff`,标题 16px/600,副文本 12px/0.72 透明度。
#### 8.4.3 气泡动画(ClassIsland 参数 + ClassHelper 弹簧的裁决)
**裁决**:岛的**整体尺寸/圆角**继续用 ClassHelper 的 `SpringValue`(stiffness=0.10、damping=0.68、换向保留 35% 动量、单帧位移 ≤ 剩余 20%、snap 0.05px,`packages/desktop-client/src/island/spring.ts:26-114`);**气泡内部的遮罩扫入/文字/进度**用 ClassIsland 的 CSS/WAAPI 参数。两条动画路径不得互相接管。
| 动画 | 参数 | 实现 |
|---|---|---|
| 斜切遮罩扫入 | 5 区段,权重 `[0,0.1,0.2,0.4,0.2,0.1]`,每段 **360ms**,错峰 **120ms**(区 0/4 同时、1/3 延迟 90ms、2 延迟 120ms),缓动 **QuarticEaseOut** | SVG `polygon` 5 片,或 `clip-path` 5 段;`--ci-speed` 缩放 |
| 斜切遮罩退出 | 反向:中间先收,1/3 延迟 90ms,0/4 延迟 120ms,缓动 **QuadraticEaseIn**,每段 360ms | 同上,反向 |
| 遮罩文字淡入 | `delay 260ms`、`250ms`、`opacity 0→1`、`cubic-bezier(0.25,1,0.5,1)` | CSS animation `both` |
| 遮罩文字缩放 | `delay 260ms`、`750ms`、`scale 1.1→1.0`、同曲线 | CSS transform |
| 遮罩退出文字 | `200ms`、`opacity 1→0` + `scale 1.0→1.1`、`cubic-bezier(0.5,0,0.75,0)` | CSS |
| 正文淡入淡出 | `200ms`、`SineEaseIn` | CSS transition |
| 进度条值 | `50ms linear` | `transform: scaleX` |
| 行宽/行高变化 | 由弹簧决定(约 300–500ms 自然落定) | `SpringValue.step()` |
**速度与降级**:`appearance.speed`(0.5–2)必须同比缩放上述所有 `ms`:`duration = base / speed`,延迟同理;`animations=false` 时全部时长置 0(直接终态),但**不得**改变最终几何。系统「减少动态效果」时按 `speed=2` 且时长压到 1ms 处理。
#### 8.4.4 气泡内容(信息层次)
- 遮罩阶段:类型图标(通知/作业/叫人 三套 SVG,沿用 `src/island/ClassicNotifications.vue:115-132`)+ 类型徽标(`紧急/重要/普通/低` 或 `叫人/新作业`)+ 一行标题;紧急加「需立即查看」、叫人加「老师正在等你」、课后补发加「下课后补发」(同一套 `live-dot` 语义)。
- 正文阶段:标题(单行省略)+ 正文(最多 2 行省略)+ 元信息(`教师名 · 时间`)+ 可选 `subtitle`(叫人提示语,如「请到办公室找我」)+ 按钮行(`打开应用` / `标为已读` / `知道了`;叫人时为 `收到`)。
- 多条列表(`listMode`):标题 `"N 条待处理通知"`,每行「类型图标 + 标题 + `教师名 · 时间` + 类型徽标」,默认显示前 **3** 行,超出提示「展开更多(还有 N 条)」或「更多请前往应用内操作」(`packages/shared/src/constants.ts:379-402,445-474`)。
#### 8.4.5 `ClassicNotifications.vue` 的改造与 `winisland` 兼容形态
`packages/desktop-client/src/island/ClassicNotifications.vue` 不得整体废弃,必须**拆成「气泡内容」组件**并同时服务两种形态:
1. **两阶段复用**:`fixed` + `classisland` 下,`mask` 阶段只渲染它的 `header` 部分(类型图标 + 类型徽标 + 标题 + 紧急/叫人提示),`overlay` 阶段再渲染 `title / body / footer`(标题、正文、元信息、按钮行);列表形态(多条)只在 `overlay` 阶段出现。原组件的 `size/listLayout/pendingNotifications` 计算逻辑保留,改为由父组件传入。
2. **`winisland` 兼容**:整卡(header + title + body + footer 一次性渲染)继续用于详情卡,**交互与尺寸不变**;只把文案/徽标/按钮语义与新气泡对齐(`叫人` 时按钮「收到」等)。
3. **不得复制两份实现**:图标、徽标、行布局、按钮行必须共用同一组件与同一份 `classic-island.css`,避免两种形态文案不一致。
### 8.5 强提醒全屏特效(Topmost)
#### 8.5.1 行为
1. **触发条件**(全部满足):消息 `priority==='URGENT'`(紧急通知或紧急叫人);`appearance.animations===true` 且 `appearance.effectEnabled`(新增外观开关,默认 `true`);岛当前可见(不是编辑模式、不是预览、不是隐藏);本节特效与气泡可同时出现。
2. **表现**:以岛中心为原点,强调色涟漪扩散到覆盖主界面所在屏幕;扩散 **600ms**、同步淡出 **600ms**,**750ms** 后移除元素并隐藏特效窗口。
3. **独立窗口**:新建 `topmost-effect` 渲染入口与独立 BrowserWindow(`transparent:true`、`frame:false`、`skipTaskbar:true`、`alwaysOnTop:true`、`showInactive`、`setIgnoreMouseEvents(true)`、逐屏一个窗口);**禁止** `setBackgroundMaterial()`;窗口内元素清空即隐藏,不销毁。
4. **缩放与多屏**:`effectScale`(默认 1.0,复用现有 `ISLAND_SHADOW_PAD` 之外的独立字段)缩放窗口与圆心;屏幕/DPI 变化重算;4K@150% 下涟漪必须覆盖整屏无接缝。
5. **降级**:`animations=false`、系统减少动态、编辑模式、窗口重建中、系统休眠恢复后的 1s 内 → 不播特效(只播气泡),不得阻塞消息展示。
6. **非目标**:本轮**不做**音效与 TTS(ClassHelper 无音频通道);`docs/app/notifications.md:23` 的「约 45 秒收起」仍由队列超时实现,与特效无关。
#### 8.5.2 像素/动画参数
| 项 | 值 |
|---|---|
| 涟漪元素 | 全屏 SVG/CSS 圆(`border-radius:50%` 或 SVG circle),`pointer-events:none` |
| 扩散 | `scale 0→1`(半径 = 圆心到屏幕最远角),600ms,`QuadraticEaseIn`(≈`cubic-bezier(0.11,0,0.5,0)`) |
| 淡出 | `opacity 1→0`,600ms,`SineEaseIn`(≈`cubic-bezier(0.12,0,0.39,0)`) |
| 颜色 | `--island-accent`,默认 `#0a84ff`,透明度按 1→0 |
| 生命周期 | `setTimeout(750 / speed)` 后移除 |
| 窗口 | 覆盖 `display.workArea`;`scale` 后宽高乘系数 |
### 8.6 课程提醒五类(prepare / on-class / break / ending / progress)
> 命名对照:任务/文档口中的 `progress`(课中进度)在代码里对应 `IslandLessonReminder.kind === 'in-class'`,开关为 `classProgressEnabled`;其余四类同名。本节一律用代码枚举值,并在需要时注明「progress」。
#### 8.6.1 设置与默认值
`islandLayout.lessonReminders`(`packages/shared/src/island-layout.ts:141-158,390-398`):
| 字段 | 默认 | 范围 |
|---|---|---|
| `prepareEnabled` | `true` | — |
| `onClassEnabled` | `true` | — |
| `breakEnabled` | `true` | — |
| `endingEnabled` | `true` | — |
| `classProgressEnabled` | `true` | — |
| `prepareSeconds` | `60` | 5–1800 |
| `endingSeconds` | `60` | 5–600 |
#### 8.6.2 判定算法(`LessonReminderTracker.update`,必须逐行实现)
输入:`schedules`(当天过滤后按 `startTime` 升序)、`now`、`settings`。输出 `IslandLessonReminder | null`。证据:`packages/desktop-client/src/main/lesson-reminder.ts:17-109`。
1. 当天课表 = `dayOfWeek === (now.getDay() || 7)` 的条目,按开始时间升序(周日=7)。
2. `current` = 满足 `start ≤ now < end` 的条目;`next` = 第一个 `start > now`;`ended` = `end ≤ now` 且距 `end` < **12 000ms** 的最近一条。
3. **kind 判定顺序(自上而下,命中即止)**:
1. `current && now - current.start < 5 000 && onClassEnabled` → `on-class`;
2. `breakEnabled && !current && ended && now - ended.end < (next ? 12 000 : 5 000) && (now - ended.end < 5 000 || this.current?.id === keyOf(ended,'break'))` → `break`;
3. `current && endingEnabled && current.end - now ≤ endingSeconds*1000` → `ending`;
4. `!current && next && prepareEnabled && next.start - now ≤ prepareSeconds*1000` → `prepare`;
5. `current && classProgressEnabled` → `in-class`。
4. `maskUntil / endsAt / lessonStartsAt / lessonEndsAt` 必须按源码公式:
| kind | maskUntil | endsAt |
|---|---|---|
| ending / in-class | `entered`(无遮罩) | `end` |
| prepare | `min(start, entered + 3000)` | `start` |
| break 且有下一节 | `entered + 2000` | `end + 12000` |
| break 无下一节 | `entered + 5000` | `end + 5000` |
| on-class | `entered + 5000` | `start + 5000` |
5. `id = "${dayKey}:${scheduleId}:${start}:${end}:${kind}"`;`entered` 为同一 id 的连续保持时刻(避免同一提醒重复计时)。
6. 课程名 `course?.name ?? '课程'`;时间字符串截到 `HH:mm`(导入数据可能带秒,`lesson-reminder.ts:78-79`)。
7. **渲染进程不得自行推导 kind**,只消费主进程下发的 `lessonReminder`(以及新增的 `progress/leftSeconds`)。
#### 8.6.3 课程提醒条 UI(像素)
| 项 | 值 | 依据 |
|---|---|---|
| 容器 | 高 **40**、宽 `max-content`、`min-width 300`、`max-width 800`、圆角 **8**、背景 `#20202080` | `LessonReminderContent.vue:196-208` |
| 信息层 | 高 40、居中、`gap 12`、`padding 0 14`、`white-space:nowrap` | `:209-216` |
| 主字号 | 16(课程名 20、时间 14、遮罩标题 18) | `:204,235-245,262` |
| 进度条 | 高 **2px**、贴底、强调色、`scaleX(progress)`、`transition 50ms linear` | `:307-316` |
| 遮罩 | 5 片斜切 SVG、`tan(30°)*40` 偏移、区域边界 `[0,0.1,0.3,0.7,0.9,1]`、每片 360ms | `:62-72` |
| 遮罩时序 | 开:`delays [0,90,120,90,0]`;关:`[120,90,0,90,120]`;`eased = p^4`(开)/ `p²`(关) | `:91-99` |
| 兜底 | rAF 停摆时 `600/speed` ms 后强制到位 | `:83-89` |
| 文字动画 | 淡入 250ms/延迟 260ms、缩放 750ms/延迟 260ms,曲线 `(0.25,1,0.5,1)`;退出 200ms、`(0.5,0,0.75,0)` | `:126-138,273-306` |
**文案规则**(`LessonReminderContent.vue:14-23,143-169`):
| kind | 左侧 | 课程侧 |
|---|---|---|
| prepare | `距上课还剩 N 秒` | `下节课是:课程名` + 时间 |
| on-class | `开始上课` | `本节课:课程名` + 时间 |
| break | `课间休息` | `下节课是:下一节课程名` + 下一节时间 |
| ending | (无左侧倒计时文字,只显示课程侧) | `本节课:课程名` + 时间 + **右侧蓝色秒数徽标**(见 8.7) |
| in-class | `距下课还剩 N 秒` | `本节课:课程名` + 时间(无徽标) |
- 无课程时课程名显示 `"今日课程已结束"`。
- `leftSeconds = ceil((目标时间 - now)/1000)`,下限 0;目标时间:ending/in-class 用 `lessonEndsAt`,其余用 `lessonStartsAt`。
- `progress`:ending/in-class 用「已上课时间 / 总课时」,其余用「剩余时长 / 本提醒总时长」,钳制 0–1。
#### 8.6.4 三形态差异(必须实现)
| 形态 | 行为 |
|---|---|
| `fixed` + `classisland` | 课程提醒以**行内条**接管岛内容:信息行临时替换为提醒条(宽 = 行宽,高 40);`ending` 用徽标而非遮罩;`in-class`(进度)默认**不使用**提醒条(由课程表组件常驻显示进度),仅当用户开启 `classProgressEnabled` 且组件不可用时回退为提醒条【推断】 |
| `fixed` + `winisland` | 保留现有实现:`customLayout=false` 时 `ending` 不接管(`island.ts:173-185` 的 `lessonShowing` 已排除 custom 的 ending),`fixed` 的 `ending` 仍以提醒条显示并使用徽标(`docs/app/island.md:70`) |
| `custom` | `customLayout=true` 时:`kind==='in-class'` 不接管岛(由 `schedule` 组件常驻显示进度);`kind==='ending'` **不接管岛内容**,只在当前课程名右侧显示徽标(`island.ts:198-199`;`docs/app/island.md:65-68`) |
**必须遵守的三条硬规则**:
1. **没有「即将下课」遮罩**:`ending` 绝不显示 ClassIsland 式全行遮罩,只显示课程名右侧的蓝色秒数徽标(`docs/app/island.md:68`)。
2. **不替换自定义岛常态组件**:`custom` 模式下课程提醒(除 prepare/on-class/break 的临时接管)不得替换常态组件行。
3. **关闭开关即不显示**:`endingEnabled=false` 时不显示徽标;`endingSeconds` 只影响徽标出现时机,不影响其他提醒。
### 8.7 下课前蓝色秒数徽标(重点规格)
#### 8.7.1 视觉
| 项 | 值 |
|---|---|
| 位置 | 课程名 `<strong>` 右侧,与课程名同一行(`inline-flex`,`flex:0 0 auto`) |
| 形态 | **正圆 30×30**,`border-radius:50%` |
| 背景 | 蓝色 `#0a84ff`(与默认强调色同值;可随 `--island-accent` 同步【推断:现实现写死 #0a84ff】) |
| 文字 | `-N`,白色 `#fff`,**12px / 600**,`font-variant-numeric: tabular-nums`,`line-height:1`,水平垂直居中 |
| 无障碍 | `aria-label="距下课N秒"` |
| 证据 | `packages/desktop-client/src/island/lesson-ending.css:1-16`;`LessonReminderContent.vue:160-167` |
#### 8.7.2 行为
1. **显示条件**(全部满足):`kind==='ending'`(即 `current && endingEnabled && end - now ≤ endingSeconds*1000`);`leftSeconds ≥ 0`;课表数据存在 `endTime`。
2. **数值**:`leftSeconds = ceil((lessonEndsAt - now)/1000)`;显示为 `-${leftSeconds}`。
3. **剩余一秒显示 `-1`**:当真实剩余时间在 `(0, 1000]ms` 时 `ceil` 结果为 1,徽标显示 `-1`;**不得**显示 `-0`,也不得在剩余 0 时继续显示(到点后 `ending` 条件不再成立,徽标消失)。这是用户明确要求的行为,必须有单测用例(`now = end - 1ms` → `-1`)。
4. **阈值与开关**:`endingSeconds` 默认 60(5–600);`endingEnabled=false` 时完全不出现徽标;`animations=false` 也不影响徽标(徽标不是动画)。
5. **出现/消失动画**:徽标可淡入 100ms(`opacity 0→1`)并随课程提醒条一起退出;**不得**用缩放弹跳(避免课堂投影上的视觉干扰)。
6. **两种岛都要用**:`fixed`(两种 family)与 `custom` 都必须显示该徽标;`custom` 下徽标挂在 `schedule` 组件的当前课程名右侧,由组件内部渲染同一 `lesson-ending.css`,不得复制第二套样式。
7. **边界**:
- 无时间点数据、按 `ScheduleDto` 回退显示课程时,`leftSeconds` 同样由 `endTime` 计算,徽标照常显示;
- 跨天(课程结束时间 ≤ 开始时间)视为数据异常,不显示徽标并记一次告警;
- 系统时间跳变:以主进程下发的 `leftSeconds` 为准,渲染进程不得自行用 `Date.now()` 覆盖;
- 连续多节连堂:每节课结束时徽标各自独立计算,切换瞬间不得出现「-60 → -1」跳变(切换时应先隐藏上一节徽标再显示新一节)。
### 8.8 课程进度条(两处,不得混用)
| 位置 | 高度 | 颜色 | 过渡 | 取值 |
|---|---|---|---|---|
| 课程提醒条底部(ClassHelper 既有) | **2px** | `--island-accent` | `transform 50ms linear` | 见 8.6.3 的 progress 公式 |
| 课程表组件内「当前课程」进度(ClassIsland 规格) | **3px** | 强调色 | `transform 50ms linear` | 已上/总课时,钳 0–1 |
**裁决**:两者同时出现时(`fixed` + `classisland` 信息行同时含课程表与提醒条)**保留两条**——提醒条 2px 贴岛底,组件内进度 3px 贴课程项底;不得合并成一条,也不得把提醒条改成 3px(会与 ClassIsland 「行内进度条 3px」混淆)。渲染必须用 `scaleX` + `transform-origin:left`,不得对宽度做过渡。
### 8.9 天气简报(Open-Meteo,保留 ClassHelper 语义)
#### 8.9.1 数据链路(不得改)
渲染组件 `WeatherBrief.vue` → `window.island.getIslandWeather({latitude, longitude})`(preload 白名单)→ IPC `island:weather`(`packages/desktop-client/src/main/ipc.ts:23`)→ 主进程 `getIslandWeather`(`packages/desktop-client/src/main/island-weather.ts`)→ Open-Meteo。
#### 8.9.2 服务行为(必须原样保留)
1. 固定服务与参数,**不接受渲染层传 URL**:`https://api.open-meteo.com/v1/forecast` + `current=temperature_2m,weather_code,wind_speed_10m` + `forecast_days=1`(`island-weather.ts:35-41`)。
2. 坐标校验:`latitude/longitude` 必须是有限数字,纬度绝对值 ≤90、经度 ≤180,否则抛「请设置有效的经纬度」(`:14-22`)。
3. 缓存键 = 坐标定点 4 位(`:23`);成功缓存 **15 分钟**(`:60`);失败缓存 **60 秒**退避并保留旧数据(`:63`);同一坐标并发请求复用同一 Promise(`:29-30`);缓存上限 **32** 条(`:68`)。
4. 请求安全:`AbortSignal.timeout(8000)`、`redirect:'error'`、响应体 > **64 000** 字节拒收(`:42-45`);自动验收模式下不访问外网(`:33-34`)。
5. 数据校验:三个数值字段必须都是有限数字,否则「天气数据格式不正确」(`:47-52`)。
6. `stale`:成功返回时按 `fetchedAt` 距今 ≥ **15 分钟**标记;失败返回旧数据时强制 `stale:true`(`:26,64`)。
7. 渲染侧:每 **60 秒**刷新一次、坐标变化立即刷新、失败显示「天气暂不可用」、未设置坐标显示「请设置天气地点」、`stale || unavailable` 时追加 `· 缓存`(`WeatherBrief.vue:36-49,68-81`)。
#### 8.9.3 天气文案与建议(保留)
WMO 天气码映射(`WeatherBrief.vue:50-60`):`0 晴`、`1/2/3 少云/多云/阴`、`45/48 雾`、`51-57 毛毛雨`、`61-67/80-82 雨`、`71-77/85/86 雪`、`95-99 雷雨`、其他 `天气`。建议文案:`code ≥ 95 → 雷雨天气,注意避雷`;`windSpeed ≥ 40 → 风力较大,注意出行`(`:61-66`)。
#### 8.9.4 ClassIsland 视觉映射与范围
- ClassIsland 天气组件的「6 种主信息模式」本轮**只取默认一种**:`地名 + 天气文字 + 温度(取整)+ 可选建议`,以 ClassIsland 的次要/正文字号(14/16)呈现;不实现多模式切换。
- **不实现** ClassIsland 的气象预警、三天天气预报、逐小时预报(数据源无对应字段;`docs/app/island.md:45` 明确「不提供官方气象预警」)。
- 组件内刷新失败不得阻塞岛其他内容;天气与服务端无关,不参与消息队列。
### 8.10 课件传输覆盖(最高优先级内容)
#### 8.10.1 优先级规则(必须实现,`island.ts:649-676`)
1. 课件传输(`coursewareTransfer`)**覆盖整岛内容**:`fixed` 的两种 family 与 `custom` 都必须显示;`getState()` 时若「当前有正在展示的紧急消息或叫人(`mode!=='hidden' && active && (priority==='URGENT' || kind==='call')`)」则**课件让位**,状态里返回 `coursewareTransfer:null`。
2. 其余消息不因课件覆盖而丢弃:`active/queued` 保持原状,课件结束后按队列规则继续展示。
3. 课件传输期间不触发强提醒特效(课件不是紧急消息)。
4. 课件覆盖时窗口几何按 `COURSEWARE_ISLAND_SIZE`(**424×132**,`packages/shared/src/courseware.ts:4`)+ `ISLAND_SHADOW_PAD` 定位;`custom` 常驻也临时改为该尺寸,结束后恢复原方案尺寸。
#### 8.10.2 UI 与像素(保留现有值)
| 项 | 值 |
|---|---|
| 卡片 | 424×132,`padding 17 24`,`border-radius 24`,背景 `rgba(20,20,24,0.97)`,阴影 `0 2px 8px #0004` |
| 字体 | `13px/1.4 'Segoe UI','Microsoft YaHei',sans-serif` |
| 状态行 | 12px,`#ffffffb8`;百分比/「失败」用强调色、600 |
| 文件名 | 16px、600、单行省略、`margin 5px 0` |
| 元信息 | `类型 · 教师` + `已收/总量 MB`(保留 1 位小数) |
| 进度条 | 高 **5px**、圆角 8、`accent-color`,进度槽 `#ffffff30` |
| 错误 | 11px、`#ffb4ab`、单行省略 |
| 证据 | `CoursewareTransfer.vue:67-131` |
#### 8.10.3 阶段与失败
- 阶段文案(`CoursewareTransfer.vue:26-34`):`downloading 正在接收课件`、`verifying 正在校验文件`、`completed 课件接收完成`、`failed 课件接收失败`。
- 完成后由主进程/服务端链路校验 SHA256 并回执(`docs/app/courseware.md:28`);岛只负责展示,不执行文件。
- 失败必须显示 `error` 文案并允许用户点击卡片打开课件页(`pointer-events` 在课件卡上为 `none`,点击交给整岛 `open-app`);失败卡片停留至用户处理或下一个传输覆盖,建议停留 30s 后自动恢复常态【推断】。
- 传输中窗口重建/渲染进程崩溃:主进程重新下发 `coursewareTransfer` 状态,恢复展示。
### 8.11 教师端 / 班级端差异
1. **岛只在班级端运行**:教师/管理员使用 Web 管理端,不启动岛;客户端以 `CLASS_DEVICE` 登录时才有「本班」消息。
2. 班级端:接收本班通知/作业/叫人/课件;课程提醒基于服务端解析后的本班课表(`ScheduleDto`/`IslandTimePointDto`)。
3. 教师端(Web):无岛;发布 URGENT 时的二次确认是服务端规则(`docs/app/notifications.md:25-29`),与岛无关。
4. **未登录/离线**:不展示在线消息;已缓存消息可只读展示(不得伪造未读状态);课程提醒与天气可离线工作(课表来自本地缓存,天气有缓存)。
5. **权限**:已读/撤回等操作失败(403/401)时,岛必须回滚本地状态并提示「无权限或登录已失效」,不得假装成功。
### 8.12 冲突裁决表(ClassHelper 业务优先,ClassIsland 视觉优先)
| 主题 | ClassHelper 既有语义(胜出) | ClassIsland 视觉(采用) | 落地规则 |
|---|---|---|---|
| 消息排序 | `PRIORITY_RANK` → 时间 | 提供方优先级/通知等级 | 按 8.2 第 3 条:业务优先级优先,等级仅作稳定次级键 |
| 上课暂存 | 上课期间普通通知暂存,下课补弹 | ClassIsland 上课照常播提醒 | 暂存规则保留;信息行保持可见但**不展开气泡** |
| 紧急消息 | 立即展开、45s 收起 | ClassIsland 气泡两阶段 | 立即展开气泡 + 撒特效;45s 超时保留 |
| 普通叫人 | 上课也保留可点入口,不自动展开 | ClassIsland 无「叫人」概念 | 上课显示可点 chip;点击展开气泡 |
| 去重 | 按 id + 双播合并 | 同一 request 只入队一次 | 两者都实现(8.2) |
| 已读 | 服务端已读 + 本地回执 | ClassIsland 无 | 保留现有,失败回滚 |
| 收起动画 | 收回动画 360ms/窗口淡出 | 遮罩退出 200ms | 遮罩退出用 ClassIsland 参数;岛形变用弹簧;窗口淡出保留 |
| 多消息列表 | 前 3 条 + 展开更多 | ClassIsland 无列表形态 | 用 ClassIsland 行高/字号重排列表(共享 `islandListLayout`) |
| 课程提醒 kind | prepare/on-class/break/ending/in-class | ClassIsland 只有上下课/课前/放学 | 保留五类;`ending` 用徽标,**不做「即将下课」遮罩** |
| 课程提醒条高度 | 40 | 行高 40 | 一致,沿用 40 |
| 进度条 | 2px(提醒条) | 3px(行/组件) | 两处各自保留,见 8.8 |
| 天气 | Open-Meteo 15 分钟 + 缓存标记 | 小米天气 + 预警 | 保留 Open-Meteo;不实现预警/预报 |
| 课件覆盖 | 覆盖岛内容 | 无对应 | 保留 ClassHelper 规则;紧急/叫人时不覆盖 |
| 音效/TTS | 无 | 有 | **不实现**,不进入验收 |
| 自动化/插件/管理端 | 无 | 有 | **不实现** |
### 8.13 边界与失败降级
1. **无课表/课表为空**:`lessonReminder=null`;信息行显示日期 + 课程表占位(`今天没有课程。`);课程提醒全部不触发;不得因此隐藏整行。
2. **上课状态未知**(`inClass` 未下发/服务端断线):按「非上课」处理(消息正常展示),不得猜测;恢复后以下发值为准。
3. **时间跳变/时区变化**:课程提醒 id 含 `dayKey`,跨天自动重建;时间回退不得重复触发同一 id 的提醒(`entered` 逻辑),时间前跳不得补发多条。
4. **消息风暴**(一次 > 20 条):列表最多 `maxRows=20`,其余提示「更多请前往应用内操作」;不得逐条弹气泡;同一 id 重复到达按合并规则处理。
5. **重复广播 / 断线重连重放**:以 id 去重;重连后以服务端列表为准做一次全量对账(新增/删除/已读状态),不得凭队列恢复弹窗。
6. **已读请求失败**:`markRead/markAllRead` 失败时恢复本地未读状态并提示;不得把消息从队列删除。
7. **课件传输中断/断网**:显示 `failed + error`;恢复网络后由心跳补拉(`docs/app/courseware.md:26-28`);岛不得自行重试下载。
8. **渲染进程崩溃/窗口重建**:主进程保留 `state`,重建后一次性下发;课件/课程提醒/消息不丢;重建限流沿用现有(3 次/10 分钟)。
9. **系统睡眠/唤醒**:唤醒后立即重算课程提醒与上课状态;跨过的时间点不补弹(与 8.13 第 3 条一致);课件若在睡眠中完成,按最新状态展示。
10. **多屏切换/DPI 变化**:重算命中矩形、气泡与特效窗口;切换瞬间不得显示残影(先隐藏特效窗口再重算)。
11. **无网络启动**:消息/课表用离线缓存(按服务器与班级隔离),天气显示缓存或「暂不可用」;课程提醒只要课表缓存存在就必须工作。
12. **编辑模式/设置预览**:编辑模式不弹新气泡、不播特效、课程提醒条不接管编辑画布;预览模式停留 30s 自动结束(`PREVIEW_DURATION_MS`,`island.ts:2299-2300`),不写已读。
### 8.14 验收标准(可勾选,全部通过才算完成)
**消息与队列**
1. 同一 id 的通知与叫人双播到达:只出现一条,`kind='call'`、`priority` 取高者;不会降级成普通通知。
2. 课间收到普通通知:`fixed`+`classisland` 行内自动展开气泡(先遮罩后正文);`winisland` 为胶囊;`custom` 铃铛计数 +1。
3. 上课收到普通通知:不弹气泡,行内显示暂存角标;下课自动补弹最新一条,其余保留「还有 N 条」。
4. 上课收到普通叫人:显示可点 chip,不自动展开;点击后展开气泡并可「收到」。
5. 紧急通知/紧急叫人:无论上课与否立即展开气泡,并在 45s/90s 后按规则收起(有未处理时保持 chip/角标)。
6. 「知道了」与「标为已读」语义区分正确;整批已读会过滤 homework 本地 id,不报「通知不存在」。
7. 实时撤回后,岛内该消息从 active 与 queued 同时消失。
8. 消息列表默认 3 行、超出显示「展开更多(还有 N 条)」;展开后受屏幕高度限制改提示「更多请前往应用内操作」。
**气泡视觉**
9. 气泡遮罩 5 区段斜切扫入,区段 360ms、错峰 120ms;文字延迟 260ms 淡入 250ms + 缩放 750ms;退出 200ms 且缩放回 1.1;`speed` 改变后所有时长同比缩放。
10. 行高 40、圆角 8、背景不透明度 0.5、底部进度条 3px、值过渡 50ms 线性;文字始终不透明。
11. `animations=false` 或系统减少动态时,气泡直接终态,最终几何与有动画时一致。
**强提醒特效**
12. 紧急消息触发全屏涟漪:600ms 扩散 + 600ms 淡出,750ms 后移除;多屏各一窗口;`effectScale` 生效。
13. 编辑模式、隐藏状态、窗口重建中不播特效,但气泡照常;特效窗口不接收鼠标、不改变岛命中。
**课程提醒**
14. 五类提醒按 8.6.2 算法在构造的时间轴下逐一出现,且同一提醒不重复;关闭对应开关后不出现。
15. `custom` 下 `in-class` 与 `ending` 不接管岛;`fixed` 下 `ending` 显示徽标而非遮罩;任何形态都**没有「即将下课」遮罩**。
16. 蓝色秒数徽标:正圆 30×30、`#0a84ff`、12px/600、显示 `-N`;`now = end - 1ms` 时显示 `-1`;`endingEnabled=false` 不显示。
17. 课程进度:提醒条 2px、组件内 3px,两条同时存在且均贴底、均用 `scaleX`。
**天气与课件**
18. 天气 15 分钟缓存、失败 60s 退避并保留旧数据、断网显示「· 缓存」、坐标非法显示「请设置天气地点」;同一坐标并发请求只发一次网络请求。
19. 课件传输覆盖整岛(424×132),显示文件名/类型/教师/已收-总量/进度;紧急或叫人在播时课件让位且不丢消息;完成后 SHA256 校验通过才显示「完成」。
**边界**
20. 跨天、时间跳变、睡眠唤醒、渲染进程崩溃重建后,消息不丢、不重复弹、课程提醒状态正确。
21. 无课表/无网络/未登录时,岛显示合理占位且不崩溃;已读失败会回滚并提示。
22. 全程未出现音效/TTS、自动化、插件、主题市场等非目标功能;也未出现第二个岛窗口(课件与特效窗口除外,且均为独立用途)。
---
## 9. 编辑器与设置页重构(IslandSettingsView / IslandEditorView / IslandComponentSettings)
本 part 规定「灵动岛设置页」与「自定义方案编辑器」在重构后的完整实现。所有改动**必须在现有 ClassHelper 仓库内完成**,沿用 pnpm monorepo、Electron 44、Vue 3.5、Element Plus 2.14、Pinia、Vue Router、Vite 与既有 `@classhelper/shared` 契约;**不得新建工程、不得引入 Avalonia/C#、不得新增服务端依赖、不得新建额外桌面窗口来做编辑预览**。所有数值以 `packages/...:行号` 标注证据;ClassIsland 侧以 `repos/ClassIsland/...:行号` 标注;无法从源码确认的写【推断】。
### 9.1 口径与术语(与 parts-r/01、05、07 对齐)
1. **`family` 是 fixed 形态的外观族**:`islandAppearance.family: 'classisland' | 'winisland'`,默认 `'classisland'`(新安装默认)。两种取值只改变 **fixed** 的外观:
- `classisland` + `fixed`:**ClassIsland 风格信息行**,默认 1 行 = 日期 + 课程表,行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条,消息到来时在同一行内展开 ClassIsland 风格气泡(业务规则仍用 ClassHelper 的暂存/紧急/叫人/已读/去重);
- `winisland` + `fixed`:保留「胶囊 + 详情卡」旧形态(兼容老用户);
- `classisland` + `custom` 与 `winisland` + `custom`:**都使用 ClassIsland 规格的行/组件**(行高 40、行内留白 12、组件契约见 parts-r/07、编辑模式见本 part 9.9–9.16),`family` 对 custom 不产生影响。
**禁止**把 `classisland` 下的 fixed 写成小胶囊;**禁止**让 custom 因 `family=winisland` 回退成旧 custom 观感。
2. **`layout` 与 `family` 正交**:`layout: 'fixed' | 'custom'` 决定「消息行 / 常驻组件方案」与是否开放编辑器;`family` 只决定 **fixed** 的视觉族。设置页必须在 `layout === 'custom'` 时把 `family` 控件置灰,并显示「自定义方案始终使用 ClassIsland 规格;外观族只作用于经典 fixed 形态。」;`layout === 'fixed'` 时 `family` 可用且实时生效。
3. **升级口径**:老配置文件没有 `island.family` 字段时,必须迁移为 `'winisland'`(保持用户既有 fixed 胶囊观感;custom 始终 ClassIsland 规格,迁移不影响);新安装/清空配置时用 `'classisland'`(`fontSize` 默认 16)。**迁移必须保留用户原有 `appearance.fontSize` 数值**(不因 family 迁移改成 13 或 16);若旧配置无该字段,按迁移后的 family 取默认(winisland=13 / classisland=16)。迁移实现见 9.18,需与 parts-r/05 的 `islandLayout.version` 迁移函数同批落地。
4. **术语**:组件(`IslandComponentNode`)、行(`IslandLayoutRow`)、方案(`IslandLayoutScheme`)、常态内容(`rows`)、无通知展开内容(`expandedRows`)、外观(`IslandAppearance`)。组件种类集合固定为 11 类编辑器可见:`date/clock/weather/countdown/schedule/text/separator/slide/rolling/group/stack`;消息类 `notification/homework/call` 不在组件库露出,仅兼容既有节点(`packages/shared/src/island-layout.ts:3-18`、`:45-60`;`packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue:58-70`)。
5. **fixed 的内置信息行 v2 是只读内置方案,不可编辑**:`classisland` + `fixed` 固定为 1 行 = 日期 + 课程表,该行属于**内置方案**,**不进入方案编辑器**(不占 `schemes/rows/nodes`,编辑器只服务 custom)。设置页对 fixed 只提供 9.5 的外观参数与 9.4-4 的静态缩略示意,**不得**提供行/组件编辑入口、不得出现「编辑固定行」按钮。后续版本若开放 fixed 行编辑,必须升级 `islandLayout.version` 并在 parts-r/05 增补契约;v2 不开放。
6. **`editingLayout` 死标志不属本 part**:`main/island.ts` 中该标志只有读取点、无写入点的问题归 parts-r/04 处理;本 part 只规定“编辑模式由主窗口路由页承担、岛窗口不新增编辑态”,`island.ts` 的清理以 parts-r/04 为准,09 不修改该文件。
### 9.2 文件级改造清单(只列本 part 负责的文件)
| 文件 | 改造类型 | 必须做的事 |
|---|---|---|
| `packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue` | 大改 | 重排信息架构;新增 `family` 切换与两套外观参数;把课程提醒设置并入本页;保留实时预览/保存/恢复默认/预览效果 |
| `packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue` | 中改 | 保留全部既有编辑能力;行/画布/选中框/抽屉/组件库按 ClassIsland 编辑模式对齐;组件设置抽屉拆为「内容/外观/尺寸/隐藏条件」四页 |
| `packages/desktop-client/src/renderer/components/IslandComponentSettings.vue` | 大改 | 四页签结构;补齐 11 类组件的全部设置项;字段/控件/默认值/范围见 9.15 |
| `packages/desktop-client/src/renderer/components/IslandEditorNode.vue` | 中改 | 选中框改为 1.5px 虚线 1/1.5;容器虚线;组件外框左右各 6px;拖拽手感与 ARIA 保持 |
| `packages/desktop-client/src/renderer/styles/island-editor.css` | 中改 | 行高 40、行距 scheme.spacing、组件外边距 6、抽屉 250ms 曲线、缩放/落点/ghost 样式 |
| `packages/desktop-client/src/renderer/styles/index.css` | 小改 | 设置页新增的 `.island-family-*`、`.island-preview-thumb` 等局部样式;复用既有 `--ch-*` 令牌(`index.css:21-54`、`:103-131`) |
| `packages/shared/src/island-layout.ts` | 中改 | `IslandAppearance` 扩展字段的默认值与 normalize 边界;`normalizeIslandLayouts` 的 12 行/200 节点/6 层/20 方案上限保持(`:256-403`)——字段最终名以 parts-r/05 为准 |
| `packages/desktop-client/src/main/config.ts` | 小改 | 外观新字段迁移(老用户 family=winisland)与 normalize;沿用 120ms 合并写盘(`:252-298`) |
| `packages/desktop-client/src/main/ipc.ts` | 不改逻辑 | 确认 `saveConfig` 收到 `islandLayout` 后调用 `island.syncCustomLayout()`(`:34-45`);外观仍走 `island:set-appearance`/`island:get-appearance`(`:2389-2410`) |
| `packages/desktop-client/src/types/desktop.d.ts` | 小改 | `saveConfig`/`islandSetAppearance` 的纯数据注释保留(`:143-175`) |
**不得删改** `main/island.ts` 的窗口/命中/几何逻辑;编辑器与设置页只能通过既有 IPC 影响它(本 part 不承担岛渲染)。
### 9.3 设置页信息架构(重构后)
设置页仍挂在 `/settings/island`(`packages/desktop-client/src/renderer/router/index.ts:63-68`),侧栏标题「灵动岛」(`layouts/ClientLayout.vue:48-55`)。页面外层沿用 `.page`(`padding: 16px 20px 24px`)、`.page-header`(`margin-bottom: 12px`)、`.page-title`(`20px/600`)(`styles/index.css:186-204`),整体改为「页头 → 布局形态卡 → 外观卡 → 课程提醒卡 → 指针与窗口卡 → 底部操作条」,每张卡为 `el-card shadow="never"`(圆角 8、1px 描边、`backdrop-filter: blur(20px) saturate(1.1)`,`index.css` 的 `.el-card` 规则)。
1. **页头**:左侧 `h2`「灵动岛」;右侧显示当前形态徽标 `el-tag`:`经典信息行`(family=classisland、layout=fixed)/ `自定义常驻`(layout=custom)/ `经典胶囊(兼容)`(family=winisland、layout=fixed)。徽标随保存结果更新,不额外请求。
2. **布局形态卡**(保留既有两张选择卡并升级文案与点击):
- 左卡「经典灵动岛」:`family=classisland` 时描述必须改为「ClassIsland 风格信息行:日期 + 课程表,课中进度条;消息在同一行内展开,v2 为只读内置方案」;`family=winisland` 时保留现有描述「显示课程进度、上下课提醒与班级消息」;
- 右卡「自定义灵动岛」:描述保留「ClassIsland 风格,常驻显示时钟、课程等组件」(custom 始终 ClassIsland 规格,与 family 无关);
- 卡片样式沿用 `.layout-choice`(padding 20、圆角 12、选中 `border-color: var(--el-color-primary)` + `background: var(--el-color-primary-light-9)`,`:450-478`);点击调用 `chooseLayout('fixed'|'custom')`,立即写 `saveConfig({ islandLayout: { ...config.islandLayout, layout, residentEnabled: mode === 'custom' } })`(现有实现 `IslandSettingsView.vue:44-59` 保留);
- 选择 `custom` 后追加「编辑入口」块(现有 `.layout-edit-entry`,`:192-198`、`:479-496`):说明「编辑常态内容与无通知展开内容;课前自动提示,通知操作与经典岛共用。」+ 主按钮「编辑岛内组件」→ `router.push('/settings/island/editor')`;
- 选择 `fixed` 后**不得**出现任何行/组件编辑入口:fixed 的 classisland 信息行是只读内置方案(见 9.1-5),卡片说明里必须写明「固定信息行由内置方案渲染,暂不支持编辑」。
3. **外观卡**:标题「外观、位置与弹出」保留;顶部说明两段改为:
- `classisland`:「默认信息行高度 40、圆角 8、背景不透明度 0.5;紧急消息立即展开,课中普通通知暂存、下课后展开;自定义岛可点击铃铛主动查看。」
- `winisland`:保留现有「紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。」
- 卡内第一行必须是 **视觉风格(family)** 控件(9.4),随后按 family 分支渲染参数组。
4. **课程提醒卡**:新增(内容见 9.7),单一数据源固定为 `config.json.islandLayout.lessonReminders`;原 `/settings/reminder` 页的「上下课提醒」只保留只读摘要 + 跳转,不得保留可编辑控件(避免双写)。
5. **指针与窗口卡**:`layout=custom` 时显示「指针避让」整块(现有 `.resident-window-options`,`:199-218`、`:423-444`);`classisland` fixed 下增加「触摸后自动恢复」说明【推断:由 ClassHelper 触摸失焦宽限承担,无独立参数】。
6. **底部操作条**(保留现有):`保存设置`(primary,loading)、`预览效果`、`恢复默认`;下方 InfoBar「拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效」(`:385-395` 原样保留,文案可保留)。
### 9.4 视觉风格(family)与两套参数组
1. **控件(必做)**:`el-segmented`(Element Plus 2.14 可用)或两个 `el-radio-button`,标签:
- `ClassIsland 风格`(value `classisland`,默认):「信息行 / 半透明横条,行高 40、圆角 8、底部进度条;正文基准字号 16」;
- `经典 WinIsland(兼容)`(value `winisland`):「保留胶囊 + 详情卡外观,仅建议老用户保持;岛字号默认 13」。
下方常驻一行 Warning 说明(`el-alert type="warning"`,不可关闭):「从旧版本升级的配置默认使用经典 WinIsland 外观;切换到 ClassIsland 风格会改变经典形态的外观,消息暂存/紧急/叫人等规则不变。」——这就是**老用户升级默认 winisland** 的用户侧说明。
2. **切换行为**:`family` 写入 `islandAppearance.family`,通过 `applyIslandAppearance({ family })` 实时应用(`islandSetAppearance`),点「保存设置」才写 `config.island`。切换不得重置另一族的参数值(两族参数分别保留在 `IslandAppearance` 中)。
**字号语义随 family 切换**:`classisland` 下 `appearance.fontSize` 是**正文基准字号**,标签文案必须为「正文字号(ClassIsland 基准)」,默认 `16`,并由它派生 14/18/20 三档阶梯(次级/强调/大号);`winisland` 下保持旧语义,标签文案必须为「岛字号(经典)」,默认 `13`,岛内全部文本按旧系数缩放。切换 family 时必须切换标签文案与「默认值」提示(如滑块右侧显示「默认 16」/「默认 13」),但**不得**在切换瞬间强制改写用户已设置的值;仅当值为空/非法时才用当前 family 的默认值。
3. **可见性规则**:`layout === 'custom'` 时 `family` 控件 `disabled`,并显示「自定义方案始终使用 ClassIsland 规格;外观族只作用于经典固定形态。」;`layout === 'fixed'` 时按 `family` 显示下方参数组:`classisland` 显示 9.5,`winisland` 显示 9.6。
4. **固定信息行预览缩略**(新增,P1):在 `classisland` fixed 下于外观卡顶部放一个纯 CSS 缩略图(宽 360×高 44,圆角 8,背景 `#000000` + 不透明度 0.5,内含「10月17日 周五」「语文 08:00-08:40」两段文本与底部 3px 进度条),标注「示意:默认 1 行 = 日期 + 课程表;实际内容由日程数据驱动」。**禁止**用截图或额外窗口实现,必须是 DOM/CSS(与「编辑预览不创建额外桌面窗口」同源约束,`docs/app/island.md:47`)。
### 9.5 classisland 族外观参数(逐项)
所有控件改变时立即调用 `applyIslandAppearance({...})` 实时预览;`saveIslandAppearance()` 再写盘。范围/默认值证据见 `packages/shared/src/constants.ts:264-302`、`packages/shared/src/island-layout.ts:20-36`;新增字段以 parts-r/05 为准。
| # | 控件(label-width 110 左侧标签) | 字段 | 控件类型与范围 | 默认 | 生效时机 | 备注 |
|---|---|---|---|---|---|---|
| 1 | `界面缩放 ${scale}` | `appearance.scale`【新】 | `el-slider` 0.75–2.5,step 0.05,tooltip `F2` | 1 | 即时 | ClassIsland `Scale` 语义(`repos/ClassIsland/ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml:27-40`);`layout=custom` 时隐藏(缩放在编辑器方案设置内) |
| 2 | `圆角 ${radius}px` | `appearance.radius`(有则用;缺省取当前方案) | `el-slider` 0–20,step 1 | 8 | 即时 | `layout=custom` 时同时写 `activeScheme.radius` 并 `saveConfig`(保留现有同步,`IslandSettingsView.vue:38-40`、`:113-122`) |
| 3 | `背景不透明度 ${round(opacity*100)}%` | `appearance.opacity` | `el-slider` 0–1,step 0.05 | 0.5 | 即时 | `layout=custom` 时同步 `activeScheme.opacity` |
| 4 | `行间距 ${spacing}px` | `appearance.spacing`【新】 | `el-slider` 0–150,step 1 | 5 | 即时 | 与 `scheme.spacing` 同步(custom);ClassIsland `MainWindowLineVerticalMargin` 默认 5(`Models/Settings.cs:219`) |
| 5 | `主题色` | `appearance.accent` | `el-color-picker` | `#0a84ff` | 即时 | 用于高亮、按钮与进度条;ClassIsland 强调色默认 `#00BFFF`(`Settings.cs:35`),本实现沿用 ClassHelper 主题色 |
| 6 | `前景(文字)颜色` | `appearance.foreground`【新】 | `el-color-picker` + 开关「自定义」 | 关 / `#ffffff` | 即时 | 自定义前景色覆盖主题派生色(ClassIsland `IsCustomForegroundColorEnabled`+`CustomForegroundColor`,`AppearanceSettingsPage.axaml:536-551`) |
| 7 | `字体` | `appearance.fontFamily`【新】 | `el-select` 可搜索,默认项文案「HarmonyOS Sans SC(默认)」 | 默认字体 | 即时 | 选项来自系统字体或内置列表;ClassIsland 同(`:405-448`) |
| 8 | `字重` | `appearance.fontWeight`【新】 | `el-select`:Thin/ExtraLight/Light/Normal/Medium/SemiBold/Bold/ExtraBold/Black/ExtraBlack | Medium(500) | 即时 | ClassIsland `MainWindowFontWeight2` 默认 Medium(`Settings.cs:101`) |
| 9 | 次级字号(派生 14) | `appearance.secondaryFontSize`【新】 | `el-input-number` 8–96,后缀 px | 14(未显式设置时由 `fontSize` 派生阶梯给出) | 即时 | 时间点附加信息等;classisland 默认阶梯 14/16/18/20 |
| 10 | `正文字号(ClassIsland 基准) ${fontSize}px` | `appearance.fontSize` | `el-input-number` 8–96 或 slider 8–40,后缀 px | **16** | 即时 | **classisland 语义:正文基准字号**(`repos/ClassIsland/ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml:482-533` 四档字号以正文为基准);派生 14/18/20 三档;`bodyFontSize` 若存在则作为显式覆盖(以 parts-r/05 为准) |
| 11 | 强调字号(派生 18) | `appearance.emphasizedFontSize`【新】 | 同上 | 18(派生) | 即时 | 课程表、时钟、提醒标题、天气预报 |
| 12 | 大号字号(派生 20) | `appearance.largeFontSize`【新】 | `el-input-number` 8–128,后缀 px | 20(派生) | 即时 | 课程预报 |
| 13 | `分体显示` | `appearance.separated`【新】 | `el-switch` | 关 | 即时 | 行之间透明分隔;ClassIsland `IsIslandSeperated`(`Settings.cs:1852`) |
| 14 | `进度条` | `appearance.progressBar`【新】`{ show, height, color }` | 开关 + 高度 2–6px(默认 3)+ 颜色(默认强调色) | 显示/3px/强调色 | 即时 | ClassIsland 3px 进度条(brief §1.2);隐藏时行底不留空白 |
| 15 | `显示位置` | `appearance.position` | `el-select` 6 项(顶/底 × 左/中/右) | `top-center` | 即时 | 现有 `ISLAND_POSITIONS`;classisland fixed 默认 `top-center` |
| 16 | `左右边距` | `appearance.marginX` | `el-slider` 0–200,step 2,居中停靠时禁用 | 8 | 即时 | 仅左右停靠生效,保留现有禁用逻辑与解释(`IslandSettingsView.vue:88-99`、`:324-337`) |
| 17 | `上下边距` | `appearance.marginY` | `el-slider` 0–160,step 2 | 8 | 即时 | 停靠顶/底生效 |
| 18 | `跟随鼠标屏幕` | `appearance.followCursorDisplay` | `el-switch` | 关 | 即时 | 多显示器 |
| 19 | `动画` | `appearance.animations` | `el-switch` | 开 | 即时 | 关闭后展开/收起瞬时完成 |
| 20 | `动画速度` | `appearance.speed` | `el-slider` 0.5–2,step 0.1 | 1 | 即时 | 与 `spring.ts`/CSS 时长倍率联动(part 10) |
| 21 | `始终置顶` | `appearance.alwaysOnTop` | `el-switch` | 开 | 即时 | |
| 22 | `明暗主题` | `appearance.theme`【新】`'auto'|'light'|'dark'` | `el-select`:跟随应用/明亮/黑暗 | 跟随应用 | 即时 | 岛主题独立于主窗口 `config.theme`;ClassIsland 应用主题三态(`AppearanceSettingsPage.axaml:123-135`) |
新增字段(1/4/6/7/8/9–14/22)**必须**由 parts-r/05 定稿字段名与 normalize 边界;本表给出默认值,任何越界值在 `normalizeIslandAppearance` 内钳制,禁止出现 NaN(现有归一化参考 `main/island.ts:2229` 的注释与 `config.ts` 的 normalize 调用)。
**字号单一事实来源**:`appearance.fontSize` 在 classisland 族是正文基准(默认 16),四档字号中「正文 = fontSize」「次级/强调/大号 = fontSize - 2 / + 2 / + 4」;若用户显式修改次级/强调/大号,则写 `secondaryFontSize/emphasizedFontSize/largeFontSize` 覆盖派生值(字段最终由 parts-r/05 定)。`layout=custom` 时字号由编辑器方案与组件外观控制,设置页这几项隐藏。
### 9.6 winisland 族外观参数(兼容保留,不得删)
`family === 'winisland'` 时渲染下列全部控件,字段、范围、默认值、文案与现有实现一致,**禁止改变默认外观**(老用户回退依赖它):
| # | 控件 | 字段 | 范围/step | 默认 |
|---|---|---|---|---|
| 1 | `高度 ${height}px` | `appearance.height` | 36–72/1 | 44 |
| 2 | `宽度 ${width}px` | `appearance.width` | 220–420/2 | 268 |
| 3 | `圆角 ${radius}px` | `appearance.radius` | 8–32/1 | 20 |
| 4 | `不透明度 ${%}` | `appearance.opacity` | 0.4–1/0.05 | 1 |
| 5 | `岛字号(经典) ${fontSize}px` | `appearance.fontSize` | 11–20/1 | 13(旧版语义;新装默认由 family 决定为 16,见下) |
| 6 | `动画速度 ${speed}x` | `appearance.speed` | 0.5–2/0.1 | 1 |
| 7 | `主题色` | `appearance.accent` | 颜色 | `#0a84ff` |
| 8 | `视觉风格` | `appearance.style` | 纯黑 `black`/毛玻璃 `glass`/主题色渐变 `tinted` | `black` |
| 9 | `显示位置` | `appearance.position` | 6 项 | `top-center` |
| 10 | `左右边距` | `appearance.marginX` | 0–200/2,居中禁用 | 8 |
| 11 | `上下边距` | `appearance.marginY` | 0–160/2 | 8 |
| 12 | `跟随鼠标屏幕` | `appearance.followCursorDisplay` | 开关 | 关 |
| 13 | `动画` | `appearance.animations` | 开关 | 开 |
| 14 | `始终置顶` | `appearance.alwaysOnTop` | 开关 | 开 |
| 15 | `空闲细缝` | `appearance.idleSliver` | 开关 | 关 |
| 16 | `预览效果` | — | 按钮:`islandPush` 一条 `priority: 'NORMAL'`、`context.preview = true` 的本地通知(30 秒自动消失、失焦不收起) | — |
证据:默认值与范围 `shared/src/constants.ts:264-302`;字段语义 `shared/src/types.ts:1145-1188`;`ISLAND_STYLE_LABELS` `constants.ts:323-330`;预览实现 `IslandSettingsView.vue:141-155`。字号/高度/宽度滑块若 `family=classisland` 时被隐藏,**不得**清空其值(切回 winisland 仍生效)。
**`fontSize` 双语义(必须按 family 区分)**:`winisland` 下它是「岛字号」,保持旧语义与旧系数、旧版默认 `13`(`constants.ts:272`),标签必须写「岛字号(经典)」;`classisland` 下它是「正文字号基准」,默认 `16`,派生 14/18/20 阶梯(见 9.5)。老配置升级时**保留用户原来的 `fontSize` 数值**,不因 family 迁移而改写;新安装默认 `16`。设置页在切换 family 时只切换标签与默认值提示,不强制改写已有值。
### 9.7 课程提醒设置(并入本页)
在「课程提醒」卡内按 ClassHelper 既有 `lessonReminders` 字段渲染 5 个开关 + 2 个阈值;保存走 `saveConfig({ islandLayout: { ...config.islandLayout, lessonReminders: {...} } })`(现有 `ReminderSettingsView.vue:8-24` 的逻辑迁移过来),**迁移不改变字段名与默认值**(`shared/src/island-layout.ts:140-158`)。
| 控件 | 字段 | 控件类型/范围 | 默认 | 语义对齐 |
|---|---|---|---|---|
| 准备上课提醒 | `prepareEnabled` | `el-switch` | 开 | ClassIsland `IsClassPrepareNotificationEnabled`(`Settings.cs:47`) |
| 上课提醒 | `onClassEnabled` | 开关 | 开 | `IsClassChangingNotificationEnabled`(`Settings.cs:46`) |
| 即将下课倒数 | `endingEnabled` | 开关 | 开 | 下课前秒数徽标;ClassIsland 蓝色圆形徽标、剩余一秒显示 `-1` |
| 经典岛课中常显课程进度 | `classProgressEnabled` | 开关 | 开 | 仅 windowisland/经典形态显示;classisland fixed 用行底进度条 |
| 下课提醒与下节课预告 | `breakEnabled` | 开关 | 开 | `IsClassOffNotificationEnabled`(`Settings.cs:55`) |
| 课前提前(秒) | `prepareSeconds` | `el-input-number` 5–1800,step 5 | 60 | ClassIsland `ClassPrepareNotifySeconds`(`Settings.cs:43`) |
| 下课倒数提前(秒) | `endingSeconds` | `el-input-number` 5–600,step 5 | 60 | ClassHelper 既有语义;ClassIsland 无同名项,标注【映射】 |
布局:每行使用现有 `.lesson-setting-row`(左右分布、`padding: 12px 0`、底部分隔线 `1px solid var(--ch-border)`,`ReminderSettingsView.vue:126-137`),阈值行紧随 5 个开关之后。所有改动即时保存;保存失败 `ElMessage.error('提醒设置保存失败')`(沿用现有文案)。
提醒页 `/settings/reminder` 必须改为:顶部链接卡「上下课提醒已移至【灵动岛 → 课程提醒】」→ 按钮跳 `/settings/island`;其余「通知提醒」「成绩查询」卡保持不变。**不得**两处都可编辑,避免双写。
### 9.8 保存、实时预览与失败重试(设置页)
1. **两条通道必须区分**:
- 外观改动 → `window.desktop.islandSetAppearance({ ...appearance })`(纯对象;主进程 16ms 合并,`main/island.ts:2389-2410`)→ 立即生效但不落盘;
- 点「保存设置」→ `window.desktop.saveConfig({ island: { ...appearance } })` → 落盘(合并写 120ms,`main/config.ts:252-298`)→ 成功 `ElMessage.success('个性化设置已保存')`,失败弹出错误并保留 `savingIsland=false` 以允许重试。
2. **禁止传响应式 Proxy**:所有 IPC 入参必须是展开后的纯对象(`types/desktop.d.ts:143-152`、`:166-175`;现有注释已写明 `An object could not be cloned.` 的坑)。设置页已有的 `{ ...island.value }`、`JSON.parse(JSON.stringify(...))` 写法必须保留。
3. **圆角/行距/不透明度同步 custom 方案**:当 `layout === 'custom'` 时,这三项除写 appearance 外还要写 `activeScheme.radius/opacity/spacing` 并 `saveConfig({ islandLayout: 深拷贝 })`;失败提示「圆角保存失败,请重试」(现有文案,`IslandSettingsView.vue:115-120`),并**不要**回滚滑块显示值(下次保存重试)。
4. **恢复默认**:`恢复默认` 按钮必须 `confirmAction('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' })`,确认后 `applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE })` 再保存;**新默认** 为 classisland 族参数(`DEFAULT_ISLAND_APPEARANCE` 扩展后),但恢复默认**不得**覆盖 `family`(用户显式选择要保留;如产品要一并重置,需二次确认)。
5. **载入与刷新**:`onMounted` 调 `islandGetAppearance()` 与 `getConfig()`;`window.addEventListener('focus', loadLayout)`(现有 `:82-86`)保留,确保从编辑器返回后方案列表/圆角同步。
6. **预览效果按钮**:仅 `family=winisland` 显示(classisland fixed 预览用缩略图 + 真实运行态验证);点击推送预览通知(id 前缀 `appearance-test-`),说明文案必须含「拖动滑块即可实时预览」。
### 9.9 编辑器总体布局(像素级)
文件:`IslandEditorView.vue`;样式:`styles/island-editor.css`。保留现有 DOM 结构(smoke 依赖,见 9.20),只做尺寸/对齐调整。
1. **页面容器**:`.island-editor-page` 保持 `display:flex; flex-direction:column; flex:1 0 auto; padding: 12px 24px 16px; background: var(--ch-bg)`(`:1-10`);`:has(> .island-editor-page)` 让 `.main` 也纵向 flex(`:11-14`)。
2. **顶部操作条** `.editor-actions`:右对齐、`gap:8px`、`min-height:30px`、`margin-bottom:4px`(`:19-25`);元素依次为保存状态文本(12px,见 9.18)、`返回设置`(text small)、`应用并返回`(primary small,`loading=!!pending`,`disabled=!ready`)。
3. **页头** `.component-heading`:`align-items:flex-end; gap:24px; margin-bottom:20px`(`:35-39`);左侧标题「组件」`font-size: 34px*var(--ch-font-scale)`、`font-weight:600`、`line-height:1.4`、`margin-bottom:20px`;说明「以下组件将显示在主界面上,从组件库拖动组件以添加组件。」16px(`:40-53`);右侧方案工具区 `flex: 0 1 348px; min-width:300px`(`:54-57`)。
4. **错误条**:`error` 非空时在页头下显示 `el-alert type="error"`,内容为错误消息 + `重试` 按钮(`ready ? save() : load()`)(`:608-610`)。
5. **内容工具条** `.surface-toolbar`:左右两组;左侧 `.surface-tabs` 两个按钮「常态内容」「无通知展开内容」(12px,选中下方 2px 主色下划线,`:102-124`);右侧 `.surface-actions` 三个按钮:`新增行`、`行设置`、`进入编辑模式/退出编辑模式`(最后一个选中时 primary)(`:612-653`)。**「新增行」文案不可改**(smoke 断言依赖)。
6. **预览画布** `.preview-canvas`:`min-height:80px; overflow:auto; padding:10px 0 20px`(`:125-129`);`live-preview` 时改为 `display:grid; place-items:center; min-height:160px; padding:24px; background:var(--ch-layer-alt); border-radius:6px; margin-top:8px`(`:184-192`)。
7. **标签态** `.component-lines`(`min-width:100%; width:max-content`,`:130-133`)逐行渲染 `.component-line`:
- `display:flex; align-items:center; min-height:46px; padding:2px 4px; border:1px solid transparent; border-radius:var(--ch-radius-control); gap:12px`;行与行间距改为 `margin-top: var(--preview-gap)`(值取 `scheme.spacing`,默认 5),**不再固定 6px**(原 `:143-145`;对齐 ClassIsland 行距默认 5);
- 选中/激活行 `border-color: var(--ch-border-strong)`;拖放目标行 `outline:2px dashed var(--ch-accent); outline-offset:-2px; background:var(--ch-accent-soft)`(保留 `:228-232`)。
- 行把手 `.row-handle`:宽 `20px`(原 24,改以对齐 ClassIsland 拖拽把手宽度 20,`repos/ClassIsland/ClassIsland/MainWindow.axaml:496`)、`align-self:stretch`、图标「Ⅱ」16px、`cursor:grab`、`touch-action:none`;点击选中行、拖动排序(保留双行为)。
- 行内 `.line-components`:`display:flex; align-items:center; gap: var(--row-gap)`(取 `row.gap`,默认 8)、`min-height:40px`(对齐行高 40)、`flex:1`、`padding-right:28px`(为行尾按钮留位,保留);
- 空行占位 `.empty-line` 保留「将组件拖到这里,或点击下方组件添加」。
8. **实时预览态** `.island-preview`:`display:flex; flex-direction:column; gap:var(--preview-gap); width:max-content; padding:12px; border-radius:var(--preview-radius); background:#202024; color:#ffffffeb`(`:193-202`);`.preview-row` 改 `min-width:120px; min-height:40px`(原 44);`separated` 时容器透明、每行独立 `padding:12px;background:#202024`(`:210-217`);缩放通过 `zoom: scheme.scale * zoom` 生效(现有模板 `:700-708`)。
9. **组件库** `.component-library`:上边框分隔、`margin:0 -24px; padding:14px 24px 24px`;标题「组件库」17px/600 + 3px 主色下划线;网格 `grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px 20px`;每项 `min-height:76px; padding:10px 8px; gap:12px; border:1px solid var(--ch-border); border-radius:var(--ch-radius-control)`,图标 36×36,名称 18px/500,描述 14px 次级(`:249-330`);响应式 1150px→3 列、800px→2 列、520px→1 列(`:371-417`)。**11 项顺序必须为**:文本、分割线、课程表、日期、时钟、天气简报、倒计时、轮播容器、滚动容器、分组容器、堆叠容器(`IslandEditorView.vue:58-70`)。
10. **抽屉** `.el-drawer.island-inspector`:宽 `min(360px, 94vw)`、`modal=false`、`modal-penetrable`、`append-to-body`、`lock-scroll=false`(`:781-790`);标题「组件设置」;内部顶部 `el-radio-group` 三个页签「方案/行/组件」(组件不可选时 disabled);抽屉背景/输入框样式沿用 `island-editor.css:346-361`。**抽屉动画统一 250ms `cubic-bezier(0,0,0,1)`**(对齐 ClassIsland `DrawerHost`:`repos/ClassIsland/ClassIsland.Core/Controls/DrawerHost.axaml:42-59`),并在 `AnimationLevel=0`/`prefers-reduced-motion` 时改为无动画。
11. **拖拽幽灵** `.drag-ghost`:`position:fixed; z-index:10000; padding:10px 16px; border-radius:6px; border:1px solid var(--ch-accent); box-shadow:var(--ch-shadow-flyout)`,位置 `pointer.x+16 / pointer.y+16`(`:331-342`、模板 `:774-780`)。
12. **导入文件输入**:隐藏 `<input type=file accept="application/json,.json">`(`:773`)保留。
13. **骨架**:`ready=false` 时 `el-skeleton :rows="8" animated`;`ready=true` 才渲染全部编辑 UI(`:611`)。
### 9.10 方案栏(保留并升级)
1. **选择方案**:`el-select` 绑定 `settings.activeSchemeId`,选项 `scheme.name`;`disabled=!ready`;`@change="switchScheme"`(重置选中 + 保存)。`aria-label="当前方案"` 保留(`:557-568`)。
2. **重置当前方案**:图标按钮(Refresh)`aria-label="重置当前方案"`、`title="重置当前方案"`;确认框标题「重置方案」,正文「将「{方案名}」的组件和外观恢复为默认配置?」,按钮「重置/取消」,`type: warning`;确认后用默认方案内容替换但**保留原 id 与 name**,并校验组件容量(现有 `resetScheme`,`:403-422`)。
3. **新建方案**(Plus,`aria-label="新建方案"`):`ElMessageBox.prompt('为新的组件方案命名', '新建方案', { inputValue:'新方案', inputValidator: 1–64 字符, confirmButtonText:'创建', cancelButtonText:'取消' })`;方案数上限 20(`schemeCapacity`,`:379-383`);新方案内容复制默认方案并**重生成全部 id**(`:384-402`、`renewScheme` `:371-378`);创建后自动选中并保存。
4. **管理方案**(FolderOpened 图标,`aria-label="管理方案"`,`el-dropdown trigger=click`)菜单顺序固定:`方案设置`、`复制方案`、分隔、`导入方案`、`导出方案`、分隔、`删除方案`(方案数 < 2 时禁用)(`:589-604`、`:423-432`)。
5. **复制方案**:深拷贝当前方案,名称 `${name.slice(0,61)} 副本`,重生成 id,push 后选中;受 20 方案与 200 组件上限约束(`:214-230`)。
6. **删除方案**:`ElMessageBox.confirm('删除方案「X」?', '删除方案', { confirmButtonText:'删除', cancelButtonText:'取消', type:'warning' })`;删除后 `activeSchemeId = schemes[0].id` 并保存(`:231-245`)。
7. **导出方案**:JSON 结构 `{ format: 'classhelper-island-layout', scheme }`、2 空格缩进、`Blob` 下载;文件名把 `[<>:"/\\|?*]` 替换为 `_`;下载后 1s `revokeObjectURL`(`:246-257`)。**该 format 字符串是老用户导入兼容契约,不得改。**
8. **导入方案**:文件 ≤ 1 MB;解析后校验 `format === 'classhelper-island-layout'` 且 `scheme.rows` 为非空数组;经 `normalizeIslandLayouts` 归一化;重生成全部 id;容量校验;成功 `ElMessage.success('方案已导入')`,失败 `ElMessage.error(错误信息)`;`finally` 清空 input(`:258-289`)。
9. **方案设置抽屉页**:名称(`maxlength=64`)、`界面缩放`(input-number 0.75–2.5、step 0.05,绑 `scheme.scale`)、`行间距`(slider 0–150,绑 `scheme.spacing`)、`常态圆角`(slider 0–20,绑 `scheme.radius`)、`各行分离显示`(switch,绑 `scheme.separated`);底部提示「字体、透明度、位置和动画与灵动岛共用设置。」+ 链接「前往灵动岛外观设置」→ `/settings/island`(`:832-857`)。**必须保留**这些字段与控件类型(ClassIsland 方案级参数:缩放/行距/圆角/分离,见 `repos/ClassIsland/ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml:27-118`)。
### 9.11 行与行设置(ClassIsland 编辑模式对齐)
1. **行列表**:每行渲染 `row.name`(编辑态以把手 aria-label「编辑{行名}」、title「{行名},拖动排序,点击设置」暴露,`IslandEditorView.vue:670-678`);行数上限 12(`addRow`,`:142-159`,文案「每种内容最多添加 12 行」)。
2. **新增行**:保留工具条「新增行」按钮;**新增**行列表末尾居中的第二入口:32px 高、图标 `E00D`(20px)、文字「新增行」,样式与 ClassIsland「新主界面行」一致(`repos/ClassIsland/ClassIsland/MainWindow.axaml:449-455`),点击行为与工具条相同。新行默认 `name: 组件行 N`、`main:false`、`allowNotifications:true`、`gap:8`、`nodes:[]`。
3. **排序**:拖行把手到另一行位置上/下方即可重排(现有 `dropPreview` 的 `current.rowId` 分支,`:505-512`);也提供抽屉内「上移/下移」按钮(`:824-830`)。
4. **主行**:抽屉按钮「设为主要行」;已是主行时按钮禁用且文案「已设为主要行」;设置主行会清除其他行 `main`(`setMain`,`:210-213`);行数为 1 时不允许删除(`removeRow`,`:202-209`)。
5. **允许提醒**:开关「允许显示提醒」绑定 `row.allowNotifications`;提示文案「提醒优先使用主要行;关闭提醒后使用下一条允许提醒的行。」(`:817-823`)。
6. **行间距**:`el-input-number` 0–64,绑 `row.gap`(组件间距);方案级行间距在方案设置抽屉(`scheme.spacing` 0–150)。**两者不得混用**。
7. **行设置抽屉规格**:标题「组件行」;字段顺序固定:当前行下拉(可切换编辑行)、行名称(maxlength 64)、组件间距、允许显示提醒、主行按钮、提示、上移/下移/删除此行(danger plain)。抽屉沿用 360px 非模态规格;打开/关闭动画 250ms `cubic-bezier(0,0,0,1)`。
8. **ClassIsland 对齐点(必须实现)**:
- 选中框:`1.5px` 虚线、`dasharray:1,1.5`、`opacity:0.75`、圆角跟随行/组件圆角;选中色 `AccentFillColorDefaultBrush` 等价主色,未选中容器为 `SurfaceStrokeColorDefaultBrush` 等价 `--ch-border-strong`(`repos/ClassIsland/ClassIsland/Controls/EditMode/EditableComponentsListBox.axaml:225-251`;`MainWindow.axaml:353-366`);
- 拖动把手:行把手 20px 宽、组件 chip 自身可拖(`TouchDragThumb` 等价);拖拽时被拖元素 `opacity:0.5`(ClassIsland 拖动预览透明度 0.5,`MainWindow.axaml:478-481`;ClassHelper 可保留现有 ghost 不降低原元素透明度,二选一但需统一);
- 行内组件外边距:组件外层**左右各 6px**(`Margin="6 0"`,`repos/ClassIsland/ClassIsland/MainWindow.axaml:388-399`、`:538-548`),叠加行内 `gap` 后形成视觉间距;运行态组件无此外边距;
- 行内容留白:行内左右 `12px`(`Margin="12 0"`、`MinWidth="20"`,`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:203-232`);编辑态为可选中的把手/加号额外留位;
- 行高:`40px`(`IslandContainerHeight`,`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:16`;`MainWindow.axaml:530-537` 容器行高 40)。
9. **既有交互必须保留**:行把手拖动排序、点击行选中并打开行设置、空行点击打开行设置、行尾「新增行」、主行/提醒开关的即时保存、最多 12 行提示。
### 9.12 组件库(11 类,保留既有 DOM 契约)
1. 数据源固定 11 项、顺序见 9.9-9;每项包含 `kind`、描述(现有 `library` 数组,`IslandEditorView.vue:58-70`)。
2. 每个库项必须是 `button`,带 `data-library-kind={kind}`、`aria-label="添加{中文名}"`;保留 `pointerdown` 开始拖拽、`click` 直接添加(两者共存,拖动后 `suppressClick` 防误加,`:754-768`、`:447`、`:500-503`)。
3. 直接添加逻辑:若当前选中容器则加入该容器(层级 ≥ 6 时警告「最多嵌套 6 层容器」),否则加入当前行末尾;添加后自动选中并保存(`add`,`:130-141`)。
4. 组件图标使用 `ComponentIcon`(`:763`);名称与描述分别 18px/14px(随 `--ch-font-scale` 缩放)。
5. 消息类组件(`notification/homework/call`)**不得**出现在库中,但既有方案里的这些节点必须继续渲染与可编辑(只读提示「此组件使用实时数据。可在外观和尺寸设置中调整显示。」)。
### 9.13 拖拽规格(三类源,必须保留现有实现)
1. **统一 Pointer Events**:`pointerdown`(仅 `button===0`)→ 在源元素 `setPointerCapture`(合成事件失败时 try/catch 忽略)→ `pointermove` 超过 **6px** 才判定开始拖拽 → `pointerup` 落点;`pointercancel`/`blur`/画布外释放一律取消(`beginDrag/moveDrag/dropPreview/cancelDrag`,`IslandEditorView.vue:433-503`;模板事件绑定 `:674`、`:688`、`:730`、`:760`)。
2. **三种拖拽源**:组件库项(`kind` 源)、组件 chip(`nodeId` 源)、行把手(`rowId` 源)。
3. **落点判定**:
- 用 `document.elementFromPoint` + `closest('[data-row]')` 找行;无行则取消;
- 行内找 `[data-node]`:未命中节点 → 落到行尾;命中节点 → 计算指针在节点宽度内的比例 `ratio`;
- 容器节点(`group/stack/slide/rolling`)且 `0.2 < ratio < 0.8` → `inside`;否则 `ratio > 0.5` → `after`,否则 `before`(`dragTarget`,`:462-481`);
- 拖拽期间落点高亮:行级 `outline:2px dashed`,节点 `before/after` 显示 3px 主色竖条(`IslandEditorNode.vue:107-123`),`inside` 高亮容器(`:107-110`)。
4. **约束**:单次拖拽内不得把父节点放进自己的后代(`all([node]).some(child.id === target.node.id)` → 忽略,`:517`);目标层级 + 被拖子树高度 ≤ 6(`:518-522`);组件总数 ≤ 200(`capacity`,`:338-347`)。
5. **落库**:移动完成后 `selectedId` 指向被拖节点、`rowId` 指向目标行、`panel='component'`,立即 `save()`(`:534-537`)。
6. **ghost**:仅 `pointer.moved` 后显示,文本为组件中文名或行名(`dragging.label`),偏移 +16/+16。
7. **触摸**:必须继续支持 `pointerType==='touch'`(现有 smoke 用合成 touch 事件验证跨行拖动,`main/smoke-layout.ts:74-91`);拖动时 `touch-action:none`、禁止页面滚动(`.component-chip`、`.row-handle`、`.library-item` 已设置)。
### 9.14 选中、组件设置抽屉(四页签)
1. **选中**:点击组件 chip → `selectNode`:设置 `selectedId`、`rowId`、`panel='component'`、打开抽屉;点击行把手/空行 → `selectRow`:清空 `selectedId`、`panel='row'`;切换方案/常态/展开内容时 `resetSelection()`(`IslandEditorView.vue:106-125`)。选中框按 9.11-8 规格。
2. **抽屉结构**:顶部 `el-radio-group`(方案 / 行 / 组件);`component` 页签在无选中时禁用;内容区渲染 `IslandComponentSettings`(组件)或行/方案表单(`IslandEditorView.vue:791-857`)。
3. **四页签**(`IslandComponentSettings.vue` 重构,顺序固定):`内容`、`外观`、`尺寸`、`隐藏条件`。每页签使用 `el-form label-position="top"`;选项选择类控件宽度 100%;数字/颜色控件与标签同一行(现有 `:283-307` 样式保留)。
4. **跨页签即时保存**:任一控件 `@change` 必须 `emit('change')` → 编辑器 `save()`(现有模式);不设「应用」按钮。
5. **删除/复制/包裹入口**:内容页底部固定「包裹到容器」下拉(`data-action="wrap"`)、「复制组件」、「删除」(danger plain)(`:152-170`);复制节点时深拷贝并重生成自身与所有后代 id(`copyNode`,`IslandEditorView.vue:180-188`);包裹受层级与容量约束(`wrap`,`:189-201`)。
6. **抽屉 DOM 契约**:`.component-settings`、`.el-tabs__item` 数量为 4、`.el-tab-pane` 的可见页内容互斥;编辑器 smoke 会按页签断言内容(现有 3 页签断言见 `main/smoke-layout.ts:111-137`,重构后由 parts-r/11 同步更新为 4 页签)。
### 9.15 组件设置项清单(与 parts-r/07 组件契约一一对应)
字段名直接来自 `packages/shared/src/island-layout.ts:77-108`(`settings`/`appearance`/`advanced`)。默认值与取值范围以 `normalizeIslandLayouts` 的 clamp 为准(`:268-332`)。**任何新增字段必须先落到 shared 契约并在 parts-r/07 说明渲染方式。**
#### 9.15.1 内容页(按 kind)
| kind | 控件 | 字段 | 类型/范围 | 默认 | 生效 |
|---|---|---|---|---|---|
| 全部 | (无内容设置时)提示 | — | 文本「此组件使用实时数据。可在外观和尺寸设置中调整显示。」 | — | — |
| `clock` | 12 小时制 | `settings.hour12` | switch | false | 即时 |
| `clock` | 显示秒 | `settings.seconds` | switch | true | 即时 |
| `countdown` | 倒计时目标 | `settings.target` | select:自动 `auto`/下一节课 `next`/本节下课 `end`/放学 `school`/指定日期 `date` | `auto`(编辑器新建时置为 `date`,`makeNode` `IslandEditorView.vue:359-363`) | 即时 |
| `countdown` | 目标名称(仅 date) | `settings.countdownTitle` | input maxlength 64 | `目标日` | 即时 |
| `countdown` | 目标日期(仅 date) | `settings.targetDate` | `ch-date-picker` `YYYY-MM-DD`,必须 `isDayKey` 合法 | `''` | 即时 |
| `weather` | 地点名称 | `settings.locationName` | input maxlength 64 | `''` | 即时 |
| `weather` | 纬度 | `settings.latitude` | input-number -90–90,precision 4,允许 `null` | `null` | 即时 |
| `weather` | 经度 | `settings.longitude` | input-number -180–180,precision 4,允许 `null` | `null` | 即时 |
| `text` | 文字内容 | `settings.text` | textarea rows 3,≤2000 字 | `班级信息` | 即时 |
| `text` | 启用模板变量 | `settings.template` | switch;提示「支持 {date} 日期、{time} 时间、{course} 当前课程。」 | false | 即时 |
| `schedule` | 显示课程 | `settings.display` | select:当前时间点 `current`/下一节课 `next`/今日时间点 `today` | `current` | 即时 |
| `schedule` | 显示时间点数(仅 today) | `settings.count` | input-number 1–30(normalize 1–40) | 5 | 即时 |
| `schedule` | 当前时间点显示起止时间 | `settings.showPeriod` | switch | true | 即时 |
| `schedule` | 当前课程显示进度 | `settings.showProgress` | switch | true | 即时 |
| `group`/`stack`/`slide`/`rolling` | 排列方向 | `settings.direction` | select:横向 `horizontal`/纵向 `vertical` | `horizontal` | 即时 |
| 同上 | 组件间距 | `settings.gap` | input-number 0–64 | 8 | 即时 |
| `slide` | 切换间隔(秒) | `settings.interval` | input-number 1–300 | 5 | 即时 |
| `rolling` | 滚动速度 | `settings.speed` | input-number 1–200 | 30 | 即时 |
| `separator`/`date`/`notification`/`homework`/`call` | — | — | 无内容参数(消息类显示只读提示) | — | — |
#### 9.15.2 外观页(所有 kind 通用,`appearance`)
| 控件 | 字段 | 类型/范围 | 默认 | 生效 |
|---|---|---|---|---|
| 独立外观 | `appearance.resourceOverridingEnabled` | switch | false | 即时;关闭时其余项禁用并沿用岛外观 |
| 辅助文字字号 | `appearance.secondaryFontSize` | input-number 8–96 | 14 | 即时 |
| 正文字号 | `appearance.bodyFontSize` | 同上 | 16 | 即时;**组件级覆盖,仅 custom 使用**;classisland fixed 的四档字号来自 `IslandAppearance.fontSize` 派生(见 9.5),本项不参与 |
| 强调文字字号 | `appearance.emphasizedFontSize` | 同上 | 18 | 即时 |
| 大号文字字号 | `appearance.largeFontSize` | input-number 8–128 | 20 | 即时 |
| 自定义文字颜色 | `appearance.customForegroundEnabled` + `appearance.foreground` | switch + color-picker(`#rrggbb`) | 关 / `#ffffff` | 即时 |
| 自定义背景颜色 | `appearance.customBackgroundEnabled` + `appearance.background` | switch + color-picker | 关 / `#202024` | 即时 |
| 自定义背景透明度 | `appearance.customBackgroundOpacityEnabled` + `appearance.backgroundOpacity` | switch + input-number 0–1 step 0.05 | 关 / 0.5 | 即时 |
| 自定义圆角 | `appearance.customCornerRadiusEnabled` + `appearance.cornerRadius` | switch + input-number 0–100(ClassIsland 上限 20 用于岛;组件可为 100) | 关 / 16 | 即时 |
| 组件透明度 | `appearance.opacity` | slider 0–1 step 0.05 | 1 | 即时 |
#### 9.15.3 尺寸页(原「高级」拆分,`advanced`)
| 控件 | 字段 | 类型/范围 | 默认 | 生效 |
|---|---|---|---|---|
| 对齐方式 | `advanced.align` | select:拉伸 `stretch`/起始 `start`/居中 `center`/末端 `end` | `stretch` | 即时 |
| 固定宽度(px) | `advanced.width` | input-number 0–2000(0=自适应) | 0 | 即时 |
| 最大宽度(px) | `advanced.maxWidth` | input-number 0–2000(0=不限制) | 0 | 即时 |
| 提示 | — | 「宽度为 0 时自适应;对齐在父容器设定宽度时生效。」 | — | — |
#### 9.15.4 隐藏条件页
| 控件 | 字段 | 类型/取值 | 默认 | 生效 |
|---|---|---|---|---|
| 隐藏条件(多选) | `advanced.hideRules` | checkbox-group:上课时 `in-class`/没有消息时 `no-message`/没有当前课程时 `no-class` | `[]` | 即时;数组只保留这三个合法值(normalize `:321-328`) |
| 高级说明 | — | 文本「隐藏条件基于当前课堂与消息状态即时生效;不会释放组件资源。」 | — | — |
ClassIsland 对齐:ClassIsland 用「按规则隐藏 + 规则集」承载同一语义(`repos/ClassIsland/ClassIsland/Views/SettingPages/ComponentsSettingsPage.axaml:423-443`);本轮**不引入规则集引擎**,保留 ClassHelper 三枚举,标注【映射】。若 parts-r/05 增补 `advanced.hideRules` 扩展,必须同步本页与 parts-r/07。
### 9.16 预览与「进入编辑模式」
1. **`editMode` 为页面内状态**(`ref(false)`),**不打开额外桌面窗口、不注入 iframe**(`docs/app/island.md:47`);smoke 会断言不存在 `.layout-editor`/`iframe`(`main/smoke-layout.ts:69-72`、`:154`)。
2. **进入**:工具条「进入编辑模式」→ `editMode=true`;画布切换为 `.island-preview` 真实组件渲染:`ComponentNode` 以 `:node :state :now :schedules :time-points editing :selected-id alert-node-id=""` 渲染,`state` 使用本地空状态对象(`IslandEditorView.vue:47-57`、`:719-732`);容器组件在编辑时展示全部子组件(`ComponentNode` 的 `editing` 分支由 parts-r/07 定义)。
3. **预览缩放**:仅在编辑模式显示 `.preview-toolbar`:文本「预览缩放」+ `el-slider` 0.3–1.5、step 0.1、tooltip `%` + 「重置缩放」(置 1)+ 提示「点选组件调整内容、外观和隐藏条件」(`:739-750`);缩放与方案缩放相乘(`zoom: scheme.scale * zoom`)。
4. **布局约束**:`preview-canvas` 必须完整容纳预览且不遮挡工具条——`canvas.bottom <= footer.top + 1` 且页面无横向滚动(smoke 断言 `main/smoke-layout.ts:138-148`);`separated` 时按 9.9-8 渲染。
5. **点击编辑态组件**:与标签态一致打开抽屉(`@select`);背景点击(`.preview-row @click.self`)选中该行。
6. **退出**:再次点击「退出编辑模式」回到标签态;抽屉保持打开状态但选中不变;`Esc` 优先关闭抽屉、再退出编辑模式(见 9.17)。
7. **实时同步真实岛**:编辑器每次 `save()` 通过 `saveConfig` 触发主进程 `island.syncCustomLayout()`(`main/ipc.ts:34-45`;`main/island.ts:320-338`),真实岛收到 `island:layout` 后热更新(`LayoutIslandApp.vue:35-51`);**因此“预览”= 页面内预览 + 真实岛热更新,不新增窗口**。
### 9.17 键盘、触摸与焦点
| 场景 | 按键 | 行为 |
|---|---|---|
| 编辑器全局 | `Esc` | 抽屉打开→关闭抽屉;否则编辑模式→退出编辑模式;否则返回 `/settings/island`(需保存成功后) |
| 选中组件 | `Delete`/`Backspace` | 删除选中组件(无后代时直接删;有后代弹确认「删除后其子组件一并删除」,确认后删);删除后 `panel='row'` |
| 选中组件 | `Ctrl+D`/`Cmd+D` | 复制选中组件(同「复制组件」,受 200 上限约束) |
| 选中组件/行 | `Enter`/`Space` | 打开组件设置/行设置抽屉 |
| 行把手/组件 chip | `Tab` → `Enter` | 键盘聚焦并选中;拖动需指针;不提供仅键盘拖动(标注【推断】,作为 P2 可加方向键移动) |
| 抽屉 | `Tab` 循环限制在抽屉内 | `el-drawer` 自带焦点陷阱,保留 |
| 设置页 | 无全局快捷键 | 滑块可用方向键微调(Element Plus 默认) |
触摸:所有拖拽继续使用 Pointer Events(鼠标/触摸/笔统一);触摸时不得触发页面滚动与文本选择;`touch-action:none` 只加在可拖元素;抽屉在窄屏(<520px)宽 `94vw`。焦点可见性:所有自绘按钮必须有 `:focus-visible` 2px 主色 outline + 2px offset(现有 `.library-item/.row-handle/.surface-tabs button` 规则保留,`island-editor.css:305-310`)。
### 9.18 数据流、契约与失败处理
1. **读取**:编辑器 `load()` → `window.desktop.getConfig()` → `settings = normalizeIslandLayouts(config.islandLayout)`、`appearance = config.island`;失败显示错误条并提供「重试」(现有 `load`, `IslandEditorView.vue:300-313`)。设置页同时读 `getConfig()` 与 `islandGetAppearance()`。
2. **保存队列**(编辑器):每次改动 `save()`:
- `snapshot = normalizeIslandLayouts(JSON.parse(JSON.stringify(settings)))`(必须先深拷贝纯对象,禁止 Proxy 过桥);
- `revision++`、`pending++`、`saved=false`;
- 串行 promise 链 `saveConfig({ islandLayout: snapshot })`;成功且 `current===revision` 时清 `error`、`saved=true`;失败写 `error`、`saved=false`;`finally pending--`(现有 `:81-105`)。
- 状态文本固定四态:`error` → 「保存失败」;`pending>0` → 「正在保存…」;`saved` → 「已自动保存」;否则「更改自动保存到本机」(`:543-546`)。
3. **退出保存**:`onBeforeRouteLeave(async () => { if (!ready) return true; await save(); return !error })`——有错不发离开(`:333-337`);「应用并返回」`apply()`:强制 `settings.layout='custom'`、`residentEnabled=true`,`await save()`,无错误才 `ElMessage.success('自定义灵动岛已应用')` 并 `router.push('/settings/island')`(`:290-299`)。
4. **失败重试**:错误条 `重试` 按钮按 `ready ? save() : load()`;保存失败不得清空本地编辑内容;不得自动回滚到磁盘值(避免用户输入)
丢失;若连续失败需在控制台/日志打出 IPC 错误(沿用主进程 logger)。
5. **应用/保存语义**:编辑器每次改动即 `saveConfig`(含磁盘合并写 120ms,`main/config.ts:252-298`),所以「退出保存」不是唯一写入点;**保存成功≠已点“应用”**,但配置已写入 `islandLayout`。`layout` 只有在「应用并返回」或设置页选择自定义时才切到 `custom`。
6. **主进程侧**:`ipcMain.handle(IPC_CHANNELS.saveConfig)` → `saveConfig(allowed)` → 若 `allowed.islandLayout` 则 `island.syncCustomLayout()`(`main/ipc.ts:34-45`);`syncCustomLayout()` 在常驻开关切换时重置 state,向岛渲染进程 `send('island:layout', getConfig().islandLayout)`(`main/island.ts:320-338`);岛渲染层 `onLayout` 更新(`LayoutIslandApp.vue:35-51`)。
7. **外观通道**:`island:set-appearance` 主进程 16ms 合并后 `island.setAppearance`(`main/island.ts:2389-2410`);`island:get-appearance` 会先 flush 再返回(`:2407-2410`)。外观不落盘,除非设置页点保存写 `config.island`。
8. **迁移**:`config.ts` 读盘时 `normalizeIslandLayouts` + `normalizeIslandAppearance`(`:198-233`)。老用户(无 `island.family`)→ `'winisland'`;新装默认 `'classisland'`。实现方式(二选一,必须由 parts-r/05 定稿):
- 在 `readPersistedFromDisk` 里判断 `parsed.island` 是否存在且 `family` 缺省 → `family:'winisland'`;或
- `islandLayout.version < 2` 且 `config.island` 存在 → 迁移为 winisland,并写 `version=2`。
**`fontSize` 迁移规则**:旧配置已有 `island.fontSize`(例如 13)时**原样保留**,不得因 family 从 classisland 变 winisland(或反向)而改写;旧配置没有该字段时才按迁移后的 family 取默认(`winisland=13` / `classisland=16`)。新安装/清空配置:`family='classisland'` 且 `fontSize=16`。`DEFAULT_ISLAND_APPEARANCE` 的 `fontSize` 默认值需按此改为 16,旧默认 13 仅作为 winisland 语义说明保留在文档与文案中。
迁移失败必须回落默认配置并记日志,不得让设置页崩溃。
9. **禁止**:在渲染进程直接写 config 文件;在编辑器内创建第二个 BrowserWindow;把 `ref`/`reactive` 直接传 IPC;用 `setBackgroundMaterial()` 影响岛透明(`AGENTS.md` 客户端章节)。
10. **契约对照(必须在 PR 中核对)**:本 part 用到的字段均来自 `IslandComponentNode.settings/appearance/advanced`、`IslandLayoutRow`、`IslandLayoutScheme`、`IslandLayoutSettings`、`IslandAppearance`(`shared/src/island-layout.ts:61-158`、`shared/src/types.ts:1146-1188`);新增字段以 parts-r/05 为准,组件渲染细节以 parts-r/07 为准,消息/课程提醒视觉以 parts-r/08 为准。
### 9.19 状态、空态、主题与性能约束
1. **加载/空态**:
- `ready=false` 前只渲染页头/方案栏骨架,禁止允许编辑(避免对未加载的 `scheme` 写入);编辑器用 `el-skeleton :rows="8"`,设置页用 `v-loading` 或同款骨架;
- `settings.schemes` 由 `normalizeIslandLayouts` 保证至少 1 个(空输入回落默认方案,`shared/src/island-layout.ts:399-402`);若运行期出现空数组(异常导入),必须立即用 `defaultIslandLayouts().schemes[0]` 补位并提示「方案数据异常,已恢复默认方案」;
- 行无节点时显示空行占位;容器无子组件时显示「拖入组件」(`IslandEditorNode.vue:47`);未选中组件时抽屉「组件」页签禁用;
- 删除当前选中节点后,`panel` 回到 `row`、`selectedId` 清空;切换方案/常态/展开内容时全部选中态清空(现有 `resetSelection`,`IslandEditorView.vue:119-125`)。
2. **错误态**:`load()` 失败(非桌面客户端打开、`getConfig` 抛错)→ 页面显示 `el-alert type="error"` + 错误文本 + `重试`;编辑功能全部不可用。保存失败只显示错误条与状态文本,不弹全局遮罩,不丢失编辑内存。
3. **主题与关怀模式**:设置页与编辑器必须只使用 `--ch-*` 令牌与 `var(--ch-font-scale)` 缩放文字(`styles/index.css:21-131`、`:103`),不得写死浅色/深色;深色模式由 `html.dark` 变量覆盖,且 `smoke-layout.ts:149-157` 会断言深色下 `.island-editor-page` 的 `--ch-text` 存在。**例外**:编辑器的岛预览区(`.island-preview`)固定使用岛自身底色 `#202024` 与 `#ffffffeb` 文字,以真实预览岛外观,不跟随设置页主题;该区域的组件颜色仍遵循 `appearance` 覆盖。关怀模式放大字号时,组件库名称/描述、按钮文字跟随 `--ch-font-scale`,但抽屉宽度、行高、组件外边距等几何令牌不变(`AGENTS.md` §5「关怀模式放大字号/控件,不改变几何令牌」)。
4. **响应式**:编辑器按现有断点:≤1150px 组件库 3 列、≤800px 2 列且方案工具区换行、≤520px 1 列并收窄页面左右 padding(`island-editor.css:371-417`);设置页在 ≤680px 时布局选择卡改单列(`IslandSettingsView.vue:497-505`),抽屉宽度 `min(360px, 94vw)` 自适应。
5. **性能**:拖动 `pointermove` 只在 `dragging` 存在时处理,且必须 `passive:false` 才可 `preventDefault`(现有 `{ passive: false }`,`IslandEditorView.vue:315`);保存用串行队列 + revision 去陈旧结果,禁止每次输入都直接 `await getConfig()`;节点上限 200/行 12/方案 20/深度 6 必须在写入前检查而不是写入后裁剪;编辑页不得创建额外 BrowserWindow、不得轮询 `getConfig`(只 `focus` 时刷新一次设置页)。
6. **无障碍**:所有自绘可点元素使用原生 `button` 或 `role="button"` + `tabindex="0"`;保留现有 `aria-label`(新建/重置/管理方案、添加组件、编辑组件、编辑行);拖拽不是唯一操作路径,必须同时提供按钮/下拉的等价操作(添加组件、上移/下移、包裹、复制、删除),以满足键盘与触屏用户。
### 9.20 既有实现与测试契约(重构不得破坏)
以下行为/文案/DOM 已被现有冒烟与代码依赖,重构时必须保留或由 parts-r/11 同步更新测试:
1. 路由:`/settings/island`、`/settings/island/editor`(`router/index.ts:63-74`);侧栏「灵动岛」入口(`ClientLayout.vue:48-55`);岛内「编辑」通过 `onIslandEditorOpen` 事件把主窗口切到编辑器(`ClientLayout.vue:23-26`;主进程 `main/index.ts:229-235`)。
2. 编辑器:`.component-library` 首组 11 个 `.library-item`;按钮文本「新增行」「行设置」「进入编辑模式/退出编辑模式」「应用并返回」「返回设置」;`data-row`/`data-node`/`data-library-kind`/`data-action=wrap`;`.component-settings .el-tabs__item` 页签;`.preview-canvas/.preview-toolbar/.island-preview`(`main/smoke-layout.ts:40-73`、`:111-148`、`:186-226`)。
3. 方案:`aria-label` 为「新建方案」「重置当前方案」「管理方案」;下拉项文案「方案设置/复制方案/导入方案/导出方案/删除方案」;导入 format 字符串 `classhelper-island-layout`;导入 ≤1 MB;新建/复制重生成 id;删除保持至少 1 个方案(`:264-360`)。
4. 设置页:`.layout-choice` 两张卡、`编辑岛内组件` 按钮、指针避让开关、保存/预览/恢复默认按钮与实时预览(`IslandSettingsView.vue:170-397`)。
5. 保存队列行为与状态文本(9.18-2);失败重试按钮;`应用并返回` 成功后才离开。
6. 这些断言若因重构改变(例如组件设置页签从 3 变 4),**必须**在同一 PR 更新 parts-r/11 的冒烟脚本,禁止只改实现不改测试。
### 9.21 验收标准
实现完成后逐项验证并记录结果(全部通过才算完成;验证必须无头/离屏,禁止启动可见岛做验收,除非显式 `--visual`):
1. **family 与默认**:新配置(清空 config)打开设置页,`family` 默认 `ClassIsland 风格` 且 `appearance.fontSize=16`、标签「正文字号(ClassIsland 基准)」;老配置(人为删除 `island.family`)重载后自动为 `经典 WinIsland(兼容)` 且外观与升级前一致(对比截图/配置 JSON),其原有 `fontSize`(如 13)保持不变、标签「岛字号(经典)」。
2. **fixed 语义**:`family=classisland`、`layout=fixed` 时,岛外观为信息行(行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条),默认显示日期 + 课程表;**不出现**胶囊/详情卡;来消息时在同一条内展开气泡;`family=winisland` 时恢复胶囊 + 详情卡。`layout=fixed` 时设置页不得出现任何行/组件编辑入口(fixed v2 为只读内置方案)。
3. **外观参数**:classisland 族 22 项、winisland 族 15 项逐一改动——拖动时岛即时变化(≤1 帧合并),点保存后 `config.json.island` 落盘;重启客户端后保持;非法值(如缩放 9、圆角 -1、颜色 `red`)被钳制/回落,不出现 NaN。**字号语义**:classisland 下改「正文字号(ClassIsland 基准)」时正文 = 该值、次级/强调/大号按 14/18/20 阶梯派生,默认 16;winisland 下标签为「岛字号(经典)」、默认 13;两种 family 间切换标签与默认值提示正确、不强制改写已有值。
4. **custom 与 family**:`layout=custom` 时 `family` 控件禁用并显示「自定义方案始终使用 ClassIsland 规格;外观族只作用于经典固定形态。」;custom 的渲染始终为 ClassIsland 规格(不因 `family=winisland` 回退旧观感);设置页「圆角/不透明度/行间距」改动同时写入 `schemes[activeSchemeId]` 并落盘;「界面缩放」在 custom 下隐藏并有说明;`schemes/rows/nodes` 编辑器只服务 custom,fixed 无编辑入口。
5. **课程提醒**:5 个开关 + 2 个阈值改动即时保存到 `config.json.islandLayout.lessonReminders`(**唯一数据源**);阈值越界被钳制到 5–1800 / 5–600;`/settings/reminder` 只显示只读摘要 + 跳转链接,页面内无可编辑的上课提醒控件。
6. **编辑器布局**:编辑器页 `padding 12/24/16`;行最小高 46(标签态)/40(预览态);行距等于 `scheme.spacing`;组件外层左右各 6px;选中框 1.5px 虚线 `1,1.5`、opacity .75;行把手 20px;「新增行」在工具条与行列表末尾均可用。
7. **四页签**:组件设置抽屉依次为「内容/外观/尺寸/隐藏条件」;11 类组件的控件、默认值、范围与 9.15 表一致;切换组件时回到「内容」页。
8. **拖拽**:组件库→行、组件库→容器(20%–80% 区域)、行内排序、跨行移动、行排序均可用(鼠标与触摸各测一次);拖动阈值 6px;拖动父组件到自身后代被拒绝且数据不变;嵌套超过 6 层、组件超过 200、行超过 12、方案超过 20 均给出提示且不写入。
9. **方案**:新建(提示输入,1–64 字,最多 20 个)、复制(名称加「副本」、id 全量重生)、重置(保留 id/name)、删除(确认,至少保留 1 个)、导入(≤1 MB,format 校验,id 重生)、导出(文件名非法字符替换、可再导入)全部通过;`activeSchemeId` 始终有效。
10. **保存与失败**:连续快速拖动/输入时状态文本按「正在保存…」→「已自动保存」变化;人为让 `saveConfig` reject(mock IPC)时显示「保存失败」+ 错误条「重试」,且未点应用不允许离开;重试成功后错误清除。
11. **编辑模式**:进入后页面内渲染真实组件(无额外窗口/iframe),缩放 0.3–1.5 生效且「重置缩放」回 100%;预览不遮挡工具条、无横向滚动;退出后回到标签态;真实岛在保存后热更新(`island:layout` 生效)。
12. **键盘/触摸**:`Esc` 逐层退出(抽屉→编辑模式→返回);`Delete` 删除选中组件(有子组件先确认);`Ctrl+D` 复制;所有自绘按钮有 `:focus-visible` 焦点环;触摸拖动不触发页面滚动。
13. **无回归**:9.20 列出的 DOM/文案/路由/导入格式全部保持;现有冒烟脚本(若未按 9.20-6 更新)中的编辑器断言通过;未新增桌面窗口、未改 `main/island.ts` 的透明/命中/几何逻辑;`pnpm typecheck` 与 `pnpm lint` 通过(不执行构建/运行验证);9.19 的状态/空态/主题/响应式行为逐一验证。