Files
classhelper-island-refactor…/ClassHelper灵动岛重构-给ChatGPT的完整提示词.md
T

4301 lines
464 KiB
Markdown
Raw Normal View History

# ClassHelper 灵动岛重构:给 ChatGPT 的完整实现提示词
> **文档性质**:这是一份「提示词」而非分析报告。请把本文件(或按章节分卷)投喂给 ChatGPT,要求它在 **ClassHelper 现有仓库**(`LoveChengke/ClassHelper`,pnpm monorepo,Electron + Vue 3)中,以 ClassIsland(`ClassIsland/ClassIsland`,C#/Avalonia)的灵动岛(主界面)为基准,**重构 ClassHelper 的灵动岛子系统**。不是从零新建项目,不重写服务端与管理端,不更换技术栈。
>
> **信息来源**:ClassHelper 工作区源码(`repos/ClassHelper-master`)与 ClassIsland v2.1 源码(`repos/ClassIsland`)。文中所有确切数值、文件名、行为均尽量标注 `packages/...:行号` 或 `repos/ClassIsland/...:行号`;无源码依据处标注【推断】。ClassIsland 的源码只作为行为与视觉基准,**不得**把其 C#/Avalonia 代码、XAML、本地档案体系引入 ClassHelper。
>
> **重要纪律**:本重构的一切自动验证(构建、冒烟、截图、动画检查)必须**无头/离屏运行,禁止弹出可见窗口、禁止播放声音、禁止干扰用户桌面**。ClassHelper 仓库默认的 `verify:desktop` / `verify:packaged` 就是后台快速检查,只有显式 `--visual` 才允许可见验收——重构后必须保持这一约定(见第 11、12 部分)。
## 使用说明(给人类读者,不投喂给模型)
1. **投喂方式**:全文约 12 万 tokens,建议按 `out/投喂指南-重构版.md` 的五卷/逐章方式投喂;一次性投喂需 ≥ 128k 上下文模型。投喂前请把 ClassHelper 仓库(或至少 `packages/desktop-client`、`packages/shared`、`packages/ui`、`docs/`)提供给 ChatGPT(作为代码上下文或附件)。
2. **推荐开场**:「请先阅读第 0–2 部分,复述你对现状、目标与 7 条裁决的理解;然后给出文件级重构计划(含迁移与回滚),等我确认后再按 M0–M7 逐阶段实现。」
3. **证据约定**:`packages/...:行号` 指 ClassHelper 仓库相对路径;`repos/ClassIsland/...:行号` 是 ClassIsland 基准来源(相对本工作区),只用于理解行为,不是运行时依赖。
4. **验收纪律**:任何「已完成」的声明都必须附带无头验证输出与第 12 部分的逐项勾选结果;未验证项必须标注。
## 目录
- 使用说明(给人类读者,不投喂给模型)
- 0. 给 ChatGPT 的任务声明(从本节开始执行)
- 1. 重构目标与范围
- 1.1 为什么重构、以什么为基准
- 1.2 目标状态(重构完成后用户看到什么)
- 1.3 范围(In / Out)
- 1.4 模块级 Keep / Change / Add / Remove
- 1.5 关键设计裁决(必须逐条执行)
- 1.6 成功标准(重构完成的定义)
- 1.7 主要风险与对策
- 1.8 阅读顺序提示
- 2. ClassHelper 灵动岛现状基线(重构的"现状地图")
- 2.0 阅读与使用方式
- 2.1 进程、窗口与装配全景
- 2.2 主进程岛控制器分区地图(`src/main/island.ts`,共 2445 行)
- 2.3 窗口几何:固定包围盒 + 岛内形变
- 2.4 命中、穿透、触摸与"点不动"防护
- 2.5 消息状态机(fixed 经典模式)
- 2.6 经典模式渲染(`src/island/IslandApp.vue`,683 行)
- 2.7 自定义布局(custom)运行时与数据
- 2.8 课程提醒与课程组件
- 2.9 天气现状
- 2.10 课件传输覆盖
- 2.11 渲染入口与组件清单(现状文件级地图)
- 2.12 主窗口 → 主进程桥接(`src/renderer/island/bridge.ts`)
- 2.13 配置与数据契约现状
- 2.14 IPC 现状(重构必须保持的契约)
- 2.15 编辑器与设置页现状
- 2.16 既有硬约束(重构不得破坏,逐条对应 AGENTS.md §4)
- 2.17 现状问题清单(重构动因,逐条对应 04 的改造项)
- 2.18 验收标准(第 2 部分)
- 3. ClassIsland 灵动岛行为基准(重构对照规格)
- 3.1 主界面行(行为模型)
- 3.2 组件系统(11 类 + 容器)
- 3.3 编辑模式(行为语义)
- 3.4 隐藏与显示规则(完整判定)
- 3.5 多显示器、停靠与点击穿透
- 3.6 时间状态机与跨天(课程计算的唯一事实源)
- 3.7 课程表组件的行为(重点)
- 3.8 提醒队列与优先级(行为)
- 3.9 通知两层 UI(气泡 + 强提醒)
- 3.10 天气展示(行为)
- 3.11 主题语义(Fluent vs Classic 的差异)
- 3.12 动画语义总则(实现时必须遵守)
- 3.13 本轮不实现的 ClassIsland 能力(必须写进实现说明,不得遗漏标注)
- 3.14 验收标准(行为级)
- 4. 目标架构与文件级改造清单
- 4.0 重构原则(必须逐条遵守)
- 4.1 目标模块边界与目录
- 4.2 文件级 Keep / Change / Add / Remove 总表
- 4.3 关键文件改造详情(逐个文件:现状 → 目标 → 接口 → 风险)
- 4.4 IPC 契约 v2(增量、不改旧通道语义)
- 4.5 迁移与门面的落地顺序(必须按序提交)
- 4.6 风险清单与强制缓解
- 4.7 验收标准(第 4 部分)
- 5. 数据契约、schema v2 与配置迁移
- 5.0 总则(必须遵守)
- 5.1 落点与版本治理
- 5.2 `appearance` v2(`config.json.island`)
- 5.3 `islandLayout` v2(`config.json.islandLayout`)
- 5.4 v1 → v2 迁移算法(必须逐步实现,可重入)
- 5.5 校验与夹取(`normalizeIslandLayoutV2` / `normalizeAppearanceV2`)
- 5.6 DTO 映射(ClassHelper 数据 → ClassIsland 语义)
- 5.7 IPC payload 版本与兼容
- 5.8 编辑器预览与临时状态(不持久化)
- 5.9 回滚与降级
- 5.10 版本变更记录与兼容矩阵
- 5.11 验收标准(第 5 部分)
- 6. 设计 tokens 与视觉基线(双风格:ClassIsland 默认 / winisland 兼容)
- 6.0 双风格模型(必须先建,再谈 token)
- 6.1 变量分层与命名规范
- 6.1.1 `--ci-*` 变量速查表(必须在 `tokens.css` 中一次性定义齐)
- 6.2 颜色 token 表
- 6.3 字体 token
- 6.4 圆角 token
- 6.5 几何与间距 token
- 6.6 阴影 token
- 6.7 透明与不透明度 token
- 6.8 层级(z-index)token
- 6.9 DPI 与缩放 token
- 6.10 深浅色 token
- 6.11 两套风格差异总表(默认选择点)
- 6.12 配置迁移与兼容策略(token 相关部分)
- 6.13 文件落点与实施顺序
- 6.14 常见错误与自查清单
- 6.15 验收标准
- 7. 岛结构与组件规格(ClassHelper 落地版)
- 7.0 改造口径与文件级改动清单
- 7.1 岛窗口与几何
- 7.2 行、背景、进度条、遮罩与淡出(ClassIsland 规格:custom 恒定 / fixed 默认)
- 7.3 外观族:`family='classisland'`(默认)与 `family='winisland'`(兼容,仅 fixed)
- 7.4 组件逐个规格(11 内置 + 3 消息入口 + 课件覆盖)
- 7.5 编辑器渲染侧结构(组件库 / 拖拽 / 选中框 / 行设置)
- 7.6 验收标准
- 8. 消息、提醒、课程提醒、强提醒特效、天气与课件覆盖(ClassHelper 落地规格)
- 8.1 数据契约(复用为主,最小扩展)
- 8.2 去重、合并与排序(ClassHelper 业务规则,必须保留)
- 8.3 队列与展示状态机(三形态对照 + 伪代码)
- 8.4 ClassIsland 风格气泡(`fixed` 默认形态,两阶段)
- 8.5 强提醒全屏特效(Topmost)
- 8.6 课程提醒五类(prepare / on-class / break / ending / progress)
- 8.7 下课前蓝色秒数徽标(重点规格)
- 8.8 课程进度条(两处,不得混用)
- 8.9 天气简报(Open-Meteo,保留 ClassHelper 语义)
- 8.10 课件传输覆盖(最高优先级内容)
- 8.11 教师端 / 班级端差异
- 8.12 冲突裁决表(ClassHelper 业务优先,ClassIsland 视觉优先)
- 8.13 边界与失败降级
- 8.14 验收标准(可勾选,全部通过才算完成)
- 9. 编辑器与设置页重构(IslandSettingsView / IslandEditorView / IslandComponentSettings)
- 9.1 口径与术语(与 parts-r/01、05、07 对齐)
- 9.2 文件级改造清单(只列本 part 负责的文件)
- 9.3 设置页信息架构(重构后)
- 9.4 视觉风格(family)与两套参数组
- 9.5 classisland 族外观参数(逐项)
- 9.6 winisland 族外观参数(兼容保留,不得删)
- 9.7 课程提醒设置(并入本页)
- 9.8 保存、实时预览与失败重试(设置页)
- 9.9 编辑器总体布局(像素级)
- 9.10 方案栏(保留并升级)
- 9.11 行与行设置(ClassIsland 编辑模式对齐)
- 9.12 组件库(11 类,保留既有 DOM 契约)
- 9.13 拖拽规格(三类源,必须保留现有实现)
- 9.14 选中、组件设置抽屉(四页签)
- 9.15 组件设置项清单(与 parts-r/07 组件契约一一对应)
- 9.16 预览与「进入编辑模式」
- 9.17 键盘、触摸与焦点
- 9.18 数据流、契约与失败处理
- 9.19 状态、空态、主题与性能约束
- 9.20 既有实现与测试契约(重构不得破坏)
- 9.21 验收标准
- 10. 动画与动效映射(ClassHelper 现状 → ClassIsland 目标;双风格默认值)
- 10.0 总则与不可协商的规则
- 10.1 ClassHelper 现状动画清单(重构前基线,必须保留语义)
- 10.2 映射总表(目标规格;每条给出双风格默认值)
- 10.2.1 消息气泡完整时间线(按毫秒对齐,验收时逐点核对)
- 10.3 冲突与选择点(必须按默认执行,并保留开关)
- 10.4 性能预算与降级矩阵
- 10.5 实现方式规范(逐条遵守)
- 10.6 逐文件改动清单
- 10.7 验收标准
- 11. 兼容性、性能、平台与测试基线
- 11.1 兼容矩阵(必须逐项通过)
- 11.2 配置迁移与回滚(硬要求)
- 11.3 IPC 与契约兼容
- 11.4 性能预算(超预算必须优化)
- 11.5 平台差异与窗口护栏
- 11.6 测试基线(无头优先)
- 11.7 观测与诊断
- 11.8 文档与交付说明
- 11.9 本部分验收标准
- 12. 验收清单(逐项勾选,不得跳过)
- 12.1 工程门禁
- 12.2 视觉验收(像素级)
- 12.3 功能验收
- 12.4 动画验收
- 12.5 兼容与迁移验收
- 12.6 无头与不打扰验收
- 12.7 性能验收
- 12.8 文档与交付物
- 12.9 禁止事项(验收时再次确认)
- 13. 重构里程碑(M0–M7)
- M0 基线固化与安全网(0.5–1 天)
- M1 共享层 v2:schema、常量、迁移、tokens(1–2 天)
- M2 渲染引擎:行/组件/tokens(2–3 天)
- M3 消息、课程提醒、强提醒、天气、课件(2–3 天)
- M4 设置页与编辑器重构(2–3 天)
- M5 动画、主题、细节打磨(1–2 天)
- M6 迁移、兼容与回归(1–2 天)
- M7 全量验收与交付(1–2 天)
- 里程碑风险提示
## 0. 给 ChatGPT 的任务声明(从本节开始执行)
### 0.1 你的任务
你是一名资深 Electron + Vue 3 + TypeScript 桌面工程师,熟悉透明置顶窗口、逐像素自绘 UI、CSS 动画与主进程/渲染进程协作。你的任务是在 **ClassHelper 现有 monorepo** 中重构灵动岛(island)子系统:
- 保留 ClassHelper 的工程结构、技术栈、服务端数据来源、既有业务规则与配置体系;
- 以 ClassIsland 的灵动岛为**视觉与行为基准**,重构 ClassHelper 的 `fixed`(经典)与 `custom`(自定义常驻)两套布局的渲染、交互、动画、设置与编辑器;
- 新增 `winisland` 兼容风格开关,使老用户可保留现有观感;默认使用 ClassIsland 风格;
- 不新建应用、不重写服务端/数据库/Web 管理端、不引入新的 UI 框架。
最终交付是**可合并进 ClassHelper 的代码改动 + 数据迁移 + 文档 + 无头测试 + 验收报告**,而不是示例代码或原型。
### 0.2 不可协商的技术与工程约束
1. 技术栈原样沿用:pnpm@11.8.0 monorepo;Electron ^44.3.0;Vue ^3.5.42;Element Plus ^2.14.5(仅设置/编辑器界面);Pinia ^4.0.3;Vue Router ^5.3.1;Vite ^8.3.0;TypeScript ~5.9.3;主进程/preload 用 esbuild,渲染层用 Vite。
2. 包边界不变:`packages/shared`(类型/常量/纯函数/岛 schema)、`packages/ui`(共享控件/tokens)、`packages/desktop-client`(Electron 主进程 + preload + 主窗口渲染 + 独立岛渲染 `island.html`);服务端 `packages/server` 与 Web 管理端 `packages/web-admin` **不在本次改动范围**。
3. 岛窗口仍是**独立透明 BrowserWindow + 独立 `island.html` 渲染入口 + 独立 island preload**;主窗口设置页只作为编辑器入口,编辑预览不创建额外桌面窗口。
4. 渲染层零 Node 权限:preload 仅暴露 contextBridge 白名单,不暴露 `ipcRenderer`;`nodeIntegration:false`、`contextIsolation:true`、`sandbox:true`。
5. 禁止 `setBackgroundMaterial()` / `vibrancy` / `backdrop-filter` 等会破坏逐像素透明的 API;透明、圆角、阴影全部由 CSS 自绘。
6. 数据来源不变:课表/时间点/通知/作业/叫人/课件仍来自 ClassHelper 服务端解析结果(`ScheduleDto`、`IslandTimePointDto`、`IslandState`、`IslandNotification` 等);不得引入 ClassIsland 的本地 Profile/ClassPlan 档案体系。
7. 配置仍写入 `config.json.islandLayout`(本机,不同步服务端);新增字段必须可迁移、可回滚,旧 `fixed`/`custom` 配置无损升级。
8. 既有硬约束必须保留:`winisland` 兼容形态下的连续圆角与投影常驻、触摸输入、失焦宽限、固定窗口几何护栏;所有形态下消息按 id 去重、通知/叫人双播不重复、课件传输优先、默认验证不创建可见岛或托盘、冒烟假执行不真关机。
9. 所有自动验证无头执行;不得修改验收标准来「通过」;未验证项必须标注。
### 0.3 交付物
- 代码:`packages/shared`、`packages/desktop-client`(main/preload/island/renderer)的改动;如涉及 `packages/ui` 的 token 复用也需提交。
- 数据迁移:`config.json.islandLayout` 的版本化迁移函数、旧配置升级、失败回滚路径;不得丢失用户的 schemes/rows/nodes/外观设置。
- 文档:更新 `docs/app/island.md` 与 `docs/dev/implementation.md` 的岛章节;新增本次重构的 `docs/dev/island-refactor.md`(架构、数据契约、动画表、迁移说明)。
- 测试:无头单元测试(schema 迁移、normalize、消息队列、课程状态机)+ 组件测试(岛渲染快照)+ 既有无头 `verify:desktop` 的适配;视觉验收仅在 `--visual` 下执行。
- 报告:第 12 部分验收清单逐项勾选;与 ClassIsland 的差异说明;未实现项与原因;性能数据(启动、空闲 CPU、内存、动画帧率)。
### 0.4 工作方式
1. 先通读本提示词与 ClassHelper 相关源码,输出:①你对现状与目标的理解复述;②文件级重构计划(含每个文件的改动类型、接口变化、迁移与回滚);③你识别出的歧义点(一次性提问);④M0 的无头验证方案。
2. 严格按第 13 部分的 M0–M7 顺序推进;每个里程碑结束都交付「可无头启动 + 通过该阶段验收 + 不回归既有功能」的状态。
3. 每个模块完成后写清:改了什么、证据(文件/行号/行为变化)、未完成/推断项、验证方法与结果。
4. 遇到本提示词未覆盖的细节:ClassHelper 既有行为优先(业务语义与既有交互),ClassIsland 规格优先(视觉、像素、动画);仍不明确时在代码与交付说明中标注【推断】。
5. 不得顺手重构无关模块;保持 diff 可审阅、可回滚。
### 0.5 术语表
- **灵动岛 / 岛(island)**:ClassHelper 的独立透明窗口与 `island.html` 渲染的悬浮界面。
- **fixed(经典)**:ClassHelper 现有「胶囊 + 详情卡」消息型布局。
- **custom(自定义常驻)**:ClassHelper 现有「多行 + 组件 + 组件方案」布局,常驻显示。
- **winisland**:ClassHelper 现有观感(WinIsland 风格:连续圆角、胶囊 268×44、纯黑/毛玻璃/主题色渐变);作为兼容风格保留。
- **ClassIsland 风格**:本重构的默认观感,按 ClassIsland 主界面的行/组件/像素/动画规格实现。
- **行(row / line)**:承载组件的横向容器(对应 ClassIsland `MainWindowLine`)。
- **组件(component / node)**:`IslandComponentNode`,最小显示单元(日期/时钟/天气/倒计时/课程表/文本/容器等)。
- **方案(scheme)**:`IslandLayoutScheme`,一套常态 `rows` 与无消息展开 `expandedRows` 的组件布局。
- **消息(message)**:通知/作业/叫人的统称(`IslandNotification`)。
- **强提醒(Topmost effect)**:ClassIsland 的全屏强调特效窗口;本次以 Electron 独立窗口实现,不抢焦点、不阻塞。
- **无头验证**:不创建可见窗口/托盘/声音的自动化检查(ClassHelper 的 `verify:desktop` 默认模式)。
### 0.6 阅读顺序
0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13。第 2 部分先建立现状地图,第 3 部分给出行为基准,第 4/5 部分给改造清单与数据迁移,第 6/7/10 部分给视觉、组件、动画规格,第 8/9 部分给消息与编辑器,第 11/12/13 部分给兼容、验收、里程碑。
---
## 1. 重构目标与范围
### 1.1 为什么重构、以什么为基准
ClassHelper 的灵动岛已经具备完整的工程骨架:独立透明窗口、`fixed`/`custom` 两种布局、多方案组件编辑器、消息队列、课程提醒、天气、课件覆盖。但它的视觉语言来自 WinIsland(连续圆角、小胶囊、弹簧形变、纯黑/毛玻璃),与 ClassIsland 的「Fluent 信息行 + 组件化 + 提醒气泡 + 强提醒特效 + 精细过渡」存在系统性差距。本次重构以 **ClassIsland v2.1 的灵动岛(MainWindow / MainWindowLine / Components / Notification / TopmostEffect)为行为与视觉基准**,把 ClassHelper 的岛升级为同等精细度,同时保留 ClassHelper 的工程结构、数据来源与业务规则。
你必须记住三条基准优先级:
1. **ClassHelper 业务语义优先**:消息去重、优先级、上课暂存、紧急展开、叫人胶囊、已读回执、班级设备、课件传输优先等规则不得改变;
2. **ClassIsland 视觉与动画优先**:像素、字号、圆角、间距、颜色、动画时长与曲线以本提示词第 6、7、10 部分为准;
3. **ClassHelper 既有工程与配置不得推翻**:pnpm monorepo、独立岛渲染进程、`config.json.islandLayout`、`IslandComponentNode`/`IslandLayoutScheme` 数据模型、编辑器入口、无头验证纪律继续有效。
### 1.2 目标状态(重构完成后用户看到什么)
1. **fixed(经典)默认变成 ClassIsland 风格的课表信息行**:屏幕顶部/底部悬浮一条或多条信息行(默认 1 行:日期 + 课程表;行高 40、圆角 8、背景不透明度 0.5、字号 14/16/18/20、底部 3px 进度条),显示当天课程、当前课程、剩余分钟/倒计时;不再是 268×44 的小胶囊。
2. **消息到来时的表现**:普通通知/作业在岛内展开为 ClassIsland 风格的气泡(行内遮罩 + 内容两阶段:遮罩扫入 360ms + 错峰 120ms,文字延迟 260ms 淡入并 1.1→1.0 缩放 750ms),紧急消息立即展开;保留 ClassHelper 的上课暂存、普通叫人可点胶囊、紧急立即展开等规则。
3. **强提醒**:可配置的 ClassIsland 强提醒(Topmost 特效)在岛所在显示器全屏/大区域显示,超时自动关闭,不抢焦点、不阻塞操作;默认仅对"强调提醒"启用。
4. **custom(自定义常驻)**:保留现有方案/行/组件/编辑器,但渲染与交互升级为 ClassIsland 规格:行高 40、行间距 5、组件外边距 6/6、行设置抽屉、选中框 1.5px 虚线、拖拽把手、隐藏规则、行/组件外观覆盖、进入编辑模式(实际内容预览)、退出保存。
5. **winisland 兼容风格(只作用于 fixed)**:`appearance.family = 'winisland'` 时保留现有经典 fixed 观感(连续圆角、胶囊 268×44、纯黑/毛玻璃/主题色渐变、弹簧形变);`custom` 不随 family 变化,始终使用 ClassIsland 规格(其编辑器/行/组件结构保持不变,只按 07 细化像素)。默认新装 `classisland`,老用户升级迁移为 `winisland`。
6. **设置与编辑器**:灵动岛设置页新增风格选择、ClassIsland 外观参数(字号阶梯、行间距、进度条、强提醒开关、主题 Fluent/Classic 选择);自定义方案编辑器增加"编辑模式"与行设置抽屉;所有改动即时保存、失败可重试、退出不丢改动。
7. **数据与兼容**:旧 `config.json.islandLayout` 无损迁移;用户的 schemes/rows/nodes、外观值、编辑器习惯全部保留;迁移失败可回滚;旧版本客户端读到新配置时按"忽略未知字段 + 默认值"处理(或明确拒绝并提示升级)。
### 1.3 范围(In / Out)
**必须做(In)**:
- 岛窗口与渲染层:`island.html`、`LayoutIslandApp.vue`、`IslandApp.vue`、`CustomIslandRuntime.vue`、`ComponentNode.vue`、`ClassicNotifications.vue`、`LessonReminderContent.vue`、`CoursewareTransfer.vue`、`spring.ts`、`squircle.ts`、各 CSS。
- 主进程岛控制器:`src/main/island.ts`(窗口几何、命中、触摸、消息队列、课程提醒、天气、课件、编辑预览、保存配置、预览/应用)。
- preload 与 bridge:`src/preload/island.ts`、`src/renderer/island/bridge.ts`、`src/types/desktop.d.ts` 的岛契约。
- 共享层:`packages/shared/src/{island-layout,constants,motion}.ts` 的 schema、常量、归一化、迁移。
- 设置/编辑器:`IslandSettingsView.vue`、`IslandEditorView.vue`、`IslandComponentSettings.vue`、`IslandEditorNode.vue`、`island-editor.css`,以及 `router` 中岛编辑路由的入口。
- 配置:`config.json.islandLayout` 版本化迁移、原子写、回滚。
- 文档与无头测试。
**不做(Out,最多在交付说明中标注为后续)**:
- 不重写服务端/数据库/Web 管理端;不改变服务端接口字段与权限。
- 不引入 ClassIsland 的 Profile/TimeLayout 本地档案、插件系统、自动化系统、管理端、主题市场、Excel 导入、更新器。
- 不引入新框架/新 UI 库/新状态库;不更换构建管线。
- 不把岛改成多窗口;仍只有一个原生岛窗口 + 一个可选的强提醒特效窗口。
- 不改动主窗口(设置/作业/成绩/通知中心)布局,除编辑器入口与预览联动外。
### 1.4 模块级 Keep / Change / Add / Remove
| 模块 | 处理 | 说明 |
|---|---|---|
| 岛窗口创建/几何/命中/触摸/失焦宽限 | **Keep + Change** | 保留固定包围盒与透明窗口护栏;按 ClassIsland 行高/宽度/停靠语义重算几何;fixed 从胶囊尺寸改为信息行尺寸;custom 保持测量适配但按 ClassIsland 行模型约束 |
| `IslandApp.vue`(经典) | **Change(大改)** | 从"胶囊+详情卡"重构为"ClassIsland 信息行 + 通知气泡 + 进度条";保留消息状态机与紧急/上课规则 |
| `CustomIslandRuntime.vue`(自定义) | **Change** | 保留测量/缩放/行渲染;行高、行距、组件边距、背景、圆角、进度条、选中框、拖拽按 ClassIsland 规格;编辑模式重做 |
| `ComponentNode.vue` / 组件库 | **Change + Add** | 11 类组件的默认样式/尺寸/字号/空数据按 ClassIsland 规格;新增进度条/倒计时徽标/课程时间点展开等子结构 |
| `ClassicNotifications.vue` | **Change** | 气泡两阶段(斜切遮罩/文字/缩放)与 ClassIsland 参数对齐;保留 ClassHelper 消息类型/排序/回执 |
| `LessonReminderContent.vue` | **Change** | 课程提醒条/倒计时徽标按 ClassIsland 布局与字号;下课前蓝色圆形秒数徽标(含 `-1`) |
| `CoursewareTransfer.vue` | **Keep** | 覆盖优先逻辑不变,只按新视觉加外壳/动画 |
| `spring.ts` / `squircle.ts` | **Keep(条件使用)** | ClassIsland 风格默认用 CSS transition + 标准圆角;winisland 风格继续用弹簧/连续圆角;不得把弹簧用于 ClassIsland 规格动画 |
| `main/island.ts` | **Change** | 窗口几何、状态模式、消息队列、课程提醒、天气、编辑预览按新交互调整;新增强提醒窗口控制、编辑模式、迁移调用;保留单实例/恢复/限流 |
| `shared/island-layout.ts` | **Change(v2)** | 新增 `version`、风格/主题/强提醒/进度条等字段;新增 ClassIsland 组件设置字段;normalize/migrate;旧配置无损升级 |
| 设置页/编辑器 | **Change + Add** | 新增风格切换、ClassIsland 外观项、编辑模式、行设置抽屉;保留方案/行/组件/导入导出 |
| 文档/测试 | **Change + Add** | 更新 docs;新增无头测试;`--visual` 保持可选 |
### 1.5 关键设计裁决(必须逐条执行)
1. **固定形态风格双轨、默认 ClassIsland**:`appearance.family: 'classisland' | 'winisland'` 只决定 **fixed** 的形态(信息行 vs 旧胶囊);`custom` 始终 ClassIsland 规格。新装默认 `classisland`;旧用户升级保持 `winisland`(避免 fixed 观感突变);设置页在 fixed 下提供切换并即时预览。
2. **fixed 重定义**:fixed 不再是"小胶囊消息岛",而是 ClassIsland 风格信息行(默认 1 行,日期+课程表;消息时行内气泡)。原胶囊形态只在 `winisland` 风格下保留。
3. **custom 保留数据模型**:schemes/rows/nodes/expandedRows 不变,只扩展字段与渲染;已有用户方案无需重做即可获得新视觉。
4. **数据不迁移**:继续消费 ClassHelper 的课表/通知/作业/叫人/课件数据;ClassIsland 的 Lesson/Notification 语义只作为行为参考。
5. **强提醒是新增窗口而非替换岛**:ClassIsland 的 TopmostEffect 以独立透明窗口实现(无边框、置顶、不抢焦点、超时关闭);只在消息的"强调提醒"开关开启时出现;编辑/预览模式不创建。
6. **配置版本化**:`islandLayout.version`(整数,当前 2);`normalizeIslandLayouts()` 先迁移再归一化;迁移前备份 `config.json`,失败回滚并提示。
7. **验证无头**:所有自动验证必须后台执行;`--visual` 才允许可见岛、动画、截图验收;不得为验收创建真实班级/发送测试通知/重置密码。
### 1.6 成功标准(重构完成的定义)
- **视觉**:第 6/7 部分的像素值在 1920×1080@100%、1080p@125%/150%、深/浅色、ClassIsland Fluent/Classic 两套主题、ClassHelper winisland 风格下逐项对齐(容差:颜色 ±2/255,尺寸/位置 ±1px,透明度 ±1%)。
- **动画**:第 10 部分动画表逐项实现(时长误差 ≤ 16ms、曲线一致);60fps;减少动画/失焦/隐藏页暂停生效。
- **功能**:消息/课程提醒/天气/课件/编辑器/设置/迁移全部可用;无回归(见第 12 部分)。
- **兼容**:旧配置升级成功率 100%(本仓库 fixtures 内);回滚可用;旧字段 `residentEnabled` 继续兼容。
- **性能**:岛渲染进程启动 ≤ 500ms;空闲 CPU ≤ 1%;主+岛进程内存 ≤ 250MB;消息风暴 20 条/分钟无卡顿/丢失/重复。
- **纪律**:`verify:desktop` 默认不创建可见岛/托盘;无 GUI 打扰。
### 1.7 主要风险与对策
| 风险 | 对策 |
|---|---|
| 透明窗口改尺寸导致闪烁 | 固定包围盒 + 窗内形变(保留现有策略);`setBounds` 仅模式/设置/显示器变化且节流 ≥100ms |
| fixed 重定义导致老用户不习惯 | `winisland` 风格保留 + 升级默认沿用旧风格 + 设置页可切换 |
| 消息状态机重构引入竞态 | 先写状态机单测(去重/优先级/暂存/紧急/回执),再改 UI;所有状态变更走单一入口 |
| 编辑模式拖拽与触摸冲突 | 编辑模式暂停指针避让/消息展开;触摸与鼠标分别走命中/宽限路径 |
| 配置迁移破坏用户方案 | 迁移前备份;schema 校验 + 归一化;失败回滚;fixtures 覆盖 fixed/custom/旧版字段 |
| 多显示器/DPI 偏差 | 全部使用 DIP;屏幕变化事件驱动重算;截图验收用 devicePixelRatio 换算 |
| 动画掉帧 | 只用 transform/opacity;弹簧仅限 winisland;长任务移出主线程;帧率监控 |
### 1.8 阅读顺序提示
- 先读第 2 部分(ClassHelper 现状)与第 3 部分(ClassIsland 行为基准),确认"保留什么、改什么"。
- 第 4/5 部分是施工图与数据迁移,必须在写代码前读完。
- 第 6/7/10 部分是像素与动画的硬规格,逐条落实。
- 第 8/9 部分处理消息、课程、强提醒与编辑器。
- 第 11/12/13 部分是兼容、验收与里程碑,按顺序执行。
---
## 2. ClassHelper 灵动岛现状基线(重构的"现状地图")
### 2.0 阅读与使用方式
1. 你在重构前必须先读懂本部分。本部分不是评价现状好坏,而是**逐文件、逐行号**给出"现在到底是什么样"。重构的每一处改动都必须能在本部分找到对应的现状依据。
2. 本部分所有行号以 `repos/ClassHelper-master/` 为工作区相对根,写作 `packages/desktop-client/...:行号`;与 ClassIsland 对照时写 `repos/ClassIsland/...:行号`。行号是**行区间起点**,读源码时按主题名检索同样有效。
3. 现状基线里出现的所有数值(尺寸、时长、阈值、限制)都是**重构时必须先保持、再按新规格替换**的对象;不得因为"要改成 ClassIsland 风格"就整体删除,至少要先在迁移层保留等价语义。
### 2.1 进程、窗口与装配全景
灵动岛是**独立渲染进程 + 独立透明 BrowserWindow**,与主窗口并存,不占任务栏:
- 主进程在 `app.whenReady()` 里 `registerIpcHandlers()`、`registerIslandIpc()`,随后调用 `island.init(devUrl ? `${devUrl}/island.html` : null)`;生产环境加载 `dist/renderer/island.html`。依据:`packages/desktop-client/src/main/index.ts:188-221`、`packages/desktop-client/src/main/island.ts:613-617`。
- 主进程把三件事接给灵动岛:`island.setOpenAppHandler`(打开应用)、`island.setEditLayoutHandler`(打开编辑器)、`island.setMarkReadHandler/setMarkAllReadHandler`(把"标为已读"转给主窗口渲染进程)。依据:`src/main/index.ts:221-245`。
- 托盘菜单提供「编辑灵动岛布局」,调用 `island.editLayout()`;退出清理时 `island.destroy()`。依据:`src/main/tray.ts:42`、`src/main/index.ts:304-307`。
- 灵动岛窗口创建参数(重构后仍必须保留这些窗口级语义):`frame:false`、`transparent:true`、`resizable:false`、`movable:false`、`minimizable:false`、`maximizable:false`、`fullscreenable:false`、`skipTaskbar:true`、`hasShadow:false`、`focusable:true`、`alwaysOnTop:true`、`type:'toolbar'`,`webPreferences` 带独立 `preload`(`../preload/island.js`)、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`src/main/island.ts:480-512`。
- 创建后设置 `setAlwaysOnTop(appearance.alwaysOnTop,'screen-saver')`、`setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true})`、拒绝所有新开窗口与非法导航、默认 `setIgnoreMouseEvents(true,{forward:true})`。依据:`src/main/island.ts:514-527`;导航白名单实现见 `:32-50`。
- **严禁**调用 `setBackgroundMaterial()`。现状代码在 `src/main/island.ts:1726-1748` 有整段踩坑说明:一旦调用(包括 `'none'`)窗口就不再逐像素透明,卡片外圈会永久留白底;`glass` 风格因此改用 CSS 半透明色 `rgba(32,32,36,0.804)` 降级(`src/island/classic-island.css:71-77`)。
### 2.2 主进程岛控制器分区地图(`src/main/island.ts`,共 2445 行)
这是本轮重构最需要拆分的文件。按现状行区间整理如下(ChatGPT 必须按此表定位旧实现):
| 行区间 | 现状职责 | 关键符号 |
|---|---|---|
| 1-130 | 导入、开发/生产 URL 白名单、窗口尺寸基线与形态增量、收回动画时长、各状态超时表 | `prodIslandUrl`、`isAllowedIslandNavigation`、`BASE`、`ISLAND_SIZE_DELTA`、`CLOSE_ANIMATION_MS=360`、`TIMEOUTS` |
| 134-166 | 对外载荷/动作类型 | `IslandPushContext`、`IslandClassStatePayload`、`IslandActionName` |
| 168-460 | `IslandController` 字段与 getter:课程提醒、计时器、窗口、状态、外观、触摸/常驻/编辑、命中、预览、心跳、恢复 | `lessonCue`、`collapseTimer`、`closeTimer`、`fadeToken`、`touchMode`、`residentShapeTimer`、`previewId`、`recoverAt` |
| 468-634 | `init()`:恢复外观、算尺寸、建窗、导航限制、失焦/无响应/心跳监听、加载页面、首帧同步 | `init`、`render-process-gone`、`unresponsive`、`console-message` |
| 636-680 | 窗口/课表/时间点读取、状态组装、课件状态、就绪判断 | `getWindow`、`getState`、`setCoursewareTransfer` |
| 684-767 | 消息语义:立即展开判定、可点开判定、双播合并、待处理排序、列表布局 | `isImmediate`、`isOpenable`、`mergeNotification`、`pendingNotifications`、`listLayout` |
| 770-915 | 视口高度、收到通知的分流、上课/下课状态迁移、下课补弹 | `refreshViewport`、`pushNotification`、`setClassState`、`flushQueueAfterClass` |
| 929-1077 | 用户动作状态机:展开/收起/展开更多/知道了/整批/标已读/打开应用 | `handleAction` |
| 1079-1214 | 隐藏、收回动画、立即隐藏、销毁、移除通知 | `hide`、`beginClose`、`finishClose`、`hideImmediately`、`destroy`、`removeNotification` |
| 1216-1322 | 关闭/激活/收起定时/清理/预览收尾 | `dismissActive`、`clearPendingForClose`、`activate`、`scheduleCollapse`、`clearTimers` |
| 1324-1410 | 锚点与目标屏、换屏重排、状态落地与推送 | `resolveAnchor`、`targetDisplay`、`syncAnchorDisplay`、`setState`、`emit` |
| 1412-1512 | 可见性/焦点/透明度总协调(含上课隐藏守卫) | `syncWindow` |
| 1514-1648 | 固定包围盒/触摸贴合/重排/窗口矩形 | `windowBox`、`syncTouchLayout`、`relayout`、`applyWindowLayout`、`windowBounds` |
| 1650-1724 | 焦点策略、淡入淡出、取消几何动画 | `applyFocusable`、`fadeIn`、`fadeOut`、`cancelBoundsAnimation` |
| 1749-1783 | 外观热更新 | `setAppearance` |
| 1785-1910 | 命中开关、岛体矩形、常驻控件/触摸、鼠标避让淡化、触摸模式 | `setInteractive`、`setHitRect`、`updateResidentPointer`、`setTouchMode` |
| 1912-2047 | 冒烟注入点、坐标换算、兜底岛体矩形、岛体命中判定 | `setTestInputPassthrough`、`setHitTestCursor`、`cursorLocal`、`expectedHitRect`、`isCursorOnIsland`、`syncHitFromCursor` |
| 2049-2157 | 60ms 命中轮询、渲染心跳、窗口自愈(10 分钟 3 次) | `startHitPoll`、`checkRendererAlive`、`recoverWindow` |
| 2159-2184 | 冒烟读取器(交互/窗口/外观) | `getInteractive`、`getWindowBounds`、`getAppearance`、`emitAppearance` |
| 2186-2264 | 尺寸换算、细缝尺寸、外观夹紧 | `recomputeSizes`、`getSliverSize`、`normalizeAppearance` |
| 2266-2334 | 留白/失焦宽限/预览时长/恢复限流/接口类型 | `ISLAND_MARGIN=8`、`BLUR_GRACE_MS=500`、`BLUR_GRACE_TOUCH_MS=1200`、`PREVIEW_DURATION_MS=30000`、`MIN_CARD_HEIGHT=120`、`RENDERER_ALIVE_TIMEOUT_MS=20000` |
| 2335-2445 | 单例导出与 IPC 注册 | `island`、`registerIslandIpc`、`ISLAND_SIZES/URGENT/CALL/TIMEOUTS` 再导出 |
1. 重构时**不得**在这个文件里继续堆逻辑。你必须按 04 部分的模块边界把它拆成 `island/` 目录(controller/window/hit/touch/messages/lessons/weather/courseware/ipc),并保留 `src/main/island.ts` 作为再导出门面(`export { island, registerIslandIpc } from './island/index.js'`),以免一次性改动 `main/index.ts`、`tray.ts`、`courseware.ts`、`smoke.ts` 的调用点。
### 2.3 窗口几何:固定包围盒 + 岛内形变
现状采用「窗口一次建大、岛在画布内形变」的模型(照搬 WinIsland):
- 形态基线:胶囊 `216×34`,展开 = 胶囊 + `ISLAND_SIZE_DELTA.expanded`,紧急/叫人各再加大。依据:`src/main/island.ts:72-96`、`packages/shared/src/constants.ts:344-351`。
- `recomputeSizes(appearance)` 按用户可调宽高联动所有形态,并计算 `BBOX_SIZE = 最大形态 + 2×ISLAND_SHADOW_PAD`(留白 10)。依据:`src/main/island.ts:2187-2215`、`packages/shared/src/constants.ts:351`。
- 普通(鼠标)模式窗口尺寸恒为包围盒;触摸模式窗口贴合当前形态;列表形态允许窗口先长高。依据:`src/main/island.ts:1521-1553`。
- 窗口位置由锚点推出:居中用 `center - round(width/2)`,左/右贴边留 `pad`,顶/底同理;自定义模式再夹到工作区内。依据:`src/main/island.ts:1615-1648`。
- 渲染进程侧同样维护一套弹簧目标尺寸:胶囊取外观宽高,展开/紧急/叫人取增量,细缝取 6px 宽。依据:`src/island/IslandApp.vue:188-204`、`packages/shared/src/constants.ts:336`。
- 连续圆角:`squirclePath(w,h,r)` 用超椭圆指数 4.2、每角 14 段;胶囊圆角取 `min(r, h/2)`,展开取 `min(48*r/20, w/2, h/2)`。依据:`src/island/squircle.ts:21-24,44-81`、`src/island/IslandApp.vue:206-212,343-344`。
- 弹簧:`SpringValue` 默认 `stiffness=0.10`、`damping=0.68`、`snapThreshold=0.05px`、换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%、`MAX_DT=6` 帧;渲染进程按 `delta * appearance.speed` 缩放帧时间。依据:`src/island/spring.ts:26-114`、`src/island/IslandApp.vue:287-332`。
### 2.4 命中、穿透、触摸与"点不动"防护
现状把"命中"当成一级问题处理,重构必须原样保留这些防护:
1. 窗口默认整块穿透(`setIgnoreMouseEvents(true,{forward:true})`),由渲染进程 `mousemove` 命中后上报 `island:set-interactive(true)`;**关闭命中一律由主进程按真实光标位置决定**(双向下发会让窗口在穿透/接收之间抖动,表现为"点了没反应")。依据:`src/main/island.ts:2412-2418`、`src/preload/island.ts` 的 `setInteractive`、`src/island/IslandApp.vue:456-487`。
2. 主进程 60ms 轮询读系统光标,并与渲染进程上报的岛体矩形比对来校正命中(Windows 下 mousemove 转发不可靠)。依据:`src/main/island.ts:2049-2078,2026-2047`。
3. 岛体矩形由渲染进程限流上报(最小间隔 50ms,尾随保证最后一次送达)。依据:`src/island/IslandApp.vue:412-454`。
4. 触摸屏:渲染进程按 `navigator.maxTouchPoints > 0` 或首次 `touchstart` 上报 `setTouchMode(true)`;主进程据此让窗口**贴合岛体**并始终接收输入,且命中判定的入参被强制为 true。依据:`src/island/IslandApp.vue:538-549`、`src/main/island.ts:245-257,1795-1822,1891-1902`。
5. Windows 专属 `setShape` 用于触摸模式下把窗口裁成岛体+8px 留白;**非 Windows 平台没有该能力**,重构必须按 04 的降级方案处理(整窗穿透 + 岛可见区命中),不得依赖 `setShape` 才能点。依据:`src/main/island.ts:278-316`。
6. 失焦收起:展开态获取焦点,点到别处 `blur` → 回缩胶囊;但必须有两层防护——指针仍在岛上则不收起;刚展开的宽限期内不收起(鼠标 500ms、触摸 1200ms)。依据:`src/main/island.ts:557-578,2287-2297,2020-2023`。
7. 窗口「始终可激活」(`focusable:true`),收起时用 `blur()` 而不是关掉可激活性——Windows 下 `WS_EX_NOACTIVATE` 会丢 `mousedown`,胶囊就点不动。依据:`src/main/island.ts:1655-1672`。
### 2.5 消息状态机(fixed 经典模式)
1. 状态载体 `IslandState`:`mode: 'hidden'|'pill'|'expanded'`、`active`、`queued`、`inClass`、`currentPeriodEnd`、`reason`、`listExpanded`、`maxCardHeight`、`lessonReminder`、`coursewareTransfer`、`updatedAt`。依据:`packages/shared/src/types.ts:1198-1230`。
2. 分流规则(必须作为重构的行为基线):紧急(`priority==='URGENT'`)立即展开、无视上课;上课期间普通通知只入队并隐藏;上课期间**普通叫人**保持胶囊可点、不自动展开;上课期间用户主动点开的通知允许显示(`explicitShowId`);预览模式(设置页"预览效果")直接展开、失焦不收起、30 秒自动收尾。依据:`src/main/island.ts:684-702,794-867,2338-2380`。
3. 双链路合并:同一 id 经 `notification:new` 与 `call:new` 双播时必须合并——类型取"更特殊"(call 优先)、优先级取更高,避免叫人被降级成普通通知。依据:`src/main/island.ts:704-724`。
4. 排序:按优先级权重(`PRIORITY_RANK`:LOW 0 / NORMAL 1 / HIGH 2 / URGENT 3)降序,同级按 `createdAt` 倒序。依据:`packages/shared/src/constants.ts:183-209`、`src/main/island.ts:726-749`。
5. 超时表(毫秒):胶囊 15000、下课后展开 14000、下课后胶囊 25000、展开 20000、紧急 45000、叫人 90000;关闭动画 360;收回时先切胶囊、待动画后再淡出隐藏。依据:`src/main/island.ts:121-132,1106-1128`。
6. 收起的判定:只要还有未处理消息(当前或队列)就保持胶囊;队列清空/用户点了知道了/标为已读/进入上课才彻底隐藏。依据:`src/main/island.ts:1266-1296`。
7. 多条消息:展开态且待处理 ≥2 时进入列表形态;默认显示 3 条 + 「展开更多(还有 N 条)」;放不下时提示"更多请前往应用内操作";卡片高度由共享 `islandListLayout()` 计算,主进程与渲染进程必须算同一个数。依据:`src/main/island.ts:755-765,770-785`、`packages/shared/src/constants.ts:379-471`、`src/island/ClassicNotifications.vue:34-58`。
8. 动作:`expand`、`collapse`、`expand-list`、`dismiss`、`dismiss-all`、`mark-read`、`mark-all-read`、`open-app`;其中 `mark-read/mark-all-read` 通过主进程回调转给主窗口通知中心;作业卡 id 是本地合成 `homework-<id>`,整批已读时必须排除。依据:`src/main/island.ts:150-166,1044-1070`、`src/renderer/island/bridge.ts:191-211`。
9. 预览/冒烟注入口:`preview`、`setTestInputPassthrough`、`setHitTestCursor`、`setBlurCollapseEnabled`、`setTouchMode`、`getWindowBounds`、`getHitRect`、`getInteractive`、`isRecovering` 等 **重构后必须继续存在**(大量冒烟断言依赖),不得改名或删除。依据:`src/main/island.ts:187-190,416,927-1930,2154-2183`。
### 2.6 经典模式渲染(`src/island/IslandApp.vue`,683 行)
1. 卡片结构:`.island-root > .island-card > svg.shape + span.content >(tint + pill-layer + ClassicNotifications + LessonReminderContent)`。依据:`src/island/IslandApp.vue:573-681`。
2. 内容交叉淡入:胶囊层 `1 - progress*1.5` 退净(进度 2/3 处),展开层 `progress²` 进入;`progress` 由高度差推导。依据:`:304-309,346-351`。
3. 胶囊标题按类型:叫人→"老师叫你"、作业→"新作业"、紧急→"紧急通知"、普通→"新消息";多条时汇总为「新消息:叫人/作业/通知(共 N 条)」。依据:`:115-146`。
4. 停靠:`data-anchor` 决定内容贴边(左贴左/右贴右/居中);居中用 `left:50% + translateX(-50%)`,左右/上下停靠留 `ISLAND_SHADOW_PAD`。依据:`:353-383`、`src/island/classic-island.css:207-233`。
5. 形状与投影:路径 `fill: var(--wn-bg)` + 1px 描边;投影**始终声明**、只在展开态改参数(避免加/去 filter 造成开合闪一帧)。依据:`src/island/classic-island.css:102-153`。
6. 状态色:紧急红 `#ff453a`、叫人橙 `#ff9f0a`、作业用 accent;只做内层径向微染 + 描边,不外溢。依据:`src/island/IslandApp.vue:151-157`、`classic-island.css:155-185`。
7. 内容层裁剪:`.content{clip-path:var(--card-path)}`;圆角路径由 SVG `path()` 注入 CSS 变量 `--card-path`。依据:`classic-island.css:127-133`、`IslandApp.vue:363-371`。
8. 轮廓保护:`island-root.embedded` 用于设置页内嵌预览(去掉绝对定位)。依据:`classic-island.css:41-47`、`IslandApp.vue:578-581`。
### 2.7 自定义布局(custom)运行时与数据
1. 数据根:`config.json.islandLayout`,默认 `layout:'fixed'`;`residentEnabled` 仅作兼容字段,`custom` 即常驻。依据:`packages/desktop-client/src/main/config.ts:26-36,198-233`、`packages/shared/src/island-layout.ts:131-139,384-403`。
2. 组件方案 `IslandLayoutSettings`:`layout`、`residentEnabled`、`pointerFadeEnabled/Reversed`、`lessonReminders`、`activeSchemeId`、`schemes[]`。依据:`packages/shared/src/island-layout.ts:131-150`。
3. 方案 `IslandLayoutScheme`:`id/name/rows/expandedRows/scale/color/foreground/opacity/radius/separated/spacing`;`rows` 为常态行、`expandedRows` 为"无消息时点击展开"的内容。依据:`island-layout.ts:109-130`。
4. 行 `IslandLayoutRow`:`id/name/main/allowNotifications/gap/nodes`;`main` 行唯一(normalize 会强制只留一个)。依据:`island-layout.ts:109-116,334-356`。
5. 节点 `IslandComponentNode`:`id/kind/settings/appearance/advanced/children`;`kind` 14 类(`date/clock/weather/countdown/schedule/text/separator/slide/rolling/group/stack/notification/homework/call`)。依据:`island-layout.ts:3-18,77-108`。
6. `settings`:文本/模板、12 小时制、秒、倒计时目标与目标日、地点/经纬度、显示模式(当前/下一节/今日)、显示数量、显示起止时间、显示进度、排列方向、间距、轮播间隔、滚动速度。依据:`island-layout.ts:80-99`。
7. `appearance`:资源覆盖开关、四档字号、前景/背景/透明度/圆角覆盖、整体透明度;`advanced`:隐藏规则(`in-class/no-message/no-class`)、对齐、固定宽/最大宽。依据:`island-layout.ts:61-76,100-107`。
8. 归一化边界(重构后仍必须保留的硬边界):最多 12 行、200 节点、6 层、方案 ≤20;颜色必须 `#rrggbb`;非法 kind 丢弃;重复 id 重命名;`main` 行唯一。依据:`island-layout.ts:256-403`(尤其 `:269,337,358`)。
9. 自定义运行时:先测量自然内容(compact/details/lesson 三份),再按工作区与方案 `scale` 适配;两个页面层(常态/详情)交叉淡入,第三层承载课程提醒;铃铛显示未处理数(>99 显示 `99+`)。依据:`src/island/CustomIslandRuntime.vue:58-124,238-323,338-527`。
10. 鼠标避让淡化:常驻模式按指针是否在岛内切换透明度到 `CLASSISLAND_ISLAND_METRICS.fadedOpacity=0.05`(可反转);触摸/编辑/课程提醒期间暂停。依据:`src/main/island.ts:1851-1877`、`island-layout.ts:35`。
11. 编辑布局时窗口 `interview` 行为:`editingLayout` 下不参与命中轮询、不自动收起;`island:edit-layout` 与托盘入口都走 `editLayout()`。依据:`src/main/island.ts:350-356,1430-1433`。
12. 常驻窗口几何:窗口固定为 `min(960, 工作区宽) × min(640, 工作区高 - marginY)`,内容在画布内弹簧形变;**常驻开合期间窗口不做逐帧 setBounds**。依据:`src/main/island.ts:1522-1528`、`CustomIslandRuntime.vue:214-216`。
### 2.8 课程提醒与课程组件
1. `LessonReminderTracker` 只用服务端解析后的 `ScheduleDto` 计算五类提醒:准备(默认提前 60s)、上课、课间、下课前倒数(默认 60s)、课中进度;每种提醒的进入/退出时间由 `maskUntil/endsAt` 表达。依据:`src/main/lesson-reminder.ts:14-110`、`packages/shared/src/island-layout.ts:140-158`。
2. 主进程每秒刷新一次提醒;**自定义布局下 `in-class` 提醒不接管岛体**(课程组件持续显示进度),`ending` 提醒只作为课程名右侧秒数徽标。依据:`src/main/island.ts:171-213`、`docs/app/island.md:65-70`。
3. 提醒条 UI:高 40、圆角 8、底色 `#20202080`;5 个斜切区域(30° 偏移)覆盖式打开/关闭,区段 360ms、错峰延迟 `[0,90,120,90,0]`(开)/反向(关);开 `1-(1-p)^4`、关 `p²`;文字延迟 260ms、scale 1.1→1.0、750ms;进度条 2px、`transform 50ms linear`。依据:`src/island/LessonReminderContent.vue:59-123,133-137,195-317`。
4. 经典模式临近下课用课程名右侧蓝色圆形徽标显示剩余秒数(剩余 1 秒显示 `-1`),不显示"即将下课"遮罩。依据:`docs/app/island.md:68-70`、`CustomIslandRuntime.vue:30-33`、`ComponentNode.vue:259-266`。
5. 关闭"即将下课倒数"后不得显示徽标;阈值沿用本机提醒设置。依据:`docs/app/island.md:69`、`lesson-reminder.ts:54-60`。
### 2.9 天气现状
1. 天气只在主进程抓取(固定服务、固定参数、不接受渲染层传 URL):`api.open-meteo.com/v1/forecast`,取 `temperature_2m/weather_code/wind_speed_10m`,8 秒超时、`redirect:'error'`、响应 ≤64KB;成功缓存 15 分钟,失败保留旧值并标 `stale`/退避 60 秒,缓存最多 32 个坐标。依据:`src/main/island-weather.ts:1-73`。
2. 渲染层 `WeatherBrief.vue` 每分钟刷新一次;天气码映射文案(晴/少云/多云/阴/雾/毛毛雨/雨/雪/雷雨);断网或陈旧时显示"· 缓存";未配置坐标提示"请设置天气地点"。依据:`src/island/WeatherBrief.vue:12-90`。
3. 冒烟模式禁止访问外部天气(`ELECTRON_SMOKE_TEST==='1'` 且使用真实 fetch 时直接抛错)。依据:`island-weather.ts:33-34`。
### 2.10 课件传输覆盖
1. 主进程持有 `coursewareTransfer`,`coursewareShowing` 时优先覆盖岛内容;`syncWindow` 分支强制显示、取消淡入、不接收鼠标;`formSize()` 返回课件固定尺寸。依据:`src/main/island.ts:661-676,1420-1429,1956`、`src/main/courseware.ts:36`。
2. 课件卡尺寸 `424×132`,内边距 `17px 24px`、圆角 24、底色 `rgba(20,20,24,.97)`、进度条 5px、字号 13/16/12/11;显示文件名、类型、教师、已收/总 MB、进度%、错误行。依据:`packages/shared/src/courseware.ts:4`、`src/island/CoursewareTransfer.vue:12-132`。
### 2.11 渲染入口与组件清单(现状文件级地图)
| 文件 | 行数 | 现状职责 |
|---|---|---|
| `src/island/main.ts` | 30 | 岛渲染入口:引入 `tokens.css`、`applyMotionTokens()`、挂载 `LayoutIslandApp` |
| `src/island/LayoutIslandApp.vue` | 76 | 读取 layout/state/appearance/schedules/timePoints,选择经典/自定义/课件三种渲染 |
| `src/island/IslandApp.vue` | 683 | 经典岛:胶囊+展开卡+形变+命中+课程提醒 |
| `src/island/CustomIslandRuntime.vue` | 528 | 自定义常驻:测量适配+三页交叉淡入+铃铛+消息面板 |
| `src/island/ComponentNode.vue` | 456 | 14 类组件的渲染与课程表折叠规则;编辑态拖拽手柄 |
| `src/island/ComponentIcon.vue` | 36 | 组件图标(编辑库与铃铛) |
| `src/island/ClassicNotifications.vue` | 234 | 单条详情卡/多条列表卡/按钮行(打开应用/标为已读/知道了) |
| `src/island/LessonReminderContent.vue` | 318 | 课程提醒条 + 5 区斜切遮罩 + 进度条 + 徽标 |
| `src/island/CoursewareTransfer.vue` | 132 | 课件传输覆盖卡 |
| `src/island/WeatherBrief.vue` | 91 | 天气简报组件 |
| `src/island/spring.ts` | 115 | SpringValue 帧积分弹簧 |
| `src/island/squircle.ts` | 81 | 连续圆角路径 |
| `src/island/classic-island.css` | 757 | 经典岛全部样式(胶囊/展开/列表/按钮/状态色) |
| `src/island/custom-island.css` | 205 | 自定义行/组件/铃铛基础样式 |
| `src/island/custom-runtime.css` | 255 | 常驻三页/停靠/分隔/消息宿主样式 |
| `src/island/lesson-ending.css` | 16 | 课程名右侧秒数徽标 |
| `src/renderer/island/bridge.ts` | 473 | 主窗口→主进程的岛数据桥:课表/上课状态/通知/作业/叫人/补弹/已读 |
| `src/preload/island.ts` | 123 | 岛窗口 contextBridge 白名单 |
| `src/preload/index.ts` | 101 | 主窗口 contextBridge(含岛推送 API) |
| `src/types/desktop.d.ts` | 294 | 桥接契约与 config 形状 |
### 2.12 主窗口 → 主进程桥接(`src/renderer/island/bridge.ts`)
1. 桥接职责:从 IndexedDB 缓存/服务端取当天课表与完整时间点,每秒复算"是否上课",变化时通过 `window.desktop.islandSetClassState` 下发给主进程;主进程据此决定通知立即弹/暂存/下课补弹。依据:`src/renderer/island/bridge.ts:21-56,148-172,416-435`。
2. 课表来源 `/schedules/active-week`,同时构建 `IslandTimePointDto[]`(含课间/分割线/隐藏标记),课程名优先取日课表条目、无条目再回退时间点默认课程;离线缓存只有条目时退化为"只有上课时间点"。依据:`bridge.ts:57-96,107-136`。
3. 通知投递:普通通知、新作业(带"本机录入免打扰"指纹去重)、叫人(带"请尽快前往"副文案)分别打包成 `IslandNotification`,随 `inClass/currentPeriodEnd` 上下文推给主进程。依据:`bridge.ts:174-289`。
4. 离线补弹:恢复联网时取最近 5 条离线未读补投,推完清离线起点;冒烟模式跳过。依据:`bridge.ts:291-364`。
5. 已读回流:主进程收到岛的"标为已读"后转发给主窗口,桥接订阅并调用通知 store 标记已读(离线先本地改)。依据:`bridge.ts:366-405`、`src/main/index.ts:237-245`。
6. 生命周期:登录后启动、退出登录/切班时停止;停止时必须清定时器、解订阅、清本地课表并主动同步 `inClass:false`。依据:`bridge.ts:437-473`、`src/renderer/App.vue:64-93`、`src/renderer/layouts/ClientLayout.vue:396-405`。
### 2.13 配置与数据契约现状
1. `config.json` 的岛相关字段:`islandLayout`(完整方案)与 `island`(外观);两者都有逐字段归一化与默认值。依据:`src/main/config.ts:26-84,124-163,198-233,334-346,435-523`;配置文件路径 `userData/config.json` 见 `:165-167`。
2. 写入语义:内存缓存 + 120ms 合并写 + 退出前 `flushConfigSync`;不允许每次读取都同步读盘。依据:`config.ts:235-346`。
3. 外观契约 `IslandAppearance`:height/width/radius/opacity/accent/fontSize/animations/speed/position/alwaysOnTop/style/idleSliver/marginX/marginY/followCursorDisplay;范围 36–72 / 220–420 / 8–32 / 0.4–1 / 11–20 / 0.5–2 / 0–200 / 0–160。依据:`packages/shared/src/types.ts:1145-1195`、`constants.ts:265-302`。
4. 经典外观默认:`width 268`、`height 44`、`radius 20`、`fontSize 13`、`opacity 1`、`accent #0a84ff`、`style 'black'`、`position 'top-center'`、`marginX/Y 8`。依据:`constants.ts:265-290`。
5. 语义色与排版系数:`ISLAND_TYPE_SCALE`(title 1.08/body 0.95/meta 0.78/badge 0.76/pillTitle 0.92/pillSub 0.74)必须与 `classic-island.css` 顶部注释一致;所有字号由 `--island-font` 推导。依据:`constants.ts:354-364`、`classic-island.css:1-7`。
6. `CLASSISLAND_ISLAND_METRICS`(重构后的目标几何基线,已存在于 shared):rowHeight 40、水平内边距 12、圆角 8(最大 20)、背景不透明度 0.5、缩放 1(0.75–2.5)、字号 14/16/18/20、行间距 5(最大 150)、避让淡出 0.05。依据:`packages/shared/src/island-layout.ts:19-36`。
### 2.14 IPC 现状(重构必须保持的契约)
1. 主窗口桥接:`getConfig/saveConfig/clearConfig/appInfo/openExternal`;岛:`islandPush/islandRemoveNotification/islandSetClassState/islandGetState/islandSetAppearance/islandGetAppearance/onIslandMarkRead/onIslandMarkAllRead`;另有 `islandEditLayout/onIslandEditorOpen`。依据:`src/preload/index.ts:25-99`。
2. 岛窗口桥接:`getLayout/saveLayout/onLayout`、`getEditing/setEditing/onEditing`、`getVisible/onVisibility`、`reportSize/reportResidentControl/noteResidentTouch/onViewport`、`getSchedules/onSchedules`、`getTimePoints/onTimePoints`、`getState/onState/sendAction`、`getAppearance/onAppearance/setInteractive/setHitRect/setTouchMode/alive`。依据:`src/preload/island.ts:14-121`。
3. 主进程侧注册:`island:get-schedules`、`island:get-time-points`、`island:get-layout`、`island:save-layout`、`island:edit-layout`、`island:get-editing`、`island:get-visible`、`island:resident-control`、`island:resident-touch`、`island:set-editing`、`island:content-size`、`island:remove-notification`、`island:push`、`island:class-state`、`island:alive`、`island:get-state`、`island:set-appearance`、`island:get-appearance`、`island:set-interactive`、`island:set-hit-rect`、`island:set-touch-mode`、`island:action`。依据:`src/main/island.ts:2338-2438`。
4. 安全规则:所有 `invoke` 必须校验发送方是岛窗口(`event.sender === island.getWindow()?.webContents`);渲染进程不能读写配置(`get-layout` 是唯一读取口,`save-layout` 由主进程做归一化)。依据:`src/main/island.ts:2339-2356,2345-2350`。
### 2.15 编辑器与设置页现状
1. 设置页「灵动岛」:布局二选一(fixed/custom)、自定义方案入口、鼠标避让两个开关、外观滑块(宽/高/圆角/透明度/字号/速度)、主题色、视觉风格、位置、左右/上下边距、跟随鼠标屏幕、动画开关、始终置顶、空闲细缝、"测试灵动岛"(本地推一条 30 秒预览通知)。依据:`src/renderer/views/settings/IslandSettingsView.vue:26-160,162-392`。
2. 设置页改动即生效:外观走 `window.desktop.islandSetAppearance` 实时下发,保存才落盘;自定义模式下圆角滑块会同步写当前方案圆角。依据:`IslandSettingsView.vue:112-133`。
3. 编辑器是主窗口的设置子路由 `/settings/island/editor`(不创建额外桌面窗口):上方渲染组件标签/预览、右上角方案菜单(新建/复制/删除/重置/JSON 导入导出)、下方组件库(11 类)+ 检查器;分别编辑常态 `rows` 与无消息展开 `expandedRows`。依据:`src/renderer/views/settings/IslandEditorView.vue:25-70,290-337,541+`、`docs/app/island.md:36-48`、`src/renderer/router/index.ts:69-74`。
4. 编辑器保存:深拷贝 + `normalizeIslandLayouts` + 串行保存队列(`saveQueue`)+ 修订号(只有最后一次成功才算 saved)+ 路由离开前等待保存;上限:组件总数 200、方案 20。依据:`IslandEditorView.vue:81-105,338-347,379-383`。
5. 拖动/放置:指针事件实现拖拽(`beginDrag/startDrag/dragTarget/moveDrag/dropPreview`),编辑标签节点用 `IslandEditorNode.vue`,支持 before/after/inside 三种落点。依据:`IslandEditorView.vue:443-539`、`src/renderer/components/IslandEditorNode.vue:1-124`。
6. 组件设置抽屉:内容/外观/高级三页;内容按 kind 提供字段,外观含独立字号/前景/背景/透明度/圆角/整体透明度,高级含对齐/固定宽/最大宽/隐藏条件;底部支持包裹到容器、复制、删除。依据:`src/renderer/components/IslandComponentSettings.vue:26-307`。
7. 编辑预览:编辑器内嵌预览区(`preview-canvas/island-preview`)用岛内组件渲染,缩放滑块控制;**不创建第二个桌面窗口**,也不自动切到自定义布局(点"应用"才切)。依据:`IslandEditorView.vue:290-299`、`docs/app/island.md:47`、`src/renderer/styles/island-editor.css:125-249`。
### 2.16 既有硬约束(重构不得破坏,逐条对应 AGENTS.md §4)
1. 保留经典岛连续圆角、投影常驻、触摸输入、失焦宽限、固定窗口几何护栏。依据:`repos/ClassHelper-master/AGENTS.md` §4「灵动岛」。
2. 禁止 `setBackgroundMaterial()`;毛玻璃只能是 CSS 半透明。依据:`src/main/island.ts:1726-1748`。
3. 消息按 id 去重;通知/叫人双播不得重复;本机录入作业不上岛(指纹 + id 双判定)。依据:`src/main/island.ts:713-724`、`bridge.ts:191-267`。
4. 编辑预览不创建额外桌面窗口;打开编辑页不自动切布局,应用后才启用自定义方案。依据:`docs/app/island.md:47`。
5. 默认验证不创建可见岛/托盘;只有显式 `--visual` 才做桌面像素/动画验收;冒烟模式跳过外部天气与离线补弹。依据:`docs/dev/testing.md:9,29-32`、`island-weather.ts:33-34`、`bridge.ts:326-331`。
6. 经典岛与自定义岛共用一个原生窗口,切布局不得新开第二个岛。依据:`docs/app/island.md:8-10`、`src/main/island.ts:468-634`(单 `this.win`)。
7. 左右边距只在左/右停靠生效,居中锚点永远是屏幕中线;`marginX` 对居中无效必须在 UI 说明。依据:`docs/app/island.md:54`、`IslandSettingsView.vue:88-94`。
8. 所有验证不得打扰真实桌面(不弹窗、不发系统通知、不挪真实鼠标);冒烟注入点只能改"逻辑光标"。依据:`src/main/island.ts:1910-1930`。
### 2.17 现状问题清单(重构动因,逐条对应 04 的改造项)
1. `src/main/island.ts` 单文件 2445 行,窗口/命中/消息/课程/课件/外观全部耦合,难以按 ClassIsland 规格替换视觉与交互。
2. 几何与尺寸常量分散在主进程 `BASE/SIZES`、shared `ISLAND_SIZE_DELTA`、渲染进程 `targetSize()` 三处,主/渲染各算一次,改动时容易漂移。
3. 经典与自定义是两套独立运行时,但共享消息面板、课程提醒、命中上报;重构需要抽出公共"岛壳 + 消息面板 + 课程条"层,避免两套 UI 各改一遍。
4. 数据里没有版本号:`config.islandLayout` 无 `version`,新增 ClassIsland 行/组件/外观字段时无法做确定性迁移与回滚。
5. 现状行/组件缺少 ClassIsland 的行级外观(背景色/不透明度/圆角/字号覆盖)、行级隐藏规则、行级进度条等字段;只有节点级覆盖。
6. 提醒只有"条状提醒 + 徽标",没有 ClassIsland 的两阶段气泡与强提醒 Topmost 特效;需要在视觉层新增但不得改变现有消息队列语义。
7. 主进程状态机把"提醒优先级、上课守卫、预览、课件"混在一个 `syncWindow` 里,重构需要拆成"状态投影 + 可见性决策 + 几何/命中"三层。
8. 编辑器保存/校验逻辑在 Vue 侧,主进程只做 `normalizeIslandLayouts`;重构应把 schema 校验/迁移收口到 shared,编辑器和主进程共用。
### 2.18 验收标准(第 2 部分)
1. 你能在不运行程序的情况下,仅凭本部分定位:岛窗口创建位置、消息分流规则、命中轮询、触摸模式、失焦宽限、收回动画、课程提醒、课件覆盖、设置页与编辑器的入口文件与行号。
2. 你能列出重构前必须保留的 8 条硬约束与 8 条现状问题,并说明每条对应到 04 的哪个改造项。
3. 你能复述现状数据流:服务端 `ScheduleDto` → `bridge.ts` → `islandSetClassState` → 主进程状态机 → `island:state` → 渲染进程;以及通知/作业/叫人三条投递链路的差异。
4. 你能指出任何一处"主进程与渲染进程各算一次"的几何/布局逻辑(窗口包围盒、列表高度、命中矩形、细缝尺寸、字号推导),并确认 04 已把它们收口到单一事实来源。
5. 本部分引用到的每个数值都能追到 `packages/...:行号` 或 `packages/shared/...:行号`;没有"凭印象"的数值。
---
## 3. ClassIsland 灵动岛行为基准(重构对照规格)
本节是**行为基准**,不是像素规范。实现者(ChatGPT)在 ClassHelper 现有仓库内重构灵动岛时,必须让「`fixed` 默认风格(ClassIsland 信息行)」与「`custom` 常驻模式」在**行为语义**上与 ClassIsland 主界面(MainWindow / MainWindowLine / 组件系统 / 提醒系统)对齐;`fixed` 的 `winisland` 兼容风格只保留原胶囊交互即可,不要求对齐 ClassIsland。具体颜色、间距、圆角、阴影、字号、动画曲线上限等像素值一律以第 6 章(tokens)、第 7 章(结构与组件)、第 10 章(动画)为准,本节只给出「必须实现什么行为、判定顺序、默认值与边界」。凡本节与 ClassIsland 源码冲突,以源码行为为准;凡与 ClassHelper 既有业务规则冲突,以第 8 章的裁决表为准(ClassHelper 业务优先、ClassIsland 视觉优先)。
#### 3.0.1 与 ClassHelper 三种运行形态的映射(口径,必须一致)
| ClassHelper 形态 | 目标语义 | 说明 |
|---|---|---|
| `fixed` + 默认风格 `classisland` | **ClassIsland 风格信息行** | 默认 1 行 = 日期 + 课程表;行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条;消息到来时**在同一行内**展开 ClassIsland 风格气泡(遮罩阶段 + 正文阶段),暂存/紧急/叫人/已读/去重规则沿用 ClassHelper |
| `fixed` + 风格 `winisland` | **原「胶囊 + 详情卡」形态** | 老用户升级默认保持 `winisland`;只做必要的动画/修 bug,不按 ClassIsland 规格重做几何与气泡 |
| `custom` | **常驻多行** | 保留 schemes/rows/nodes/expandedRows/编辑器数据模型,按 ClassIsland 的行/组件/编辑/隐藏/动画规格重构渲染与交互 |
风格枚举落盘字段以 parts-r/05 为准(本节统一写作 `appearance.family = 'classisland' | 'winisland'`,对应配置键 `islandAppearance.style`);新安装默认 `classisland`,升级用户默认 `winisland`。下文凡写「`fixed`」而未特别说明,均指默认风格 `classisland` 的信息行。
证据写法:`repos/ClassIsland/...` 指本工作区内的 ClassIsland 源码;`packages/...` 指 ClassHelper 源码。未在源码中确认的写【推断】。
### 3.1 主界面行(行为模型)
**必须实现的模型**:主界面是一个**多行容器**,每行(MainWindowLine)包含「背景层 + 内容层(组件集合) + 提醒层(遮罩/正文) + 进度条」。行是提醒的宿主,也是编辑的最小单位。
1. **行高固定 40**,且同一行内所有组件共享该高度;组件区左右内边距 12、最小内容宽 20(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:16`;`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:110`)。
2. **行数无硬上限**,但默认只有 1 行;行间距默认 5(`repos/ClassIsland/ClassIsland/Models/Settings.cs:219`,范围 0–150、步进 5)。
3. **必须存在且只存在一行「主行」**(MainLine):删除到只剩 1 行时不得再删,必须提示「至少需要保留 1 个主界面行。」(`repos/ClassIsland/ClassIsland/Views/EditModeView.axaml:428` 文案,`MainWindow.axaml.cs` 的删除守卫)。
4. **行模板三分支**(必须按条件选,不得混用,`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:275-278`):
- Full:`IsNotificationEnabled = true`,含遮罩/正文/进度条;
- Min:`IsNotificationEnabled = false`,只有背景与内容;
- 分体(Separated):`IsIslandSeperated=true` 或行级 `IslandSeparationMode=2`,整行背景改为每个顶级组件各自胶囊。
5. **行的可见性**(必须逐条实现,`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:29-49`):
```
visible = ( (!IsAllComponentsHid || IsOverlayOpen) && (HideOnRule ? IsVisible : true) ) || IsEditMode
```
- `IsAllComponentsHid`:该行所有组件都被隐藏时整行消失,不能留一条空胶囊;
- `IsOverlayOpen`:该行正在播提醒时必须可见(即使组件全隐藏);
- `HideOnRule`:该行命中隐藏规则集时隐藏;规则集变化要实时重算(订阅规则状态更新);
- 编辑模式下**永远可见**。
6. **行级不透明度**默认 1.0(范围 0.1–1、步进 0.05),**只作用于行内容**;背景不透明度是另一个独立设置(默认 0.5),**只作用于背景层**,文字必须始终 100% 不透明(`repos/ClassIsland/ClassIsland/Models/Settings.cs:64,183-184`)。
7. **鼠标淡出**(必须实现):`IsMouseInFadingEnabled`(默认 true)与 `IsMouseInFadingReversed`(默认 false)决定「指针不在行内时行变淡到 0.05」,判定用行的屏幕矩形(含 DPI 与缩放),编辑模式不淡出(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:503-509,619-637`)。
8. **行背景宽度 = 内容实际宽度**,不是窗口宽度;内容宽度变化必须平滑跟随(Fluent 300ms / Classic 550ms),且**不得触发窗口尺寸变化**(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:614-617`;动画细节见第 10 章)。
9. **行的提醒宿主能力**:`AcceptsNotificationRequests = IsNotificationEnabled && !IsAllComponentsHid && !_isOverlayOpen && !_isUnloading`——只有同时满足这些条件的行才能接收提醒;多行时由提醒分发规则决定落到哪一行(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:814`)。
10. **行级设置**(必须保留在编辑器中可改):不透明度、是否允许显示提醒、隐藏规则、圆角/间距(继承全局或覆盖)、附加设置(见第 7 章)。
### 3.2 组件系统(11 类 + 容器)
必须实现 ClassIsland 的 11 种内置组件与 4 种容器(`repos/ClassIsland/ClassIsland/Controls/Components/`),并保留 ClassHelper 现有的同名组件作为承载:
| 组件 | 职责(行为) | 关键默认值 |
|---|---|---|
| 日期 `DateComponent` | 显示「今天」或自定义格式日期;支持农历/自定义模板(模板语法按组件设置) | 显示今天 |
| 时钟 `ClockComponent` | 显示当前时间;支持 12/24 小时、秒、自定义格式 | 24 小时制、显示秒 |
| 倒计时 `CountDownComponent` | 目标时间倒计时;目标可固定日期或「按课表倒数至上课/下课/放学」 | 见 3.7 |
| 课程表 `ScheduleComponent` | 今日时间点/当前课程/下一节,见 3.7 | 见 3.7 设置表 |
| 天气简报 `WeatherComponent` | 天气图标 + 温度 + 文本;多种主信息模式 | 见 3.10 |
| 文本 `TextComponent` | 静态文本,支持模板变量与滚动 | 见组件设置 |
| 滚动容器 `RollingComponent` | 单行内容超宽时循环滚动 | 见组件设置 |
| 轮播容器 `SlideComponent` | 多个子组件按间隔轮播 | 见组件设置 |
| 分组容器 `GroupComponent` | 子组件横向成组,统一间距 | 见组件设置 |
| 堆叠容器 `StackComponent` | 子组件纵向堆叠 | 见组件设置 |
| 分割线 `SeparatorComponent` | 竖线分隔,2px 宽 25px 高(视觉细节归第 7 章) | — |
**必须实现的组件通用语义**:
1. **组件宿主间距**:行内相邻组件水平间距 12(每个组件 `Margin="6 0"`);容器组件内部用 `Margin="-6 0"` 抵消,避免嵌套累积(`repos/ClassIsland/ClassIsland/MainWindow.axaml:396`)。
2. **组件可见性**:组件自身有 `IsVisible`;行内「全部组件都不可见」→ 整行 `IsAllComponentsHid`(见 3.1)。
3. **组件级隐藏规则**(`custom` 模式必须对齐):按 `in-class`(上课中)、`no-message`(无消息)、`no-class`(无课/放学后)三个条件隐藏;ClassHelper 现有 `advanced.hideRules` 的三值语义与之一一对应,必须保留。
4. **组件宽高**:`advanced.align/width/maxWidth` 必须生效;`stretch` 为默认,剩余空间按 flex 分配。
5. **组件的附加设置**:附加设置可挂在科目/时间点/课表/时间表(ClassIsland 语义,见第 7 章),本重构只保留 ClassHelper 已有的「课程控件附加设置」语义,不引入 ClassIsland 本地档案。
6. **编辑态**:所有组件必须可被点击选中、可拖动排序、可跨行移动;容器组件展开子项供选择(第 9 章编辑器规范)。
7. **空数据兜底**:任何组件在无数据时必须渲染占位(不得空白、不得抛错),占位文案与场景见第 7 章。
### 3.3 编辑模式(行为语义)
必须实现「编辑模式」作为岛的唯一配置入口,行为如下(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:510-528,1391-1403`):
1. **进入条件**:菜单「编辑主界面…」或 URI `classisland://app/edit`;进入前必须通过权限校验(ClassHelper 侧对应「已登录班级设备」判断)。
2. **进入步骤(按序)**:置编辑标志 → 激活窗口 → 挂载编辑视图 → 同步特效窗口的编辑态(编辑期间**不播强提醒特效**)→ 更新窗口层级 → 编辑模式下窗口必须接收鼠标。
3. **编辑画布**:画布覆盖整个屏幕;岛内容在画布内按停靠位置排布,并预留底部操作栏空间(操作栏高度 64 的语义见第 7/9 章);编辑模式内容整体垂直居中。
4. **编辑态可见性**:所有行、所有组件(含被隐藏规则隐藏的)都必须可见可选中;隐藏规则命中时以半透明/斜纹提示,而不是直接消失。
5. **底部操作栏(行为)**:重置视图、外观抽屉、添加组件抽屉、组件配置方案、完成;「完成」退出并**保存**。
6. **两级抽屉**:主抽屉(外观/添加组件/配置方案)与次抽屉(组件设置);抽屉打开时挂载「逐项入场」动画;拖拽组件时主抽屉自动临时收起,结束后恢复。
7. **退出**:关闭抽屉 → 清编辑标志 → 清教程提示 → **保存组件配置**(保存必须幂等、原子:写临时文件再替换);退出后恢复逐像素透明与点击穿透。
8. **编辑期间**:不得创建额外桌面窗口;不得因编辑导致提醒被丢弃(提醒进队列,退出后按规则补播);强提醒特效丢弃但提醒本体照常。
9. **配置方案**:支持多套组件配置方案(ClassHelper 现有 `schemes`),方案切换即时生效并持久化;方案 JSON 导入导出走本机配置,不上服务端。
### 3.4 隐藏与显示规则(完整判定)
必须实现两档 `hideMode`(默认 `0`,`repos/ClassIsland/ClassIsland/Models/Settings.cs:185`;`repos/ClassIsland/ClassIsland/MainWindow.axaml:150-163`):
- **0 基本**:三个独立开关,全部默认 `false`:
- `HideOnClass`:当前时间状态为「上课」时隐藏;
- `HideOnMaxWindow`:前台窗口最大化时隐藏;
- `HideOnFullscreen`:前台窗口全屏时隐藏。
- **1 高级**:用规则集 `HideRules` 求值(规则集/逻辑见第 8 章的规则语义;ClassHelper 侧只保留独立开关 + 上课状态条件,不引入完整规则集)。
**检测数据源与频率**:
1. 全屏/最大化状态在**每个主计时器 tick** 检测,基准屏幕必须是「岛所在屏幕」(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:451-456`)。
2. 排除进程名单 `ExcludedFullscreenWindow` 默认 `["explorer"]`:这些进程在前台时不算全屏/最大化(`repos/ClassIsland/ClassIsland/Models/Settings.cs:56-59`)。
3. **强制显示**(覆盖隐藏):正在播放提醒或处于编辑模式时,即使命中隐藏条件也必须显示,并临时置顶(见 3.9 的置顶锁语义)。
4. **手动隐藏**:`IsMainWindowVisible`(默认 true)为用户手动开关;手动隐藏时同时不接受新提醒(与 3.1 第 9 条一致)。
5. **全部隐藏**:所有行都隐藏时,可见内容 `opacity:0`,**窗口包围盒不变、窗口不销毁**,保证提醒到来能立刻显示(`repos/ClassIsland/ClassIsland/MainWindow.axaml:135-141`)。
6. **ClassHelper 映射要求**:
- `custom` 模式的「常驻」不能被 `HideOnClass` 直接隐藏——上课时只允许按 `hideRules:in-class` 隐藏**指定组件**;
- `fixed` + `classisland` 信息行按 ClassIsland 默认(`HideOnClass=false`)**上课也保持可见**,只是「普通消息暂存、紧急/叫人例外」;不得回到「上课即整岛隐藏」;
- `fixed` + `winisland` 兼容形态保留 ClassHelper 原规则(上课时普通消息暂存并隐藏岛体,紧急与叫人例外,见 3.9 与第 8 章)。
### 3.5 多显示器、停靠与点击穿透
1. **6 个停靠位置**:顶/底 × 左/中/右(`0/1/2` 贴工作区上边、`3/4/5` 贴下边;左/中/右分别为贴左/居中/贴右),窗口始终横跨整屏,岛在窗口内形变(`repos/ClassIsland/ClassIsland/Models/Settings.cs:48`)。
2. **偏移**:`WindowDockingOffsetX/Y` 必须生效;顶部停靠时正值向下偏移不得被屏幕边缘裁掉(偏移吞进安全区)。
3. **多显示器**:`WindowDockingMonitorIndex` 选择目标屏;可选「跟随鼠标所在屏」(ClassHelper 现有 `followCursorDisplay` 保留)。
4. **DPI**:所有几何与命中必须按「物理像素 × 缩放」换算,4K@150% 与 1080p@100% 下不得出现 1px 白边、黑边、圆角锯齿;圆角为 0 时是直角,为最大值时左右边缘为半圆。
5. **点击穿透**:非编辑模式下,岛默认鼠标穿透(点击落到桌面应用);只有指针真正在岛体上时才接收鼠标;编辑模式下必须接收(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:839`;ClassHelper `packages/desktop-client/src/main/island.ts:2000-2080` 的命中轮询必须保留)。
6. **置顶锁**:提醒播放期间临时置顶,多提醒时以「锁计数」方式管理——只有最后一个锁释放才还原层级,避免闪烁(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:1056-1092`)。
### 3.6 时间状态机与跨天(课程计算的唯一事实源)
1. **主计时器周期 50ms**,优先级 Render;每 tick 顺序:`PreMainTimerTicked` → 计算 → `PostMainTimerTicked`(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:39-45,373-388`)。ClassHelper 映射为**主进程单一 1s/50ms 混合节拍**(秒级 UI 用 1s,跨点判定用 50ms【推断:可降到 200ms,见 3.13 性能非功能】)。
2. **时间状态枚举**:`None / OnClass / PrepareOnClass(预留) / Breaking / AfterSchool`(`repos/ClassIsland/ClassIsland.Shared/Enums/TimeState.cs:6-27`)。
3. **每 tick 计算顺序**(必须逐条实现):
1. 清理过期临时课表/临时层/临时课表群;
2. 若「加载课表」关闭 → 当前课表为空;
3. 选当天课表(预定 > 临时 > 常规,详见 parts/06 §6.2);
4. `now = 当前本地时间`;
5. 当前时间点 = 有效时间点中第一个 `TimeType∈{0,1}` 且 `StartTime ≤ now ≤ EndTime`;
6. 上课 → `CurrentState=OnClass` 并映射科目;课间 → `CurrentState=Breaking`,`CurrentSubject=课间科目`(名称=课间名);
7. 下一节 = 第一个 `TimeType=0 && EndTime ≥ now`;下一个课间 = 第一个 `TimeType=1 && EndTime ≥ now`;
8. 剩余时间:上课时算「距下课」,否则算「距上课」;**下限钳 0**;
9. 下一节与下一课间都不存在 → `AfterSchool`;
10. 统一回写(未算出用默认值);状态变化时才发事件,顺序固定「先 `CurrentTimeStateChanged`,再按新状态发 `OnClass` / `OnBreakingTime` / `OnAfterSchool`」。
4. **有效时间点的双向裁剪**:禁用一节早课 → 其前所有时间点不显示;禁用一节晚课 → 其后所有时间点不显示(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:54-98`)。ClassHelper 侧映射为「`ScheduleDto` 投影 + 当前时间点/下一节,由服务端解析结果决定」,**不得在客户端重写轮换规则**。
5. **跨天**:每次 tick 检测日期变化;跨天必须重新取当天课表、清除「今天」的已提醒标记、重置课程提醒状态机(`LessonReminderTracker` 的 `dayKey` 变化),不得沿用昨天的「已提醒」状态。
6. **时间突变**(手动改系统时间、NTP 校正、睡眠唤醒):下一次 tick 必须按新时间重新判定状态;不得补发错过的提醒,也不得因时间回退而重复发出同一提醒(用「状态变化」而非「时间比较」触发)。
### 3.7 课程表组件的行为(重点)
课程表组件是「一行多条课程」的核心,ClassHelper 的 `schedule` 组件必须按下列行为实现(视觉像素见第 7 章):
#### 3.7.1 显示哪些时间点(必须逐条判定,`repos/ClassIsland/ClassIsland/Controls/Components/ScheduleComponent.axaml.cs:243-251`)
对当天每个时间点:
1. `TimeType ∈ {2 分割线, 3 行动}` 不作为课程项显示(分割线由专门的竖线控件渲染;行动不显示);
2. `HideFinishedClass=true` 且 `EndTime < now` → 不显示;
3. `IsHideDefault=true` 且不是当前选中项 → 不显示;
4. `TimeType==1`(课间)且不是当前选中项 → 不显示;
5. `ShowCurrentLessonOnlyOnClass=true` 且当前选中项是上课类型且该项不是当前项 → 不显示。
**映射到 ClassHelper(`custom` 模式的 `schedule` 组件)**:`display:'today'` 必须实现「只当前项展开、其余折叠」的 ClassIsland 语义:
- 上课点折叠只显示课程名;**当前时间点展开显示「课程名 + 起止时间」**;
- 课间与 `isHideDefault` 的上课点**折叠时完全隐藏**,轮到它们成为当前项时才出现;
- 分割线 `DIVIDER` 渲染为一条竖线;`ACTION` 完全不显示(`packages/shared/src/class-plan.ts:4,70-87`;`packages/desktop-client/src/island/ComponentNode.vue:48-80`);
- 无时间点数据时**回退按 `ScheduleDto` 条目显示课程**(`docs/app/island.md:69`);
- **倒计时/徽标只在「当前时间点临近结束」时出现**,不得常驻。
#### 3.7.2 三种课程呈现
必须实现三种课程项(`repos/ClassIsland/ClassIsland.Core/Controls/LessonsControls/`):
- **展开型**:科目名(强调字号)+ 附加信息 + 进度条;左右留白按「课程表文本间距」缩放(默认 1,范围 0.2–1,左右各 16×间距);
- **最小型**:只显示科目首字(`Subject.Initial`),用于 `IsHideDefault` 时间点或「上课时仅显示当前课程」;
- **分割线**:2px 竖线、高 25、次要色、透明度 0.8。
**已结束课程淡化**:`FadeCompletedClasses` 默认 true,透明度 0.6,过渡 150ms(`CubicEaseInOut`);`HighlightChangedClass` 默认 false,换课课程高亮(强调色块)。
#### 3.7.3 附加信息与倒计时(必须实现 6 种 `ExtraInfoType`)
`ShowExtraInfoOnTimePoint=true` 时,当前课程可显示(`repos/ClassIsland/ClassIsland/Controls/Components/LessonControlExpanded.axaml:85-171`):
| 值 | 显示 | 格式 |
|---|---|---|
| 0 | 起止时间 | `hh:mm-hh:mm` |
| 1 | 已过时间 | `-mm:ss` |
| 2 | 剩余时间 | `-mm:ss` |
| 3 | 进度百分比 | `N%` |
| 4 | 模糊倒计时 | `-<1min`(按 `CountdownSeconds` 向上取整) |
| 5 | 精确倒计时 | `-mm:ss` |
**当前课程剩余时间药丸**:`IsCountdownEnabled=true` 时,当前时间点剩余 ≤ `CountdownSeconds`(默认 60)才出现;精确模式 `-mm:ss`,非精确模式 `<<数值`(`IsNonExactCountdownEnabled=false`,`LessonControlExpanded.axaml:173-214`)。
**课程进度条**:贴底、`Maximum=1`、值为课程进度、高度 3px(视觉细节归第 7 章);进度更新必须按「比例插值」而非每帧重算布局。
#### 3.7.4 空状态与明天课表
- 空占位(默认显示,`ShowPlaceholderOnEmptyClassPlan=true`):
- 今天没有课表 → `"今天没有课程。"`;
- 今日课程全部结束且 `HideFinishedClass=true` → `"今日课程已全部结束。"`;
- 垂直居中、正文色;占位与课程列表互斥。
- 明天课表 `TomorrowScheduleShowMode` 默认 `1`:`0` 不显示、`1` 放学后显示、`2` 总是显示、`3` 今天无可显示课程时显示;显示时左侧出现「明天」药丸、主列表隐藏。
- `AfterSchool` 判定 = 当前时间状态为放学后**或**今天没有课表。
#### 3.7.5 课程表组件设置默认值(必须照抄)
| 字段 | 默认 | 说明 |
|---|---|---|
| `ShowExtraInfoOnTimePoint` | `true` | 显示附加信息 |
| `ExtraInfoType` | `0` | 0–5 |
| `IsCountdownEnabled` | `true` | 结束倒计时药丸 |
| `CountdownSeconds` | `60` | 阈值(秒) |
| `IsNonExactCountdownEnabled` | `false` | 模糊倒计时 |
| `ScheduleSpacing` | `1` | 0.2–1 |
| `ShowCurrentLessonOnlyOnClass` | `false` | 上课仅显示当前课程 |
| `HideFinishedClass` | `false` | 不显示已结束课程 |
| `FadeCompletedClasses` | `true` | 淡化已上课程 |
| `HighlightChangedClass` | `false` | 标记换课 |
| `ShowPlaceholderOnEmptyClassPlan` | `true` | 空态占位 |
| `PlaceholderTextNoClass` | `"今天没有课程。"` | 文案 |
| `PlaceholderTextAllClassEnded` | `"今日课程已全部结束。"` | 文案 |
| `TomorrowScheduleShowMode` | `1` | 0–3 |
(证据:`repos/ClassIsland/ClassIsland/Models/ComponentSettings/LessonControlSettings.cs:8-23`;`repos/ClassIsland/ClassIsland/Models/Settings.cs:103-109,208-209,221`。)
### 3.8 提醒队列与优先级(行为)
ClassIsland 的提醒是**统一管线**,必须实现以下行为:
1. **一切提醒都进同一管线**:上下课、放学、天气、集控、行动触发的提醒共用队列与消费者;不得各写一套弹窗。
2. **两层 UI**:遮罩(Mask,短文案,第一阶段)+ 正文(Overlay,详细内容,第二阶段);无正文时只播遮罩(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:40-63`)。
3. **队列与优先级**:
- 提供方优先级:`NotificationProvidersPriority` 数组下标越小越先播;设置页可拖动排序。
- 同提供方内先入队先播(FIFO)。
- ClassIsland 源码的 `IsPriorityOverride` 比较器存在取反异常(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationPriority.cs:47-50`);**本轮目标语义**按第 8 章裁决:三级排序键(提供方下标 → 通知等级 Emergency>High>Normal>Low → 入队序),不照抄异常行为。
4. **去重**:同一 request 对象只入队一次;出队用「已弹出集合」避免链式头节点重复;天气预警按 `Detail` 会话内去重;ClassHelper 侧另有「同 id 通知/叫人双播合并」规则(见第 8 章),两者不冲突。
5. **链式提醒**:数组请求串成链,任一子请求取消即取消全部;准备上课 + 上课是典型链(`repos/ClassIsland/ClassIsland/Services/NotificationHostService.cs:205-254`)。
6. **消费者**:按优先级排序,只有 `AcceptsNotificationRequests && QueuedNotificationCount<=0` 才推送;无消费者时退回队列;播放完主动拉取后续(`NotificationHostService.cs:337-419`)。
7. **播放状态机**:`None → Queued → Playing →(Paused/Cancelled/Completed)`;取消/完成分别触发对应令牌回调;被中断但未被取消的提醒必须**重新入队**(`NotificationHostService.cs:488-522`)。
8. **取消与清理**:`CancelAllNotifications` 清空队列 + 取消所有正在播放的请求;用户入口为主界面右键菜单「清除全部提醒」,仅在「有提醒正在播放」时可见(`repos/ClassIsland/ClassIsland/MainWindow.axaml:86-94`);**不实现**独立「清除提醒浮动按钮窗口」(源码 `#if false` 遗留)。
### 3.9 通知两层 UI(气泡 + 强提醒)
#### 3.9.1 气泡(行内)行为
1. 遮罩阶段:背景切换为强调色 + 斜切遮罩扫入 + 遮罩文字入场;此阶段不显示详情。
2. 遮罩结束:有正文则切正文(背景恢复、正文淡入、底部进度条显示剩余时间);无正文则直接收起。
3. 正文阶段:内容 + 底部进度条(`Maximum=1`、`Value=剩余进度`、值过渡 50ms 线性);显示时长由内容 `Duration` 或 `EndTime` 决定。
4. 结束时:正文淡出 → 遮罩退出 → 行恢复原状;队列里还有提醒则继续播下一条。
5. **提醒期间行的可见性**:强制可见(覆盖隐藏规则),并置顶;提醒结束释放置顶锁。
6. **关键动画参数**(语义;完整表见第 10 章):斜切遮罩 5 区段、区段 360ms、错峰 120ms、开 `QuarticEaseOut`/关 `QuadraticEaseIn`;遮罩文字 `delay 260ms`、淡入 250ms、缩放 1.1→1.0、750ms,曲线 `(0.25,1,0.5,1)`;退出文字 200ms、缩放 1.0→1.1、曲线 `(0.5,0,0.75,0)`;正文淡入淡出 200ms、`SineEaseIn`。Classic 主题是另一套(上滑/下压)语义,必须两套都保留。
7. **音效与 TTS**:ClassIsland 支持提醒音效与语音(默认关闭语音、音效音量 1.0);**本轮重构不实现音效/TTS**(ClassHelper 无音频通道,见 3.13),只保留视觉提醒。
#### 3.9.2 强提醒(Topmost 全屏特效)行为
1. 触发:提醒开始且生效设置开启「强调特效」、主界面可见、行未全部隐藏、本次遮罩尚未播过声音。
2. 表现:从行中心向全屏扩散的涟漪(默认强调色),扩散 600ms + 淡出 600ms,750ms 后移除元素;特效窗口为**独立透明置顶窗口**,逐屏覆盖主界面所在屏幕,按 `NotificationEffectRenderingScale`(默认 1.0)缩放。
3. 生命周期:特效元素集合为空即隐藏窗口,不销毁;非 `OSShutdown/ApplicationShutdown` 时禁止关闭窗口;编辑模式下丢弃新特效。
4. **必须支持多屏**:每个显示器一个特效窗口,屏幕/DPI 变化时重算位置与尺寸。
5. ClassHelper 映射:`custom`/`fixed` 共用同一套特效窗口;动画速度倍率缩放 600/750ms;`animations=false` 时不播特效只播气泡。
### 3.10 天气展示(行为)
1. **数据频率**:天气服务每 5 分钟刷新一次,启动立即刷新一次;刷新成功才置「已刷新」标志,所有依赖天气的提醒/规则先判该标志(`repos/ClassIsland/ClassIsland/Services/WeatherService.cs:41-44,69-71,386-389`)。
2. **缓存**:最近一次天气持久化在设置中,重启先展示旧数据并标注更新时间;刷新失败保留旧数据,不清空。
3. **组件展示**:天气组件有多种主信息模式(图标 + 温度 + 文本;默认展示当前天气+温度);无数据时显示占位。
4. **提醒**:气象预警与天气预报的提醒在「上课 / 课间」事件时触发;预警按 `Detail` 会话内去重、显示时长按文本长度钳制 10–90 秒;预报正文 15 秒;预警要求数据已刷新。
5. **本轮范围**:ClassHelper 已有 Open-Meteo 天气简报(15 分钟缓存、断网标「缓存」、**无官方气象预警**)。重构后必须**保留 ClassHelper 数据源与行为**,ClassIsland 的「气象预警/三天预报/逐小时预报提醒」**不实现**(见第 8 章冲突裁决)。
### 3.11 主题语义(Fluent vs Classic 的差异)
必须保留两套「风格」的概念(ClassHelper `islandAppearance.style` 默认 `classisland`,回退 `winisland`),差异只体现在**行为参数**上:
| 维度 | Fluent(默认目标) | Classic(可选回退) |
|---|---|---|
| 行宽过渡 | 300ms、`(0.65,0,0.35,1)` | 550ms、`(0,1,0.5,1.1)`(带过冲) |
| 遮罩入场 | 斜切 5 区段扫入 + 文字延迟 260ms | 整层上滑/下压(`Y -50→0`,200ms `QuadraticEaseIn`) |
| 遮罩退场 | 文字淡出 200ms + 缩放 | 下移 `0→50`、200ms `QuadraticEaseOut`、延迟 100ms |
| 行列表面 | 中性填充 + 1px 描边 + 阴影 `0 4 8 2 #48000000` | 类似,但强调色/描边更强 |
| 分体 | 每组件独立胶囊(min-width 40、padding 0 16) | 同语义,参数略异 |
| 主题色来源 | 系统强调色/自定义 | 同 |
**硬约束**:背景不透明度只作用于背景层;文字始终不透明;投影必须常驻声明、只改参数(禁止在开合瞬间增删 `filter`);两套主题共用同一行为状态机,只切换视觉参数。
### 3.12 动画语义总则(实现时必须遵守)
1. **进场慢、退场快**:进场 200–300ms,退场 100–150ms;提醒遮罩文字淡入 250ms、淡出 200ms,属于「进场慢退场快」的允许例外(遮罩退场本身另有 100–200ms 的层退出)。
2. **延迟表达因果**:先扫入(0–360ms)、再文字(+260ms)、再投影(+600ms);不得所有元素同时启动。
3. **错峰封顶**:列表入场每项 25–35ms,超过 10 项必须封顶;不得线性累加到 1 秒以上。
4. **可打断**:进/出必须成对、可反向,反向时新动画直接覆盖旧动画(不排队);不得用 JS 计时器模拟状态机。
5. **动画不改业务**:动画只表达状态,不得在动画结束回调里发请求或用动画时长当业务计时。
6. **最终几何一致**:动画结束后元素几何必须与「无动画直跳终态」逐像素一致。
7. **动画级别**:`AnimationLevel` 0/1/2(默认 1);级别 0 禁用动画但**主界面提醒动画不在此列**(设置项描述明确);同时响应系统「减少动态效果」(视为级别 0 + 时长 1ms,加载指示器放慢不停)。
8. **性能禁忌**:不对包含文字的容器做 `width/margin` 过渡(用绝对定位背景层或 `clip-path`/`scaleX`);卡片投影用 `box-shadow` 不用 `filter: drop-shadow`;透明窗口**禁用** `setBackgroundMaterial()`。
### 3.13 本轮不实现的 ClassIsland 能力(必须写进实现说明,不得遗漏标注)
以下能力在 ClassIsland 基准里存在,但**本轮重构明确不实现**,实现者不得自行扩展、也不得因缺失判为缺陷:
1. **自动化系统**(触发器/行动/规则集/时间点行动):不实现;岛内不出现自动化入口。
2. **插件系统与插件市场**:不实现;不引入 `.cipx`、不加载第三方 DLL/模块。
3. **主题市场/主题包/壁纸取色**:不实现(壁纸取色在 ClassIsland 也已是 P2 停用状态);只做岛内两套风格(`classisland`/`winisland`)。
4. **集控/管理端**(ManagementServer、策略、审计、授权级别):不实现;ClassHelper 用服务端权限体系替代。
5. **Excel 导入/导出、档案导入导出、CSES 互转**:不实现(属 ClassIsland 本地档案能力);ClassHelper 继续用服务端课表/YAML 导入。
6. **自动更新器、启动闪屏、OOBE、欢迎向导、教程中心、崩溃恢复窗口、作者/致谢窗口**:不实现(属其它 part/非岛范围)。
7. **提醒音效与 TTS(系统 TTS / Edge TTS)**:不实现;本轮只做视觉提醒。若未来需要,作为独立扩展项,默认关闭。
8. **ClassIsland 的「即将下课」遮罩**:不实现——课程临近下课的提示统一走 ClassHelper 的「课程名右侧蓝色秒数徽标」(见第 8 章裁决)。
9. **本地档案/Profile/TimeLayout/CSES 体系**:不实现;数据继续用 ClassHelper 服务端解析结果(`ScheduleDto`/`IslandTimePointDto`)。
10. **通知音效音量、静音时段、语音源切换**:不实现(ClassIsland 有,ClassHelper 无对应通道)。
### 3.14 验收标准(行为级)
实现者必须逐条自查,全部通过才算完成本节:
1. 多行场景:1→5→1 行增删后,行可见性与「主行」约束正确;只剩 1 行时删除被拒绝且提示「至少需要保留 1 个主界面行。」。
2. 行可见性表达式三态:组件全隐藏时整行消失;提醒期间强制可见;编辑模式下隐藏行也可见可选。
3. 隐藏规则:`hideMode=0` 三个开关各自生效,`explorer` 前台时不算全屏;提醒/编辑时覆盖隐藏;手动隐藏后不再接收新提醒;全部隐藏时内容透明度为 0 但窗口不销毁。
4. 时间状态机:构造「上课→课间→放学」一天,状态与事件顺序正确(状态改变事件先于具体事件);禁用一节早课/晚课后,时间点的双向裁剪与「今天没有课程」占位一致。
5. 课程表组件:6 种附加信息、倒计时药丸阈值 60 秒、已结束课程淡化 0.6、明天课表 4 种模式、无课表/全部结束两种占位文案全部正确。
6. 提醒:两条提醒先后入队,第一条播完自动播第二条;提醒期间行强制可见并置顶,结束后释放;右键「清除全部提醒」仅在有提醒时出现并立即清空。
7. 强提醒:开启特效时从岛中心扩散涟漪,600ms 扩散 + 600ms 淡出、750ms 后消失;编辑模式下不播;多屏时覆盖岛所在屏。
8. 主题:Fluent 与 Classic 两套的遮罩入场/退场行为不同(斜切 vs 上下滑),行宽过渡 300ms vs 550ms;切换主题不改变任何业务状态。
9. 动画:`AnimationLevel=0` 时除岛提醒外无动画;系统「减少动态效果」下时长压到 1ms 且加载指示器不停;动画结束后几何与直跳终态一致。
10. 非目标确认:界面上不存在自动化、插件、主题市场、集控、Excel 导入、音效/TTS 入口;代码中不存在对应依赖。
11. 形态映射:新安装默认 `family='classisland'` 的信息行(日期+课程表、行高 40、底部 3px 进度条),升级用户默认 `family='winisland'` 的胶囊形态;`custom` 仍走 schemes/rows/nodes;三种形态切换不丢消息、不重建第二个岛窗口。
---
## 4. 目标架构与文件级改造清单
### 4.0 重构原则(必须逐条遵守)
1. **只在现有仓库内重构**:不得新建工程、不得换框架、不得引入服务端依赖;沿用 pnpm@11.8.0 monorepo 与 Electron ^44.3.0 + Vue ^3.5.42 + Element Plus ^2.14.5 + Pinia ^4.0.3 + Vite ^8.3.0 + TS ~5.9.3。
2. **渐进式替换,不一次性推倒**:每次改动后应用必须仍可启动、旧 `config.json` 仍可加载;用"门面(facade)+ 新模块"的方式把旧调用点隔离,避免一次改 `src/main/index.ts`、`tray.ts`、`smoke.ts` 的全部 import。
3. **单一事实来源**:任何"主进程与渲染进程各算一次"的数值(窗口包围盒、列表高度、命中矩形、细缝尺寸、字号推导)必须收口到 `packages/shared/src/island/`,两端只调用同一函数。现状三处漂移见 02 §2.3。
4. **保留现有业务语义**:消息去重、上课暂存、紧急展开、已读回执、作业免打扰指纹、课件覆盖优先级、离线补弹,全部保持(02 §2.5/§2.12);ClassIsland 只改变"视觉与布局表现"。
5. **两套风格族(family)共存,且 family 只作用于 fixed**:`appearance.family` 取值 `'classisland' | 'winisland'`;**默认 `classisland`**;`winisland` 保留原胶囊+详情卡外观;老用户升级默认 `winisland`(迁移见 05)。**`custom` 布局始终按 ClassIsland 规格渲染,不受 family 影响**(family 只决定 fixed 用哪套渲染器)。
6. **`fixed` 重新定义**:在 `family='classisland'` 下,`fixed` 不再是胶囊+详情卡,而是 **ClassIsland 风格信息行**(默认 1 行 = 日期 + 课程表,行高 40、圆角 8、背景不透明度 0.5、3px 进度条、消息以行内气泡呈现)。原"胶囊+详情卡"只在 `family='winisland'` 下保留。这是本轮最重要的语义变化,所有文件改造必须按此分叉。
7. **一个原生窗口**:多种布局/风格共用一个 `BrowserWindow`;切换 family/layout 不得新开窗口(现状约束,见 `docs/app/island.md:8-10`、`src/main/island.ts:468-634`)。
8. **不打扰纪律**:默认验证无头/离屏,不创建可见岛或托盘;只有显式 `--visual` 才做桌面像素验收。依据:`docs/dev/testing.md:9,29-32`。
9. **调色板与风格分离**:`style: black|glass|tinted` 继续保留,写为 winisland 调色板字段(canonical `appearance.winislandStyle`,旧 `style` 作为兼容镜像,不删除);family 与 style 独立,切换 family 时保留用户 style 原值。
10. **外观新增定稿字段**:`colorScheme: 'light'|'dark'|'system'`(默认 system)、`theme: 'fluent'|'classic'`(默认 fluent)、`effectEnabled`(默认 true)、`effectScale: 1|0.75|0.5`(默认 1)、`progressBar:{height:3,show:true}`;`fontSize` 语义随 family:classisland=正文基准(新装默认 16,派生 14/18/20),winisland=旧语义(默认 13,迁移保留原值)。
11. **版本只保留一个**:落盘版本只有 `islandLayout.version=2`;`schemaVersion` 仅允许出现在 IPC payload 作运行时校验,不得落盘。
12. **fixed 消息/提醒口径**:行内气泡同一时刻只显示 1 条 active;多条折叠为「还有 N 条」并可打开应用内通知中心;`prepare/on-class/break` 以行内容接管呈现、`ending` 只用蓝色圆形秒数徽标;队列/去重/暂存规则不变。
### 4.1 目标模块边界与目录
必须把灵动岛拆成四个边界清晰的部分。目录与职责如下(标注「新增」的目录/文件由本轮创建;其余为移动或改造):
```text
packages/shared/src/island/ # 新增:岛的唯一契约层(纯 TS,无 DOM/Electron)
├─ index.ts # 统一导出(供 desktop-client 与 web-admin 可复用)
├─ schema.ts # IslandLayoutV2 / IslandAppearanceV2 / IslandRowV2 / IslandNodeV2 类型
├─ defaults.ts # ClassIsland 规格默认值(family/行/组件/外观)
├─ normalize.ts # normalizeIslandLayoutV2 / normalizeAppearanceV2(校验+夹取)
├─ migrate.ts # migrateIslandLayoutV1ToV2(旧 config → v2)
├─ metrics.ts # CLASSISLAND_ISLAND_METRICS + WINISLAND_ISLAND_METRICS + 排版系数
├─ geometry.ts # 锚点/包围盒/细缝/触摸贴合尺寸的纯函数(主/渲染共用)
├─ messages.ts # 去重、排序、列表布局、行内气泡分组(纯函数)
├─ lessons.ts # 课程提醒计算(从 main/lesson-reminder.ts 上移)
├─ dto.ts # IslandState/IslandNotification/ScheduleDto 等对外 DTO 与映射
└─ ipc.ts # IPC 通道名与 payload 类型(主进程与 preload 共用)
packages/desktop-client/src/main/island/ # 新增:主进程岛引擎(从 island.ts 拆出)
├─ index.ts # 门面:export { island, registerIslandIpc, ... }(旧 import 不变)
├─ controller.ts # 状态机编排:消息、上课状态、预览、动作分发
├─ window.ts # 建窗、固定包围盒、锚点布局、可见性/焦点/透明度
├─ hit-test.ts # 命中轮询、岛体矩形、interactive 决策(含 setShape 降级)
├─ touch.ts # 触摸模式、贴合、失焦宽限、常驻控件
├─ messages.ts # 队列存储 + 合并 + 超时计时器(无 UI)
├─ lessons.ts # 每秒推进课程提醒
├─ weather.ts # 从 island-weather.ts 移入
├─ courseware.ts # 课件覆盖状态桥
├─ preview.ts # 设置页"测试灵动岛"的预览注入
└─ ipc.ts # island:* 全部 handler(从 island.ts 尾部搬来)
packages/desktop-client/src/island/ # 渲染进程(现有目录内重组)
├─ main.ts # Keep:tokens + motion + mount
├─ LayoutIslandApp.vue # Change:分发(custom→CustomIslandRuntime;fixed 按 family 二选一)
├─ family/
│ ├─ classisland/
│ │ ├─ FixedInfoRow.vue # 新增:ClassIsland 信息行(fixed 新默认)
│ │ ├─ InlineMessageBubble.vue # 新增:行内消息气泡(fixed 下替代详情卡)
│ │ └─ ClassislandTheme.css # 新增:Fluent/Classic 两套行样式与动画
│ └─ winisland/
│ ├─ ClassicIsland.vue # Change:由 IslandApp.vue 迁移并改名(原胶囊+详情卡)
│ └─ winisland.css # Change:由 classic-island.css 迁移(数值不变,仅 fixed 使用)
├─ custom/
│ ├─ CustomIslandRuntime.vue # Change:始终按 ClassIsland 行/组件规格重写测量与适配(不看 family)
│ ├─ CustomRows.vue # 新增:常态行 / 展开行渲染(行级外观)
│ ├─ CustomRow.vue # 新增:单行(行设置、隐藏规则、进度条)
│ └─ CustomRowSettingsDrawer.vue# 新增:行设置抽屉(背景/字号/圆角/隐藏;字段由 09 定义)
├─ shell/
│ ├─ IslandShell.vue # 新增:squircle 形状 + 弹簧形变 + 命中上报(fixed 两族共用)
│ ├─ MessagePanel.vue # Change:由 ClassicNotifications.vue 改造(单条/列表/行内气泡)
│ └─ LessonBar.vue # Change:由 LessonReminderContent.vue 改造(ClassIsland 遮罩)
├─ components/ # 新增:按 kind 拆分的组件渲染器
│ ├─ index.ts # node.kind → 渲染器映射
│ ├─ DateNode.vue / ClockNode.vue / TextNode.vue / CountdownNode.vue
│ ├─ ScheduleNode.vue / WeatherNode.vue / SeparatorNode.vue
│ └─ ContainerNode.vue # group/stack/slide/rolling(rolling 支持 mode continuous|pause)
├─ motion/ # Keep:spring.ts、squircle.ts(可加适配器)
├─ styles/ # 新增:shared.css / classisland.css / custom.css(custom 始终 classisland 命名空间)
└─ Legacy/ # 可选:保留旧文件名再导出门面(ComponentNode.vue 等)
packages/desktop-client/src/renderer/island/ # 主窗口侧的桥(现有)
├─ bridge.ts # Keep:数据桥(课表/上课状态/通知/作业/叫人/补弹/已读)
└─ editor/ # 新增:编辑器逻辑 composables(从 IslandEditorView 抽出)
├─ useIslandDraft.ts # 草稿、脏标记、保存队列、修订号
├─ useIslandDrag.ts # 指针拖拽/落点/自动滚动
└─ useIslandPreview.ts# 内嵌预览缩放与测量
packages/desktop-client/src/renderer/ # 设置/编辑器页面(现有)
├─ views/settings/IslandSettingsView.vue # Change:family 选择器 + 条件外观
├─ views/settings/IslandEditorView.vue # Change:调用新版架构 + 行设置
├─ components/IslandComponentSettings.vue # Change:ClassIsland 组件字段
├─ components/IslandEditorNode.vue # Keep(可加行级拖拽)
├─ components/IslandRowSettingsDrawer.vue # 新增:行设置抽屉
└─ styles/island-editor.css # Change:ClassIsland 编辑器视觉
```
### 4.2 文件级 Keep / Change / Add / Remove 总表
| 文件/目录 | 类型 | 一句话目标 |
|---|---|---|
| `packages/shared/src/island-layout.ts` | Change | 保留导出名兼容;实现迁移到 `island/` 新目录,本文件仅再导出 |
| `packages/shared/src/constants.ts` | Change | 保留旧 WinIsland 常量;新增 ClassIsland 度量与 family 常量(迁移见 05) |
| `packages/shared/src/types.ts` | Change | `IslandAppearance` 增加 `family`/`colorScheme`/`effectEnabled`/`effectScale`/`progressBar`/`theme`/`winislandStyle`;新增 `IslandState.family/pendingStaged/bubblePhase/bubbleDeadline`、`IslandNotification.level`、`IslandLessonReminder.progress/leftSeconds`(运行期,不落盘) |
| `packages/shared/src/island/ipc.ts` | Add | IPC 通道名与 payload 类型;`island:push` 增加 `source`/`dedupeKey` |
| `packages/shared/src/motion.ts` | Keep | 不动令牌数值(03/10 部分引用) |
| `packages/shared/src/class-plan.ts` | Keep | `IslandTimePointDto`/`TimePointType` 不动 |
| `src/main/island.ts` | Change | 2445 行 → 门面(约 30 行),实际逻辑移入 `main/island/*` |
| `src/main/island/*.ts` | Add | 见 4.1(controller/window/hit-test/touch/messages/lessons/weather/courseware/preview/ipc) |
| `src/main/config.ts` | Change | 启动时执行 v2 迁移钩子(唯一版本=`islandLayout.version`);写盘前校验 `family/colorScheme/fontSize/effect*`;保留合并写/退出 flush |
| `src/main/index.ts` | Change | `island.init()` 改调新门面;注册编辑器/预览所需 IPC |
| `src/main/ipc.ts` | Change | 岛相关 handler 全部委托 `main/island/ipc.ts`;主窗口配置剔除规则保留 |
| `src/main/courseware.ts` | Change | 改为调用 `island.setCoursewareTransfer`(门面保留同名方法) |
| `src/main/tray.ts` | Change | 菜单文案与入口不变;`island.editLayout()` 改由新控制器实现 |
| `src/main/island/messages.ts` | Add | 消息队列 + 合并 + 超时 + **作业指纹/去重表**(见 4.3.13) |
| `src/main/island/ipc.ts` | Add | 校验 `island:push` 的 `source/dedupeKey`,投递前查重 |
| `src/main/smoke.ts` | Change | 保留全部既有读取器与注入点(名称/语义不变,见 4.7 第 11 条);新增 v2 断言(行高/行数/气泡/迁移);改造归属 part 11 测试基线 |
| `src/preload/index.ts` | Change | 新增 `family/colorScheme` 感知的配置读写;`islandPush` 透传 `source/dedupeKey`;保留全部旧方法名 |
| `src/preload/island.ts` | Change | 新增行/编辑器预览相关事件;旧事件名与语义不变 |
| `src/types/desktop.d.ts` | Change | 配置形状加外观新字段与 `islandLayout.version`;新增 v2 DTO 类型;`islandPush` payload 扩展 |
| `src/island/LayoutIslandApp.vue` | Change | 分发:`custom` → CustomIslandRuntime;`fixed` 按 family 二选一(见 4.3.1) |
| `src/island/IslandApp.vue` | Change | 迁出为 `family/winisland/ClassicIsland.vue`;旧文件保留再导出(防断裂) |
| `src/island/CustomIslandRuntime.vue` | Change | **始终按 ClassIsland 行/组件规格**重写测量、行渲染、行设置抽屉;不受 family 影响 |
| `src/island/ComponentNode.vue` | Change | 拆为 `components/*Node.vue`;本文件保留兼容导出;滚动组件新增 `settings.mode('continuous'|'pause')` |
| `src/island/ClassicNotifications.vue` | Change | 改为 `shell/MessagePanel.vue`;新增行内气泡模式(同一时刻 1 条 active) |
| `src/island/LessonReminderContent.vue` | Change | 改为 `shell/LessonBar.vue`;遮罩参数按 ClassIsland 重算;fixed 下 prepare/on-class/break 行内容接管、ending 仅徽标 |
| `src/island/CoursewareTransfer.vue` | Keep | 课件覆盖卡保持现状(424×132 等,见 02 §2.10) |
| `src/island/WeatherBrief.vue` | Keep | 天气简报保持现状(Open-Meteo/15 分钟/缓存标记);**本轮不引入 ClassIsland 天气服务** |
| `src/island/spring.ts`、`squircle.ts` | Keep | 弹簧与连续圆角数值不动(10 部分仍按此写) |
| `src/island/classic-island.css` | Change | 改名 `family/winisland/winisland.css`(fixed 专用);数值不变,选择器按新 DOM 调整 |
| `src/island/custom-runtime.css`、`custom-island.css` | Change | 移到 `styles/custom.css` 与 `custom/*`;**始终按 ClassIsland 行规格重写**(custom 不受 family 影响) |
| `src/island/lesson-ending.css` | Keep | 秒数徽标样式不动 |
| `src/island/main.ts` | Keep | 入口与令牌注入顺序不动 |
| `src/renderer/views/settings/IslandSettingsView.vue` | Change | 顶部加 `family` 选择器(只影响 fixed);新增 colorScheme/theme/effectEnabled/effectScale/progressBar;fontSize 语义随 family 展示 |
| `src/renderer/views/settings/IslandEditorView.vue` | Change | 数据改为 v2;行设置抽屉;预览按 ClassIsland 规格;保存队列/上限保留;拆分节奏按 09 文件计划(见 4.3.11) |
| `src/renderer/components/IslandComponentSettings.vue` | Change | 组件字段与 ClassIsland 规格对齐(含滚动 mode);旧字段保留只读兼容 |
| `src/renderer/components/IslandEditorNode.vue` | Keep | 标签节点可复用;若加行级拖拽则小幅 Change |
| `src/renderer/components/IslandRowSettingsDrawer.vue` | Add | 行级设置的唯一入口(字段清单由 09 定义) |
| `src/renderer/island/bridge.ts` | Change | 作业投递增加 `source:'homework-local'|'realtime'|'replay'` 与 `dedupeKey`;保留本机指纹快速路径(见 4.3.13) |
| `src/renderer/views/HomeworkView.vue` | Keep | `markHomeworkCreatedLocally` 调用点保留(录入前/后登记指纹,见 4.3.13) |
| `src/renderer/styles/island-editor.css` | Change | 编辑器视觉按 ClassIsland(间距/分隔/虚线框/预览画布) |
| `packages/desktop-client/scripts/smoke.mjs` | Change | 视觉/几何断言改为按 family 分套;默认 quick 仍无头 |
| `docs/app/island.md`、`docs/dev/implementation.md` | Change | 口径更新为 family 与 fixed 新语义(文档不是代码,但必须与实现同步) |
### 4.3 关键文件改造详情(逐个文件:现状 → 目标 → 接口 → 风险)
#### 4.3.1 `src/island/LayoutIslandApp.vue`(Change,按 layout + family 分发)
- **现状**:只按 `layout==='custom'` 二选一(`CustomIslandRuntime` / `FixedIsland`),课件优先覆盖。依据:`packages/desktop-client/src/island/LayoutIslandApp.vue:56-75`。
- **目标**(定稿分发规则):
- `layout==='custom'` → **始终** `custom/CustomIslandRuntime.vue`(ClassIsland 行/组件规格),**不看 family**;
- `layout==='fixed'` 且 `appearance.family==='classisland'` → 新增 `family/classisland/FixedInfoRow.vue`(信息行 + 行内气泡);
- `layout==='fixed'` 且 `appearance.family==='winisland'` → `family/winisland/ClassicIsland.vue`(原胶囊+详情卡,行为不变);
- 课件传输仍最高优先级,覆盖以上所有分支。
- **接口**:props 保持 `settings/state/schedules/timePoints/appearance/nativeVisible`;新增计算 `family = appearance.family ?? 'classisland'` 仅用于 fixed 分支;不新增 IPC 通道。
- **为什么**:family 只影响 fixed,custom 不需要两套渲染;分发规则收窄后,custom 的数据与视觉可以独立演进。
- **风险**:切换 family 只改变 fixed 渲染器,custom 不受影响——必须保证切换时若当前 layout=custom 不重建 custom 运行时(避免方案展开态/测量状态丢失);切换命中矩形时 `reportHit(null)` 并 `syncTargets(true)`。
#### 4.3.2 `src/island/IslandApp.vue` → `family/winisland/ClassicIsland.vue`(Change,大改)
- **现状**:683 行,胶囊层 + 展开层 + 课程提醒 + 命中上报 + 弹簧形变全在一个文件;窗口固定包围盒内的岛由 `morph` 驱动。依据:`src/island/IslandApp.vue:44-570`。
- **目标**:
1. 保留并迁移全部 winisland 行为(胶囊/展开/紧急/叫人/多条列表/课程提醒/命中),**视觉与交互不变**(老用户回退路径)。
2. 把"壳"(squircle 路径、弹簧、`--card-path`、命中上报)抽到 `shell/IslandShell.vue`,把消息展示抽到 `shell/MessagePanel.vue`,把课程提醒抽到 `shell/LessonBar.vue`;`ClassicIsland.vue` 只负责"胶囊内容 + 展开内容"的编排。
3. 文件顶部保留兼容导出:`export { default } from './family/winisland/ClassicIsland.vue'`,避免旧 import 断裂。
- **接口**:props 必须兼容现有 `{ embedded?, suspended? }`;继续调用 `window.island.sendAction/setInteractive/setHitRect`;继续使用 `ISLAND_SIZE_DELTA`/`ISLAND_LIST_METRICS`。
- **为什么**:ClassIsland fixed 取代胶囊后,旧实现仍要作为 `winisland` 保留;抽壳后两族共用同一套命中/形变,减少重复实现。
- **风险**:抽壳会改变 DOM 层级,现有 CSS 选择器(`.island-card .content`、`.pill-inner`)与冒烟断言依赖 DOM;必须保留 `.island-card`、`.island-root`、`data-anchor`、`data-style` 等关键钩子。
#### 4.3.3 `family/classisland/FixedInfoRow.vue`(Add,fixed 的新默认)
- **目标**:ClassIsland 信息行的固定布局:默认 1 行,行高 40、行内水平内边距 12、行间距 5、圆角 8、背景不透明度 0.5(背景色取主题 token + `appearance.winislandStyle`/surface 材质)、1px 描边、字号由 `appearance.fontSize`(classisland 下=正文基准,默认 16)派生 14/18/20、进度条高度取 `appearance.progressBar.height`(默认 3)、`progressBar.show=false` 时不显示;默认行 = 日期组件 + 课程表组件;消息以**行内气泡**呈现(普通/重要/紧急/叫人/作业),不再弹"详情卡";强提醒特效仅在 `effectEnabled=true` 时叠加,尺寸按 `effectScale`。
- **接口**:props `{ state, appearance, schedules, timePoints, now, editing? }`;内部复用 `components/*Node.vue` 与 `shell/MessagePanel.vue`(行内模式);只读 `appearance.colorScheme` 决定行底色/文字对比。
- **为什么**:ClassIsland 的主界面就是"一行行信息",fixed 只有变成信息行才符合重构目标。
- **风险**:fixed 原有"点胶囊展开卡片"的点击目标消失,必须保证消息可读(行内气泡可点开详情面板);命中矩形上报必须按真实行矩形,不能用旧胶囊尺寸(窗口包围盒由 geometry.ts 计算)。
#### 4.3.4 `family/classisland/InlineMessageBubble.vue`(Add,行内气泡)
- **目标**:**同一时刻只显示 1 条 active 气泡**;点击展开详情/操作;有多条未处理消息时折叠为「还有 N 条」,点击打开应用内通知中心(沿用 ClassHelper 现有通知中心路由),不并列展示多条;两阶段表现为先横向展开成条、再展开详情面板(动画由 08/10 定义);普通/重要/紧急/叫人/作业五类样式;紧急消息仍按现有业务规则"立即展开、无视上课"。
- **接口**:props `{ active, pendingCount, mode, listExpanded }`;动作只通过 `window.island.sendAction`,不得直接改状态;数据来源是 `IslandState.active/queued` 与 `IslandNotification.level`。
- **为什么**:ClassIsland 的提醒是"行内/气泡"而非独立卡片;不改变业务队列,只换表现。
- **风险**:行内气泡只改变行内内容测量与截断(正文最多 2 行、超出省略),**不改变窗口包围盒**(fixed 窗口尺寸固定为 min(工作区宽,960) × (40+2×pad));「还有 N 条」跳转必须带未读上下文,不得丢失已读回执。
#### 4.3.5 `custom/CustomIslandRuntime.vue`(Change,大改;始终 ClassIsland 规格)
- **现状**:三页(compact/details/lesson)交叉淡入 + 自然内容测量 + 铃铛 + 命中上报;行/组件来自 `scheme.rows`。依据:`src/island/CustomIslandRuntime.vue:52-336,338-527`。
- **目标**:保留"测量 + 三页"骨架,**统一按 ClassIsland 行规格**重写(**不区分 family**):单行基准高 40、行间距 5、行内水平留白 12、行背景/圆角/不透明度/字号覆盖、行级隐藏规则、行级进度条(3px,含义见 05 §5.3);行设置抽屉入口(字段由 09 定义);`rows`/`expandedRows` 的编辑语义不变;滚动组件支持 `settings.mode: 'continuous'|'pause'`(pause 时到边界停顿 `settings.interval`)。
- **接口**:props 不变;新增读取 `row.appearance/row.hideRules/row.progress`;`window.island.reportResidentControl` 继续上报铃铛矩形;`window.island.reportSize` 继续上报适配后尺寸;CSS 使用 `classisland` 命名空间(custom 不受 family 影响)。
- **为什么**:ClassIsland 组件库/行模型与本应用 schemes 高度相似,且 family 最终只作用于 fixed;custom 统一到 ClassIsland 规格可避免"两套自定义外观"。
- **风险**:老 custom 用户升级后视觉会变化(自定义布局统一到 ClassIsland 规格),必须在发布说明与设置页提示;`ResizeObserver` + `requestAnimationFrame` 测量链对行数敏感,改写后的几何只由 metrics 计算,不用动画中的壳反推(现状注释见 `:73-79`)。
#### 4.3.6 `src/main/island.ts` → `main/island/*`(Change + Add,大重构)
- **现状**:2445 行单文件(分区见 02 §2.2)。
- **目标**:
- `index.ts`:门面导出 `island`、`registerIslandIpc`、`recomputeSizes`、`normalizeAppearance`、`getSliverSize`、`ISLAND_SIZES` 等旧导出名,**保证 `main/index.ts`、`tray.ts`、`courseware.ts`、`smoke.ts` 的 import 不改**。
- `controller.ts`:状态与编排;持有 `IslandState`;消息/上课/预览/动作分发。
- `window.ts`:建窗、固定包围盒/触摸贴合、锚点、可见性/焦点/透明度、`syncWindow` 拆分后的"可见性决策"。
- `hit-test.ts`:60ms 轮询、`setInteractive`、`setHitRect`、`expectedHitRect`、`setShape` 降级(非 Windows 见 4.6)。
- `messages.ts`:`pushNotification` 分流、合并、排序、超时计时器、`dismissActive`、`clearPendingForClose`、`handleAction`。
- `lessons.ts`:`LessonReminderTracker` 每秒推进与 `lessonShowing` 判定。
- `ipc.ts`:`registerIslandIpc()` 全部 handler(含发送方校验与外观合并节流 16ms)。
- **接口**:必须保留全部旧公开方法与冒烟读取器(02 §2.5 第 9 条清单);新增 `getMetrics()`、`getLayoutVersion()` 供编辑器/冒烟断言。
- **为什么**:不拆文件无法安全替换 fixed 渲染、无法让 engine 复用 shared 纯函数,也无法让 ChatGPT 在单文件里做可靠改动。
- **风险**:拆分过程中最容易出现"私有字段跨模块互相引用";必须先把 `IslandState`、`WindowBox`、`HitRect`、`Metrics` 定义到 `types`,模块间只经接口通信;拆完立即用现有 `verify:desktop`(无头)回归。
#### 4.3.7 `src/island/ComponentNode.vue` → `components/*Node.vue`(Change)
- **现状**:456 行,14 类 kind 的条件渲染 + 课程表折叠算法 + 编辑手柄 + 行内样式。依据:`src/island/ComponentNode.vue:29-210,212-293`。
- **目标**:按 kind 拆文件,`components/index.ts` 提供 `resolveNodeRenderer(kind)`;每个组件只负责自己的内容与样式;课程表的"折叠/展开/隐藏/进度/徽标"逻辑放进 `ScheduleNode.vue` 并复用 02 §2.8/§2.9 规则。
- **接口**:保持 `IslandComponentNode` 数据结构不变(字段名/默认值见 `packages/shared/src/island-layout.ts:77-108`);编辑态仍用 `node-handle` 拖拽句柄。
- **为什么**:ClassIsland 组件规格要为每类组件补字段(字号覆盖、进度条、显示模式),单文件条件链会继续膨胀。
- **风险**:`<style scoped>` 拆分后类名作用域变化;必须把跨组件公共样式放 `styles/components.css`,避免样式丢失。
#### 4.3.8 `src/island/ClassicNotifications.vue` → `shell/MessagePanel.vue`(Change)
- **现状**:单条详情卡 + 多条列表卡 + 按钮行;只用于展开态。依据:`src/island/ClassicNotifications.vue:104-233`。
- **目标**:同一组件支持三种形态:`detail`(winisland 详情卡)、`list`(winisland 多条列表)、`inline`(classisland fixed 行内气泡);按钮语义(打开应用/标为已读/知道了/收到)与批量语义不变;`canMarkRead` 排除作业的规则保留。
- **接口**:props 增加 `variant: 'detail'|'list'|'inline'`;保持 `state/appearance/maxHeight`。
- **为什么**:fixed 默认不再有详情卡,但消息仍需可操作;共用一套面板避免三处重复实现按钮/已读逻辑。
- **风险**:`inline` 形态的高度必须计入行高与窗口包围盒(由 `shared/island/messages.ts` 提供),不得在渲染层单独硬编码。
#### 4.3.9 `src/island/LessonReminderContent.vue` → `shell/LessonBar.vue`(Change)
- **现状**:5 区斜切遮罩 + 进度条 + 秒数徽标;遮罩区段 360ms/错峰 90-120ms、开 `1-(1-p)^4`、关 `p²`;文字 260ms、scale 1.1→1.0、750ms。依据:`src/island/LessonReminderContent.vue:59-123,133-137`。
- **目标**:按 ClassIsland 规格校准:遮罩斜切权重 `[0,0.1,0.2,0.4,0.2,0.1]`(与 03/14 部分一致)、区段 360ms、错峰 120ms、开 QuarticEaseOut、关 QuadraticEaseIn、文字 delay 260ms;行高 40、圆角 8。**叠加口径(定稿)**:fixed(classisland)下 `prepare/on-class/break` 以**行内容接管**呈现(临时替换课程表内容,动画由 08/10 定义);`ending` 不接管行内容,只在课程名右侧显示蓝色圆形秒数徽标;custom 不接管(保持课程组件 + 徽标)。
- **接口**:props 保持 `{ reminder, now, appearance, motion }`;新增 `variant: 'winisland'|'classisland'`。
- **为什么**:ClassIsland 的提醒遮罩是"整行被斜切色块覆盖 + 文字弹入",与现有实现同源但参数不同;必须抽成共用组件。
- **风险**:`ref` 宽度依赖 `ResizeObserver`,在 fixed 行内宽度变化频繁;必须避免测量死循环(只在行宽变化时重算多边形)。
#### 4.3.10 `src/renderer/views/settings/IslandSettingsView.vue`(Change)
- **现状**:布局二选一、外观滑块、测试预览。依据:`src/renderer/views/settings/IslandSettingsView.vue:26-160,162-392`。
- **目标**:
1. 顶部先选 **风格族 family**(classisland 默认 / winisland 兼容)——**只影响 fixed**,说明文案必须写清"自定义布局始终使用 ClassIsland 规格";
2. `fixed` 的说明按 family 变(classisland=fixed 信息行;winisland=fixed 胶囊+详情卡);
3. 新增外观控件:`colorScheme`(浅色/深色/跟随系统,默认跟随)、`theme`(fluent/classic,仅 classisland)、`effectEnabled`(强提醒特效总开关)、`effectScale`(1/0.75/0.5)、`progressBar.show/height`(默认 3px)、`fontSize`(classisland 显示为"正文字号(16 基准)"、winisland 显示为旧"基础字号(13 基准)");
4. 调色板 `winislandStyle`(black/glass/tinted)仅在 winisland 下显示;切换 family 时**保留用户原值**,不重置;
5. "测试灵动岛"必须按当前 family + layout 推预览。
- **接口**:`window.desktop.saveConfig({ island: {...} })`、`islandSetAppearance` 不变;新增字段随 `island` 下发(纯数据,禁止传 Proxy)。
- **为什么**:family 是重构后最上层的开关,必须最先可见;新增外观字段需要用户可发现、可关闭。
- **风险**:切换 family 会立即改变 fixed 外观;必须先弹确认"切换后仅 fixed 表现改变,自定义方案与 style 不变",且不得自动改 layout。
#### 4.3.11 `src/renderer/views/settings/IslandEditorView.vue`(Change)
- **现状**:组件标签 + 内嵌预览 + 方案菜单 + 组件库 + 检查器 + 指针拖拽 + 保存队列。依据:`IslandEditorView.vue:25-130,130-540,541+`。
- **目标**:数据切到 v2;增加"行"选择与行设置抽屉(`IslandRowSettingsDrawer.vue`,字段清单由 09 定义);预览统一按 ClassIsland 规格渲染(custom 编辑也用 `CustomRows`);保留上限(200 组件/20 方案)、保存队列、离开前保存、JSON 导入导出。
- **接口**:仍通过 `window.desktop.getConfig/saveConfig`;`normalizeIslandLayouts` 改为 `normalizeIslandLayoutV2`;`getIslandSchedules/getIslandTimePoints` 不动。
- **为什么**:ClassIsland 编辑器的核心是"行/组件/行设置抽屉/组件库拖拽",现有编辑器已有 80% 骨架。
- **风险**:编辑器是 855 行大文件;**拆分节奏按 09 的文件计划执行,不强制本轮先抽 composables**;若单文件继续膨胀,再按 4.1 的 `renderer/island/editor/` 抽取,抽取时不得改变保存队列/修订号语义。
#### 4.3.12 `src/main/config.ts`(Change,迁移接入点)
- **现状**:`PersistedConfig` 含 `islandLayout`/`island`,逐字段归一化,120ms 合并写。依据:`src/main/config.ts:26-84,198-233,334-346`。
- **目标**:**不新增文件级版本号**;读取 `islandLayout` 时若 `version` 缺失 → 调用 `migrateIslandLayoutV1ToV2`(含 family/外观默认值,见 05),迁移结果写回并备份;`saveConfig` 收到 `islandLayout` 时先 `normalizeIslandLayoutV2`;`island` 增加 `family/colorScheme/theme/effectEnabled/effectScale/progressBar/winislandStyle` 归一化。
- **接口**:导出 `getConfig/saveConfig/flushConfigSync` 签名不变;新增 `getIslandLayoutV2()` 供主进程使用。
- **为什么**:迁移必须在任何消费者(岛窗口、编辑器、冒烟)之前完成,且只能发生一次;版本字段只保留 `islandLayout.version` 可避免两套版本号分叉。
- **风险**:迁移写盘与"用户正在编辑"竞争;必须保证迁移只在 `app.whenReady()` 后、岛窗口创建前执行一次,失败时保留原文件并让应用以默认 v2(只读)启动。
#### 4.3.13 作业指纹链路(本机刚录作业不上岛)
- **现状**:渲染层 `src/renderer/island/bridge.ts` 用"内容指纹 + id"避免本机录入的作业弹上岛——`homeworkFingerprint()` 由 `classId|assignDate|title|content` 拼成,`markHomeworkCreatedLocally()` 在发请求前登记指纹、回来后补记 id,`isLocalHomework()` 在 `pushHomeworkToIsland()` 前判定;指纹 TTL 60 秒。依据:`bridge.ts:191-267`;调用点在 `src/renderer/views/HomeworkView.vue:419-428`(录入前/后登记)。
- **目标(双层去重,职责明确)**:
1. **渲染层(快速路径)**:保留现有 `isLocalHomework()` 判定,命中则根本不发 `islandPush`;`markHomeworkCreatedLocally()` 调用点保留,不改交互时序。
2. **主进程(兜底去重)**:`island:push` payload 增加 `source: 'realtime'|'homework-local'|'replay'|'call'` 与 `dedupeKey`(作业用同一指纹;普通通知/叫人用 `source+id`);`main/island/ipc.ts` 校验字段、`main/island/messages.ts` 维护 LRU 去重表(key→过期时间,TTL 60s),命中直接丢弃并记 debug 日志。
3. **IPC 归属**:渲染层 `bridge.ts` 计算并传 `source/dedupeKey` → preload `islandPush` 原样透传 → 主进程 `main/island/ipc.ts` 校验 → `messages.ts` 去重后入队。不得在 preload 里做业务判断,也不得让主进程反查渲染层 store。
- **接口**:`IslandPushPayload`(`packages/shared/src/island/ipc.ts`)新增两个可选字段;旧调用点缺字段时主进程按 `id` 去重(兼容)。
- **为什么**:现状只有渲染层一层判定,实时事件先于接口响应到达时仍可能漏判;主进程兜底可保证"同一台机器录的作业绝不上岛",且离线补弹/重连重复投递也被覆盖。
- **风险**:指纹跨班/跨天误伤(必须带 classId+assignDate);主进程去重表不能无限增长(LRU+TTL);冒烟需要能注入/清理去重表(提供 `testResetHomeworkDedupe()` 或在冒烟模式禁用去重,名称归 part 11 测试基线)。
### 4.4 IPC 契约 v2(增量、不改旧通道语义)
| 通道 | 方向 | 现状 | v2 变更 |
|---|---|---|---|
| `island:get-layout` | 岛→主 | 返回 `islandLayout` | 返回 v2(`version:2`;含 fixed/schemes) |
| `island:save-layout` | 岛→主 | 归一化后保存 | 先 `normalizeIslandLayoutV2`,返回 v2 |
| `island:state` | 主→岛 | `IslandState` | 增加 `family/pendingStaged/bubblePhase/bubbleDeadline`(运行期,不落盘);旧字段不删 |
| `island:appearance` | 主→岛 | `IslandAppearance` | 增加 `family/colorScheme/theme/effectEnabled/effectScale/progressBar/winislandStyle`;旧字段保留 |
| `island:push` | 应用窗口→主 | `{notification, context}` | 增加 `source`/`dedupeKey`(作业指纹去重,见 4.3.13);缺省按 `id` 去重 |
| `island:action` | 岛→主 | 8 种动作 | 保留;新增 `open-message`(classisland fixed 行内气泡打开详情)与 `open-notifications`(「还有 N 条」打开应用内通知中心) |
| `island:content-size` | 岛→主 | 自定义尺寸上报 | 增加 `mode:'custom'|'classisland-fixed'` |
| `classisland:island:get-metrics` | 新 | — | 返回当前 layout/family 的 metrics(编辑/冒烟/调试用) |
| `classisland:island:editor-open` | 主→应用窗口 | 已有 `island:open-editor` | 保留旧通道;新通道可带 `{rowId,nodeId}` 定位 |
| `island:set-hit-rect`/`island:set-interactive` | 岛→主 | 已有 | 语义不变;fixed 信息行按真实行矩形上报 |
1. payload 可携带 `schemaVersion:2` **仅用于运行时校验/调试**,**不得落盘**;落盘版本只有 `islandLayout.version`。接收方遇到未知字段必须忽略而不是拒绝。
2. 通道名继续集中在 `src/main/channels.ts` 或 `shared/island/ipc.ts` 的**单一常量表**中;preload 不得直接写字符串字面量。
3. 旧通道(`island:get-layout` 等)不得改名/删除;改名只能通过"新通道 + 旧通道转发"完成,至少保留一个版本周期。
4. 所有冒烟注入/读取通道(`island:set-interactive`、`island:set-hit-rect`、`island:set-touch-mode`、`island:alive` 及主进程方法 `setTestInputPassthrough/setHitTestCursor/getWindowBounds/getHitRect/getInteractive/setBlurCollapseEnabled/setTouchMode/getTouchMode/isRecovering`)名称与语义保持不变,改造归属 part 11 测试基线。
### 4.5 迁移与门面的落地顺序(必须按序提交)
1. **M0 建契约层**:`shared/island/` 类型、默认值、normalize、migrate、metrics、geometry、messages、lessons、ipc;只加不删,旧 `island-layout.ts`/`constants.ts` 保持再导出。
2. **M1 拆主进程**:`main/island/*` 拆分 + 门面;行为与现状 1:1(不改变任何可见行为),用无头 `verify:desktop` 回归。
3. **M2 迁移层接入**:`config.ts` 读时迁移 + 版本号;用旧配置夹具验证(fixed/custom 各存一份)。
4. **M3 渲染层抽壳**:`shell/IslandShell.vue`、`MessagePanel.vue`、`LessonBar.vue`;winisland 外观不变。
5. **M4 新增 classisland fixed**:`FixedInfoRow.vue` + `InlineMessageBubble.vue` + `IslandSettingsView` family 选择器;默认新装走 classisland,老配置走 winisland;family 只影响 fixed。
6. **M5 自定义行改造**:`CustomIslandRuntime` 行级外观/隐藏规则/进度条/滚动 mode/行设置抽屉;custom 始终 ClassIsland 规格(老 custom 用户升级后有视觉变化,需在发布说明提示)。
7. **M6 编辑器改造**:v2 数据、行设置、预览、保存队列。
8. **M7 收尾**:CSS/文档/冒烟断言/迁移回滚演练。
每一步都必须"可独立回滚":新增文件先经 feature 开关(`appearance.family`/`config.islandLayout.version`)控制;不得在某一步同时删除旧文件与旧逻辑。
### 4.6 风险清单与强制缓解
| 风险 | 触发点 | 缓解(必须实现) |
|---|---|---|
| 拆分 `island.ts` 导致隐藏状态丢失 | M1 | 先对齐私有字段到接口;拆分后逐项对比 `getState()/getWindowBounds()/getHitRect()` 冒烟输出 |
| DOM/CSS 选择器断裂 | M3/M4 | 保留 `.island-card/.island-root/[data-anchor]/[data-style]` 钩子;视觉回归由 --visual 专项做 |
| 两套 family 逻辑分叉 | M4 | 业务状态机只保留一份(controller);family 只选择 fixed 渲染器,custom 不分叉 |
| fixed 语义变化引起老用户困惑 | M4 | 老配置迁移默认 `winisland`;设置页显著位置显示 family 并解释两种 fixed;custom 升级有视觉变化的提示 |
| custom 用户升级后观感变化 | M5 | 统一 ClassIsland 规格是定稿;发布说明+设置页提示;数据不变、可回滚 config 备份 |
| 迁移把用户自定义方案写坏 | M2 | 迁移前备份 `config.json`;迁移失败保留原文件;提供"恢复旧版布局" |
| 编辑器 855 行继续膨胀 | M6 | 先抽 composables;单文件不超过 500 行;新逻辑进 `renderer/island/editor/` |
| 非 Windows 触摸命中退化 | M1 | `setShape` 仅 win32 分支;其它平台走"整窗穿透 + 可见区命中"(02 §2.4 第 5 条) |
| 性能(行数/组件数增加) | M5 | metrics 计算缓存;测量只在依赖变化时执行;列表/行使用 `will-change` 谨慎 |
| 无头验证覆盖不到视觉 | M7 | 默认 quick 保持无头;视觉断言只在显式 `--visual` 下跑,并在文档写明 |
### 4.7 验收标准(第 4 部分)
1. 仓库中存在 `packages/shared/src/island/` 与 `packages/desktop-client/src/main/island/` 两个新目录,且 `src/main/island.ts` 只剩门面(≤50 行,内容为 import/export 与类型再导出)。
2. `src/main/index.ts`、`tray.ts`、`courseware.ts`、`smoke.ts` 对 `./island.js` 的 import 与调用**不需要改动**(门面签名兼容)。
3. `LayoutIslandApp.vue` 分发正确:`custom` 始终 `CustomIslandRuntime`(与 family 无关);`fixed` 在 classisland 下用 `FixedInfoRow`、winisland 下用 `ClassicIsland`;切换不新建第二个窗口、不残留旧命中矩形。
4. `fixed` 在 `family='classisland'` 下渲染为信息行(默认 1 行:日期 + 课程表;行高 40、圆角 8、背景不透明度 0.5、`progressBar.height` 默认 3px、消息行内气泡);在 `family='winisland'` 下保留原胶囊+详情卡行为与既有冒烟断言。
5. `CustomIslandRuntime` 支持行级外观/隐藏规则/进度条,行设置抽屉可打开并保存;滚动组件支持 `mode:'continuous'|'pause'`;`rows`/`expandedRows` 编辑语义与现状一致。
6. 外观新字段全部落地:`family`(只影响 fixed)、`colorScheme`、`theme`、`effectEnabled`、`effectScale`、`progressBar`、`winislandStyle`;`fontSize` 在 classisland 下为正文基准(新装 16)、winisland 下旧语义(13,迁移保留原值)。
7. 编辑器 / 设置页 / 组件设置全部读写 v2 数据;旧 v1 配置在首次启动被迁移且不丢字段(迁移细节按 05 验收)。
8. 所有新增/修改的 IPC 通道登记在单一常量表;旧通道全部保留;preload 未暴露 `ipcRenderer`;`island:push` 带 `source/dedupeKey` 且主进程有兜底去重表。
9. 默认 `pnpm verify:desktop`(无头 quick)通过;不得因为重构把任何断言改成"跳过";冒烟注入点名称/语义全部保留。
10. 代码中不存在 `setBackgroundMaterial`;不存在绕过 contextBridge 直接访问 Node 的渲染层代码;custom 不受 family 影响、不引用 winisland 样式命名空间。
11. 作业指纹链路双层生效:本机录入后 60 秒内,实时事件/接口响应先后到达都不产生岛消息;跨班/跨天不误伤;去重表有 TTL 上限且可被冒烟清理。
12. 文件级改造清单(4.2/4.3)逐项落地;`docs/app/island.md`、`docs/dev/implementation.md` 的 family/fixed/custom 描述与实现一致。
---
## 5. 数据契约、schema v2 与配置迁移
### 5.0 总则(必须遵守)
1. 本章定义重构后的**唯一数据契约**。所有读写岛配置的代码(主进程 `config.ts`、岛渲染进程、编辑器、设置页、冒烟)必须只经过 `packages/shared/src/island/schema.ts` 的类型与 `normalize.ts`/`migrate.ts` 的函数,不得各自解析 JSON。
2. 旧配置必须**无损升级**:`config.json` 现有的 `islandLayout`(fixed/custom、schemes、nodes、expandedRows、residentEnabled、pointerFade、lessonReminders)与 `island`(appearance 全字段)一个都不许丢;升级失败必须可回滚。
3. 新增 `appearance.family`:取值 `'classisland' | 'winisland'`。**新装默认 `classisland`;已存在配置的老用户升级默认 `winisland`**。`fixed` 的语义随 family 分叉:`classisland` 下 fixed = ClassIsland 信息行(默认 1 行 = 日期 + 课程表,行高 40、圆角 8、不透明度 0.5、3px 进度条、消息行内气泡);`winisland` 下 fixed = 原胶囊 + 详情卡。
4. `islandLayout.version` 必须写入;当前版本 `2`。版本号是**唯一迁移判据**,不得用"字段是否存在"猜版本(字段判断只作为迁移内部的兜底)。
5. 写入必须沿用现有 `config.json` 的"内存缓存 + 120ms 合并写 + 退出前 flush"语义(`packages/desktop-client/src/main/config.ts:235-346`),**不得**为迁移新增同步写盘路径;迁移本身例外(必须一次写成功并立即 flush)。
6. 客户端不得把 v2 数据写回给旧版本可见的字段后又不写版本号;写 v2 必须同时写 `version:2`,保证降级/回滚可判断。
### 5.1 落点与版本治理
| 数据 | 位置 | 版本字段 | 说明 |
|---|---|---|---|
| 运行期配置 | `<userData>/config.json` | 无独立文件版本 | 版本只在 `islandLayout.version` 上表达(唯一版本字段) |
| 岛布局 | `config.json.islandLayout` | `version`(新增,当前 2) | 方案/行/节点/外观的结构版本;唯一迁移判据 |
| 岛外观 | `config.json.island` | 无独立版本 | 增加 `family`、`theme`、`effectEnabled`、`effectScale`、`progressBar`、`fontSizes`;`style` 保留为 surface 层 |
| 迁移前备份 | `<userData>/config.json.v1.bak` | — | 迁移前完整拷贝,只保留一份(覆盖式) |
| 迁移标记 | `<userData>/config.island-migration.json` | `{from,to,at,appVersion}` | **仅诊断用**,不参与版本判断、不是第二套版本字段 |
1. **只保留 `islandLayout.version` 一个落盘版本字段**:不得新增 `config.schemaVersion` 或其它文件级版本号;迁移判定只看 `islandLayout.version`(缺失=v1)。
2. 迁移必须在 `app.whenReady()` 后、`island.init()` 之前完成一次;不得由渲染进程触发,也不得在岛窗口创建后再迁移(否则窗口会先按旧数据渲染一帧)。
3. 迁移成功后必须立即 `flushConfigSync()`;失败时不得写回半成品:保留原 `config.json`,删除 `.tmp`,应用以内存默认 v2 运行并在设置页显示"迁移失败,已保留原配置"。
4. 读到 `islandLayout.version > 2` 时应用必须**只读**(不写回),并在设置页提示"配置来自更高版本"。
### 5.2 `appearance` v2(`config.json.island`)
在现有 `IslandAppearance` 基础上**只增不改**,旧字段全部保留(`packages/shared/src/types.ts:1145-1195`)。新增/语义变化如下:
| 字段 | 类型 | 默认值(新装) | 说明与依据 |
|---|---|---|---|
| `family` | `'classisland' \| 'winisland'` | `'classisland'`(老用户迁移为 `'winisland'`) | **风格族字段的唯一落点**;**作用域只限 fixed**(classisland=信息行;winisland=旧胶囊+详情);`custom` 始终 ClassIsland 规格、不受影响 |
| `colorScheme` | `'light' \| 'dark' \| 'system'` | `'system'` | 岛配色模式:跟随应用主题(system,默认)/强制浅色/强制深色;只影响岛自身渲染,不改主窗口主题 |
| `theme` | `'fluent' \| 'classic'` | `'fluent'` | 仅 `family='classisland'` 的 fixed 使用;对应 ClassIsland Fluent/Classic 两套主题;具体 token 由 06 定义 |
| `effectEnabled` | boolean | `true` | **强提醒特效总开关**(对应 ClassIsland 的提醒特效 Topmost/Effect 是否启用);关闭后只保留行内气泡/普通提醒 |
| `effectScale` | `1 \| 0.75 \| 0.5` | `1` | 强提醒特效渲染缩放(对应 ClassIsland `NotificationEffectRenderingScale`,默认 1.0);只允许三档 |
| `progressBar` | `{height:number, show:boolean}` | `{height:3, show:true}` | 岛内进度条统一配置:高度默认 3px,总开关默认显示;语义见 5.3 行级进度条说明 |
| `fontSize` | number | classisland=`16`(正文基准,派生 14/18/20 阶梯);winisland=`13`(旧语义) | `family='classisland'` 时它是正文基准字号,`fontSizes` 由它派生;`family='winisland'` 保持旧语义;**迁移时旧配置保留原值,新安装默认 16** |
| `fontSizes` | `{secondary:number, body:number, emphasized:number, large:number}` | 由 `fontSize` 派生:classisland `14/16/18/20`;winisland `≈14/16/18/20`(按 `fontSize/13` 比例) | ClassIsland 四档字号;显式配置优先于派生(见 5.4) |
| `height` / `width` | number | `44` / `268` | 仅 `family='winisland'` 的胶囊尺寸;classisland 下不作为信息行尺寸 |
| `radius` | number | `20`(winisland)/`8`(classisland 行为默认) | classisland 下作为行默认圆角;解析时取 `family` 对应默认 |
| `opacity` | number | `1` | 岛整体不透明度;classisland 下行背景不透明度另见行设置(默认 0.5) |
| `winislandStyle` | `'black' \| 'glass' \| 'tinted'` | `'black'` | **winisland 调色板字段的 canonical 名**:纯黑/半透明/主题色渐变;仅 `family='winisland'` 的 fixed 使用 |
| `style` | `'black' \| 'glass' \| 'tinted'` | 镜像 `winislandStyle` | **兼容镜像字段(不删除)**:旧配置读入时若没有 `winislandStyle` 则用它;写出时同时写 `winislandStyle` 与 `style`,保持旧版本可读;建议 v3 再评估废弃 |
| `position`/`alwaysOnTop`/`animations`/`speed`/`idleSliver`/`marginX`/`marginY`/`followCursorDisplay`/`accent` | 不变 | 不变 | fixed(两种 family)与 custom 共用;classisland fixed 也用停靠与边距 |
1. **字段职责必须严格区分**:`family` 只决定 **fixed** 用哪套渲染器(ClassIsland 信息行 / WinIsland 胶囊);`winislandStyle`(兼容镜像 `style`)只决定 winisland 的调色板;`theme` 只决定 classisland fixed 的 Fluent/Classic;`colorScheme` 只决定浅色/深色/跟随。四者不得互相覆盖、不得合并。`custom` 不使用 `family`/`theme`/`winislandStyle`(始终 ClassIsland 规格),但仍读取 `colorScheme` 决定明暗。
2. `family` 归一化:只接受两个值,非法值回落 `'classisland'`(新装默认);迁移函数负责把老用户写成 `'winisland'`(见 5.4)。
3. `colorScheme` 归一化:只接受 `light/dark/system`,非法回落 `system`;缺失补 `system`。
4. `fontSize` 归一化:8–96;classisland 下作为正文基准,`fontSizes` 缺失时由它派生(body=`fontSize`,secondary=`round(fontSize*14/16)`,emphasized=`round(fontSize*18/16)`,large=`round(fontSize*20/16)`);winisland 下保持旧语义(`fontSizes` 缺失时按 `fontSize/13 × 14/16/18/20` 推导)。迁移时旧 `fontSize` 原值保留,不强行改成 16。
5. `fontSizes` 归一化:每项 8–96;显式值优先于派生;缺失项按第 4 条补齐。
6. `theme` 仅 classisland fixed 生效;非法值回落 `'fluent'`;`winisland` 下忽略但不删除该字段。
7. `effectEnabled` 必须为 boolean(缺省 true);`effectScale` 必须夹取到 `[1, 0.75, 0.5]`(缺省 1,非法值取 1);`progressBar.height` 夹取 1–8、`progressBar.show` 缺省 true;`progressBar` 缺失时整对象补默认。
8. `winislandStyle` 归一化:仅 `black/glass/tinted` 合法,非法回落 `black`;`style` 作为镜像字段同步写;读入优先 `winislandStyle`,回退 `style`。**切换 family 时保留用户 `winislandStyle` 原值,不重置**(UI 默认值提示归 09)。
### 5.3 `islandLayout` v2(`config.json.islandLayout`)
v2 顶层(`IslandLayoutV2`):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `version` | number | `2` | 必须存在 |
| `layout` | `'fixed' \| 'custom'` | `'fixed'` | 布局模式(沿用旧字段) |
| `fixed` | `FixedSchemeV2` | 见下 | classisland fixed 信息行配置;winisland 下忽略 |
| `schemes` | `IslandLayoutSchemeV2[]` | 1 个默认方案 | 自定义方案(沿用旧结构并扩展) |
| `activeSchemeId` | string | `'default'` | 当前方案 |
| `residentEnabled` | boolean | `layout==='custom'` | 兼容字段;不得再作为独立开关 |
| `pointerFadeEnabled` / `pointerFadeReversed` | boolean | `true` / `false` | 鼠标避让淡化(沿用) |
| `lessonReminders` | `LessonReminderSettings` | 沿用旧默认 | 准备/上课/课间/结束/课中进度开关与秒数 |
| `updatedAt` | number | `0` | 最近保存时间(仅诊断) |
`FixedSchemeV2`(classisland fixed 的信息行):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `rows` | `IslandRowV2[]` | 1 行 = 日期 + 课程表 | 信息行;默认只有一行 |
| `mainRowId` | string | 首行 id | 主行(承载消息气泡/课程提醒) |
| `rowHeight` | number | `40` | ClassIsland 行高(32–64 夹取) |
| `rowSpacing` | number | `5` | 行间距(0–150) |
| `backgroundOpacity` | number | `0.5` | 行背景不透明度 |
| `cornerRadius` | number | `8` | 行圆角(0–20) |
| `progressBarHeight` | number | `3` | 行内进度条高度(3px) |
| `messageBubble` | `'inline' \| 'card'` | `'inline'` | fixed 下消息表现:inline=行内气泡,card=老式详情卡(仅回退用) |
| `maxRows` | number | `3` | 信息行最多行数(自动换行/溢出策略由 07 定义) |
`IslandLayoutSchemeV2`(在 v1 方案上扩展):
| 字段 | 类型 | 默认值 | 变化 |
|---|---|---|---|
| `id`/`name`/`rows`/`expandedRows` | 沿用 | 沿用 | 结构不变;行内容升级为 `IslandRowV2` |
| `scale` | number | `1` | 0.75–2.5(沿用于 `CLASSISLAND_ISLAND_METRICS.scaleMin/Max`) |
| `color`/`foreground`/`opacity`/`radius`/`separated`/`spacing` | 沿用 | 沿用 | `radius` 0–20;`spacing` 0–150 |
| `rowHeight` | number | `40` | 新增:行高 32–64 |
| `rowSpacing` | number | `5` | 新增:与 `spacing` 同步;`spacing` 保留兼容 |
| `theme` | `'fluent' \| 'classic'` | 继承 `appearance.theme` | 新增:方案级主题覆盖 |
| `fontSizes` | `{secondary,body,emphasized,large}` | 继承 `appearance.fontSizes` | 新增:方案级字号覆盖 |
| `progressBarHeight` | number | `3` | 新增:行内进度条高度 |
| `unrecognizedNodes` | `object[]` | `[]` | 新增:未知 kind 的原始节点(**不得丢弃**,见 5.5 第 6 条) |
`IslandRowV2`(行):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `id`/`name`/`main`/`allowNotifications`/`gap`/`nodes` | 沿用 | 沿用 | `main` 行唯一;`gap` 0–64 |
| `appearance` | `RowAppearance` | 见下 | 行级外观(新增) |
| `hideRules` | `RowHideRules` | 全 false | 行级隐藏规则(新增) |
| `progress` | `RowProgress` | 见下 | 行级进度条(新增) |
`RowAppearance`:`backgroundEnabled=false`、`backgroundColor='#000000'`、`backgroundOpacity=0.5`、`foregroundColor='#ffffff'`、`cornerRadius=8`、`opacity=1`、`fontSizes`(继承方案/外观)。
`RowHideRules`:`inClass=false`、`noMessage=false`、`noClass=false`、`fullscreen=false`;全部为 false 表示不隐藏。语义对应 ClassIsland 的行隐藏规则(`repos/ClassIsland/ClassIsland.Core/Models/Components/MainWindowLineSettings.cs` 的 `HideOnRule`/`HidingRules`)。
`RowProgress`:`enabled=true`、`style='line'`、`height=3`、`color='accent'`、`showRemaining=true`。
**行内 3px 进度条的语义(数据来源不在本层实现)**:进度条表示"当前显示内容的时间进度"——消息气泡显示时 = 该消息剩余显示时间;否则 = 当前课程进度。具体数据来源与取值规则由 07/08 定义,本 schema 只保证 `progress`(行级)与 `appearance.progressBar`(全局)提供 `height/show` 两个开关;行设置抽屉的完整字段由 09 定义,本章只登记字段名(`appearance/hideRules/progress`)并要求与 09 对齐,不得在 05 里发明抽屉字段。
`IslandNodeV2`(节点,在 v1 `IslandComponentNode` 上扩展,字段名不变):
| 字段 | 变化 |
|---|---|
| `id`/`kind`/`appearance`/`advanced`/`children` | 沿用(`appearance` 四档字号默认改为继承行/方案) |
| `settings.schedule.display` | 沿用 `'current'|'next'|'today'`;新增 `today` 折叠规则由此驱动 |
| `settings.schedule.progressStyle` | 新增:`'bar'|'line'`,默认 `'line'`(3px) |
| `settings.schedule.showProgress` | 沿用;默认 `true` |
| `settings.schedule.showPeriod`/`count` | 沿用 |
| `settings.mode`(仅 `kind='rolling'`) | 新增:`'continuous' | 'pause'`,默认 `'continuous'`;`pause` 表示滚动到边界后停顿,停顿时长由同组件的 `settings.interval`(秒,1–300)控制 |
| `advanced.hideRules` | 沿用 `in-class/no-message/no-class`;新增 `fullscreen`(与行级一致) |
| `advanced.rowSpan` | 新增:仅容器组件使用,默认 `1` |
**默认方案(新装/重置)**:`layout:'fixed'`、`family:'classisland'`、`fixed.rows=[{main:true, allowNotifications:true, gap:12, nodes:[date, schedule]}]`,`schemes=[默认方案:main 行含 date/clock/schedule/notification]`。数值依据:`packages/shared/src/island-layout.ts:217-255`(现有默认方案)+ `CLASSISLAND_ISLAND_METRICS`(`:19-36`)。注意:`schemes` 是 custom 布局数据,**描述 ClassIsland 规格**且不随 family 改变;`fixed` 配置只在 `family='classisland'` 时由 fixed 渲染器读取。
### 5.4 v1 → v2 迁移算法(必须逐步实现,可重入)
**输入判定**:
1. 读取 `config.json`(不存在 → 全新安装:直接构造 v2 默认,`family='classisland'`,**不写迁移标记**)。
2. 文件存在但 `islandLayout.version` 缺失:视为 v1(唯一版本判据;**不得**再引入 `schemaVersion` 作为第二套版本号)。
3. 老用户判定:`islandLayout.version` 缺失 **或** 文件里存在 `islandLayout`/`island` 任一字段 → 迁移时 `family='winisland'`(保持原外观,不惊吓用户)。
4. 全新安装判定:文件不存在、或既无 `islandLayout` 又无 `island` → `family='classisland'`。
**步骤(必须按序、每步失败可中止)**:
```text
step 0 备份:把 config.json 原样复制到 config.json.v1.bak(存在则覆盖);写 config.island-migration.json {from,to,at}(仅诊断,不是版本字段)
step 1 外观迁移(appearance):
- family := 'winisland'(老用户)| 'classisland'(新装)
- colorScheme := 'system'(缺失补默认;已有值原样保留)
- winislandStyle := 旧 style(缺省 'black');style := winislandStyle(兼容镜像)
- theme := 'fluent'(新装默认;老用户也写 'fluent',仅 classisland fixed 生效)
- effectEnabled := true(缺失补默认)
- effectScale := 1(缺失补默认;只允许 1/0.75/0.5)
- progressBar := { height:3, show:true }(缺失补默认)
- fontSize:迁移保留旧值(classisland 新装写 16;老用户写原值)
- fontSizes := 由 fontSize 派生:
老用户(winisland):secondary=round(fontSize*14/13), body=round(fontSize*16/13),
emphasized=round(fontSize*18/13), large=round(fontSize*20/13)
新装(classisland):14/16/18/20(fontSize=16 为正文基准)
- 所有旧字段原样保留(height/width/radius/opacity/accent/fontSize/style/...)
step 2 布局迁移(islandLayout):
- version := 2(唯一落盘版本字段)
- layout := 旧 layout(缺省 'fixed')
- residentEnabled := (layout==='custom')(覆盖旧的独立值,保证一致)
- pointerFadeEnabled/pointerFadeReversed/lessonReminders 原样保留
- fixed := 若旧文件无 fixed 字段 → 生成 FixedSchemeV2 默认(1 行 date+schedule;progressBar 继承 appearance.progressBar)
- schemes := 旧 schemes 逐个升级:
rowHeight := 40;rowSpacing := 旧 spacing;theme := 'fluent';
fontSizes := 继承 appearance.fontSizes;progressBarHeight := appearance.progressBar.height;unrecognizedNodes := []
每行补 appearance/hideRules/progress 默认;
每节点补 settings.schedule.progressStyle='line'、advanced.rowSpan=1;
若节点 kind='rolling' 补 settings.mode='continuous'(pause 保留旧 interval 语义)
- 未知 kind 的原节点移入该方案的 unrecognizedNodes(保留原始 JSON,不参与渲染)
step 3 校验与夹取:调用 normalizeIslandLayoutV2 / normalizeAppearanceV2(见 5.5);
校验失败项收集成 warnings,不得中止;结构性错误(schemes 为空等)用默认方案兜底
step 4 写回:saveConfig({ islandLayout, island }) 后立即 flushConfigSync()(**不写 schemaVersion**)
step 5 读回校验:重新读取并 deep-equal 比较关键字段(islandLayout.version/family/layout/方案数/节点数);
不一致 → 从 config.json.v1.bak 恢复、删除迁移标记、报错并降级为"只读默认 v2"
```
1. **可重入/幂等**:若 `islandLayout.version===2`,迁移函数必须直接返回原对象(不写盘、不改字段);重复执行不得产生二次修改。
2. **老用户 fixed 不变**:迁移后 `family='winisland'`,fixed 仍渲染原胶囊+详情卡(`ClassicIsland.vue`),因此老用户升级后视觉 0 变化;只有用户主动在设置页切到 `classisland` 或新装用户才看到信息行。
3. **custom 老用户**:`custom` **始终按 ClassIsland 规格渲染、不受 family 影响**;迁移只升级数据(补行级默认、滚动 mode 等),不改 schema 结构。老 custom 用户升级后视觉会变为 ClassIsland 行规格(这是定稿行为),发布说明与设置页必须提示;fixed 用户(默认 winisland)不受影响。
4. 编辑器打开时必须先读迁移后的 v2;若 `islandLayout.version` 不匹配(例如用户用旧版应用改过文件),必须触发一次"再迁移"而不是把 v1 当 v2 解析。
5. 迁移必须记录 warning 列表到内存并在设置页"存储/诊断"可查看(哪几项被夹取、哪几个未知组件被保留)。
### 5.5 校验与夹取(`normalizeIslandLayoutV2` / `normalizeAppearanceV2`)
必须在 shared 中实现,主进程保存前、编辑器加载后、IPC 存入前**三处共用同一个函数**。规则:
1. `version`:仅接受 `2`;缺失/非法按迁移处理,不得静默改成 2 后继续用旧结构。
2. `layout`:`'fixed'|'custom'`,非法回落 `'fixed'`。
3. `family`:`'classisland'|'winisland'`,非法回落 `'classisland'`。
4. 数量上限:行 ≤12/方案、节点 ≤200/全部方案、嵌套深度 ≤6、方案 ≤20;`main` 行唯一(多余取消)。
5. 数值夹取:`rowHeight 32–64`、`rowSpacing 0–150`、`gap 0–64`、`radius 0–20`、`opacity 0–1`、`backgroundOpacity 0–1`、`fontSizes 8–96`、`scale 0.75–2.5`、`rowSpan 1–6`、`progressBarHeight 1–8`;非法/NaN 回落默认值(禁止 `Math.max(min, NaN)` 这种写法,参考 `src/main/island.ts:2225-2241` 的 fallback 说明)。
6. 未知 kind:**不得丢弃**。把原始节点 JSON 放入 `scheme.unrecognizedNodes`(最多 200 条),渲染时忽略并显示"未知组件(已保留)";这样用旧版应用打开再保存也不会丢数据。
7. 颜色:统一 `#rrggbb` 小写;非法回落默认;透明度字段独立,不把 `#rrggbbaa` 塞进颜色字段。
8. id:空/非字符串/重复 → 重新生成(`node-<n>`/`row-<n>`/`scheme-<n>`);id 最长 100 字符。
9. 文本:`settings.text` ≤2000 字符、`locationName/countdownTitle` ≤64;超长截断并记 warning。
10. 经纬度:`latitude -90..90`、`longitude -180..180`,可空;空表示未配置(组件显示"请设置天气地点")。
11. 未知字段:v2 对象里出现但类型未定义的字段必须**原样保留**在对象上(不删除),以便前向兼容。
12. `lessonReminders`:`prepareSeconds 5–1800`、`endingSeconds 5–600`(依据现有 `packages/shared/src/island-layout.ts:390-398`)。
13. `winislandStyle` / `style`:两者都只接受 `black/glass/tinted`,非法回落 `black`;读入优先 `winislandStyle`、回退 `style`,写出两者同步(兼容镜像);**不得**因 family 迁移而删除 `style`;family 与调色板相互独立(family 决定 fixed 用哪套渲染,winislandStyle 决定 winisland 的调色板)。
14. `theme`:只接受 `fluent/classic`,非法回落 `fluent`;具体色板/圆角/阴影 token 由 06 定义,05 只登记字段与默认值。
15. `colorScheme`:只接受 `light/dark/system`,非法回落 `system`;`system` 时跟随应用 `html.dark` 状态决定岛明暗(映射逻辑由渲染层实现,数据层只存枚举)。
16. `fontSize`:8–96,缺省按 family 取(classisland=16 正文基准、winisland=13 旧基线);`fontSizes` 缺失时按 5.2 第 4 条派生;显式 `fontSizes` 优先;迁移不得覆盖旧 `fontSize`。
17. `effectEnabled`:boolean,缺省 `true`;`effectScale`:只接受 `1/0.75/0.5`,缺省 `1`,非法取 `1`;`progressBar`:`{height:1–8, show:boolean}`,缺省 `{3,true}`。
18. 进度条高度优先级:`行 progress.height` > `方案 progressBarHeight` > `appearance.progressBar.height`;任一缺失沿上一级回落,最终默认 3。`progressBar.show=false` 时所有行都不显示(行级 `enabled=false` 只关该行)。
19. 滚动组件:`settings.mode` 只接受 `continuous/pause`,缺省 `continuous`;`pause` 时停顿秒数复用 `settings.interval`(夹取 1–300);`continuous` 时 `speed` 夹取 1–200(沿用现有范围)。
20. 渲染/样式命名空间**只由 layout(fixed 的 family)决定,不写入数据**:`fixed + family='winisland'` → winisland 命名空间;`fixed + family='classisland'` → classisland 命名空间;**`custom` 始终 classisland 命名空间、不受 family 影响**;normalize 不得改写样式类名/选择器相关字段。
21. `IslandNotification.level`、`IslandLessonReminder.progress/leftSeconds`、`IslandState.family/pendingStaged/bubblePhase/bubbleDeadline` 均为**运行期派生字段**,normalize 不处理、不落盘。
### 5.6 DTO 映射(ClassHelper 数据 → ClassIsland 语义)
**fixed(classisland)行内气泡规则**:同一时刻只显示 1 条 active 气泡;点击展开详情/操作;有多条未处理消息时不并列显示,而是折叠为「还有 N 条」,点击打开应用内通知中心(沿用 ClassHelper 现有通知中心路由);消息队列、去重、上课暂存、已读回执规则完全不变(02 §2.5/§2.12)。
**`IslandState` → ClassIsland 主界面状态**:
| ClassHelper 字段 | ClassIsland 语义 | 渲染映射(classisland fixed) |
|---|---|---|
| `mode:'hidden'` | 岛不可见(仅提醒特效时临时显示) | 信息行隐藏/仅保留细缝 |
| `mode:'pill'` | 紧凑态 | 行内气泡/铃铛提示 |
| `mode:'expanded'` | 展开态 | 行展开 + 消息面板 |
| `active`/`queued` | 提醒队列(当前播放/待播) | 行内气泡列表,按 `PRIORITY_RANK` 排序 |
| `inClass` | `TimeState=OnClass` | 行隐藏规则 `inClass` 生效、提醒遮罩可用 |
| `currentPeriodEnd` | 当前时间点结束 | 进度条/倒计时取值 |
| `reason` | 提醒来源(新消息/课后补发/紧急/叫人/收回中) | 气泡入场动画与角标 |
| `listExpanded` | 列表是否展开更多 | 行内"展开更多" |
| `lessonReminder` | 课级提醒(本地) | 行覆盖层(遮罩/秒数徽标) |
| `coursewareTransfer` | 课件提示(覆盖态) | 最高优先级覆盖全部行 |
**`IslandNotification` → ClassIsland 提醒语义**:
| ClassHelper 字段 | ClassIsland 对应 | 规则 |
|---|---|---|
| `priority: LOW/NORMAL/HIGH/URGENT` | 提醒队列优先级(`NotificationPriority`) | URGENT=插队立即播放;HIGH/NORMAL/LOW 按排序入队 |
| `kind:'notification'` | 普通提醒(Mask+Overlay 两阶段) | 两阶段时长取 5s/5s(`repos/ClassIsland/...NotificationRequest.cs:17-20`) |
| `kind:'homework'` | 作业提醒(本地合成 id `homework-<id>`) | 不进入已读回执;不可"标为已读" |
| `kind:'call'` | 叫人(Call) | 上课时保持可点、不自动展开;紧急叫人按 URGENT |
| `title/content/teacherName/courseName/createdAt` | 提醒标题/正文/来源/时间 | 气泡第一行标题、第二行来源+时间 |
| `id` | 去重键 | 双链路(通知/叫人)合并时 id 相同即合并(见 02 §2.5 第 3 条) |
**`ScheduleDto` → ClassIsland 课表语义(仅映射,不落 Profile 文件)**:
| ClassHelper 字段 | ClassIsland 概念 | 映射规则 |
|---|---|---|
| `id` | `ClassInfo`/条目标识 | 仅内部使用 |
| `course.courseName` | `Subject.Name` | 直接展示;null 显示"—" |
| `dayOfWeek`(1–7) | `TimeRule.WeekDay` | 1=周一…7=周日 |
| `startTime`/`endTime`(HH:mm) | `TimeLayoutItem.StartTime/EndTime` | 转 `HH:mm:00`;只用于显示与进度计算 |
| `weekStart`/`weekEnd` | 周次范围 | 用于"本周是否有课" |
| `weekParity: ALL/ODD/EVEN` | `TimeRule.WeekCountDiv`(0/1/2)+ `WeekCountDivTotal=2` | ALL→0;ODD→1;EVEN→2 |
| `location` | 附加信息 | 仅在气泡/详情展示,不参与时间点 |
**`IslandTimePointDto` → `TimeLayoutItem`**:
| ClassHelper | ClassIsland | 规则 |
|---|---|---|
| `timeType: LESSON/BREAK/DIVIDER/ACTION` | `TimeType: 0/1/2/3` | 一一对应(`repos/ClassIsland/...TimeLayoutItem.cs:119-138`) |
| `name`(课间名/时间点名) | `BreakName`/名称 | 课间空时显示"课间休息" |
| `courseName` | 默认课程 | null 等价 `Guid.Empty`(无课) |
| `startTime`/`endTime` | 开始/结束 | 用于行内课程表折叠与进度 |
| `isHideDefault` | `IsHideDefault` | 折叠时隐藏、轮到时展开 |
1. 上述映射只用于**渲染语义对齐**,不得把 `ScheduleDto` 落盘成 ClassIsland 档案;数据仍以服务端解析结果为准(02 §2.12)。
2. `CoursewareTransfer` 映射为"最高优先级覆盖态",不进入提醒队列、不参与已读;映射字段 `name/category/teacherName/receivedBytes/totalBytes/progress/stage/error` 直接使用(`packages/shared/src/courseware.ts`、`src/island/CoursewareTransfer.vue:12-65`)。
**运行期状态扩展(`IslandState`,不落盘,必须随状态推送)**:
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
| `family` | `'classisland' \| 'winisland'` | 当前 `appearance.family` | 当前生效风格;渲染进程据此切换 CSS 命名空间 |
| `pendingStaged` | number | `0` | 上课期间被暂存、下课后待补弹的消息数(对应现有 `queued` 中因上课被压住的数量;`queued.length` 不变) |
| `bubblePhase` | `'idle' \| 'mask-in' \| 'content-in' \| 'out'` | `'idle'` | 行内气泡/提醒的两阶段播放阶段(固定 5s/5s 时长的可视化映射) |
| `bubbleDeadline` | number | `0` | 当前气泡自动收起的时间戳(毫秒);`0` 表示无 |
1. `IslandState` 的新字段只存在于**运行期推送**(`island:state`),不得写进 `config.json`、不得被编辑器保存;旧渲染进程缺字段时必须按默认值渲染(前向兼容)。
2. `pendingStaged` 必须由主进程根据现有上课暂存逻辑计算(02 §2.5 第 2 条),不得让渲染进程自行推断。
**`IslandNotification.level`(展示层级,派生字段)**:
| `priority` | `level` | 用途 |
|---|---|---|
| `URGENT` | `'emergency'` | 紧急气泡/强提醒特效可触发 |
| `HIGH` | `'high'` | 高亮气泡/角标 |
| `NORMAL` | `'normal'` | 普通气泡 |
| `LOW` | `'low'` | 弱化展示 |
`level` 由 priority 派生,**不落库、不持久化**;通知进入岛队列时在 `main/island/messages.ts` 统一计算,渲染层只读取。
**`IslandLessonReminder` 扩展字段(运行期,不落盘)**:
| 字段 | 类型 | 语义 |
|---|---|---|
| `progress` | number (0–1) | 当前提醒/课程进度:`ending/in-class` = `(now-lessonStartsAt)/(lessonEndsAt-lessonStartsAt)`;其余 = `(endsAt-now)/(endsAt-startedAt)`(与现有 `LessonReminderContent.vue:36-50` 计算一致) |
| `leftSeconds` | number | 下课前剩余秒(`ceil((lessonEndsAt-now)/1000)`,clamp ≥0),供蓝色圆形秒数徽标与遮罩标题使用;现有 `LessonReminderContent.vue:24-35` 已有同义计算 |
| `kind` | 不变 | 仍为 `prepare/on-class/break/ending/in-class` |
**fixed(classisland)下的提醒叠加口径**:`prepare`/`on-class`/`break` 以**行内容接管**呈现(临时替换课程表内容,动画由 08/10 定义);`ending` 不接管行内容,只在课程名右侧显示蓝色圆形秒数徽标(`leftSeconds` 驱动);`custom` 模式不接管,保持课程组件 + 秒数徽标。该口径只影响渲染,不改变 02 §2.8 的五类提醒计算与开关语义。
### 5.7 IPC payload 版本与兼容
1. IPC payload **可以**携带 `schemaVersion:2` 仅用于运行时校验/调试,**不得落盘、不得作为配置版本判据**;配置版本只认 `islandLayout.version`。`island:state`/`island:appearance`/`island:get-layout` 三个高频 payload 建议带上该字段(旧字段保留,新增字段可选)。
2. 接收方规则:**未知字段忽略、缺字段回落默认**;不得因为缺 `family` 就拒绝渲染(老渲染进程遇到新主进程时应按 `winisland` 降级,反之按 `classisland`)。
3. `island:save-layout` 必须返回归一化后的 v2 结果(编辑器以返回值为准,避免"本地看着对、主进程夹取后不一致")。
4. 主进程 `getConfig()` 返回给渲染进程的 `islandLayout` 必须是 v2;`island` 必须含 `family` 与新增外观字段(`theme/effectEnabled/effectScale/progressBar`)。
5. 旧通道名全部保留(见 04 §4.4);新增通道必须同时在 `shared/island/ipc.ts` 与 preload 中登记,不得字符串散落。
6. 作业指纹相关 payload(见 04 §4.3.13)必须带 `source`(`realtime/homework-local/replay/call`)与 `dedupeKey`;主进程按 key 去重,渲染进程保留现有"本机刚录作业免上岛"快速路径。
### 5.8 编辑器预览与临时状态(不持久化)
1. 编辑器的内嵌预览、缩放、选中项、拖拽态属于**临时状态**,不得写入 `config.json`;只有"应用/保存"才写 v2。
2. "无消息展开内容(expandedRows)"与常态 `rows` 分开编辑、分开保存;`activeSchemeId` 只在切换方案时写。
3. 设置页"测试灵动岛"仍走 `islandPush` + `context.preview=true`(30 秒预览、失焦不收起,见 02 §2.15),预览消息 id 必须带前缀(如 `appearance-test-`)以便清理;预览不写历史、不改已读。
4. 编辑器打开时若检测到迁移 warning,必须在顶部显示"部分旧设置已按新格式调整(可查看详情)",不得静默。
### 5.9 回滚与降级
1. **回滚入口**:设置页"灵动岛 → 恢复升级前布局"必须存在;回滚步骤:先备份当前 `config.json` → 把 `config.json.v1.bak` 覆盖回去(该文件天然没有 `islandLayout.version`,即 v1)→ 提示重启 → 重启后旧版本行为恢复(family=winisland/fixed=胶囊)。
2. **迁移失败降级**:应用不得崩溃;以内存默认 v2 启动(不写盘),岛显示默认信息行,设置页显示错误与"恢复备份"按钮。
3. **前向兼容**:读到 `islandLayout.version>2` 时,应用只读该配置(不覆盖写),在设置页提示"配置由更高版本创建";不存在其他版本字段可用于判断。
4. **文件损坏**:`config.json` 非法 JSON 时沿用现有 `.bak` 回退逻辑(`config.ts` 现状:读取失败回落默认),回滚后仍必须按 `islandLayout.version` 缺失执行一次迁移判定;不得用损坏文件覆盖 `.bak`。
5. **旧版应用打开新文件**:不可避免(旧版不认识 v2 字段),因此 v2 必须保持"未知字段保留"原则(5.5 第 11 条)并把关键新数据放在旧版会**忽略而不是报错**的位置;旧版保存后新字段可能丢失,需在文档提示用户不要跨版本反复切换。
### 5.10 版本变更记录与兼容矩阵
必须维护一张可追溯的变更表(代码里以常量数组或文档形式存在),用于判断"某个字段从哪版开始有、哪版废弃":
| 变更 | 版本 | 兼容策略 |
|---|---|---|
| `islandLayout.version` 新增(唯一落盘版本字段) | v2 | 缺失=1,按 5.4 迁移 |
| `appearance.family` 新增 | v2 | 缺失时:老配置迁移=winisland;新装=classisland |
| `appearance.theme`(fluent/classic)新增 | v2 | 缺失=fluent;仅 classisland 生效;具体 token 由 06 定义 |
| `appearance.effectEnabled` / `effectScale` 新增 | v2 | 缺失 `true`/`1`;effectScale 只允许 1/0.75/0.5 |
| `appearance.progressBar {height,show}` 新增 | v2 | 缺失 `{3,true}`;高度优先级 行 > 方案 > 外观 |
| `appearance.fontSizes` 新增 | v2 | 缺失时按旧 `fontSize` 比例推导(5.4 step1) |
| `fixed`(信息行)新增 | v2 | 缺失时生成默认 1 行(date+schedule);仅 classisland fixed 使用 |
| 行级 `appearance/hideRules/progress` 新增 | v2 | 缺失时补默认;字段名与 09 对齐;winisland fixed 下保留数据但忽略(custom 始终使用) |
| 方案级 `rowHeight/rowSpacing/theme/fontSizes/progressBarHeight` 新增 | v2 | 缺失时继承 appearance 默认 |
| `appearance.colorScheme` 新增 | v2 | 缺失=`system`;非法回落 `system` |
| `appearance.fontSize` 语义按 family 分叉 | v2 | classisland=正文基准(新装 16,派生 14/18/20);winisland=旧语义(13);迁移保留旧值 |
| `appearance.winislandStyle` 新增,`style` 转兼容镜像 | v2 | 读优先 `winislandStyle`、回退 `style`;写两者同步;`style` 不删除;切换 family 保留原值 |
| 滚动组件 `settings.mode` 新增 | v2 | 缺失=`continuous`;非法回落 `continuous`;`pause` 复用 `settings.interval` |
| `residentEnabled` 转纯兼容 | v2 | 读出后与 `layout==='custom'` 对齐;不再作为独立开关 |
| `family` 作用域 | v2 | **只影响 fixed**;custom 始终 ClassIsland 规格、不受 family 影响 |
| 旧 `style` 字段 | 保留镜像 | 本轮不删除;与 family/winislandStyle 独立;建议 v3 再评估废弃 |
| 迁移标记文件 `config.island-migration.json` | 2 | 仅诊断用途;不是版本字段、不参与运行逻辑 |
| 回滚文件 `config.json.v1.bak` | 2 | 迁移前覆盖写;回滚后仍保留,供二次回滚 |
**兼容矩阵(读写方向必须明确)**:
1. **新读旧**(v2 代码读 v1 文件):允许,走 5.4 迁移;迁移后内存中只存在 v2。
2. **新读新**:允许,走 normalize;`islandLayout.version` 更高时只读。
3. **旧读新**(旧代码读 v2 文件):旧代码会忽略未知字段继续运行(因为 v2 把新字段放在其不认识的键上),但**旧代码保存后会丢新字段**;因此必须在文档与发布说明中写明"不要用旧版应用反复编辑新配置"。
4. **新写旧**:禁止。v2 代码写盘必须带 `islandLayout.version:2`,不得输出 v1 结构,也不得写任何文件级版本号。
5. **迁移与编辑器并发**:编辑器打开时若检测到 `config.json` 被迁移,必须重新加载(比较 `updatedAt`/`mtime` 或读取返回值),不得用旧草稿覆盖迁移结果;保存前再次 `getConfig` 核对 `islandLayout.version`。
**兼容性红线(不得违反)**:
- 不得删除任何仍被旧配置使用的字段名(如 `style`、`residentEnabled`、`spacing`、`expandedRows`、`pointerFadeEnabled`)。
- 不得把对象结构从"方案数组"改成"对象字典"或反之(旧编辑器/旧冒烟会解析失败)。
- 不得改变 `ISLAND_LIST_METRICS` 与 `ISLAND_SIZE_DELTA` 的**语义**(可以新增 classisland 专用 metrics,但旧常量必须继续导出且数值不变),因为大量冒烟断言按旧值计算(`packages/shared/src/constants.ts:344-351,379-402`)。
- 不得让 v2 的 `rows` 与 v1 的 `rows` 在同一键上结构不兼容:v2 行只**增加**字段,不重命名已有字段。
### 5.11 验收标准(第 5 部分)
1. 新装启动后 `config.json` 不含任何 `schemaVersion`;含 `island.family:'classisland'`、`island.colorScheme:'system'`、`island.fontSize:16`、`island.theme:'fluent'`、`island.effectEnabled:true`、`island.effectScale:1`、`island.progressBar:{height:3,show:true}`、`islandLayout.version:2`、`fixed.rows` 默认 1 行(日期+课程表);`layout:'fixed'`,界面呈现 ClassIsland 信息行。
2. 用一份旧 v1 配置(fixed、custom 各一份,含 schemes/nodes/appearance/residentEnabled)启动:迁移成功、无字段丢失、`family:'winisland'`、旧胶囊外观不变、`fontSize` 保留原值、`style`/`winislandStyle` 一致;`config.json.v1.bak` 存在。
3. 迁移后再次启动:不重复迁移、不重复写盘(可用文件 mtime/内容哈希断言幂等)。
4. 迁移失败模拟(把 `config.json` 改坏/只读)时:原文件不被破坏、应用以默认 v2 启动、设置页显示错误与恢复入口。
5. "恢复升级前布局"可把 v1 备份恢复回来并重启生效;恢复前自动再备份当前配置。
6. `normalizeIslandLayoutV2` 对行数>12、节点>200、深度>6、方案>20、非法颜色/数值/kind 全部夹取或保留到 `unrecognizedNodes`;不崩溃、不丢未知组件。
7. 老用户切到 `classisland` 后 fixed 变信息行、切回 `winisland` 恢复胶囊;**family 切换时 `style`/`winislandStyle` 与 `fontSize` 保持用户原值不变**;custom 始终 ClassIsland 规格、切换 family 不改变 custom 渲染。
8. 编辑器中常态行/展开行分开保存;应用后 `islandLayout` 为 v2 且 `island:save-layout` 返回值与本地一致。
9. `IslandState`/`IslandNotification`/`ScheduleDto`/`IslandTimePointDto` 的映射表逐项实现:优先级、上课态、时间点类型、单双周、作业/叫人特殊语义均可按 5.6 追踪。
10. IPC 兼容:旧通道全部可用;payload 可选带 `schemaVersion` 仅作校验(不落盘);缺字段时按 family 降级渲染而不是报错。
11. 编辑器/设置页不把预览、缩放、选中项写入配置;预览消息不写历史、不改已读。
12. 读到 `islandLayout.version>2` 时应用只读并提示,不得覆盖用户文件。
13. 外观新字段:关闭 `effectEnabled` 后强提醒特效不再渲染(行内气泡/普通提醒仍工作);`effectScale` 只接受 1/0.75/0.5 并实际改变特效尺寸;`progressBar.show=false` 时所有行进度条隐藏,行级高度覆盖方案/外观。
14. 运行期状态:`IslandState` 含 `family/pendingStaged/bubblePhase/bubbleDeadline` 且**不写入 config.json**;上课暂存时 `pendingStaged` 与队列数一致;气泡阶段按 `idle→mask-in→content-in→out` 推进。
15. 派生字段:`IslandNotification.level` 按 `URGENT→emergency/HIGH→high/NORMAL→normal/LOW→low` 生成且不落盘;`IslandLessonReminder.progress` 在 0–1、`leftSeconds` 与蓝色圆形徽标一致(剩余 1 秒显示 `-1`)。
16. fixed(classisland)下 `prepare/on-class/break` 以行内容接管呈现、`ending` 只显示秒数徽标;custom 下不接管(课程组件 + 徽标);两种 family 的提醒开关语义一致。
17. fixed 行内气泡同一时刻只显示 1 条 active;多条折叠为「还有 N 条」并可打开应用内通知中心;队列/去重/暂存规则不变。
18. `family='winisland'` 只影响 fixed(保持现有胶囊观感);custom 始终按 ClassIsland 规格渲染、不受 family 影响、不引用 winisland 样式命名空间;`family='classisland'` 的 fixed 使用 classisland 命名空间。
19. 经 `pnpm verify:desktop`(默认无头 quick)验证时,所有既有冒烟注入点仍可用:`setTestInputPassthrough`/`setHitTestCursor`/`getWindowBounds`/`getHitRect`/`getInteractive`/`setBlurCollapseEnabled`/`setTouchMode`/`getTouchMode`/`isRecovering` 等名称与语义不变(改造归属 part 11 测试基线)。
20. `colorScheme` 三种取值均生效:`system` 跟随应用深浅色(`html.dark`)、`light`/`dark` 强制;非法值回落 `system`;切换后岛即时重渲染且不改主窗口主题。
21. `fontSize` 语义按 family:classisland 下改 `fontSize` 会派生 14/18/20 阶梯并整体缩放行文字;winisland 下保持旧"基准字号"行为;迁移用户旧值不被覆盖。
22. 滚动组件 `settings.mode`:`continuous` 持续滚动、`pause` 到边界停顿 `settings.interval` 秒;normalize 后取值合法,旧配置缺失补 `continuous`。
---
## 6. 设计 tokens 与视觉基线(双风格:ClassIsland 默认 / winisland 兼容)
本节是整轮重构的「视觉地基」。实现者必须先把 token 落到 `packages/desktop-client/src/island/` 下的独立样式入口,再改任何两个岛的渲染文件。所有像素、颜色、字号、圆角、阴影、透明度必须从这里取值,**禁止**在 `IslandApp.vue`、`CustomIslandRuntime.vue`、`ComponentNode.vue`、`ClassicNotifications.vue`、`LessonReminderContent.vue`、`custom-runtime.css` 里写裸值。
### 6.0 双风格模型(必须先建,再谈 token)
外观契约新增/改为(`packages/shared/src/types.ts` 的 `IslandAppearance` 扩展,`config.ts` 的 `normalizeIslandAppearance` 同步):
| 字段 | 类型 | 默认 | 含义 | 证据 |
|---|---|---|---|---|
| `family` | `'classisland' \| 'winisland'` | 新装 `classisland`;**老用户升级为 `winisland`**(见 §6.12 迁移规则) | 视觉家族 | Lead 裁决第 1 条(`parts-r/01-objective.md:60`) |
| `theme` | `'fluent' \| 'classic'` | `fluent` | 仅 `family='classisland'` 有效;对应 ClassIsland 两套 XamlTheme | `repos/ClassIsland/ClassIsland/App.Services.xaml.cs:297-315` |
| `colorScheme` | `'light' \| 'dark' \| 'system'` | `system` | 仅 `family='classisland'` 有效;跟随应用深浅色 | `repos/ClassIsland/ClassIsland/Models/Settings.cs:979-989`、`Services/ThemeService.cs:53-63` |
| `style` | `'black' \| 'glass' \| 'tinted'` | `black` | 仅 `family='winisland'` 有效(现有语义不变) | `packages/shared/src/constants.ts:265-290,324-330` |
| `animations` | `boolean` | `true` | 岛动画总开关 | `constants.ts:273`;`IslandApp.vue:249` |
| `speed` | `number` | `1`(0.5–2) | 动画速度倍率(按比例缩放所有时长) | `constants.ts:274`;`IslandApp.vue:291` |
| `fontSize` | `number` | `13`(11–20) | **base 字号**,四档字号由它派生 | `constants.ts:272`;`IslandApp.vue:527` |
| `height` / `width` | number | 44 / 268 | **仅 winisland** 的胶囊几何;classisland 下用于「信息行最小宽/高」约束(见 §6.5) | `constants.ts:266-267`;`IslandApp.vue:188-204` |
| `radius` | number | 20(8–32) | winisland 胶囊基准半径;classisland 下取 `8`(范围 0–20),见 §6.4 | `constants.ts:268`;`IslandApp.vue:209-211` |
实现要求:
1. 岛根元素必须同时挂三个数据属性,CSS 用它们切 token 与几何:`data-family="classisland|winisland"`、`data-theme="fluent|classic"`、`data-color-scheme="light|dark"`(winisland 恒为 `dark`)。禁止用 JS 拼内联样式切风格。
2. `family='classisland'` 时**不使用** `spring.ts` / `squircle.ts`:几何用标准 `border-radius` + CSS transition;`family='winisland'` 时继续用弹簧形变与超椭圆路径(Lead 裁决第 4 条,`parts-r/01-objective.md:52`)。
3. 两套 family 的 token **不得混用**:classisland 的静态值定义在 `--ci-*`;winisland 的调色板保持 `--wn-*`;运行时外观注入继续用 `--island-*`(两套共用这几个动态变量:`--island-font`/`--island-radius`/`--island-accent`/`--island-dur`/`--island-anim`)。
4. Token 文件必须是**单一来源**:新建 `packages/desktop-client/src/island/tokens.css`,在 `island.html` 的样式链最前面 import(`src/island/main.ts` 已 import `@classhelper/ui/tokens.css`,见 `main.ts:1`),并由 `IslandApp.vue` 与 `CustomIslandRuntime.vue` 共同继承。禁止在 `.vue` 的 `<style>` 里重新定义全局 token。
5. 主题切换动画只允许改颜色类 token,**绝不**覆盖圆角/间距/字号/动画 token(与主窗口纪律一致:`docs/dev/motion.md:125-126`)。
### 6.1 变量分层与命名规范
| 层 | 前缀 | 归属 | 是否可被运行时覆盖 | 本重构动作 |
|---|---|---|---|---|
| 1 应用外壳 | `--ch-ui-*`、`--ch-*`、`--el-*` | `packages/ui/src/tokens.css`、`theme.css`、`renderer/styles/index.css` | 是(主窗口主题) | **KEEP,不改值**;岛只读引用(如编辑器 UI) |
| 2 ClassIsland 岛静态 token | `--ci-*` | 新增 `src/island/tokens.css` | 否(改值只能改源码) | **NEW** |
| 3 岛运行时外观 | `--island-*` | `IslandApp.vue:522-528`、`CustomIslandRuntime.vue:102-123` 注入 | 是(设置页实时) | **KEEP 并扩展**(补 `--island-row-*`) |
| 4 winisland 调色板 | `--wn-*` | `classic-island.css:60-82`(`.island-card` 内) | 是(`data-style` 切换) | **KEEP**(仅 winisland 生效) |
| 5 组件方案 | `--custom-*`、`--resident-*`、`--row-gap` | `custom-island.css`、`custom-runtime.css` 由 `scheme` 注入 | 是 | **KEEP**,值按 §6.5 改默认 |
命名冲突处理(必读):现有 `LessonReminderContent.vue:133-137` 已在组件作用域注入 `--ci-speed`、`--ci-duration`、`--ci-title-duration`、`--ci-scale-duration`、`--ci-delay`,`ComponentNode.vue`/`custom-runtime.css` 还使用 `--ci-body-font`。这些是**组件级**变量,与本节新增的静态 token 同前缀,容易误伤。处理办法:
- 新增静态 token 中**禁止**使用这 6 个名字;
- M5 阶段把 `LessonReminderContent.vue` 的 5 个变量重命名为 `--ci-lesson-speed/-duration/-title-duration/-scale-duration/-delay`,保留旧名作为 alias(`var(--ci-lesson-duration, var(--ci-duration, 200ms))`)一版后删除;
- `--ci-body-font` 保留(custom 行基准字号),在 tokens.css 中定义为 `var(--ci-fs-body)`。
### 6.1.1 `--ci-*` 变量速查表(必须在 `tokens.css` 中一次性定义齐)
实现者按此表逐行写入,缺一个都会在第 7/10 部分的样式里解析为空、导致整条声明失效。
| 变量 | 默认(classisland 深色) | 用途 |
|---|---|---|
| `--ci-bg` | `#202020` | 岛/行背景色(配合 `--ci-opacity-bg`) |
| `--ci-bg-message` | `#202020` | 阅读消息/详情实底 |
| `--ci-fill-1` | `rgba(255,255,255,.06)` | 行内浅底(列表行、chip) |
| `--ci-fill-2` | `rgba(255,255,255,.10)` | 悬停底、按钮底 |
| `--ci-fill-3` | `rgba(255,255,255,.14)` | 徽标底、强强调底 |
| `--ci-border` | `rgba(255,255,255,.15)` | 岛/行 1px 描边 |
| `--ci-border-strong` | `rgba(255,255,255,.24)` | 选中/拖拽目标描边 |
| `--ci-border-accent` | `var(--ci-accent)` | 强调边(遮罩边缘) |
| `--ci-divider` | `rgba(255,255,255,.09)` | 行内分隔线 |
| `--ci-focus-ring` | `var(--ci-accent)` | 焦点环颜色 |
| `--ci-text-1` | `#FFFFFF` | 主文字 |
| `--ci-text-2` | `rgba(255,255,255,.855)` | 次要文字 |
| `--ci-text-3` | `rgba(255,255,255,.773)` | 辅助文字 |
| `--ci-text-4` | `rgba(255,255,255,.529)` | 装饰文字 |
| `--ci-text-5` | `rgba(255,255,255,.365)` | 禁用文字 |
| `--ci-text-on-accent` | `#FFFFFF` | accent 底上文字 |
| `--ci-accent` | `#00BFFF`(运行时被 `--island-accent` 覆盖) | 强调色 |
| `--ci-accent-soft` | accent @10% | 强调淡底 |
| `--ci-accent-soft-strong` | accent @14% | 强调淡底加强 |
| `--ci-state-urgent` | `#FF453A` | 紧急 |
| `--ci-state-call` | `#FF9F0A` | 叫人 |
| `--ci-state-homework` | `var(--ci-accent)` | 作业 |
| `--ci-state-ok` | `#228B22` | 正常/优 |
| `--ci-warning` | `#FFA500` | 警告 |
| `--ci-danger` | `#CD5C5C` | 危险/错误 |
| `--ci-tint-urgent` | 径向 `rgba(255,69,58,.22)` | 紧急内层染色 |
| `--ci-tint-call` | 径向 `rgba(255,159,10,.20)` | 叫人内层染色 |
| `--ci-progress-h` | `3px` | 行内进度条高度 |
| `--ci-progress-track` | `rgba(255,255,255,.15)` | 进度条轨道 |
| `--ci-progress-fill` | `var(--ci-accent)` | 进度条填充 |
| `--ci-progress-radius` | `0` | 进度条圆角 |
| `--ci-mask-fill` | `var(--ci-accent)` | 斜切遮罩填充 |
| `--ci-mask-text` | `#FFFFFF` | 遮罩文字 |
| `--ci-radius-island` | `8px` | 岛圆角(0–20) |
| `--ci-radius-row` | `8px` | 单行圆角(分体) |
| `--ci-radius-card` | `8px` | 详情卡/气泡圆角 |
| `--ci-radius-control` | `4px` | 控件/列表行圆角 |
| `--ci-radius-pill` | `999px` | 胶囊/徽标圆角 |
| `--ci-row-h` | `40px` | 行高 |
| `--ci-row-pad-x` | `12px` | 行内左右内边距 |
| `--ci-row-min-w` | `20px` | 行内容最小宽 |
| `--ci-row-gap` | `5px` | 行间距(可调 0–150) |
| `--ci-comp-gap` | `12px` | 相邻组件间距(单侧 6) |
| `--ci-shadow-pad` | `10px` | 窗口/阴影留白 |
| `--ci-shadow-island` | `0 4px 8px 2px rgba(0,0,0,.28)` | 岛投影 |
| `--ci-shadow-mask` | `0 8px 32px rgba(0,0,0,.4)` | 提醒时投影 |
| `--ci-shadow-popup` | `0 8px 16px rgba(0,0,0,.45)` | 岛内弹层投影 |
| `--ci-opacity-bg` | `0.5` | 背景不透明度 |
| `--ci-opacity-faded` | `0.05` | 指针避让淡化 |
| `--ci-fs-secondary` | `14px`(派生) | 次要字号 |
| `--ci-fs-body` | `16px`(派生) | 正文字号 |
| `--ci-fs-emphasized` | `18px`(派生) | 强调字号 |
| `--ci-fs-large` | `20px`(派生) | 大号字号 |
| `--ci-fw-regular` | `400` | 常规字重 |
| `--ci-fw-medium` | `500` | 中等字重 |
| `--ci-fw-bold` | `700` | 粗字重 |
| `--ci-font` | HarmonyOS 栈 | 正文字体族 |
| `--ci-z-mask` | `2` | 遮罩层 |
| `--ci-z-mask-text` | `3` | 遮罩文字层 |
| `--ci-z-popup` | `4` | 岛内弹层 |
| `--ci-z-drag-ghost` | `10000` | 编辑器拖拽幽灵 |
| `--ci-dur-instant` | `90ms` | 高频反馈 |
| `--ci-dur-fast` | `100ms` | 淡出/短过渡 |
| `--ci-dur-base` | `200ms` | 默认过渡 |
| `--ci-dur-slow` | `300ms` | 行宽(Fluent) |
| `--ci-dur-overshoot` | `550ms` | 行宽(Classic) |
| `--ci-dur-long` | `750ms` | 遮罩缩放 |
| `--ci-ease-out` | `cubic-bezier(.16,1,.3,1)` | ClassHelper 主缓动 |
| `--ci-ease-fl-1` | `cubic-bezier(.65,0,.35,1)` | Fluent 行宽 |
| `--ci-ease-fl-2` | `cubic-bezier(.25,1,.5,1)` | 遮罩文字/缩放 |
| `--ci-ease-fl-3` | `cubic-bezier(.5,0,.75,0)` | 遮罩退出 |
| `--ci-ease-overshoot` | `cubic-bezier(0,1,.5,1.1)` | Classic 行宽过冲 |
| `--ci-ease-quartic-out` | `cubic-bezier(.16,1,.3,1)`【推断】 | 区段扫入 |
| `--ci-ease-quadratic-in` | `cubic-bezier(.11,0,.5,0)`【推断】 | 区段收拢 |
| `--ci-ease-sine-in` | `cubic-bezier(.12,0,.39,0)`【推断】 | 覆盖层淡入 |
| `--ci-dur-mask-region` | `360ms` | 单个斜切区段 |
| `--ci-dur-mask-stagger` | `120ms` | 区段错峰 |
| `--ci-delay-mask-text` | `260ms` | 遮罩文字延迟 |
| `--ci-dur-mask-text` | `250ms` | 遮罩文字淡入 |
| `--ci-dur-mask-scale` | `750ms` | 遮罩文字缩放 |
| `--ci-delay-mask` | `600ms` | 遮罩投影延迟 |
| `--ci-dur-mask-shadow` | `100ms` | 遮罩投影/背景淡入 |
| `--ci-dur-mask-exit` | `200ms` | 遮罩退出 |
### 6.2 颜色 token 表
#### 6.2.1 背景与表面
| Token | ClassIsland 目标值(family=classisland) | ClassHelper 现状(变量/值) | 迁移策略 |
|---|---|---|---|
| `--ci-bg` | 深色:`#202020` @ `opacity` 0.5(Fluent 用 `SolidBackgroundFillColorSecondaryBrush`,Classic 用 `SolidBackgroundFillColorBaseBrush`);浅色:`#F3F3F3` @ 0.5 | `--wn-bg`:`#000000`(black)/`rgba(32,32,36,0.804)`(glass)/`#0b0b0f`(tinted),见 `classic-island.css:60-82`;custom:`--wn-bg: #20202080`(`custom-runtime.css:241-245`) | **NEW** `--ci-bg`;classisland 下替换 `.island-card` 背景;winisland 保留 `--wn-bg`;custom 在 classisland family 下把方案默认色从 `#000000` 改为 `#202020`(`island-layout.ts:246`),并叠加 0.5 不透明度 |
| `--ci-bg-message` | `#202020`(不透明,阅读消息/详情时用实底) | `.message-surface { --wn-bg:#202020 }`(`classic-island.css:84-86`、`custom-runtime.css:253-255`) | **KEEP 值**,改名 `--ci-bg-message` 作为语义 token;winisland 继续用 `.message-surface` 覆盖 `--wn-bg` |
| `--ci-bg-elevated` | `rgba(255,255,255,0.06)`(行内 chip/进度底/列表行底) | `rgba(255,255,255,0.045)`(`.list-row`,`classic-island.css:389`)、`rgba(255,255,255,0.12)`(`.row-badge`、`.pill-type`)、`#ffffff0d`/`#ffffff1a`(custom) | **CHANGE**:classisland 统一到 6%/10%/14% 三档:`--ci-fill-1/2/3` |
| `--ci-fill-1/2/3` | 6% / 10% / 14% 白(深色);4% / 8% / 12% 黑(浅色) | 同上散落 | **NEW** |
| `--ci-mask-fill` | 强调色(`AccentFillColorDefaultBrush`,ClassIsland 提醒遮罩填充) | 当前无(ClassHelper 消息用胶囊/详情卡,无遮罩) | **NEW**,classisland fixed 消息气泡与 lesson 提醒共用 |
| `--ci-tint-urgent` | `rgba(255,69,58,0.22)` 径向 | `.urgent .tint` 同值(`classic-island.css:168-171`) | **KEEP 值**,归入 classisland 状态色 |
| `--ci-tint-call` | `rgba(255,159,10,0.20)` 径向 | `.call .tint` 同值(`:173-176`) | **KEEP 值** |
| `--ci-tint-tinted` | accent 30% → transparent 135° 渐变 | `[data-style='tinted'] .tint`(`:178-185`) | **KEEP**(仅 winisland tinted) |
| `--ci-bg-hover` | `rgba(255,255,255,0.10)`(classisland 行/按钮 hover) | `--wn-surface-hover: rgba(255,255,255,0.18)` / `#ffffff1a` | **CHANGE**:classisland 用 10%,winisland 保留 18% |
#### 6.2.2 描边
| Token | ClassIsland 目标值 | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-border` | 1px `rgba(255,255,255,0.15)`(深色)/ `rgba(0,0,0,0.08)`(浅色) | `--wn-border: rgba(255,255,255,0.118)`(`classic-island.css:62`);glass `0.157`(`:74`);custom `inset 0 0 0 1px #ffffff1e`(`custom-island.css:13`) | **NEW**;classisland 行容器 `border:1px solid var(--ci-border)`;winisland 保留描边画在 SVG path 上(`stroke-width:1`,`:110-113`) |
| `--ci-border-strong` | `rgba(255,255,255,0.24)`(选中/拖拽目标) | `#ffffff2b`(`custom-island.css:171`)、`.node-editing:hover` `#ffffff28`(`ComponentNode.vue:432`) | **NEW**,统一 24% |
| `--ci-border-accent` | accent 100%(遮罩/强提醒强调边) | `--state-color` 混合描边(`classic-island.css:156-159`,62% 状态色) | **CHANGE**:classisland 用 accent 纯色;winisland 保留混合规则 |
| `--ci-divider` | `rgba(255,255,255,0.09)`(行内分隔) | `--wn-divider: rgba(255,255,255,0.09)`(`:68`) | **KEEP 值**,语义化改名 |
| `--ci-focus-ring` | 2px accent,`outline-offset:2px`(设置/编辑器)+ `-3px`(岛内按钮) | `#8dafff`/`#adc4ff`/`#85aaff` 三处不同(`custom-island.css:65-68`、`custom-runtime.css:190-193`、`ComponentNode.vue:394`) | **CHANGE**:classisland 统一 `2px solid var(--ci-accent)`;winisland 保留原色 |
#### 6.2.3 文字阶梯
| Token | ClassIsland 目标值(深色) | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-text-1` | `#FFFFFF`(100%) | `--wn-text-1: rgba(255,255,255,0.92)`(`classic-island.css:63`) | **CHANGE**:classisland 用 100%(ClassIsland 深层级 255 alpha,见 `repos/ClassIsland/ClassIsland.Core/Helpers/UI/ControlColorHelper.cs:24-33`);winisland 保留 0.92 |
| `--ci-text-2` | `rgba(255,255,255,0.855)` | `--wn-text-2: 0.72`(`:64`) | **CHANGE**:classisland 85.5%;winisland 72% |
| `--ci-text-3` | `rgba(255,255,255,0.773)` | `--wn-text-3: 0.58`(`:65`) | **CHANGE**:classisland 77.3%;winisland 58% |
| `--ci-text-4` | `rgba(255,255,255,0.529)` | 无(classic 用 0.34 的 chevron,`:317`) | **NEW** |
| `--ci-text-5` | `rgba(255,255,255,0.365)` | `#ffffff60`(`custom-island.css:88`)、`#ffffff80`(`custom-runtime.css:145`) | **NEW** |
| `--ci-text-on-accent` | `#FFFFFF`(深色遮罩上) | `#fff`(`.title`,`classic-island.css:628`)、`#0b0b0f`(`.solid-btn`,`:711`) | **NEW**,遮罩/强提醒文字统一白;winisland solid-btn 保留深色字 |
| 浅色方案 | `#1B1B1B` / `rgba(0,0,0,0.855)` / `0.773` / `0.529` / `0.365` | 岛当前恒为深色 | **NEW**:classisland family 支持浅色(§6.10) |
字号必须同时遵守 `ISLAND_TYPE_SCALE`(winisland)与四档阶梯(classisland),见 §6.3。
#### 6.2.4 强调色与状态色
| Token | ClassIsland 目标值 | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-accent` | ClassIsland 默认 `#00BFFF`(DeepSkyBlue,`repos/ClassIsland/ClassIsland/Models/Settings.cs:35`);运行时由 `appearance.accent` 覆盖 | `DEFAULT_ISLAND_APPEARANCE.accent = '#0a84ff'`(`constants.ts:271`);`IslandApp.vue:107` 注入 | **KEEP 用户值**;`family='classisland'` 且用户从未改过 accent(仍等于旧默认 `#0a84ff`)时迁移为 `#00BFFF`;否则保留。`--island-accent` 继续作为运行时变量 |
| `--ci-accent-soft` | accent @ 10%(ClassIsland `CustomizedAccentBarBackground1Brush` 运行时 = accent@10%,`repos/ClassIsland/ClassIsland/Services/ThemeService.cs:66-74`) | `color-mix(in srgb, var(--island-accent) 20%/22%/26%, transparent)`(`classic-island.css:375,524,545`) | **CHANGE**:classisland 统一 10%/14% 两档 `--ci-accent-soft`、`--ci-accent-soft-strong` |
| `--ci-state-urgent` | `#FF453A` | `#ff453a`(`classic-island.css:170,260,514,551`) | **KEEP 值**,改名 token |
| `--ci-state-call` | `#FF9F0A` | `#ff9f0a`(`:175,256,519,567`) | **KEEP 值** |
| `--ci-state-homework` | accent | accent(`classic-island.css:263-265,417-419,523-526`) | **KEEP** |
| `--ci-state-ok` | `#228B22`(可选用天气/正常态) | 无 | **NEW**(与 ClassIsland AQI 1 同值,见 `Themes/LightTheme.axaml:10`) |
| `--ci-danger` | `#CD5C5C`(IndianRed,ClassIsland 危险文字) | `#ffb4ab`(课件错误,`CoursewareTransfer.vue:127`) | **KEEP 现状**(课件)+新增 classisland `--ci-danger` |
| `--ci-warning` | `#FFA500` | 无 | **NEW** |
| `--ci-on-state` | `#FFFFFF` | `.badge-urgent` `#ff6961` 等 | **KEEP winisland** |
#### 6.2.5 进度条与遮罩
| Token | ClassIsland 目标值 | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-progress-h` | `3px`(ClassIsland 行内进度条) | `2px`(`.lesson-progress`,`ComponentNode.vue:351`)、`2px`(`.ci-lesson-progress`,`LessonReminderContent.vue:312`)、`5px`(课件,`CoursewareTransfer.vue:111`) | **CHANGE**:classisland family 行内进度条 3px;课件保持 5px(不同组件,不受此 token 管) |
| `--ci-progress-track` | `rgba(255,255,255,0.15)` | `#ffffff24`(`ComponentNode.vue:352`) | **CHANGE** |
| `--ci-progress-fill` | `var(--island-accent)` | accent(`ComponentNode.vue:359`、`LessonReminderContent.vue:313`) | **KEEP** |
| `--ci-progress-radius` | `0`(ClassIsland 进度条直角) | `1px`(`ComponentNode.vue:354`) | **CHANGE**:classisland 0;winisland 保留 1px |
| `--ci-mask-fill` | accent(斜切遮罩填充,ClassIsland `AccentFillColorDefaultBrush`,`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:180`) | `var(--island-accent, #60a5fa)`(`LessonReminderContent.vue:251`) | **KEEP 语义**,改用 `--ci-mask-fill` |
| `--ci-mask-text` | `#FFFFFF`(ClassIsland `TextOnAccentFillColorPrimaryBrush`) | `#fff` | **KEEP** |
#### 6.2.6 Fluent / Classic 主题色差(classisland family 内部)
| Token | Fluent(默认) | Classic | 证据 |
|---|---|---|---|
| 行背景 | `SolidBackgroundFillColorSecondaryBrush`(暗 `#1C1C1C`【推断】) | `SolidBackgroundFillColorBaseBrush`(暗 `#202020`【推断】) | `repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:45`;`ClassicTheme/Styles.axaml:214` |
| 描边 | 1px `ControlElevationBorderBrush` | 无显式描边 | `FluentTheme:46-47` |
| 阴影 | `0 4 8 2 #48000000` | 常态无投影(提醒时 `DropShadowEffect blur 8 opacity 1`) | `FluentTheme:49`;`ClassicTheme:36-45,283-287` |
| 圆角 | 用 `RadiusX`(默认 8,0–20) | 直角(圆角 0) | `FluentTheme:48`;`ClassicTheme:46-59` |
| 遮罩动画 | 斜切分块 + 文字缩放 | 整块下压/上滑 | 见第 10 部分 |
实现:`data-theme="fluent|classic"` 只切换上表 5 项与第 10 部分的动画参数,**不得**切换字号/间距。
### 6.3 字体 token
| Token | ClassIsland 目标值(classisland family) | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-font` | `'HarmonyOS Sans SC','Microsoft YaHei UI','Noto Sans CJK SC','Noto Sans SC','PingFang SC','Source Han Sans SC',SimHei,sans-serif`(ClassIsland 字体栈,`repos/ClassIsland/ClassIsland/Assets/Fonts/Fonts.axaml:3`) | `-apple-system,'Segoe UI Variable Display','Segoe UI','Microsoft YaHei','PingFang SC','Helvetica Neue',sans-serif`(`classic-island.css:22-25`) | **CHANGE**:classisland family 用新栈;winisland 保留旧栈。**不内置 TTF**(许可与体积原因,HarmonyOS 字体许可见其 `LICENSE.txt`);若后续要内置,只加 `@font-face` 不改栈顺序 |
| `--ci-font-mono` | 数字等宽:`font-variant-numeric: tabular-nums` | 已用(`lesson-ending.css:11`、`LessonReminderContent.vue:226`) | **KEEP**,抽为工具类 `.ci-num` |
| `--ci-fs-secondary` | `14px` | 由 `--secondary: (fontSize/13)*14`(`CustomIslandRuntime.vue:119`)、`.pill-sub` 0.74×、`small` 12px(`ComponentNode.vue:436`) | **CHANGE**:classisland 固定 4 档由 `fontSize` 按 14/16/18/20 比例派生(见下) |
| `--ci-fs-body` | `16px` | `--island-font` 基准 13(winisland) | **CHANGE** |
| `--ci-fs-emphasized` | `18px` | `--emphasized` 默认 20(`ComponentNode.vue:441`) | **CHANGE** |
| `--ci-fs-large` | `20px` | `--large` 默认 28(`ComponentNode.vue:444`) | **CHANGE** |
| `--ci-fw-regular/medium/bold` | 400 / 500 / 700 | 600(`.pill-title`、`.row-title`、`.badge`)、700(`.title`)、600(`.solid-btn`) | **CHANGE**:classisland 行文字 500、标题 700;winisland 保留 600/700 |
| 行高 | body `1.25`、说明 `1.4`(【推断】按 ClassIsland 40px 行高与 16px 字号比例) | `1.15`(`.pill-title/.pill-sub`)、`1.4`(`.content-text`) | **CHANGE**:classisland 1.25;winisland 保留 1.15 |
| `appearance.fontSize` | 11–20,classisland 下表示 **body 基准**(默认 16);winisland 保持 13(基准字号) | 同一字段,默认 13 | **CHANGE 语义**:按 family 解释;迁移时 winisland 用户不变,classisland 新装默认 16 |
**classisland 四档派生公式(必须逐字实现)**:
```text
ratio = appearance.fontSize / 16 // 默认 16 → 1.0
--ci-fs-secondary = 14 * ratio
--ci-fs-body = 16 * ratio
--ci-fs-emphasized = 18 * ratio
--ci-fs-large = 20 * ratio
```
这与现有 custom 的派生方式同构(`CustomIslandRuntime.vue:119-122` 用 `/13` 基准),只是基准从 13 换成 16。winisland 继续使用 `ISLAND_TYPE_SCALE`(`constants.ts:354-367`):`title 1.08 / body 0.95 / meta 0.78 / badge 0.76 / pillTitle 0.92 / pillSub 0.74`。
### 6.4 圆角 token
| Token | classisland 目标 | winisland 现状 | custom 现状 | 迁移策略 |
|---|---|---|---|---|
| `--ci-radius-island` | `8px`(0–20 可调;`appearance.radius` 复用该范围) | 20px(8–32;squircle 基准半径) | `scheme.radius` 默认 8 | **NEW**;classisland 用标准 `border-radius`,不生成 squircle path |
| `--ci-radius-row` | `8px`(分体行与整体行同值) | — | — | **NEW** |
| `--ci-radius-card` | `8px`(详情卡/气泡) | 展开卡 = `min(48×radius/20, w/2, h/2)`(`IslandApp.vue:209-211`) | `min(appearance.radius, w/2, h/2)`(`CustomIslandRuntime.vue:129-133`) | **NEW** |
| `--ci-radius-control` | `4px`(按钮/列表行/徽标内控件) | `calc(font×0.5)`(`.list-row`、`.row-icon`) | `6px`/`8px` 混用(`custom-island.css:79,126`) | **CHANGE**:classisland 4px;winisland 保留比例派生 |
| `--ci-radius-pill` | `999px` | 999px(`.pill-type/.badge/.ghost-btn`) | 7px 圆点 | **KEEP** |
| `--ci-radius-progress` | `0` | 1px | 1px | **CHANGE**(见 §6.2.5) |
| squircle 参数 | 不使用 | 指数 `4.2`、每角 14 段(`squircle.ts:21,24`) | 同 winisland(共用 `squirclePath`) | **KEEP**(仅 winisland/custom) |
| 节点自定义圆角 | 默认 `8`(ClassIsland `RadiusX`) | — | `islandNode().appearance.cornerRadius = 16`(`island-layout.ts:211`) | **CHANGE**:新默认 8;旧配置保留 16(无损迁移,不强制改) |
### 6.5 几何与间距 token
| Token | classisland 目标 | ClassHelper 现状/变量 | 迁移策略 |
|---|---|---|---|
| 行高 `--ci-row-h` | `40px`(fixed 气泡同样恒 40,不随内容增高) | `--resident-height: 40`、`min-height: max(40px, font×2.5)`(`custom-runtime.css:77,110,113`);winisland 胶囊高 `appearance.height` 默认 44 | **NEW**;classisland 行高固定 40(不随字号/内容变;正文最多 2 行、超出省略,不得撑高行——见第 7 部分) |
| 行内边距 `--ci-row-pad-x` | `12px` | `--resident-padding`/`padding 0 12px`(`custom-runtime.css:76`);winisland `padding: 0 calc(font×0.78)` | **NEW**;classisland 固定 12 |
| 行最小宽 `--ci-row-min-w` | `20px` | 无 | **NEW**(对应 ClassIsland `MinWidth=20`,`repos/ClassIsland/.../FluentTheme/Styles.axaml:109`) |
| 行间距 `--ci-row-gap` | `5px`(0–150 可调) | `scheme.spacing` 默认 5(`island-layout.ts:251`)、`.layout-rows gap: var(--custom-row-gap)` | **KEEP 值**,classisland 下用 `--ci-row-gap` 驱动 `--custom-row-gap` |
| 组件间距 `--ci-comp-gap` | 相邻 12px(单侧 6px) | `--row-gap` 默认 12(`scheme`/row `gap`,`island-layout.ts:169,236`;`custom-runtime.css:117`) | **KEEP**;classisland 组件 padding 单侧 = `calc(var(--row-gap)/2)` 即 6px |
| 窗口/阴影留白 `--ci-shadow-pad` | `10px` | `ISLAND_SHADOW_PAD = 10`(`constants.ts:351`) | **KEEP** |
| 自动布置容器 `--ci-window-max` | `960×640`(custom 常驻画布) | `windowBox()`:`min(960, area.width) × min(640, max(60, area.height-marginY))`(`main/island.ts:1521-1528`) | **KEEP** |
| 固定包围盒(fixed) | `宽 = min(工作区宽, 960)`、`高 = 40 + 2×pad`(pad 默认 20,覆盖阴影);行内容在窗口内按停靠对齐并在超出时截断/省略;消息到达不改变窗口尺寸 | `BBOX_SIZE` 取最大形态 + 20(`island.ts:108-112,1543-1547`) | **CHANGE**:classisland 下包围盒 = 行宽 + 2×10;行宽变化时窗口在隐藏帧改尺寸(保留"不可见时改大小"策略);**单条气泡恒 40px、不因内容扩窗**,`maxCardHeight` 只用于 custom 多条列表与强提醒窗口 |
| 胶囊几何 | 不适用 | `width 268 / height 44`(`constants.ts:266-267`),`ISLAND_SIZE_DELTA`(`:344-348`)、`ISLAND_SLIVER_WIDTH 6`(`:336`)、`sliver height max(12,min(28,h-22))`(`IslandApp.vue:196`) | **KEEP**(仅 winisland) |
| 课程提醒条 | 高 40、宽 `max-content`、最小 300、最大 800 | 同(`LessonReminderContent.vue:196-208`) | **KEEP** |
| 课件卡 | `424×132` | `COURSEWARE_ISLAND_SIZE`(`shared/src/courseware.ts:4`)、padding 17/24、radius 24(`CoursewareTransfer.vue:71-72`) | **KEEP**(独立组件,不套 classisland 行) |
| 编辑器预览行 | 40px(与真岛一致) | `.preview-row min-height:44px`(`island-editor.css:207`)、`padding 12`、背景 `#202024`(`:193-201`) | **CHANGE**:44→40,背景改 `--ci-bg` |
| 图标/徽标 | 下课前秒数徽标 30×30 圆形 | `.lesson-ending-badge 30×30`(`lesson-ending.css:3-5`) | **KEEP** |
### 6.6 阴影 token
| Token | classisland 目标 | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| `--ci-shadow-island` | `0 4px 8px 2px rgba(0,0,0,0.28)`(即 ClassIsland `0 4 8 2 #48000000`) | 无(winisland 用 `filter: drop-shadow(0 2px 3px rgba(0,0,0,.11))`,`classic-island.css:123,148-153`) | **NEW**:classisland 用 `box-shadow` 挂行容器(矩形 + 8px 圆角),**禁止** `filter: drop-shadow`(会闪帧,`classic-island.css:114-120`) |
| `--ci-shadow-mask` | `0 8px 32px rgba(0,0,0,0.4)`(提醒时) | 无 | **NEW** |
| `--ci-shadow-popup` | `0 8px 16px rgba(0,0,0,0.45)`(深色) | `--ch-shadow-flyout`(`index.css:53`) | **NEW**(岛内弹层用) |
| `--ci-shadow-custom` | 内描边 + 小投影 | `inset 0 0 0 1px #ffffff1e, 0 2px 3px #0000001c`(`custom-island.css:12-14`) | **KEEP**(custom 行);classisland family 下改 `inset 0 0 0 1px var(--ci-border), 0 4px 8px 2px rgba(0,0,0,.28)` |
| 投影常驻原则 | 只改参数、不增删 `filter`/`box-shadow` 声明 | 已实现(`classic-island.css:114-123`) | **KEEP** |
### 6.7 透明与不透明度 token
| Token | classisland 目标 | ClassHelper 现状/公式 | 迁移策略 |
|---|---|---|---|
| `--ci-opacity-bg` | `0.5`(行背景不透明度,与 ClassIsland 默认 `Opacity=0.5` 一致) | `scheme.opacity` 默认 0.5(`island-layout.ts:248`);winisland `appearance.opacity` 默认 1 | **KEEP 语义分开**:背景不透明度(0.5)≠ 窗口不透明度(1)。classisland 下 `--ci-opacity-bg`=scheme.opacity;winisland 无背景层不透明度概念 |
| 窗口不透明度 | `1`;阅读消息时强制 `1` | `displayOpacity`:`readingMessage ? 1 : appearance.opacity`(`main/island.ts:233-240`) | **KEEP** |
| 淡化比例 | `0.05` | `CLASSISLAND_ISLAND_METRICS.fadedOpacity = 0.05`(`shared/src/island-layout.ts:35`),用于 `main/island.ts:1441` | **KEEP** |
| 玻璃/毛玻璃 | classisland 不使用 | `--wn-bg: rgba(32,32,36,0.804)`(`classic-island.css:73`);**不采样桌面模糊**(文档 `winisland-design-tokens.md:23`) | **KEEP**(winisland only) |
| 消息实底 | `1.0` 不透明 `#202020` | `.message-surface`(见 §6.2.1) | **KEEP** |
| 遮罩文字层 | accent 上白字 100% | 同 | **KEEP** |
| 禁用动画 | 不涉及透明度 | `--island-anim: 0|1`(`IslandApp.vue:527`);`animations=false` 时所有 `transition/animation` 应为 0 或直接落位 | **KEEP 扩展**:classisland 新增 `--ci-anim: 0|1`,与 `--island-anim` 二选一由 family 决定 |
### 6.8 层级(z-index)token
| 层 | classisland 目标 | ClassHelper 现状 | 迁移策略 |
|---|---|---|---|
| 岛根层 | 0 | `.island-root`/`.resident-window` | **KEEP** |
| 形状/背景层 | 0 | `.shape`(`classic-island.css:103-124`)、`svg.shape` | **KEEP** |
| 内容层 | 1 | `.content`/`.resident-page` | **KEEP** |
| 遮罩层(消息/提醒) | 2 | `.ci-mask`(`LessonReminderContent.vue:246-253`)、`.tint` | **NEW 统一**:`--ci-z-mask:2` |
| 遮罩文字层 | 3 | `.ci-mask-title`(`:254-268`) | **NEW**:`--ci-z-mask-text:3` |
| 弹层/下拉(岛内) | 4 | 无 | **NEW** |
| 编辑器拖拽幽灵 | `10000` | `.drag-ghost z-index:10000`(`island-editor.css:331-334`)、`.ch-guide 10000` | **KEEP** |
| 强提醒窗口 | 独立 BrowserWindow,`alwaysOnTop` + `'screen-saver'` | 岛窗口同级别(`island.ts:514`) | **NEW**:效果窗口层级与岛同级或更高,见第 8 部分 |
| 岛窗口层级 | `screen-saver`(置顶) | `setAlwaysOnTop(alwaysOnTop,'screen-saver')`(`island.ts:514`) | **KEEP** |
### 6.9 DPI 与缩放 token
| 规则 | 要求 | 证据 |
|---|---|---|
| 逻辑单位 | 岛内全部用 CSS px = Electron DIP;**不手动乘 devicePixelRatio** | `winisland-design-tokens.md:53`、`01-objective.md:86` |
| 阴影留白 | 10 CSS px(窗口比岛大 20) | `ISLAND_SHADOW_PAD = 10`(`constants.ts:351`);`island.ts:1621-1636` |
| 画布上限 | custom 常驻窗 ≤ 960×640(或工作区高 − marginY) | `island.ts:1521-1528` |
| 尺寸最小钳制 | 自定义窗口 ≥ 40 宽/24 高;可用画布 ≥ 40 | `island.ts:389-390`、`CustomIslandRuntime.vue:262-263` |
| 方案缩放 | `scale` 0.75–2.5(custom 常驻);实际 `fit.scale = min(scheme.scale, availW/naturalW, availH/naturalH)` | `island-layout.ts:371-375`;`CustomIslandRuntime.vue:72-80` |
| 字号范围 | 11–20(`appearance.fontSize`);classisland 派生 4 档后仍受「行高 40」约束,溢出用省略而非撑行 | `constants.ts:298`;§7 组件规格 |
| 主窗口缩放 | 设置页/编辑器沿用 `--ch-font-scale`(1 / 关怀 1.15),**不影响岛** | `renderer/styles/index.css:103,973-979` |
| DPI 断言 | 截图/像素验收按 `devicePixelRatio` 换算基准图;125%/150% 下 ±1px 容差 | `01-objective.md:70` |
| 屏幕定位 | 全部用工作区 `workArea` + 锚点 + `marginX/marginY`;左右边距只在左/右停靠生效,主停靠中线不受影响 | `winisland-design-tokens.md:26`;`island.ts:1615-1648` |
### 6.10 深浅色 token
`family='classisland'` 必须同时支持深/浅色;`family='winisland'` 恒为深色(不随应用主题)。
| Token | 深色(默认) | 浅色 |
|---|---|---|
| `--ci-bg` | `#202020` @0.5 | `#F3F3F3` @0.5 |
| `--ci-border` | `rgba(255,255,255,0.15)` | `rgba(0,0,0,0.08)` |
| `--ci-text-1..5` | 白 100/85.5/77.3/52.9/36.5% | 黑 100/85.5/77.3/52.9/36.5% |
| `--ci-fill-1/2/3` | 白 6/10/14% | 黑 4/8/12% |
| `--ci-shadow-island` | `0 4px 8px 2px rgba(0,0,0,0.28)` | `0 4px 8px 2px rgba(0,0,0,0.18)` |
| `--ci-progress-track` | 白 15% | 黑 12% |
| 遮罩/强提醒 | accent 底 + 白字 | 同(accent 底 + 白字) |
| 切换时机 | 主窗口 `theme` 变化 → 经 preload 下发 `island:appearance`,岛根 `data-color-scheme` 变;颜色过渡 200ms | 同 |
| 默认 | `colorScheme='system'`(跟随应用;应用默认 `Theme=2` 深色,`repos/ClassIsland/.../Settings.cs:32`) | — |
实现纪律:`html.dark` 式全局覆盖**不适用于岛**(岛是独立渲染入口,`island.html` 不继承主窗口 `html.dark`)。必须显式传 `colorScheme` 并在岛根节点切 `data-color-scheme`。切换只改颜色,几何/动效 token 保持不变(`docs/dev/motion.md:125-130`)。
### 6.11 两套风格差异总表(默认选择点)
| 维度 | classisland(默认) | winisland(兼容) |
|---|---|---|
| fixed 形态 | 信息行(多行,行高恒 40,组件横排,底部 3px 进度条);单条气泡也在 40px 行内、正文最多 2 行省略 | 胶囊 268×44 + 展开详情卡(`ISLAND_SIZE_DELTA` 展开 156×186) |
| 形状 | 标准 `border-radius: 8px` | squircle 连续圆角(指数 4.2 / 14 段),胶囊满圆 h/2,展开 `min(48r/20,…)` |
| 形变 | CSS transition(行宽/透明度;行高恒 40 不变) | `SpringValue` 帧积分(stiffness 0.10 / damping 0.68 / 换向 35% / 上限 20% / snap 0.05px) |
| 内容切换 | 行内容淡入淡出 + 气泡两阶段 | 胶囊层 `1-1.5p` 退净、展开层 `p²` 进入 |
| 字号 | 14/16/18/20 四档(由 fontSize 派生) | `--island-font`×排版系数(title 1.08/body .95/meta .78/…) |
| 字重 | 500 正文 / 700 标题 | 600 标题 / 700 大标题 |
| 颜色 | 深/浅色主题 + Fluent/Classic 两套;背景 0.5 透明 | 纯黑/毛玻璃/主题色渐变三套;恒深色 |
| 状态色 | accent 遮罩 + 状态描边 | 红/橙/accent 内层微染 + 状态描边 |
| 进度条 | 3px、直角 | 1–2px、1px 圆角 |
| 主题切换 | Fluent↔Classic / 深↔浅 各 200ms 颜色过渡 | 无主题概念,`style` 切换即时 |
| 设置项 | `theme`+`colorScheme` 新增;`height/width/radius` 退化为约束 | `height/width/radius/style/idleSliver` 全部保留 |
| 默认动画 | 见第 10 部分 classisland 列 | 见第 10 部分 winisland 列 |
### 6.12 配置迁移与兼容策略(token 相关部分)
1. `config.json.island` 增加 `family`、`theme`、`colorScheme`、`winislandStyle`(可选,默认为旧 `style` 的值)。
2. **老用户升级**:`family` 缺失 → 判为老用户 → `family='winisland'`,`winislandStyle = 旧 style`,`theme/colorScheme` 写默认值但忽略。**新安装**(无 config 或 `island` 缺失)→ `classisland`。
3. 旧 `style='black'|'glass'|'tinted'` 保留字段(winisland 使用),同时写入 `winislandStyle`;classisland family 下忽略 `style`。
4. `appearance.accent` 保持用户值;仅当值等于旧默认 `#0a84ff` 且迁移到 classisland 时改 `#00BFFF`,并在迁移日志中记录。
5. `appearance.fontSize`:winisland 保持 13;迁移到 classisland 时若仍为 13 → 改为 16(避免 4 档过小),并记录。
6. `scheme.radius`/`node.cornerRadius` 不做强制改写(无损);classisland 渲染时若 `radius` 超过 20 则钳制到 20(`appearance.radius` 范围扩到 0–32 仅为兼容 winisland,classisland 侧钳制)。
7. 迁移必须幂等:`normalizeIslandAppearance()` 对已有 `family` 的配置不得再次改值。
### 6.13 文件落点与实施顺序
| 步骤 | 文件 | 动作 |
|---|---|---|
| 1 | `packages/shared/src/types.ts` | 扩展 `IslandAppearance`(family/theme/colorScheme/winislandStyle);导出 `ISLAND_FAMILIES`、`ISLAND_THEMES`、`ISLAND_COLOR_SCHEMES` |
| 2 | `packages/shared/src/constants.ts` | 新增 classisland 默认值常量(`DEFAULT_CLASSISLAND_FONT=16`、`ISLAND_CLASSISLAND_RANGES`);把 `ISLAND_SIZE_DELTA` 标注为 winisland-only |
| 3 | `packages/shared/src/island-layout.ts` | `CLASSISLAND_ISLAND_METRICS` 增加 `componentGap:12`、`progressHeight:3`、`rowMinWidth:20`、`shadowPad:10`、字号四档保持现有值 |
| 4 | 新建 `packages/desktop-client/src/island/tokens.css` | 写 `--ci-*` 静态 token(含 `[data-family]`、`[data-theme]`、`[data-color-scheme]` 组合选择器) |
| 5 | `src/island/main.ts` | 在 `applyMotionTokens()` 前 import `./tokens.css` |
| 6 | `IslandApp.vue` / `CustomIslandRuntime.vue` | 根元素挂 `data-family/data-theme/data-color-scheme`;`style` 注入补 `--island-row-*`/`--ci-fs-*` 派生值 |
| 7 | `classic-island.css` / `custom-island.css` / `custom-runtime.css` / `lesson-ending.css` | 把硬编码颜色/尺寸改为引用 `--ci-*`(winisland 分支保留 `--wn-*`/`--custom-*`);`[data-family='classisland']` 下覆盖布局 |
| 8 | `src/main/island.ts` | 下发 `family/theme/colorScheme`;窗口包围盒按 classisland 行宽计算(保留固定包围盒与"不可见时改尺寸") |
| 9 | 设置页/编辑器 | `IslandSettingsView.vue` 增加风格/主题/深浅色;`island-editor.css` 预览行 44→40、背景 token 化 |
| 10 | `docs/dev/winisland-design-tokens.md`、`docs/app/island.md` | 标注双风格与默认值,新增 `docs/dev/island-refactor.md` 的 token 章 |
### 6.14 常见错误与自查清单
实现者必须主动规避以下 13 类错误(每一条都对应真实会出现的问题):
1. **把 `--ci-*` 写进主窗口的 `renderer/styles/index.css`**:岛是独立渲染入口,主窗口的 `:root` 不共享;写错位置会导致岛内变量解析为空、整条 `transition` 简写失效(`docs/dev/motion.md:58-59` 已记录该坑)。
2. **漏掉 `island.html` 的样式引入顺序**:`tokens.css` 必须在 `classic-island.css`/`custom-*.css` 之前 import,否则后者的 `var()` 在解析时找不到值。
3. **用 `html.dark` 给岛做浅色**:岛不加载主窗口的 `<html class="dark">`,必须 `data-color-scheme`。
4. **在 classisland family 里继续用 squircle**:`squirclePath()` 只在 winisland/custom 分支调用;classisland 用 `border-radius: var(--ci-radius-island)`。
5. **把 `appearance.opacity` 同时当作背景不透明度**:窗口不透明度(0.4–1,阅读消息强制 1)与背景不透明度(默认 0.5)是两个 token;混用会导致"消息看不清"或"背景太实"。
6. **给行容器加 `backdrop-filter`**:会模糊整块矩形并破坏逐像素透明;毛玻璃只在 winisland 的 `glass` 里以半透明色模拟(`docs/dev/winisland-design-tokens.md:23-24`)。
7. **用 `filter: drop-shadow` 做 classisland 行投影**:会新建合成表面导致开合闪帧;classisland 用 `box-shadow`(`classic-island.css:114-120`)。
8. **在 classisland 分支继续用 `--wn-text-*`**:winisland 的 0.92/0.72/0.58 与 classisland 的 100/85.5/77.3/52.9/36.5 不是一套;混用会让同一行文字出现两种灰。
9. **字号派生写成固定四档**:classisland 四档必须由 `fontSize/16` 比例派生;写死 14/16/18/20 会让设置页字号滑杆失效。
10. **把圆角钳制只写在 CSS**:`aspect` 变化时 `appearance.radius` 可能大于行高一半,必须在 JS 里也钳制到 `min(radius, w/2, h/2)`(现有 `IslandApp.vue:334-341` 已做,classisland 分支同样要做)。
11. **迁移时无条件改用户设置**:只有"仍等于旧默认值"的 accent/fontSize 才允许改;用户调过的值必须原样保留(§6.12)。
12. **忘记给编辑器预览同步 token**:`island-editor.css` 的预览行、背景、圆角必须与真岛一致,否则"预览一个样、上屏另一个样"。
13. **把 ClassHelper 旧展开卡行为带进 classisland fixed**:fixed 行高恒 40,气泡/遮罩/进度条都在行内完成,正文最多 2 行省略;**不得**写「行高随内容增高」「行高 200ms 过渡」「为气泡扩窗」,`maxCardHeight` 只服务 custom 多条列表与强提醒窗口。
自查命令(无头执行,不启动 Electron):
```bash
# 1) 颜色字面量只允许出现在 token/兼容定义处
grep -rn "#[0-9a-fA-F]\{6\}" packages/desktop-client/src/island
# 2) 检查 classisland 分支没有误用 winisland 变量
grep -rn "\-\-wn-" packages/desktop-client/src/island/classic-island.css | head -40
# 3) token 是否齐全(每个 --ci- 使用处都能在 tokens.css 找到定义)
grep -rho "\-\-ci-[a-z0-9-]*" packages/desktop-client/src/island | sort -u > /tmp/used.txt
grep -ho "\-\-ci-[a-z0-9-]*" packages/desktop-client/src/island/tokens.css | sort -u > /tmp/def.txt
comm -23 /tmp/used.txt /tmp/def.txt
# 4) 确认 package.json 无新增依赖
git diff packages/*/package.json package.json
```
### 6.15 验收标准
1. **单一来源**:`grep -rn "#[0-9a-fA-F]\{6\}" packages/desktop-client/src/island` 只允许命中 `tokens.css`、`classic-island.css` 的 `--wn-*` 定义块、`custom-*.css` 的兼容块与 SVG 渐变;两个 `.vue` 文件内联样式里不得出现颜色字面量(`--state-color` 计算值除外)。
2. **双风格隔离**:切到 `winisland` 后,岛仍显示 268×44 胶囊 + squircle + 弹簧开合,像素 diff 与重构前一致(±1px);切到 `classisland` 后无 squircle path、无弹簧形变,行高 40/圆角 8/进度条 3px。
3. **默认值**:新安装 `family='classisland'`、`theme='fluent'`、`colorScheme='system'`;旧 fixtures 升级后 `family='winisland'` 且 `winislandStyle` 等于旧 `style`。
4. **字号**:classisland 下把 `fontSize` 从 16 改到 20,四档变为 17.5/20/22.5/25,行高仍 40 且内容按第 7 部分规则省略;winisland 下改字号时全部文本按排版系数等比缩放。
5. **颜色**:深/浅色各截一张基准图,`--ci-text-1..5`、背景 0.5、描边 1px、进度条轨道/填充与 §6.2/§6.10 表逐项一致(颜色 ±2/255)。
6. **Fluent/Classic**:切换主题后,行背景色、描边有无、阴影、圆角(Fluent 8 / Classic 0)按 §6.2.6 变化,字号与间距不变。
7. **不透明度语义分离**:把行背景不透明度调到 0.2、窗口不透明度调到 0.8,两者独立生效;打开一条消息时窗口不透明度强制 1(`displayOpacity`),关闭后回到 0.8。
8. **层级**:消息遮罩在内容之上、遮罩文字在遮罩之上;编辑器拖拽幽灵 `z-index:10000` 高于一切;岛窗口始终 `screen-saver` 层级。
9. **DPI**:100%/125%/150%/200% 四档下,行高 40、圆角 8、字号逻辑值不变,无 0.5px 模糊;截图基准按 devicePixelRatio 换算后 ±1px。
10. **迁移幂等**:对同一份配置连续运行 `normalizeIslandAppearance()` 三次,输出字节级一致;旧字段 `residentEnabled`、`style` 仍在且语义不变。
11. **兼容别名**:`--ci-lesson-*` 重命名期间旧 `--ci-duration` 等变量仍生效(至少一个版本),组件视觉无变化。
12. **无新依赖/无新框架**:`package.json` 依赖零新增;token 只用 CSS 自定义属性,不引入 CSS-in-JS 或新 UI 库。
13. **fixed 气泡恒定 40px**:构造超长正文(>500 字)的消息,行高仍为 40、正文最多 2 行且以省略号收尾,窗口 `bounds` 不因该消息变化;`maxCardHeight` 只在 custom 多条列表/强提醒窗口路径被读取。
14. **reduce-motion 口径**:系统开启 `prefers-reduced-motion` 时,岛动画仍由 `appearance.animations`/`speed` 决定(默认不覆盖);仅当 P2 选项 `respectReducedMotion=true` 时按 `animations=false` 落位。
---
## 7. 岛结构与组件规格(ClassHelper 落地版)
本部分是「重构 ClassHelper 灵动岛」的视觉与组件规范。它**不是**新建工程的说明,而是对现有 `packages/desktop-client` 的改造指令:所有改动都必须落到既有文件(`src/island/*`、`src/main/island.ts`、`packages/shared/src/*`、`src/renderer/views/settings/*`)上,技术栈、构建方式、IPC 白名单、单窗口模型一律不变。
### 7.0 改造口径与文件级改动清单
**总口径(必须遵守)**:
1. 目标视觉**默认改为 ClassIsland 规格**(行高 40、行内边距 12、行距 5、圆角 8、背景不透明度 0.5、字号 14/16/18/20、进度条 3px、描边 1px、阴影 `0 4 8 2 #48000000`);ClassHelper 的 WinIsland 经典外观**只作为 fixed 的兼容外观族保留**(`islandAppearance.family === 'winisland'`)。**新安装默认 `family='classisland'`;老用户升级保持 `winisland`,避免观感突变**。
2. **`family` 只影响 `fixed`**:`classisland` 下 fixed = ClassIsland 风格信息行(1 行 = 日期 + 课程表,行高 40、圆角 8、背景 0.5、底部 3px 进度条;消息在**固定 40px 行内**展开 ClassIsland 气泡,保留 ClassHelper 既有暂存/紧急/叫人/已读/去重规则);`winisland` 下 fixed = 旧"胶囊 + 详情卡"(零改动)。**`custom` 始终使用 ClassIsland 规格,不提供 winisland 回退**(custom 现有 rows/nodes 结构本就接近 ClassIsland,只做细化与像素对齐,见 7.2、7.4)。
3. 不新增第二个窗口、不新增框架、不改服务端:`LayoutIslandApp.vue` 仍是唯一渲染入口,`main/island.ts` 仍是唯一窗口持有者(`packages/desktop-client/src/island/LayoutIslandApp.vue:1-76`)。
4. 消息/提醒/课程提醒的业务语义(优先级、去重、上课暂存、紧急展开、已读回执)归第 8 部分;本部分只规定它们**在岛上的容器、尺寸、颜色、动画**。
5. 所有像素值以本文与第 6 部分 tokens 为准;实现时**不得**再保留"ClassIsland 用 border-radius、ClassHelper 用 squircle"这种分歧——`fixed` 由 `appearance.family` 决定走哪套,`custom` 固定走 ClassIsland 套;代码里两套都在,但同一时刻只启用一套。
**必须改动的文件(按改动类型)**:
| 文件 | 改动类型 | 内容 |
|---|---|---|
| `packages/shared/src/constants.ts` | 扩展 | 新增 `ISLAND_FAMILIES` + `ISLAND_FAMILY_LABELS`;`DEFAULT_ISLAND_APPEARANCE` 增加 `family: 'classisland'`;新增 ClassIsland 规格常量(行高/内边距/行距/圆角/进度条/描边/阴影/字号)供渲染与主进程共用 |
| `packages/shared/src/island-layout.ts` | 扩展 | 组件 `settings` 增补 ClassIsland 语义字段(见 7.4);`appearance` 增补 `customMargin`(可选);`normalizeIslandLayouts()` 增加 `family` 迁移(老用户 → winisland)与规范化范围 |
| `packages/desktop-client/src/main/island.ts` | 改造 | `normalizeAppearance` 接受 `family`;`windowBox` 的 fixed/custom 包围盒按 7.1.2 调整;`updateResidentPointer` 的淡出比例沿用 shared 常量;新增 `island:report-layout-metrics` 承载"内容最大高" |
| `packages/desktop-client/src/island/IslandApp.vue` | 改造 | 增加 `data-family="classisland"` 分支:fixed 改为"信息行 + 行内气泡"(见 7.3.2);`winisland` 分支保持原样 |
| `packages/desktop-client/src/island/CustomIslandRuntime.vue` | 改造 | custom 常驻卡改为 ClassIsland 行容器(背景/圆角/内边距/行距/进度条/淡出);`classisland` 分支的 `border-radius` 与行背景 |
| `packages/desktop-client/src/island/ComponentNode.vue` | 改造 | 组件内边距 6 → 0(改由行 padding 承担);字号 token 改为 ClassIsland 的 14/16/18/20;新增 `schedule` 的 ClassIsland 折叠规则与进度条 3px;新增 countdown 的 ClassIsland 文案 |
| `packages/desktop-client/src/island/WeatherBrief.vue` | 改造 | 按 ClassIsland 天气简报的图标/数值排版(见 7.4.3) |
| `packages/desktop-client/src/island/classic-island.css` | 扩展 | 新增 `[data-family='winisland']` 下的三套 `data-style` 变量组保持现值;`classisland` 的行/气泡样式写进 `custom-runtime.css` |
| `packages/desktop-client/src/island/custom-runtime.css` | 改造 | 行/背景/进度条/淡出/编辑器选中框的 ClassIsland 规格 |
| `packages/desktop-client/src/island/custom-island.css` | 改造 | `.custom-card` 的 ClassIsland 背景/圆角/行距变量 |
| `packages/desktop-client/src/island/LessonReminderContent.vue` | 复用 | 其 5 段斜切遮罩算法**必须提取为共享遮罩组件**,供通知遮罩复用(见 7.2.6) |
| `packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue` | 改造 | 新增"外观族"下拉(绑定 `appearance.family`,默认 `classisland`,仅 `layout='fixed'` 可见);原"视觉风格"下拉仅在 `fixed+winisland` 下可见;`custom` 下两项都隐藏;圆角/行距/行高滑块范围按 ClassIsland 对齐 |
| `packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue` + `island-editor.css` + `IslandEditorNode.vue` | 改造 | 选中框改 1.5px 虚线、行高与块级尺寸按 ClassIsland(见 7.5) |
| `packages/desktop-client/src/renderer/components/IslandComponentSettings.vue` | 改造 | 组件设置项按 7.4 的字段表补充(隐藏规则、宽度、对齐、边距、字体覆盖) |
**不得改动**:`main.ts` 的零依赖策略(`packages/desktop-client/src/island/main.ts:6-11`)、`preload/island.ts` 的白名单结构(`:14-62`)、`spring.ts` 的弹簧参数(`spring.ts:26-30`)、`squircle.ts` 的超椭圆参数(`squircle.ts:21-24`)。
### 7.1 岛窗口与几何
#### 7.1.1 窗口创建参数(保持不变,只能增加)
沿用现有 `BrowserWindow` 配置(`packages/desktop-client/src/main/island.ts:480-512`),并逐条核对:
- `frame: false`、`transparent: true`、`resizable: false`、`movable: false`、`minimizable/maximizable/fullscreenable: false`(`:484-490`)。
- `skipTaskbar: true`、`hasShadow: false`(`:491-492`)——阴影由 CSS/SVG 画,不得用系统窗口阴影。
- `focusable: true` **必须保留**(`:502`):Windows 下关掉可激活性会丢 `mousedown`,岛点不动(`:493-501` 的实机结论)。"不抢焦点"靠 `showInactive()` + 收起时 `blur()` 实现(`applyFocusable`,`:1663-1672`)。
- `alwaysOnTop: true` + `setAlwaysOnTop(appearance.alwaysOnTop, 'screen-saver')` + `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`(`:503, 514-515`)。
- `type: 'toolbar'`、`setIgnoreMouseEvents(true, { forward: true })`(`:504, 527`)。
- `webPreferences`: `contextIsolation: true`、`sandbox: true`、`backgroundThrottling: false`、preload 指向 `island.js`(`:505-511`)。
- **禁止**:`win.setBackgroundMaterial()` / `vibrancy` / `backgroundMaterial`(`:523-524` 的历史结论;逐像素透明是硬约束)。**禁止** `setContentProtection`(岛不是隐私窗口)。
- 导航与新窗口一律拒绝(`:516-522`)。
#### 7.1.2 窗口几何:fixed(两种外观族)/ custom
**fixed 的两种口径(由 `appearance.family` 决定,不是同一个形态)**
`fixed` 只表示"岛按经典交互工作(收到消息才出现)",它的**外观**由 `family` 决定:
**A. `family='classisland'`(新安装默认):ClassIsland 风格信息行**
- 形态**重定义**:fixed 不再是小胶囊,而是**一条 ClassIsland 风格的信息行**——默认 1 行 = 日期 + 课程表,行高 **40**、圆角 **8**、背景不透明度 **0.5**、1px 描边、阴影 `0 4 8 2 #48000000`、底部 **3px** 进度条(课程进度 / 提醒倒计时)。
- 隐藏语义不变:无消息时仍按现有 `state.mode='hidden'` 隐藏窗口(或按 `idleSliver` 留细缝,`main/island.ts:1483-1496`);消息到来才显示行。
- 消息到来:**在同一行内、行高 40px 的固定容器里渲染 ClassIsland 风格气泡**——气泡不是"把行撑高",而是整块 40px 行换上遮罩层 + 内容层 + 进度层(见 7.2.8)。**行高恒为 40,禁止临时增高到 64 等**(`--ci-island-height: 40px` 不得被气泡改写)。保留 ClassHelper 既有业务规则(普通上课暂存、紧急立即展开、普通叫人可点、已读回执、按 id 去重),业务规则归第 8 部分,视觉归 7.2.8。
- 窗口几何(**预置固定包围盒**,开合/消息变化时都不改窗口尺寸):
- **宽 = min(目标显示器工作区宽, 960) DIP**(960 为本实现的岛内容上限;行内容在窗口内按停靠位对齐、超出截断);**不得**按"行内容自然宽"逐次扩缩窗口;
- **高 = 40 + 2 × 20 = 80 DIP**(20 = ClassIsland Fluent 安全区/阴影余量;行高 40 恒不变,气泡、正文 2 行、进度条都在 40 行内完成);
- **只在显示器/DPI 变化、停靠/偏移设置变化、窗口不可见时**重算 `setBounds`;行内容变化(课程名、秒数、消息出现/收起)**禁止**改窗口尺寸(`main/island.ts:1598-1612` 的比较逻辑仅用于上述设置变化);
- 可见行背景宽度 = 行内容自然宽(≤ 窗口宽),水平对齐按停靠位:左/中/右;行背景之外是透明余量,用户不可见。
- 行宽/内容宽仍必须由渲染层测量后上报(`reportSize`/`setHitRect`,`CustomIslandRuntime.vue:255`、`IslandApp.vue:421-454`),用于**行背景宽度、截断与命中矩形**,不用于改窗口尺寸。
- 气泡宽度上限 **720**【推断:按 1080p 下 ClassIsland 岛常用宽度取整】;超宽时课程表按 `settings.count` 截断(7.4.5),气泡正文最多 2 行(7.2.8)。
- 鼠标交互:行上可点(展开/收起气泡、按钮);点行外空白 → 收起气泡(不改变 hidden 语义)。
**B. `family='winisland'`(老用户升级默认):胶囊 + 详情卡**
- 保留现有全部几何:`windowBox()` 普通模式取 `max(BBOX_SIZE, 当前形态 + 2*pad)`(`main/island.ts:1543-1548`);触摸模式取"当前形态 + 2*pad"并贴合岛体(`:1549-1552`)。
- 胶囊 = `appearance.width × height`(默认 268×44,`constants.ts:266-267`),展开/紧急/叫人 = 胶囊 + `ISLAND_SIZE_DELTA`(`:344-348`);岛本体尺寸由渲染层弹簧逼近(`IslandApp.vue:216-220, 238-264`)。
- 圆角 = `winisland` 规格(胶囊 `h/2`、展开卡 `min(48×radius/20, w/2, h/2)`),背景 = 纯黑/毛玻璃/主题色渐变三选一。
**custom(常驻):固定画布 + 内容自然测量(始终 ClassIsland 规格,与 `family` 无关)**
- **外观不走 `family`**:custom 无论 `family` 是 `classisland` 还是 `winisland`,都按 7.2 的 ClassIsland 行/背景/进度条/遮罩规格渲染;`family` 只决定 fixed 用哪种外观(7.3.1)。设置页在 `layout='custom'` 时不得展示/生效 `family` 与 `winisland` 色调选项。
- 现在的窗口画布已经是"固定画布":`windowBox()` 在 `residentEnabled` 时返回 `min(960, area.width) × min(640, max(60, area.height - marginY))`(`main/island.ts:1522-1529`),这与 ClassIsland 的固定包围盒思路一致,**保留**。
- 目标态改动:画布高度上限改为"内容上限"而不是固定 640——渲染进程上报 `reportLayoutMetrics({ contentMaxHeight })`(新增 IPC),主进程取 `min(area.height - marginY, contentMaxHeight + 2*pad)`;默认 `contentMaxHeight = 常态行数 × (40 + 行距) + 24`。理由:ClassIsland 的岛高度只由行决定(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:916`),640 会让屏幕上方出现一块"看不见但可能吃掉点击"的余量(虽然命中已按 hitRect 控制,但窗口越大越依赖 hitRect 的准确性)。
- `available`(渲染层可用尺寸)保持现值算法:`innerWidth/innerHeight - 2*ISLAND_SHADOW_PAD - 4`(`CustomIslandRuntime.vue:260-266`),最小值 40。
- 内容缩放:`fitted()` 取 `min(scheme.scale, available.width/natural.width, available.height/natural.height)`(`:72-80`),**保留**;`scheme.scale` 范围 0.75–2.5(`island-layout.ts:370-375`)。
- `customSize` 初值 424×80(`main/island.ts:261`)保留,仅作为首帧占位;首次 `reportSize` 后覆盖。
#### 7.1.3 锚点、停靠与多显示器
必须沿用现有算法(`main/island.ts:1331-1351`),逐条核对:
- 停靠位置 6 值:`top-center / top-left / top-right / bottom-center / bottom-left / bottom-right`(`constants.ts:305-312`)。
- 垂直:`vMode = position.startsWith('bottom') ? 'bottom' : 'top'`;`vValue = bottom ? area.y + area.height - marginY : area.y + marginY`(`:1341-1342`)。
- 水平:`hMode = endsWith('left') ? 'left' : endsWith('right') ? 'right' : 'center'`;`hValue = left ? area.x + marginX : right ? area.x + area.width - marginX : round(area.x + area.width/2)`(`:1343-1349`)。
- **居中停靠不受 `marginX` 影响**(`docs/dev/winisland-design-tokens.md:26`),只有左右停靠用水平边距。
- 锚点全部取整(`Math.round`),避免 0.5px 抖动(`:1325-1330`)。
- 窗口定位时窗口比岛大 `ISLAND_SHADOW_PAD`,因此要再偏移一个 pad,让**岛**的边/中心落在锚点上(`windowBounds`,`:1615-1648`):左停靠 `x = hValue - pad`、右停靠 `x = hValue + pad - width`、居中 `x = hValue - round(width/2)`;`y = bottom ? vValue + pad - height : vValue - pad`。
- 多显示器:`targetDisplay()` = `followCursorDisplay ? getDisplayNearestPoint(cursor) : getPrimaryDisplay()`(`:1354-1358`);换屏检测挂在既有 60ms 光标轮询上(`syncAnchorDisplay`,`:1370-1379`),**不得**新增定时器。
- DPI:Electron 的 `BrowserWindow.setBounds` 用 DIP,渲染层 CSS px 与 DIP 一一对应,**禁止**在渲染层再乘 `devicePixelRatio` 或 `zoomFactor`;`appearance` 里的宽高/边距全部是 DIP(`constants.ts:264-290` 的注释口径)。
- 目标态新增:ClassIsland 的"竖直安全区"(Fluent 20px / Classic 0,`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:314`)在 ClassHelper 里以 **P2 / 可选**实现——仅当 `appearance.family === 'classisland'` 且设置项开启时,把锚点向屏幕内侧再推 `safe` 像素;默认 0,不得改变现有视觉位置。
#### 7.1.4 点击穿透、命中与触摸
这是岛最容易出 regress 的部分,**必须逐条保持**:
1. 窗口默认 `setIgnoreMouseEvents(true, { forward: true })`(`main/island.ts:527`)。
2. 渲染层只上报"进入岛体"(`setInteractive(true)`),**不上报关闭**;关闭由主进程按真实光标轮询决定(`IslandApp.vue:456-487` 的整段说明)。理由:两边都下发会让窗口在"接收/穿透"之间每几毫秒翻转,表现为"点了没反应"。
3. 命中矩形 `hitRect`:渲染层用 `.island-card`/`.resident-card` 的 `getBoundingClientRect()` 上报(`IslandApp.vue:421-454`、`CustomIslandRuntime.vue:200-213`),限流 50ms 且尾随发送一次;主进程按 60ms 兜底校正(`main/island.ts:1824-1836`)。
4. 触摸模式:渲染层按 `navigator.maxTouchPoints > 0` 上报 `setTouchMode(true)`(`IslandApp.vue:546-549`),主进程切到"窗口贴合岛体 + 始终接收输入"(`main/island.ts:1891-1899, 1795-1807`)。触摸模式的窗口**只增不减**,收小要等窗口隐藏后再做(`syncTouchLayout`,`:1560-1578`)。
5. 失焦宽限:`BLUR_GRACE_MS = 500`、触摸/编辑态 `BLUR_GRACE_TOUCH_MS = 1200`(`main/island.ts:2287, 2297`);blur 时若指针仍在岛上则忽略(`:565-578`)。
6. 常驻模式的指针避让淡化:`residentFaded` 由 `updateResidentPointer()` 计算(`:1851-1877`),触发条件包含"非编辑、非课程提醒、非展开、非触摸操作后 1s、未悬停铃铛",淡化比例 = `CLASSISLAND_ISLAND_METRICS.fadedOpacity = 0.05`(`island-layout.ts:35`)。
7. 编辑预览时**不创建额外桌面窗口**(`docs/app/island.md:47`),编辑态由 `island:set-editing` 控制 `setInteractive(true)`(`main/island.ts:1430-1432`)。
#### 7.1.5 窗口护栏(不得删减)
- 渲染进程心跳:渲染层每 5s `alive()`(`LayoutIslandApp.vue:36`),主进程 `RENDERER_ALIVE_TIMEOUT_MS = 20_000`(`main/island.ts:2315`),超时隐藏并重建窗口,重建限流 3 次 / 10 分钟(`:2317-2318`)。
- 淡入淡出:步进 0.16 / 0.2、16ms 一步(`fadeIn/fadeOut`,`:1674-1710`);`appearance.animations === false` 时直接落位。
- 窗口只在"尺寸/位置设置变化"或"不可见"时重排(`applyWindowLayout` 前先比较 4 个值,`:1598-1612`);**禁止**在开合动画中改窗口几何(`:1477-1479` 的注释)。
- `suspended`(被 custom 或课件覆盖时)必须停掉经典岛的测量与命中(`IslandApp.vue:238-246` 的 `syncTargets` 早退)。
### 7.2 行、背景、进度条、遮罩与淡出(ClassIsland 规格:custom 恒定 / fixed 默认)
#### 7.2.1 行容器 DOM(目标态)
`CustomIslandRuntime.vue` 的常态页与展开页都必须渲染成下面这棵树(现状见 `:396-504`,需按此重排):
```html
<div class="resident-window" data-anchor="left|center|right" data-bottom="true|false" data-family="classisland" :data-style="appearance.style">
<section class="custom-card resident-card island-card">
<svg class="shape" v-if="appearance.family === 'winisland'"><path :d="path" /></svg>
<!-- classisland:不用 squircle 形状层,圆角由 CSS border-radius 承担(见 7.2.2) -->
<span class="tint" aria-hidden="true"></span>
<div class="resident-page resident-compact-page">
<div class="resident-source resident-compact">
<div class="layout-rows" style="gap: var(--ci-line-gap)">
<section class="layout-row" data-row="main">
<div class="row-content">
<div class="row-components" style="gap: var(--ci-component-gap)">
<ComponentNode … />
</div>
</div>
</section>
</div>
<button class="resident-bell">…</button> <!-- 消息入口,见 7.4.12 -->
</div>
</div>
<div class="resident-page resident-detail-page">…</div>
<div class="resident-page resident-lesson-page">…</div>
</section>
</div>
```
强制要求:
- 行容器类名保持 `.layout-row` / `.row-content` / `.row-components`(现有 CSS 已依赖,`custom-island.css:145-164`、`custom-runtime.css:98-121`),**不要改名**;新增的规格通过 CSS 变量注入。
- `.layout-rows` 的 `gap` 从 `scheme.spacing` 改为 `var(--ci-line-gap)`(默认 5px,`island-layout.ts:34`),`scheme.spacing` 仅作为覆盖值(编辑器"行间距"仍是它)。
- 组件列表的 `gap` 从 `row.gap` 改为 `var(--ci-component-gap)`(默认 12px)。语义映射:ClassIsland 的相邻组件间距 = 两个 `Margin="6 0"` 之和 = 12(`repos/ClassIsland/ClassIsland/MainWindow.axaml:396`)。因此 `row.gap` 的默认值应从 12 改为 **12 并作为覆盖**(`island-layout.ts:343` 默认 8 → 改为 12),范围 0–64 保留。
#### 7.2.2 行的像素规格(ClassIsland 规格)
必须写死为 CSS 变量并在两个 CSS 文件里引用(`custom-runtime.css` 为主):
```css
.resident-window {
--ci-line-height: 40px; /* ClassIsland IslandContainerHeight,MainWindowLine.axaml:16 */
--ci-line-pad-x: 12px; /* GridContentRoot Margin="12 0",FluentTheme/Styles.axaml:110 */
--ci-line-gap: 5px; /* MainWindowLineVerticalMargin 默认 5,Models/Settings.cs:219 */
--ci-component-gap: 12px; /* 组件 Margin="6 0" ×2,MainWindow.axaml:396 */
--ci-radius-island: 8px; /* RadiusX 默认 8,Models/Settings.cs:183 */
--ci-island-opacity: 0.5; /* Opacity 默认 0.5,Models/Settings.cs:64 */
--ci-line-opacity: 1; /* 行整体不透明度默认 1.0,MainWindowLineSettings.cs:42 */
--ci-stroke-island-w: 1px; /* line-background BorderThickness=1,FluentTheme/Styles.axaml:47 */
--ci-shadow-island: 0 4px 8px 2px #48000000; /* FluentTheme/Styles.axaml:49 */
--ci-progress-height: 3px; /* 本轮裁决,所有进度条共用 */
--ci-fade-ratio: 0.05; /* CLASSISLAND_ISLAND_METRICS.fadedOpacity,island-layout.ts:35 */
}
```
行本体:
```css
.layout-row {
min-height: var(--ci-line-height);
padding: 0 var(--ci-line-pad-x); /* ClassIsland 的 12px 内边距搬到行上 */
box-sizing: border-box;
display: flex;
align-items: center;
opacity: var(--ci-line-opacity);
}
.row-components {
display: flex;
align-items: center;
gap: var(--ci-component-gap);
min-height: var(--ci-line-height);
}
```
**方案级覆盖**:第 5、13 部分在 scheme 上新增 `rowHeight` / `componentGap` / `showProgress` 三个字段(默认 40 / 12 / true)。渲染时按"scheme 覆盖 > 外观族默认"写入 CSS 变量:
```css
.resident-card {
--ci-line-height: v-bind('scheme.rowHeight ? scheme.rowHeight + "px" : "40px"');
--ci-component-gap: v-bind('scheme.componentGap + "px"');
}
```
`showProgress=false` 时所有行内进度条(课程进度、倒计时进度)隐藏;行距仍是 `--ci-line-gap`(默认 5,来自 `scheme.spacing`,`island-layout.ts:34`)。
背景(两种形态都要):
```css
/* 整块岛背景(separated=false) */
.resident-card {
background: var(--ci-bg-secondary); /* SolidBackgroundFillColorSecondaryBrush */
border: var(--ci-stroke-island-w) solid var(--ci-stroke-island);
border-radius: var(--ci-radius-island);
box-shadow: var(--ci-shadow-island);
opacity: var(--ci-island-opacity); /* 只作用背景,不作用文字 */
}
/* 分体(scheme.separated=true):每行一个独立胶囊 */
.resident-card.resident-separated { background: transparent; box-shadow: none; border: 0; padding: 0; }
.resident-separated .layout-row {
background: var(--ci-bg-secondary);
border: var(--ci-stroke-island-w) solid var(--ci-stroke-island);
border-radius: var(--ci-radius-island);
box-shadow: var(--ci-shadow-island);
opacity: var(--ci-island-opacity);
padding: 0 var(--ci-line-pad-x);
}
```
**硬性要求**:背景不透明度只允许作用在背景层;文字/图标必须 100% 不透明(ClassIsland 把 `Opacity` 绑在 `Border.line-background` 上,`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:44`)。现有实现里卡片 `opacity` 由主进程 `win.setOpacity()` 统一控制(`main/island.ts:1440-1442`),**不得**把背景不透明度也塞进窗口 opacity——必须拆成"窗口 opacity(用户可调 0.4–1)+ 背景层 alpha(0.5)"两层。
#### 7.2.3 组件内边距的归零
现在 `ComponentNode` 自身有 `padding: 6px`(`ComponentNode.vue:295-301`),行 `.row-components` 的 `gap` 又是 `row.gap`,两者叠加会让实际间距变成 `gap + 12`。ClassIsland 是"组件 0 内边距 + 行 12px 内边距 + 组件 6/6 外边距"。
改造:
```css
.component-node { padding: 0; } /* 由行 padding 承担左右留白 */
.row-components { gap: var(--ci-component-gap); } /* 12px */
```
同时在 `ComponentNode` 上**移除** `overflow: hidden`(`ComponentNode.vue:296`)→ 改为 `overflow: visible`,否则倒计时进度环、天气预警告牌会被裁掉;裁切交给行(`clip-path` 或 `overflow: hidden` 挂在 `.layout-row`)。
#### 7.2.4 进度条
三条进度条统一 `3px`、`border-radius: 1.5px`、贴底、`transform-origin: left`:
| 位置 | 现状 | 目标 |
|---|---|---|
| 课程行内进度 | `.lesson-progress { height: 2px; background: #ffffff24 }`(`ComponentNode.vue:348-355`) | `height: var(--ci-progress-height)`;底色 `rgba(255,255,255,0.08)`;填充 `var(--ci-accent)` |
| 课程提醒卡进度 | `.ci-lesson-progress { height: 2px }`(`LessonReminderContent.vue:307-316`) | 同上,高度 3px |
| 通知遮罩进度(新增) | 无 | 同 7.2.6,贴在遮罩层底部 |
过渡:课程进度 `transition: transform 1s linear`(`ComponentNode.vue:361`)保留;提醒进度 `50ms linear`(`LessonReminderContent.vue:315`)保留;通知倒计时进度 `100ms linear`(`prefers-reduced-motion` 时 0)。
#### 7.2.5 淡出与透明度
- 整窗淡入淡出:主进程步进 0.16/0.2 × 16ms(`main/island.ts:1674-1710`)保留。
- 指针避让淡化:`residentFaded` → `win.setOpacity(displayOpacity × 0.05)`(`:1440-1442` + `island-layout.ts:35`)保留;**必须**保证展开态、编辑态、课程提醒期间不淡化(`:1864-1873`)。
- 行级淡出(新增,P2):ClassIsland 的 `IsLineFaded` 是 0.05(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:73-75`);ClassHelper 已有"整窗淡化",**默认不叠加行级淡化**,仅当 `pointerFadeReversed` 语义需要时用行级 opacity 过渡 100ms 实现。
#### 7.2.6 斜切遮罩(通知遮罩必须复用课程提醒的实现)
ClassIsland 的遮罩是 5 段斜切,参数为:区段权重 `[0, 0.1, 0.2, 0.4, 0.2, 0.1]`、60° 斜面、区段 360ms、错峰 120ms、开 `QuarticEaseOut` / 关 `QuadraticEaseIn`、文字 delay 260ms + 250ms 淡入 + scale 1.1→1.0 / 750ms(`repos/ClassIsland/ClassIsland/Controls/SlantedMaskControl.cs:177-200, 284-308`;行模板 `XamlThemes/FluentTheme/Styles.axaml:329-367`)。
**现状**:这套算法已经存在于 `LessonReminderContent.vue:59-102`,并且参数完全对得上——`edges = [0, 0.1, 0.3, 0.7, 0.9, 1]`(等价权重 0/0.1/0.2/0.4/0.2/0.1)、`offset = tan(30°) × 40`、`delays = open ? [0,90,120,90,0] : [120,90,0,90,120]`、`ease = 1-(1-p)^4` / `p²`、总时长 480ms、兜底 600ms。
**改造要求**:
1. 把 `LessonReminderContent.vue` 的遮罩部分抽成 `packages/desktop-client/src/island/SlantedMask.vue`(props:`width`、`height`、`open: boolean`、`accent: string`、`motion: boolean`、`speed: number`),`LessonReminderContent` 与新的通知遮罩共用。
2. 通知遮罩的 DOM 必须与 ClassIsland 对齐:
```html
<div class="ci-mask-layer" :class="{ open }">
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none">
<polygon v-for="(p, i) in polygons" :key="i" :points="p" />
</svg>
<div class="ci-mask-title" :class="{ 'ci-title-in': open }">…通知标题…</div>
</div>
```
3. 遮罩填充色 = `var(--ci-accent-fill)`(ClassIsland 用 `AccentFillColorDefaultBrush`);文字色 = `var(--ci-text-on-accent)`。
4. `prefers-reduced-motion` 或 `appearance.animations === false` 时直接落位(`regions` 全部 0/1),不做逐帧插值(`LessonReminderContent.vue:77-80` 已有该分支,保留)。
5. **不得**给遮罩层加 `filter: blur()` 或 `backdrop-filter`(透明窗口下不可靠)。
#### 7.2.7 行/组件隐藏规则
ClassHelper 现有的 `advanced.hideRules` 只有三值:`in-class` / `no-message` / `no-class`(`island-layout.ts:101-106`,判定在 `ComponentNode.vue:108-120`)。目标是 ClassIsland 的规则集(`HideMode` 基本/高级、`HideOnClass`、`HideOnMaxWindow`、`HideOnFullscreen`、规则集编辑器,`repos/ClassIsland/ClassIsland/Models/Settings.cs:185, 418-505`)。
本轮落地口径:
- **保留**现有三值并保证语义不变(`in-class` ↔ 上课隐藏、`no-message` ↔ 无消息隐藏、`no-class` ↔ 无课程隐藏)。
- **新增**(v2 schema,见第 5 部分):`hideRules` 允许字符串扩展 `'max-window' | 'fullscreen'`,判定数据由主进程通过 `IslandState` 新增字段 `foreground: { maximized: boolean; fullscreen: boolean }` 下发(主进程已有前台窗口检测能力,`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:444-457` 为语义参考)。
- **行级**隐藏(ClassIsland 的 `MainWindowLineSettings.HideOnRule`)本轮只做"行整体可隐藏",P1;规则集编辑器(`RulesetControl`)不在本轮范围,P2。
- 编辑器必须能编辑 `hideRules`(现有三值)与 `align/width/maxWidth`;新增两项后要同步 `IslandComponentSettings.vue` 与 `normalizeIslandLayouts` 的白名单(`island-layout.ts:321-328`)。
#### 7.2.8 消息气泡(`family='classisland'` 的 fixed 模式,40px 行内)
**硬性约束:气泡不得增高行、不得增高窗口。** 行高恒 `40px`(`--ci-island-height: 40px` 不得被气泡改写),气泡的遮罩、内容、进度条全部在这个 40px 容器内完成。
DOM(叠在行内容之上,`position: absolute; inset: 0`):
```html
<div class="ci-row-shell"> <!-- height: 40px; position: relative -->
<div class="layout-row">…常态行组件…</div> <!-- 气泡展开时 opacity → 0(不卸载,保留宽度测量) -->
<div class="ci-row-bubble" v-if="mode==='expanded'"> <!-- inset: 0; border-radius: 8px; overflow: hidden -->
<SlantedMask class="ci-bubble-mask" :open="true" :width="rowWidth" :height="40" />
<div class="ci-bubble-row"> <!-- display:flex; align-items:center; height:40px; padding: 0 12px -->
<span class="ci-bubble-icon">…</span> <!-- 16×16 线性图标 -->
<span class="ci-bubble-badge">紧急</span> <!-- 可选;高 18、圆角 9、字号 11 -->
<div class="ci-bubble-text"> <!-- min-width:0; flex:1; display:flex; flex-direction:column; justify-content:center -->
<span class="ci-bubble-title">…单行省略…</span>
<span class="ci-bubble-body">…最多 2 行…</span>
</div>
<span class="ci-bubble-actions">…按钮…</span>
</div>
<div class="ci-bubble-progress" :style="{ transform: `scaleX(${leftProgress})` }" />
</div>
</div>
```
CSS 规格:
```css
.ci-row-bubble { position: absolute; inset: 0; border-radius: var(--ci-radius-island); overflow: hidden; }
.ci-bubble-text { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 0; }
.ci-bubble-title {
font-size: var(--ci-fs-body); /* 16px */
font-weight: 600; line-height: 20px; /* 40px 行内两行:20 + 18 */
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ci-bubble-body {
font-size: var(--ci-fs-secondary); /* 14px */
line-height: 18px; color: var(--ci-text-2);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; /* 正文最多 2 行,超出省略 */
}
.ci-bubble-progress {
position: absolute; left: 0; bottom: 0; width: 100%; height: var(--ci-progress-height);
background: var(--ci-accent-fill); transform-origin: left;
transition: transform 50ms linear;
}
```
**行内的排版预算**:标题 1 行(20px)+ 正文 2 行(18×2=36)已超过 40px,因此实际规则是——**标题占用 1 行时正文最多 1 行;无标题时正文最多 2 行**;两种都靠 `-webkit-line-clamp` + 固定行高把内容锁在 40px 内,**不得**用 `overflow: visible` 让文字溢出到行外。
**动画(严格按 ClassIsland 原版参数)**:
| 阶段 | 属性 | 时长 / 延迟 | 缓动 |
|---|---|---|---|
| 遮罩斜掠(开) | 5 段 `scaleX 0→1` | 每段 360ms,错峰 0/90/90/120/120ms | `cubic-bezier(0.165,0.84,0.44,1)`(QuarticEaseOut) |
| 遮罩斜掠(关) | 5 段 `scaleX 1→0` | 每段 360ms,反向错峰 | `cubic-bezier(0.55,0.085,0.68,0.53)`(QuadraticEaseIn) |
| 气泡文字淡入 | `opacity 0→1` | 250ms,delay 260ms | `cubic-bezier(0.25,1,0.5,1)` |
| 气泡文字缩放 | `scale 1.1→1.0` | 750ms,delay 260ms | `cubic-bezier(0.25,1,0.5,1)` |
| 气泡文字淡出 | `opacity 1→0` | **200ms** | 线性 |
| 文字缩回 | `scale 1.0→1.1` | **200ms** | `cubic-bezier(0.5,0,0.75,0)` |
| 常态行淡出 | `opacity 1→0` | 200ms | `SineEaseIn`(同 ClassIsland 的 `GridContentRoot` 过渡) |
| 进度条 | `scaleX` | 50ms | 线性 |
以上参数与 ClassIsland 完全一致(`repos/ClassIsland/ClassIsland/Controls/SlantedMaskControl.cs:177-200`;`XamlThemes/FluentTheme/Styles.axaml:329-367`)。
**Classic 主题(`appearance.theme='classic'`)**:遮罩改为 `translateY(-50px → 0)`、200ms `QuadraticEaseIn`;退出 `0 → +50px`、delay 100ms、200ms `QuadraticEaseOut`;文字 200ms delay / 100ms(`XamlThemes/ClassicTheme/Styles.axaml:265-331`)。
**禁止**:`islandListLayout()` 的高度再参与 fixed 气泡(它只用于 custom 详情页与 winisland 详情卡);把气泡做成"行下方新增一块 64px 高的区域"。
### 7.3 外观族:`family='classisland'`(默认)与 `family='winisland'`(兼容,仅 fixed)
**适用范围(硬性)**:`appearance.family` **只影响 `fixed` 模式的外观**;`custom` 始终按 ClassIsland 规格渲染(7.2、7.4),**不提供 winisland 回退**。
#### 7.3.1 数据与开关(字段名以第 5 部分为准)
**新增顶层字段 `appearance.family: 'classisland' | 'winisland'`**,与既有 `appearance.style`(`black/glass/tinted` 色调)**不是一个概念**:
- `family` 决定 **fixed** 长什么样、怎么排(信息行 vs 胶囊、圆角 8 vs squircle);对 `custom` 无意义;
- `style` 只在 `fixed + family='winisland'` 下有意义(纯黑/毛玻璃/主题色渐变),其余情况忽略。
落地规则:
1. `packages/shared/src/constants.ts` 新增 `ISLAND_FAMILIES = ['classisland','winisland'] as const` + `ISLAND_FAMILY_LABELS`(`classisland: 'ClassIsland(默认)'`、`winisland: 'WinIsland 经典'`);`ISLAND_STYLES`(`:324`)与 `ISLAND_STYLE_LABELS`(`:326-330`)**保持原样不动**。
2. `DEFAULT_ISLAND_APPEARANCE` 增加 `family: 'classisland'`(`:265-290`)——这是"新安装默认"。
3. `normalizeAppearance()` 接受 `family` 白名单(`main/island.ts:2244-2264`),非法值回退 `'classisland'`。
4. **升级迁移(必须与第 5 部分一致)**:旧配置没有 `family` 字段 → 迁移时写入 `family: 'winisland'`(老用户观感不变);新安装 → `'classisland'`。迁移函数放在 `packages/shared/src/island-layout.ts` 的 `normalizeIslandLayouts()` 里,与 `version` 一并处理。
5. 设置页拆分(`IslandSettingsView.vue:291-305` 现绑定 `style`):
- "外观族"下拉绑定 `appearance.family`,**仅在 `layout='fixed'` 时可见**;
- "视觉风格/色调"下拉仅在 `layout='fixed' && family='winisland'` 时可见;
- `layout='custom'` 时两者都隐藏(custom 恒 ClassIsland 规格),只保留圆角/行高/行距/字号等 Scheme 级设置;
- 切换必须即时生效并可回退(走既有 `appearance` 通道,`preload/island.ts:48-49`)。
6. 主题轴(`appearance.theme: 'fluent' | 'classic'`)由第 6 部分定义;本部分只要求"行背景色/描边/阴影/遮罩动画参数随 theme 切换",落点在 `custom-runtime.css` 的 `[data-theme='classic']` 覆盖组(custom 与 fixed-classisland 共用)。
#### 7.3.2 `family='classisland'`(默认,必须实现)
**fixed 模式下(重定义后)= 信息行 + 行内气泡**:
| 维度 | 规格 | 落点 |
|---|---|---|
| 形态 | **1 行信息行**(默认 日期 + 课程表),不是胶囊 | `IslandApp.vue` 新增 `classisland` 模板分支(见下方改造点) |
| 行高 | 40 | `--ci-line-height` |
| 行内边距 | 左右 12 | `--ci-line-pad-x` |
| 圆角 | 8(矩形圆角,不用 squircle) | `border-radius: var(--ci-radius-island)` |
| 背景 | 不透明度 0.5 + 1px 描边 + 阴影 `0 4 8 2 #48000000` | 7.2.2 |
| 进度条 | 底部 3px(课程进度 / 提醒倒计时) | 7.2.4 |
| 字号 | 14 / 16 / 18 / 20 | `--ci-fs-*` |
| 消息 | **在固定 40px 行内**渲染 ClassIsland 气泡:斜切遮罩(5 段)+ 标题 + 正文(最多 2 行省略)+ 底部 3px 倒计时进度条;**不增高行、不增高窗口** | 7.2.8 + 第 8 部分 |
| 空闲 | 保持 `hidden` / `idleSliver` 语义(不改成常驻) | `main/island.ts:1483-1496` |
| 形变 | **无高度形变**:行高恒 40;只有"行 ↔ 气泡"的内容层交叉淡入/遮罩斜掠;`radius` 恒 8 | `IslandApp.vue:216-264` 的 `classisland` 分支 |
**custom 模式下** = 现有 schemes/rows/nodes 模型升级,**始终 ClassIsland 规格**(7.2、7.4 全部适用),与 `family` 无关;课程提醒接管规则见 7.3.3。
**关键改造点(`IslandApp.vue`)**:现有 fixed 模板是"胶囊层 + 展开层 + 课程层"(`:606-678`),胶囊内容是"图标 + 标题 + 副标题 + chevron"(`:616-659`)。改造要求:
```html
<!-- family=classisland:fixed 信息行(固定 40px 高,气泡在行内叠加) -->
<div class="ci-row-shell" style="height: 40px">
<div class="layout-row"><div class="row-components">
<ComponentNode v-for="node in fixedRowNodes" … />
</div></div>
<div v-if="mode==='expanded'" class="ci-row-bubble" role="region">
<SlantedMask class="ci-row-mask" :open="true" :width="rowWidth" :height="40" />
<div class="ci-bubble-content">…标题 / 正文(2 行省略)/ 操作…</div>
<div class="ci-bubble-progress" :style="{ transform: `scaleX(${leftProgress})` }" />
</div>
</div>
```
- `fixedRowNodes` = 默认 `[islandNode('date','fixed-date'), islandNode('schedule','fixed-schedule')]`;来源可以新建一个内置 fixed 方案(与 custom 的 scheme 结构复用),**不要**把节点写死在模板里。
- `targetSize()` 增加 `classisland` 分支:**高度恒 40**,`{ width: mode==='expanded' ? max(rowNaturalWidth + 24, 420) : rowNaturalWidth + 24, height: 40 }`;**禁止**出现 `40 + bubbleHeight` 之类的增高分支。
- 展开/收起**不做高度弹簧**:`mode` 切换时只播"行内容 → 气泡"的遮罩斜掠与内容交叉淡入(7.2.8 的 360/120ms 与 260/250ms 参数);行高、窗口高全程不动。
- `family='winisland'` 时走原模板,逻辑零改动。
#### 7.3.3 fixed 与 custom 差异总表(含课程提醒接管)
先给出三种组合的**行为**差异总表(后续 7.3.4 细化 winisland、7.3.5 说明共享约束),不得自行发明第四种组合:
| 维度 | `fixed` + `family='classisland'`(默认) | `fixed` + `family='winisland'`(兼容) | `custom`(常驻,**恒 ClassIsland 规格,family 不适用**) |
|---|---|---|---|
| 常态 | 隐藏(或细缝);消息到来才显示 1 行信息行 | 隐藏;消息到来弹胶囊 | 常驻方案 `rows` |
| 行高 | 恒 40 | 胶囊 44 / 展开卡按内容 | `scheme.rowHeight`(默认 40) |
| 消息展示 | **固定 40px 行内**气泡(遮罩+标题+正文 2 行+3px 进度),**行不增高** | 胶囊 → 详情卡(高度按 `islandListLayout`),窗口高度随形态变 | 铃铛 + 详情页(`resident-detail-page`),不改成行内气泡 |
| 课程提醒 | **显示完整课程提醒条**:`LessonReminderContent` 渲染在 40px 行内(准备/上课/课间/下课前) | 显示课程提醒条(现有 `ci-fixed-lesson` 覆盖) | **不因 in-class/prepare/break 临时接管整岛**;课程进度由 `schedule` 组件展示;`ending` 只在课程名右侧显示蓝色圆形秒数徽标(`lesson-ending.css` 的 30×30);**不新增"临时接管一行"行为** |
| 课程进度 | 行底 3px 进度条(当前课程) | 课程提醒条底部 2px(改 3px,7.2.4) | `schedule` 组件内 3px 进度条 |
| 多消息 | 第 8 部分规则;气泡内最多 2 行正文,超出省略 | 列表模式(`islandListLayout`)| 详情页 `ClassicNotifications` list-mode |
| 空闲交互 | 鼠标移入不淡化(隐藏态);显示时按命中接收 | 指针避让淡化 0.05 | 指针避让淡化 0.05(`updateResidentPointer`) |
| 窗口几何 | 宽 = min(工作区宽, 960),高恒 40+2×pad(pad 默认 20) | 固定包围盒(最大形态 + 2×pad) | 固定画布(内容上限 + 2×pad) |
**custom 课程提醒的落地改动(必须做,且只做这些)**:
- `CustomIslandRuntime.vue:31-33` 的 `lesson` 计算改为 **恒 `null`**(不再让 prepare/on-class/break/in-class 触发 `resident-lesson` 页),并从模板移除/停用 `resident-lesson-page`(`:505-524`)——custom 不再整岛接管。
- 保留 `ending` 徽标:`ComponentNode.vue` 的 `.lesson-ending-badge`(`:261-266`,样式 `lesson-ending.css:1-16`)继续在课程名右侧显示 `-{remaining}`,尺寸 30×30、`#0a84ff` 底、白字 12px/600、等宽数字。
- **不得**新增 "custom 下临时接管一行显示课程提醒" 的行为(这是本轮明确排除的范围)。
#### 7.3.4 `family='winisland'`(兼容,仅 fixed,保持现有外观)
- 几何:胶囊 `width × height`(默认 268×44)、圆角 = `min(appearance.radius, h/2)`(`IslandApp.vue:206-212`)、展开卡半径 = `min(48 × radius/20, w/2, h/2)`。
- 形状:squircle 超椭圆(`squircle.ts:21-24`,指数 4.2、每角 14 段)。
- 底色:`appearance.style` 的 `black` / `glass` / `tinted` 三套 CSS 变量组(`classic-island.css:60-86`),用 `data-style` 属性表达(现有模板已写 `:data-style="appearance.style"`,`IslandApp.vue:587`、`CustomIslandRuntime.vue:348`)。
- 排版:`--island-font` 基础字号 + 系数(`ISLAND_TYPE_SCALE`,`constants.ts:354-367`),胶囊内边距 `0 font*0.78`、gap `font*0.62`(`classic-island.css:207-216`)。
- 阴影:只在展开态 `0 2px 3px rgba(0,0,0,.11)`,且**声明常驻只改参数**(`classic-island.css:110-124, 148-153`)。
- 交互:胶囊点击展开、点击卡片空白收起、失焦宽限 500ms——全部保持现状。
#### 7.3.5 三种组合的共享约束
无论 `layout` 与 `family` 如何组合都必须满足:
- 命中/穿透/触摸/失焦/心跳逻辑**完全不变**(7.1.4、7.1.5):family 只改 **fixed** 的渲染,不改输入、不影响 custom。
- 编辑器与设置页共用同一份 `islandLayout` 数据;`family` 不改变数据模型(除 7.4 的字段增补);`custom` 的 scheme/rows/nodes 在两套 family 下渲染完全一致。
- 所有组合的 `ComponentNode` 是**同一个组件**,规格共用 7.4;差异只在行容器、背景层与气泡容器。
- `prefers-reduced-motion` 与应用内"动画"开关同时生效;关闭动画时所有弹簧直接 `snap()`(`CustomIslandRuntime.vue:148-151`、`IslandApp.vue:249-256`)。
- 可回退:feature flag(`family`)必须在 M2/M3 阶段即可一键切回旧 fixed 渲染;`custom` 无回退档(始终 ClassIsland 规格),验收时 `fixed×2 family` 与 `custom` 三条路径都要跑(第 11、12 部分)。
### 7.4 组件逐个规格(11 内置 + 3 消息入口 + 课件覆盖)
统一约定(所有组件适用):
- 契约字段:`id / kind / settings / appearance / advanced / children`(`island-layout.ts:77-108`)。
- `appearance`:`resourceOverridingEnabled / secondaryFontSize / bodyFontSize / emphasizedFontSize / largeFontSize / customForegroundEnabled / foreground / customBackgroundEnabled / background / customBackgroundOpacityEnabled / backgroundOpacity / customCornerRadiusEnabled / cornerRadius / opacity`(`:61-76`)。
- `advanced`:`hideRules / align / width / maxWidth`(`:101-106`)。
- 默认值来源:`islandNode()`(`:173-216`),规范化边界在 `normalizeIslandLayouts`(`:257-403`)。
- 外观应用方式:`ComponentNode.style` 计算(`ComponentNode.vue:179-210`)——`opacity`、`width`、`maxWidth`、`alignSelf`(仅父级有宽度时)、`textAlign`、自定义前景/背景/圆角、字号覆盖、容器方向与 gap。
- 字体:`--secondary` 默认 14、`--emphasized` 默认 18、`--large` 默认 20(由 `CustomIslandRuntime.vue:119-122` 按 `appearance.fontSize/13` 比例算出)→ **改为直接使用 `--ci-fs-secondary/emphasized/large`(14/18/20)**,不再按字号比例缩放;只有 `appearance.fontSize` 作为"整岛缩放"时再乘系数。
- 图标:统一用 `ComponentIcon.vue` 的 24×24 线性图标(`ComponentIcon.vue:4-35`,`stroke-width 1.5`);ClassIsland 用 FluentSystemIcons,**不引入新图标字体**,按语义映射(见各组件)。
#### 7.4.1 `date` 日期
- 数据字段:仅 `settings` 常规字段(`text` 等未使用)。
- 渲染:`new Date(now).toLocaleDateString('zh-CN', { month:'long', day:'numeric', weekday:'long' })`(`ComponentNode.vue:134-135`),如 `11月12日星期三`。
- ClassIsland 基准:`ddd MM/dd` 格式(`repos/ClassIsland/ClassIsland/Controls/Components/DateComponent.axaml:11`),即 `周三 11/12`。
- **改造**:默认格式改为 `周三 11/12`(短星期 + 补零月日 + 斜杠),保留 `zh-CN` 区域;字号 `var(--ci-fs-body)`(16px)、`white-space: nowrap`、`font-variant-numeric: tabular-nums`。
- 空数据兜底:`now` 异常时显示 `--`,不得抛错。
- 动画:无。
#### 7.4.2 `clock` 时钟
- 数据字段:`settings.hour12`(默认 `false`)、`settings.seconds`(默认 `true`,`island-layout.ts:182`)。
- 渲染:`toLocaleTimeString('zh-CN', { hour:'2-digit', minute:'2-digit', ...(seconds?{second:'2-digit'}:{}), hour12 })`(`ComponentNode.vue:136-142`)。
- ClassIsland 基准:`HH:mm` / `HH:mm:ss`、冒号闪动(`repos/ClassIsland/ClassIsland/Controls/Components/ClockComponent.axaml.cs:71-76`)。
- **改造**:
- 秒数默认改 `false`(与 ClassIsland 一致,`Models/ComponentSettings/ClockComponentSettings.cs` 的 `ShowSeconds=false`),旧配置不动;
- 新增 `settings.flashSeparator`(默认 `false`,P2 才做闪动);闪动规则 `sep = !flash || seconds || 秒%2===1`;
- 字号 `var(--ci-fs-emphasized)`(18px)、`font-variant-numeric: tabular-nums`、`white-space: nowrap`(现有 `:305-308` 已有)。
- 空数据兜底:无。
- 动画:秒变化不做过渡(避免数字滚动抖动)。
#### 7.4.3 `weather` 天气简报
- 数据字段:`settings.locationName / latitude / longitude`(`island-layout.ts:87-90`)。
- 数据源:`bridge.getIslandWeather({ latitude, longitude })`(`preload/island.ts:15`),Open-Meteo,15 分钟刷新,断网保留缓存并标"缓存"(`docs/app/island.md:45`)。
- 渲染:`WeatherBrief.vue`(现有 2.9KB,需按下面改写)。
```html
<span class="weather-brief" :data-stale="stale">
<icon class="weather-icon" /> <!-- 22×22,用 ComponentIcon 或天气码映射 -->
<span class="weather-temp">
<span class="value">23</span><span class="unit">°C</span>
</span>
<span v-if="windSpeed" class="weather-wind">…</span>
</span>
```
- ClassIsland 基准(`repos/ClassIsland/ClassIsland/Controls/Components/WeatherComponent.axaml:51-184`):横排 `gap 6`;图标 22×22;温度值 + 单位 `margin-left:6`;湿度/风/气压/体感/AQI 为可选主信息(一次只显示一种);AQI 6 级配色;预警药丸高 22、圆角 11、文字 14px。
- **改造**(P1 做图标 + 温度;其余 P2):
- 图标尺寸 22×22、`margin-right: 6px`;
- 温度 `font-size: var(--ci-fs-body)`(16px);
- 地点缺失(`latitude/longitude === null`)→ 显示"未设置地点"(`opacity: 0.7`),不得显示 `NaN`;
- 数据缺失 → 只显示图标占位 + `--`;
- `stale === true` → 追加小字"缓存"(`var(--ci-fs-caption)`,`opacity: 0.6`)。
- 动画:数据刷新时数字变化不做过渡(避免每 15 分钟闪一下)。
#### 7.4.4 `countdown` 倒计时
- 数据字段(`island-layout.ts:85-88`):`target: 'auto'|'next'|'end'|'school'|'date'`、`targetDate`、`countdownTitle`。
- 渲染逻辑:`ComponentNode.vue:150-176`(自动/下一节/本节下课/放学/指定日期五种目标,文案 `距上课 m:ss` / `距下课 m:ss` / `距放学 m:ss` / `距X N天`)。
- ClassIsland 基准(`repos/ClassIsland/ClassIsland/Controls/Components/CountDownComponent.axaml:64-114`):`距离 <名称> 还有 <数值>`,紧凑模式去掉"距离/还有";进度环 22×22(或底部进度条);自定义格式 token `%D/%H/%M/%S/%P/%p/%L/%d/%h/%m/%s/%x`。
- **改造(P1)**:
- 文案结构改为 `distance`(默认"距离")+ `countdownTitle` + `connector`(默认"还有")+ 数值;新增 `settings.compact`(默认 `false`);
- 新增 `settings.showProgress`(默认 `false`)+ `settings.progressMode: 'ring'|'bar'`(默认 `ring`):ring 用 22×22 SVG 弧(`stroke-width 3.1`、`stroke-linecap: round`、半径 9.5),bar 用 3px 底部进度条;
- 数值格式化:`%m/%s` 补两位、`%D/%H/%M/%S` 向上取整(与 ClassIsland 一致),默认格式 `%m:%s`(`mm:ss`);
- 颜色:`var(--ci-text-1)`;`settings.color` 覆盖(P2)。
- 空数据兜底:无课程数据 → `今日课程已结束`(现有 `:170`);目标日期为空 → `请设置目标日期`(`:152`)。
- 动画:进度条 `transition: transform 1s linear`;ring 的 `stroke-dashoffset` 用 1s 线性过渡。
#### 7.4.5 `schedule` 课程表
最复杂,必须逐条对照 ClassIsland。
- 数据字段:`settings.display: 'current'|'next'|'today'`(默认 `current`)、`settings.count`(默认 5,范围 1–40)、`settings.showPeriod`(默认 `true`)、`settings.showProgress`(默认 `true`)。
- 数据源:`schedules` + `timePoints`(`ComponentNode.vue:30-61`),时间点优先用服务端完整时间表,缺失时回退课表条目。
- 显示规则(现有,保留):`ComponentNode.vue:74-97`——
- `current`:当前时间点;无则下一节;
- `next`:下一节课 1 条;
- `today`:过滤 `DIVIDER`、当前时间点、未隐藏的 `LESSON`,按 `count` 取窗口并让当前项居中。
- ClassIsland 基准(`repos/ClassIsland/ClassIsland/Controls/Components/ScheduleComponent.axaml` + `LessonControlExpanded.axaml`):
- 每个时间点一个"课程 chip":左右各 `16px × ScheduleSpacing`(默认 1)、科目名 `18px Bold`、附加信息 14px、进度条 3px;
- 隐藏规则:`HideFinishedClass`、`ShowCurrentLessonOnlyOnClass`、`IsHideDefault`、课间只在当前时展开;
- 明天课表 4 种模式(P2);
- 空状态占位"今天没有课程。"/"今日课程已全部结束。"。
- **改造(P1)**:
```html
<div class="node node-schedule" :data-display="display">
<div v-for="p in schedulePoints" :key="p.id" class="lesson-line" :class="{ current, break: p.timeType==='BREAK' }">
<strong class="lesson-name">{{ p.courseName || p.name || '—' }}</strong>
<span v-if="isEnding" class="lesson-ending-badge">-{{ remaining }}</span> <!-- 30×30 正圆,见 lesson-ending.css -->
<small v-if="current && settings.showPeriod" class="lesson-time">{{ p.startTime }}–{{ p.endTime }}</small>
<span v-if="current && p.timeType==='LESSON' && settings.showProgress" class="lesson-progress">
<i :style="{ transform: `scaleX(${elapsed})` }" />
</span>
</div>
<span v-if="!schedulePoints.length" class="lesson-empty">今天没有课程。</span>
</div>
```
- 课程名 `var(--ci-fs-emphasized)`(18px)+ `font-weight: 700`;时间/附加信息 `var(--ci-fs-secondary)`(14px);
- 行内 gap 6px(现状 `ComponentNode.vue:313-321` 已是 6px,保留);
- 进度条 3px(7.2.4);
- 已结束课程 `opacity: 0.6`(ClassIsland `LessonsListBox.axaml:56-58`),过渡 150ms `cubic-bezier(0.645,0.045,0.355,1)`;
- 分割线:`1×18`、`opacity: 0.25`(现状 `:334-342`),可提到 24px 与 ClassIsland 的 25px 对齐(P2);
- 课后占位:现有"今日课程已结束"只用于 countdown;schedule 需新增 `今天没有课程。`/`今日课程已全部结束。` 两态(ClassIsland `LessonControlSettings` 默认文案)。
- 空数据兜底:`timePoints` 与 `schedules` 都为空 → 显示占位、不占高度超过 40px;只有 `schedules` → 回退按条目渲染(现有逻辑)。
- 动画:切换时间点时课程名不做滑动(ClassIsland 是整行裁切),只做进度条与徽标的数值更新。
#### 7.4.6 `text` 文本
- 数据字段:`settings.text`(默认 `'班级信息'`)、`settings.template`(默认 `false`)。
- 渲染:模板变量 `{date}` / `{time}` / `{course}`(`ComponentNode.vue:143-149`)。
- ClassIsland 基准:`TextComponent`(文本内容 + 自定义字体颜色 + 字体大小 16–30)。
- **改造**:字号 `var(--ci-fs-body)`;超长 `text-overflow: ellipsis`(现有 `:125-129` 已有);模板变量保留;新增 `settings.fontSize`(默认 16,范围 16–30)写入 inline `font-size`。
- 空数据兜底:`text` 为空 → `display: none`(不占宽度与间距)。
- 动画:无。
#### 7.4.7 `separator` 分割线
- 数据字段:无。
- 现状:`1px × 24px`、`opacity: 0.25`(`ComponentNode.vue:369-377`,模板 `:249`)。
- ClassIsland 基准:`2×25`、`StrokeThickness=2`、`opacity 0.8`(`repos/ClassIsland/ClassIsland/Controls/Components/SeparatorComponent.axaml:10-14`)。
- **改造**:宽 `2px`、高 `25px`、颜色 `var(--ci-text-fill-secondary)`(`rgba(255,255,255,0.72)`)、`opacity: 0.8`。
- 空数据兜底:无。
- 动画:无。
#### 7.4.8 `slide` 轮播容器
- 数据字段:`settings.interval`(默认 5,范围 1–300)、`settings.direction`、`settings.gap`。
- 现状:非编辑时按 `floor(now / (interval*1000)) % children.length` 只渲染一个子项(`ComponentNode.vue:121-130`),**无过渡动画**。
- ClassIsland 基准:切换间隔默认 15s、三种模式(循环/随机/往复)、启用动画时 250ms 上滑淡入 + 上滑淡出、`cubic-bezier(0.65,0,0.35,1)`、位移 ±40px(`repos/ClassIsland/ClassIsland/Controls/Components/SlideComponent.axaml:72-107`)。
- **改造(P1)**:
- 新增 `settings.interval` 默认改 **15**(旧配置保留)、范围 1–300;
- 新增 `settings.slideMode: 'loop'|'random'|'repeat'`(默认 `loop`);
- 新增 `settings.animate`(默认 `false`):开启后切换用 CSS 类 `slide-in/slide-out`,`250ms`、`translateY(-40px → 0)`、`opacity 0 → 1`;关闭时直接换(现状);
- 子项 `max-height: var(--ci-line-height)`。
- 空数据兜底:0 子项 → 塌缩为 0 宽;1 子项 → 不轮播。
- 动画:`prefers-reduced-motion` 时关闭过渡。
#### 7.4.9 `rolling` 滚动容器
- 数据字段:`settings.speed`(默认 30,范围 1–200)。
- 现状:CSS 动画 `rolling`(`translateX(30%) → -80%`)+ `--rolling-duration: max(1, 200/speed)s`,无限线性(`ComponentNode.vue:384-392, 446-453`,`:205`)。
- ClassIsland 基准(`repos/ClassIsland/ClassIsland/Controls/Components/RollingComponent.axaml.cs:85-135`):仅当内容宽 > 容器宽才滚动;`dur = innerW / speed`、可暂停、可回程;速度默认 40px/s。
- **改造(P1)**:
- 默认速度改 **40**;`--rolling-duration: calc(var(--rolling-distance) / var(--rolling-speed) * 1s)`;
- 仅当 `scrollWidth > clientWidth` 时加 `rolling` 类(用 JS 测量,`ResizeObserver` 监听容器与内容);
- 保留"编辑时不滚动"(`:390-392`);
- 暂停/停止规则 P2。
- 空数据兜底:内容不溢出 → 静止、不抖动。
- 动画:`linear`、无限循环;`prefers-reduced-motion` 时静止。
#### 7.4.10 `group` 分组容器
- 数据字段:`children`、`settings.direction`(默认 `horizontal`)、`settings.gap`(默认 8)。
- 现状:`display:flex`、`gap`、`align-items: center`(`ComponentNode.vue:206-208, 378-380`)。
- ClassIsland 基准:分组容器 = 横向 `StackPanel` + `Margin="-6 0"` + 子项 `Margin="6 0"`(`repos/ClassIsland/ClassIsland/Controls/Components/GroupComponent.axaml:13-33`),即子项间距 12。
- **改造**:`gap` 默认改 **12**;子项外边距 0;容器自身不带内边距。
- 空数据兜底:0 子项 → 0 宽。
- 动画:无(子项变化时行宽由行过渡)。
#### 7.4.11 `stack` 堆叠容器
- 数据字段:`children`、`settings.direction`、`settings.gap`。
- 现状:`flex-wrap: wrap`(`ComponentNode.vue:381-383`)——这是"换行排列",**不是 ClassIsland 的堆叠语义**。
- ClassIsland 基准:`Panel`,所有子项占据同一矩形、各自按对齐摆放(`repos/ClassIsland/ClassIsland/Controls/Components/StackComponent.axaml:12-32`)。
- **改造(P1)**:`.stack { display: grid; }`,所有子项 `grid-area: 1 / 1`,`align-self/justify-self` 由 `advanced.align` 决定;容器宽高取子项测量最大值;子项间距 0。
- 空数据兜底:0 子项 → 0 宽。
- 动画:无。
#### 7.4.12 消息入口 `notification` / `homework` / `call`
这三个 kind 不是普通内容组件,而是"消息入口":
- 现状:渲染时在 `ComponentNode` 里走分支(`ComponentNode.vue:285-288`),只有 `node.id === alertNodeId` 时才渲染 `<slot name="alerts">`,否则编辑态显示文案;数据来自 `IslandState.active/queued`。
- 组件库只展示前 11 类(`docs/app/island.md:40`),消息类由铃铛与消息面板承担。
- **ClassIsland 对应**:通知行/提醒气泡(行内遮罩 + 浮层 + 进度条,`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:70-202`)。
- **改造(P1)**:
- 保留三个 kind 作为"入口标记":`notification` = 通知入口、`homework` = 作业入口、`call` = 叫人入口;
- 铃铛显示逻辑(`CustomIslandRuntime.vue:421-441`)改为:有消息时显示图标 + 数量徽标(`99+` 上限),无消息时显示 chevron;铃铛图标沿用 `ComponentIcon` 的 `notification` 路径;
- 徽标尺寸保持 14px 高、圆角 7、`#f45b58`(`custom-island.css:94-110`),文字 9px/600;**ClassIsland 风格下**允许改为 `var(--ci-accent-fill)` 底 + 白字(一行样式覆盖);
- 消息卡本体的内容与业务由第 8 部分规定;本部分只规定容器:
- `custom` 详情页:高度 = `islandListLayout().height`(`constants.ts:445+`),宽度 = 岛上可用宽度,位置在 `resident-detail-page`;
- `fixed + classisland`:**固定 40px 行内气泡**(7.2.8),`islandListLayout()` **不参与**;
- `fixed + winisland`:沿用现有详情卡高度 = `islandListLayout().height`。
- 空数据兜底:无消息 → 三个入口都不渲染(`hidden` 计算已处理,`ComponentNode.vue:108-120`)。
- 动画:徽标出现 `resident-indicator-in`(`custom-runtime.css:147-149, 199-206`)保留;`custom` 详情页展开/收起走高度弹簧;`fixed + classisland` 走 7.2.8 的遮罩/文字动画,**不做高度动画**。
#### 7.4.13 `CoursewareTransfer` 课件覆盖
- 触发:`state.coursewareTransfer` 非空时,`LayoutIslandApp.vue:58, 71-75` 直接渲染课件卡,并让经典岛 `suspended`、自定义岛不渲染(`:57`)。
- 现状视觉:`position: fixed`、尺寸 `COURSEWARE_ISLAND_SIZE`、`padding 17px 24px`、`border-radius 24px`、底色 `rgba(20,20,24,0.97)`、阴影 `0 2px 8px #0004`、字号 13/1.4、进度条 5px(`CoursewareTransfer.vue:67-131`)。
- ClassIsland 对应:课件传输不在 ClassIsland 中,属 ClassHelper 独有能力 → **保留现状视觉**,只做两点对齐:
1. 锚点/边距与岛一致(左/中/右 + `ISLAND_SHADOW_PAD`,`CoursewareTransfer.vue:12-25`),尺寸不随 `style` 变化;
2. `pointer-events: none` 保留(`:81`),并由 `setHitRect` 上报矩形(`:35-38`),保证不吞桌面点击。
- 空数据兜底:`transfer.error` 存在时显示错误行(`:64`),不隐藏整卡。
#### 7.4.14 组件默认值总表(必须与代码一致)
`islandNode()` 当前默认(`island-layout.ts:173-216`)必须逐项落实;本轮改动用 **粗体** 标注:
| 字段 | 当前默认 | 本轮目标 |
|---|---|---|
| `settings.text` | `班级信息` | 保留 |
| `settings.template` | `false` | 保留 |
| `settings.hour12` | `false` | 保留 |
| `settings.seconds` | `true` | 保留(新节点默认仍 true,兼容) |
| `settings.target` | `auto` | 保留 |
| `settings.targetDate` | `''` | 保留 |
| `settings.countdownTitle` | `目标日` | 保留 |
| `settings.locationName` | `''` | 保留 |
| `settings.latitude/longitude` | `null` | 保留 |
| `settings.display` | `current` | 保留 |
| `settings.count` | `5` | 保留 |
| `settings.showPeriod` | `true` | 保留 |
| `settings.showProgress` | `true` | 保留 |
| `settings.direction` | `horizontal` | 保留 |
| `settings.gap` | `8` | **12**(组/堆叠容器子项间距) |
| `settings.interval` | `5` | **15**(轮播间隔,秒) |
| `settings.speed` | `30` | **40**(滚动 px/s) |
| `appearance.*` 字号 | 14/16/18/20 | 保留(改用 `--ci-fs-*`) |
| `appearance.background` | `#202024` | 保留 |
| `appearance.backgroundOpacity` | `0.5` | 保留 |
| `appearance.cornerRadius` | `16` | **8**(与 ClassIsland 圆角一致) |
| `appearance.opacity` | `1` | 保留 |
| `advanced.align` | `stretch` | 保留 |
| `advanced.width/maxWidth` | `0/0` | 保留 |
### 7.5 编辑器渲染侧结构(组件库 / 拖拽 / 选中框 / 行设置)
编辑器是主窗口里的设置页(`IslandEditorView.vue`,860 行),**不是**岛的渲染进程;但它的 DOM/CSS 必须与岛保持同一套 token,避免"编辑器里好看、上岛变形"。
#### 7.5.1 两态:标签排列 vs 实际预览
- 非编辑态(`editMode=false`):`component-lines` 排列行与组件 chip(`IslandEditorView.vue:659-695`),每行一个 `.component-line`。
- 编辑态(`editMode=true`):`island-preview` 按方案的真实布局渲染(`:698-738`),支持预览缩放 `0.3–1.5`、步进 `0.1`、重置(`:739-750`)。
- **必须保留**:"编辑预览不创建额外桌面窗口"(`docs/app/island.md:47`)。
#### 7.5.2 尺寸与选中框(ClassIsland 规格)
| 元素 | 现状 | 目标 |
|---|---|---|
| 行容器 `.component-line` | `min-height: 54px`、`padding: 4px`、`border: 1px solid transparent`、`radius: var(--ch-radius-control)`(`island-editor.css:134-141`) | `min-height: 48px`(行高 40 + 上下 4)、`padding: 4px 8px` |
| 行把手 `.row-handle` | `width: 24px`(`:149-154`) | 保留 24px |
| 组件 selecetd | `background: var(--ch-accent-soft)`、`border-color: var(--ch-accent)`(`IslandEditorNode.vue:86-89`) | **改为 1.5px 虚线**:`outline: 1.5px dashed var(--ci-accent)`;`outline-offset: -1.5px`;不加背景色 |
| 容器组件 | `border: 1px dashed var(--ch-border-strong)`、`padding: 3px`(`:90-94`) | `1.5px dashed`、`padding: 3px`、`border-radius: 8px` |
| 拖入指示 | `.drop-before/after` 3px 强调色竖条(`:111-123`);`.drop-inside` 2px 实线(`:107-110`) | 保留,颜色改 `var(--ci-accent)`、圆角 1.5px |
| 拖拽幽灵 | `.drag-ghost`(`IslandEditorView.vue:774-780`) | 保留,`padding: 4px 8px`、`border-radius: 6px`、背景 `var(--ci-bg-tertiary)` |
| 详情抽屉 | `el-drawer` 宽 `min(360px, 94vw)`(`:785`) | 保留 |
#### 7.5.3 行设置面板字段
现有行面板(`IslandEditorView.vue:804-831`):当前行、行名称(≤64 字)、组件间距(0–64)、允许显示提醒、设为主要行、上移/下移/删除。
**目标态新增(与 ClassIsland 对齐,P1)**:
- 行不透明度 `opacity`(默认 1.0,范围 0.1–1,步进 0.05)——写在行 `style.opacity`;
- 行隐藏规则 `hideOnRule`(默认 false)+ 规则集(P2);
- 行圆角覆盖(P2)、行背景色/不透明度覆盖(P2,对应 ClassIsland 行级"自定义背景颜色/不透明度/圆角")。
保存路径不变:`bridge.saveLayout()`(`preload/island.ts:17`)→ `island:save-layout`。
#### 7.5.4 组件库
- 11 个条目(`date/clock/weather/countdown/schedule/text/separator/slide/rolling/group/stack`),每项 `ComponentIcon` + 名称 + 描述(`IslandEditorView.vue:751-770`,数据来自 `ISLAND_COMPONENT_LABELS` + 本地描述表)。
- 交互:`pointerdown` 开始拖拽、`click` 点击添加(`:760-761`)。
- 目标态:图标尺寸 18×18(`IslandEditorNode.vue:74-78`)保持不变;卡片 `min-height: 76px`(`island-editor.css:285`)保留;**不得**把 `notification/homework/call` 加回组件库(`docs/app/island.md:40` 的既有约定)。
#### 7.5.5 渲染侧必须新增/调整的 DOM 与 token
必须新增(岛渲染进程):
- `.ci-row-shell`(`classisland` 风格 fixed 模式的行容器);
- `.ci-mask-layer` / `.ci-mask-title`(7.2.6);
- `.lesson-empty`(课程表占位);
- `.resident-bell` 的 `[data-family='classisland']` 覆盖;
- `.layout-row[data-hide='true']`(隐藏规则命中)。
必须新增(CSS 变量,挂在 `:root` 与 `.resident-card` 上):
```css
--ci-line-height: 40px; --ci-line-pad-x: 12px; --ci-line-gap: 5px; --ci-component-gap: 12px;
--ci-radius-island: 8px; --ci-island-opacity: 0.5; --ci-line-opacity: 1;
--ci-stroke-island: #ffffff26; --ci-stroke-island-w: 1px;
--ci-shadow-island: 0 4px 8px 2px #48000000;
--ci-progress-height: 3px;
--ci-fade-ratio: 0.05;
--ci-fs-secondary: 14px; --ci-fs-body: 16px; --ci-fs-emphasized: 18px; --ci-fs-large: 20px;
--ci-bg-secondary: #20202080; --ci-text-1: rgba(255,255,255,0.92); --ci-text-2: rgba(255,255,255,0.72);
--ci-accent: (用户强调色); --ci-accent-fill: color-mix(in srgb, var(--ci-accent) 100%, transparent);
```
**命名对齐**:`packages/shared/src/island-layout.ts:20-36` 的 `CLASSISLAND_ISLAND_METRICS` 已经是这些值的单一来源(`rowHeight:40`、`horizontalPadding:12`、`radius:8`、`opacity:0.5`、`rowSpacing:5`、字号 14/16/18/20、`fadedOpacity:0.05`)。渲染层**必须**从该常量生成 CSS 变量(通过 `style` 内联或 `applyAppearance` 写入 `document.documentElement.style`,位置同 `CustomIslandRuntime.vue:102-124`),**不得**在 CSS 里再抄一份数字。
### 7.6 验收标准
实现者必须逐条自查,并**只在显式 `--visual` 的视觉验证里**做桌面像素比对(默认后台验证不显示岛):
1. 默认值:新安装 `appearance.family = 'classisland'`;旧配置(无 `family`)升级后 `family = 'winisland'` 且 fixed 观感与升级前一致,不报错、不丢组件数据;设置页可一键切换并即时预览、可回退。
1.1 **`family` 只影响 fixed**:`layout='custom'` 时切 `family` 不改变任何渲染(custom 恒 ClassIsland 规格),设置页在 custom 下不展示"外观族/视觉风格"两项;`custom` 无 winisland 回退档。
2. `family='classisland'` 的 **fixed 模式是信息行不是胶囊**:默认 1 行 = 日期 + 课程表、行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条;无消息时不显示(或按 `idleSliver` 留细缝);消息到来时在**同一条 40px 行内**渲染 ClassIsland 气泡(斜切遮罩 + 标题 + 正文 + 倒计时进度),收起后回到常态行。
2.1 **气泡不增高行**:气泡出现/消失前后,行高恒 40px(像素量测误差 ≤1px),窗口高恒 40+2×pad,`win.getBounds()` 不变;正文最多 2 行、超出省略号,不得出现文字溢出到行外或行高变成 48/56/64。
2.2 气泡动画参数与 ClassIsland 一致:遮罩每段 360ms、错峰 0/90/90/120/120ms(总 480ms)、开 QuarticEaseOut / 关 QuadraticEaseIn;文字 250ms delay 260ms + scale 1.1→1.0/750ms;退出 200ms、缩回 `cubic-bezier(0.5,0,0.75,0)`;`theme='classic'` 走 translateY 变体。
3. `family='winisland'` 的 fixed 模式仍是胶囊 + 详情卡:胶囊 268×44、圆角 `h/2`、squircle、纯黑/玻璃/渐变三色调、展开态阴影 `0 2px 3px rgba(0,0,0,.11)`;与改造前逐像素一致。
4. 岛在 6 个停靠位置、两个显示器、125%/150% DPI 下位置正确:居中停靠不受 `marginX` 影响;左右停靠距屏幕边缘 = `marginX`;底停靠时窗口贴底且岛不被裁。
5. 窗口几何:开合过程中 `win.getBounds()` **不变化**(用主进程日志或注入断言验证);只有设置变化/不可见时才 `setBounds`;`classisland` fixed 的窗口高恒为 40 + 2×20 = 80 DIP。
6. 行:可见行高 `40px`(像素量测)、行内边距 12、行距 5、圆角 8、描边 1、阴影 `0 4 8 2 #48000000`;相邻组件间距 12;进度条 3px。
7. 背景不透明度 0.5 只影响背景:文字在截图里为纯白 0.92/0.72 层级,不随背景变透明。
8. 三条进度条高度都是 3px(课程、课程提醒、通知气泡),且不出现 2px 残留。
9. `custom` 模式:`rows` 与 `expandedRows` 都按行渲染;`scheme.separated=true` 时每行独立背景胶囊,无整块背景;**custom 渲染恒按 ClassIsland 规格,切换 `family` 不产生任何视觉变化**。
9.1 **custom 不被课程提醒接管**:prepare / on-class / break / in-class 期间岛仍显示常态行与铃铛(课程进度由 `schedule` 组件呈现),不出现整岛替换;`ending` 只在课程名右侧显示蓝色圆形秒数徽标(30×30、`-N`);不存在"临时接管一行"的行为。
10. 遮罩:通知/课程提醒共用 `SlantedMask`,5 段从两侧向中间打开(总 480ms)、关闭反向;开启减少动态效果或关闭动画时直接落位。
11. 触摸屏:手指能点开岛(触摸模式生效),窗口贴合岛体,失焦宽限 1200ms;非触摸屏行为与改造前完全一致。
12. 鼠标穿透:岛外点击一律落到桌面;命中矩形随形变更新(限流 50ms),形变结束后无"看不见却点不动"的死区。
13. 11 个组件逐项:`date` 显示 `周三 11/12`、`clock` 显示 `HH:mm`、`weather` 显示 22px 图标 + 温度且无数据时不出现 `NaN`、`countdown` 文案与进度环/条可选、`schedule` 当前项高亮 + 3px 进度 + 已结束 0.6 淡化 + 空占位、`text` 空内容不占宽、`separator` 2×25/0.8、`slide` 15s 切换且可关动画、`rolling` 仅溢出时滚动且 40px/s、`group` 间距 12、`stack` 子项完全重叠。
14. 消息入口:铃铛数量徽标正确(1–99,>99 显示 `99+`),无消息显示 chevron;`notification/homework/call` 不在组件库出现,但旧方案里的节点仍能渲染。
15. 课件覆盖:传输中出现课件卡且岛隐藏;锚点与边距正确;`pointer-events: none`,不吞点击。
16. 编辑器:选中组件为 1.5px 虚线强调框;容器组件为 1.5px 虚线;拖入指示在 before/after/inside 三种位置正确;预览缩放 0.3–1.5 不导致岛变形错位。
17. `prefers-reduced-motion` 与设置里的"动画"开关都生效:关闭时所有弹簧 `snap()`、滚动静止、遮罩直接落位。
18. 不得出现:`setBackgroundMaterial`、`backdrop-filter`、第二个 `BrowserWindow`、把窗口尺寸写进 CSS 的做法、在动画帧里 `setBounds`。
---
## 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 的状态/空态/主题/响应式行为逐一验证。
---
## 10. 动画与动效映射(ClassHelper 现状 → ClassIsland 目标;双风格默认值)
本节是动效施工图。实现者必须先把「风格开关」接好,再按表逐条替换动画:`family='classisland'`(默认)走 ClassIsland 规格的 CSS transition/keyframes + 遮罩分块;`family='winisland'`(老用户兼容)保留现有弹簧形变与胶囊开合。**同一条动画不得同时用两套实现**,由 `data-family` 与 `appearance.animations` 决定分支。
### 10.0 总则与不可协商的规则
1. **动画级别只有一个开关**:岛的动画由 `appearance.animations`(boolean)总控,**不是** ClassIsland 的 `AnimationLevel`(那是主窗口的设置)。`animations=false` 时所有过渡必须「瞬落」:弹簧 `snap()`、CSS `transition/animation: 0ms`、遮罩直接设终值(现有实现见 `IslandApp.vue:249-256`、`LessonReminderContent.vue:77-80`)。
2. **速度倍率**:`appearance.speed`(0.5–2)按比例缩放所有时长:`实际时长 = 基准时长 / speed`。现有实现已贯穿:弹簧 `dt × speed`(`IslandApp.vue:291`)、窗口淡入 `IslandApp.vue` 之外的 `--island-dur = 320/max(.25,speed)`(`:528`)、课程条 `--ci-duration = 200/speed` 等(`LessonReminderContent.vue:134-137`)。新增动画一律除以 `speed`。
3. **弹簧仅限 winisland**:`family='classisland'` 的几何变化用 CSS transition;`family='winisland'` 继续用 `SpringValue`(stiffness 0.10 / damping 0.68 / 换向保留 35% / 单帧 ≤ 剩余 20% / snap 0.05px / 零过冲钳制,`packages/desktop-client/src/island/spring.ts:26-114`)。**不得**在 classisland 分支引入弹簧(Lead 裁决,`parts-r/01-objective.md:52`)。
4. **窗口几何不动画**:开合动画全部发生在固定包围盒内部(`main/island.ts:1412-1417`)。classisland 的 fixed 信息行也必须沿用这一约束:行宽/气泡变化在渲染层完成,窗口只在「不可见时」或「模式/设置变化」时 `setBounds`(`island.ts:1476-1479,1598-1612`)。**fixed 单条气泡的行高恒为 40px**,气泡(遮罩/内容/进度条)全部在 40px 行内完成,不因内容改行高、也不因此扩窗;`maxCardHeight` 只用于 custom 的多条消息列表与强提醒窗口的尺寸计算。唯一例外是「多条消息列表」比普通形态更高,窗口必须在形变开始前先长高(`island.ts:1514-1518`),与单条气泡无关。
5. **不可打断的动画可被新状态覆盖**:新状态直接接管(取消旧 rAF/定时器、重设目标),不得排队、不得闪回。现有 `syncTargets(snap)`、`animate()`、`animateMask()` 都按此实现(`IslandApp.vue:238-264`、`CustomIslandRuntime.vue:127-177`、`LessonReminderContent.vue:73-102`)。
6. **隐藏页/窗口不可见必须暂停**:custom 的 `motion = animations && !document.hidden && nativeVisible`(`CustomIslandRuntime.vue:57-58`);classic 通过 `props.suspended` 停 rAF(`IslandApp.vue:240-246`)。新增动画(遮罩、气泡、强提醒)同样要遵守:页面隐藏时不得继续跑 rAF;恢复可见时按当前状态**直接落位**(不要补播)。
7. **减少动态效果(prefers-reduced-motion)不覆盖岛**:ClassHelper 明确「应用内动画开关决定岛动画,不能用主窗口的减少动态效果说明替代岛自身规则」(`docs/app/island.md:57`;`docs/dev/motion.md:78`)。岛动画的**唯一开关**是 `appearance.animations`,`appearance.speed` 统一缩放时长;实现者**不得**让系统 reduced-motion 强制关闭岛动画或改写其时长,只在设置页给出提示文案。若未来用户明确要求「尊重系统设置」,只能作为 P2 可选项(设置项 `respectReducedMotion`,默认 `false`),默认行为保持不变。
8. **帧预算**:岛渲染进程 60fps;任意单帧主线程 ≤ 8ms;动画只作用于 `transform`/`opacity`/`clip-path`/`box-shadow`(颜色类)与 SVG `points`(遮罩),**禁止**逐帧改 `width/height/top/left` 触发重排(弹簧形变改的是 SVG path 与元素尺寸,属于既有例外,仅 winisland 使用)。
9. **兜底定时器**:任何依赖 rAF 的动画必须有「到点直接落位」的兜底,因为 Windows 会把置顶透明小窗判定为被遮挡而把 rAF 压到 1fps(`IslandApp.vue:266-286` 的 `MORPH_FALLBACK_MS=1200`)。classisland 的 CSS transition 天然有终点,但遮罩(SVG 分块)与任何 JS 驱动的动画仍需兜底(现有 600/speed,`LessonReminderContent.vue:82-89`)。
10. **动画结束清场**:动画结束必须:移除临时 `will-change`、取消 rAF/定时器、移除 `closing` 等临时类;不得留下持续运行的循环(`12-acceptance.md:73`)。
### 10.1 ClassHelper 现状动画清单(重构前基线,必须保留语义)
| 编号 | 现状动画 | 触发 | 当前实现 | 关键数值/证据 |
|---|---|---|---|---|
| S1 | classic 胶囊↔展开卡形变 | 消息展开/收起 | `SpringValue` 逐帧逼近 width/height/radius | 目标尺寸见 `IslandApp.vue:188-204`;圆角胶囊 `min(radius,h/2)`、展开 `min(48×radius/20,w/2,h/2)`(`:207-212`);弹簧参数 `spring.ts:26-30` |
| S2 | 内容交叉淡入 | 形变过程中 | 胶囊层 `1-1.5p`、展开层 `p²` | `IslandApp.vue:346-351`;progress 由高度差推导(`:304-309`) |
| S3 | 收回动画 | 点「知道了/标为已读」 | 形态先切回胶囊,360ms 后淡出隐藏 | `CLOSE_ANIMATION_MS = 360`(`main/island.ts:114-121`) |
| S4 | 窗口淡入/淡出 | 显示/隐藏 | 主进程逐帧改 `setOpacity` | 淡入每 16ms +0.16;淡出每 16ms −0.2,≤0.02 后 `hide()`(`island.ts:1674-1715`,`island.ts:1686,1705`) |
| S5 | custom 形变 | 展开/收起/课程条切换 | width/height/radius + content/lesson 进度弹簧 | `CustomIslandRuntime.vue:88-177`;`snapThreshold:0.001`(`:92-93`);兜底 `1200/speed`(`:156`) |
| S6 | custom 页面交叉淡入 | 常态↔展开↔课程 | `compactAlpha=1-1.5p`、`detailsAlpha=p²`、`lessonProgress` | `CustomIslandRuntime.vue:99-100`;三页 `opacity/visibility` 绑定(`:385-511`) |
| S7 | custom 测量适配 | 内容/字体/视口变化 | `ResizeObserver` + `nextTick` 量 `offsetWidth/offsetHeight` + `fitted()` | `CustomIslandRuntime.vue:72-80,238-266` |
| S8 | 课程提醒遮罩 | 准备/上课/下课/下课前 | SVG 5 个多边形按进度重绘 | 区段 360ms、开错峰 `[0,90,120,90,0]`、关 `[120,90,0,90,120]`、总 480ms、开 `1-(1-p)^4`、关 `p²`(`LessonReminderContent.vue:59-102`) |
| S9 | 课程提醒标题 | 遮罩出现/消失 | CSS animation | 入 250ms 延迟 260ms `(0.25,1,0.5,1)`;缩放 750ms 延迟 260ms;出 200ms linear 反向(`:134-137,273-280`) |
| S10 | 课程提醒进度条 | 每秒更新 | `transform: scaleX` transition | 50ms linear(`LessonReminderContent.vue:307-316`,高度 2px) |
| S11 | 组件行课程进度 | 每秒更新 | `transform: scaleX` transition | 1s linear(`ComponentNode.vue:348-362`,高度 2px) |
| S12 | 滚动组件 | 文字超宽 | CSS keyframes | `rolling` 30%→−80%,时长 `max(1,200/speed)` 秒,linear infinite;编辑时停(`ComponentNode.vue:205,384-392,446-453`) |
| S13 | 轮播组件 | 定时切换 | 计算当前索引,瞬时替换 | `interval` 秒(`ComponentNode.vue:121-130`;`island-layout.ts:196` 默认 5s) |
| S14 | 课程徽标 | 下课前 | 静态圆形 | 30×30、`#0a84ff`、12px/600、tabular-nums(`lesson-ending.css:3-16`) |
| S15 | 徽标入场 | 铃铛计数出现 | CSS animation | `resident-indicator-in` 200/speed ms fade(`custom-runtime.css:147-149,199-206`) |
| S16 | 课件覆盖 | 课件开始/结束 | 瞬时显示/隐藏,无动画 | `CoursewareTransfer.vue`(无 transition) |
| S17 | 悬停/按压 | 指针悬停、按下 | CSS transition | 按钮/列表行 140ms ease-out(`classic-island.css:393,480,613-616,699-702`);按下 `scale(.97)`(`:724-727`);仅 hover 设备(`:736-750`) |
| S18 | 编辑预览 | 打开编辑器 | 静态排版,无动画 | `island-editor.css:193-227`(预览行 44px 高) |
| S19 | 外观变化 | 设置页改宽高/字号/配色 | 重写 CSS 变量,瞬时生效 | `IslandApp.vue:175-176,516-528`;`CustomIslandRuntime.vue:102-124` |
| S20 | 失焦宽限 | 展开后失焦 | 宽限期内不收起(非动画但影响观感) | 鼠标 500ms、触摸 1200ms(`island.ts:2287,2297`) |
### 10.2 映射总表(目标规格;每条给出双风格默认值)
说明:`CS` = `family='classisland'`(默认),`WI` = `family='winisland'`(兼容)。「实现」列 `CSS-T` 表示 CSS transition、`CSS-A` 表示 CSS keyframes/WAAPI、`SVG-rAF` 表示 SVG/Canvas 按进度重绘、`Spring` 表示 `SpringValue` 帧积分、`主进程` 表示 Electron 主进程逐帧/定时。
#### A. 岛出现 / 隐藏 / 开合
classisland fixed 下「岛」不再是胶囊,而是一条信息行:出现/隐藏必须轻、快、不抢注意力,避免每来一条消息就在屏幕顶部弹一个大卡片。开合类动画只允许改变行背景层与不透明度,内容层文字不得跟着宽度动画重排(否则会出现「文字先铺开再被省略号收回」的抖动,ClassHelper 的 `classic-island.css:196-206` 已记录同类问题)。winisland 分支的胶囊开合仍按原有弹簧语义执行,不得为了「统一」把弹簧搬到 classisland。
**fixed 行高恒 40px(硬约束)**:气泡的遮罩、正文、进度条全部在 40px 行内完成;正文最多 **2 行**、超出用省略号(`display:-webkit-box; -webkit-line-clamp:2; overflow:hidden`),**不得**出现「行高随内容增高」「行高 200ms 过渡」「为气泡扩窗」这类 ClassHelper 旧展开卡行为。`maxCardHeight` 只参与 custom 的多条消息列表与强提醒窗口尺寸计算,单条气泡不读它。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| A1 | CS fixed 岛出现 | 首次显示/新消息首次弹出 | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 250ms | `cubic-bezier(.25,1,.5,1)` | 0 | CSS-T | `animations=false` 直接落位 | 无(新) |
| A2 | CS fixed 岛隐藏 | 消息清空且无课程 | `opacity` | 1 → 0 | 150ms | `cubic-bezier(.16,1,.3,1)` | 0 | CSS-T 后主进程 `hide()`(隐藏帧) | 无动画直接 hide | 对应 S4 |
| A3 | CS 行宽度变化 | 内容/字号/组件变化 | `width`(行背景层) | 当前 → 目标 | 300ms(Fluent)/550ms(Classic) | `(.65,0,.35,1)` / `(0,1,.5,1.1)` | 0 | CSS-T(背景层绝对定位,文字层瞬时) | 直接落位 | 对应 S19 |
| A4 | CS 单条气泡布局(行高固定) | 单条消息出现/内容变化 | 行高(不变)、正文行数 | **恒 40px**;正文 0→2 行(超出省略),内容在行内完成遮罩/进度 | 0(不改行高) | — | — | 固定 `--ci-row-h`;`-webkit-line-clamp:2`;不扩窗 | 同(直接布局) | 对应 S3/S8;**不得**沿用旧展开卡增高行为 |
| A5 | CS 分体行出现 | 开启 `separated` | 行间距、圆角 | 0 → 5px、0 → 8px | 200ms | `(.16,1,.3,1)` | 每行 0 | CSS-T | 直接落位 | 无(新) |
| A6 | WI 胶囊↔展开 | 展开/收起 | width/height/radius | 胶囊 `w×h×r(h/2)` ↔ 展开 `w+156 × h+186 × min(48r/20)` | 由弹簧决定(约 300–500ms) | 弹簧(近临界阻尼,无过冲) | 0 | Spring | `animations=false` → `snap()` | S1 |
| A7 | WI 内容交叉淡入 | 同 A6 | 两层 `opacity` | 胶囊 `1−1.5p`;展开 `p²` | 跟随 p | 线性映射 | 0 | JS 逐帧绑定 | 直接显示终态 | S2 |
| A8 | WI 收回并淡出 | 点知道了/标为已读 | 形态→胶囊,再窗口淡出 | 展开 → 胶囊;opacity→0 | 360ms 形态 + 淡出(−0.2/16ms) | 弹簧 + 线性 | 360ms 后淡出 | Spring + 主进程 | 立即 hide | S3/S4 |
| A9 | WI 窗口淡入 | 显示 | 窗口 `opacity` | 当前 → `displayOpacity` | 每 16ms +0.16(约 60–110ms) | 线性 | 0 | 主进程 | 立即设置 | S4 |
| A10 | WI 窗口淡出 | 隐藏 | 窗口 `opacity` | 当前 → 0 | 每 16ms −0.2(约 50–80ms) | 线性 | ≤0.02 后 `hide()` | 主进程 | 立即 hide | S4 |
| A11 | 上课时段隐藏 | 进入上课且无紧急/叫人 | 窗口可见性 | 可见 → 隐藏 | 0(立即) | — | — | 主进程 `hideImmediately()` | 同 | `island.ts:1463-1473` |
#### B. 行内布局与组件
classisland 信息行的内部布局必须「稳」:数据每秒变化时只换文本、不动布局;只有组件增删、方案切换、拖拽排序时才允许位移类动画。行内所有组件共用 --ci-fs-* 与 --ci-row-h,任何组件的默认尺寸都不得把行撑高(超高时内部滚动或省略,见第 7 部分)。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| B1 | CS 组件淡入 | 组件首次挂载/由隐藏转可见 | `opacity` | 0 → 1 | 300ms | `(.65,0,.35,1)` | 0 | CSS-T | 直接显示 | 对应 ClassIsland 组件淡入(`repos/ClassIsland/.../ComponentPresenter.axaml.cs:224-243`) |
| B2 | CS 首次多行错峰入场 | 岛首次显示且 ≥2 行 | `opacity`、`translateY` | 0 / 6px → 1 / 0 | 320ms | `(.16,1,.3,1)` | 每行 30ms,封顶 180ms | CSS-A(按行 index 计算 delay) | 跳过 | 对应 ClassIsland 列表错峰(25ms/项,`StackPanelIntroAnimationBehavior.cs:74-90`);本处取 30ms 更克制 |
| B3 | CS 组件重排过渡 | 编辑器中拖动排序 | `transform` | 旧位置 → 新位置 | 225ms | `CubicEaseInOut`≈`(.65,0,.35,1)`【推断】 | 0 | FLIP(先读后写) | 直接跳位 | 对应 `WrapPanelResizingAnimationAssist.cs:45-49` |
| B4 | 数据文本刷新 | 时钟/日期/倒计时每秒 | 文本内容 | — | 0 | — | — | 直接替换(`tabular-nums`) | 同 | 无动画是**有意**的(ClassIsland 也不做数字翻转) |
| B5 | CS 消息气泡宽度 | 单条气泡换行/字数变化 | 宽度 | 当前 → 内容宽 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接落位 | 无(新) |
#### C. 进度条
进度条是岛内唯一「高频更新」的动画元素:它必须用 transform: scaleX() 而非宽度,且每次更新只触发合成层。ClassIsland 的倒计时进度用 50ms 线性过渡(repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:137-145),classisland 风格沿用;winisland 的课程进度保持 1s 线性(每秒一 tick),两者视觉一致但实现成本不同。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| C1 | 提醒倒计时进度 | 剩余时间更新 | `transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 0 | CSS-T | 直接落位 | 对应 ClassIsland `FluentTheme/Styles.axaml:137-145` |
| C2 | CS 行课程进度 | 每秒更新 | `scaleX` | 旧 → 新 | 50ms(每 50ms 更新一次时长)或 1s linear(每秒更新) | 线性 | 0 | CSS-T | 直接落位 | 现状 1s linear(S11),ClassIsland 50ms;**默认选 50ms**,若性能紧张可切 1s(见 §10.3 选择点) |
| C3 | WI 课程进度 | 每秒更新 | `scaleX` | 旧 → 新 | 1s | 线性 | 0 | CSS-T | 直接落位 | S11 保留 |
| C4 | 课件进度 | 传输事件 | 原生 `<progress>` | 旧 → 新 | 150ms | 线性 | 0 | CSS-T(`progress` 值即时,条宽过渡) | 直接落位 | S16 |
| C5 | 进度条高度 | 组件挂载 | `height` | 2px → 3px(CS) | 0(静态) | — | — | 静态 token `--ci-progress-h` | 同 | ClassIsland 3px;现状 2px |
#### D. 通知气泡两阶段 / 斜切遮罩(classisland 默认核心)
这是本轮重构最核心的动效。ClassIsland 的提醒是「先遮罩、后内容」的两阶段:斜切分块从两侧向中间扫入,文字延迟 260ms 再淡入并轻微缩放,最后背景投影浮现;退出时反向收拢并放大退出。ClassHelper 的 LessonReminderContent.vue 已经实现过一版(edges [0,.1,.3,.7,.9,1] 等价于权重 [0,.1,.2,.4,.2,.1]),本轮必须把它抽成公共组件并让消息气泡复用,不得再写第二份。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| D1 | 遮罩扫入(开) | 气泡/课程提醒出现 | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | QuarticEaseOut(`1−(1−p)^4`) | 区段错峰 `[0,90,120,90,0]`,总 480ms | SVG-rAF 或 CSS `clip-path` | 直接设 1 | S8(现有实现一致) |
| D2 | 遮罩收拢(关) | 气泡/提醒消失 | 5 个区段 | 1 → 0 | 每段 360ms | QuadraticEaseIn(`p²`) | 错峰 `[120,90,0,90,120]`,总 480ms | SVG-rAF | 直接设 0 | S8 |
| D3 | 遮罩形状 | 任意宽度 | 5 段权重、斜切角 | 权重 `[0,.1,.2,.4,.2,.1]`;偏移 `tan(30°)×40` | — | — | — | SVG `polygon` / `clip-path` | 同 | S8(现有 edges `[0,.1,.3,.7,.9,1]` 等价) |
| D4 | 遮罩文字淡入 | 遮罩打开 | `opacity` | 0 → 1 | 250ms | `(.25,1,.5,1)` | 延迟 260ms | CSS-A `both` | 直接显示 | S9 |
| D5 | 遮罩文字缩放 | 遮罩打开 | `scale` | 1.1 → 1.0 | 750ms | `(.25,1,.5,1)` | 延迟 260ms | CSS-A | 直接落位 | S9 |
| D6 | 遮罩文字淡出 | 遮罩关闭 | `opacity`、`scale` | 1/1.0 → 0/1.1 | 200ms | 线性(透明度)/`(.5,0,.75,0)`(缩放) | 0 | CSS-A | 直接隐藏 | S9 |
| D7 | 遮罩投影淡入 | 遮罩打开 | `box-shadow` | 无 → `0 8px 32px rgba(0,0,0,.4)` | 100ms | 线性 | 延迟 600ms | CSS-T | 直接到位 | ClassIsland `FluentTheme:355-367` |
| D8 | 遮罩底色淡入 | 遮罩打开 | 背景 overlay `opacity` | 0 → 1 | 100ms | 线性 | 延迟 600ms | CSS-A | 直接到位 | `FluentTheme:355-367` |
| D9 | 覆盖层淡入 | 遮罩打开后正文/详情出现 | `opacity` | 0 → 1 | 200ms | SineEaseIn≈`(.12,0,.39,0)`【推断】 | 0 | CSS-T | 直接显示 | ClassIsland `FluentTheme:419-451` |
| D10 | 覆盖层淡出 | 正文结束/关闭 | `opacity` | 1 → 0 | 200ms | SineEaseIn | 0 | CSS-T | 直接隐藏 | 同上 |
| D11 | Classic 遮罩(主题=classic) | 同 D1/D2 | `translateY` | −50 → 0(入)/0 → 50(出) | 200ms | QuadraticEaseIn/Out | 出延迟 100ms | CSS-A | 直接落位 | ClassIsland `ClassicTheme/Styles.axaml:265-331` |
| D12 | Classic 文字淡入/淡出 | 同上 | `opacity` | 0→1(100ms 延迟 200ms)/1→0(100ms) | 100ms | 线性 | 见说明 | CSS-A | 直接落位 | 同上 |
| D13 | 多行气泡错峰 | 列表 ≥2 条 | `opacity`、`translateY` | 0 / 6px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 每行 30ms,封顶 120ms | CSS-A | 直接显示 | 无(新增,参考 ClassHelper 主窗口列表错峰) |
| D14 | 气泡内进度条 | 倒计时更新 | `scaleX` | 旧 → 新 | 50ms | 线性 | 0 | CSS-T | 直接落位 | 对应 C1 |
| D15 | 气泡图标/徽标入场 | 气泡出现 | `scale`、`opacity` | .9/0 → 1/1 | 150ms | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | 无(新增) |
| D16 | 关闭后进入下一批 | 队列非空 | 遮罩重播 | 同 D1/D2 | 同 | 同 | 先 D2 再 D1(交叉 100ms) | SVG-rAF | 直接切 | ClassHelper 队列现为「直接换内容」,需补过渡 |
| D17 | 关闭并淡出 | 队列清空 | 遮罩 + 窗口 | 遮罩收拢 + `opacity→0` | 480 + 150ms | 同 | 遮罩结束后淡出 | SVG-rAF + CSS-T | 直接 hide | 对应 S3(WI 用 360ms) |
#### E. 强提醒特效(新增)
强提醒是新增的独立透明窗口(不抢焦点、不阻塞、超时自关),其动画只有「涟漪扩散」一条,其余都是窗口显隐。特效必须在岛所在显示器上铺到足够大的区域,尺寸按 ClassIsland 规则随屏高缩放,且编辑/预览模式下绝不创建。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| E1 | 特效窗口出现 | 强调提醒 | 窗口可见性 | 隐藏 → 显示 | 0 | — | 0 | 主进程 `showInactive()` | 同 | ClassIsland `TopmostEffectWindow` |
| E2 | 涟漪扩散 | 特效开始 | `scale` | 0 → 1 | 600ms | QuadraticEaseIn | 0 | WAAPI(独立窗口) | 直接显示静态 | ClassIsland `RippleEffect.axaml.cs:92-96` |
| E3 | 涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | SineEaseIn | 0 | WAAPI | 直接移除 | `RippleEffect.axaml.cs:102-106` |
| E4 | 特效收尾 | 750ms 后 | 移除元素/隐藏窗口 | — | — | — | 750ms 总时长 | 主进程 `hide()` | 立即隐藏 | `RippleEffect.axaml.cs:108-109` |
| E5 | 特效渲染缩放 | 屏高 ≥1400/≥2000 | 窗口尺寸 | 1× → .75× /.5× | 0 | — | — | 主进程按屏算 | 固定 1× | `repos/ClassIsland/.../MainWindow.axaml.cs:377-392` |
#### F. 课程徽标 / 课程提醒
课程提醒的视觉规格已经在 LessonReminderBar 中实现过半(倒计时/课程名/时间/三段信息/遮罩/进度条)。本轮需要补齐的是「入场/退场」「徽标出现」「徽标颜色随剩余时间变化」三处,并确保徽标数字 -1 的边界行为(剩余 1 秒显示 -1,不显示「即将下课」遮罩,docs/app/island.md:68-70)。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 课程提醒条入场 | 提醒开始(CS fixed) | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接显示 | 无入场动画(新增) |
| F2 | 课程提醒条退场 | 提醒结束 | `opacity` | 1 → 0 | 150ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接隐藏 | 无(新增) |
| F3 | 课程遮罩 | 提醒开始(mask 阶段) | 同 D1/D2 | 同 | 360/120 | 同 | 同 | 复用遮罩组件 | 直接落位 | S8 |
| F4 | 下课前徽标出现 | `ending` 且开启倒数 | `scale`、`opacity` | .8/0 → 1/1 | 150ms | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | S14(静态) |
| F5 | 徽标数字更新 | 每秒 | 文本 | `-N` → `-(N-1)`,最后 `-1` | 0 | — | — | 直接替换(tabular-nums) | 同 | `docs/app/island.md:68-70` |
| F6 | 徽标颜色 | 剩余 ≤10s | 颜色 | `--ci-accent` → `--ci-state-urgent`【决策】 | 150ms | 线性 | 0 | CSS-T | 直接换色 | 无(新增,可选) |
| F7 | 课程进度条 | 每秒 | `scaleX` | 旧 → 新 | 50ms(CS)/1s(WI) | 线性 | 0 | CSS-T | 直接落位 | S10/S11 |
| F8 | 课程条接管岛(custom) | 课程提醒出现 | 页面交叉 | 常态 → 课程条 | CS 200ms / WI 弹簧 | `(.16,1,.3,1)` / 弹簧 | 0 | CSS-T / Spring | 直接切 | S5/S6 |
#### G. 组件滚动 / 轮播
滚动与轮播是 custom 方案里唯一的「持续动画」。它们必须遵守两条纪律:① 页面隐藏/窗口不可见时暂停;② 编辑模式下暂停并展开全部子组件,便于选择与排序。classisland 风格的轮播切换按 ClassIsland 的 250ms 上下滑动,winisland 保留瞬时切换。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| G1 | 滚动组件(默认连续) | 内容超宽 | `translateX` | `30%` → `−80%` | `max(1,200/speed)` 秒 | 线性无限循环 | 0 | CSS-A | 静止并省略号 | S12 保留 |
| G2 | 滚动组件(ClassIsland 暂停式,可选) | 设置 `mode='pause'` | `translateX` | 0 → `−(innerW)` 停顿 → 回 0 | `innerW/40 + pause(默认10s) + outerW/40` 秒 | 线性无限 | 无 | WAAPI 关键帧 | 回落 G1 | 对应 ClassIsland `RollingComponent.axaml.cs:85-135`(速度 40px/s、暂停 10s) |
| G3 | 轮播切换(CS) | 定时 | `translateY`、`opacity` | 旧:−40→0 / 0→1;新:0→40 / 1→0 | 250ms | `(.65,0,.35,1)` | 0 | CSS-A | 瞬时替换 | ClassIsland `SlideComponent.axaml:69-107`;现状 S13 无动画 |
| G4 | 轮播切换(WI) | 定时 | 瞬时替换 | — | 0 | — | — | 直接换 | 同 | S13 保留 |
| G5 | 编辑模式下滚动/轮播 | 进入编辑器 | 暂停/展开全部 | 动画 → 停止 | 0 | — | — | 移除 animation / 显示全部子节点 | 同 | `ComponentNode.vue:388-392` |
| G6 | 隐藏页暂停 | `document.hidden` / 窗口不可见 | 暂停所有组件动画 | 运行 → 暂停 | 0 | — | — | `motion` 计算 / CSS `animation-play-state` | 同 | `CustomIslandRuntime.vue:57-58` |
#### H. 主题 / 风格切换
风格切换(classisland↔winisland)是「整岛换一套几何与实现」,必须避免「半新半旧」的中间帧:切换时先做 200ms 交叉淡化,再在同一帧替换 data-family 与变量,最后淡入。主题切换(Fluent↔Classic)与深浅色只改颜色 token,几何与动画参数不动——这是 ClassHelper 与 ClassIsland 共同的纪律(docs/dev/motion.md:125-130)。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| H1 | family 切换 | 设置页切换 classisland↔winisland | 整岛 `opacity` + 变量 | 1→0.6→1(快速交叉) | 200ms 出 + 200ms 入 | `(.16,1,.3,1)` | 0 | CSS-T(切 `data-family` 前后加过渡类) | 直接切 | S19(现为瞬时) |
| H2 | theme 切换(Fluent↔Classic) | 设置页切换 | 背景/描边/阴影颜色 | 旧 → 新 | 200ms | 线性 | 0 | CSS-T(仅颜色 token) | 直接切 | 对应 ClassIsland 窗口 `BrushTransition 200ms`(`MainWindow.axaml:40-44`) |
| H3 | 深浅色切换 | 应用主题变化 | 颜色 token | 旧 → 新 | 200ms | 线性 | 0 | CSS-T | 直接切 | 同 H2 |
| H4 | 强调色变化 | 设置页改 accent | `--island-accent` 派生色 | 旧 → 新 | 150ms | 线性 | 0 | CSS-T(颜色) | 直接切 | S19 |
#### I. 消息列表 / 徽标 / 交互
交互类动画是「手感」而非「表演」:悬停 140ms、按压 90ms、徽标 200ms,全部短于 250ms,且只作用于可交互元素。所有 hover 效果必须包在 @media (hover:hover) and (pointer:fine) 内,避免触摸屏幽灵 hover(classic-island.css:729-750)。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| I1 | 铃铛徽标入场 | 未读数 0→N | `opacity` | 0 → 1 | 200ms/speed | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | S15 保留 |
| I2 | 列表行悬停 | 指针移入(仅 hover 设备) | `background` | 旧 → `--ci-fill-2` | 140ms | `(.16,1,.3,1)` | 0 | CSS-T | 无 hover | S17 |
| I3 | 按钮按压 | 按下/松开 | `scale` | 1 → .97 → 1 | 90ms 按下 / 抬起 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接 | S17 |
| I4 | 图标按钮悬停 | 指针移入 | 背景/前景 | 旧 → 新 | 140ms | `(.16,1,.3,1)` | 0 | CSS-T | 无 | S17 |
| I5 | 选中/焦点环 | Tab 键聚焦 | `outline` | 无 → 2px accent | 0 | — | — | CSS 静态 | 同 | 无(新增 a11y) |
| I6 | 多条消息列表高度变化 | 展开更多/新消息(**仅 custom 多条列表**,非单条气泡) | 列表卡片高度 | 旧 → 新 | CS 200ms / WI 弹簧 | `(.16,1,.3,1)` / 弹簧 | 0 | CSS-T/Spring + 主进程先扩窗 | 直接落位 | 现有 `islandListLayout`(度量);单条气泡不适用,行高恒 40 |
#### J. 编辑预览 / 拖拽
编辑器在主窗口里运行,动画要克制:进入/退出编辑模式 200ms 交叉淡化即可;拖拽必须跟手(0ms 跟随),只对「落位排序」做 150ms FLIP。预览区不创建额外桌面窗口(01-objective.md:35、docs/app/island.md:47)。
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| J1 | 进入/退出编辑模式 | 点「进入编辑模式」 | 整页 `opacity` | 0 ↔ 1 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接切 | 无(新增) |
| J2 | 拖拽幽灵 | 拖动组件 | 跟随指针 | — | 0 | — | — | 直接定位(`island-editor.css:331-334`) | 同 | S18 |
| J3 | 拖拽排序 | 放下组件 | `transform` | 旧位 → 新位 | 150ms | `(.16,1,.3,1)` | 0 | FLIP | 直接跳 | 无(新增) |
| J4 | 放置高亮 | 拖到行上 | `outline`/背景 | 无 → 2px accent 虚线 | 0/100ms | 线性 | 0 | CSS 静态 | 同 | `island-editor.css:228-232` |
| J5 | 预览缩放滑杆 | 拖动 | `--resident-scale` | 连续 | 0(实时跟随) | — | — | 直接赋值 | 同 | `island-editor.css:193-217` |
| J6 | 保存状态 | 保存中/失败 | 文本颜色 | 灰 ↔ 红 | 0 | — | — | 直接切 | 同 | `island-editor.css:27-34` |
#### K. 课件覆盖
| 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 |
|---|---|---|---|---|---|---|---|---|---|---|
| K1 | 课件卡入场 | 传输开始 | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接显示 | S16(无动画) |
| K2 | 课件进度 | 进度事件 | 进度条宽度 | 旧 → 新 | 150ms | 线性 | 0 | CSS-T | 直接跳 | S16 |
| K3 | 课件卡退场 | 完成/失败后 3s | `opacity` | 1 → 0 | 150ms | `(.16,1,.3,1)` | 3s 停留 | CSS-T 后主进程恢复岛 | 直接隐藏 | S16 |
#### L. 暂停 / 降级 / 兜底
| 编号 | 规则 | 触发 | 行为 | 证据 |
|---|---|---|---|---|
| L1 | 动画总开关 | `animations=false` | 弹簧 `snap`、CSS 过渡 0ms、遮罩设终值、rAF 不启动 | `IslandApp.vue:249-253`、`CustomIslandRuntime.vue:148-151`、`LessonReminderContent.vue:77-80` |
| L2 | 速度倍率 | `speed≠1` | 所有时长 `/speed`;弹簧 `dt×speed`;兜底定时器 `/speed` | `IslandApp.vue:291,528`、`CustomIslandRuntime.vue:156,220-222`、`LessonReminderContent.vue:134-137` |
| L3 | 页面隐藏 | `document.hidden` | 暂停弹簧/rAF;恢复时按当前状态落位 | `CustomIslandRuntime.vue:57-58,269-273` |
| L4 | 窗口不可见 | 主进程 `hide()` | 渲染层收到 `nativeVisible=false`,暂停 | `CustomIslandRuntime.vue:58`;preload `island:visibility`(`main/island.ts:528-533`) |
| L5 | 系统减少动态效果 | `prefers-reduced-motion` | **不覆盖岛**;岛动画唯一开关是 `appearance.animations`,`speed` 统一缩放;仅编辑器/设置 UI 遵循系统设置 | `docs/app/island.md:57`、`docs/dev/motion.md:78` |
| L5b | 尊重系统设置(P2,可选) | 用户显式开启 `respectReducedMotion` | 系统 reduced-motion 时按 `animations=false` 落位;**默认关闭** | 本行为可选实现,默认不得启用 |
| L6 | rAF 被限流 | Windows 判定遮挡 | 兜底定时器到点直接落位(形变 1200/speed、遮罩 600/speed、命中 600/speed) | `IslandApp.vue:236,266-286`、`LessonReminderContent.vue:82-89`、`CustomIslandRuntime.vue:220-222` |
| L7 | 触摸窗口缩放 | 触摸模式形态变化 | 变大立即、变小等隐藏后收(不做动画) | `main/island.ts:1555-1578` |
| L8 | 失焦宽限 | 展开后 blur | 500ms(鼠标)/1200ms(触摸)内不收起,避免焦点抖动导致误收起 | `main/island.ts:2287,2297,557-578` |
### 10.2.1 消息气泡完整时间线(按毫秒对齐,验收时逐点核对)
以一条普通通知在 `family='classisland'`、`speed=1`、`animations=true`、`theme='fluent'` 下为例,完整时间线必须严格如下(同一条消息的遮罩、文字、投影、进度条、窗口状态都按此对齐):
| 时刻 | 事件 | 实现要点 |
|---|---|---|
| t=0 | 主进程把消息压入队列并把 `mode` 切为 `expanded`;窗口使用预设包围盒(宽 = min(工作区宽,960)、高 = 40+2×pad),**不因气泡扩窗** | 窗口 `setBounds` 不得在可见时发生(`main/island.ts:1476-1479`) |
| t=0 | 岛出现(若原本隐藏):`opacity 0→1 / translateY 8→0`,250ms | A1;同时开始窗口淡入(winisland 为 +0.16/16ms) |
| t=0 | 5 个斜切区段开始扫入:区 0/4 立即、区 1/3 延迟 90ms、区 2 延迟 120ms,每段 360ms QuarticEaseOut | D1;总 480ms |
| t=0 | 气泡图标/徽标入场 150ms(scale .9→1) | D15 |
| t=260 | 遮罩文字开始淡入 250ms + 缩放 1.1→1.0 750ms | D4/D5;两个 CSS animation 必须同时挂、各自 `both` |
| t=480 | 遮罩完全覆盖行(5 区到位) | D1 结束 |
| t=600 | 背景投影/遮罩底色开始淡入 100ms | D7/D8 |
| t=700 | 投影/底色到位;如为「有正文」的提醒,覆盖层正文开始 200ms 淡入 | D9 |
| 正文超长时 | **行高恒 40px**;正文最多 2 行、超出省略号 | 不扩窗、不改行高;`maxCardHeight` 不参与单条气泡 |
| 展示期 | 倒计时进度每 50ms 更新一次、每次 50ms 线性过渡 | D14 |
| 收尾 | 用户点「知道了/标为已读」或超时:覆盖层 200ms 淡出 → 遮罩反向收拢(错峰 `[120,90,0,90,120]`,每段 360ms)→ 窗口 150ms 淡出并隐藏 | D2/D10/D17 |
| 队列非空 | 上一批收拢到 50% 时启动下一批扫入,交叉 100ms,不得出现空白帧 | D16 |
对应 winisland 路径:`t=0` 时改为胶囊→展开卡弹簧形变(目标宽高由 `ISLAND_SIZE_DELTA` 决定),文字交叉淡入按 `1−1.5p` / `p²`,点「知道了」后 360ms 收回再淡出(A6–A8)。两条路径共享同一消息状态机与队列规则,只有「表现层」不同。
### 10.3 冲突与选择点(必须按默认执行,并保留开关)
| 编号 | 冲突点 | ClassHelper 现状 | ClassIsland 目标 | **默认选择** | 开关/回退 |
|---|---|---|---|---|---|
| X1 | fixed 形态 | 268×44 胶囊 + 详情卡 | 40px 信息行 + 行内气泡 | **classisland:信息行**;winisland:胶囊 | `appearance.family` |
| X2 | 开合实现 | 弹簧形变 | CSS transition(行宽/行高) | classisland 用 CSS;winisland 用弹簧 | `family` |
| X3 | 单条气泡的尺寸 | 卡片长高(最大 ~220) | 40px 行内气泡,正文 2 行省略 | **行高恒 40、不增高、不扩窗**;`maxCardHeight` 只用于 custom 多条列表与强提醒窗口 | `family`;无「增高」回退项(WinIsland 原展开卡保留在 winisland fixed) |
| X4 | 遮罩形态 | 无(直接换卡片) | 斜切 5 区分块 + 文字缩放 | **两风格都实现遮罩**(winisland 的详情卡也用遮罩增强观感,但保留卡片尺寸动画) | `family` 决定是否叠加卡片尺寸动画 |
| X5 | 滚动组件 | 连续 30%→−80% 跑马灯 | 40px/s + 10s 暂停 + 回程 | **默认连续**(ClassHelper 观感),设置项可切 `mode='pause'` | 组件设置 `settings.speed` 已有;新增 `settings.mode`【推断字段名】 |
| X6 | 轮播切换 | 瞬时 | 250ms 上下滑 + 淡入淡出 | **classisland 250ms**;winisland 瞬时 | `family` |
| X7 | 课程进度条 | 1s linear | 50ms linear | **classisland 50ms;winisland 1s** | `family` |
| X8 | 进度条高度 | 2px | 3px | **classisland 3px;winisland 2px** | `family` |
| X9 | 系统减少动态效果 | 岛不受其影响 | 同 ClassHelper(岛自身开关) | **不覆盖岛**:岛动画唯一开关 `appearance.animations`,`speed` 缩放时长;如用户显式要求,可提供 P2 选项 `respectReducedMotion`(默认 false) | `animations` / P2 选项 |
| X10 | 消息列表错峰 | 无 | ClassIsland 无(列表瞬时) | **新增 30ms 错峰但封顶 120ms**,可在 `animations=false` 时关闭 | `animations` |
| X11 | 主题切换 | 瞬时 | 200ms 颜色过渡 | **200ms**(classisland);winisland 瞬时 | `family` |
### 10.4 性能预算与降级矩阵
| 场景 | 目标 | 降级顺序 | 说明 |
|---|---|---|---|
| 弹簧形变(winisland) | 60fps、单帧 ≤8ms | ① 降步进到 30fps ② 只动尺寸不动圆角 ③ `snap()` | 必须在岛渲染进程 rAF,独立于主进程 |
| CSS 行宽过渡(classisland;行高恒 40 不参与过渡) | 60fps | ① 去掉阴影过渡 ② 直接落位 | 只过渡背景层,文字层瞬时;避免触发整行重排 |
| 斜切遮罩(SVG 5 区) | 60fps、单帧 ≤4ms | ① 降为 3 区 ② 降到 30fps ③ 静态遮罩 | 每帧重算 `points`,避免重绘整棵 SVG |
| 消息气泡(40px 行内遮罩 + 2 行正文) | ≥50fps | ① 关闭错峰 ② 关闭文字缩放 ③ 直接切 | 与 11 部分性能基线一致(`parts-r/11-compat-tests.md:46`);气泡不改变行高 |
| 强提醒特效 | ≥50fps | ① 关闭涟漪改静态色块 ② 缩小渲染缩放 ③ 不显示 | 特效窗口独立进程/独立窗口,动画结束即关 |
| 多条列表高度变化(custom) | 60fps | ① 关闭过渡直接跳 ② 限制可见行数 | 窗口先扩再形变,避免裁切;单条气泡不适用 |
| 主窗口隐藏 | 0 动画 | — | 岛仍可见时不得因主窗口隐藏而停;只有岛窗口隐藏/页面隐藏才暂停 |
### 10.5 实现方式规范(逐条遵守)
1. **classisland 优先 CSS**:几何用 `transition`(`width/height/border-radius/opacity/box-shadow/background-color`),分块遮罩用 JS 进度 + SVG `polygon`(现有实现 `LessonReminderContent.vue:59-102` 可直接复用/抽组件),文字用 `@keyframes`(`var(--ci-*)` 时长/曲线)。
2. **winisland 保留弹簧**:`SpringValue` 参数与兜底不得改(`spring.ts:26-114`);`syncTargets` 的 `snap` 分支必须保留。
3. **遮罩复用**:把 `LessonReminderContent.vue` 里的 5 区遮罩抽成 `IslandMask.vue`(props:`open`、`duration`、`stagger`、`fill`、`motion`、`speed`),消息气泡与课程提醒共用;禁止复制第二份实现。
4. **进度条统一样式**:抽 `.ci-progress` 类(高度 `var(--ci-progress-h)`、轨道 `var(--ci-progress-track)`、填充 `var(--ci-progress-fill)`、圆角 `var(--ci-progress-radius)`),用 `transform: scaleX()` 驱动;**不要**用 `width`。
5. **FLIP 只用于重排**:编辑器拖拽排序、组件位置变化用 FLIP(先批量读、再写 transform、下一帧过渡),不得对每帧读布局。
6. **强制落位**:所有 `transition` 在 `data-family='classisland'` 下用 CSS;`animations=false` 时通过根元素 `data-anim='0'` 把 `transition-duration/animation-duration` 覆盖为 `0ms`(参照 `--island-anim` 的做法,`IslandApp.vue:527`),并让 JS 跳过 rAF。
7. **不要动画窗口 bounds**:仅 M0 新建窗口、模式切换、显示器/边距变化时 `setBounds`,且节流 ≥100ms(`parts-r/01-objective.md:81`)。
8. **可取消**:每个动画入口先取消同目标旧动画(`cancelAnimationFrame` + 清 timer + 重置变量),再启动新动画;状态机以「目标值」而不是「播放进度」为准。
9. **无障碍**:`aria-live="polite"` 保留在课程/消息条(`LessonReminderContent.vue:140`);动画不改变语义顺序;`prefers-reduced-motion` 下编辑器 UI 动画压到 1ms(主窗口规则,`index.css:909-917`)。
10. **测试钩子**:在 dev 构建暴露 `window.__islandMotion = { list(), state(), setSpeed(x), disable() }`,供无头验证读取(`--visual` 才做画面验证)。
### 10.6 逐文件改动清单
| 文件 | 动作 |
|---|---|
| `packages/desktop-client/src/island/spring.ts` | **KEEP**;仅允许为 classisland 增加 `snapTo()` 便捷方法(可选),不得改默认参数 |
| `packages/desktop-client/src/island/squircle.ts` | **KEEP(仅 winisland/custom 使用)**;classisland 不调用 |
| `src/island/IslandApp.vue` | **大改**:family 分支;classisland fixed 改为信息行渲染;保留消息状态机与 winisland 胶囊路径;接 `animations/speed` 与暂停 |
| `src/island/CustomIslandRuntime.vue` | **改**:classisland 下行宽用 CSS 过渡、行高恒 40(不随内容增高);保留测量与 winisland 弹簧分支;`motion` 规则扩展 |
| `src/island/ClassicNotifications.vue` | **改**:气泡两阶段(斜切遮罩 + 文字 + 缩放);多行错峰;保留消息类型/排序/回执 |
| `src/island/LessonReminderContent.vue` | **改**:遮罩抽成共用组件;进度 2→3px;标题/缩小时长走 token;变量改名 `--ci-lesson-*`(保留 alias) |
| `src/island/ComponentNode.vue` | **改**:滚动/轮播动画按 family;课程进度 50ms/1s;`--secondary/--emphasized/--large` 改用 `--ci-fs-*` |
| `src/island/CoursewareTransfer.vue` | **改**:K1–K3 入场/退场/进度过渡;其余 KEEP |
| `src/island/classic-island.css` | **改**:classisland 分支 token 化;winisland 分支保持;悬停/按压过渡统一 token |
| `src/island/custom-island.css` / `custom-runtime.css` | **改**:行高/间距/进度条/圆角 token 化;classisland 背景/描边/阴影;预览行 44→40 |
| `src/island/lesson-ending.css` | **改**:徽标颜色 token 化;新增出现动画类(可选) |
| `src/island/tokens.css` | **新增**:`--ci-*` + `--ci-dur-*`/`--ci-ease-*` |
| `src/main/island.ts` | **改**:下发 `family/theme/colorScheme`;classisland 包围盒计算;关闭/淡出时序不变;特效窗口控制 |
| `src/renderer/styles/island-editor.css` | **改**:J1/J3/J4 动画;预览 40px;token 化 |
| `docs/dev/island-refactor.md` | **新增**:动画表、降级矩阵、测试钩子说明 |
### 10.7 验收标准
1. **逐条覆盖**:本节 A1–A11、B1–B5、C1–C5、D1–D17、E1–E5、F1–F8、G1–G6、H1–H4、I1–I6、J1–J6、K1–K3、L1–L8 全部实现;抽测 12 条(覆盖两种 family),时长误差 ≤16ms、曲线一致。
2. **双风格默认值**:`family='classisland'` 下无弹簧形变、无 squircle;行宽过渡 300/550ms;气泡遮罩 360+120、文字 260/250、缩放 750、退出 200;`family='winisland'` 下胶囊开合仍为弹簧、收缩 360ms 后淡出、窗口淡入 +0.16/16ms、淡出 −0.2/16ms。
3. **总开关**:`animations=false` 时所有动画在 1 帧内完成,不出现「终态先行」;rAF/定时器数量归零(DevTools Performance 验证)。
4. **速度**:`speed=2` 时所有时长减半、弹簧更快;`speed=0.5` 时加倍;不得出现某条动画没乘 speed 的不一致。
5. **暂停**:主窗口最小化/岛窗口 `hide()`/`document.hidden` 时无 rAF、无 CSS 动画推进;恢复可见后直接呈现当前状态(不补播)。
6. **可打断**:快速连续展开/收起、连续推送 5 条消息、消息中途标记已读 → 无闪回、无残留、无「停在半路」;队列切换时旧遮罩被取消。
7. **视觉一致**:遮罩 5 区权重与 60° 斜切与 ClassIsland 一致;文字先于底色结束(260ms 延迟)不出现「白字压白底」;Classic 主题下遮罩整块下压/上滑(200ms)。
8. **进度条**:classisland 3px、50ms 线性;winisland 2px、1s 线性;进度条用 `transform` 而非 `width`(Performance 面板无 layout)。
9. **无窗口抖动**:开合/气泡/列表过程中窗口 `setBounds` 调用次数为 0(隐藏帧除外);触摸模式下窗口只增不减。
10. **性能**:主界面过渡 ≥55fps、气泡/强提醒 ≥50fps;单帧主线程 ≤8ms;动画结束无残留 `will-change`/rAF/timer(`parts-r/11-compat-tests.md:46`、`12-acceptance.md:73`)。
11. **无头验证**:`verify:desktop` 默认不创建可见岛即可断言动画状态机与时长(通过测试钩子读取);只有 `--visual` 才做逐帧/截图验收。
12. **文档**:`docs/dev/island-refactor.md` 的动画表与本节一致;`docs/app/island.md` 补充 family 与动画开关说明。
13. **fixed 气泡不增高**:推送超长正文消息,行高恒 40px、正文最多 2 行省略、窗口 bounds 不变;全过程无 `height`/`max-height` 过渡,`maxCardHeight` 未被单条气泡读取。
14. **reduce-motion 口径**:系统开启 `prefers-reduced-motion` 后,岛动画仍按 `appearance.animations`/`speed` 播放(默认不覆盖);只有 P2 选项 `respectReducedMotion=true` 时才按 `animations=false` 落位,且该选项默认关闭。
---
## 11. 兼容性、性能、平台与测试基线
> 本部分规定「重构不能把原来能用的东西弄坏」,以及如何在不打扰用户的前提下验证。所有验证默认必须无头/离屏执行。
### 11.1 兼容矩阵(必须逐项通过)
| 维度 | 必须保持兼容的内容 | 验证方式 |
|---|---|---|
| 配置 | 旧 `config.json.islandLayout`(`fixed`/`custom`、`residentEnabled`、`schemes`/`rows`/`nodes`/`expandedRows`/`appearance`/`lessonReminders`/`pointerFade*`)无损升级 | 迁移单测 + fixtures 回放 |
| 布局模式 | `fixed` 与 `custom` 均可切换、可保存、可重启恢复;两布局共用一个原生岛窗口,切布局不新开窗口 | 无头集成测试 + 主进程单测 |
| 风格 | `family` 只影响 fixed:`winisland` 保持旧胶囊观感,`classisland` 为默认信息行;`custom` 始终 ClassIsland 规格;切换即时生效、可回退 | 截图(仅 `--visual`)+ 视觉回归 |
| 消息 | 通知/作业/叫人的类型、优先级、id 去重、排序、上课暂存、紧急展开、叫人可点胶囊、已读回执、安静模式、教师/班级端差异不变 | 状态机单测 + 无头消息场景 |
| 课程 | 课表解析结果(`ScheduleDto`/`IslandTimePointDto`)、准备/上课/课间/下课前/课中进度提醒、下课前秒数徽标、当前/下一节/今日时间点显示;**行为变更**:custom 不再临时接管岛显示课程提醒(仅保留 ending 徽标,进度由课程组件显示),fixed 按 ClassIsland 接管 | 时间状态机单测 + 假时钟无头场景 |
| 天气 | Open-Meteo 数据、15 分钟刷新、断网缓存标记"缓存"、无官方预警提示 | 无头 mock 网络 + 快照 |
| 课件 | 课件传输状态优先覆盖岛内容、SHA256 校验、回执、不自动运行文件 | 无头课件场景 |
| 编辑器 | 方案/行/组件/导入导出/预览/重置/新建/复制/删除、12 行/200 节点/6 层/20 方案边界 | 单测 + 组件测试 |
| 窗口/输入 | 透明、置顶、不占任务栏、点击穿透、触摸模式、失焦宽限、固定几何护栏、单实例 | 无头窗口参数断言 + 手工触摸说明 |
| 平台 | Windows 10/11(主目标)、macOS、Linux X11;DPI 100/125/150/200;多显示器与热插拔 | 平台矩阵 + 无头几何断言 |
| 性能 | 启动、空闲 CPU、内存、消息风暴、动画帧率 | 性能脚本 + 采样 |
### 11.2 配置迁移与回滚(硬要求)
1. **版本化**:`IslandLayoutSettings.version` 从 `1`(旧配置缺失视为 1)升级到 `2`;迁移函数必须**幂等**(重复执行结果一致)。
2. **迁移顺序**:读取原始 JSON → 备份到 `config.json.bak.<时间戳>`(或配置目录 `backups/`)→ 应用迁移(只补齐/转换,不删除用户数据)→ `normalizeIslandLayouts()` 校验与夹取 → 原子写(临时文件 + rename)。
3. **必须迁移的字段**:
- 顶层新增 `version`、`appearance.family`(老用户默认 `'winisland'`,新安装 `'classisland'`)、`appearance.theme`(`'fluent'`/`'classic'`)、`strongNotification` 开关与参数、`progressBar` 参数、`rowStyle`(行背景/分隔/圆角覆盖);
- scheme 新增 `rowHeight`(默认 40)、`componentGap`(默认 12,对应单侧 6)、`showProgress`(默认 true)、`hideRules` 扩展(保留旧 `in-class`/`no-message`/`no-class`,新增 `full-screen`/`max-window` 需按 12 部分验收);
- 组件新增字段(如 schedule 的 `display` 扩展、countdown 的 `showProgress`、clock 的 `showSeconds`),旧值必须原样保留。
4. **回滚**:迁移失败或新版启动检测到配置不可用时,必须能恢复备份并启动旧结构(主进程启动早期读 `config.json` 的 `islandLayout` 失败时使用默认值 + 保留原文件为 `.corrupt`),不得清空用户方案。
5. **降级兼容**:若旧版本客户端读到新配置,必须忽略未知字段并使用默认值;禁止因未知字段崩溃。版本号高于当前可识别值时,主进程写日志并走只读模式(不自动覆盖新配置)。
6. **fixtures**:`packages/shared` 测试中必须包含:fixed 默认、custom 多方案/多行/嵌套容器/旧 residentEnabled、极端值(超 12 行/200 节点/6 层/颜色非法/坐标为 null)、旧字段缺失、迁移幂等、迁移失败回滚。
### 11.3 IPC 与契约兼容
- 保留现有通道:`island:get-schedules`、`island:get-time-points`、`island:get-layout`、`island:save-layout`、`island:edit-layout`、`island:get-editing`、`island:get-visible`、`island:resident-control`、`island:resident-touch`、`island:set-editing`、`island:content-size`、`island:remove-notification`、`island:push`、`island:class-state`、`island:alive`、`island:get-state`、`island:set-appearance`、`island:get-appearance`、`island:set-interactive`、`island:set-hit-rect`、`island:set-touch-mode`、`island:action`。
- 新增通道必须遵守:`ipcMain.handle/on` 前校验 `event.sender === island.getWindow()?.webContents`(或主窗口 webContents),payload 必须是纯数据且做类型/范围校验;preload 白名单同步更新 `src/preload/island.ts` 与 `src/types/desktop.d.ts`。
- 新增通道建议(如需要):`island:strong-notification`(主进程→岛/强提醒窗口)、`island:get-metrics`(渲染层上报行/组件度量)、`island:preview-mode`(编辑器预览)。不得把 Vue Proxy/函数/类实例跨 contextBridge 传递。
- 渲染进程心跳、幽灵窗口检测、渲染崩溃自愈(限流 3 次/10 分钟)与恢复逻辑保持不变。
### 11.4 性能预算(超预算必须优化)
- 岛渲染进程启动 ≤ 500ms(从窗口创建到首帧可见);冷启动到岛可见 ≤ 3s(不含网络)。
- 空闲 CPU 平均 ≤ 1%(岛可见、时钟/课程正常刷新);消息到达/展开瞬间峰值 ≤ 15% 且 ≤ 500ms。
- 主进程 + 岛渲染进程内存 ≤ 250MB(不含可选强提醒窗口);连续运行 24h 增长 ≤ 10%。
- 消息风暴:1 分钟 20 条不丢、不重复、不卡顿;队列长度上限与丢弃策略必须有日志。
- 动画:主界面过渡 ≥ 55fps;消息气泡/强提醒 ≥ 50fps;不得因背景页(主窗口隐藏)继续动画。
- 包体:本次重构不得新增 > 200KB 的运行时依赖;不得引入新 UI 库/图标库(图标仍用现有方案)。
### 11.5 平台差异与窗口护栏
- Windows:透明/置顶/点击穿透/`setShape`(若用)只在 Windows 分支;Windows 10 不支持 Mica 时退回纯色;高 DPI 下窗口 bounds 用 DIP。
- macOS:透明窗口、置顶、全屏空间行为;不使用 `setBackgroundMaterial`;点击穿透退化为整窗 `setIgnoreMouseEvents(true,{forward:true})` + 命中通道。
- Linux X11:透明窗口与置顶可用;Wayland 明确不支持并给提示(与 ClassHelper 既有说明一致)。
- 多显示器:`position`/`followCursorDisplay`/停靠偏移语义保持不变;显示器热插拔/DPI 变化必须重算包围盒与锚点,节流 ≥100ms。
- 触摸:触摸模式仍以"窗口贴合岛体 + 始终接收输入"为原则;编辑模式与触摸手势不能互相抢占;失焦宽限(鼠标 500ms / 触摸 1200ms)保持不变。
- 强提醒窗口:无边框、置顶、不抢焦点、`skipTaskbar`、独立渲染入口或复用岛窗口的独立层;关闭后释放;不得在编辑器/预览中创建。
### 11.6 测试基线(无头优先)
1. **单元测试(shared)**:`normalizeIslandLayouts` 边界、迁移幂等、版本识别、颜色/数值夹取、组件树限制(12 行/200 节点/6 层/20 方案)、消息排序/去重、课程状态机(准备/上课/课间/下课前/放学/跨天/跨周)。
2. **主进程测试**:窗口几何计算(6 停靠 × DPI × 多屏)、hit rect/触摸模式切换、消息队列与暂存、课程提醒调度、天气缓存、课件覆盖优先级、配置写入原子性与回滚。
3. **组件测试**:固定数据下 `IslandApp.vue`/`CustomIslandRuntime.vue` 的渲染快照(DOM 结构 + 关键 computed 样式),不创建真实窗口(可用 happy-dom/离屏渲染)。
4. **集成(无头)**:`pnpm verify:desktop` 默认后台快速检查——不得创建可见岛/托盘;覆盖启动、布局切换、消息推送、课程提醒、编辑器保存/读取、迁移。
5. **视觉(显式)**:只有 `pnpm verify:desktop:visual` / `--visual` 才允许真实桌面、动画采样、截图;截图输出目录用临时路径;不得把窗口对照图发布为展示图。
6. **数据纪律**:测试使用临时 SQLite/夹具,禁止使用真实班级、真实密码、真实通知;远控/安装器使用假执行器。
7. **证据**:每次汇报附实际命令输出摘要(通过/失败数量),不得沿用历史数字。
### 11.7 观测与诊断
- 主进程日志必须记录:配置迁移(旧→新字段、备份路径、结果)、窗口几何重算原因、消息队列变化(入队/出队/丢弃/去重)、课程状态切换、天气刷新/失败、强提醒创建/销毁、渲染崩溃/重建(限流计数)。
- 岛渲染进程必须上报心跳与度量(行/组件尺寸、可见性);主进程对"幽灵窗口"(渲染卡死但窗口在)执行重建(限流)。
- 诊断页/日志中不得包含用户密码、令牌、完整数据库路径;日志轮转与大小上限沿用现有 logger。
- 提供 `docs/dev/island-refactor.md` 的排障章节:黑屏/透明失效/点击穿透/闪烁/动画卡顿/消息不弹/课程不刷新/迁移失败的症状 → 排查步骤 → 回滚方法。
### 11.8 文档与交付说明
- 更新 `docs/app/island.md`:新默认观感、风格切换、editor 编辑模式、强提醒、行为表(含 fixed 重定义)。
- 更新 `docs/dev/implementation.md`:岛的新架构入口、schema v2、迁移函数位置。
- 新增 `docs/dev/island-refactor.md`:架构图、文件职责、数据契约、动画表、迁移与回滚、测试命令。
- `docs/` 中的展示截图必须来自显式视觉验收且人工确认;不得使用重构中的对照图/失败截图。
- 交付说明必须包含:与 ClassIsland 的差异清单(技术栈/能力取舍)、未实现项及原因、性能数据、回滚步骤。
### 11.9 本部分验收标准
- [ ] fixtures 覆盖 11.2 全部场景,迁移全部通过且幂等。
- [ ] 旧配置在重构版启动后方案、组件、外观、课程提醒开关全部保留,且可切回 `winisland` 观感。
- [ ] 所有既有 IPC 通道仍可用,新增通道有 sender 校验与类型校验。
- [ ] `pnpm verify:desktop` 无参数运行时**不创建可见岛/托盘/声音**;`--visual` 才创建。
- [ ] 多显示器/DPI 切换后岛位置与尺寸正确,无 1px 抖动。
- [ ] 24h 无内存增长 > 10%、无崩溃、无幽灵窗口。
- [ ] 文档更新完成,含迁移/回滚/差异说明。
---
## 12. 验收清单(逐项勾选,不得跳过)
> 所有自动验证默认无头/离屏;`--visual` 才允许真实桌面。任一项未通过都不得声称完成。
### 12.1 工程门禁
- [ ] `pnpm lint` 0 error(warning 需列出)。
- [ ] `pnpm typecheck` 0 error(先构建 shared)。
- [ ] `pnpm build:desktop` 成功;`island.html` 与主窗口产物分别可加载。
- [ ] 未新增 UI 框架/状态库/图标库;新增依赖(若有)≤ 200KB 且说明不可替代性。
- [ ] 只改 `packages/shared`、`packages/desktop-client`、`packages/ui`(如需)与 docs;服务端/Web 管理端零改动。
- [ ] 所有新增 IPC 通道有 sender 校验、payload 类型校验;preload 白名单与 `desktop.d.ts` 同步。
- [ ] 无 `console.log` 残留;日志走统一 logger。
### 12.2 视觉验收(像素级)
在 1920×1080@100%、2560×1440@125%、深色/浅色、`family=classisland`/`winisland`、Fluent/Classic 主题下截取离屏基准图,逐项比对(颜色 ±2/255、尺寸/位置 ±1px、透明度 ±1%):
**fixed(classisland 风格)**
- [ ] fixed 窗口包围盒固定:宽 = min(工作区宽, 960)、高 = 40 + 2×pad(pad 默认 20);消息到达/展开/多条列表均不改变窗口尺寸。
- [ ] 可见行高 40px,行内容左右内边距 12px,最小内容宽 20px;行间距 5px。
- [ ] 行背景:圆角 8px、背景不透明度 0.5、1px 描边、阴影 `0 4 8 2 #48000000`(Fluent)。
- [ ] 字号:次要 14 / 主体 16 / 强调 18 / 超大 20;字重 Medium;正文 `line-height: 1.25`。
- [ ] 进度条:高 3px、贴底、圆角 1.5px、颜色为强调色、按设置精度更新。
- [ ] 默认 1 行 = 日期 + 课程表;无课表时显示占位文本("今天没有课程。"/"今日课程已全部结束。")。
- [ ] 课程表显示当前/下一节/今日时间点模式,当前项展开显示起止时间,其余折叠;课间与"默认隐藏"时间点折叠时隐藏,分割线显示竖线,行动不显示。
- [ ] 下课前秒数徽标:蓝色圆形、显示在课程名右侧、剩余 1 秒显示 `-1`、不显示"即将下课"遮罩、不替换常态组件。
- [ ] 消息气泡:遮罩 5 区权重 `[0,0.1,0.2,0.4,0.2,0.1]`、60° 斜切、区段 360ms + 错峰 120ms、开 QuarticEaseOut / 关 QuadraticEaseIn;文字延迟 260/250ms、scale 1.1→1.0/750ms;退出 200ms `(0.5,0,0.75,0)`;行高 40。
- [ ] 强提醒:全屏/大区域遮罩、超时关闭、不抢焦点、在岛所在显示器出现;关闭后无残影。
- [ ] `winisland` 风格:胶囊 268×44(默认值可配置 220–420 × 36–72)、连续圆角(超椭圆 4.2/14 段)、弹簧形变、纯黑/毛玻璃/主题色渐变、阴影常驻;不得与 classisland 风格混用。
**custom(始终 ClassIsland 规格,family 不影响 custom)**
- [ ] 行高 40、行间距 5、组件外边距 6/6、行背景圆角 8、背景不透明度 0.5。
- [ ] 行结构:`Auto, *, Auto` 三列(左拖动把手 20px / 中组件 / 右行操作区);选中框 1.5px 虚线 `1,1.5`、Opacity 0.75、圆角同背景。
- [ ] 多行、分体岛(separated)、主行标记、允许提醒标记、隐藏规则、组件外观覆盖(字号/前景/背景/圆角/透明度/宽度/对齐)逐项与设置一致。
- [ ] 编辑器中的组件库、行、容器嵌套、选中态、抽屉尺寸与间距符合第 9 部分。
- [ ] 所有组件的默认样式/空数据兜底/超长文本截断/滚动行为符合第 7 部分。
**窗口与层级**
- [ ] 岛窗口无边框、透明、置顶、默认不占任务栏、默认不抢焦点;`winisland` 与 `classisland` 风格均不出现白底/黑边/窗口阴影残影。
- [ ] 6 个停靠位 × 偏移(X/Y)× 多显示器索引正确;`followCursorDisplay` 行为保持。
- [ ] 编辑模式窗口/画布尺寸正确:屏宽 ×(屏高−1px)、四边 60px 内缩、开启命中。
- [ ] 深浅色/主题切换无残留样式;岛动画由 `appearance.animations` 开关控制(系统 `prefers-reduced-motion` 不强制关闭岛动画,遵循 ClassHelper 既有文档约定)。
### 12.3 功能验收
**消息与提醒**
- [ ] 通知/作业/叫人三类消息的图标、标题、正文、优先级、时间显示符合第 8 部分。
- [ ] id 去重;同批消息按"优先级 → 时间"排序;列表"展开更多"与"超出工作区提示"正确。
- [ ] 上课期间普通通知/作业暂存、下课补弹;普通叫人保留可点胶囊;紧急通知/紧急叫人立即展开;与 ClassHelper 现有规则逐条一致。
- [ ] 已读/知道了/标为已读/批处理的语义与通知中心状态一致;本机新录作业指纹去重。
- [ ] 自动收起超时:胶囊 15s、展开 20s、紧急 45s、叫人 90s(按现有常量),可打断、可重入。
- [ ] 强提醒仅对开启"强调提醒"的消息出现;关闭后不出现;超时自动关闭;不阻塞岛与其他应用。
- [ ] 消息风暴 20 条/分钟:不丢、不重复、不卡顿、队列上限有日志。
**课程与天气**
- [ ] 准备/上课/课间/下课前倒数/课中进度五类提醒按设置触发,阈值默认 60s 可调。
- [ ] 课程状态机跨天、跨周、临时课表、预定课表、调课场景正确(与 ClassHelper 现有解析结果一致)。
- [ ] 课程提醒接管:fixed(classisland)按 ClassIsland 行内容接管显示 prepare/on-class/break;custom 不再临时接管整岛(移除 `resident-lesson-page`),进度由课程组件显示,ending 只保留课程名右侧 30×30 蓝色圆形秒数徽标。
- [ ] 天气:Open-Meteo 15 分钟刷新、断网缓存并显示"缓存"、失败重试与降级、无官方气象预警措辞。
- [ ] 课件传输:传输中覆盖岛内容,显示文件名/类型/教师/字节/进度,完成后校验并回执;不自动运行文件。
**编辑器与设置**
- [ ] 设置页:经典/自定义切换、风格切换、外观参数、课程提醒开关、指针避让、动画开关、强提醒开关全部即时保存并生效。
- [ ] 编辑器:方案栏(新建/复制/删除/重命名/导入导出/重置)、组件库 11 类、行(新增/排序/主行/允许提醒/删除)、容器嵌套、拖拽(行内/跨行/库→行/库→容器)、选中与组件设置(内容/外观/尺寸/隐藏条件)、编辑模式预览、预览缩放、退出保存/失败重试。
- [ ] 边界:12 行/200 节点/6 层/20 方案;超限拒绝并提示;非法颜色/数值被夹取;未知组件/未知 Kind 不崩溃。
- [ ] 旧方案导入后可直接渲染(迁移测试)。
### 12.4 动画验收
- [ ] 第 10 部分动画表逐项实现;时长误差 ≤ 16ms,曲线一致(DevTools Animations 或逐帧截图验证)。
- [ ] 岛出现/隐藏、行展开/折叠、行宽/高度、进度条、消息气泡两阶段、强提醒、课程徽标、滚动/轮播、主题切换全部流畅(≥55fps)。
- [ ] 不可打断的动画可被新状态覆盖(不排队、不闪回)。
- [ ] `animations=false` 生效(唯一岛动画开关);`speed` 统一缩放时长;失焦/隐藏页暂停;恢复后不补播。
- [ ] 动画结束无残留 `will-change`、定时器、rAF。
### 12.5 兼容与迁移验收
- [ ] 旧 `fixed`/`custom` 配置 fixtures 升级后:方案、行、组件、外观、课程提醒、指针避让、`residentEnabled` 全部保留;迁移幂等。
- [ ] 升级失败可回滚(备份恢复);不可用时保留原文件为 `.corrupt` 并启动默认配置,不得清空。
- [ ] 新配置被旧客户端读取时忽略未知字段、不崩溃(如能构造旧客户端则测;否则单测模拟)。
- [ ] `winisland` 风格下旧观感/旧交互无回归(对照基线图)。
- [ ] 切换 fixed↔custom、风格切换、方案切换均不新开窗口、不丢配置。
### 12.6 无头与不打扰验收
- [ ] `pnpm verify:desktop`(不带 `--visual`)全程不创建可见岛、托盘气泡、系统通知、声音;测试结束无残留进程/窗口。
- [ ] 使用独立 userData;不触碰真实配置/真实班级/真实通知。
- [ ] `--visual` 仅在显式允许时运行,且截图输出到临时目录;不自动发布。
- [ ] 崩溃自愈限流有效:1 分钟内最多重建 3 次,超过进入安全模式/停止重建。
- [ ] 日志无密码/令牌/敏感路径。
### 12.7 性能验收
- [ ] 岛渲染进程启动 ≤ 500ms;冷启动到岛可见 ≤ 3s。
- [ ] 空闲 CPU ≤ 1%;消息风暴峰值 ≤ 15% 且 ≤ 500ms。
- [ ] 主进程 + 岛渲染进程内存 ≤ 250MB;24h 增长 ≤ 10%;无句柄泄漏。
- [ ] 包体增量 ≤ 200KB(除说明的例外);无新增大图标/字体文件。
### 12.8 文档与交付物
- [ ] `docs/app/island.md`、`docs/dev/implementation.md` 更新;新增 `docs/dev/island-refactor.md`。
- [ ] 架构图、数据契约、动画表、迁移与回滚、测试命令齐全。
- [ ] 交付说明:与 ClassIsland 差异、未实现项、性能数据、回滚步骤。
- [ ] 第 12 部分本清单逐项勾选并附验证输出摘要;未验证项标注原因。
### 12.9 禁止事项(验收时再次确认)
- [ ] 未通过修改验收标准/删除测试来"通过"。
- [ ] 未因重构删除既有功能(消息、课程、天气、课件、编辑、导入导出、指针避让、触摸)。
- [ ] 未引入需要联网/登录的新依赖。
- [ ] 未把 ClassIsland 的 C#/XAML/本地档案代码复制进 ClassHelper。
---
## 13. 重构里程碑(M0–M7)
> 必须按顺序推进;每个里程碑结束都要「可无头运行 + 通过该阶段验收 + 既有功能不回归」。禁止先大改 UI 再补架构与测试。
### M0 基线固化与安全网(0.5–1 天)
- 交付:
- 读通现状并输出文件级计划(第 4 部分)与迁移计划(第 5 部分)。
- 为现有行为建立无头基线:`normalizeIslandLayouts` 边界用例、消息排序/去重、课程状态机、窗口几何计算、旧配置 fixtures(fixed/custom/嵌套/极端值)。
- 记录性能基线:启动耗时、空闲 CPU、内存、消息风暴耗时。
- 在 `verify:desktop` 中补充"不创建可见窗口/托盘"的断言。
- 验证:`pnpm typecheck`、`pnpm verify:desktop`(无头)通过;fixtures 全部可回放。
- 完成标准:没有行为改动;所有基线用例通过;性能数字记录在 `docs/dev/island-refactor.md` 草案。
### M1 共享层 v2:schema、常量、迁移、tokens(1–2 天)
- 交付:
- `packages/shared/src/island-layout.ts`:`version=2`、`appearance.family`、`appearance.theme`、`strongNotification`、`progressBar`、`rowStyle`、scheme 的 `rowHeight/componentGap/showProgress`、组件新字段;`normalizeIslandLayouts()` 迁移+校验(12 行/200 节点/6 层/20 方案、颜色/数值夹取)。
- 常量/token:ClassIsland 行高 40、内边距 12、行距 5、圆角 8、透明度 0.5、字号 14/16/18/20、进度条 3px 等;`winisland` 兼容值保留。
- 迁移函数(幂等)+ 备份/回滚;单元测试覆盖全部 fixtures。
- 验证:单测(迁移、夹取、边界、幂等);`typecheck`。
- 完成标准:旧配置升级后数据无损;新 schema 能被现有代码读取(旧渲染仍可运行);迁移失败可回滚。
### M2 渲染引擎:行/组件/tokens(2–3 天)
- 交付:
- 统一行/组件渲染模型:fixed 与 custom 共用行结构、背景、进度条、选中框、隐藏规则、淡出、圆角/阴影。
- `IslandApp.vue` 重构为 ClassIsland 信息行(默认日期+课程表),`CustomIslandRuntime.vue` 行/组件按新规格;`ComponentNode.vue` 11 类组件默认样式/空数据/布局约束。
- `family` 双轨渲染仅作用于 fixed(classisland 信息行 / winisland 旧胶囊);custom 统一 ClassIsland 规格;默认按用户升级策略选择;设置页可切换。
- DOM/CSS token 结构(第 6/7 部分)落地;旧 `winisland` 视觉保持。
- 验证:组件快照测试(关键 computed 样式/DOM 结构);无头启动渲染;`--visual` 像素基准(1920×1080、深/浅色、两风格)。
- 完成标准:两风格可切换且互不污染;行/组件像素值达标;旧观感无回归。
### M3 消息、课程提醒、强提醒、天气、课件(2–3 天)
- 交付:
- 气泡两阶段(遮罩斜切 360/120ms、文字 260/250ms、scale 1.1→1.0/750ms、退出 200ms)与队列/去重/暂存/紧急/叫人规则。
- 课程提醒五类 + 下课前蓝色秒数徽标 + 课程进度条。
- 强提醒(Topmost)独立透明窗口:全屏/大区域、超时关闭、不抢焦点、多显示器、仅在强调提醒开启时出现;编辑/预览不创建。
- 天气显示/缓存标记;课件覆盖优先与进度。
- 验证:消息/课程单测(假时钟、假队列);无头推送场景;`--visual` 气泡/强提醒动画采样。
- 完成标准:ClassHelper 既有消息/课程规则逐条不变;视觉/动画达标;强提醒不影响其他应用。
### M4 设置页与编辑器重构(2–3 天)
- 交付:
- `IslandSettingsView.vue`:经典/自定义切换、风格切换、外观参数(字号阶梯/行距/进度条/强提醒)、课程提醒、指针避让、动画开关;即时保存、校验、回显。
- `IslandEditorView.vue`:方案栏、组件库、行/容器、拖拽、组件设置抽屉、编辑模式预览、预览缩放、退出保存/失败重试;与 ClassIsland 编辑模式对齐(选中框、行设置、添加行、组件边距)。
- `IslandComponentSettings.vue`:字段与第 7 部分一一对应;未知字段保留。
- 验证:组件测试 + 无头保存/读取 + 迁移后旧方案编辑;`--visual` 编辑器截图。
- 完成标准:所有设置即时生效;编辑器可用、保存可靠、退出不丢;12 行/200 节点/6 层/20 方案边界提示。
### M5 动画、主题、细节打磨(1–2 天)
- 交付:
- 第 10 部分动画表逐项;60fps;可打断;`animations=false`/减少动画/失焦/隐藏页暂停。
- Fluent/Classic 两套岛主题;`winisland` 玻璃/纯黑/渐变保持;主题切换无闪烁。
- 交互细节:点击区域、悬停、触摸、键盘、拖拽阈值、选中/取消、右键(如适用)。
- 验证:DevTools/帧采样;逐项动画检查;像素 diff;无头回归。
- 完成标准:动画误差 ≤16ms、帧率达标;主题/风格切换无残留;减少动画生效。
### M6 迁移、兼容与回归(1–2 天)
- 交付:
- 迁移 fixtures 全量回放(fixed/custom/旧 residentEnabled/嵌套容器/极端值/非法数据);回滚演练。
- `winisland` 观感对照基线;旧交互逐条回归(消息/课程/天气/课件/编辑器/触摸/多屏)。
- 旧版本读新配置的兼容处理;日志/诊断/文档更新。
- 验证:迁移单测 + 回滚测试 + `pnpm verify:desktop` + `--visual` 对照。
- 完成标准:旧配置 100% 无损;回滚可用;旧观感/旧交互无回归。
### M7 全量验收与交付(1–2 天)
- 交付:
- 第 12 部分逐项勾选;性能数据(启动/CPU/内存/帧率/消息风暴);差异说明;回滚说明。
- `pnpm lint`、`pnpm typecheck`、`pnpm build:desktop`、`pnpm verify:desktop`(无头)、`--visual` 视觉验收记录。
- `docs/app/island.md`、`docs/dev/implementation.md`、`docs/dev/island-refactor.md` 完成。
- 验证:按第 12 部分执行;未通过项必须列出原因与修复计划。
- 完成标准:第 12 部分全通过(或明确标注例外并获得确认);无可见窗口打扰的默认验证可重复通过。
### 里程碑风险提示
- M1 迁移必须先于任何渲染改动,否则旧配置会在开发中损坏。
- M2/M3 必须保留 feature flag(`family` 与内部开关),确保随时可回退到旧渲染。
- M4 编辑器依赖 M1 的 schema 与 M2 的渲染组件;不得并行乱改。
- M5/M6 的像素/动画验收需要稳定的基准图与固定测试数据,禁止用真实班级数据。