## 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-`(`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 视觉 | 项 | 值 | |---|---| | 位置 | 课程名 `` 右侧,与课程名同一行(`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. **导入文件输入**:隐藏 ``(`: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 的状态/空态/主题/响应式行为逐一验证。