feat: ClassIsland / ClassHelper 灵动岛提示词套件

- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
This commit is contained in:
ClassIsland Prompt Kit committed 2026-11-12 21:59:58 +08:00
commit 22c1784bab
117 files changed
+47759

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# 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 部分,复述你对现状、目标与 8 条裁决的理解;然后给出文件级重构计划(含迁移与回滚),等我确认后再按 M0–M7 逐阶段实现。」
3. **证据约定**:`packages/...:行号` 指 ClassHelper 仓库相对路径;`repos/ClassIsland/...:行号` 是 ClassIsland 基准来源(相对本工作区),只用于理解行为,不是运行时依赖。
4. **验收纪律**:任何「已完成」的声明都必须附带无头验证输出与第 12 部分的逐项勾选结果;未验证项必须标注。
<!-- TOC -->
## 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 部分给兼容、验收、里程碑。
+95
View File
@@ -0,0 +1,95 @@
## 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 部分是兼容、验收与里程碑,按顺序执行。
+220
View File
@@ -0,0 +1,220 @@
## 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/...:行号`;没有"凭印象"的数值。
@@ -0,0 +1,314 @@
## 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;三种形态切换不丢消息、不重建第二个岛窗口。
+328
View File
@@ -0,0 +1,328 @@
## 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 描述与实现一致。
+387
View File
@@ -0,0 +1,387 @@
## 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`。
+428
View File
@@ -0,0 +1,428 @@
## 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` 落位。
@@ -0,0 +1,793 @@
## 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。
- 窗口几何(固定包围盒,开合时不动):
- 宽 = 行内容自然宽 + 2 × 12(行内边距)+ 2 × `ISLAND_SHADOW_PAD`(=10,`packages/shared/src/constants.ts:351`);
- **高恒为 40 + 2 × `ISLAND_SHADOW_PAD`**(无消息 / 有消息都一样;气泡在行内,不改变窗口高度);
- 只在"行宽跨档"、显示器/DPI 变化或"窗口不可见"时 `setBounds`(沿用 `applyWindowLayout` 的比较逻辑,`main/island.ts:1598-1612`)。
- 行宽/内容宽必须由渲染层测量后上报:`reportSize({ width, height })`(`CustomIslandRuntime.vue:255`)+ `setHitRect`(`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×`ISLAND_SHADOW_PAD`。
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`。
@@ -0,0 +1,473 @@
## 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、自动化、插件、主题市场等非目标功能;也未出现第二个岛窗口(课件与特效窗口除外,且均为独立用途)。
+385
View File
@@ -0,0 +1,385 @@
## 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 的状态/空态/主题/响应式行为逐一验证。
+311
View File
@@ -0,0 +1,311 @@
## 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` 落位,且该选项默认关闭。
+91
View File
@@ -0,0 +1,91 @@
## 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%、无崩溃、无幽灵窗口。
- [ ] 文档更新完成,含迁移/回滚/差异说明。
+112
View File
@@ -0,0 +1,112 @@
## 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。
+76
View File
@@ -0,0 +1,76 @@
## 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 的像素/动画验收需要稳定的基准图与固定测试数据,禁止用真实班级数据。
+67
View File
@@ -0,0 +1,67 @@
# ClassIsland 复刻工程:给 ChatGPT 的完整实现提示词(ClassHelper 技术栈版)
> **文档性质**:这是一份「提示词」而非分析报告。请把本文件(或按章节分卷)直接投喂给 ChatGPT,要求它按第 17 部分的里程碑从零实现一个 ClassIsland 等价应用;实现技术栈采用 ClassHelper 桌面端(Electron 44 + Vue 3.5 + Element Plus + Pinia + Vite 8 + TypeScript 5.9 + pnpm monorepo),以等价替换 ClassIsland 原项目的 C# / .NET 8 / Avalonia / FluentAvalonia / XAML。
>
> **信息来源**:ClassIsland v2.1 源码(`repos/ClassIsland`,C#/Avalonia)与 ClassHelper 源码(`repos/ClassHelper-master`,TypeScript/Electron)。文中所有确切数值、资源名、行为均尽量标注 `文件:行号` 证据;无源码依据处标注【推断】。ClassIsland 的截图与文档仅用于校对视觉语义,不复制其代码。
>
> **重要说明**:本提示词要求实现者的一切自动验证(构建、冒烟、截图、动画检查)必须**无头/离屏运行,禁止弹出可见窗口、禁止播放声音、禁止干扰用户桌面**。这是本项目的强制纪律,也是验收的一部分(见第 18 部分)。
## 使用说明(给人类读者,不投喂给模型)
1. **整体投喂**:全文约 200k tokens,需使用上下文 ≥ 200k 的模型(如 GPT-5 级长上下文 / Gemini 2.5 Pro)才能一次粘贴;将本文件从「## 0.」到结尾整体粘贴,尾附一句:「请严格按此提示词实现,先给出实施计划与文件树,再按里程碑推进。」
2. **分卷投喂**:按 `## 0` + `## 1` + `## 2`(基础)→ `## 3`~`## 5`(视觉与组件)→ `## 6`~`## 8`(业务)→ `## 9`~`## 13`(系统与数据)→ `## 14`~`## 19`(动画、资源、验收)的顺序分批发送;每批发完后要求模型复述关键参数,避免长上下文丢失。
3. **推荐追问**:「请先输出 20 个你认为最可能歧义的点」「请给出 packages/desktop-client 的完整文件树与每个文件的职责」「请先实现 M0/M1 并给出无头验证脚本」。
4. **证据约定**:文中 `repos/ClassIsland/...` 与 `repos/ClassHelper-master/...` 是规格来源路径,不是让你照抄的运行时依赖;实现时应把这些规格转写为 TypeScript/CSS。
<!-- TOC -->
## 0. 给 ChatGPT 的任务声明(从本节开始执行)
### 0.1 你的任务
从零实现一个名为 **ClassIsland**(实现代号 `classisland-ts`)的跨平台桌面应用:它常驻班级多媒体屏幕,以悬浮「岛」显示当天课表、当前课程、倒计时、日期时间、天气等信息;在上下课等重要时间点发出包含音效、语音与全屏特效的提醒;并提供课表编辑、档案管理、组件系统、主题、自动化、插件、自动更新等完整能力。灵感来源是 iOS 灵动岛(Dynamic Island),但它是为教室大屏与「不打扰授课」场景重新设计的桌面工具。
你必须交付**可安装、可离线运行、可长时间无人值守运行**的 Electron 应用,而不是网页 Demo 或原型。视觉与交互规格必须严格按本提示词执行,尤其是第 3(设计 tokens)、4(主界面)、5(组件)、14(动画)部分中的数值。
### 0.2 不可协商的技术栈
- pnpm@11.8.0 monorepo;Node.js >= 20.19;TypeScript ~5.9.3(strict)。
- Electron ^44.3.0 + electron-builder ^26.15.3;主进程/preload 用 esbuild 打包,渲染层用 Vite ^8.3.0 多入口(`index.html` 应用窗口、`island.html` 岛)。
- Vue ^3.5.42 + Vue Router ^5.3.1 + Pinia ^4.0.3;Element Plus ^2.14.5 仅用于设置/管理类界面,**不得进入岛的渲染进程**。
- 三个进程角色:主进程(窗口/调度/配置/更新/IPC)、岛渲染进程(常驻、透明、逐像素自绘)、应用窗口渲染进程(按需创建)。
- 复用 ClassHelper 已验证的实现模式:独立 `island.html` + 独立透明 BrowserWindow、连续圆角 `squircle.ts`、弹簧动画 `spring.ts`、`config.json` 原子读写、contextBridge 白名单、`tokens.css` 设计变量、自研 `auto-update.ts`(禁止 electron-updater)、崩溃守卫 `crash-guard.ts`。
- 不引入服务端依赖;所有业务数据本地存储;可选管理端联动只能作为 P2 可裁剪子包。
### 0.3 硬性纪律(违反即不合格)
1. 所有自动验证必须无头/离屏执行;不得显示可见窗口、托盘气泡、系统通知或声音;需要人工观察的验证用显式 `--visual` 参数开启且不得作为默认。
2. 渲染层零 Node 权限:preload 只暴露 contextBridge 白名单,不暴露 `ipcRenderer`;`nodeIntegration=false`、`contextIsolation=true`、`sandbox=true`。
3. 主窗口(岛)不得抢焦点、默认不进入任务栏;不得使用 `setBackgroundMaterial()` 等破坏逐像素透明的 API。
4. 动画必须 60fps:优先 transform/opacity,禁止 `setInterval` 逐帧驱动、禁止逐帧改布局属性。
5. 核心功能离线可用;数据写入原子化并自动备份;迁移前先备份。
6. 不得伪造验收结果;未验证项必须显式标注。
### 0.4 交付物
- 完整源码 + `pnpm-lock.yaml` + 构建/打包脚本;`README.md` 含运行、构建、打包、数据目录说明。
- `docs/architecture.md`、`docs/data-schema.md`、`docs/plugin-api.md`、`docs/theme-api.md`。
- 至少 1 个示例课表档案、1 个示例主题、1 个示例插件(P1)。
- 无头测试(单元 + 组件 + 端到端)与第 16 部分的验收报告。
### 0.5 工作方式
1. 通读本提示词后,先输出:实施计划、完整文件树、你识别出的歧义点(一次性提问)。
2. 按第 17 部分的 M0→M8 顺序实现;每个里程碑结束都交付「可无头启动 + 通过该阶段验收」的状态。
3. 每完成一个模块,写清楚:实现了什么、证据(文件与行号)、未实现/推断项、如何验证。
4. 遇到本提示词未覆盖的细节:按第 18.5 节的优先级裁决,并在代码注释标注「推断」。
5. 不得把不同模块混在一起「顺手重构」;保持提交/变更范围单一、可回滚。
### 0.6 术语与阅读顺序
- **岛(Island / MainWindow)**:常驻悬浮主界面;由一行或多行「行(Line)」组成。
- **行(MainWindowLine)**:承载组件的横向容器,拥有自己的背景、进度条、遮罩与提醒气泡。
- **组件(Component)**:最小显示单元(日期、时钟、倒计时、课程表、天气等),可嵌套。
- **档案(Profile)**:一套课表/时间表/科目的配置集合,对应一个 JSON 文件。
- **课表(ClassPlan)** 与 **时间表(TimeLayout)**:见第 6 部分。
- **提醒(Notification)**、**自动化(Automation)**、**规则集(Ruleset)**、**主题(XamlTheme→CSS 主题包)**、**插件(Plugin)**:见对应章节。
- 阅读顺序:0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19。
+121
View File
@@ -0,0 +1,121 @@
## 1. 项目总纲:产品定位、总体体验与系统架构
### 1.1 你的角色与最终目标
你是一名资深跨平台桌面应用工程师,精通 Electron、Vue 3、TypeScript 与现代桌面 UI 动效实现。你的任务是从零实现一个**班级多媒体屏幕课表信息显示工具**,本提示词称其为 **ClassIsland(实现代号 `classisland-ts`)**。它常驻在教室大屏/讲台电脑上,以悬浮「岛」的形式显示当天课表、当前课程、倒计时、天气、日期时间等信息,并在上课、下课、课前准备、放学、天气变化等重要时间点发出提醒,同时提供课表编辑、组件自定义、主题、自动化、插件扩展等一整套能力。
最终交付必须是一个**可安装、可离线运行、可长时间无人值守运行**的 Windows / macOS / Linux 桌面应用,而不是网页 Demo。你必须严格按本提示词的数值、交互、动画与数据结构实现;凡本提示词给出确切数值的地方,不得用「差不多」的默认样式替代。
### 1.2 产品定位
- 一句话定位:**给班级多媒体屏幕用的、灵感来自 iOS 灵动岛的课表与班级信息悬浮显示工具。**
- 使用环境:Windows 10+(主目标)、macOS 11+、Linux X11(不支持 Wayland);屏幕常见 1920×1080、2560×1440、3840×2160,缩放 100%–200%,也可能是希沃等触摸一体机。
- 使用方式:开机自启,常驻桌面最上层;上课时悬浮在课件上方但不遮挡关键内容;教师可通过右键菜单/托盘快速进入编辑、换课、设置;放学后可由自动化或管理策略隐藏。
- 核心价值:**零打扰**(不弹系统级窗口、不抢焦点、可点击穿透)、**秒级准确**(网络对时 + 本地时钟补偿)、**一眼可读**(大字号、强对比、信息分层)、**完全可定制**(组件、主题、课表、提醒、自动化)。
- 明确不是:不是 Web 管理系统、不是手机 App、不是需要登录云服务的 SaaS。除天气、更新、可选管理端外,全部功能必须离线可用。
### 1.3 目标用户与典型场景
1. **班主任/任课老师**:用 Excel 导入课表,设置单双周/多周轮换、临时换课;设置上下课提醒音效与语音;用自动化在午休时关闭屏幕。
2. **电教委员/学生**:日常只看岛,不需要任何操作;断网、重启后应用自动恢复;不误触、不遮挡投影。
3. **学校管理员**:通过主题/配置批量部署;可选管理端下发策略(本提示词的 P2 可选模块,默认不依赖服务器)。
4. **插件开发者**:按插件规范扩展组件、提醒提供者、自动化触发器/行动、设置页。
### 1.4 十条不可妥协的体验原则
1. **像素级还原本提示词的视觉规格**:圆角、透明度、字号阶梯、间距、进度条、阴影必须逐项落实;默认参数见第 3、4 部分。
2. **绝不打扰授课**:主窗口默认不抢焦点(`show: false` 显示、`focusable: false`)、不进入任务栏、可配置鼠标穿透;任何自动验证/冒烟必须无头运行,不得创建可见窗口。
3. **60fps 丝滑动画**:所有过渡优先使用 CSS transform/opacity(合成层),禁止用 JS 逐帧改布局;弹簧动画采用本提示词给定的物理参数。
4. **时间必须准**:所有课程状态以「带补偿的当前时间」计算;支持 NTP 对时、手动时间偏移、时间倍速调试;跨天、跨周、闰秒、系统休眠唤醒都要正确。
5. **离线优先、数据安全**:配置、课表、提醒全部本地存储;写入采用原子替换 + 自动备份;任何迁移前先备份,迁移失败可回滚。
6. **组件化与可组合**:主界面的每一行、每一个显示单元都是组件;组件可嵌套(分组/堆叠/滚动/滑动)、可单独设置、可按规则隐藏。
7. **默认即好看**:首次启动走完 OOBE 后,即使不导入课表也要显示一个可用的岛(日期、时间、欢迎信息),不能白屏。
8. **可恢复**:渲染进程崩溃、GPU 崩溃、配置损坏、误删数据时,应用必须能自愈或进入恢复模式;崩溃后不得反复重启刷屏。
9. **可扩展**:插件/主题/提醒提供者/自动化行动全部通过注册表扩展,核心不写死;公共 API 保持向后兼容。
10. **可访问与本地化**:中文为默认语言(ClassIsland 2.1 的界面文案为硬编码中文);支持高对比、减少动画(`AnimationLevel`)、键盘操作、触摸操作;文案集中到常量/词条文件,预留 i18n key 体系,不得把字符串散落在组件里。
### 1.5 功能全景(必须实现的模块)
> 优先级:P0 = 首版必须;P1 = 首个稳定版必须;P2 = 可分期但提示词要求预留架构。
| 模块 | 能力清单 | 优先级 |
|---|---|---|
| 主界面(岛) | 多行岛、背景/圆角/透明度/毛玻璃、课程信息行、进度条、倒计时、遮罩/提醒宿主、编辑模式、隐藏规则、点击穿透、多显示器停靠、缩放、窗口置顶 | P0 |
| 组件系统 | 日期、时钟、倒计时、课程表、天气、文本、滚动、滑动、堆叠、分组、分隔线、容器;组件设置、显示/隐藏规则、多行组件、轮播 | P0 |
| 课程表与档案 | 时间表(节次/课间/分割线/行动)、课表(单日/多日/周模板)、多周轮换(1–9 周)、科目与教师、临时课表/预定课表、单日与跨天换课、CSES 导入导出(P0)、Excel 导入导出(P1,用 xlsx 重建;ClassIsland 2.1 源码中该功能被 `#if false` 停用,布局仅作参考)、多档案 | P0/P1 |
| 提醒系统 | 提醒队列与优先级、上/下课/课前/放学提醒、天气类提醒、自定义提醒、气泡与强提醒特效、音效、TTS 语音、提醒历史与清除(ClassIsland 2.1 无独立「静音时段」;只有全局提醒开关与 `-q` 静默启动后 10s 丢弃提醒,不得虚构静音时段,P2 扩展需默认关闭) | P0 |
| 设置中心 | 通用/外观/组件/提醒/自动化/天气/窗口/时钟/主题/更新/存储/插件/管理/隐私/高级/关于/调试,即时保存、设置覆盖层、按注册顺序导航(ClassIsland 2.1 无搜索框,本实现不编造搜索;如需可标 P2 增强) | P0 |
| OOBE 向导 | 8 页:欢迎、许可、翻新/迎新、基本、颜色、外观、系统、完成;可跳过、可重复打开 | P0 |
| 主题系统 | 内置 Fluent/Classic 两套主题、主题包(CSS 变量 + 资源 + 预览图)、自定义强调色/背景色/前景色、深浅色跟随系统;壁纸取色在 ClassIsland 2.1 中已整体停用(`WallpaperPickingService` 全文 `#if false`,设置页对应项禁用隐藏),本实现只作为 P2 实验性能力(默认关闭、给算法与数据结构、不纳入 P1 验收) | P1(壁纸取色 P2) |
| 自动化 | 触发器(时间点/CRON/信号/URI/托盘菜单/窗口状态/当前课程/天气/日出日落)、行动(提醒/运行程序/打开文件网页/关机/睡眠/重启/修改设置/广播信号/切换课表等)、规则集(嵌套条件) | P1 |
| 天气 | 当前天气、逐小时/未来 3 天预报、降水提醒、极端天气预警、定位/城市选择、天气图标主题 | P1 |
| 插件系统 | 插件包格式、清单、权限、生命周期、组件/提醒/触发器/行动/设置页扩展点、插件市场(可选)、启用/禁用/更新 | P1 |
| 调试与诊断 | 日志查看、配置校验、崩溃上报(可选 Sentry)、渲染模式切换、时间调试、动画调试、窗口规则调试、教程系统 | P1 |
| 更新与运维 | 自动检查更新、镜像/通道、断点下载、哈希校验、静默安装、崩溃恢复、单实例、开机自启 | P1 |
| 管理端联动 | 可选远程策略、管理凭据、通知通道(P2,默认关闭且不得成为运行前置依赖) | P2 |
### 1.6 总体技术架构(ClassHelper 栈)
**必须采用 ClassHelper 的技术栈与工程约定**(来源:`repos/ClassHelper-master/package.json`、`pnpm-workspace.yaml`、各 `packages/*/package.json`、`AGENTS.md`):
- 包管理器:`pnpm@11.8.0`,Node.js >= 20.19;根 `package.json` 为版本单一来源。
- 语言:TypeScript ~5.9.3,开启 `strict`;Vue 3.5 SFC(`<script setup lang="ts">`),禁止 `any` 泛滥。
- 桌面壳:Electron ^44.3.0 + electron-builder ^26.15.3;Windows 目标 NSIS/portable,macOS dmg/zip,Linux AppImage/deb(任选其一即可,但构建脚本必须齐全)。
- 渲染层:Vue ^3.5.42 + Vue Router ^5.3.1 + Pinia ^4.0.3 + Element Plus ^2.14.5(仅设置/管理类界面使用 Element Plus;主界面「岛」使用手写 CSS 与自定义组件,**不得**让 Element Plus 的默认样式污染岛)。
- 构建:Vite ^8.3.0(`@vitejs/plugin-vue` ^6.0.8),多入口构建:`index.html`(设置/主窗口)与 `island.html`(独立岛渲染进程)。
- 进程模型(必须三进程分离):
1. **主进程 `src/main`**:窗口管理、托盘、全局快捷键、单实例、配置读写、调度(时间引擎/提醒调度/自动化)、自动更新、系统集成、IPC 服务端。
2. **岛渲染进程 `island.html` → `src/island`**:只负责画「岛」,常驻内存,通过 contextBridge 暴露的最小 API 接收状态;窗口透明、点击穿透由主进程控制。
3. **应用窗口渲染进程 `index.html` → `src/renderer`**:设置中心、档案编辑、OOBE 向导、关于、日志、调试、恢复等;按需创建,关闭即销毁(pinia 状态持久化到主进程)。
- 共享包:`packages/shared`(类型、常量、i18n 词条、数据 schema、纯函数业务逻辑:时间/周次/课表解析)、`packages/ui`(CSS tokens、基础 Vue 控件、主题)。
- 目录结构必须为 pnpm monorepo(`packages/*`),至少包含:`packages/desktop-client`(Electron 应用)、`packages/shared`、`packages/ui`。若实现插件宿主,可增加 `packages/plugin-sdk`(P1)。
- 复用 ClassHelper 已验证的桌面岛关键实现(可直接参照其代码组织与参数):
- 独立 `island.html` 渲染入口 + 独立透明 BrowserWindow(见 `repos/ClassHelper-master/packages/desktop-client/src/main/island.ts`)。
- `squircle.ts` 连续圆角(超椭圆指数 4.2,每角 14 段,半径 `min(r, w/2, h/2)`)。
- `spring.ts` 弹簧动画(`stiffness=0.10`、`damping=0.68`、换向动量保留 35%、单帧位移 ≤ 剩余距离 20%、snap 阈值 0.05px、60fps 帧单位)。
- `config.json` + `config.ts` 的原子读写、版本迁移、默认值合并(参照 `src/main/config.ts`)。
- preload 只暴露 contextBridge 白名单,不暴露 `ipcRenderer`;渲染层不得直接访问 `fs`/`child_process`。
- `renderer/composables/motion.ts` 的统一动效 token;`packages/ui/src/tokens.css` 的颜色/圆角/动效变量体系。
- **不得引入 ClassHelper 的服务端依赖**:本应用是本地单机应用;Express/Prisma/Socket.IO 仅在你选择实现 P2「管理端联动」时才可作为可选子包,且主流程不得依赖网络。
- 允许使用成熟开源库完成非核心能力(如 `xlsx` 解析 Excel、`yaml` 读取插件清单、`adm-zip` 解压插件/主题包);**更新器必须自研**(参照 ClassHelper 的 `src/main/auto-update.ts` + `update-installer.ts`,禁止引入 electron-updater);不要引入 `node-pty`、`sharp` 等原生模块。所有依赖必须列明版本并锁定 `pnpm-lock.yaml`。
**逻辑分层(进程内)**:
```
main
├─ config/ 配置读写、迁移、备份(Settings/Profiles/Automations/Components/Themes)
├─ windows/ island / app / splash / topmost-effect / class-changing / oobe ...
├─ engine/
│ ├─ clock NTP 对时 + 偏移 + 倍速;对外只暴露当前时间
│ ├─ lessons 课表解析、当前/下一节、时间点状态机、周次计算
│ ├─ notification 提醒队列、提供者、优先级、去重、TTS/音效
│ ├─ automation 触发器轮询、行动执行、规则集求值
│ ├─ weather 天气拉取、缓存、预警
│ └─ update 更新检查/下载/安装
├─ ipc/ contextBridge 白名单 + 通道校验 + 节流
└─ tray/ 托盘菜单、开机自启、单实例
renderer(island) IslandApp.vue + 组件渲染器 + 动画
renderer(app) SettingsWindow / ProfileEditor / OOBE / About / Logs / Recovery ...
```
### 1.7 信息架构与导航
- 主界面(岛)是唯一常驻界面,无标题栏、无任务栏图标、无边框。右键(或长按触摸)弹出**原生上下文菜单**:ClassIsland(关于)、教学…、帮助文档…、显示/隐藏主界面、清除全部提醒、更多选项…、编辑档案…、加载临时课表…、换课…、编辑主界面…、应用设置…、重启、退出(调试模式下追加 dev__Debug 子菜单)。菜单项文案、图标、可见条件必须与第 4 部分一致。
- 托盘图标提供左键行为(默认打开主菜单/打开档案编辑/显示隐藏主界面/打开换课/打开设置,见设置项 `TaskBarIconClickBehavior` 默认值:Windows 0,其他平台 4)。
- 应用窗口为 Fluent 风格的单窗口 + 页面栈:设置中心(左侧导航列表 + 面包屑返回;ClassIsland 2.1 **无设置搜索框**,本实现不得编造搜索,如做需明确标 P2 增强)、档案编辑器(左侧档案树 + 右侧编辑区)、OOBE(无侧栏的 8 页步骤向导)、关于、日志、调试、恢复模式;提醒不设独立「通知中心」窗口,由主界面行内气泡 + 强提醒特效承载。
- 所有次级窗口居中显示、可用 Esc 关闭、标题栏使用系统样式(Fluent 主题下自绘标题栏);关闭设置窗口不退出应用,仅隐藏。
- URL Scheme:`classisland://app/...` 用于深链(关于页、设置页等),由单实例机制转发到已运行实例。
### 1.8 首次启动与默认状态
1. 启动时先显示无边框闪屏(可选,默认关闭;打开时 1.2s 内渐显 Logo 与应用名,可显示加载状态),随后显示 OOBE。
2. OOBE 完成后写 `config.json`(并兼容导出 ClassIsland 的 `Settings.json`)的 `IsWelcomeWindowShowed=true`,主界面以默认参数出现:位置=中上侧(`WindowDockingLocation=1`)、缩放 100%、背景不透明度 0.5、圆角 8px、字体默认系统字体(Windows 为微软雅黑 UI 回退链)、主体字号 16、次要 14、强调 18、超大 20、行间距 5px。
3. 默认组件配置为 1 行 2 组件:日期(DF3F8295-21F6-482E-BADA-FA0E5F14BB66)+ 课程表(1DB2017D-E374-4BC6-9D57-0B4ADF03A6B8,依据 ComponentsService.cs:33-52)。未加载档案时课程表组件显示「今天没有课程。」等占位符,而不是白屏或欢迎文本;加载课表后显示课程信息。
4. 默认设置必须保证:岛可见、不穿透、不抢焦点、不遮挡全屏应用(`HideOnFullscreen=false`,可开启)、提醒开启、音效开启但音量 100%、调试关闭。
### 1.9 本提示词的使用方式与验收总则
- 本提示词按「总纲 → 技术栈 → 设计系统 → 主界面 → 组件 → 课表/提醒/自动化 → 主题/设置/窗口 → 动画/资源 → 插件/数据 → 验收」组织。实现时必须全部通读,再按第 17 部分的里程碑顺序开发。
- 每一部分末尾都有「验收标准」;你必须逐条自测并在交付说明中报告结果。自测必须无头执行(`--headless` / 隐藏窗口 / CI 模式),**不得弹出可见窗口、不得占用用户桌面、不得发出声音**;像素级验收使用 Playwright/`electron` 的离屏截图模式完成。
- 任何你没有把握的数值,优先服从本提示词;本提示词未覆盖的细节,按「Fluent Design + 灵动岛」的直觉补齐,并在代码注释中说明。
- 交付必须包含:完整源码、`README.md`(含运行/构建/打包命令)、`docs/`(数据 schema 与插件 API)、至少 1 个示例主题与 1 个示例插件(P1)、以及一套无头自动化测试(单元测试 + 关键交互的组件测试)。
+411
View File
@@ -0,0 +1,411 @@
## 2. 技术栈与工程结构
### 2.1 技术选型总则(必须逐条遵守)
1. 你必须用 **TypeScript + Electron + Vue 3** 实现本应用,工程形态必须是 **pnpm monorepo**,不得做成单包项目、不得用 Webpack、不得用 Electron Forge、不得用 `electron-vite` 之外的脚手架替代本文规定的手写构建管线。
2. 你必须**完整沿用 ClassHelper 桌面客户端的工程约定**:主进程/preload 用 esbuild 打包为 CommonJS,渲染进程用 Vite 多入口打包,打包用 electron-builder;共享类型、常量、纯函数放 `packages/shared`;共享 Vue 控件与样式令牌放 `packages/ui`;Electron 应用全部代码放 `packages/desktop-client`。依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-2`、`repos/ClassHelper-master/packages/desktop-client/package.json:8-18`。
3. 你**不得**把 ClassHelper 的服务端(Express/Prisma/Socket.IO)引入主流程。ClassIsland 是本地单机应用,一切业务数据必须落在本机;仅当实现「可选管理端联动」时才允许把 `packages/server` 作为可裁剪子包,且主流程不得依赖网络。依据:`repos/ClassHelper-master/packages/server/package.json:19-40` 的依赖清单,以及总纲的 P2 定义。
4. 你必须把「版本」收敛为**单一来源**:根 `package.json` 的 `version` 是唯一手写值,所有子包版本由脚本同步;日常功能提交不得涨版本。依据:`repos/ClassHelper-master/AGENTS.md`「根 package.json 是版本单一来源」与 `repos/ClassHelper-master/docs/dev/release.md:8-10`。
5. 你必须保持「渲染层零 Node 能力」:渲染进程不得 `require('node:fs')`、不得访问 `process` 环境变量之外的 Node API,所有文件、系统、更新、托盘能力一律经主进程 IPC。依据:`repos/ClassHelper-master/AGENTS.md`「preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer」。
### 2.2 依赖清单(版本必须按此锁定)
以下版本是硬约束,`pnpm-lock.yaml` 必须锁定到这些 semver 区间;不得因为"想用新 API"而升级大版本。
| 用途 | 包名 | 版本 | 证据 |
|---|---|---|---|
| 包管理器 | pnpm | `11.8.0`(`packageManager` 字段) | `repos/ClassHelper-master/package.json:6` |
| Node 运行时 | Node.js | `>=20.19.0` | `repos/ClassHelper-master/package.json:7-9` |
| 桌面壳 | `electron` | `^44.3.0` | `repos/ClassHelper-master/packages/desktop-client/package.json:32` |
| 打包 | `electron-builder` | `^26.15.3` | 同上 `:34` |
| UI 框架 | `vue` | `^3.5.42` | 同上 `:26` |
| 路由 | `vue-router` | `^5.3.1` | 同上 `:27` |
| 状态 | `pinia` | `^4.0.3` | 同上 `:24` |
| 组件库(仅设置/管理界面) | `element-plus` | `^2.14.5` | 同上 `:23` |
| 图标 | `@element-plus/icons-vue` | `^2.3.2` | 同上 `:21` |
| 构建 | `vite` | `^8.3.0` | 同上 `:37` |
| Vue 插件 | `@vitejs/plugin-vue` | `^6.0.8` | 同上 `:32` |
| 主进程打包 | `esbuild` | `^0.28.2` | 同上 `:35` |
| 语言 | `typescript` | `~5.9.3` | 根 `package.json:74` |
| 类型检查 | `vue-tsc` | `^3.3.11` | `desktop-client/package.json:38` |
| Node 类型 | `@types/node` | `^22.20.2` | 同上 `:31` |
允许额外引入的第三方库(必须写进依赖清单并锁版本):`xlsx`(Excel 导入导出;**P1 必须实现**,用 xlsx 库重建 ClassIsland 的 ReoGrid/WPF 版导入导出,ClassHelper 服务端同款 `0.18.5`,见 `packages/server/package.json:38`)、`adm-zip` 或 `yauzl`(`.cipx`/主题包解压)、`yaml`(读取插件 `manifest.yml`,见 `packages/server/package.json:40`)、`electron-log` 可选但**不强制**(本文要求自研 `logger.ts`,理由见 2.8)。禁止引入:`electron-updater`(本文的更新模型是自研的哈希校验 + 独立安装助手,见 2.11 与 12 部分)、`better-sqlite3` 等原生模块(`electron-builder.yml:37-38` 明确 `npmRebuild: false`、无原生模块)、任何需要在线服务的 SDK。
`pnpm-workspace.yaml` 必须显式声明允许执行构建脚本的依赖,否则 pnpm 11 会因 `ERR_PNPM_IGNORED_BUILDS` 直接失败:
```yaml
packages:
- 'packages/*'
allowBuilds:
esbuild: true
electron: true
vue-demi: true
electron-winstaller: false
verifyDepsBeforeRun: false
```
依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-26`(含注释解释 electron-winstaller 为何必须显式为 false)。
### 2.3 仓库目录结构(必须逐项落地)
你必须在仓库根建立如下结构;标注「新增」的是本应用特有模块,其余照抄 ClassHelper 的组织方式。
```text
classisland-ts/
├─ package.json # 版本单一来源、pnpm@11.8.0、engines>=20.19
├─ pnpm-workspace.yaml
├─ pnpm-lock.yaml
├─ tsconfig.base.json # target ES2023、strict、verbatimModuleSyntax
├─ .prettierrc.json # LF、单引号、100 列
├─ scripts/
│ ├─ version.mjs # 版本唯一修改入口(--bump patch|minor|major)
│ ├─ check-icons.mjs # 图标资源完整性门禁
│ └─ lib/electron-env.mjs # 跨平台解析 electron 可执行文件
├─ packages/
│ ├─ shared/ # 三端共享:类型/常量/schema/纯函数
│ │ └─ src/
│ │ ├─ index.ts # 统一导出(照抄 packages/shared/src/index.ts)
│ │ ├─ constants.ts # 尺寸/默认值/枚举/映射表
│ │ ├─ types.ts # DTO 与持久化类型
│ │ ├─ motion.ts # OK 动效令牌(纯常量+纯函数)
│ │ ├─ island-layout.ts # 岛行/组件方案 schema + normalize(新增扩展)
│ │ ├─ schedule.ts # 时间表/课表/周次纯函数(新增)
│ │ ├─ time.ts # 本地日期/日键/时间补偿(新增)
│ │ ├─ notifications.ts # 提醒 DTO/优先级(新增)
│ │ ├─ automation.ts # 自动化 DTO(新增)
│ │ ├─ plugins.ts # 插件清单/权限/扩展点类型(新增)
│ │ └─ ipc.ts # IPC 通道名与 payload 类型(新增,主进程与 preload 共用)
│ ├─ ui/ # 共享 Vue 控件与样式令牌
│ │ └─ src/
│ │ ├─ index.ts # registerSharedUi(app)
│ │ ├─ tokens.css # 基础色/圆角/间距(照抄)
│ │ ├─ theme.css # 语义色 + html.dark(照抄后扩展)
│ │ └─ *.vue # DatePicker/PageHeader/FullScreenGuide 等
│ └─ desktop-client/ # Electron 应用
│ ├─ package.json # main: dist/main/index.js
│ ├─ electron-builder.yml
│ ├─ index.html # 应用窗口(设置/编辑/OOBE)
│ ├─ island.html # 岛渲染入口(必须独立)
│ ├─ vite.config.mts # 双入口、base './'、outDir dist/renderer、5174
│ ├─ tsconfig.json # 渲染层(DOM lib)
│ ├─ tsconfig.node.json # 主进程/preload(node lib)
│ ├─ scripts/
│ │ ├─ dev.mjs # vite 就绪后起 electron(VITE_DEV_SERVER_URL)
│ │ ├─ build-main.mjs # esbuild -> dist/main + dist/preload
│ │ ├─ build-update-helper.mjs # 编译独立更新助手(新增,见 2.11)
│ │ ├─ dist-win.mjs # electron-builder 包装 + 产物归档
│ │ └─ check-bundle-secrets.mjs # 打包不得含凭据门禁
│ └─ src/
│ ├─ main/
│ │ ├─ index.ts # 启动编排(照抄 ClassHelper 顺序)
│ │ ├─ config.ts # config.json 读写/归一化/合并写
│ │ ├─ channels.ts # IPC 通道名叶子模块(零 import)
│ │ ├─ ipc.ts # ipcMain.handle 注册表
│ │ ├─ island.ts # 岛窗口控制器(ClassIsland 主界面宿主)
│ │ ├─ main-window.ts # 设置/编辑窗口管理(新增)
│ │ ├─ tray.ts # 系统托盘
│ │ ├─ logger.ts # main.log + 1MB 轮转
│ │ ├─ crash-guard.ts # 崩溃自愈
│ │ ├─ auto-update.ts # 下载/校验/空闲安装
│ │ ├─ update-installer.ts # 安装助手就绪/提交/回滚
│ │ ├─ storage.ts # 数据文件读写/备份/迁移(新增,见 13 部分)
│ │ ├─ scheduler.ts # 时间引擎:上课/下课/准备/放学(新增)
│ │ ├─ automation.ts # 自动化运行时(新增)
│ │ ├─ notifications.ts # 提醒队列/宿主(新增)
│ │ ├─ plugins.ts # 插件宿主(新增,见 12 部分)
│ │ └─ platform/ # 开机自启、单实例、DPI、全屏检测
│ ├─ preload/
│ │ ├─ index.ts # contextBridge 暴露 window.desktop
│ │ └─ island.ts # contextBridge 暴露 window.island
│ ├─ island/ # 岛渲染进程(手写 CSS,零 Element Plus)
│ │ ├─ main.ts # 注入动效令牌后 mount
│ │ ├─ LayoutIslandApp.vue # 选择经典/自定义渲染
│ │ ├─ spring.ts # SpringValue 帧积分
│ │ ├─ squircle.ts # 连续圆角路径
│ │ ├─ classic-island.css # 经典岛样式
│ │ └─ components/ # 岛内组件(日期/时钟/课程/进度/提醒)
│ ├─ renderer/ # 应用窗口渲染进程
│ │ ├─ main.ts # 挂载顺序见 2.9
│ │ ├─ router/index.ts # createWebHashHistory
│ │ ├─ stores/ # pinia:ui/notifications/onboarding/profile/settings
│ │ ├─ views/ # 设置中心、档案编辑、OOBE、关于…
│ │ ├─ composables/motion.ts # applyMotionTokens/useReducedMotion/useCountUp…
│ │ ├─ api/ # 本地数据访问(经 preload IPC,不含网络)
│ │ └─ styles/index.css # 语义色令牌 + 全局样式
│ └─ types/desktop.d.ts # 主进程↔渲染进程桥接契约
```
依据:`repos/ClassHelper-master/packages/desktop-client/package.json:7-18`(入口与脚本)、`vite.config.mts`(入口与产物)、`src/main/*`、`src/preload/*`、`src/island/*`、`src/renderer/*` 的既有布局。
### 2.4 进程模型(必须五类进程分离)
1. **主进程(`src/main/index.ts`)**:唯一持有窗口、托盘、配置、定时器、文件系统、自动更新、插件宿主的进程。它必须承载 ClassIsland 的「Core 服务层」职责:时间引擎、课表状态机、提醒宿主、自动化、存储与备份、插件注册表。
2. **应用窗口渲染进程(`index.html`)**:设置中心、档案编辑器、OOBE、关于页、日志、调试、恢复。按需创建;关闭窗口必须只隐藏不退出(ClassIsland 主窗口关闭即隐藏的逻辑改成「设置窗口关闭=销毁,托盘常驻」)。
3. **岛渲染进程(`island.html`)**:常驻内存,只画岛。必须独立 `BrowserWindow`,独立 preload(`dist/preload/island.js`),独立 CSS,**不得**加载 Element Plus。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:480-512`(窗口参数与 preload)、`src/island/main.ts:1-30`(零 UI 库)。
4. **插件/自动化隔离进程(`utilityProcess`,Electron 44 内置)**:插件代码、用户自动化脚本、外部命令执行必须跑在这个进程里,崩溃不得拖垮主进程;与主进程之间用 `MessagePort` 传纯数据。ClassIsland 用 `PluginLoadContext` 做程序集隔离(`repos/ClassIsland/ClassIsland/Services/PluginService.cs:186-235`),在 Electron 下等价物就是 utilityProcess + 动态 `import()`。
5. **独立更新助手(`update-helper.exe`)**:Windows 下由构建脚本生成的小型可执行程序,负责"旧进程退出后替换文件、失败回滚、写 `install-result.json`"。不得由主进程自己替换正在运行的 exe。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:516-564`(就绪→commit→退出)与 `scripts/build-main.mjs:12`(构建时产出助手)。
### 2.5 构建管线(命令与产物必须一致)
**主进程与 preload**:用 esbuild 打成 CJS,`platform: 'node'`、`target: 'node20'`、`external: ['electron']`、`define process.env.NODE_ENV`;产物固定为:
- `dist/main/index.js`
- `dist/preload/index.js`
- `dist/preload/island.js`
- `dist/main/update-helper.exe`(构建时由 `build-update-helper.mjs` 生成)
依据:`repos/ClassHelper-master/packages/desktop-client/scripts/build-main.mjs:14-46`。
**渲染进程**:用 Vite 双入口构建:`index.html`(应用窗口)与 `island.html`(岛);`base: './'`(Electron 以 `file://` 加载,必须相对路径)、`outDir: 'dist/renderer'`、`emptyOutDir: true`、`sourcemap: false`、`chunkSizeWarningLimit: 2000`;dev 服务器固定 `127.0.0.1:5174`、`strictPort: true`。依据:`repos/ClassHelper-master/packages/desktop-client/vite.config.mts:13-39`。
**开发模式**:`scripts/dev.mjs` 必须先探测 5174 端口可用,再以 `VITE_DEV_SERVER_URL=http://127.0.0.1:5174` 启动 Electron;主进程读到该变量时 `loadURL` 并打开 DevTools,读不到时 `loadFile('../renderer/index.html')`;岛的开发地址是 `${devUrl}/island.html`。依据:`src/main/index.ts:18`、`:137-145`、`:221`;`scripts/dev.mjs:21-60`。
**打包**:`electron-builder.yml` 必须包含:
| 项 | 值 | 证据 |
|---|---|---|
| appId | `com.classisland.desktop`(从 ClassHelper 改名) | `electron-builder.yml:1` |
| productName | `ClassIsland` | `:2` |
| 语言包 | 仅 `zh-CN`(删除其余 54 个 locales) | `:14-15` |
| files | `dist/**/*`、`package.json`、排除 `node_modules`、排除 `*.map` | `:30-34` |
| asar | `true` | `:36` |
| npmRebuild | `false`(无原生模块) | `:37-38` |
| compression | `normal` | `:39` |
| win target | `nsis` x64 + `portable` x64 | `:42-47` |
| artifactName | `${productName}-${version}-${arch}.${ext}` | `:52` |
| nsis | `oneClick:false`、`perMachine:false`、`allowToChangeInstallationDirectory:true`、桌面+开始菜单快捷方式 | `:54-61` |
| portable | `${productName}-${version}-${arch}-portable.${ext}` | `:63-64` |
**必须建立的门禁脚本**(在 CI/发布前按序执行,任何一步失败即中止):
1. `pnpm build:shared`(先建共享包,再建下游——依据 ClassHelper 根脚本 `package.json:11-17`)。
2. `pnpm typecheck`(`vue-tsc --noEmit` + `tsc --noEmit -p tsconfig.node.json`,依据 `desktop-client/package.json:16`)。
3. `pnpm lint`(ESLint + 自定义 authz/资源检查)。
4. `pnpm check:icons`(图标资源完整性)。
5. `pnpm build:desktop`(= check-icons + build:main + vite build + check:secrets,依据 `desktop-client/package.json:10-13`)。
### 2.6 应用启动时序(必须按此顺序,不得提前读配置)
1. 解析命令行参数(`--hidden` 静默启动、`--safe` 安全模式、`--diagnostic` 诊断模式、`--recovery` 恢复模式、`--importV1/--importV2` 导入)。
2. 若存在 `ELECTRON_SMOKE_PROFILE`(仅自动化验收使用),`app.setPath('userData', profile)`;正常启动不得设置。
3. `app.requestSingleInstanceLock()`;拿不到锁立刻 `app.quit()`,拿到锁后监听 `second-instance` 并聚焦/唤起已有窗口。依据:`src/main/index.ts:176-182`。
4. `app.whenReady()` 之后依次:`logger.setVerbose(config.developerMode)` → `registerIpcHandlers()` → `registerIslandIpc()` → 应用开机自启登录项 → 创建设置窗口(隐藏态可配)→ `installCrashGuard()` → 加载渲染页 → 注册退出回调 → `island.init()` → 创建托盘。依据:`src/main/index.ts:184-262`。
5. 退出时统一资源清理:停止定时器 → `flushConfigSync()`(配置同步落盘)→ 销毁托盘 → 销毁岛 → 销毁全部窗口 → `app.quit()`,并用 `setTimeout(() => app.exit(0), 1500)` 兜底。依据:`src/main/index.ts:291-325`。
**关键纪律**:配置必须在 `app.setPath('userData')` 之后**懒加载**,不得在模块顶层读盘(否则读到错误 profile 的 config.json)。依据:`src/main/config.ts:261-272` 的注释与实现。
### 2.7 窗口参数(像素级基线)
**岛窗口(ClassIsland 主界面)**必须满足:
- `width/height` 使用"固定包围盒 + 窗内形变",**以第 4 部分 4.3 为唯一实现口径**:`winW = 目标屏幕宽(DIP)`,`winH = contentMaxHeight + 2 × safePadding`(`safePadding ≥ 20`,其中包含阴影/光晕余量,参考 ClassHelper 的 `ISLAND_SHADOW_PAD = 10` 双侧口径);窗口在开合、行增删与提醒展开过程中尺寸不变,形变全部发生在渲染层画布内,只有显示器/DPI 变化、行数超出上限、编辑模式进出、偏移/缩放/停靠设置变化时才 `setBounds` 且节流 ≥100ms。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:72-112,480-512`(固定包围盒思想)与第 4 部分 4.3(ClassIsland 全屏宽窗口适配)。
- 窗口标志:`frame:false`、`transparent:true`、`resizable:false`、`movable:false`、`minimizable:false`、`maximizable:false`、`fullscreenable:false`、`skipTaskbar:true`、`hasShadow:false`、`focusable:false`、`alwaysOnTop:true`、`type:'toolbar'`。依据:`src/main/island.ts:480-504`;其中 `focusable` 必须为 `false`(ClassIsland 主窗口不抢焦点,见 04 §4.2)。
- `webPreferences`:`preload` 指向 `dist/preload/island.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`:505-511`。
- 创建后必须:`setAlwaysOnTop(appearance.alwaysOnTop,'screen-saver')`、`setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true})`、`setWindowOpenHandler(()=>({action:'deny'}))`、`will-navigate` 白名单校验、默认 `setIgnoreMouseEvents(true,{forward:true})`、**禁止** `setBackgroundMaterial()`。依据:`:514-527`。
- 定位:按 ClassIsland 的 6 个停靠位(`WindowDockingLocation`:0 左上 / 1 中上 / 2 右上 / 3 左下 / 4 中下 / 5 右下)+ `WindowDockingOffsetX/Y`(默认 0)+ `WindowDockingMonitorIndex`(默认 0);ClassHelper 的 `top-center` 等 6 个枚举与 `marginX/marginY=8` 只作参考,**不得**替代 ClassIsland 的停靠与偏移语义。依据:`repos/ClassIsland/ClassIsland/Models/Settings.cs:1810-1833`。
- **坐标口径(强制)**:所有窗口 bounds、停靠偏移、边距、组件尺寸与间距必须使用 **DIP(CSS px)**;`BrowserWindow` 的 `x/y/width/height`、`screen.getPrimaryDisplay().workArea` 全部按 DIP 传入与读取。**禁止**在业务布局代码里用 `devicePixelRatio` 做坐标换算;只有"截图/像素级视觉验收"时才用 `devicePixelRatio` 把 DIP 换回物理像素。依据:【推断】(与 04 部分统一口径,避免窗口定位与组件布局出现两套坐标系)。
- **非 Windows 平台的命中降级(必须实现)**:`BrowserWindow.setShape()` 仅在 Windows 生效;macOS/Linux 必须降级为"整窗默认 `setIgnoreMouseEvents(true, { forward: true })`,岛可见区域之外不接收鼠标,指针进入岛体时由渲染层上报 `set-interactive` 打开命中",不得为平台差异改变岛的视觉尺寸或停靠位置。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:278-316`(`setShape` 只在 `process.platform === 'win32'` 分支执行)。
**设置窗口**必须采用 **ClassIsland 的尺寸(第 10 部分为唯一口径)**:`width 1240`、`height 550`、`minWidth 560`、`frame:false`(Windows 11 21H2+ 可用 Mica,否则退回纯色背景)、`center:true`、`minHeight 550`,并补上 `preload: dist/preload/index.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`repos/ClassIsland/ClassIsland/Views/SettingsWindowNew.axaml`(第 10 部分 10.1)与 `src/main/index.ts:63-88`(窗口骨架参考)。
### 2.8 主进程核心模块(ClassIsland 服务层在 Electron 下的等价物)
| ClassIsland 概念 | ClassHelper/Electron 实现位置 | 必须实现的职责 |
|---|---|---|
| `AppBase` / `App` 启动编排 | `src/main/index.ts` | 单实例、窗口、托盘、退出清理、崩溃自愈 |
| `SettingsService`(`Settings.json` → `config.json`) | `src/main/config.ts` | `config.json` 为运行期唯一设置源;`Settings.json` 只做 ClassIsland 格式只读导入/导出兼容层(不双向双写);默认值合并、字段归一化、120ms 合并写、退出同步落盘、字段白名单 |
| `ProfileService`(档案) | `src/main/storage.ts` | 档案/课表/时间表读写、临时层清理(对应 `ProfileService.CleanExpiredTempClassPlan`,`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:295-328`) |
| `ComponentsService` | `src/main/storage.ts` + `src/island/*` | 组件方案 JSON 读写、迁移、注册表驱动渲染 |
| `LessonsService`(时间引擎) | `src/main/scheduler.ts` | 每 200ms 推进一次状态机、计算下一时间点/剩余时间、触发上/下课事件 |
| `NotificationHostService` | `src/main/notifications.ts` | 队列、优先级、播放会话、强提醒遮罩 |
| `AutomationService` | `src/main/automation.ts` | 触发器/行动/规则集、信号与 URI |
| `PluginService` | `src/main/plugins.ts` | 插件发现/加载/启用/卸载/依赖排序 |
| `AppLogger` | `src/main/logger.ts` | 控制台 + `app.getPath('logs')/main.log`,单文件 1MB 轮转、保留 `main.log.1` |
| `IpcService` | `src/main/ipc.ts` + `channels.ts` | 通道注册、事件下发、数据校验 |
日志必须照抄 ClassHelper 的轮转与容错口径:`LOG_MAX_BYTES = 1024*1024`、`main.log → main.log.1`、写入失败静默忽略、`app.getPath('logs')` 在 ready 前调用会抛所以路径必须懒解析。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/logger.ts:21-67`。**不得**用 `console.log` 作为唯一排障手段:打包后的 GUI 没有可见控制台。
### 2.9 渲染层挂载顺序(必须逐行对齐)
应用窗口入口 `src/renderer/main.ts` 必须按以下顺序执行,**顺序错一个都会出现白闪或首帧无动画**:
1. `createApp(App)`;
2. 注册用到的 Element Plus 图标(只注册用到的,不全量 293 个);
3. `app.use(createPinia())`;
4. `app.use(router)`;
5. `app.use(ElementPlus, { locale: zhCn })`;
6. `registerSharedUi(app)`(来自 `@classhelper/ui`);
7. 冒烟钩子(若 `window.desktop?.smokeTest`,用 `await import()` 动态加载,禁止静态 import 进主 chunk);
8. `applyMotionTokens()`(把动效令牌写进 `:root`,必须在 `mount` 之前);
9. `await router.isReady()`;
10. `app.mount('#app')`。
依据:`repos/ClassHelper-master/packages/desktop-client/src/renderer/main.ts:17-57`;动效令牌注入理由见 `docs/dev/motion.md:53-59`。
路由必须用 `createWebHashHistory()`,因为生产环境是 `file://`,history 模式会 404。依据:`src/renderer/router/index.ts:5-8,110-113`。路由守卫必须做两件事:未初始化时进 OOBE;开发者页仅当 `developerMode` 为 true 时可进并做运行时兜底(读取 pinia 状态,不每次跨 contextBridge)。依据:`:115-133`。
### 2.10 岛渲染进程的技术约定(必须与 ClassIsland 视觉对齐)
1. **岛内不得引入 Element Plus、不得引入任何图标字体**;只用 Vue + 手写 CSS + 内联 SVG。依据:`src/island/main.ts:6-10`。
2. `island.html` 必须带与主窗口一致的 CSP,且 `html, body { margin:0; padding:0; background:transparent }`。依据:`repos/ClassHelper-master/packages/desktop-client/island.html:6-21`。
3. 岛必须**再注入一次**动效令牌(两个渲染进程不共享 `:root`),漏掉会让 `transition` 简写整条失效。依据:`src/island/main.ts:13-24`、`docs/dev/motion.md:58-59`。
4. 动画时长与曲线**以第 14 部分为准**(ClassIsland 的 Storyboard/Transition 规格);弹簧动画(`SpringValue`:`stiffness=0.10`、`damping=0.68`、`momentumRetention=0.35`、`maxDistanceRatio=0.2`、`maxDt=6`、`snapThreshold=0.05px`、帧单位 `1000/60`、零过冲钳制)只用于第 14 部分明确标注为「spring/跟手」的交互(如拖拽预览、内容宽度跟随),**不得**用它替代第 14 部分给出确定时长的规格动画。依据:`repos/ClassHelper-master/packages/desktop-client/src/island/spring.ts:26-30,41-47,85-114` 与第 14 部分。
5. 圆角实现:**默认必须使用 CSS `border-radius`(等同 Avalonia `CornerRadius`,半径取 `Settings.RadiusX`,默认 8)**,以保证与 ClassIsland 的直角圆弧逐像素一致;ClassHelper 的连续圆角(squircle:超椭圆指数 `4.2`、每角 `14` 段、半径 `min(r,w/2,h/2)`)只能由主题包显式启用(P2),不得作为默认,否则 8px 角的曲率会与 ClassIsland 原版不同。依据:`repos/ClassIsland/ClassIsland/MainWindow.axaml:282` 与 `XamlThemes/FluentTheme/Styles.axaml:48`;对照实现见 `src/island/squircle.ts:21-24,44-81`。
6. 岛组件的几何基线必须集中到 `packages/shared/src/island-layout.ts` 的 `CLASSISLAND_ISLAND_METRICS`:`rowHeight 40`、`horizontalPadding 12`、`radius 8`、`opacity 0.5`、`scale 1`(0.75–2.5)、`secondaryFontSize 14`、`bodyFontSize 16`、`emphasizedFontSize 18`、`largeFontSize 20`、`rowSpacing 5`、`fadedOpacity 0.05`。依据:`repos/ClassHelper-master/packages/shared/src/island-layout.ts:19-36`。
7. 岛形态尺寸增量必须集中常量:`ISLAND_SIZE_DELTA = { expanded:{w:156,h:186}, urgent:{w:172,h:202}, call:{w:188,h:218} }`、`ISLAND_SHADOW_PAD=10`、`ISLAND_SLIVER_WIDTH=6`。依据:`packages/shared/src/constants.ts:336-351`。
8. **参考值(不是本应用默认值)**:ClassHelper 既有「经典岛」外观默认值 `width 268`、`height 44`、`radius 20`、`fontSize 13`、`opacity 1`、`accent #0a84ff`、`style 'black'`、`position 'top-center'`、`marginX/marginY 8`、`idleSliver false`、`followCursorDisplay false`(范围:高度 36–72、宽 220–420、圆角 8–32、透明度 0.4–1、字号 11–20、速度 0.5–2、marginX 0–200、marginY 0–160,依据 `constants.ts:265-302`)。本应用(ClassIsland 复刻)的默认值**必须**按第 3、4、10 部分:行高 40、圆角 8、字号 14/16/18/20、透明度 0.5、停靠位 `WindowDockingLocation=1`、偏移 0,**不得**套用 ClassHelper 经典岛的 268×44 胶囊默认值。
9. 岛内 `:hover` 规则必须包在 `@media (hover: hover) and (pointer: fine)` 内,否则希沃触摸屏会出现"点过之后永远高亮"的幽灵 hover。依据:`docs/dev/motion.md:80-81`。
10. 阴影常驻、只随形态改参数,不得在形态切换时增删 `filter`(会新建合成表面导致闪帧)。依据:`docs/dev/winisland-design-tokens.md:25-26`。
### 2.11 自动更新与打包(工程结构的一部分)
- 更新目录:`<userData>/updates`;下载先写 `.part` 再校验后 `rename`;`policy.json` 记录服务端下发的更新策略。依据:`src/main/auto-update.ts:59-133`。
- 下载超时 10 分钟;文件名必须匹配 `^[A-Za-z0-9._-]+$`(挡住路径穿越);下载完必须校验 SHA-256,服务端未给哈希则**拒绝安装**。依据:`:124-128,320-391`。
- 安装时机三档:`immediate` 立即;`after-school` 不早于 `updateNotBefore`;`idle` 需主窗口隐藏满 5 分钟(`CLIENT_UPDATE_IDLE_MS = 5*60*1000`)。依据:`packages/shared/src/constants.ts:677`、`auto-update.ts:474-506`。
- 安装必须交给独立助手:先生成/校验助手 → 助手就绪 → 主进程退出 → 助手替换文件 → 写 `install-result.json`(失败时主进程下次启动读回并展示 `failed` 状态)。依据:`auto-update.ts:516-573`。
- **安装目录策略:不做 side-by-side 多版本并存**。必须使用固定安装目录(NSIS 装到固定路径);更新助手在替换前把当前安装目录的关键文件(`ClassIsland.exe`、`resources/app.asar` 等)复制到 `updates/rollback/<版本>/`,替换失败或新版启动失败时用该副本回滚并重新拉起旧版。ClassIsland 的 `app-<版本>` + `.current` 多版本方案(`repos/ClassIsland/ClassIsland.Launcher/Program.cs:30-57`、`UpdateService.cs:841-855`)只作为"可回滚"语义参考,**不照搬目录结构**。**【推断】**
- **来源安全**:更新下载默认只接受 `https://`;镜像/自定义源必须由用户显式开启并提示"非官方来源风险",默认不启用。清单必须校验 SHA-256;签名可选(Ed25519,带 `keyId`,支持公钥轮换),详见 12.6。
- 打包产物必须做"凭据门禁":`dist/renderer` 内不得出现任何账号/口令字面量(`scripts/check-bundle-secrets.mjs`),因为 asar 可直接解包。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:13`、`src/preload/index.ts:43-54`。
### 2.12 ClassIsland → ClassHelper 技术映射决策表(必须按此实现)
| ClassIsland(C#/Avalonia) | 本实现(Electron/Vue) | 关键差异与对策 |
|---|---|---|
| Avalonia `Window` + `TransparencyLevelHint=AcrylicBlur` | `BrowserWindow{transparent:true, frame:false}` + CSS 半透明背景 | Electron 下**禁止** `setBackgroundMaterial()`,也**禁止** `backdrop-filter`(透明窗口下 Chromium 采样不可靠,会闪/发灰);毛玻璃效果用**预混合半透明纯色**实现(ClassHelper 的 glass 同样是半透明色、不采样桌面模糊,见 `docs/dev/winisland-design-tokens.md:23`);编辑模式按第 4 部分用全屏半透明纯色遮罩 |
| XAML `Style`/`ResourceDictionary` 主题 | `packages/ui/src/theme.css` + `html.dark` + CSS 变量 | 主题包改为"CSS 变量清单 + 资源目录 + 预览图",见 9 部分 |
| `Storyboard`/`Transition`(如 `BackgroundWidth 300ms cubic-bezier(0.65,0,0.35,1)`) | CSS `transition`/`@keyframes` + `motion.ts` 令牌 | 令牌数值单一来源放 `packages/shared/src/motion.ts`;不得在组件里手写曲线字面量 |
| `DispatcherTimer` 时间引擎 | 主进程 `setInterval` + `setTimeout` 精确对齐 + 系统休眠唤醒修正 | Node 定时器会漂移:必须以"当前时间与下一时间点差值"重排,不得累加计数 |
| `Mutex("Global\\ClassIsland.Lock")` 单实例 | `app.requestSingleInstanceLock()` | 二次启动必须唤起已有窗口(`second-instance`),不得静默退出 |
| `dotnetCampus.Ipc` 命名管道(`ClassIsland.IPC.v2.Server`) | `ipcMain/ipcRenderer` + contextBridge;**可选**本地只读 HTTP API | 不恢复命名管道。外部只读集成如启用,必须:只监听 `127.0.0.1`、端口随机分配、默认关闭、开启后需一次性 token 鉴权、只提供读接口(当前课程/课表/状态),**不得提供任何写操作**。详见 12.4 |
| 插件 `.cipx`(zip + `manifest.yml` + `.dll`) | 同构 zip(`.cipx`)+ `manifest.yml` + ESM 入口 `.mjs` | 插件必须在 utilityProcess 内 `import()`,不得污染主进程;清单统一 camelCase、兼容读取 PascalCase 旧清单;见 12 部分 |
| `Settings.json`(`CommonDirectories.AppRootFolderPath`) | `<userData>/config.json` 为主 + `Settings.json` 导入/导出兼容层 | 运行期只读写 `config.json`;`Settings.json` 只做 ClassIsland 格式的**只读导入**与导出,**不双向双写**;`IsIslandSeperated` 等历史拼写必须保留(见 13 部分) |
| `Profiles/<name>.json` | `<userData>/data/profiles/<name>.json` | 保留 PascalCase 字段名以兼容导入导出 ClassIsland 档案 |
| `Backups/Auto_*.zip` | `<userData>/backups/` | 自动备份 7 天一次、最多 16 份,包内含 `Config/` + `Profiles/` + `Settings.json`(若存在;见 13 部分) |
| 独立 Launcher + 多版本目录 | electron-builder NSIS + 固定安装目录 + 独立更新助手 + 回滚副本 | 不做 side-by-side 多版本;见 2.11 |
| `PackageRoot` 环境变量 | `app.getPath('userData')` + `process.resourcesPath` | 所有路径必须用 Electron API 解析,禁止硬编码 `C:\` |
| `Color`(Avalonia)序列化为 `#AARRGGBB` | 字符串 `#RRGGBB`/`#AARRGGBB` | 解析必须容忍 6 位与 8 位、容忍大小写,写出统一 8 位(AlphaComponentPosition.Trailing) |
### 2.13 第三方库等价映射(.NET/Avalonia → npm,必须按此实现)
ClassIsland 依赖的一批 .NET 库在本技术栈下必须换成下表等价物;不得因为"找不到同名库"就自研底层协议。
| ClassIsland 依赖 | 本实现替代 | 使用约束 |
|---|---|---|
| `System.Text.Json` + `ConfigureFileHelper` | Node `JSON.parse/stringify` + 自研 `storage.ts` | 必须保留"写入前浅拷贝快照、写入中不阻塞 UI、退出前同步落盘"语义(见 2.16) |
| `YamlDotNet`(插件 `manifest.yml`) | `yaml`(npm,服务端同款) | 解析必须 `ignoreUnknownKeys`:未知字段不得导致插件加载失败(对齐 `repos/ClassIsland/ClassIsland/Services/PluginService.cs:68-72` 的 `IgnoreUnmatchedProperties`) |
| `NAudio`/系统音频播放 | 渲染层 `HTMLAudioElement`,主进程只下发"播放/停止/音量" | 音效文件必须走 `file://` 本地资源;音量 0–1,默认 1.0(`Settings.NotificationSoundVolume`) |
| `System.Speech`/`EdgeTtsSharp` TTS | Windows 用 `System.Speech` 等价方案:优先系统 TTS(Windows `SAPI` 通过 PowerShell 启动会污染进程,改为可插拔 TTS 服务,默认关闭),可选在线 TTS 必须由用户显式开启 | 离线时不得阻塞提醒播放,TTS 失败只记日志 |
| `RestSharp`/`HttpClient` | Node 内置 `fetch`(Electron 44/Node 20+) | 必须显式 `AbortSignal.timeout(...)`、`redirect:'error'`(对齐 `auto-update.ts:193-199`) |
| `Downloader`(分片并行下载) | 自研流式下载 + 4 并发分片 | 必须支持 `.part` 临时文件、断点续传可选、进度节流 250ms(对齐 `auto-update.ts:216-219`) |
| `Octokit`(GitHub API) | 不用:更新源改为自建静态 JSON 清单 + 固定 URL 模板 | 若保留 GitHub 通道,必须只读、无 token、失败回退到主通道 |
| `SharpZipLib`(部署包解压) | `adm-zip` 或 Node `zlib` + 自研 zip 读取 | 解压前必须校验路径不越出目标目录(对齐 `UpdateService.cs:733-743` 的 `IsSafePath`) |
| `CommunityToolkit.Mvvm`(`ObservableObject`/`RelayCommand`) | Vue `ref/reactive/computed` + `watch` | 不得把 Vue 响应式代理跨 contextBridge 传递(见 2.15 数据纯净化) |
| `DynamicData`(集合变换) | `computed` + 普通数组操作 | 大数据量列表必须避免在 `computed` 里做 O(n²) 操作 |
| `MoonSharp`(Lua 教程脚本) | 禁用脚本引擎;教程改为声明式 JSON 步骤 | 不得为了教程引入任意代码执行能力(安全边界见 2.17) |
| `Sentry`(崩溃上报) | 默认关闭;开启时只上报匿名崩溃栈,不含课表与个人信息 | 与 ClassIsland 的 `IsReportingEnabled` 默认值保持一致(默认 true,但必须可在设置中关闭) |
| `dotnetCampus.Ipc` | Electron IPC(见 12 部分) | 通道名必须与 `channels.ts` 单一来源 |
| `HotAvalonia`(热重载) | Vite HMR | 仅开发期启用;生产包不得包含 HMR 客户端 |
### 2.14 共享包(`packages/shared`)的工程约定
1. `packages/shared` 必须是**纯 TypeScript**:`type: module`、`main: ./dist/index.js`、`types: ./dist/index.d.ts`、构建用 `tsc -p tsconfig.json`,产物只含 `dist`。依据:`repos/ClassHelper-master/packages/shared/package.json:4-18`。
2. `tsconfig` 的 `lib` 不得包含 `DOM`:共享包会被主进程(Node)与渲染进程(浏览器)同时消费,任何 `document`/`window`/`localStorage` 引用都会让主进程侧编译失败。依据:`repos/ClassHelper-master/packages/shared/src/motion.ts:10-16` 的显式约束与 `docs/dev/motion.md:30-37`。
3. 入口必须单一:`packages/shared/src/index.ts` 只做 `export *`/`export type *` 转发;新增模块必须同步加一行导出。依据:`packages/shared/src/index.ts:1-16`。
4. 命名约定:类型用 `PascalCase`;常量全大写下划线;函数用 `camelCase`;持久化 JSON 字段必须与 ClassIsland 原名一致(如 `TimeLayouts`、`ClassPlans`、`WeekCountDivTotal`),以便直接导入导出 ClassIsland 档案;IPC 内部 DTO 用 `camelCase` 并在 `types.ts` 注明与持久化字段的映射。**【推断】**(ClassIsland 的 JSON 为 PascalCase,见 `repos/ClassIsland/examples/example.json:4-19`。)
5. 全部"归一化"函数必须放在 shared:例如 `normalizeIslandLayouts(input)` 负责把任意 IPC/导入数据夹紧到合法区间(最多 12 行、200 节点、6 层、颜色必须 `#rrggbb`、未知 kind 丢弃、重复 id 重命名)。依据:`packages/shared/src/island-layout.ts:256-403`。
6. `motion.ts` 必须保持"纯常量 + 纯函数":只导出缓动曲线、弹簧参数、时长令牌、`motionCssVars()`;DOM 注入放各端 `composables/motion.ts`。依据:`packages/shared/src/motion.ts:136-241`。
### 2.15 共享 UI 包(`packages/ui`)与样式令牌工程
1. `packages/ui` 必须以源码形式被消费:`exports` 直接指向 `./src/index.ts`、`./src/theme.css`、`./src/tokens.css`,不产出 dist;`peerDependencies` 只允许 `vue` 与 `element-plus`。依据:`repos/ClassHelper-master/packages/ui/package.json`。
2. `tokens.css` 只放"基础令牌"(品牌色、圆角、间距、阴影),且必须提供 `html.dark` 覆盖;不得在 `html.dark` 里改圆角与动效令牌(几何必须两套主题一致,否则切换时布局跳动)。依据:`packages/ui/src/tokens.css:1-21` 与 `docs/dev/motion.md:123-130`。
3. 基础令牌必须至少包含:`--ch-ui-primary:#5d6bfa`、`--ch-ui-bg:#f7f8fa`、`--ch-ui-surface:#fff`、`--ch-ui-surface-alt:#f3f5f9`、`--ch-ui-border:#e6e9ef`、`--ch-ui-text:#242936`、`--ch-ui-muted:#737c8e`、`--ch-ui-radius:8px`、`--ch-ui-gap:16px`、`--ch-ui-shadow:0 8px 30px rgb(30 40 70 / 8%)`;深色覆盖 `bg #14161a`、`surface #1d2027`、`surface-alt #252934`、`border #343946`、`text #e9edf5`、`muted #a2abbd`、`shadow 0 8px 30px rgb(0 0 0 / 25%)`。依据:`packages/ui/src/tokens.css:1-21`。
4. 语义色令牌必须与总纲第 3 部分一致:`--ch-surface`、`--ch-surface-alt`、`--ch-surface-sunken`、`--ch-surface-hover`、`--ch-text`、`--ch-text-secondary`、`--ch-text-muted`、`--ch-text-placeholder`。组件里一律引用变量,禁止写死 `#fff`/`rgba(0,0,0,...)`(固定深色区域除外:登录页、启动过渡页、岛本身、全屏作业看板、accent 渐变上的白字)。依据:`docs/dev/motion.md:107-138`。
5. 改令牌时必须"只改值",禁止对样式表跑批量替换:把 `#303133` 全量替换成 `var(--ch-text)` 会把令牌定义本身改成自引用,导致整条自定义属性失效。依据:`docs/dev/motion.md:128-130`。
6. Element Plus 的全站过渡必须改写 `--el-transition-*` 变量(300/200ms → 200/140ms,缓动换成 `EASE_OUT`),不得改组件源码。依据:`docs/dev/motion.md:65-66`。
### 2.16 主进程并发与 I/O 纪律(直接决定"卡不卡")
1. **主进程是 Electron 的消息泵**:任何同步文件 I/O 都会同时卡住窗口动画。必须遵守:
- 配置写入必须"内存缓存 + 异步合并写 + 退出同步兜底":写盘合并窗口 `CONFIG_WRITE_DEBOUNCE_MS = 120`,写入串行化(`writing` 标志),写入期间的改动进 `pendingWrite` 再补写一次;`flushConfigSync()` 在 `before-quit` 调用。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/config.ts:235-346`。
- 读取配置必须只读一次内存缓存,不得每次 `getConfig()` 都 `readFileSync + JSON.parse`(ClassHelper 明确记录过这条导致侧栏展开卡顿)。依据:`config.ts:235-250` 的注释。
2. **禁止在主进程做 CPU 重活**:Excel 解析、主题包解压、插件加载、备份打包必须放 `utilityProcess` 或 `node:worker_threads`;主进程只负责调度与 IPC 转发。
3. **定时器纪律**:时间引擎的 tick 周期固定 200ms(ClassIsland 主计时器同量级【推断】),每次 tick 只用"当前时间 - 时间点边界"做判断,禁止累加 count 推断时间;系统休眠唤醒后必须立即重算一次(监听 `powerMonitor` 的 `resume`)。
4. **写入磁盘必须原子**:所有 JSON 先写 `<file>.tmp`,`fsync` 后 `rename` 覆盖;备份进程写 `<Auto_*.zip>.tmp` 再改名。依据:`repos/ClassIsland/ClassIsland/Shared/Helpers/ConfigureFileHelper.cs:182-188`(`FileStream.Flush(true)` 后关闭)。
5. **数据目录结构固定**:`<userData>/` 下只允许出现 `config.json`、`data/`、`logs/`、`updates/`、`backups/`、`themes/`、`plugins/`、`cache/` 八个顶层条目;不得把临时文件写到安装目录(安装目录可能只读)。
6. **退出清理必须幂等**:托盘退出、`before-quit`、`will-quit` 都可能触发同一次清理,清理函数必须可重入(ClassHelper 用 `isQuitting` 标志 + 各资源自带 `isDestroyed()` 判断,见 `src/main/index.ts:287-341`)。
### 2.17 安全基线(不得为了"方便"削弱任何一条)
1. 渲染进程必须启用 `contextIsolation: true`、`nodeIntegration: false`、`sandbox: true`、`webSecurity: true`;两个窗口都要。依据:`src/main/index.ts:79-87`、`src/main/island.ts:505-511`。
2. preload **只**通过 `contextBridge.exposeInMainWorld` 暴露显式白名单方法,绝不暴露 `ipcRenderer` 本体;不得暴露 `require`、`process`、`Buffer`。依据:`src/preload/index.ts:17-101`、`src/preload/island.ts:10-123`。
3. 通道名必须集中在**零依赖叶子模块** `src/main/channels.ts`:preload 在 `sandbox: true` 下不能 `require('node:fs')` 等内建模块,一旦 import 了主进程模块,esbuild 会把整条依赖链打进 preload 导致"加载即失败、`window.desktop === undefined`"。依据:`src/main/channels.ts:1-28` 的注释与实现。
4. 跨桥接只能传**纯数据**:传 Vue `ref`/`reactive`(Proxy)会在进入 contextBridge 之前抛 `An object could not be cloned.`。所有调用点必须展开成字面量,且 preload 返回的订阅函数必须可取消(`onXxx` 返回取消函数或由主进程移除监听)。依据:`src/types/desktop.d.ts:144-152`、`src/preload/index.ts:32-36`。
5. 导航必须白名单精确比对:开发态比对 `origin`,生产态比对"去 hash/search 后的精确 `file://` URL",不得用 `startsWith`。窗口必须 `setWindowOpenHandler(() => ({action:'deny'}))`,外链一律 `shell.openExternal`。依据:`src/main/index.ts:42-61,90-100`、`src/main/island.ts:32-50,516-522`。
6. 两个 HTML 入口都必须带 CSP:`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'`。依据:`repos/ClassHelper-master/packages/desktop-client/index.html:20-23`、`island.html:6-10`。
7. 敏感数据(登录令牌、管理凭据)必须用 `safeStorage.encryptString` 加密后 base64 落盘;`safeStorage` 不可用时必须降级明文并写 warn 日志(不得静默)。依据:`src/main/config.ts:169-196`。
8. 更新域名/下载 URL 必须来自配置白名单,渲染进程不得指定任意 URL 下载或执行;安装包路径必须校验"位于 `updates` 目录内且 realpath 一致"。依据:`auto-update.ts:477-490`。
9. 插件代码必须在隔离进程运行、只能访问 manifest 声明的权限(文件读写范围、网络域名、能否弹提醒、能否执行外部程序);未声明即拒绝。见 12 部分。
10. 发布前必须跑 `check-bundle-secrets`:`dist/renderer` 与 `dist/main` 中不得出现账号/口令/私钥字面量。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:10-13`。
### 2.18 环境变量、命令行参数与调试开关
| 名称 | 作用 | 约束 |
|---|---|---|
| `ELECTRON_SMOKE_PROFILE` | 把 `userData` 指到隔离目录 | 仅自动化验收使用;正常启动必须不设置 |
| `ELECTRON_SMOKE_TEST=1` | 冒烟模式标记 | 必须只影响"不弹窗、不写登录项、不自动重启"等行为,不得影响业务逻辑 |
| `VITE_DEV_SERVER_URL` | 开发态渲染地址 | 生产构建不得定义;主进程读到它才 `loadURL` |
| `--hidden` | 开机自启静默启动 | 因 Electron 44 在 Windows 上 `setLoginItemSettings` 没有 `openAsHidden`,必须自己带参数(依据 `src/main/index.ts:22-27,147-173`) |
| `--safe` | 安全模式 | 禁用全部第三方插件与主题、跳过自动化、禁用更新安装,仅加载内置能力 |
| `--recovery` | 恢复模式 | 允许从备份还原配置/档案,禁用写操作直到用户确认 |
| `--diagnostic` | 诊断模式 | 日志提到 debug、结束时在桌面生成诊断包(配置、日志、版本、插件列表,**不含**个人信息) |
| `--uri <classisland://...>` | 单实例 URI 导航 | 第二次启动时把 URI 转发给已运行实例后退出(参考 `repos/ClassIsland/ClassIsland/Program.cs:43-68,141-159`) |
| `--importV1/--importV2/<path>` | 导入旧版配置 | 导入前必须备份;失败可回滚 |
环境变量与参数必须在 `app.whenReady()` 之前解析完毕,之后不得再读 `process.argv` 做行为分支(避免窗口创建后又改变模式导致状态不一致)。
### 2.19 性能预算(超预算必须优化,不得降级功能)
| 指标 | 预算 | 测量方法 |
|---|---|---|
| 冷启动到岛首帧 | ≤ 2.5s(SSD 教室机) | 主进程打点:`app.ready` → 岛渲染进程 `did-finish-load` → 首帧 `requestAnimationFrame` 回传 |
| 常驻内存(主进程 + 岛渲染 + 设置窗口) | ≤ 350MB(对齐 ClassHelper 文档 250–350MB,见 `docs/get-started/requirements.md`) | 任务管理器观察 30 分钟 |
| 空闲 CPU | ≤ 1%(岛常驻,1 秒一次时钟 tick) | 任务管理器观察 10 分钟 |
| 岛动画帧率 | 60fps(掉帧率 <1%) | DevTools Performance 录制形变过程 |
| 渲染进程包体 | 应用窗口 ≤ 3.5MB(gzip 前)、岛 ≤ 800KB | `dist/renderer/assets` 体积 |
| 设置页首屏 | ≤ 400ms(路由切换) | Performance 面板 |
| 配置写入 | 单次 ≤ 5ms,且不阻塞主线程 > 1 帧 | 主进程日志打点 |
任何一项超标都必须优化实现(例如把计算移到 `computed` 缓存、拆 chunk、减少重渲染),**不得**通过删除动画、降低帧率、去掉 blur 等方式"达标"。
### 2.20 本地化与文案工程
1. 默认语言 zh-CN;文案必须集中在 `packages/shared/src/i18n/`(或 `locales/zh-CN.json`),组件内禁止硬编码中文字符串作为唯一来源(允许以 key 调用 `t('...')`)。
2. 日期/星期/时间格式必须走 `Intl.DateTimeFormat`(`zh-CN`),星期文本必须为"周一…周日";`Settings.ShowDate` 默认 true(`repos/ClassIsland/ClassIsland/Models/Settings.cs:44`)。
3. 数字与百分比必须用 `Intl.NumberFormat`;倒计时显示格式为 `mm:ss`,超过 1 小时显示 `hh:mm:ss`。
4. 文案长度必须按"最长的中文条目"预留宽度,禁止用英文长度估算导致换行溢出。
5. 所有错误提示必须给出"发生了什么 + 用户可以做什么",不得只抛出异常类名;配置加载失败必须落到"使用默认配置并提示可恢复备份"。
### 2.21 验收标准(第 2 部分)
实现者必须逐条自查,全部通过才算完成本节:
1. `pnpm install` 后可成功执行 `pnpm -r typecheck` 与 `pnpm build:desktop`,产物包含 `dist/main/index.js`、`dist/preload/index.js`、`dist/preload/island.js`、`dist/renderer/index.html`、`dist/renderer/island.html`。
2. 依赖版本与 2.2 表逐项一致(用 `pnpm list -r --depth 0` 核对),Node 版本 `>=20.19.0`,`pnpm --version` 输出 `11.8.0`。
3. 仓库为 pnpm workspace,`packages/*` 至少含 `shared`、`ui`、`desktop-client` 三个包,根 `package.json` 是唯一手写版本号。
4. 主进程启动时序与 2.6 一致;`config.json` 在 `app.setPath('userData')` 之后才被首次读取(代码审查可验证)。
5. 岛窗口参数与 2.7 列表逐项一致(逐行对照 `new BrowserWindow` 选项),且代码中不出现 `setBackgroundMaterial`。
6. 岛渲染进程产物中不含 Element Plus 与图标字体(检查 `dist/renderer/assets` 体积与 import 图)。
7. `applyMotionTokens()` 在应用窗口与岛入口都被调用,且都在 `app.mount()` 之前。
8. 打包产物通过 `electron-builder --win nsis portable` 生成 setup 与 portable 两个 exe,语言包仅含 `zh-CN.pak`,安装包内不含任何凭据字符串。
9. 单实例:第二次启动不产生第二套窗口/托盘,而是唤起已有实例。
10. 主进程日志写入 `app.getPath('logs')/main.log`,超过 1MB 轮转为 `main.log.1`,且退出前配置已同步落盘(`flushConfigSync` 被调用)。
**补充验收项(工程结构层面)**:
11. 仓库内不得出现硬编码绝对路径(`C:\`、`D:\`、`/home/`),所有路径必须经 `app.getPath`、`path.join`、`process.resourcesPath` 解析;代码审查用 `grep -rn "C:\\\\" packages/` 应为零命中。
12. 图标资源必须"源文件 + 派生文件"分离:设计导出的 `build/classisland.ico`/`.png` 为唯一源,其余尺寸由 `pnpm icons` 派生;`electron-builder.yml` 必须显式指定 `icon: build/classisland.ico`,不得依赖 electron-builder 的默认约定。依据:`repos/ClassHelper-master/packages/desktop-client/electron-builder.yml:48-52`。
13. 托盘必须存在且行为固定:左键/双击显示主窗口;右键菜单至少含「打开设置」「编辑岛」「隐藏/显示岛」「重启应用」「退出」;托盘图标缺失时必须用空图标兜底并记 warn,不得崩溃。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/tray.ts:31-54`。
14. 开机自启必须"配置值"与"系统登录项"双向一致:设置页读的是 `app.getLoginItemSettings().openAtLogin`,不是配置文件里的值;写入失败时配置仍保存并提示"下次启动重试"。依据:`src/main/ipc.ts:66-99`。
15. 所有代码必须通过 Prettier 检查(LF 换行、单引号),且不得为了过检查对历史文件做批量格式化;提交前 `pnpm format:check` 必须通过。
+356
View File
@@ -0,0 +1,356 @@
## 3. 设计系统 Token 总表(颜色 / 字体 / 圆角 / 间距 / 阴影 / 层级 / DPI / 深浅色)
本节是整份提示词的「地基」。实现者必须先落地本节的 token(CSS 自定义属性 + TypeScript 常量),再开始写任何界面。任何界面代码**不得**出现裸色值、裸字号、裸圆角,一律引用本节 token。
实现技术栈为 ClassHelper 的 Electron + Vue 3 栈,宿主已有 `packages/ui/src/tokens.css` 的 `--ch-ui-*` 与桌面端 `renderer/styles/index.css` 的 `--ch-*`。**保留并复用**这些既有 token 作为「应用外壳层」,新增 `--ci-*` 命名空间承载 ClassIsland 主界面(岛)层 token。两层必须分开:`--ch-*` 管设置中心/管理页,`--ci-*` 管岛与课程信息。
### 3.1 颜色 Token
#### 3.1.1 强调色与派生色
| Token 名 | 默认值 | 用途 | 建议 CSS 变量 | 源码证据 |
|---|---|---|---|---|
| `PrimaryColor`(用户强调色) | `#00BFFF`(Colors.DeepSkyBlue) | 设置项「强调色」的默认值;`ColorSource=0` 时作为全站 accent | `--ci-accent` | `ClassIsland/Models/Settings.cs:35`、`Settings.cs:992` |
| `SecondaryColor` | `#7FFFD4`(Colors.Aquamarine) | 备用强调色(组件/图表次级色) | `--ci-accent-2` | `Settings.cs:36`、`Settings.cs:1003` |
| FluentAvalonia 初始 accent | `DodgerBlue`=`#1E90FF` | 主题服务尚未介入时的启动色 | `--ci-accent-fallback` | `ClassIsland/App.axaml:17` |
| `SelectedPlatte` 兜底 | `DodgerBlue`=`#1E90FF` | 壁纸调色板取不到颜色时的兜底 | `--ci-accent-palette-fallback` | `Settings.cs:1036-1039`、`Settings.cs:80` |
| `AccentFillColorDefaultBrush` | 由 accent 派生(Fluent 资源) | 提醒遮罩填充、教程高亮框、进度条强调 | `--ci-accent-fill` | `ClassIsland.Core/StylesBase.axaml:90-93`、`XamlThemes/FluentTheme/Styles.axaml:180` |
| `AccentFillColorTertiaryBrush` | accent 低透明度变体 | 课程列表展开态底色 | `--ci-accent-fill-tertiary` | `ClassIsland.Core/Controls/LessonsControls/LessonControlExpanded.axaml:182` |
| `AccentTextFillColorPrimaryBrush` | accent 文字色 | 拖拽高亮描边、TeachingTip 指示箭头 | `--ci-accent-text` | `EditableComponentsListBox.axaml:310`、`MyTeachingTipStyles.axaml:213` |
| `TextOnAccentFillColorPrimaryBrush` | 白/黑(随 accent 对比度) | 遮罩上的文字 | `--ci-text-on-accent` | `FluentTheme/Styles.axaml:162,182`;`ClassicTheme/Styles.axaml:142` |
| `CustomizedAccentBarBackground1Brush` | 亮 `#FFDDE6F0` / 暗 `#26374C`;运行时被覆盖为 accent 的 10% 透明 | 「强调按钮」淡底、`Button.foreground-accent` | `--ci-accent-soft` | `Themes/LightTheme.axaml:18`、`Themes/DarkTheme.axaml:18`、`Services/ThemeService.cs:66-74` |
运行时强调色决策必须严格按以下分支实现(源码见 `ClassIsland/MainWindow.axaml.cs:743-764`):
1. `ColorSource == 0`(自定义)→ 使用 `PrimaryColor`;
2. `ColorSource == 1`(壁纸主题色)或 `3`(屏幕主题色)→ 使用 `SelectedPlatte`(壁纸调色板第 `SelectedPlatteIndex` 项);
3. `ColorSource == 2`(跟随系统)→ 传 `null`,交给系统强调色;
4. 其余 → 兜底 `DodgerBlue`。
`--ci-accent-soft` 必须以 `color-mix(in srgb, var(--ci-accent) 10%, transparent)` 实时推导,不得写死。ClassHelper 已有等价写法可复用:`packages/ui/src/theme.css:4-9` 用 `color-mix()` 派生 Element Plus 的 `--el-color-primary-light-*`。
#### 3.1.2 前景文字阶梯(前景色 5 级)
主界面自定义前景色时,前景色不是 1 个值,而是**由同一个基色派生出 5 个 alpha 版本**,必须按下表实现(源码:`ClassIsland.Core/Helpers/UI/ControlColorHelper.cs:20-52`):
| 层级 | 资源键 | alpha | 用途 | 建议 CSS 变量 |
|---|---|---|---|---|
| 1 | `TextFillColorPrimaryBrush` | 255(100%) | 主要文字、标题 | `--ci-text-1` |
| 2 | `TextFillColorInformativeSecondaryBrush` | 218(85.5%) | 次要说明(本项目覆盖值) | `--ci-text-2` |
| 3 | `TextFillColorSecondaryBrush` | 197(77.3%) | 辅助文字 | `--ci-text-3` |
| 4 | `TextFillColorTertiaryBrush` | 135(52.9%) | 装饰/弱提示 | `--ci-text-4` |
| 5 | `TextFillColorDisabledBrush` | 93(36.5%) | 禁用态 | `--ci-text-5` |
实现规则:
- 未启用自定义前景色时,上述 5 个 token 使用 Fluent 默认值:亮色 `#E4000000` / `#9E000000` / `#72000000` / `#5C000000`,深色 `#FFFFFFFF` 及对应 alpha 版本【推断:FluentAvalonia 资源值不在本仓库,按 Fluent 2 规范给出】。
- 启用自定义前景色时,5 个 token 全部按上表 alpha 从 `CustomForegroundColor` 派生,且**必须同时**重设 `TemplatedControl.Foreground` 与 `TextElement.Foreground`,否则模板内文字会漏改(`ControlColorHelper.cs:34-40`)。
- 关闭自定义前景色时,必须 `Remove` 这 5 个局部资源并 `SetValue(..., AvaloniaProperty.UnsetValue)` 回落(`ControlColorHelper.cs:44-50`)——映射到 CSS 即删除元素上的内联 `--ci-text-*`,回落 `:root`。
- 两个默认覆盖值:亮色 `TextFillColorInformativeSecondaryBrush = #c0000000`(75% 黑),深色 `#daffffff`(85.5% 白)(`Themes/LightTheme.axaml:20`、`Themes/DarkTheme.axaml:20`)。
- 语义层级类必须映射为 CSS 工具类:`.l-primary|.l1 → --ci-text-1`、`.l-secondary|.l2 → --ci-text-2`、`.l-instructive|.l3 → --ci-text-3`、`.l-decoration|.l4 → --ci-text-4`、`.l-inactive|.l5 → --ci-text-5`(`ClassIsland.Core/Themes/Layers.axaml:10-43`)。
#### 3.1.3 背景与表面色
| Token 名 | 亮色 | 深色 | 用途 | 建议 CSS 变量 | 源码证据 |
|---|---|---|---|---|---|
| `SolidBackgroundFillColorBaseBrush` | `#F3F3F3`【推断】 | `#202020`【推断】 | 经典主题岛背景、编辑模式窗口底、抽屉内容底、主题卡渐变底 | `--ci-bg-base` | `ClassicTheme/Styles.axaml:214`;`DrawerHost.axaml:29`;`MainWindow.axaml:188` |
| `SolidBackgroundFillColorSecondaryBrush` | `#EEEEEE`【推断】 | `#1C1C1C`【推断】 | Fluent 主题岛背景 | `--ci-bg-secondary` | `FluentTheme/Styles.axaml:45` |
| `SolidBackgroundFillColorTertiaryBrush` | `#F9F9F9`【推断】 | `#282828`【推断】 | 弹出层底(`.popup-bg`) | `--ci-bg-tertiary` | `StylesBase.axaml:291` |
| `ControlFillColorSecondaryBrush` | 半透明白/黑 | 同左 | 主题卡底色 | `--ci-fill-secondary` | `ThemesSettingsPage.axaml:149` |
| `SmokeFillColorDefaultBrush` | 半透明遮罩 | 同左 | 编辑模式窗口背景、抽屉忽略层 | `--ci-smoke` | `MainWindow.axaml:178`;`DrawerHost.axaml:20` |
| `Layer` 应用底色 | `#F3F3F3` | `#161719` | 设置中心/管理页页面底(沿用 ClassHelper) | `--ch-bg` | `ClassHelper/renderer/styles/index.css:27,114` |
| `Layer` 卡片底 | `rgba(255,255,255,.7)` | `rgba(32,33,36,.72)` | 应用外壳卡片 | `--ch-layer` | 同上 `:28,115` |
| `CustomBackgroundColor` | `#000000`(默认) | 同左 | 岛自定义背景色默认值 | `--ci-island-bg-custom` | `Settings.cs:34`、`Settings.cs:1129` |
主界面行的背景最终呈现必须叠加两个不透明度:
- 自定义背景色本身的不透明度:`--ci-island-bg-opacity`,默认 `0.5`(`Settings.cs:64`、`:1141`;Fluent 用 `MainWindowStylesAssist.BackgroundOpacity` 绑定到 `Border.Opacity`,见 `FluentTheme/Styles.axaml:44`);
- 整行不透明度:`MainWindowLineSettings.Opacity`,默认 `1.0`(`ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:42`;`Controls/MainWindowLine.axaml:51`)。
- 鼠标移出淡化态:整行 `opacity = 0.05`(`Controls/MainWindowLine.axaml:73-75`)。
#### 3.1.4 描边与分隔线
| Token 名 | 值 | 用途 | 建议 CSS 变量 | 源码证据 |
|---|---|---|---|---|
| 岛描边 | 1px `ControlElevationBorderBrush` | 经典/Fluent 行背景描边 | `--ci-stroke-island` / 宽度 `--ci-stroke-island-w:1px` | `FluentTheme/Styles.axaml:46-47`;`ClassicTheme/Styles.axaml:61-65` |
| `ControlStrokeColorDefaultBrush` | 亮色半透明黑 | 编辑模式选中框描边 | `--ci-stroke-control` | `MainWindow.axaml:357` |
| `DividerStrokeColorDefaultBrush` | 亮色半透明黑 | 操作条分隔线(1px,长 25px) | `--ci-stroke-divider` | `MainWindow.axaml:610-612` |
| `SurfaceStrokeColorFlyoutBrush` | 半透明 | 弹窗描边 | `--ci-stroke-flyout` | `StylesBase.axaml:292` |
| `ControlStrongFillColorDefaultBrush` | 中灰 | OOBE 方块、Workflow 进度条底、Shimmer 底 | `--ci-fill-strong` | `OobeIntroAnimationControl.axaml:34`;`WorkflowProgressIndicatorItemControl.axaml:26`;`Shimmer.axaml:11` |
| 主题卡描边 | 1px `ControlElevationBorderBrush` | 主题网格卡片 | `--ci-stroke-card` | `ThemesSettingsPage.axaml:150-151` |
| 拖拽高亮描边 | 1px accent | 组件拖拽占位 | `--ci-stroke-drag` | `EditableComponentsListBox.axaml:310-311` |
| 教程高亮描边 | 2.5px(外框)/ 3px(四角) | 教程 spotlight | `--ci-stroke-tutorial` | `TutorialHighlightControl.axaml:96-118` |
#### 3.1.5 状态色
天气与空气质量状态色(浅色/深色两套,必须都实现):
| 语义 | 亮色 | 深色 | 建议 CSS 变量 | 源码证据 |
|---|---|---|---|---|
| AQI 1 优 | `ForestGreen`=`#228B22` | `MediumSeaGreen`=`#3CB371` | `--ci-aqi-1` | `LightTheme.axaml:10`;`DarkTheme.axaml:10` |
| AQI 2 良 | `#CCCC00` | `Yellow`=`#FFFF00` | `--ci-aqi-2` | `:11` |
| AQI 3 轻度 | `DarkOrange`=`#FF8C00` | `Orange`=`#FFA500` | `--ci-aqi-3` | `:12` |
| AQI 4 中度 | `IndianRed`=`#CD5C5C` | 同亮色 | `--ci-aqi-4` | `:13` |
| AQI 5 重度 | `DarkRed`=`#8B0000` | 同亮色 | `--ci-aqi-5` | `:14` |
| AQI 6 严重 | `Purple`=`#800080` | `MediumPurple`=`#9370DB` | `--ci-aqi-6` | `:15` |
| 雨天图标色 | `Blue`=`#0000FF` | `DeepSkyBlue`=`#00BFFF` | `--ci-weather-rain` | `LightTheme.axaml:3`;`DarkTheme.axaml:3` |
| 上课发光 | `DropShadowEffect` blur 6(亮)/2(暗),颜色 `#FFFF00` | 同左 | `--ci-glow-class` | `LightTheme.axaml:4-7`;`DarkTheme.axaml:4-7` |
| 危险/删除按钮 | `IndianRed`=`#CD5C5C` | 同左 | `--ci-danger` | `Themes/Generic.xaml:225-226,275-276`;`MainWindow.axaml:435` |
| 警告/错误图标 | `SystemFillColorCriticalBrush` / `SystemFillColorCautionBackgroundBrush` | 同左 | `--ci-warning-*` | `StylesBase.axaml:209,217`;`ThemesSettingsPage.axaml:343` |
#### 3.1.6 阴影与遮罩色(必须逐值实现)
| 用途 | 值 | 建议 CSS 变量 | 源码证据 |
|---|---|---|---|
| Fluent 岛投影 | `0 4 8 2 #48000000` | `--ci-shadow-island: 0 4px 8px 2px #48000000` | `FluentTheme/Styles.axaml:49` |
| 提醒遮罩时岛投影 | `0 8 32 0 #66000000` | `--ci-shadow-mask` | `FluentTheme/Styles.axaml:324,327` |
| 竖抽屉/遮罩卡投影 | `0 8 32 0 #66000000` | `--ci-shadow-drawer` | `VerticalDrawer.axaml:45` |
| 横抽屉内浮层投影 | `0 0 8 #33000000` | `--ci-shadow-drawer-x` | `DrawerHost.axaml:33` |
| 经典主题提醒时行投影 | `DropShadowEffect` BlurRadius=8, Opacity=1, Offset=0 | `--ci-shadow-classic-mask` | `ClassicTheme/Styles.axaml:283-287` |
| 时间线装饰器阴影 | `DropShadowEffect` opacity 0.4, blur 8 | `--ci-shadow-adorn` | `Themes/Generic.xaml:215-219` |
| 弹窗底渐变遮罩 | `#00ffffff`→(0.65)`#00ffffff`→(1)`#ffffffff` | `--ci-mask-fade` | `ThemesSettingsPage.axaml:162-169` |
| 教程聚光遮罩 | `#99000000`(60% 黑) | `--ci-scrim-tutorial` | `TutorialSpotlightControl.axaml:45-61` |
| 编辑模式调试叠加 | `#77222222` | `--ci-debug-bg` | `MainWindow.axaml:645` |
| 启动闪屏底 | `#88111111` + 1px `ControlStrokeColorOnAccentDefaultBrush` | `--ci-splash-bg` | `SplashWindow.axaml:24-28` |
| 主题卡渐变底 | 径向 `#ff0c0e1f`→`#ff0d1111`(关于页) | `--ci-brand-gradient-a/b` | `AboutSettingsPage.axaml:153-154` |
| 关于页正文底 | `#0d1111` | `--ci-brand-dark` | `AboutSettingsPage.axaml:131` |
| 通知涟漪默认填充 | `AccentFillColorDefaultBrush`(可被 `NotificationContent.Color` 覆盖) | `--ci-ripple` | `RippleEffect.axaml.cs:61`;`MainWindowLine.cs:750` |
| 通知涟漪描边宽 | 200(Ellipse StrokeThickness) | `--ci-ripple-stroke:200` | `RippleEffect.axaml:15` |
### 3.2 字体 Token
#### 3.2.1 字体族
| Token | 值 | 用途 | 建议 CSS 变量/写法 |
|---|---|---|---|
| `AppFont`(全局默认) | `HarmonyOS Sans SC, Microsoft YaHei UI, Noto Sans CJK SC, Noto Sans SC, PingFang SC, Source Han Sans SC, SimHei, WenQuanYi Micro Hei, sans-serif, FluentSystemIcons-Resizable` | 全应用默认字体 | `--ci-font: 'HarmonyOS Sans SC', 'Microsoft YaHei UI', 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Source Han Sans SC', SimHei, sans-serif` |
| `MainWindowFont`(岛字体,用户可改) | 默认等于 `DefaultFontFamilyKey`:`avares://ClassIsland/Assets/Fonts/#HarmonyOS Sans SC, ...FluentSystemIcons-Resizable` | 只作用于岛的 `ZoomBorder` 文本 | `--ci-font-island`(元素级覆盖) |
| `HarmonyOsSans`(旧键) | `HarmonyOS Sans SC, Microsoft YaHei UI, SimHei` | 旧样式/右键菜单 | 兼容别名 |
| `BackendFontFamily` | `Microsoft YaHei UI` | 后端渲染/图表兜底 | `--ci-font-backend` |
| 图标字体 1 | `FluentSystemIcons-Resizable`(`avares://ClassIsland.Core/Assets/Fonts/#FluentSystemIcons-Resizable`) | 全局 Fluent 图标 | `--ci-font-icon-fluent` |
| 图标字体 2 | `lucide`(`#lucide`) | Lucide 天气图标等 | `--ci-font-icon-lucide` |
| 图标字体 3 | `SF-Symbols`(`#SF-Symbols`) | SFSymbols 天气图标 | `--ci-font-icon-sf` |
证据:`ClassIsland/Assets/Fonts/Fonts.axaml:3`、`Assets/Fonts/Fonts.xaml:3`、`MainWindow.axaml.cs:185-189`、`App.axaml:48`、`ClassIsland.Core/AppBase.cs:142-152`、`ClassIsland.Core/ThemeBase.axaml:46-47`。
实现要求:
- 字体栈**必须按上表顺序**声明,任何一环缺失即回落下一位;不得只写 `HarmonyOS Sans SC`。
- 字体文件必须以本地资源打包,**禁止**运行时从网络加载字体(离线教室环境)。
- 图标字体与正文字体必须拆成两个 token,图标组件禁止继承正文字体族。
#### 3.2.2 字重
字重设置以整数保存,映射表必须逐值实现(`ClassIsland.Core/Converters/IntToFontWeightConverter.cs:16-28`):
| 数值 | 字重名 | CSS |
|---|---|---|
| 100 | Thin | 100 |
| 200 | ExtraLight | 200 |
| 300 | Light | 300 |
| 400 | Normal | 400 |
| 500 | Medium(**默认**) | 500 |
| 600 | SemiBold | 600 |
| 700 | Bold | 700 |
| 800 | ExtraBold | 800 |
| 900 | Black | 900 |
| 其他 | Normal | 400 |
默认字重来自 `Settings.cs:101`(`(int)FontWeight.Medium` = 500)。岛内绝大多数文字必须用 500(Medium),标题可用 700(Bold),说明文字用 400(Regular)。
4 档核心字号在设置页的输入范围均为 **1–40 px**(`AppearanceSettingsPage.axaml:482-533`,`NumberBox` 的 `Minimum=1`、`Maximum=40`)。实现时必须允许 1–40 的整数/小数输入,并在预览区用四行示例文字实时展示 4 档效果(源码「字号预览」tab:`AppearanceSettingsPage.axaml:570-591`)。
#### 3.2.3 字号阶梯(核心 4 档 + 扩展档)
核心 4 档是**用户可调**的「主界面字号」,默认值与用途如下(`Settings.cs:197-200`;`ClassIsland.Core/ThemeBase.axaml:32-35`;`Settings.cs:1209-1251`):
| Token | 默认 px | 用途 | 建议 CSS 变量 | 证据 |
|---|---|---|---|---|
| `MainWindowSecondaryFontSize` | 14 | 次要信息:天气副标题、日期星期、倒计时标签 | `--ci-fs-secondary` | `ThemeBase.axaml:32`;`WeatherComponent.axaml:261,294,296,305` |
| `MainWindowBodyFontSize` | 16 | 正文:课程名、时间、温度 | `--ci-fs-body` | `ThemeBase.axaml:33`;`MainWindow.axaml:313`;`CountDownComponent.axaml:65` |
| `MainWindowEmphasizedFontSize` | 18 | 强调:时钟、提醒主文案 | `--ci-fs-emphasized` | `ThemeBase.axaml:34`;`ClockComponent.axaml:14`;`TwoIconsMaskTemplate.axaml:14` |
| `MainWindowLargeFontSize` | 20 | 大号标题 | `--ci-fs-large` | `ThemeBase.axaml:35` |
扩展固定档(非用户设置,必须写死为 token 并说明来源):
| 字号 | 用途 | 建议 CSS 变量 | 证据 |
|---|---|---|---|
| 9 | 启动闪屏版本号(0.5 不透明度) | `--ci-fs-version` | `SplashWindow.axaml:86-87` |
| 12 | 主题卡作者名 | `--ci-fs-caption` | `ThemesSettingsPage.axaml:191` |
| 13 | 组件设置小字、ClassHelper 控件基准 | `--ci-fs-control-base` | `CountDownComponentSettingsControl.axaml:185-201`;`ClassHelper/renderer/styles/index.css:97` |
| 14 | 通用控件/次级标签 | `--ci-fs-label` | `MainWindow.axaml:406,452,497` |
| 16 | 主题卡名称、闪屏状态文字 | `--ci-fs-title` | `ThemesSettingsPage.axaml:188`;`SplashWindow.axaml:80` |
| 17 | 滚动提醒文字 | `--ci-fs-rolling` | `RollingTextTemplate.axaml:26` |
| 18 | OOBE 说明文字 | `--ci-fs-oobe-body` | `WelcomeWindowIntroControl.xaml:166` |
| 20 | 启动 logo 文字、导航标签 | `--ci-fs-brand` | `SplashWindow.axaml:56-58`;`StylesBase.axaml:118-123` |
| 22 | 天气温度/图标文字 | `--ci-fs-weather` | `WeatherComponent.axaml:86-175` |
| 32 | OOBE 大标题 | `--ci-fs-oobe-title` | `OobeIntroAnimationControl.axaml:69,87`;`WelcomePage.axaml:38` |
实现硬约束:
- 4 档核心字号必须由**用户设置驱动**,且必须能作用到「组件自身」与「单个主界面行」两个作用域:行级覆盖时把 4 个变量写到行根元素的 `style` 上;关闭行级覆盖时移除内联变量,回落 `:root`(`MainWindowCustomizableNodeHelper.cs:20-37`)。
- 缩放:所有字号乘以界面缩放 `--ci-scale`(默认 1.0),但**不得**用浏览器 zoom,必须用布局缩放以保持像素对齐(见 3.8)。
- 行高:源码未给出显式 `LineHeight`,建议正文 `line-height: 1.25`、说明文字 `1.6`【推断:依据 ClassHelper `theme.css:60-63` 的 `line-height:1.6` 与 ClassIsland 主界面 40px 行高/16px 字号的比值】。
### 3.3 圆角 Token
| Token | 默认值 | 用途 | 建议 CSS 变量 | 证据 |
|---|---|---|---|---|
| `RadiusX` / `RadiusY`(岛圆角) | `8`(UI 范围 `0–20`,步进 `0.5`,提示文案:调至最大时左右边缘为半圆) | 主界面行背景圆角;`SizeDoubleToCornerRadiusConverter` 以 `ConverterParameter=1.0` 直接取值(除以 1 即原值) | `--ci-radius-island: 8px` | `Settings.cs:183-184`、`:1187-1207`;`AppearanceSettingsPage.axaml:75-87`;`FluentTheme/Styles.axaml:48,91,124,160-168,252`;`SizeDoubleToCornerRadiusConverter.cs:12-18` |
| 行级自定义圆角 | `0`(关闭) | 单行覆盖圆角 | `--ci-radius-island-local` | `MainWindowLineSettings.cs:40-41` |
| 岛内容高度 | `40` | 所有行的容器高度(Fluent/Classic 同值) | `--ci-island-height: 40px` | `FluentTheme/Styles.axaml:65`;`ClassicTheme/Styles.axaml:16`;`MainWindowLine.axaml:16` |
| 弹窗圆角 | `6` | `.popup-bg`、编辑模式操作条 | `--ci-radius-popup: 6px` | `StylesBase.axaml:295`;`MainWindow.axaml:488,599` |
| 卡片圆角 | `4` | 设置项卡片、主题卡、Shimmer、时间线装饰器、OOBE 提示 | `--ci-radius-card: 4px` | `StylesBase.axaml:257-260`;`ThemesSettingsPage.axaml:152,161`;`Themes/Generic.xaml:214` |
| 控件圆角 | `4` | 按钮/输入框等 | `--ci-radius-control: 4px` | ClassHelper `index.css:52`;`theme.css:10` |
| 抽屉圆角 | `8 8 0 0` | 底部竖抽屉 | `--ci-radius-drawer: 8px 8px 0 0` | `VerticalDrawer.axaml:9` |
| 胶囊圆角 | `999` | 徽标、胶囊按钮 | `--ci-radius-pill: 999px` | `classic-island.css:308,454,541,694` |
| 圆形按钮 | `14`(28×28) | 主题卡安装按钮 | `--ci-radius-icon-btn: 14px` | `ThemesSettingsPage.axaml:246-249` |
| OOBE 大圆钮 | `24`(48×48) | 欢迎页「下一步」 | `--ci-radius-oobe-btn: 24px` | `WelcomePage.axaml:61-65` |
| 岛内小元素 | `0.5 × --ci-island-font` / `0.36 × --ci-island-font` | 岛内图标块 | 用 calc 派生 | `classic-island.css:388,403,476,502` |
实现硬约束:
- 岛圆角必须**四角同值**(源码把 `RadiusX` 同时传给宽高两个方向:`FluentTheme/Styles.axaml:31-34` 传两次 `RadiusX`),不得实现「椭圆圆角」。
- 圆角必须作用于「背景层」与「内容裁剪层」两层,且两层同值,否则内容会溢出圆角外(`FluentTheme/Styles.axaml:48,91,124,160-168`)。
- 若引入 ClassHelper 的连续圆角(squircle),必须保持 `SUPERELLIPSE_EXPONENT = 4.2`、每角 `CORNER_SEGMENTS = 14`、展开卡半径 `min(48*scale, w/2, h/2)` 不变(`ClassHelper/packages/desktop-client/src/island/squircle.ts:21,24,79-81`)。**默认实现必须用 CSS `border-radius`**(与 Avalonia `CornerRadius` 的直角圆弧一致,保证与 ClassIsland 逐像素相同);连续圆角只能由主题包显式启用(P2),不得作为默认岛外形。
### 3.4 间距 Token
| Token | 值 | 用途 | 建议 CSS 变量 | 证据 |
|---|---|---|---|---|
| 行内容左右内边距 | `12 0` | 岛内容区 `GridContentRoot.Margin` | `--ci-island-pad-x: 12px` | `FluentTheme/Styles.axaml:110`;`ClassicTheme/Styles.axaml:100` |
| 行内容最小宽度 | `20` | `MinWidth` | `--ci-island-min-w: 20px` | `FluentTheme/Styles.axaml:109` |
| 组件间距 | `6 0` | 每个 `ComponentPresenter.Margin`;**token 值取相邻间距 12px**(每个组件左右各 6px),供 `calc(var(--ci-component-gap) / 2)` 反推单侧 margin | `--ci-component-gap: 12px` | `MainWindow.axaml:396,546`;与 part 04 §4.4 对齐 |
| 行间距 | `5` | 多行堆叠 `StackPanel.Spacing`(用户设置) | `--ci-line-gap: 5px` | `Settings.cs:219`;`MainWindow.axaml:344` |
| 设置页容器 | `margin 12 12 18 12`;`max-width 960`;`spacing 4` | 设置页正文 | `--ci-settings-pad` / `--ci-settings-max-w: 960px` | `StylesBase.axaml:67-71`;`ThemeBase.axaml:37` |
| 组件设置容器 | `margin 4 0 4 12`;`max-width 960`;`spacing 4` | 子组件设置 | `--ci-component-settings-pad` | `StylesBase.axaml:73-77` |
| 弹窗内边距 | `12` | `.popup-bg` | `--ci-popup-pad: 12px` | `StylesBase.axaml:294` |
| 主题卡网格 | 单元高 `150`;目标宽 `328`;单元 `margin 2`;卡内 `padding 8` | 主题市场网格 | `--ci-theme-card-h: 150px`、`--ci-theme-card-w: 328px` | `ThemesSettingsPage.axaml:139-142,172` |
| 编辑模式操作条 | 底距 `64`;`padding 4`;按钮间距 `6` | 编辑态底部工具条 | `--ci-editbar-bottom: 64px` | `MainWindow.axaml:595-604` |
| 闪屏 | 尺寸 `400×100`;`padding 17`;logo 高 `24`;行距 `8`;底部进度条高 `4` | 启动闪屏 | `--ci-splash-w: 400px`、`--ci-splash-h: 100px` | `SplashWindow.axaml:16-17,24,29,42,92-96` |
| 岛内水平内边距(ClassHelper 风格) | `0.78 × 字号` | 岛胶囊左右内边距 | calc 派生 | `classic-island.css:215` |
| 进度条高度 | `3px` | 岛内倒计时/提醒进度条统一高度 | `--ci-progress-height: 3px` | 与 part 04 §4.4 对齐;源码进度条未显式设高(`FluentTheme/Styles.axaml:126-146`),取 FluentAvalonia ProgressBar 默认值【推断】 |
| 岛内元素间距(ClassHelper) | `0.62 × 字号` | 岛内行间距 | calc 派生 | `classic-island.css:212` |
### 3.5 透明与模糊 Token
| Token | 值 | 用途 | 建议实现 | 证据 |
|---|---|---|---|---|
| 主窗口透明级别 | `Transparent` | 非编辑态的岛窗口必须逐像素透明 | Electron `transparent: true` + `backgroundColor: '#00000000'` | `MainWindow.axaml:168-176` |
| 编辑模式透明级别 | `AcrylicBlur, Transparent`(回退 Transparent) | 编辑态窗口使用亚克力 | 只能用 CSS `backdrop-filter` 在自绘层模拟,**不得**用 `setBackgroundMaterial`(会破坏逐像素透明) | `MainWindow.axaml:177-181`;ClassHelper `island.ts:1726-1734` 明确禁止 |
| 普通窗口模式 | 若禁用透明则 `TransparencyLevelHint=None` | 窗口模式 | `transparent:false` | `MainWindow.axaml:187-190` |
| 闪屏透明 | `AcrylicBlur, Transparent` | 启动闪屏 | `backdrop-filter: blur(...)` + 半透明底 | `SplashWindow.axaml:21` |
| 岛背景不透明度 | `0.5`(默认范围 0–1,步进 0.05,滑块带数值气泡) | 背景层 opacity | `--ci-island-opacity`(主名;兼容别名 `--ci-island-bg-opacity`) | `Settings.cs:64,1141`;`AppearanceSettingsPage.axaml:61-72`;`FluentTheme/Styles.axaml:44` |
| 行整体不透明度 | `1.0`(行级设置,可被单行覆盖) | 行级 opacity | `--ci-line-opacity` | `MainWindowLineSettings.cs:42` |
| 鼠标移出淡化 | `0.05` | 悬停淡出模式 | `opacity:.05` | `MainWindowLine.axaml:73-75` |
| 提醒特效渲染缩放 | `1.0`;屏高 ≥1400 → `0.75`;≥2000 → `0.5`(自动时) | 强提醒涟漪窗口缩放 | `--ci-effect-scale` | `Settings.cs:150`;`MainWindow.axaml.cs:377-392` |
| 遮罩动画层级不透明度 | 0 → 1(0.1s) | 遮罩出现时附加投影 | `box-shadow` transition | `FluentTheme/Styles.axaml:355-367` |
硬规则:
- **不得**给岛卡片加 `backdrop-filter`(会把整块矩形模糊掉);模糊只允许出现在「编辑模式窗口背景」与「闪屏底」这两个整窗层。
- 岛必须是「透明窗口 + 自绘圆角卡片」,卡片外区域完全透明、可点击穿透(与 ClassHelper 一致,见 `island.ts:250-257`)。
### 3.6 层级(z-index)Token
| 层 | 数值 | 用途 | 建议 CSS 变量 | 证据 |
|---|---|---|---|---|
| 窗口层级-底层 | `WindowLayer=0` | 岛沉到桌面层(`Bottommost`),仅提醒/编辑时置顶 | `--ci-z-window:0` | `Settings.cs:52`;`MainWindow.axaml.cs:843-874` |
| 窗口层级-置顶 | `WindowLayer=1`(默认) | 岛始终置顶 | `--ci-z-window-top:1` | 同上;默认值 `Settings.cs:52` |
| 岛内容层 | 0 | 正常内容 | — | — |
| 遮罩/覆盖层 | 1 | 内容之上、背景之下 | `--ci-z-mask:1` | `FluentTheme/Styles.axaml:155-197` |
| 拖拽占位 | `1`(且 opacity .5) | 编辑态拖动项 | `--ci-z-drag:1` | `MainWindow.axaml:478-481` |
| 时间线装饰器 | `9999` | 分割线/行动装饰器必须压住时间块 | `--ci-z-timeline-adorn:9999` | `Themes/Generic.xaml:193,243` |
| 「无穷大」哨兵 | `33550336` | 必须永远最前的元素(OOBE 方块、时间线提示、拖拽阈值) | `--ci-z-sentinel:33550336` | `OobeIntroAnimationControl.axaml:45`;`TimeLineListControl.axaml:309`;`MainWindow.axaml:334` |
| 提醒特效窗口 | 独立 Topmost 窗口 | 全屏涟漪,高于一切 | 独立 BrowserWindow `alwaysOnTop` | `Views/TopmostEffectWindow.axaml.cs:120-128` |
| 教程聚光灯 | 独立置顶窗口 | 遮罩挖洞 | 独立窗口 | `Controls/Tutorial/TutorialSpotlightControl.axaml:41` |
| 抽屉遮罩 | 覆盖内容 | 横/竖抽屉 | `--ci-z-drawer:10`【推断】 | `DrawerHost.axaml`、`VerticalDrawer.axaml` |
| 应用外壳引导层 | `10000` | 全屏引导 | `--ch-z-guide:10000` | `ClassHelper/packages/ui/src/theme.css:155` |
| 主题切换快照层 | 旧 `1` / 新 `2` | View Transition 两帧 | `--ch-z-view-old/new` | `ClassHelper/renderer/styles/index.css:948-954` |
实现必须满足:
- 岛内容、遮罩、覆盖层在同一「行」内按上表叠放;多行之间不重叠(`MainWindowLine` 的 `ClipToBounds=False`,行本身不裁剪)。
- `33550336` 是源码里反复使用的「无穷大」哨兵(同时被用作拖拽阈值 `HorizontalDragThreshold="33550336"` 表达「禁止横向拖动」,见 `MainWindow.axaml:334`)。实现时可直接用 `z-index: 2147483647` 代替,但**必须**在常量注释里保留原值语义。
- 编辑器/设置中心内任何弹窗、Tooltip 都必须高于主内容;不得用负 z-index 实现遮罩。
### 3.7 DPI 与缩放规则
| 规则 | 数值/算法 | 证据 |
|---|---|---|
| 界面缩放用户设置 | `Scale`,默认 `1.0`,UI 范围 `0.75–2.5`,步进 `0.05`(滑块带数值气泡,格式 `F2`) | `Settings.cs:86,1153`;`Views/SettingPages/AppearanceSettingsPage.axaml:27-39` |
| 缩放实现 | 用 `LayoutTransform`(布局缩放)+ `ScaleTransform`,同时作用于所有行;**不得**用 `transform: scale()` 直接缩放窗口内容(会模糊) | `MainWindow.axaml:250-265` |
| 编码缩放补偿 | `GridRootLeft = Width/10*(Scale-1)`、`GridRootTop = Height/10*(Scale-1)` | `MainWindow.axaml.cs:881-882,938-939` |
| 编辑模式缩放边界 | `MinZoom 0.5` / `MaxZoom 3` / `ZoomSpeed 1.2`,`EnableConstrains=True`,显示缩放指示 | `MainWindow.axaml:199-209` |
| 窗口尺寸换算 | 逻辑宽 = 物理宽 / `dpiX`;逻辑高 = (物理高 − 1) / `dpiY`(−1 防被误判全屏) | `MainWindow.axaml.cs:969-971` |
| 窗口定位 | 使用**物理像素** `PixelPoint`,不做 dpi 换算 | `MainWindow.axaml.cs:905-909` |
| 安全区(主题声明) | 竖直安全区 = 当前启用主题 `VerticalSafeAreaPx` 的最大值;Fluent 主题为 `20` | `Services/XamlThemeService.cs:111-135`;`App.Services.xaml.cs:314` |
| 教程运行时的安全区 | 主题安全区与 `150` 取大者 | `MainWindow.axaml.cs:898-902` |
| 通知特效窗口缩放 | `1 / dpiX` | `MainWindow.axaml.cs:929` |
| 鼠标命中判定 | 屏幕坐标与 `Bounds * dpi * Scale` 比较 | `Controls/MainWindowLine.cs:625-636` |
| 字体缩放(关怀模式,应用外壳) | `--ch-font-scale: 1`;关怀模式 `1.15`,只放大字号不动几何 | `ClassHelper/renderer/styles/index.css:103,973-979` |
| 岛内字号缩放(ClassHelper 模型) | 所有岛内尺寸由 `--island-font` 用 `calc()` 派生 | `classic-island.css:2-7,57` |
实现要求:
- 所有视觉常量必须定义在 96 DPI 的 CSS px 基准上,再由 `--ci-scale` 统一放大;不得为不同 DPI 写两套值。
- 多显示器:每行/每窗口使用「所在显示器」的 DPI 与工作区;切换显示器必须重新计算窗口尺寸与位置(`GetSelectedScreenSafe`,`MainWindow.axaml.cs:806-812`)。
- `Scale` 改变时必须重算窗口宽高与安全区,不能只改 CSS transform。
### 3.8 深浅色规则
| 规则 | 要求 | 证据 |
|---|---|---|
| 主题模式枚举 | `0=跟随系统`、`1=明亮`、`2=黑暗`;默认 `2`(黑暗) | `Settings.cs:32,979-989`;`Services/ThemeService.cs:53-59` |
| 跟随系统实现 | `themeVariant=Default` + `preferSystemTheme=true`;系统切换要实时响应 | `ThemeService.cs:53-63` |
| 资源覆盖机制 | Light/Dark 两套 ThemeDictionary,同名 key 覆盖:`RainyColorBrush`、`LuminousClass`、6 个 AQI、`CustomizedAccentBarBackground1Brush`、`TextFillColorInformativeSecondaryBrush` | `App.axaml:35-38`;`Themes/LightTheme.axaml`、`Themes/DarkTheme.axaml` |
| 自定义前景色优先级 | 用户自定义前景色 > 主题前景色;关闭时回落主题 | `ControlColorHelper.cs:20-52` |
| 自定义背景色优先级 | 用户自定义背景色 > 主题背景;关闭时回落 `SolidBackgroundFillColor*` | `FluentTheme/Styles.axaml:51-56`;`ClassicTheme/Styles.axaml:216-218` |
| 深色下的阴影 | 阴影颜色不变(黑),只调不透明度;**不得**在深色下把投影改成白色 | Fluent `#48000000`/`#66000000`/`#33000000` 与 `VerticalDrawer.axaml:47` 注释「Even in WinUI, shadow is always black regardless of light/dark mode」 |
| ClassHelper 深色底色 | 应用壳亮 `#F3F3F3`/暗 `#161719`;卡片 `rgba(255,255,255,.7)` / `rgba(32,33,36,.72)` | `renderer/styles/index.css:27-48,114-137` |
| 主题切换过渡 | 窗口 `Background` 用 200ms 过渡;整页换肤用圆形扩散 View Transition(320×1.5=480ms) | `MainWindow.axaml:40-44`;`ClassHelper/renderer/composables/motion.ts:226-235` |
实现禁止项:
- 禁止在组件里写死 `#fff`/`#000`;必须走 `--ci-text-*`。
- 禁止只做深色、不做亮色(两套必须同步验收)。
- 禁止在深色模式使用纯黑 `#000000` 作为岛背景(默认自定义背景色是黑,但必须靠 `--ci-island-bg-opacity` 降为 50% 并叠加主题描边,否则与桌面融为一体)。
### 3.9 ClassHelper 侧落点(实现映射表)
| 内容 | 落点文件 | 要求 |
|---|---|---|
| 应用外壳 token | `packages/ui/src/tokens.css` | 保留 `--ch-ui-*`,新增 `--ci-*` 只读引用 |
| 应用外壳主题 | `packages/ui/src/theme.css` | 把 `--ci-accent` 注入 `--el-color-primary` 系列,`color-mix` 派生 |
| 桌面端全局样式 | `packages/desktop-client/src/renderer/styles/index.css` | 在 `:root` 与 `html.dark` 中定义 `--ci-*` 的亮/暗值 |
| 岛渲染进程样式 | `src/island/classic-island.css`、`custom-island.css` | 定义 `--island-*` 与 `--wn-*`(`classic-island.css:60-82`) |
| 动效常量 | `packages/shared/src/motion.ts` + 桌面端/管理端 `composables/motion.ts` | 新增 token 只加在 shared;不得两端各抄一份(`motion.ts:14-16` 明确约束) |
| 设计常量的 TS 形态 | `packages/shared/src/constants.ts` | 输出 `CLASSISLAND_TOKENS` 常量对象,供窗口几何计算复用 |
### 3.10 落地顺序与常见错误
实现顺序必须严格如下,不得跳步:
1. 先在 `packages/shared/src/constants.ts` 建立常量对象(颜色、字号、圆角、间距、层级、DPI 基准),字段名与本节 token 名一一对应,导出为只读(`as const`)。
2. 在 `packages/ui/src/tokens.css` 的 `:root` 写入亮色值,在 `html.dark` 写入深色值;两处必须保持键集合完全一致,缺一个键都算不合格。
3. 在 `packages/desktop-client/src/renderer/styles/index.css` 引入 `--ci-*` 的桌面端覆盖(通知特效窗口、闪屏窗口等独立渲染入口要单独引入同一份 token)。
4. 在 `src/island/classic-island.css` 与 `custom-island.css` 里把 `--island-*` 映射到 `--ci-*`,禁止在岛样式里重新定义颜色字面量。
5. 最后才写组件;组件只允许消费 token。
常见错误(必须在实现时主动规避):
- 把「行级字号覆盖」写成给整个岛设置字号:这样改一行会连带改所有行,与源码作用域不符(源码把资源写在行控件自身,见 `MainWindowCustomizableNodeHelper.cs:20-26`)。
- 把 5 级前景色实现成「同一颜色的不同 `opacity` 属性」:`opacity` 会连带影响子元素与阴影,源码用的是颜色 alpha 通道(`ControlColorHelper.cs:24-33`),必须写进颜色值本身。
- 用 `filter: drop-shadow()` 代替 `box-shadow` 实现岛投影:`filter` 会新建渲染表面,开合时出现闪帧(ClassHelper `classic-island.css:114-120` 已记录该问题)。
- 在深色主题下把阴影改成 `rgba(0,0,0,.6)` 以外的颜色,或直接删除阴影:源码在两种主题下都用黑色阴影,只改透明度。
- 忘记「安全区」:Fluent 主题声明了 `20px` 竖直安全区,岛贴边时会空出这段距离(`App.Services.xaml.cs:314`、`XamlThemeService.cs:111-135`),漏实现会导致岛压住任务栏或屏幕边缘。
- 忘记 `Scale` 变化时重算窗口:只改 CSS 会导致点击热区与可见区域错位(源码在 `MainWindow.axaml.cs:876-931` 每次都重算 `Bounds` 与 `ActualClientBound`)。
- 把「编辑模式操作条」的层级设在岛内容之下:它必须浮在行内容之上,且底距 64px(`MainWindow.axaml:595`)。
### 3.11 验收标准
实现者必须逐条自查,全部通过才算完成本节:
1. **颜色**:全仓库(渲染进程 + 主进程 + shared)搜索,除 token 定义文件外不得出现 `#` 开头的颜色字面量;`grep -r "#[0-9a-fA-F]\{6\}"` 的命中只允许出现在 token 文件与主题包预览样式中。
2. **强调色**:修改 `--ci-accent` 一处,岛背景描边、提醒遮罩、进度条、按钮强调态、天气图标强调色**全部**同步变化,无残留默认蓝。
3. **5 级前景**:开启自定义前景色后,5 个 alpha 分别为 100%/85.5%/77.3%/52.9%/36.5%;关闭后 5 个变量被移除并回落。
4. **字号**:把 4 档字号分别改成 10/20/30/40,岛内所有文字按对应档位变化且不溢出 40px 行高外的容器;行级覆盖可只影响单行。
5. **圆角**:把 `--ci-radius-island` 从 8 改到 20,背景层与裁剪层同时变化,内容不溢出四角;其他卡片圆角不受影响。
6. **间距**:改 `--ci-island-pad-x` 与 `--ci-component-gap`,内容宽度变化后背景宽度动画仍正常(见 part 14)。
7. **透明**:非编辑态岛卡片外区域 100% 透明(用取色器/截图验证 alpha=0);编辑态出现亚克力底;全程不允许整窗材质导致卡片外出现色块。
8. **层级**:编辑态拖动项浮在其他项之上;提醒遮罩覆盖内容但低于右上角操作按钮(若有);`33550336` 哨兵元素永远最前。
9. **DPI**:在 100%/125%/150%/200% 四档缩放下,岛高度逻辑值恒为 40、圆角逻辑值恒为 8、文字不模糊;跨显示器拖动后重新计算,无半个像素的缝。
10. **深浅色**:Light/Dark 各截图对照,两类主题的 AQI 色、雨天色、说明文字 alpha 与上表一致;深色下阴影仍为黑色。
11. **CSS 变量唯一来源**:`--ci-*` 只在两处定义(`:root` 与 `html.dark`),组件内只引用不重定义(行级作用域覆盖除外)。
12. **字符集**:所有 token 名使用 kebab-case,前缀统一为 `ci-`;禁止出现 `--ci-primaryColor` 这类混合命名。
+755
View File
@@ -0,0 +1,755 @@
## 4. 主界面(岛):窗口、行、背景、进度、遮罩、编辑模式
本节是「主界面(MainWindow,俗称岛)」的完整实现规格。实现技术栈固定为 ClassHelper 的 Electron + Vue 3 + TypeScript:主进程用 `BrowserWindow` 复刻 Avalonia `Window` 的窗口级行为,渲染进程用 Vue 单文件组件 + CSS 复刻 `MainWindow.axaml` 的控件树与全部像素参数。所有 Avalonia 的"逻辑像素(DIP,96 DPI 基准)"一律直接对应 CSS `px`;`Settings.Scale` 用 `transform: scale()` 实现。
### 4.1 术语与实现映射总则
必须先建立下面这张映射表,后文所有规则都按它落地:
| ClassIsland 概念 | 源码实体 | 本实现替代物 |
|---|---|---|
| 主界面窗口 | `MainWindow : Window`(`repos/ClassIsland/ClassIsland/MainWindow.axaml`) | 独立 `island.html` 渲染入口 + 主进程 `BrowserWindow`(透明、无边框、置顶、跳过任务栏) |
| 主界面行 | `MainWindowLine`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs`) | `IslandLine.vue`(Vue 组件),一行一个 |
| 组件宿主 | `ComponentPresenter`(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml`) | `ComponentHost.vue`(统一承载宽度约束/透明度/隐藏规则/裁剪) |
| 组件 | `DateComponent` / `ClockComponent` / … | `components/DateNode.vue` 等,见第 5 章 |
| 主题样式 | `XamlThemes/FluentTheme/Styles.axaml`、`ClassicTheme/Styles.axaml` | `island.css` + CSS 变量主题包(`--island-*`) |
| 编辑模式视图 | `Controls/EditMode/EditModeView.axaml` | `IslandEditMode.vue`(抽屉 + 拖动 + 选择) |
| 提醒宿主 | 行模板内的 Overlay / Mask 层 | 行内 `.line-mask` / `.line-overlay` |
强制约定:
1. 不得把主界面做成"一个自适应大小的 div 窗口",也不得照搬 ClassIsland 的"窗口高度随内容自适应"。采用 4.3.1 的**固定包围盒**:窗口宽度 = 所选显示器整屏宽(DIP),高度 = 预设最大内容高 + 上下安全余量;**可见岛**在窗口内形变,其高度 = 行高之和 + 行间距之和。窗口大于岛的透明区域不会遮挡桌面(Windows 侧整窗被设为鼠标穿透,见 4.2)。
2. 不得使用 `win.setBackgroundMaterial()`(亚克力/mica)。ClassIsland 的透明是逐像素 alpha + `WS_EX_LAYERED`,用系统材质会破坏像素级透明与圆角(`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:523-524` 的既有结论)。
3. 不得给主界面行加 CSS `box-shadow` 之外的重滤镜;Fluent 主题只有 `BoxShadow`,Classic 主题只有提醒遮罩期才挂 `DropShadowEffect`。
4. 所有尺寸、时长、缓动必须按本文给的确切值写死为常量或 CSS 变量,不得凭观感微调。
### 4.2 窗口(BrowserWindow)逐项规格
按下面顺序创建窗口(默认非编辑模式):
```ts
const win = new BrowserWindow({
...geometry, // 见 4.3,x / y / width / height
show: false,
frame: false, // Avalonia: SystemDecorations=None(MainWindow.axaml:171)
transparent: true, // Background=Transparent + TransparencyLevelHint=Transparent(:169-170)
resizable: false, // CanResize=False(:172)
movable: false,
minimizable: false, // CanMinimize=False(:174)
maximizable: false, // CanMaximize=False(:173)
fullscreenable: false,
skipTaskbar: true, // 默认不进任务栏;例外见下
hasShadow: false, // 阴影由岛自己的 CSS box-shadow 画
focusable: false, // 普通态不抢焦点(配合 showInactive)
alwaysOnTop: true, // WindowLayer 默认 1=置顶(Settings.cs:52)
type: 'toolbar', // 对应 WS_EX_TOOLWINDOW(MainWindow.axaml.cs:838)
backgroundColor: '#00000000',
webPreferences: {
preload: /* island preload 白名单 */,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
},
});
```
逐条规则:
1. **透明与无边框**:`transparent: true` + `frame: false` + `hasShadow: false` + `backgroundColor: '#00000000'`。HTML 根节点(`html, body, #app`)必须 `margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden;-webkit-user-select:none;user-select:none`(对齐 `repos/ClassHelper-master/packages/desktop-client/src/island/classic-island.css:8-19`)。
2. **鼠标穿透(核心)**:ClassIsland 在 Windows 上给主窗口加 `WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE` 并调用 `SetLayeredWindowAttributes`(`repos/ClassIsland/platforms/ClassIsland.Platforms.Windows/Services/WindowPlatformService.cs:130-150`),因此**非编辑模式下整个窗口对鼠标完全透明**,点击直接落到桌面。由此得:
- 非编辑模式:调用 `win.setIgnoreMouseEvents(true, { forward: true })`,且不得注册任何需要点击的交互(行、组件都不可点)。
- 编辑模式:调用 `win.setIgnoreMouseEvents(false)` 恢复命中,并 `win.focus()`。
- 不得实现"点岛展开/收起"这类交互;ClassIsland 的主界面是纯展示层。旧设置项 `IsMouseClickingEnabled` 已被废弃并被强制关闭(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:733-737`,默认值 `false` 见 `Models/Settings.cs:53`)。
3. **不抢焦点**:显示时用 `win.showInactive()`;非编辑态 `focusable: false`。若为了接收触摸事件必须保持可激活,则只允许在编辑模式内切换 `focusable`,且退出编辑模式后立即恢复。
4. **置顶/置底**:`Settings.WindowLayer` 取值 `0=置底`、`1=置顶`,默认 `1`(`Models/Settings.cs:52`, `:1961-1978`)。置顶时 `win.setAlwaysOnTop(true, 'screen-saver')` 并 `win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`;置底时 `win.setAlwaysOnTop(false)` 并用 `setAlwaysOnTop(false)` + 必要时 `win.moveBottom()`(Windows 上等价于 `HWND_BOTTOM`,见 `WindowPlatformService.cs:151-159`)。置顶优先级还要被三个条件覆盖(`MainWindow.axaml.cs:791-803, 843-874`):
- 处于自由窗口模式(编辑模式的 windowed)或编辑模式时为普通窗口;
- `IsNotificationWindowExplicitShowed=true`(有提醒/编辑)时无论 `WindowLayer` 都置顶;
- `WindowTopmostRecheckMode` 决定重查时机:`0` 由窗口 Z 序变化事件驱动、`1` 前台窗口变化时、`2` 主计时器每 tick、`3` 1ms 高频计时器(`MainWindow.axaml.cs:420-436, 461-466, 687-704`,默认 `WindowTopmostRecheckMode = 0` 见 `Models/Settings.cs:1860`)。本实现的复核策略见 4.2.1,**禁止照搬 `3` 的 1ms 轮询**。
5. **任务栏显示**:`ShowInTaskbar` 是 `IsWindowMode || IsScreenRecordingModeEnabled` 的逻辑或(`MainWindow.axaml:34-39`)。即只有"编辑模式 + 自由窗口"或"屏幕录制模式"才进任务栏。实现用 `win.setSkipTaskbar(!(isWindowedEditMode || settings.isScreenRecordingModeEnabled))`。
- **P2 / 能力说明**:`IsWindowMode`(= `EditModeIsWindowMode && IsEditMode`,`ViewModels/MainViewModel.cs:363`)本任务**不实现具体 UI 入口**,只保留设置字段与"编辑模式可切成自由窗口"的能力说明。依据:ClassIsland 2.1 中该设置字段存在,但编辑页(`Controls/EditMode/EditModeView.axaml`)没有可见的切换按钮【推断:入口可能在教程步骤或后续版本】,实现者不得臆造按钮。
6. **防截屏**:`IsWindowCaptureBlockingEnabled=true` 时调用 `win.setContentProtection(true)`(对应 `WDA_EXCLUDEFROMCAPTURE`,`WindowPlatformService.cs:169-172`, `MainWindow.axaml.cs:840`)。
7. **屏幕录制模式**:`IsScreenRecordingModeEnabled=true` 时:不进 ToolWindow(任务栏可见)、不被置底。默认 `false`(`Models/Settings.cs:1861`)。
8. **窗口关闭拦截**:主界面窗口不得被用户关闭。ClassIsland 在 `Closing` 事件里 `e.Cancel = true`,只有应用主动退出才放行(`MainWindow.axaml.cs:577-590`)。实现:`win.on('close', (e) => { if (!appQuitting) e.preventDefault(); })`。
9. **禁止的窗口能力**:不得 `setAlwaysOnTop` 到 `'floating'` 以下级别;不得开启 `thickFrame`;不得给窗口加系统菜单;不得设置 `roundedCorners`(圆角由 CSS 画)。
10. **单实例守卫**:见第 12 章(本部分不重复),但主界面窗口必须在单实例锁之后创建。
#### 4.2.1 置顶复核与性能底线
**必须事件驱动 + 兜底轮询,禁止任何 1ms/10ms 级高频轮询。**
- 事件源(全部接上):`win.on('focus'/'blur')`、`screen` 的 `display-added/removed/metrics-changed`、编辑模式进出、`setVisibleOnAllWorkspaces` 变化、提醒开始/结束(对应 `TopmostLock` 的 `Acquire/Release`,`MainWindow.axaml.cs:1056-1092`)。
- 兜底轮询:仅在"当前是置顶态且窗口可见"时启用,间隔 **≥1000ms**,每次只做一次轻量 `setAlwaysOnTop(true, 'screen-saver')` 复核(对抗系统抢 Z 序)。窗口隐藏/置底时停止轮询。
- 禁止实现 `WindowTopmostRecheckMode === 3` 的 1ms 高频计时器(`MainWindow.axaml.cs:136-139`):那是原生 `SetWindowPos` 成本极低的平台红利,Electron 侧照搬会持续占用 CPU 并让渲染进程掉帧。
- 同类频率约束(本部分涉及的所有动画/刷新):
- 时钟/日期/倒计时/课程表:**1s 节拍**(整秒对齐);
- 提醒进度条:**100ms**(`ReduceProgressAccuracy=true` 时 250ms 即可);
- 行淡出/透明度过渡:由 CSS `transition` 驱动,**不得**用 JS 每帧写样式;
- 鼠标位置命中判定:只在 `pointermove`(编辑模式)或 200ms 兜底轮询(非编辑模式不需要,因为整窗穿透)。
### 4.3 几何:包围盒、可见内容盒与编辑画布
所有坐标计算以「所选显示器」为基准,显示器由 `Settings.WindowDockingMonitorIndex` 选定;越界或负数时回退主显示器(`MainWindow.axaml.cs:806-812`,默认 `0` 见 `Models/Settings.cs:51`)。设:
- `screen`:所选显示器;`screen.bounds` 为整屏物理像素,`screen.workArea` 为工作区物理像素;
- `dpiX = screen.scaleFactor`、`dpiY = screen.scaleFactor`(Avalonia 取 `screen.Scaling`,`MainWindow.axaml.cs:1019-1037`);
- `ox = Settings.WindowDockingOffsetX`、`oy = Settings.WindowDockingOffsetY`(单位:物理像素,默认 `0`、`0`,`Models/Settings.cs:49-50`);
- `ignoreArea = Settings.IsIgnoreWorkAreaEnabled`(默认 `false`,`Models/Settings.cs:1835`);
- `docking = Settings.WindowDockingLocation`(默认 `1`,`Models/Settings.cs:48`),取值为:
```
###############
# 0 1 2 #
# #
# 3 4 5 #
###############
```
即 `0/1/2 = 上(左/中/右)`、`3/4/5 = 下(左/中/右)`(`Models/Settings.cs:1810-1823`)。
#### 4.3.1 实现策略裁决:固定包围盒 + 窗内形变
ClassIsland 的原生模型是"窗口高度随内容自适应"(`SizeToContent = Height`,`MainWindow.axaml.cs:781`;窗口高 = 内容高 + 安全区,`:916`)。**本实现不得照搬这一条**:Electron 的逐像素透明窗口在频繁 `setBounds` 时会触发 DWM 重排与透明表面重建,产生整块闪烁/掉帧,而行增删、提醒展开、时钟秒数出现都会触发高度变化。因此采用 ClassHelper 已验证的「固定包围盒 + 窗内形变」方案(`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:480-512`,注释 `:78-90`):
1. **窗口创建时一次算好包围盒**:
- `winW = 目标屏幕宽(DIP) = screen.bounds.width / dpiX`;
- `winH = contentMaxHeight + 2 * safePadding`,其中 `contentMaxHeight` = 预设最大内容高(默认按"当前可见行数 × (40 + 行间距 5) + 上下安全区"计算,并留出一行余量),`safePadding ≥ 20`(阴影 + 光晕余量,对齐 `ISLAND_SHADOW_PAD = 10` 的双侧口径)。
2. **可见岛在窗口内形变**:窗口不动,岛用 CSS `height` 过渡(或第 14 章的 spring 形变)在包围盒内伸展/收缩;内容层按 4.5 的行高与间距排布。
3. **只有下列情形才 `setBounds`**:显示器/DPI 变化、行数超出包围盒上限、进入/退出编辑模式、窗口偏移/缩放/停靠位置设置变化。这类变更必须**节流 ≥100ms**(同一窗口内多次合并为一次),且不得与弹簧/高度动画同帧竞争。
4. **视觉结果必须与 ClassIsland 一致**:用户看到的**可见岛高度 = 行高之和 + 行间距之和**(行高恒 `40`,间距 `MainWindowLineVerticalMargin`),**可见岛宽度 = 内容自然宽 + 两侧各 12px 内边距**。窗口比岛大只是透明余量,用户不可见。
5. **禁止**:把窗口高度做成"刚好等于内容高"再逐帧改;用 `requestAnimationFrame` 每帧 `setBounds`;在行内容变化(秒数、课程名长度、提醒)时改窗口尺寸。以上都会导致 1px 级抖动。
**与 ClassIsland 的差异及理由(必须写进实现注释)**:差异 = 窗口高度固定为包围盒 vs 随内容自适应;理由 = Electron 透明窗口 resize 抖动/闪烁,且 ClassHelper 的既有实现已证明"固定包围盒 + 窗内画布形变"在开合过程中零抖动(见该文件 `:64-70` 的设计说明)。**视觉结果不做任何妥协**:可见岛的行高、间距、圆角、阴影与 4.5 完全一致。
#### 4.3.2 可见内容盒(视觉基准公式)
下面的 ClassIsland 原生公式**只用于计算"可见岛"的内容尺寸、安全区裁剪与停靠对齐**,不直接作为 `BrowserWindow.setBounds` 的 height 参数(`setBounds` 规则见 4.3.1):
1. `contentW`、`contentH`:由渲染进程上报的岛内容尺寸(CSS px)。内容盒 = 所有行高度之和 + 行间距之和(见 4.5)。
2. `safe = 主题竖直安全区`:Fluent 主题 `20`(`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:314`),Classic 主题 `0`(`ThemeManifest._verticalSafeAreaPx` 默认 0,`repos/ClassIsland/ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs:20`)。若正在播放教程且教程吸附在本窗口,则 `safe = max(safe, 150)`(`MainWindow.axaml.cs:899-902`)。
3. `dockingTop = docking ∈ {0,1,2}`。
4. `safeT = Math.max(dockingTop ? Math.min(safe, oy) : safe, 0) * scale`。
5. `safeB = Math.max(dockingTop ? safe : Math.min(safe, -oy), 0) * scale`。
6. `岛锚点 X = screen.workArea.x + ox`;`岛锚点 Y = dockingTop ? top + oy - safeT : bottom - contentH + oy + safeB`,其中 `top = ignoreArea ? screen.bounds.y : screen.workArea.y`,`bottom = ignoreArea ? screen.bounds.bottom : screen.workArea.bottom`。
7. `layoutW = screen.bounds.width / dpiX`(布局容器宽 = 整屏宽,用于左/中/右对齐计算,`MainWindow.axaml.cs:915`)。
8. `layoutH = contentH + safeT + safeB`(岛的可布局高度;这只是内容画布高,不是窗口 `height`)。
9. 岛在画布内的偏移:`translateY = safeT`、`translateX = 0`;水平对齐:`docking ∈ {0,3}` → 左、`{1,4}` → 居中、`{2,5}` → 右(对应 `align-items: flex-start / center / flex-end`,与 `WidthDoubleToRectConverter` 的 X 偏移逻辑一致,`repos/ClassIsland/ClassIsland.Core/Converters/WidthDoubleToRectConverter.cs:66-77`)。
10. 窗口自身的包围盒位置必须让"岛锚点"落在公式 6 的位置:`winX = 岛锚点X`、`winY = 岛锚点Y - safePadding`(上停靠)/ `岛锚点Y - contentMaxHeight - safePadding`(下停靠,包围盒底部贴锚点)。
#### 4.3.3 编辑模式窗口
进入编辑模式时**窗口必须扩展为全屏编辑画布**(这一步允许 `setBounds`,因为是模式切换、不是内容变化):
1. `winX = screen.bounds.x`、`winY = screen.bounds.y`。
2. `winW = screen.bounds.width / dpiX`。
3. `winH = (screen.bounds.height - 1) / dpiY`——**必须减 1px**,防止 Windows 把窗口判定成全屏并触发全屏优化(`MainWindow.axaml.cs:970`)。
4. **岛内容在窗口内四边各留 60px 安全边距**:`padding = 60px`(来源 `WindowedEditModeScreenMargin = 60`,`MainWindow.axaml.cs:134`),即编辑画布的可用区为 `winW - 120` × `winH - 120`,岛在其中垂直居中、水平按停靠位置对齐。
5. 内容偏移:`translateX = ox / dpiX`、`translateY = oy / dpiY`(编辑模式偏移是逻辑像素,`MainWindow.axaml.cs:973-974`);`clientRect = ignoreArea ? screen.bounds : screen.workArea`。
6. 鼠标事件**必须开启**:`win.setIgnoreMouseEvents(false)`(见 4.2 第 2 条)。
7. **编辑模式 + 自由窗口(windowed)**:窗口改为取 `screen.workArea` 四边各内缩 60px(`winX = workArea.x + 60`、`winY = workArea.y + 60`、`winW = (workArea.width - 120) / dpiX`、`winH = (workArea.height - 120) / dpiY`),切回系统装饰(`SystemDecorations=Full`、可缩放/最大化/最小化,`MainWindow.axaml:182-191`),标题 `编辑主界面 | ClassIsland`(`MainWindow.axaml:180`)。
8. **退出编辑模式**:恢复 4.3.1 的包围盒(位置 + 尺寸),`win.setIgnoreMouseEvents(true, { forward: true })`,恢复 `skipTaskbar`。
#### 4.3.4 触发重算的时机与节流
必须接上这些时机,但全部走"标记脏 + ≥100ms 节流"的通道,不得每次直接 `setBounds`:
- **包围盒**(4.3.1):显示器/DPI/显示器插拔变化、行数超出 `contentMaxHeight`、编辑模式进出、偏移/缩放/停靠设置变化;
- **岛内容布局**(4.3.2,纯 CSS,不改窗口):主计时器每 tick(`LessonsServiceOnPreMainTimerTicked`,`MainWindow.axaml.cs:475`)、内容容器尺寸变化(`StackPanelRootContainer_OnSizeChanged`,`MainWindow.axaml.cs:786-789`)、任何 `Settings` 属性变化(`MainWindow.axaml.cs:488-501, 730-782`)。
**节流与稳定**:用 `requestAnimationFrame` 合并同一帧内的多次 `setBounds`;同一窗口尺寸在 100ms 内多次变化时只保留最后一次;高度目标值四舍五入到整数 CSS px。禁止在主计时器 tick 里直接改窗口尺寸。
**恢复与兜底**:`WindowDockingMonitorIndex` 超出显示器数量时回退主屏(`MainWindow.axaml.cs:806-812`);`contentH` 为 0(无行/全部隐藏)时岛整体 `opacity:0`(窗口保留包围盒、不改尺寸),避免窗口在 0 高附近抖动。
### 4.4 DOM 层级与 CSS 变量
必须严格复刻下面这棵树(左侧为 `MainWindow.axaml` 的控件,右侧为实现用的标签/类名):
```
Window → html > body > #app
└ Grid#WindowRoot → .window-root(透明,pointer-events:none)
└ ZoomBorder → .zoom-layer(缩放/平移宿主,overflow:visible)
└ Canvas#LayoutContainerGrid → .layout-container(position:relative; width:100%;height:100%)
└ Panel#WorkingRoot → .working-root(position:absolute; left:0; top:safeT; width:窗口宽; height:contentH)
└ StackPanel#StackPanelRootContainer → .lines-root(display:flex; flex-direction:column; align-items:center|flex-start|flex-end)
└ LayoutTransformControl → .lines-transform(transform: translate(ox,oy) scale(scale))
└ Grid#GridRoot → .grid-root(每行背景/遮罩的公共祖先)
└ Border#ResourceLoaderBorder → .resource-loader(font-size:16px,字体族/字重继承)
└ Grid → .lines-host
├ StackPanel → .lines-stack(display:flex; flex-direction:column; row-gap:5px)
│ └ ItemsControl#RootMainWindowLinesItemsControl → .lines-list
│ └ (每行) Grid ColumnDefinitions="Auto,*,Auto" → .line-row(display:grid; grid-template-columns:auto 1fr auto)
└ ItemsControl#ContainerComponentsEditHost → .container-edit-host(仅编辑模式,position:absolute)
```
再加两个编辑模式专用兄弟节点:`.operate-area`(底部操作栏)、`.edit-mode-view`(抽屉宿主)。
CSS 根变量必须在 `#app` 或 `.grid-root` 上集中声明(对齐 ClassHelper 的 `--island-*` 命名习惯,`repos/ClassHelper-master/packages/desktop-client/src/island/IslandApp.vue:522-528`):
```css
:root {
/* 尺寸 token(ClassIsland 事实来源) */
--row-height: 40px; /* IslandContainerHeight,MainWindowLine.axaml:16 / FluentTheme/Styles.axaml:65 / ClassicTheme/Styles.axaml:16 */
--row-padding-x: 12px; /* GridContentRoot Margin="12 0",FluentTheme/Styles.axaml:110 */
--row-min-width: 20px; /* GridContentRoot MinWidth="20",同上 */
--row-gap: 5px; /* MainWindowLineVerticalMargin 默认 5,Models/Settings.cs:219 */
--component-margin-x: 6px; /* ComponentPresenter Margin="6 0",MainWindow.axaml:396 */
--island-radius: 8px; /* RadiusX/RadiusY 默认 8,Models/Settings.cs:183-184 */
--island-opacity: 0.5; /* Opacity 默认 0.5,Models/Settings.cs:64 */
--island-scale: 1; /* Scale 默认 1.0,Models/Settings.cs:86 */
--line-border-width: 1px; /* line-background BorderThickness=1,FluentTheme/Styles.axaml:47 */
--line-faded-opacity: 0.05; /* MainWindowLine.axaml:74 */
--safe-top: 20px; /* Fluent 主题安全区,App.Services.xaml.cs:314 */
--safe-bottom: 20px;
/* 字号 token */
--font-secondary: 14px; /* Models/Settings.cs:197 */
--font-body: 16px; /* Models/Settings.cs:198 */
--font-emphasized: 18px; /* Models/Settings.cs:199 */
--font-large: 20px; /* Models/Settings.cs:200 */
/* 字体 */
--font-family: 'HarmonyOS Sans SC', 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', sans-serif;
--font-weight: 500; /* MainWindowFontWeight2 默认 FontWeight.Medium,Models/Settings.cs:101 */
}
```
**与设计 token 的命名对齐(强制)**:本文为便于阅读使用语义化局部名(`--row-height`、`--font-body` 等),落地时**必须**改用 part 3 定义的 `--ci-*` 规范 token,局部名仅作为别名。对应关系:
| 本文局部名 | 规范 token(part 3) |
|---|---|
| `--row-height` | `--ci-island-height`(=40px) |
| `--row-padding-x` / `--row-min-width` | `--ci-island-pad-x`(=12px)/ `--ci-island-min-w`(=20px) |
| `--row-gap` | `--ci-line-gap`(=5px) |
| `--component-margin-x` | `calc(var(--ci-component-gap) / 2)`(=6px) |
| `--island-radius` | `--ci-radius-island`(=8px) |
| `--island-opacity`(背景层不透明度,0.5) | `--ci-island-opacity`(=0.5,作用于行背景) |
| 行整体不透明度(`MainWindowLineSettings.Opacity`,默认 1.0) | `--ci-line-opacity`(=1.0,作用于整行 `opacity`;与上面的背景不透明度是两个独立字段,不得合并) |
| `--island-scale` | `--ci-scale`(=1) |
| `--font-secondary/body/emphasized/large` | `--ci-fs-secondary/body/emphasized/large`(14/16/18/20) |
| `--accent` | `--ci-accent`;强调填充用 `--ci-accent-fill` |
| 行背景(Fluent) | `--ci-bg-secondary`;描边 `--ci-stroke-island` + `--ci-stroke-island-w` |
| 行阴影 | `--ci-shadow-island`;遮罩阴影 `--ci-shadow-mask`;Classic 遮罩阴影 `--ci-shadow-classic-mask` |
| 编辑模式遮罩层 | `--ci-smoke` |
| 编辑操作栏底距 | `--ci-editbar-bottom`(=64px) |
| 抽屉宽度 / 设置区最大宽 | `--ci-settings-max-w`(=960px) |
| 抽屉圆角 / 阴影 | `--ci-radius-drawer` / `--ci-shadow-drawer` |
| 行内强调底 | `--ci-accent-fill-tertiary` |
| 文本层级色 | `--ci-text-1/2/3`(对应 primary/secondary/tertiary) |
| 进度条高度 | `--ci-progress-height`(**固定 3px**,所有进度条共用) |
| 层级 | `--ci-z-mask` / `--ci-z-drawer` / `--ci-z-window` |
**字号/字体注入链**:`--font-*` 由设置写入 `.resource-loader`(对应 `Border#ResourceLoaderBorder` 上 `TextElement.FontSize = MainWindowBodyFontSize`,`MainWindow.axaml:311-313` 与 `MainWindow.axaml.cs:766-778`),所有子组件用 `font-size: var(--font-body)` 继承;字体族与字重写在 `.zoom-layer`(对应 `ZoomBorder` 上 `TextElement.FontFamily/FontWeight` 绑定,`MainWindow.axaml:211-212`)。行级/组件级"资源覆盖"(`IsResourceOverridingEnabled`)时用内联 CSS 变量覆盖同一批 token。
### 4.5 主界面行(MainWindowLine)
#### 4.5.1 三种模板
每行按条件选模板,不得混用:
| 模板 | 触发条件 | 差异 |
|---|---|---|
| Full(完整) | `IsNotificationEnabled = true` | 含背景、遮罩(Mask)、浮层(Overlay)、进度条 |
| Min(精简) | `IsNotificationEnabled = false`(`FluentTheme/Styles.axaml:275-278`、`ClassicTheme/Styles.axaml:219-222`) | 只有背景 + 内容,不含遮罩/浮层/进度条 |
| 分体(Separated) | `MainWindowLineSettings.IslandSeparationMode` 覆盖为"启用",或全局 `Settings.IsIslandSeperated=true`(`MainWindowLine.cs:478-492`) | 不再画整行背景,改为每个组件各自一个背景胶囊(见 4.5.6) |
`IslandSeparationMode` 的取值语义固定为 `0=继承(用全局设置)`、`1=禁用`、`2=启用`(`repos/ClassIsland/ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:47-50`,UI 文案"继承/禁用/启用"见 `EditModeView.axaml:319-329`)。
#### 4.5.2 尺寸与间距(精确值)
- 行高固定 `40px`:`IslandContainerHeight = 40`(三处定义:`Controls/MainWindowLine.axaml:16`、`XamlThemes/FluentTheme/Styles.axaml:65`、`XamlThemes/ClassicTheme/Styles.axaml:16`),并同时用作 `Border#BackgroundBorder.Height`、`Grid#PART_GridWrapper.Height`、`Grid#GridContentRoot.Height`、遮罩高度、`ListBoxItem.MaxHeight`。
- 内容盒左右内边距 `12px`:`Grid#GridContentRoot Margin="12 0"`(`FluentTheme/Styles.axaml:110`、`ClassicTheme/Styles.axaml:100`)。
- 内容盒最小宽 `20px`:同上 `MinWidth="20"`。
- 行与行之间垂直间距 `5px`:`ItemsPanel StackPanel Spacing="{Binding Settings.MainWindowLineVerticalMargin}"`(`MainWindow.axaml:344`),默认 `5`(`Models/Settings.cs:219`,设置面板范围 `0–150`、步进 `5`,`EditModeView.axaml:114-120`)。
- 行内相邻组件水平间距 `12px`:每个组件宿主 `Margin="6 0"`(`MainWindow.axaml:396`),左右各 6 相加得 12;容器组件内部再用 `Margin="-6 0"` 抵消一次(`StackComponent.axaml:14`、`GroupComponent.axaml:15`、`RollingComponent.axaml:16`),因此嵌套容器不会累积边距。
- 行背景宽度 = 内容实际宽度,不是窗口宽度:`BackgroundWidth` 由 `PART_GridWrapper.SizeChanged` 回写(`MainWindowLine.cs:614-617`),背景 Border 与遮罩都绑 `Width="{Binding BackgroundWidth}"`(`FluentTheme/Styles.axaml:81, 94`)。
#### 4.5.3 背景(Fluent 主题,默认主题)
逐条写死(`XamlThemes/FluentTheme/Styles.axaml:40-56`):
```css
.line-bg {
position: absolute;
height: var(--row-height);
width: var(--line-width); /* = BackgroundWidth,由内容决定 */
background: var(--fill-secondary); /* FluentAvalonia: SolidBackgroundFillColorSecondaryBrush */
border: var(--line-border-width) solid var(--control-elevation-border); /* ControlElevationBorderBrush */
border-radius: 8px; /* = Settings.RadiusX,见下方说明 */
box-shadow: 0 4px 8px 2px #48000000; /* "0 4 8 2 #48000000" */
opacity: var(--island-opacity); /* = Settings.Opacity,默认 0.5 */
}
```
- 圆角 = `Settings.RadiusX` 直接作为**四角统一半径**(转换器 `SizeDoubleToCornerRadiusConverter` 以参数 `1.0` 相除,`repos/ClassIsland/ClassIsland.Core/Converters/SizeDoubleToCornerRadiusConverter.cs:8-17`,调用处 `FluentTheme/Styles.axaml:48`)。默认 `8`,设置范围 `0–20`、步进 `0.5`(`Models/Settings.cs:183`, `EditModeView.axaml:98-104`)。当半径 ≥ 行高一半(20)时左右边缘为半圆。
- 自定义背景色开启时(`IsCustomBackgroundColorEnabled`)背景色替换为 `Settings.BackgroundColor`(默认黑色,`Models/Settings.cs:34`),仍保留边框、阴影、透明度(`FluentTheme/Styles.axaml:51-56`)。
- **背景不透明度只作用于背景层**:`Opacity` 绑在 `Border.line-background` 上(`FluentTheme/Styles.axaml:44`),文字不跟着透明。这是硬性要求:文字必须始终 100% 不透明。
#### 4.5.4 行裁剪(圆角裁切矩形)
行内容用几何裁剪切出圆角矩形,X 偏移随停靠位置变化(`repos/ClassIsland/ClassIsland.Core/Converters/WidthDoubleToRectConverter.cs:55-92`,应用处 `FluentTheme/Styles.axaml:21-38`):
```
clipRectWidth = BackgroundWidth // 内容宽
clipRectHeight = 行高(40) 或实际 Bounds.Height
clipX = docking ∈ {0,3} ? 0
: docking ∈ {1,4} ? (行容器宽 - BackgroundWidth)/2
: (行容器宽 - BackgroundWidth) // {2,5}
clipRadius = (RadiusX, RadiusY) = (8, 8)
```
CSS 实现:行容器 `.line-row` 上 `overflow: visible`,内容层 `.line-clip` 用 `clip-path: inset(0 round 8px)` 并把 `.line-clip` 定位到 `left: clipX`、`width: BackgroundWidth`、`height: 40px`;或直接对内容层设置 `clip-path: inset(0 calc(...) 0 ...) round 8px`。分体模式关闭该裁剪(`clip-host` 只在 `IsIslandSeperated=False` 或 `clip-enforcing` 时生效,`FluentTheme/Styles.axaml:21`)。
#### 4.5.5 行可见性(三态组合,必须逐条实现)
行的 `IsVisible` 是下面表达式的逻辑或(`Controls/MainWindowLine.axaml:29-49`):
```
visible = ( ( !IsAllComponentsHid || IsOverlayOpen )
&& ( Settings.HideOnRule ? Settings.IsVisible : true ) )
|| MainWindowInEditMode
```
其中:
- `IsAllComponentsHid` = 该行所有组件都不可见(`MainWindowLine.cs:408-413`,判定 `Settings.Children.Any(x => x.IsVisible) == false`);注意"全部组件都被隐藏规则隐藏"时整行消失,而不是留一条空胶囊。
- `IsOverlayOpen` = 该行正在播放提醒(遮罩/浮层打开);提醒期间即使组件全隐藏也要显示。
- 行的规则隐藏(`HideOnRule=true`)由 `HidingRules` 规则集求值,规则满足时 `IsVisibleInternal=false` 并订阅 `RulesetService.StatusUpdated` 实时重算(`MainWindowLine.cs:347-381`)。
- 编辑模式下**永远可见**(否则无法编辑隐藏行)。
行的 `Opacity` = `Settings.Opacity`(行级不透明度,默认 `1.0`,范围 `0.1–1`、步进 `0.05`,`MainWindowLine.axaml:51`、`MainWindowLineSettings.cs:42`、`EditModeView.axaml:340-346`)。
**鼠标淡出**("指针不在岛上就变淡"):
- 行级:`IsLineFaded = IsMouseInFadingEnabled && (mouseIn XOR IsMouseInFadingReversed)`,为 `true` 且非编辑模式时行 `opacity = 0.05`(`MainWindowLine.cs:503-509`、`MainWindowLine.axaml:73-75`)。默认 `IsMouseInFadingEnabled=true`、`IsMouseInFadingReversed=false`(`Models/Settings.cs:193-194`)。
- 判定鼠标是否在行内:把行内容矩形转换为屏幕物理坐标后与指针比较,宽度/高度都要乘 `dpi * scale`(`MainWindowLine.cs:619-637`)。
- 触摸屏:`TouchInFadingDurationMs > 0` 时,触摸命中后延迟该毫秒数再把"鼠标在内"置否(`MainWindowLine.cs:520-550`,默认 `0`,`Models/Settings.cs:195`)。
- 平台差异:靠 `pointerover` 判定而不是原始输入。
#### 4.5.6 分体主界面(Separated)
全局 `Settings.IsIslandSeperated = true` 或行级 `IslandSeparationMode = 2` 时:
- 行不再画整行背景(`BackgroundBorderWrapper.IsVisible = !IsIslandSeperated`,`FluentTheme/Styles.axaml:78`)。
- 每个顶层组件各自渲染一个胶囊(`FluentTheme/Styles.axaml:245-259`):
```css
.component-island {
position: relative;
margin: -2px 0; /* Panel Margin="-2 0" */
display: flex;
align-items: center;
}
.component-island > .bg {
position: absolute; inset: 0;
background: 行背景色; opacity: var(--island-opacity);
border: 1px solid 边框色;
border-radius: 8px;
box-shadow: 0 4px 8px 2px #48000000;
}
.component-island > .content {
position: relative;
min-width: 40px; /* Border MinWidth="40" */
padding: 0 16px; /* ContentPresenter Margin="16 0" */
border-radius: 8px;
overflow: hidden;
}
```
- 分体模式下窗口内容仍按整行排布(`MainWindow.axaml:213-217` 的 `WorkingRoot` 宽度 = 客户端宽),但每行的高度仍 `40`,行内组件按原顺序横排,胶囊之间保留 `12px` 间隔(每个组件 `Margin="6 0"`)。
- 分体模式下行的 `clip-host` 不再参与裁剪(`FluentTheme/Styles.axaml:21`),阴影由组件胶囊自己承担。
#### 4.5.7 行的过渡动画
- 行内容透明度:`opacity` 过渡 `100ms`,线性(`DoubleTransition Property="Opacity" Duration="0:0:0.100"`,`FluentTheme/Styles.axaml:268`、`ClassicTheme/Styles.axaml:206`)。
- 背景宽度:Fluent `300ms` `cubic-bezier(0.65, 0, 0.35, 1.0)`(`FluentTheme/Styles.axaml:269`);Classic `550ms` `cubic-bezier(0, 1, 0.5, 1.10)`(`ClassicTheme/Styles.axaml:207`,注意控制点 `y>1` 会产生一次轻微回弹,必须照写)。
- 行首次挂载淡入:`AnimationLevel ≥ 2` 时用组合层做 `opacity 0→1`、`250ms`、`cubic-bezier(0.25, 1, 0.5, 1)`(`MainWindowLine.cs:382-401`)。`AnimationLevel` 默认 `1`(`Models/Settings.cs:1851`),即默认不播。
- 行宽变化(组件增删、内容变化)由"自适应面板重排动画"驱动:`WrapPanelResizingAnimationAssist.Duration = 0.300s`、`Easing = (0.65, 0, 0.35, 1.0)`,挂在 `Grid#WindowRoot` 上(`MainWindow.axaml:197-198`),并把 `animated-resize` 类加给行的外层 `Panel`(`FluentTheme/Styles.axaml:103`)。编辑模式下该动画关闭(`MainWindow.axaml:345` 的 `Classes.animated-resize="{Binding !MainWindowInEditMode}"`)。
#### 4.5.8 行设置项(`MainWindowLineSettings`)与默认值
必须实现为一个 JSON 结构 + 抽屉表单。字段与默认值一律照抄(`repos/ClassIsland/ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:17-50`):
| 字段 | 类型 | 默认 | 说明 / UI |
|---|---|---|---|
| `Children` | 数组 | `[]` | 行内组件 |
| `IsMainLine` | bool | `false` | 主要行;全局唯一 |
| `IsNotificationEnabled` | bool | `true` | 是否允许显示提醒;关掉后该行用 Min 模板 |
| `IsResourceOverridingEnabled` | bool | `false` | 覆盖字号资源 |
| `MainWindowSecondaryFontSize` | number | `14` | 次级字号,范围 1–40 |
| `MainWindowBodyFontSize` | number | `16` | 正文字号 |
| `MainWindowEmphasizedFontSize` | number | `18` | 强调字号 |
| `MainWindowLargeFontSize` | number | `20` | 大号字号 |
| `IsCustomForegroundColorEnabled` | bool | `false` | 自定义字体颜色 |
| `ForegroundColor` | color | `DodgerBlue` | 默认字体色 |
| `BackgroundOpacity` | number | `0.5` | 自定义背景不透明度 |
| `IsCustomBackgroundOpacityEnabled` | bool | `false` | |
| `BackgroundColor` | color | `Black` | |
| `IsCustomBackgroundColorEnabled` | bool | `false` | |
| `CustomCornerRadius` | number | `0` | 自定义圆角,范围 0–20 |
| `IsCustomCornerRadiusEnabled` | bool | `false` | |
| `Opacity` | number | `1.0` | 整行不透明度,范围 0.1–1 |
| `IsVisible` | bool | `true` | 运行时态(规则求值结果) |
| `HideOnRule` | bool | `false` | 按规则隐藏 |
| `HidingRules` | Ruleset | `{}` | 规则集 |
| `IslandSeparationMode` | int | `0` | 0 继承 / 1 禁用 / 2 启用 |
行为约束:
- 主要行唯一:勾选某行为主要行时,其余行必须自动取消(`MainWindow.axaml.cs:1569-1588`);取消唯一的主要行时,自动把第一行设为主要行并弹 toast"已将第一行设置为主要行。"
- 主要行是提醒注册的优先消费者(注册序号 `-1`,非主要行用行号,`MainWindowLine.cs:441`)。
- **本部分只负责"提醒宿主"与 `IsNotificationEnabled` 标记**:行模板的遮罩/浮层/进度条容器(4.7)、行的可见性联动、`IsNotificationEnabled` 决定走 Full 还是 Min 模板。**多行之间谁显示提醒的分发规则归属第 7 章(提醒系统)**,本文不重复、不得在此臆造优先级。
- 删除行:只剩 1 行时禁止删除并弹警告"至少需要保留 1 个主界面行。"(`MainWindow.axaml.cs:1598-1612`)。
- 关闭所有行的提醒显示时弹警告"您已经禁用了所有主界面行的提醒显示功能……"(`MainWindow.axaml.cs:1590-1596`)。
#### 4.5.9 行内编辑控件(仅在编辑模式显示)
行模板右侧第三列(`MainWindow.axaml:401-444`)在编辑模式显示一组按钮,容器属性:
```css
.line-actions { /* StackPanel Classes="items-valign-center" */
display: flex;
align-items: center;
gap: 2px; /* Spacing="2" */
font-size: 14px; /* TextElement.FontSize="14" */
}
.line-actions .btn { background: transparent; border: none; }
.line-actions .btn-delete { color: IndianRed; }
```
四个按钮(自左至右,全部 `VerticalAlignment=Center`、`Background=Transparent`、`BorderThickness=0`):
1. 行设置:图标 `\uEF27`,ToolTip "设置…",打开行设置抽屉。
2. 主要行开关(ToggleButton):图标 `\uE994`,ToolTip "此行是主要行"。
3. 提醒开关(ToggleButton):图标 `\uE02B`,ToolTip "此行可以显示提醒"。
4. 删除(Button,`Foreground=IndianRed`,`Theme=TransparentButton`):图标 `\uE61D`,ToolTip "删除"。
行选择框(第一列的虚线矩形,`MainWindow.axaml:353-363`):
```css
.line-select-box {
position: absolute; inset: 0;
border: 1.5px dashed rgba(控制描边色);
border-radius: 0; /* 无圆角 */
opacity: 0.75;
/* StrokeDashArray="1, 1.5" → dash 1px、gap 1.5px */
background: none;
}
```
拖动把手(第一列,`MainWindow.axaml:364-367`):`TouchDragThumb`,仅在编辑模式可见,垂直方向拖动改变行顺序;拖动阈值由 `AdvancedItemDragBehavior` 决定,其 `Orientation="Vertical"`、`HorizontalDragThreshold="33550336"`(即水平拖拽被彻底禁用)、`AllowDragFromDragThumbOnly="True"`(`MainWindow.axaml:332-335`)。
"新主界面行"按钮(`MainWindow.axaml:449-455`):水平居中、`font-size:14px`、内容为图标 `\uE00D` + 文本"新主界面行";仅在编辑模式可见;点击追加一行默认配置并在该行下方插入。
### 4.6 主界面的缩放(裁决:只做缩放,不做平移)
- `ZoomBorder`(`MainWindow.axaml:199-212`)必须实现为 `.zoom-layer`,参数:`Stretch=None`、`ZoomSpeed=1.2`(每格缩放倍率)、`EnableConstrains=True`、`MaxZoomX=MaxZoomY=3`、`MinZoomX=MinZoomY=0.5`、`ShowZoomIndicator=true`、`EnableGestureRotation=False`、`Focusable=true`、`Background=Transparent`。
- **缩放手势固定为 `Ctrl + 滚轮`**:以指针位置为锚点缩放,每格倍率按 `ZoomSpeed = 1.2` 指数累乘,范围钳制在 `[0.5, 3.0]`(= `MinZoomX/Y` 与 `MaxZoomX/Y`)。不带 `Ctrl` 的滚轮不得缩放(避免与页面/抽屉滚动冲突)。
- **双击画布空白处重置为 100%**(等价 `ResetMatrix`);底部操作栏的"重置视图"按钮同样恢复 100%。
- 缩放指示器:非 100% 时在窗口左下角显示当前倍率,`font-size:12px`,`background:#77222222`、`color:#fff`【推断:样式对齐 `MainWindow.axaml:643-649` 的调试文本样式】。
- **P2 / 不做平移**:首版只实现 `Ctrl+滚轮` 缩放与双击重置;`Space + 拖拽` 平移标 **P2 /【推断】**(ClassIsland 的 `ZoomBorder` 支持拖拽平移,但控件源码未定位,且非编辑态整窗鼠标穿透用不到),不实现。
- **设置里的"界面缩放"是另一件事**:`Settings.Scale` 默认 `1.0`,范围 `0.75–2.5`、步进 `0.05`(`Models/Settings.cs:86`、`EditModeView.axaml:50-57`),应用在 `.lines-transform` 的 `transform: scale()`,`transform-origin` 需按停靠位置设置:上停靠 `center top`、下停靠 `center bottom`(对齐 `LayoutTransformControl` 的布局缩放语义,`MainWindow.axaml:257-265`)。
- 两者叠加:总视觉倍率 = 缩放设置 × ZoomBorder 倍率;**窗口包围盒只由"未缩放的内容尺寸"决定**(见 4.3.1),缩放只改渲染层 `transform`,不改窗口尺寸。
- **不做平移**:见上条 P2 说明;`EnableConstrains=true` 仅用于把缩放后的内容约束在窗口内。
### 4.7 提醒宿主、遮罩与进度条(行内)
Full 模板的行内部结构(`XamlThemes/FluentTheme/Styles.axaml:70-202`)必须按下面层级实现:
```
.line (Grid#GridRoot)
├ .line-bg-wrap → Border#BackgroundBorderWrapper (整行背景层,分体模式隐藏)
│ └ .line-bg → Border#BackgroundBorder (Classes: docking line-background)
├ .line-bg-mask → Border#BackgroundBorderOverlayMask (强调色遮罩,Opacity 0→1,仅提醒时)
│ · opacity 过渡只对 BoxShadow 做 0.1s
├ .line-clip → Grid#ContentClipBorder (Classes: clip-host)
│ └ .line-panel → Panel (Classes: animated-resize)
│ └ .grid-wrapper → Grid#PART_GridWrapper (height 40, Classes: docking)
│ ├ .content-root → Grid#GridContentRoot (height 40, min-width 20, margin 0 12)
│ │ └ .components (组件列表)
│ ├ .overlay → Grid#GridOverlay (opacity 0)
│ │ ├ .overlay-bg → Border (line-background,分体模式才可见)
│ │ └ .overlay-clip → Border (clip, corner-radius 8)
│ │ └ Panel
│ │ ├ Canvas#ProgressContainer (bottom, height = progressBar 高度)
│ │ │ └ ProgressBar#OverlayTimeProgressBar (Maximum 1, Value = LeftProgress, MinWidth 0)
│ │ └ ContentPresenter(OverlayContent,MaxHeight 40)
│ └ .mask-shadow → Border#OverlayMaskShadowRoot (仅非编辑模式)
│ └ .mask → Border#OverlayMask (背景 AccentFillColorDefaultBrush, 前景 TextOnAccentFillColorPrimaryBrush, clip, height 40)
│ └ Panel
│ ├ SlantedMaskControl (Classes: mask-bg, RegionDurationMs=360, StageStaggerMs=120, Fill=强调色)
│ └ Border (Padding 0 12)
│ └ .mask-content → ContentPresenter#OverlayMaskContent (opacity 0, scale)
```
#### 4.7.1 进度条(提醒剩余时间)
- 容器 `Canvas` 贴底、水平拉伸,高度绑定进度条自身高度(`FluentTheme/Styles.axaml:127-147`)。
- `ProgressBar`:`Maximum=1`、`Value = 当前提醒的 LeftProgress`(0–1,`MainWindowLine.axaml:77`)、`MinWidth=0`、`Width = 容器宽`、贴底。
- 值变化过渡:`50ms`、线性(`DoubleTransition Property="Value" Duration="0:0:0.05"` + `LinearEasing`,`FluentTheme/Styles.axaml:137-145`)。
- 实现:进度条高度**固定 `3px`**(token `--ci-progress-height: 3px`,`border-radius: 1.5px`,贴底;三条进度条——行提醒进度条、课程进度条、倒计时进度条——共用同一 token),颜色取 `AccentFillColorDefaultBrush`,`transform: scaleX(value)` + `transform-origin:left` 比改 width 更省重排;但不能有过渡抖动,`transition: transform 50ms linear`。
- `ReduceProgressAccuracy=true`(默认,`Models/Settings.cs:1853`)时进度按步长量化:`progressTick = max(10, totalSeconds/500)`,`value = round(seconds/progressTick)*progressTick`(倒计时组件同款算法,`Controls/Components/CountDownComponent.axaml.cs:288-295`)。主界面行进度条同样应做降频更新(100ms 级),`false` 时按 1 秒精度。
#### 4.7.2 遮罩(Mask)动画
- `SlantedMaskControl`:5 个区段宽度权重 `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]` 的累计边界,斜面角度固定 `60°`(`Controls/SlantedMaskControl.cs:284, 298-308`),填充色 `AccentFillColorDefaultBrush`。
- 参数:`RegionDurationMs = 360`、`StageStaggerMs = 120`(行模板里显式赋值,`FluentTheme/Styles.axaml:178-179`);控件默认值 `300`/`120`(`SlantedMaskControl.cs:29-33`)——以行模板的 `360/120` 为准。
- 打开(`mask-in`)顺序:区段 0 与 4 立即开始(delay 0),区段 1 与 3 延迟 `stag*0.75 = 90ms`,区段 2 延迟 `stag*1 = 120ms`;缓动 `QuarticEaseOut`;每段时长为独立的关键帧(`SlantedMaskControl.cs:167-188`)。总时长 = `120 + 360 = 480ms`。
- 关闭(`mask-out`)顺序反向:区段 2 先(delay 0),1/3 延迟 90ms,0/4 延迟 120ms;缓动 `QuadraticEaseIn`(`SlantedMaskControl.cs:189-200`)。
- CSS 实现建议:遮罩层用 5 个 `clip-path` 平行四边形(`skewX(-30deg)` 等价于 60° 斜面),每个用 `animation: mask-region 360ms <ease> <delay> both`,`--scale-x: 0 → 1`,`transform-origin` 取各自区段中心。
- 遮罩背后的内容:`Grid#GridContentRoot.Opacity` 在 `mask-in` 时置 0,`:mask-out` 时也置 0,`:overlay-in/out` 控制恢复(`FluentTheme/Styles.axaml:293-305, 368-370, 411-413, 438-450`)。
- 遮罩投影:`mask-in` 期间背景层与遮罩层加 `BoxShadow = 0 8 32 0 #66000000`(`FluentTheme/Styles.axaml:323-328`),`mask-out` 时淡掉。
- 遮罩内容入场:`delay 260ms`、`duration 250ms`、`ease cubic-bezier(0.25, 1, 0.5, 1)`、`opacity 0→1`(`FluentTheme/Styles.axaml:329-340`);同时 `scale 1.1→1.0`、`duration 750ms`、`delay 260ms`、同缓动(`:341-352`)。
- 遮罩内容容器:`Padding="12 0"`、前景色 `TextOnAccentFillColorPrimaryBrush`(`FluentTheme/Styles.axaml:181-184`)。
- 强调色底:`Border#BackgroundBorderOverlayMask` 背景 = `AccentFillColorDefaultBrush`(`StylesBase.axaml:90-93`),`Opacity 0→1`、`delay 600ms`、`duration 100ms`、`FillMode=Forward`(`FluentTheme/Styles.axaml:355-367`)。
**Classic 主题的等价实现**(必须两套都做,用 CSS 变量或 `data-theme` 切换;`XamlThemes/ClassicTheme/Styles.axaml`):
- 遮罩与强调层用 `TranslateTransform Y=-50` 起手;`mask-in`:`duration 200ms`、`QuadraticEaseIn`、`Y: -50 → 0`(`:265-281`)。
- `mask-out`:`delay 100ms`、`duration 200ms`、`QuadraticEaseOut`、`Y: 0 → 50`(`:305-318`)。
- 遮罩内容淡入:`delay 200ms`、`duration 100ms`、`opacity 0→1`(`:288-300`);淡出 `100ms`、`opacity 1→0`(`:319-331`)。
- 首次出现时给 `Grid#GridRoot` 挂 `DropShadowEffect(OffsetX=0, OffsetY=0, Opacity=1, BlurRadius=8)`,`Effect` 过渡 `100ms` 线性(`:34-45, 283-287`)。
#### 4.7.3 浮层(Overlay)
- `Grid#GridOverlay` 初始 `Opacity=0`,带 `DoubleTransition Opacity 200ms SineEaseIn`(`FluentTheme/Styles.axaml:419-432`)。
- `:overlay-in` → 浮层 `opacity 1`、内容层 `opacity 0`;`:overlay-out` → 浮层 `opacity 0`、内容层 `opacity 1`(`:434-451`)。
- 浮层内容 `MaxHeight = 40`(`FluentTheme/Styles.axaml:149`);浮层里的进度条只显示"当前提醒剩余时间"。
### 4.8 编辑模式
#### 4.8.1 进入与退出
- 入口:原生菜单"编辑主界面…"(`MainWindow.axaml:108-109`)、URI `classisland://app/edit`(`MainWindow.axaml.cs:295`)。
- 进入前必须通过权限校验(`ManagementService.AuthorizeByLevel(EditSettingsAuthorizeLevel)`,`MainWindow.axaml.cs:1357-1362`);被策略禁用时直接返回。
- 进入步骤(按序):`IsEditMode = true` → 窗口 `Activate()` → 挂载编辑视图 → `ZoomBorder.ResetMatrix()` 在退出时调用 → `TopmostEffectWindow.ViewModel.IsEditMode` 同步 → 更新窗口层级 → 重新加载主题(`MainWindow.axaml.cs:510-528`)。
- 退出步骤:关闭两级抽屉 → `IsEditMode=false` → 清空教程提示 → **保存组件配置**(`ComponentsService.SaveConfig()`,`MainWindow.axaml.cs:1391-1403`)。保存必须幂等且原子(写临时文件再替换)。
- 编辑模式的窗口态:ClassIsland 用 `Background = SmokeFillColorDefaultBrush` + `TransparencyLevelHint = "AcrylicBlur, Transparent"` + 标题 `编辑主界面 | ClassIsland`(`MainWindow.axaml:177-181`)。**本实现的裁决**:
- **禁止**调用 Electron 的 `setBackgroundMaterial()` / `vibrancy` / `backgroundMaterial` 等任何系统材质 API(原因见 4.1 第 2 条与 `repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:523-524`)。
- 替代方案:一层全屏半透明深色遮罩 div,`background: rgba(0, 0, 0, 0.30)`(token `--ci-smoke`;深色主题可下调至 `0.24`、浅色主题可上调至 `0.36`,但**默认必须是 `0.30`**),窗口保持 `transparent: true` 逐像素透明。
- **不得使用 `backdrop-filter: blur()`**:透明窗口下 Chromium 的背板采样不可靠,会出现整块灰底/边缘发白。
- 【推断 / 差异说明】该方案与 FluentAvalonia 的 Acrylic 是**近似而非等价**:Acrylic 带轻微模糊与噪声,本实现只有纯色压暗。这是**有意接受的视觉差异**,不得在实现说明里写成"与 Acrylic 完全一致";验收只要求"编辑态背景明显压暗、岛与抽屉可读、退出后恢复逐像素透明"。
- 编辑模式下窗口必须接收鼠标:`win.setIgnoreMouseEvents(false)`;同时 `Transparent` 窗口特性关闭(`MainWindow.axaml.cs:839`)。
- 平台兜底:Windows 10 1803 及更早版本不支持 windowed 透明,需给窗口加标记类 `no-windowed-transparent`,此时 windowed+edit 模式用不透明底色 `SolidBackgroundFillColorBaseBrush`、`TransparencyLevelHint=None`(`MainWindow.axaml:187-190`, `MainWindow.axaml.cs:257-260`)。
#### 4.8.2 编辑画布
- 画布 = 全屏(宽 = 屏宽,高 = 屏高 −1px),岛内容在画布内**四边各留 60px 安全边距**,再按 `(ox, oy)` 偏移(见 4.3.3 编辑模式窗口公式)。
- 行容器对齐:`dock-top` → `align-items:flex-start`,`dock-bottom` → `align-items:flex-end`,编辑模式 → `justify-content:center`(对应 `StackPanelRootContainer` 的 `VerticalAlignment` 覆写,`MainWindow.axaml:240-248`)。编辑模式下内容整体垂直居中,方便拖拽。
- 底部操作栏占用画布底部 64px 区域,安全边距必须保证岛内容不被操作栏遮挡(岛最高可到画布高 −120px)。
- 空白处左键点击:清除当前选中的组件并关闭组件设置抽屉(`MainWindow.axaml.cs:1511-1535`,只有点在 `ZoomBorder` 自身(非命中子控件)时触发)。
#### 4.8.3 底部操作栏(OperateArea)
精确规格(`MainWindow.axaml:595-634`):
```css
.operate-area {
position: fixed;
left: 50%; transform: translateX(-50%);
bottom: 64px; /* Margin="0 0 0 64" */
background: var(--commandbar-overflow-bg); /* CommandBarOverflowPresenterBackground */
border: 1px solid var(--commandbar-overflow-border);
border-radius: 6px;
padding: 4px;
display: flex; /* 内部 StackPanel Spacing="6" */
gap: 6px;
z-index: 10;
pointer-events: auto;
}
.operate-area .divider { width: 1px; height: 25px; background: var(--divider-stroke); align-self: center; }
```
内部按钮(自左至右,除"完成"外均为 `TransparentButton` 主题):
1. "重置视图"——图标 `\uE12F`,命令 = 缩放重置。
2. 竖分隔线:`StartPoint 0,0 → EndPoint 0,25`、`StrokeThickness=1`、颜色 `DividerStrokeColorDefaultBrush`。
3. "外观"——图标 `\uE51E`,打开外观抽屉;`DisableSettingsEditing` 策略下禁用。
4. "添加组件"——图标 `\uE071`,打开组件库抽屉;同策略禁用。
5. 组件配置按钮——图标 `\uE699`,文本 = 当前配置名(`Settings.CurrentComponentConfig`,默认 `"Default"`,`Models/Settings.cs:159`),`MaxWidth=150`;打开配置管理抽屉。
6. "完成"——图标 `\uE424`,`Classes="accent"`(强调按钮),退出编辑模式。
#### 4.8.4 抽屉(VerticalDrawer)
- 主抽屉:`RequestedWidth = 960`(`SettingsContainerWidth`,`ThemeBase.axaml:37`;`EditModeView.axaml:600-605`),`AllowCollapse=True`,`SmokeBackground=False`。
- 次抽屉:`SmokeBackground=True`、`AllowCollapse=False`、`ContentHeightRatio=0.5`(`EditModeView.axaml:606-611`)。
- 外壳规格(`repos/ClassIsland/ClassIsland.Core/Controls/VerticalDrawer.axaml:8-155`):
```css
.drawer-host { position:absolute; inset:0; pointer-events:none; }
.drawer-smoke { position:absolute; inset:0; background: var(--smoke-fill); opacity:0; transition: opacity 250ms cubic-bezier(0,0,0,1); }
.drawer-smoke.open { opacity:1; pointer-events:auto; }
.drawer-panel {
position:absolute; bottom:0; left:50%; transform: translateX(-50%);
width: 960px; max-width: 100%;
max-height: 80vh; /* 【推断:ContainerHeight 由内容/宿主决定,见下 */
background: var(--content-dialog-bg);
border: 1px solid var(--content-dialog-border);
border-top-left-radius: 8px; border-top-right-radius: 8px; /* "8 8 0 0" */
box-shadow: 0 8px 32px 0 #66000000;
overflow: hidden;
pointer-events: auto;
}
.drawer-inner { padding: 8px 8px 0; } /* Padding="8 8 8 0" */
.drawer-head { height: 32px; display:flex; align-items:center; justify-content:space-between; }
.drawer-body { min-height: 0; overflow:auto; }
```
- 抽屉升降过渡:`Canvas.Bottom` 与 `Height` 各 `250ms`、`cubic-bezier(0,0,0,1)`(`VerticalDrawer.axaml:29-36`)。
- 标题栏右侧三个按钮:收起 `\uEC5C`(仅 `AllowCollapse` 且已展开时可见)、展开 `\uEC5E`(`AllowCollapse` 且非展开时)、关闭 `\uE671`(`VerticalDrawer.axaml:64-99`)。
- 抽屉内容宽度上限 = `960px`;设置项容器 `.settings-container` 的 `Margin = 12 12 18 12`、`Spacing = 4`(`StylesBase.axaml:67-71`)。
- 抽屉打开时挂载"逐项入场"动画类 `animated-intro`(`EditModeView.axaml:43, 317`),对应 `ClassIsland.Core/Themes/StackPanelIntroAnimation.axaml`(时长/曲线见第 14 章)。
- 拖拽组件时主抽屉自动临时收起(`Collapsed`),拖拽结束自动展开(`EditModeView.axaml.cs:143-163`)。
#### 4.8.5 组件库抽屉
- 标题"组件库"(`EditModeView.axaml.cs:130`)。
- 结构:按组件分组(`ComponentRegistryService.GetGroupedSortedComponents()`)→ 每组一个 `group-name`(`font-weight:SemiBold`、`margin-bottom:4px`)+ 一个 `WrapPanel`(目标宽 `225px`,`WrapPanelAutoResizeBehavior`,`EditModeView.axaml:167-218`)。
- 每个组件项:高 `50px`、`Margin 0 8`、四列 `Auto,Auto,*,Auto`:
```css
.component-lib-item { display:grid; grid-template-columns:auto auto 1fr auto; height:50px; margin:8px 0; }
.component-lib-item .drag-thumb { width:24px; align-self:center; } /* TouchDragThumb Width=24 */
.component-lib-item .icon { width:32px; height:32px; font-size:32px; margin:0 -4px 4px 0; }
.component-lib-item .name { font-size:14px; overflow:hidden; text-overflow:ellipsis; }
.component-lib-item .desc { grid-column:2 / span 2; font-size:12px; opacity:0.75; white-space:normal; }
```
- 底部提示文案(按是否已选目标列表切换):`"将组件拖动到主界面上即可添加组件。"` / `"选择要添加的组件,或将组件拖动到主界面上即可添加组件。"`,样式类 `l-instructive`,`margin:8px 0`(`EditModeView.axaml:226-235`)。
- 已选目标时右下角出现"添加"按钮:`padding:16px 6px`、`Classes="accent"`、`margin:4px 0 8px`(`EditModeView.axaml:236-240`)。
#### 4.8.6 组件设置抽屉 / 行设置抽屉
- 标题栏(组件):左侧图标 16×16 + 组件名 + `TabStrip.compact` 两个页签"组件设置""高级设置"(仅当组件有 `SettingsType` 时显示第一个页签);右侧"按规则隐藏"复选框 + 编辑规则按钮(图标 `\uF17E`,仅勾选时启用),`Margin 0 0 4 0`、`Spacing 4`(`EditModeView.axaml:243-283`)。
- 标题栏(行):文本"主界面行设置" + 同样的"按规则隐藏"组合(`EditModeView.axaml:298-313`)。
- 抽屉内容宽度上限 `960`,内部 `.settings-container`(`Margin 12 12 18 12`、`Spacing 4`)。
- 选择组件时其它所有列表的选择必须清空(`MainWindow.axaml.cs:1500-1504`)。
#### 4.8.7 容器组件浮动编辑框(ContainerComponents)
在编辑模式中,点"查看子组件"把某个容器组件的子组件以浮动小窗形式加到画布上(`MainWindow.axaml:457-582`):
- 宿主:`ItemsControl` + `Canvas`(`CanvasDragBehavior`),每项 `Canvas.Left/Top` 绑定 `EditModeContainerComponentInfo.X/Y`。
- 初始位置:被点击组件的屏幕点转换到画布坐标,再 `Y + 48`(`MainWindow.axaml.cs:1559-1567`)。
- 外框:`Border`,`HorizontalAlignment=Center`、`VerticalAlignment=Bottom`、`Background=CommandBarOverflowPresenterBackground`、`CornerRadius=6`、`Padding="4 0"`、`BorderBrush=CommandBarOverflowPresenterBorderBrush`、`BorderThickness=1`。
- 内容:横排 `Spacing 4`;
- 拖动把手 `TouchDragThumb`,`Width=20`,`IsExplicitVisible=true`;
- 关闭按钮(图标 `\uE671`,ToolTip"关闭")、组件图标 16×16、组件名文本,这一组 `font-size:14`、`Spacing 4`、`Margin "4 0 0 0"`;
- 子组件列表 `EditableComponentsListBox`,`Height=40`。
- 拖拽中的项:`opacity: 0.5`、`z-index: 1`(`MainWindow.axaml:478-481`)。
- 关闭时移除对应 info(`EditModeView.axaml.cs:240-249`)。
#### 4.8.8 编辑模式的组件外观
- 被隐藏的组件仍然占位并显示"斜条纹"提示:`OpacityMask` 为 `45°` 线性渐变、`Background` 起止 `#afffffff`(0–0.5)、`#50ffffff`(0.5–1)、`Repeat`(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml:19-33`)。CSS:
```css
.comps-hidden { /* 仅编辑模式 */
-webkit-mask-image: repeating-linear-gradient(45deg, #afffffff 0 4px, #50ffffff 4px 8px);
mask-image: repeating-linear-gradient(45deg, #afffffff 0 4px, #50ffffff 4px 8px);
}
```
- 容器组件未选中时显示虚线边框(`SurfaceStrokeColorDefaultBrush`、`1.5px`、`dash 1/1.5`、`opacity 0.75`、`margin:0 2px`);选中时改为强调色虚线边框(`AccentFillColorDefaultBrush`,`EditableComponentsListBox.axaml:217-252`)。
- 选中组件的操作条(Adorner):出现在组件正下方,`Background=CommandBarOverflowPresenterBackground`、`CornerRadius=6`、`Padding=4`、`BorderBrush=CommandBarOverflowPresenterBorderBrush`、`BorderThickness=1`、`Spacing 6`(`EditableComponentsListBox.axaml:100-184`)。按钮:组件设置 `\uEF27`、"查看子组件" `\uEC30`(仅容器组件)、删除 `\uE61D`(`IndianRed`)、`25px` 竖分隔线、"更多选项…" `\uEBAC`(菜单:包裹到新容器 / 创建副本)。
- 每个组件的"添加组件"按钮:`height:32px`、图标 `\uE00D`、`font-size:20px`,位于组件列表末尾,只在编辑模式可见(`EditableComponentsListBox.axaml:55-60`)。
- 拖拽插入指示:拖拽中的列表在 `AdornerLayer` 上画一个强调色 1px 边框 + BoxShadow,并做 `1s` 交替无限动画(`0%: opacity .1` → `100%: opacity .6`,`SineEaseInOut`,`PlaybackDirection=Alternate`,`EditableComponentsListBox.axaml:307-336`)。
- 拖拽开始阈值:水平/垂直各 `3px`,拖拽预览 `opacity 0.5`(`EditableComponentsListBox.axaml:286-288`)。
- 拖拽预览浮标:`Background=AcrylicBackgroundFillColorDefaultBrush`、`CornerRadius=6`、`Padding=4`、`BorderBrush=SurfaceStrokeColorDefaultBrush`、`BorderThickness=1`、图标 16 + 名称、`Spacing 4`(`EditableComponentsListBox.axaml:22-39`)。该资源名虽含 Acrylic,但**实现必须用不透明降级色 `--ci-bg-tertiary`**,不得调用系统材质 API。
### 4.9 隐藏与显示规则(完整判定)
`hideMode`(`Settings.HideMode`,默认 `0`,`Models/Settings.cs:185`):
- `0 = 基本`:由三个独立开关控制,全部在 `GridRoot` 上以伪类组合判断(`MainWindow.axaml:150-160`):
- `HideOnClass=true` 且当前时间状态为"上课" → 隐藏(默认 `false`,`Models/Settings.cs:45`);
- `HideOnMaxWindow=true` 且前台窗口最大化 → 隐藏(默认 `false`,`Models/Settings.cs:63`);
- `HideOnFullscreen=true` 且前台窗口全屏 → 隐藏(默认 `false`,`Models/Settings.cs:54`)。
- `1 = 高级`:用 `Settings.HideRules`(规则集,JSON 字段名 `HiedRules`,`Models/Settings.cs:462-472`)求值,满足则隐藏(`MainWindow.axaml:161-163`、`MainWindow.axaml.cs:444-449`)。
检测数据源与频率:
- 全屏/最大化状态在每个主计时器 tick 检测:`PlatformServices.WindowPlatformService.IsForegroundWindowFullscreen(screen)` / `IsForegroundWindowMaximized(screen)`(`MainWindow.axaml.cs:451-456`)。
- 排除进程名单 `ExcludedFullscreenWindow`,默认 `["explorer"]`(`Models/Settings.cs:56-59`):这些进程处于前台时不算全屏/最大化,不触发隐藏。
- 判断基准屏幕必须是"本窗口所在屏幕"(`GetSelectedScreenSafe()`)。
强制显示(覆盖隐藏):
- `ExplicitShowed` = 正在播放提醒或处于编辑模式(`MainWindow.axaml:271`、`MainWindow.axaml.cs:1056-1092` 的 `Acquire/ReleaseTopmostLock`)。此时即使命中隐藏条件也必须显示,且窗口临时置顶。
- `IsMainWindowVisible`(用户手动"隐藏主界面",默认 `true`,`Models/Settings.cs:67`):`GridRoot` 的可见性 = `IsMainWindowVisible || IsEditMode`(`MainWindow.axaml:135-141`)。手动隐藏时同时不接受新提醒(行级 `AcceptsNotificationRequests` 要求 `IsNotificationEnabled && !IsAllComponentsHid && !_isOverlayOpen && !_isUnloading`,`MainWindowLine.cs:814`)。
- 所有行都隐藏时(`IsAllComponentsHid` 对所有行成立):**可见岛内容 `opacity:0`,窗口包围盒保持不变**(不改尺寸、不隐藏窗口),但不得销毁窗口(提醒到来时要能立刻显示)。
### 4.10 右键菜单(原生菜单)与托盘
菜单必须是系统原生菜单(Electron `Menu.buildFromTemplate` + `tray.setContextMenu`),不得用 HTML 菜单。逐项规格(`MainWindow.axaml:51-132`):
| # | 文本 | 图标 glyph | 可见条件 | 操作 |
|---|---|---|---|---|
| 1 | `ClassIsland` | AppLogo | 总是 | 打开"关于"设置页 |
| 2 | `教学…` | `\uE959` | `IsWelcomeWindowShowed` | 打开教程中心 |
| 3 | `帮助文档…` | `\uE23C` | `IsWelcomeWindowShowed` | 打开 `https://docs.classisland.tech/app/` |
| 4 | 分隔符 | | | |
| 5 | `显示主界面` | `\uE813` | `!IsMainWindowVisible && IsWelcomeWindowShowed` | 切 `IsMainWindowVisible` |
| 6 | `隐藏主界面` | `\uE817` | `IsMainWindowVisible && IsWelcomeWindowShowed` | 切 `IsMainWindowVisible` |
| 7 | `清除全部提醒` | `\uE029` | `IsWelcomeWindowShowed && IsNotificationsPlaying` | 取消全部提醒 |
| 8 | `更多选项…` | `\uEBAC` | 存在插件注入项 | 子菜单 |
| 9 | 分隔符 | | | |
| 10 | `编辑档案…` | `\uE699` | 总是 | 打开档案编辑窗口 |
| 11 | `加载临时课表…` | `\uE6A1` | 总是 | 打开档案窗口并展开"临时课表"抽屉 |
| 12 | `换课…` | `\uE15F` | 总是 | 打开换课窗口(需权限) |
| 13 | 分隔符 | | | |
| 14 | `编辑主界面…` | `\uE7C9` | 总是 | 进入编辑模式 |
| 15 | `应用设置…` | `\uEF27` | 总是 | 打开设置窗口 |
| 16 | 分隔符 | | | |
| 17 | `重启` | `\uE0BD` | 总是 | 重启应用 |
| 18 | `退出` | `\uE0DF` | 总是 | 退出(需权限) |
| 19 | 分隔符 | | `IsDebugEnabled` | |
| 20 | `dev__Debug` | `\uE2C8` | `IsDebugEnabled` | 子菜单:DevTools / DevPortal / 截图工具 / 教程编辑器 / 启用课表 / 显示崩溃窗口 / WelcomeWindow / WelcomeWindow(full) |
托盘左键点击行为 `TaskBarIconClickBehavior`:`0=打开主菜单`(仅 Windows 有效,其他平台自动改为 4)、`1=打开档案编辑窗口`、`2=显示/隐藏主界面`、`3=打开换课窗口`、`4=打开设置窗口`;默认 Windows 平台 `0`、其他平台 `4`(`Models/Settings.cs:102`, `:540-561`,实现 `MainWindow.axaml.cs:617-662`)。
### 4.11 本部分动画总表(照此实现,不得自创)
| 动画 | 触发 | 属性 | 时长 | 缓动 |
|---|---|---|---|---|
| 行背景色过渡 | 主题/背景色变化 | `Background` | 200ms | 线性(`MainWindow.axaml:40-44` BrushTransition) |
| 行内容透明度 | 行显示/隐藏 | `opacity` | 100ms | 线性(`FluentTheme/Styles.axaml:268`) |
| 行背景宽度 | 内容宽变化 | `width` | 300ms(Fluent)/ 550ms(Classic) | `cubic-bezier(0.65,0,0.35,1.0)` / `cubic-bezier(0,1,0.5,1.1)` |
| 行挂载淡入 | 首次可见 | `opacity 0→1` | 250ms | `cubic-bezier(0.25,1,0.5,1)`;`AnimationLevel≥2` 才播 |
| 内容层交错重排 | 组件变化 | 位置/宽 | 300ms | `cubic-bezier(0.65,0,0.35,1.0)`(`MainWindow.axaml:197-198`) |
| 遮罩斜掠(开) | 提醒开始 | 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` | 100ms,delay 600ms | 线性 |
| 遮罩文字淡入 | 提醒开始 | `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)` |
| 浮层淡入淡出 | Overlay 开始/结束 | `opacity` | 200ms | SineEaseIn |
| 提醒进度条值 | 剩余时间更新 | `value` | 50ms | 线性 |
| 行变淡 | 指针离开 | `opacity →0.05` | 100ms | 线性 |
| 编辑抽屉升降 | 打开/关闭抽屉 | `bottom`/`height` | 250ms | `cubic-bezier(0,0,0,1)` |
| 遮罩层(smoke) | 抽屉打开 | `opacity` | 250ms | `cubic-bezier(0,0,0,1)` |
| 拖拽插入高亮 | 拖动中 | `opacity 0.1↔0.6` | 1000ms 交替无限 | SineEaseInOut |
| 斜条纹隐藏提示 | 隐藏组件 | — | 静态 | — |
### 4.12 验收标准
实现者必须逐条自查,全部通过才算完成本节:
1. 非编辑模式下用 Spy++/AutoHotkey 之类工具点击岛的任何位置,点击都穿透到桌面应用;编辑模式下岛内的按钮、拖拽把手、抽屉都能被点到。
2. 主界面窗口在 4K(150% 缩放)与 1080p(100% 缩放)两种 DPI 下都不出现 1px 白边、黑边、圆角锯齿;圆角在 `RadiusX=0` 时是直角,在 `20` 时左右边缘为半圆。
3. 6 个停靠位置逐个验证:`0/1/2` 贴工作区上边、`3/4/5` 贴下边;左/中/右的横向对齐分别贴左、居中、贴右,且窗口本身始终横跨整屏。
4. 修改"窗口向右/向下偏移"后岛整体平移,正值向下时上停靠要把偏移吞进安全区(不得被屏幕边缘裁掉)。
5. 行数从 1 增到 5、再减到 1:**可见岛高度严格等于"行高之和 + 行间距之和"**(误差 ≤1px),岛在窗口包围盒内平滑形变,**不出现窗口级闪烁**;包围盒只在行数超出 `contentMaxHeight` 时变更一次,且变更被节流 ≥100ms。
5.1 用 DevTools 的 Performance 面板录 10 秒:`setBounds` 调用次数为 0(无设置/行数变化时);置顶复核轮询间隔 ≥1000ms,无 1ms/10ms 级高频定时器。
6. 单行内容宽度变化(如时钟秒数出现/消失、课程名变长)时,背景宽度在 300ms 内以 `cubic-bezier(0.65,0,0.35,1.0)` 平滑跟随,文字不重排抖动,且**不触发窗口 `setBounds`**。
7. 行间距设为 `0`、`5`、`150` 时,相邻行间距精确等于设置值(用像素标尺截图量)。
8. 关闭某行"可以显示提醒"后,该行不再出现遮罩与进度条(走 Min 模板),其余行不受影响;多行同时具备提醒能力时,**具体由哪一行显示由第 7 章的分发规则决定**,本节只验证宿主与 `IsNotificationEnabled` 标记生效。
8.1 三条进度条(行提醒、课程、倒计时)高度均为 `3px`,圆角 `1.5px`,贴底且不越出行圆角裁切。
9. 删除到只剩 1 行时再点删除,出现"至少需要保留 1 个主界面行。"提示且行数不变。
10. 勾选 A 行为主要行后 B 行自动取消;取消 A 行后第一行自动成为主要行并出现提示。
11. 触发一次提醒:遮罩从左、右两端同时向中间斜掠,总时长约 480ms,文字在 260ms 后淡入并伴随 1.1→1.0 缩放;遮罩期间行内容消失,结束后恢复。
12. 阈值条件全开(上课隐藏、最大化隐藏、全屏隐藏),在前台最大化窗口时岛消失、切回桌面 1 个计时器周期内恢复;`ExcludedFullscreenWindow` 含 `explorer` 时桌面(资源管理器)前台不隐藏。
13. 鼠标移出岛 0.5 秒内行的整体不透明度降到 0.05,移回立即恢复;勾选"反向"后行为相反。
14. 编辑模式:进入后窗口扩展为"屏宽 ×(屏高−1px)",岛内容四边留 60px 安全边距并垂直居中,任务栏出现图标,鼠标可交互;退出后恢复包围盒尺寸与穿透状态,并写盘保存组件配置;编辑态背景为 `rgba(0,0,0,0.30)` 遮罩(无 `backdrop-filter`、无系统材质),退出后恢复逐像素透明。
15. 编辑模式下拖拽组件到另一行的插入位置时,出现强调色发光插入线(1s 交替闪烁),松手后组件落到目标位置且行宽平滑重排。
16. 容器组件"查看子组件"浮动框出现在被点组件下方 48px 处,可拖动、可关闭,关闭后 `ContainerComponents` 中对应项被移除。
17. 主界面完全不可见(手动隐藏 / 全部隐藏规则命中)时收到提醒,岛必须临时显示、置顶,提醒结束后回到隐藏状态。
18. 托盘图标左键行为的 5 种取值(`0–4`)逐个验证正确,其中非 Windows 平台 `0` 自动降级为 `4`。
+751
View File
@@ -0,0 +1,751 @@
## 5. 组件系统:全部内置组件逐个规格
本节规定主界面上所有组件的实现规格。组件是"岛"的内容单元:每个组件的实例由一份 JSON(`ComponentSettings`)描述,含「组件 ID + 组件自己的设置对象 + 一系列外观/布局/隐藏覆盖项」。实现必须做成可插拔的注册表(原生 11 个组件 + 后续插件组件走同一接口),不得把组件硬编码进主界面。
### 5.1 组件系统总则
#### 5.1.1 组件注册表(唯一事实来源)
必须内置下面 11 个组件,ID、名称、图标 glyph、描述、是否容器、设置类型逐字照抄(`[ComponentInfo]` 特性):
| 组件 | ID(GUID) | 名称 | 图标 glyph | 描述 | 容器 | 设置类型 | 证据 |
|---|---|---|---|---|---|---|---|
| 日期 | `DF3F8295-21F6-482E-BADA-FA0E5F14BB66` | 日期 | `\ue304` | 显示今天的日期和星期。 | 否 | 无 | `Controls/Components/DateComponent.axaml.cs:14` |
| 时钟 | `9E1AF71D-8F77-4B21-A342-448787104DD9` | 时钟 | `\ue31c` | 显示现在的时间,支持精确到秒。 | 否 | `ClockComponentSettings` | `ClockComponent.axaml.cs:17` |
| 倒计时 | `7C645D35-8151-48BA-B4AC-15017460D994` | 倒计时 | `\uf361` | 显示距离某一天的倒计时。 | 否 | `CountDownComponentSettings` | `CountDownComponent.axaml.cs:29` |
| 课程表 | `1DB2017D-E374-4BC6-9D57-0B4ADF03A6B8` | 课程表 | `\ue751` | 显示当前的课程表信息。 | 否 | `LessonControlSettings` | `ScheduleComponent.axaml.cs:30` |
| 天气简报 | `CA495086-E297-4BEB-9603-C5C1C1A8551E` | 天气简报 | `\uf465` | 显示当前的天气概况和气象预警。 | 否 | `WeatherComponentSettings` | `WeatherComponent.axaml.cs:16` |
| 文本 | `EE8F66BD-C423-4E7C-AB46-AA9976B00E08` | 文本 | `\uf323` | 显示自定义文本。 | 否 | `TextComponentSettings` | `TextComponent.axaml.cs:16` |
| 滚动容器 | `70FCD5EA-3FAE-4E06-ACA2-4F4DF47F9ACD` | 滚动容器 | `\uf279` | 滚动显示组件内的内容。 | 是 | `RollingComponentSettings` | `RollingComponent.axaml.cs:33` |
| 轮播容器 | `7E19A113-D281-4F33-970A-834A0B78B5AD` | 轮播容器 | `\uefc9` | 轮播多个组件。 | 是 | `SlideComponentSettings` | `SlideComponent.axaml.cs:24` |
| 分组容器 | `C911D762-107F-40C6-84CC-0146AB3C86B1` | 分组容器 | `\ue92f` | 将多个组件组合到一个组件中显示。 | 是 | `GroupComponentSettings` | `GroupComponent.axaml.cs:11` |
| 堆叠容器 | `2D849ECE-9F21-4C78-9434-415CFC283294` | 堆叠容器 | `\uf04f` | 将多个容器堆叠显示。 | 是 | `StackComponentSettings` | `StackComponent.axaml.cs:11` |
| 分割线 | `AB0F26D5-9DF6-4575-B844-73B04D0907C1` | 分割线 | `\uf023` | 显示一个分割线,视觉上对组件进行分组。 | 否 | 无 | `SeparatorComponent.axaml.cs:9` |
图标字体为 `FluentSystemIcons-Resizable`(`repos/ClassIsland/ClassIsland.Core/ThemeBase.axaml:46`),`FontFamily` 绑到 `ci|FluentIcon` 全局样式(`StylesBase.axaml:79-81`)。实现时用同名 SVG 图标表 + `mask-image` 或内联 SVG,不得贴无对应关系的图标。
`课程表` 组件有一个历史 ID 需在迁移时映射:`E7831603-61A0-4180-B51B-54AD75B1A4D3` → 现 ID(`[MigrateFrom]`,`ScheduleComponent.axaml.cs:29`)。
#### 5.1.2 默认组件布局(首次启动的样子)
首次启动必须生成 1 个主界面行,行内依次为「日期」「课程表」,其余字段取默认值(`repos/ClassIsland/ClassIsland/Services/ComponentsService.cs:33-52`)。实现即:`lines = [{ children: [dateId, scheduleId], ...默认行设置 }]`。
#### 5.1.3 组件宿主(ComponentPresenter):所有组件共用的外壳
每个组件都必须包在统一的宿主里,宿主负责下面这些语义(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml`):
1. **可见性三态**:`visible = IsVisibleInternal || 主界面处于编辑模式`(`:13-18`)。隐藏的组件在编辑模式仍占位。
2. **透明度**:宿主 `opacity = 组件设置.Opacity`(默认 `1.0`,范围 0–1,`:34-36`)。
3. **字号继承**:宿主根元素的 `font-size = var(--font-body)`(16px),即所有组件默认正文字号;组件内部需要别的字号时必须显式引用 `--font-secondary/emphasized/large`(`:37-38`)。
4. **宽度三态**(互斥,优先级:固定宽 > 最大宽/最小宽):
- `IsFixedWidthEnabled` → `width = FixedWidth`(默认 `200px`,`:45-47`);
- `IsMaxWidthEnabled` → `max-width = MaxWidth`(默认 `300px`,`:39-41`);
- `IsMinWidthEnabled` → `min-width = MinWidth`(默认 `100px`,`:42-44`)。
默认三者全 `false`(`repos/ClassIsland/ClassIsland.Core/Models/Components/ComponentSettings.cs:30-35`)。
5. **水平对齐**:宿主内容的 `justify-content` 取 `Settings.HorizontalAlignment`,默认 `stretch`(枚举 `Stretch/Left/Center/Right`,`ComponentSettings.cs:36`,选择器 `:49-51`)。
6. **内边距**:宿主内部 `Shimmer` 容器 `padding: 8px 0`(`:55-57`),但组件自身的横向间距由外层 `margin: 0 6px` 提供(行内模板 `Margin="6 0"`,`MainWindow.axaml:396`),两者都要保留,不得合并。
7. **加载占位**:首次布局或配置切换时显示 Shimmer 占位,占位宽度取上次测量宽度 `LastWidthCache`(默认 `100`,`ComponentSettings.cs:39`,`ComponentPresenter.axaml:69-70`)。占位态不得让行高变化。
8. **隐藏规则**:`HideOnRule + HidingRules` 满足时组件不可见但不卸载、不释放资源(`ComponentSettings.cs:100-123`)。
9. **裁剪**:宿主自身 `overflow: visible`(不对组件内部做裁剪),裁剪交给行的 `clip-path`(见 4.5.4);但轮播容器内部有 40px 高度钳制(见 5.9)。
#### 5.1.4 组件通用设置项("高级设置"页,所有组件都有)
以下字段存在于每一个 `ComponentSettings` 上,默认值固定(`repos/ClassIsland/ClassIsland.Core/Models/Components/ComponentSettings.cs:23-51`),UI 顺序照 `ComponentAdvancedSettingsControl.axaml`:
| 字段 | 默认 | UI 控件 | 范围/步进 |
|---|---|---|---|
| `IsResourceOverridingEnabled` | `false` | 开关 + 4 个字号输入 | 字号 1–40 |
| `MainWindowSecondaryFontSize` | `14` | NumberBox | 1–40 |
| `MainWindowBodyFontSize` | `16` | NumberBox | 1–40 |
| `MainWindowEmphasizedFontSize` | `18` | NumberBox | 1–40 |
| `MainWindowLargeFontSize` | `20` | NumberBox | 1–40 |
| `IsCustomForegroundColorEnabled` | `false` | 开关 + ColorPicker | — |
| `ForegroundColor` | `DodgerBlue` | ColorPicker | — |
| `Opacity` | `1.0` | Slider | 0–1,步进 0.05 |
| `IsCustomBackgroundColorEnabled` | `false` | 开关 + ColorPicker | — |
| `BackgroundColor` | `Black` | ColorPicker | — |
| `IsCustomBackgroundOpacityEnabled` | `false` | 开关 + Slider | 0–1,步进 0.05 |
| `BackgroundOpacity` | `0.5` | Slider | 0–1 |
| `IsCustomCornerRadiusEnabled` | `false` | 开关 + Slider | 0–20,步进 0.5 |
| `CustomCornerRadius` | `8.0` | Slider | 0–20 |
| `HorizontalAlignment` | `Stretch` | 4 个图标按钮 | 左/居中/拉伸/右 |
| `IsFixedWidthEnabled` / `FixedWidth` | `false` / `200` | 开关 + NumberBox | — |
| `IsMinWidthEnabled` / `MinWidth` | `false` / `100` | 开关 + NumberBox | — |
| `IsMaxWidthEnabled` / `MaxWidth` | `false` / `300` | 开关 + NumberBox | — |
| `IsCustomMarginEnabled` | `false` | 开关 | — |
| `MarginLeft` / `MarginTop` / `MarginRight` / `MarginBottom` | `0` / `0` / `0` / `0` | 4 个 NumberBox | — |
| `HideOnRule` / `HidingRules` | `false` / `{}` | 开关 + 规则集编辑器 | — |
| `RelativeLineNumber` | `0` | 内部字段 | — |
对齐按钮图标(`ComponentAdvancedSettingsControl.axaml:223-232`):左=`\uE03E`、居中=`\uE03A`、拉伸=`\uE034`、右=`\uE03C`。
自定义背景/圆角只有在"组件位于独立岛"(分体主界面)语义下才画得出来;普通行内背景由行承担,此时组件的背景设置不生效——实现必须在 UI 上如实表现(禁用或不渲染),不得偷偷画。
#### 5.1.5 容器组件通用约定
- 注册时带 `[ContainerComponent]`,宿主 `ComponentInfo.IsComponentContainer = true`。
- 子组件集合 = 组件设置对象实现 `IComponentContainerSettings.Children`(`ComponentSettings.cs:143-144`);因此 `GroupComponentSettings`、`StackComponentSettings` 只有 `Children` 一个字段(`Models/ComponentSettings/GroupComponentSettings.cs`、`StackComponentSettings.cs`)。
- 容器内部为子组件再套一层 `ComponentPresenter`,`IsOnMainWindow=true`、`Margin="6 0"`,并透传 `HidingRules/HideOnRule`(`GroupComponent.axaml:25-29`、`StackComponent.axaml:24-28`、`RollingComponent.axaml:36-41`、`SlideComponent.axaml:114-120`)。
- 容器的子列表外层容器统一 `Margin="-6 0"`(抵消一次边距),循环嵌套时视觉间距保持 12px 恒定(`GroupComponent.axaml:15`、`StackComponent.axaml:14`、`RollingComponent.axaml:16`、`SlideComponent.axaml:22`)。
- 容器内部的子组件条目使用 "components-panel" 面板类:`WrapPanel`/`StackPanel` 需要"重排动画"标记;`StackPanel` 固定 `Orientation=Horizontal`(`repos/ClassIsland/ClassIsland.Core/Themes/ComponentsPanelStyles.axaml:11-16`)。
#### 5.1.6 组件高度与"岛"的约束
- 组件可用高度恒为 `40px`(行高,见 4.5.2)。所有组件必须在这个高度内垂直居中显示。
- 宿主的 `MaxHeight` 受容器约束:轮播容器显式 `MaxHeight = IslandContainerHeight`(`SlideComponent.axaml:119`)、编辑态列表项 `MaxHeight = IslandContainerHeight`(`EditableComponentsListBox.axaml:70`)。
- 组件内部若出现高度超过 40px 的内容(如两行文字),必须自行 clamp 或按设计折行;不得撑高行。
#### 5.1.7 时间驱动
除文本/分割线外,所有组件在主计时器 tick 时刷新(ClassIsland 用 `ILessonsService.PostMainTimerTicked`,`DateComponent.axaml.cs:39-40`、`ClockComponent.axaml.cs:61`、`CountDownComponent.axaml.cs:142`、`ScheduleComponent.axaml.cs:119`)。实现:
- 用单一 1 秒节拍(主进程 `setInterval(1000)` 对齐到整秒边界)+ 事件广播;
- 需要亚秒/更快的(滚动、轮播、提醒进度)各自用 `requestAnimationFrame` 或独立定时器,不得把 1 秒节拍改成 16ms 全局广播;
- 页面不可见或被系统节流时,靠"重新挂载时立即刷新一次"兜底(对应 `AttachedToVisualTree` 时 `UpdateContent()`)。
### 5.2 日期(DateComponent)
- 注册:见 5.1.1。
- 视觉:一个 `TextBlock`,垂直居中;文本格式 `ddd MM/dd`(`DateComponent.axaml:11`,`StringFormat=\{0:ddd MM/dd\}`)。
- 中文(`zh-CN`)渲染示例:`周三 11/12`;星期用短名(不补"星期"二字),月/日补零两位,斜杠分隔,无空格之外的分隔符(`ddd` 与 `MM/dd` 之间有一个空格)。
- 字号:继承 `--font-body` = 16px;颜色:继承前景色;字重:继承 `--font-weight`(默认 500)。
- 数据:`Today = ExactTimeService.GetCurrentLocalDateTime()`,每次 tick 刷新(`DateComponent.axaml.cs:43-46`)。必须使用"精确时间服务"(含 NTP 校正与时间偏移),不得直接用 `new Date()`。
- 设置:无。
- 边界:
- 跨日 00:00 必须自动更新(不能只在启动时取一次)。
- 系统时间被手动修改后 1 个 tick 内反映。
- 语言/区域变化时按 `Intl.DateTimeFormat('zh-CN', { weekday:'short' })` 重新渲染,且宽度变化要触发行宽动画(见 4.5.7)。
- Vue 结构建议:`<span class="node node-date">{{ dateText }}</span>`,`white-space: nowrap; font-variant-numeric: tabular-nums;`。
### 5.3 时钟(ClockComponent)
- 视觉结构(`ClockComponent.axaml:13-41`):外层网格水平/垂直居中,`font-size = var(--font-emphasized)`(18px)。
```html
<div class="node node-clock" data-sep="1" data-seconds="0">
<span class="clock-full">09:41</span> <!-- TextBlock1,分隔符闪动用 -->
<span class="clock-hour">09</span> <!-- 左对齐 -->
<span class="clock-minute">41</span> <!-- 右对齐 -->
</div>
```
- 三种显示态由两个类控制(`:19-32`):
- `TimeSeparatorShowing`:`clock-full` 可见(`opacity:1`),`clock-hour/clock-minute` 隐藏;
- 非 `TimeSeparatorShowing`:`clock-full` 隐藏(`opacity:0`),`clock-hour` 左对齐 + `clock-minute` 右对齐显示,二者之间留出分隔符的宽度(用等宽字符宽的占位,或给两个 span 之间固定 `gap: <一个字符宽>`;Avalonia 里两个 TextBlock 在同一 Grid 单元格内用 Left/Right 对齐实现,中间自然留空)。
- `ShowSeconds`:文本格式变为 `HH:mm:ss`(`:28-30`)。
- 分隔符闪动逻辑(`ClockComponent.axaml.cs:71-76`):
```
IsTimeSeparatorShowing = !FlashTimeSeparator || ShowSeconds || (当前秒 % 2 == 1)
```
即:开启闪动且不显示秒时,冒号在奇数秒显示、偶数秒消失(每次消失 1 秒);显示秒时冒号常亮。
- 数字排版:必须 `font-variant-numeric: tabular-nums`(等宽数字),否则冒号闪动与秒数变化会让整块宽度跳动(ClassHelper 对时钟也做了同样处理:`repos/ClassHelper-master/packages/desktop-client/src/island/ComponentNode.vue:305-308`)。
- 时间源:`CurrentTime = Settings.ShowRealTime ? DateTime.Now : ExactTimeService.GetCurrentLocalDateTime()`(`ClockComponent.axaml.cs:73`)。
- 设置项与默认值(`Models/ComponentSettings/ClockComponentSettings.cs`):
| 字段 | 默认 | UI 文案 | 说明 |
|---|---|---|---|
| `ShowRealTime` | `false` | 使用实际时间 | 显示不经过时间偏移的系统时间 |
| `ShowSeconds` | `false` | 显示秒数 | 精确到秒 |
| `FlashTimeSeparator` | `true` | 闪动时间分隔符 | 模拟电子时钟分隔符闪动 |
- 边界:
- 24 小时制,不显示 AM/PM(格式串 `HH:mm`)。
- 显示秒时每秒重绘一次;不显示秒时也需每秒 tick 以驱动闪动。
- 若系统时间不可用(精确时间服务失败)必须回退本地时间,不得显示空白。
- 验收要点:开启闪动后,肉眼观察冒号 1 秒亮 1 秒灭;开启秒数后冒号常亮且每秒变化;整块数字在切换时不发生水平位移。
### 5.4 倒计时(CountDownComponent)
最复杂的非容器组件。结构(`CountDownComponent.axaml:64-114`):
```html
<div class="node node-countdown" data-progress="ring|bar" data-compact="0">
<!-- 进度环(ProgressBarMode=0 且 ShowProgress 时可见) -->
<div class="ring">
<svg width="22" height="22" viewBox="0 0 22 22">
<circle cx="11" cy="11" r="9.5" stroke="var(--control-strong-stroke-disabled)" stroke-width="2.6" fill="none"/>
<circle cx="11" cy="11" r="9.5" stroke="var(--accent)" stroke-width="3.1" stroke-linecap="round"
stroke-dasharray="59.69" stroke-dashoffset="计算值" transform="rotate(-90 11 11)"/>
</svg>
</div>
<span class="connector-prefix">距离</span> <!-- 非紧凑模式 -->
<span class="name">倒计时</span> <!-- 颜色=FontColor,字号=FontSize -->
<span class="connector">还有</span> <!-- 非紧凑模式 -->
<span class="value">30</span> <!-- 颜色=FontColor,字号=FontSize -->
<!-- 进度条(ProgressBarMode=1 且 ShowProgress 时可见) -->
<div class="bar-wrap"><div class="bar" style="width: Percent%"></div></div>
</div>
```
尺寸与样式(逐条,`CountDownComponent.axaml:66-112`):
- 外层:横排、`gap: 2px`、垂直居中。
- 进度环容器:`margin-right: 4px`、垂直居中;底环 `Ellipse` 宽高 `21.4px`、描边 `2.6px`、颜色 `ControlStrongStrokeColorDisabledBrush`;前景弧 `Path` 宽高 `22px`、`stroke-linecap: round`、`stroke-width: 3.1`、颜色默认 `AccentFillColorDefaultBrush`,开启"进度条使用强调色"后改为 `Settings.FontColor`。
- 弧半径计算必须照抄:控件尺寸 22×22、描边 3、`radius = 22/2 - 3/2 = 9.5`,起点在正上方(`center.Y - radius`),顺时针扫过 `percent/100*360` 度,`percent >= 100` 时按 `99.9999` 处理以避免闭合渲染问题,`percent <= 0` 时不画(`CountDownComponent.axaml.cs:32-85`)。
- 文本:"距离" + 名称 + 连接词 + 数值;"距离"与连接词用 `connector l-instructive` 类(次要色);名称与数值用 `Settings.FontColor` + `Settings.FontSize`(默认 `#FFFF0000`、16px)。
- 紧凑模式(`IsCompactModeEnabled`):隐藏"距离"与连接词,只留名称 + 数值(`:85, :95`)。
- 底部进度条:`Maximum=100`、`Minimum=0`、`Value=Percent`、贴底、`min-width:0`、宽度撑满行、**高度固定 `3px`(token `--ci-progress-height: 3px`,`border-radius: 1.5px`)**;实现用 `width: Percent%` 或 `scaleX`(`:102-112`)。
- 设置项与默认值(`Models/ComponentSettings/CountDownComponentSettings.cs`,逐字段):
| 字段 | 默认 | 说明 |
|---|---|---|
| `CountDownName` | `"倒计时"` | 事件名称 |
| `CountdownSource` | `0` | `0 固定时间 / 1 周期 / 2 今天 / 3 本周` |
| `StartTime` | 今天 0 点 | 固定时间模式的起点 |
| `OverTime` | 今天 0 点 | 固定时间模式的终点(日为 0 的那天) |
| `CycleStartTime` | 当前时刻 | 周期模式起点 |
| `CycleBeforeDuration` | `TimeSpan.Zero` | 周期前阶段 |
| `CycleDuration` | `1 天` | 周期长度 |
| `CycleAfterDuration` | `TimeSpan.Zero` | 周期后阶段 |
| `IsCycleCountLimited` | `false` | 限制循环次数 |
| `CycleCountLimit` | `2` | 循环次数上限(UI 范围 1–33550336) |
| `IsAdvancedCycleTimingEnabled` | `false` | 启用前后阶段 |
| `NatureTimeUseMode` | `0` | `0 默认 / 1 强制自然时间 / 2 强制课程时间` |
| `WeekCountdownStartDay` | `Monday` | 本周模式的第一天 |
| `IsCustomWeekCountdownStartDayEnabled` | `false` | 自定义每周第一天(否则取学期开始日的星期) |
| `FontColor` | `#FFFF0000` | 名称与数值颜色(UI 文案"强调色") |
| `FontSize` | `16` | 范围 16–30,步进 1 |
| `IsCompactModeEnabled` | `false` | 紧凑模式 |
| `CountDownConnector` | `"还有"` | 连接词 |
| `IsConnectorColorEmphasized` | `false` | 连接词用强调色 |
| `ShowProgress` | `false` | 显示进度 |
| `ProgressBarMode` | `0` | `0 进度环 / 1 进度条` |
| `UseAccentOnProgressBar` | `true` | 进度使用强调色(否则用应用主题色) |
| `IsProgressInverted` | `false` | 进度反相(用"剩余"而非"已过"计算) |
| `CustomStringFormat` | `"%D天"` | 数值格式化模板 |
- 格式化模板替换规则(必须逐 token 实现,`CountDownComponent.axaml.cs:268-287`):
| Token | 含义 | 取整 |
|---|---|---|
| `%D` | 总天数 | 向上取整 |
| `%H` | 总小时数 | 向上取整 |
| `%M` | 总分钟数 | 向上取整 |
| `%S` | 总秒数 | 向上取整 |
| `%X` | 总毫秒数 | 向上取整 |
| `%P` | 已过百分比(`P0`,如 `30%`) | 四舍五入到 2 位小数后按 P0 |
| `%p` | 已过百分比(`P2`,如 `30.00%`) | 保留 2 位 |
| `%L` | 剩余百分比(`P0`) | 四舍五入到 2 位 |
| `%d` | 天(元素值) | 不取整 |
| `%h` | 小时(元素值) | 不取整 |
| `%m` | 分钟(元素值) | 补两位 |
| `%s` | 秒(元素值) | 补两位 |
| `%x` | 毫秒(元素值) | 补三位 |
中国区必须用不变文化(`InvariantCulture`)格式化数字,避免千分位。`delta < 0` 时钳制为 0(`:260-264`)。
- 四种时间源的时间区间计算(必须照抄,`CountDownComponent.axaml.cs:166-247`):
1. **固定时间**:`(StartTime, OverTime)`。
2. **周期**:`total = before + duration + after`;`cycles = floor((now - cycleStart)/total)`(启用次数限制时再取 `min(cycles, limit)`);`start = cycleStart + cycles*total + before`;`end = start + duration`。`total <= 0` 时退化为 `(cycleStart, cycleStart)`。
3. **今天**:`NatureTimeUseMode==1` → `(今天0点, 明天0点)`;否则取当天时间表中第一个"上课/课间"时间点的开始 → 最后一个"上课/课间"时间点的结束;课程不可用时按 `NatureTimeUseMode==2` 退化为 `(DateTime.MinValue, DateTime.MinValue)`(不显示倒计时),否则退化为自然日。
4. **本周**:按 `WeekCountdownStartDay`(或学期开始日的星期)求出本周起点;`NatureTimeUseMode==1` → 周起点到下周起点+1 天;否则遍历 7 天找第一个有课的日子与最后一个有课的日子,取其首/末时间点拼成区间;课程全无时按模式 2 退化或自然周。
- 进度百分比(`:288-295`):`value = (IsProgressInverted ? end - now : now - start).TotalSeconds`;`totalSeconds <= 0` 时 `Percent = 0`;开启进度精度降低(主界面全局设置 `ReduceProgressAccuracy` 默认 `true`)时 `progressTick = max(10, totalSeconds/500)`,`Percent = round(value/progressTick)*progressTick / totalSeconds * 100`;否则 `progressTick = 1`。
- 边界:
- 终点在过去 → 数值全 0、`Percent=0`、进度环不画。
- 起点在未来(固定时间模式配错)→ `Percent` 为负,必须钳到 0。
- 名称/连接词为空字符串时不得留出空 span 的间距(用 `display:none` 而非空文本)。
- 字号变化(16–30)时进度环/进度条不跟随放大,只放大文本。
- Vue 结构建议:`CountDownNode.vue` props = `{ settings, now }`,内部 `computed` 出 `remaining`、`percent`、`text`;进度环用 SVG `circle` + `stroke-dashoffset = C * (1 - percent/100)`,`C = 2π*9.5 ≈ 59.69`。
### 5.5 课程表(ScheduleComponent)
课程表是"一行多条课程"的核心组件,内部由 `LessonsListBox`(`repos/ClassIsland/ClassIsland.Core/Controls/LessonsControls/LessonsListBox.axaml`)渲染一组课程控件。
#### 5.5.1 外层结构
```html
<div class="node node-schedule" data-tomorrow="0" data-empty="none">
<div class="tomorrow-badge">明天</div> <!-- 条件可见 -->
<ul class="lessons"> <!-- LessonsListBox#MainLessonsListBox -->
<li class="lesson-item">…课程控件…</li>
<li class="lesson-item">…</li>
</ul>
<ul class="lessons lessons-tomorrow">…</ul> <!-- LessonsListBox#TomorrowLessonsListBox -->
<span class="empty-placeholder">今天没有课程。</span>
</div>
```
- 外层 Grid 两列 `Auto, *`(`ScheduleComponent.axaml:78`)。
- "明天"标记:`margin-right: 2px`、垂直居中、背景 `AccentFillColorDefaultBrush`、内边距 `8px 2px`、`border-radius: 16px`(药丸)、文本"明天"、前景 `TextOnAccentFillColorPrimaryBrush`、文本 `margin: 0 1px -1px`(微调基线)(`:79-89`)。
- 课程列表容器:`height: 40px`、水平 `StackPanel`、`ScrollViewer.VerticalScrollBarVisibility=Disabled`(`LessonsListBox.axaml:41-50`)。
- 每个列表项容器(`LessonsListBox.axaml:100-136`):`background: ListViewItemBackground`、`border-radius: var(--control-corner-radius)`(FluentAvalonia 控制圆角,默认 4【推断:FluentAvalonia ControlCornerRadius 默认 4】)、`padding: 0`、`max-height: 40px`、`min-width: 0`、内容纵横都 `stretch`;`:disabled` 时 `opacity: 0.5`;`:pointerover/:pressed/:selected` 各自换背景色(默认鼠标穿透,实际见不到,但编辑/演示态仍要正确)。
- 已结束课程淡化:`opacity: 0.6`,`transition: opacity 150ms cubic-bezier(0.645, 0.045, 0.355, 1)`(`CubicEaseInOut`,`LessonsListBox.axaml:56-77`)。开关 `FadeCompletedClasses`(默认 `true`)。
#### 5.5.2 三种课程控件
**(a) 展开型 `LessonControlExpanded`**(时间点为"上课"时显示,`LessonControlExpanded.axaml`):
```html
<div class="lesson lesson-expanded">
<span class="spacer" style="width:16px * ScheduleSpacing"></span>
<span class="subject"><em>语文</em></span> <!-- HighlightBox 包裹 -->
<span class="extra"> <!-- margin-left:6px,l-secondary -->
<span class="detail">08:00-08:40</span> <!-- 依 ExtraInfoType 切换 -->
</span>
<span class="spacer" style="width:16px * ScheduleSpacing"></span>
<div class="progress"><i style="transform:scaleX(ProgressPercent)"></i></div>
</div>
```
- 左右各一个 `16px` 间隔,宽度按 `ScheduleSpacing` 水平缩放(`ScaleTransform ScaleX`,`:51-56, 219-224`);`ScheduleSpacing` 默认 `1`,UI 范围 `0.2–1`(`ScheduleComponentSettingsControl.axaml:156`),因此左右各 3.2–16px。
- 科目名:`font-size = var(--font-emphasized)`(18px)、`font-weight: bold`,包在 `HighlightBox` 里(`:65-68`)。
- 附加信息区:`margin: 6px 0 0 0`、类 `l-secondary`(次要色、`font-size = var(--font-secondary)` = 14px)、贴底(`:72-84`)。
- `ExtraInfoType`(`ShowExtraInfoOnTimePoint=true` 时生效)6 种(`:85-171`):
- `0`:`开始时间-结束时间`,格式 `hh:mm`(如 `08:00-08:40`);
- `1`:已过时间,格式 `-mm:ss`(秒数向上/向下取整见转换器);
- `2`:剩余时间,格式 `-mm:ss`;
- `3`:进度百分比;
- `4`:模糊倒计时,`-<1min` 形式(用 `CountdownSeconds` 向上取整);
- `5`:精确倒计时,`-mm:ss`。
- 当前课程的"剩余时间药丸"(`IsCountdownEnabled` 且 `MasterTabIndex==1` 时):背景 `AccentFillColorTertiaryBrush` + `opacity: 0.3` 的底 + `1px` 边框 `AccentFillColorDefaultBrush` + `padding: 8px 2px` + 圆角 = 高度的一半(药丸);文本 `font-size = var(--font-body)`(16px),普通模式 `-mm:ss`,非精确倒计时模式 `<<countdownSeconds`(`:173-214`)。
- 课程进度条:贴底、`Maximum=1`、`Value=ProgressPercent`、`min-width:0`、宽度撑满、**高度固定 `3px`(`--ci-progress-height`)**(`:227-236`)。
- 换课高亮:`ChangedClass && HighlightChangedClass`(设置项 `HighlightChangedClass` 默认 `false`)时 `HighlightBox.IsHighlight = true`(`:30-32`),即科目名后出现强调色下划色块。
**(b) 最小型 `LessonControlMinimized`**(`TimeLayoutItem.IsHideDefault` 或"上课时仅显示当前课程"等场合):只显示科目名首字(`Subject.Initial`),`font-size = var(--font-emphasized)`,左右各 `10px * ScheduleSpacing` 间隔(`LessonControlMinimized.axaml:30-64`)。
**(c) 分割线 `LessonControlSeparator`**(时间点为"分割线/Action"时):一条 `2px` 竖线、高 `25px`、颜色 `TextFillColorSecondaryBrush`、`opacity: 0.8`,左右各 `10px * ScheduleSpacing`(`LessonControlSeperator.axaml:8-26`)。注意:分割线是**每个时间点自己的一项**,不合并。
`HighlightBox` 的高亮条规格:`background: var(--accent)`、`opacity: 0.4`、高 `12px`、垂直居中、`margin: -2px 8px -2px 0`、`border-radius: 2px`(`repos/ClassIsland/ClassIsland.Core/Controls/HighlightBox.axaml:15-18`)。
#### 5.5.3 哪些时间点显示(必须逐条实现)
对每个 `TimeLayoutItem`(`ScheduleComponent.axaml.cs:243-251`):
1. `TimeType ∈ {2 分割线, 3 行动}` 不显示【注意:分割线类型由专门的 `LessonControlSeparator` 渲染,判定顺序以源码为准】;
2. `HideFinishedClass=true` 且 `EndTime < now` → 不显示;
3. `IsHideDefault=true` 且不是当前选中项 → 不显示;
4. `TimeType==1`(课间)且不是当前选中项 → 不显示;
5. `ShowCurrentLessonOnlyOnClass=true` 且当前选中项是"上课"类型且该项不是当前项 → 不显示。
`ShowCurrentLessonOnlyOnClass` 的取值来自"附加的课程控件设置"(`LessonControlAttachedSettings`,优先级链:附加设置 → 组件设置)或组件设置本身(`ScheduleComponent.axaml.cs:207-215`)。
#### 5.5.4 空状态、明天课表与"放学后"
- 空状态占位符(`ScheduleComponent.axaml:26-57, 105-107`):
- 今天没有课表(`LessonsService.CurrentClassPlan == null`)或(显示明天课表时明天也没有)→ 显示 `PlaceholderTextNoClass`,默认 `"今天没有课程。"`;
- 今日课程全部结束且 `HideFinishedClass=true`(`AfterSchool` 状态)→ 显示 `PlaceholderTextAllClassEnded`,默认 `"今日课程已全部结束。"`;
- 两者都受 `ShowPlaceholderOnEmptyClassPlan`(默认 `true`)控制;垂直居中、`font-size = var(--font-body)`。
- 占位符与课程列表互斥显示(不重叠)。
- 明天课表(`TomorrowScheduleShowMode`,默认 `1`,`:166-172, 59-76`):
- `0` 不显示;
- `1` 放学后显示;
- `2` 总是显示;
- `3` 今天无可显示课程时显示。
显示时:左侧出现"明天"药丸、主列表隐藏、明天列表显示;明天课表无数据时退回占位符逻辑。
- 放学后(`AfterSchool`)= 当前时间状态为"放学后"或今天没有课表(`:188-193`)。
#### 5.5.5 课程表设置项与默认值(`LessonControlSettings`)
(`repos/ClassIsland/ClassIsland/Models/ComponentSettings/LessonControlSettings.cs:8-23`,与全局设置同名项默认值一致:`Models/Settings.cs:103-108, 208-209, 221`)
| 字段 | 默认 | UI 文案 | 说明 |
|---|---|---|---|
| `ShowExtraInfoOnTimePoint` | `true` | 显示时间点附加信息 | |
| `ExtraInfoType` | `0` | 要显示的附加信息 | 0–5,见 5.5.2 |
| `IsCountdownEnabled` | `true` | 时间点结束倒计时 | |
| `CountdownSeconds` | `60` | 时间点结束倒计时时长 | 秒 |
| `IsNonExactCountdownEnabled` | `false` | 使用模糊倒计时 | 显示 `<<数值` |
| `ExtraInfo4ShowSecondsSeconds` | `0` | — | 【预留】保留 schema、不给 UI |
| `ScheduleSpacing` | `1` | 课程表文本间距 | 0.2–1,影响左右间隔 |
| `ShowCurrentLessonOnlyOnClass` | `false` | 上课时仅显示当前课程 | |
| `HideFinishedClass` | `false` | 不显示已结束的课程 | |
| `FadeCompletedClasses` | `true` | 淡化上过的课程 | 透明度 0.6 |
| `HighlightChangedClass` | `false` | 标记换课课程 | |
| `ShowPlaceholderOnEmptyClassPlan` | `true` | 无课程时显示占位符 | |
| `PlaceholderTextNoClass` | `"今天没有课程。"` | 当日没有课程的占位符内容 | |
| `PlaceholderTextAllClassEnded` | `"今日课程已全部结束。"` | 当日课程结束的占位符内容 | |
| `ShowTomorrowSchedules` | `false` | — | 【预留】保留 schema、不给 UI(语义已被 `TomorrowScheduleShowMode` 取代) |
| `TomorrowScheduleShowMode` | `1` | 显示明天课表 | 0–3 |
"时间点结束倒计时"只在"当前时间点剩余时间 ≤ CountdownSeconds"时出现(药丸),否则不显示。
#### 5.5.6 边界
- 课表里有 20 节课且全部展开时,一行放不下:课程表组件不换行、不滚动(`ScrollViewer` 禁用),超出部分被行裁切。必须在设置里引导用户启用"不显示已结束的课程"或"仅显示当前课程"。
- 时间点为 `null`(无课表)时不得抛异常,走占位符分支。
- `ScheduleSpacing` 为 `0.2` 时左右间隔各 3.2px,视觉上仍要能区分相邻课程。
- 课程名过长:科目名 `text-overflow: ellipsis`(ClassHelper 同样处理,`ComponentNode.vue:322-327`),但不要截断到只剩 1 个字符。
### 5.6 天气简报(WeatherComponent)
结构(`WeatherComponent.axaml:50-313`):横排 `StackPanel`、`Spacing 6`、垂直居中、子项居中。
#### 5.6.1 主天气信息(`MainWeatherInfoKind`,默认 `0`,共 6 种)
| 值 | 内容 | 图标/尺寸 | 文本 |
|---|---|---|---|
| `0` | 天气图标 + 温度 | `WeatherPackIconControl` 高 `22px` | 温度值 + 单位,`margin-left:6px` |
| `1` | 湿度 | FluentIcon `\uE794`,22×22 | 湿度值 + 单位 |
| `2` | 风 | FluentIcon `\uF43B` 22×22 + FluentIcon `\uE196` 22×22(按风向角旋转,`transform-origin: center`,`margin-left:6px`) | 风速值 + 单位 |
| `3` | AQI | FluentIcon `\uEA7D` 22×22(颜色随 AQI 等级 1–6 变) | AQI 数值 |
| `4` | 气压 | FluentIcon `\uE8E0` 22×22 | 气压值 + 单位 |
| `5` | 体感温度 | FluentIcon `\uF1B3` 22×22 | 体感值 + 单位 |
- 所有 `ci|FluentIcon` 统一 `margin: 0 -2px 0 0`(`WeatherComponent.axaml:42-44`),用于视觉居中补偿。
- 温度/数值文本继承 `--font-body`(16px)。
- AQI 等级配色:`AqiLevel` 1–6 分别用 `AqiLevel1Brush` … `AqiLevel6Brush`(主题资源,`:22-41`),等级来自 `Settings.LastWeatherInfo.Aqi.AqiLevel`(`WeatherComponent.axaml.cs:47-50`)。实现时把 6 个颜色做成 CSS 变量 `--aqi-1`…`--aqi-6`,按等级切 `data-aqi`。
#### 5.6.2 气象预警(`ShowAlerts`,默认 `true`)
- 容器:`WrapPanel`(带重排动画)、`vertical-align:center`、`overflow:visible`;`visible = ShowAlerts && 预警数 > 0`(`:186-198`)。
- 每种预警有两种形态,由 `AlertsTitleShowMode` 决定(默认 `1`):
- `0 = 总是图标`:只显示 22×22 图标;
- `1 = 默认`:**有自定义图标**的预警显示图标,没有的显示全文药丸(`IsDefaultIcon` 判定);
- `2 = 总是全文`。
- 图标形态:`<img width="22" height="22" style="margin-left:2px">`(`:246-251`)。
- 全文形态(`:252-263`):
```css
.alert-pill {
display: grid; grid-template-columns: 1fr 1fr;
margin-left: 2px;
}
.alert-pill .pill-bg {
grid-column: 1 / span 2;
background: var(--control-fill-default);
border-radius: 11px; /* 高度 22 的整圆 */
}
.alert-pill img { width: 22px; height: 22px; }
.alert-pill .type {
margin: 4px 8px 0 4px; /* Margin="4 0 8 0" 水平方向 */
font-size: var(--font-secondary); /* 14px */
align-self: center;
}
```
- 预警图标由后端 `Images[icon]` 提供(可能是 GIF/PNG),必须支持动态图与失败兜底(失败时显示感叹号图标,不得整块消失)。
#### 5.6.3 降水提示(`ShowRainTime`,默认 `true`)
- 仅当 `雨剩余分钟数 > 0` 时显示(`:268-275`)。
- 双层药丸(`:276-311`):
```css
.rain { position: relative; display: flex; align-items: center; padding: 0 8px; height: 22px; }
.rain .bg { position: absolute; inset: 0; background: var(--rainy-color); opacity: 0.15; border-radius: 11px; }
.rain .ring { position: relative; border: 1px solid var(--rainy-color); border-radius: 11px; padding: 0 8px;
display: flex; align-items: center; }
.rain .icon { font-size: var(--font-secondary); margin-right: 4px; } /* LucideIcon \uE092 */
.rain .text { font-size: var(--font-secondary); font-weight: 400; } /* 14px */
```
- 文本两态:`IsFuzzyLongRainCountdownEnabled=true`(默认)→ 用"约 X 分钟/约 X 小时"的模糊文案(`MinutesToApproxTimeConverter`);`false` → 精确 `{n}min`(`:296-308`)。
- 药丸圆角 = 自身高度的一半(`CornerRadius = SizeDoubleToCornerRadiusConverter(Bounds.Height)`)。
#### 5.6.4 设置项与默认值(`WeatherComponentSettings`)
| 字段 | 默认 | UI 文案 |
|---|---|---|
| `ShowMainWeatherInfo` | `true` | 显示天气信息 |
| `MainWeatherInfoKind` | `0` | 主信息类型(6 选 1) |
| `ShowAlerts` | `true` | 显示预警信号 |
| `AlertsTitleShowMode` | `1` | 显示预警类型(0 总是图标/1 默认/2 总是全文) |
| `ShowRainTime` | `true` | 显示降水提示 |
| `IsFuzzyLongRainCountdownEnabled` | `true` | 模糊长时间降雨倒计时时间显示精度 |
#### 5.6.5 天气图标模板
全局设置 `Settings.WeatherIconId`,默认 `"classisland.weatherIcons.lucide"`(`Models/Settings.cs:1849`)。内置 4 套(`App.Services.xaml.cs:287-290`):
| ID | 名称 |
|---|---|
| `classisland.weatherIcons.lucide` | Lucide(默认) |
| `classisland.weatherIcons.fluentDesign` | Fluent Design |
| `classisland.weatherIcons.sfSymbols` | SF Symbols |
| `classisland.weatherIcons.simpleText` | 纯文本 |
- 纯文本模板的渲染规则(`Controls/WeatherIcons/SimpleTextWeatherIconTemplate.axaml`):若该图标用于"到"(`Tag='To'`),前置一个 `~` 分隔符,再显示 `WeatherName`;否则只显示 `WeatherName`,水平居中。
- 实现:把这 4 套模板做成可注册的"图标渲染器"(返回 SVG 或文本节点),`weatherPackIcon` 组件按 `WeatherIconId` 分发;未知 ID 必须回退到 lucide。
#### 5.6.6 边界
- 天气数据为空/加载失败:不显示温度与图标(不得显示 `0°` 这种假数据);预警与降水区整体隐藏;组件不得撑高。
- 天气数据过期(`stale`)时仍显示旧数据,但可加 `opacity: 0.7`【推断:ClassIsland 未做此处理,属可选增强】。
- 预警可能同时 3 条以上,`WrapPanel` 在 40px 内只放得下一行,多余项被行裁切;必须按"最多显示 N 条 + 更多"策略实现【推断:ClassIsland 未裁剪,依赖用户自行配置】。
### 5.7 文本(TextComponent)
- 视觉:单个 `TextBlock`,垂直居中,`font-size = Settings.FontSize`(`:18-28`)。
- 设置项与默认值(`Models/ComponentSettings/TextComponentSettings.cs`):
| 字段 | 默认 | UI 文案 |
|---|---|---|
| `TextContent` | `""` | 文本内容 |
| `UseCustomFontColor` | `true` | 自定义字体颜色 |
| `FontColor` | `#FFFFFF` | 颜色选择器 |
| `FontSize` | `16` | 字体大小,范围 16–30、步进 1 |
- 颜色仅当 `UseCustomFontColor=true` 时生效,否则继承前景色(宿主/行/全局)。
- 边界:内容为空时不占宽度(`display:none` 或空 span),避免行内留出 12px 空隙;内容超长时按 `max-width` 约束(如果启用了最大/固定宽度)并 `ellipsis`。
- 支持多行文本?ClassIsland 只有一个 `TextBlock` 且行高 40,因此**必须换行时用 `\n` 也只会被裁切**;实现上禁止插入换行,输入框遇到换行符替换为空格。
### 5.8 滚动容器(RollingComponent)
- 语义:内容宽于容器时,内容横向匀速滚动;可定时暂停、可按规则停止。
- 结构(`RollingComponent.axaml:14-48`):外层 `Border`(`overflow: visible`、`margin: 0 -6px`)→ `Canvas`(宽 = 内容 `ItemsControl` 宽、左对齐)→ `Grid`(`grid-template-columns: auto 16px auto`)→ `ItemsControl`(子组件横排)。
- 滚动动画(关键公式,`RollingComponent.axaml.cs:85-135`):
```
innerW = 内容自然宽度
outerW = 容器宽度
仅当 outerW < innerW 才滚动
pausePos = (!IsPauseEnabled || PauseOffsetX > innerW) ? 0 : PauseOffsetX
durSeconds = innerW / clamp(SpeedPixelPerSecond, 1, +∞)
pauseSeconds= IsPauseEnabled ? PauseSeconds : 0
backSeconds = outerW / clamp(SpeedPixelPerSecond, 1, +∞)
totalSeconds= durSeconds + pauseSeconds + backSeconds
关键帧(全部 LinearEasing,IterationBehavior=Forever):
t=0 X = 0
t=pause/total X = -pausePos
t=(pause+dur)/total X = -(innerW + pausePos)
t=(pause+dur)/total (同一时刻跳切) X = outerW
t=1 X = 0
```
即:先停 `pauseSeconds`(若 `PauseOffsetX>0` 则先移到 `-PauseOffsetX`),再匀速把内容推到完全离开,瞬间跳回右侧,再匀速推回原位。`Duration = pauseSeconds + durSeconds`(`:108`)——注意 Aughor 原实现里 `anim.Duration` 只含前两段,靠 `t=1` 关键帧兜住回程;实现时必须按"总时长 = pause+dur+back"重新归一化时间轴,否则回程速度会与去程不一致(这是源码中的已知近似,实现时应修正以保证视觉匀速)。
- 规则停止:`StopOnRule && 规则满足` → 停止动画并立刻回到初始位置(`visual.Offset.X = -pausePos`),并写一个 1 秒的"空动画"把 Offset 固定住防止被其他动画拖走(`:137-157, 171-180`)。
- 设置项与默认值(`Models/ComponentSettings/RollingComponentSettings.cs`):
| 字段 | 默认 | UI 文案 | 范围 |
|---|---|---|---|
| `SpeedPixelPerSecond` | `40.0` | 滚动速度 | ≥0.1 px/s |
| `IsPauseEnabled` | `true` | 定时暂停 | |
| `PauseSeconds` | `10.0` | 暂停时长 | 0–最大秒数 |
| `PauseOffsetX` | `0.0` | 暂停位置 | ≥0 px |
| `PauseOnRule` | `false` | — | 【预留】保留 schema、不给 UI(暂停由 5.9 轮播容器的规则承担) |
| `PauseRule` | `{}` | — | 【预留】保留 schema、不给 UI |
| `StopOnRule` | `false` | 满足规则时停止 | |
| `StopRule` | `{}` | 规则集 | |
- 提示 InfoBar(首次使用显示):"滚动组件仅在组件内容超出滚动组件宽度时才会开始滚动。您可能需要在【高级设置】中为本组件设置【最大宽度】或【固定宽度】……"(`RollingComponentSettingsControl.axaml:22-24`,`IsRollingComponentWarningVisible` 默认 `true`,`Models/Settings.cs:1850`)。
- 边界:内容未超出时完全不滚动(不得出现 1px 抖动);`SpeedPixelPerSecond` 极小时按 ≥1 px/s 计算以免除零;子组件全部隐藏时宽度为 0,应停止滚动;窗口失焦/系统节流导致帧不连续时不得跳帧。
### 5.9 轮播容器(SlideComponent)
- 语义:多个子组件共用一块区域,按间隔轮换显示;支持动画过渡。
- 结构(`SlideComponent.axaml:17-125`):`ListBox`(`margin: 0 -6px`、`overflow: visible`、水平滚动禁用、`ItemsPanel=Grid`(所有项叠在同一格))。
- 未启用动画(`IsAnimationEnabled=false`,默认):非选中项 `display:none`(`:51-53`),只显示当前项。
- 启用动画(`:54-108`):
```css
.slide-item { position: relative; }
/* 进入 */
.slide-item.is-selected .content {
animation: slide-in 250ms cubic-bezier(0.65, 0, 0.35, 1) both;
}
@keyframes slide-in {
0% { opacity: 0; transform: translateY(-40px); visibility: visible; }
100% { opacity: 1; transform: translateY(0); }
}
/* 离开 */
.slide-item:not(.is-selected) .content {
animation: slide-out 250ms cubic-bezier(0.65, 0, 0.35, 1) both;
}
@keyframes slide-out {
0% { opacity: 1; transform: translateY(0); visibility: visible; }
100% { opacity: 0; transform: translateY(40px); visibility: hidden; }
}
```
源码里两段动画都是 `Duration=250ms`、`KeySpline="0.65, 0, 0.35, 1"`(`:72-107`),进入位移 `-40 → 0`、离开位移 `0 → +40`,透明度 0↔1 同步;离开结束后 `IsVisible=false`。
- 轮播调度(`SlideComponent.axaml.cs:51-121`):
- 间隔 = `Settings.SlideSeconds`(默认 `15`,UI 最小 `0.1`);
- 子组件数 ≤ 1 时不轮播,恒显示第 0 个;
- `SlideMode`:`0 循环`(+1 取模)、`1 随机`(预生成打乱队列,用尽再洗牌,保证一轮内不重复)、`2 往复`(到边界反向);
- 跳过被隐藏规则的子组件(循环查找可见项,全部隐藏时停止);
- 规则:`IsPauseOnRuleEnabled` 满足 → 暂停定时器;`IsStopOnRuleEnabled` 满足 → 停止定时器并把索引归 0;两者同时满足时"停止"优先。
- 每项 `MaxHeight = 40px`(`:119`),`ComponentPresenter` `Margin="6 0"`、`VerticalAlignment=Stretch`。
- 设置项与默认值(`Models/ComponentSettings/SlideComponentSettings.cs`):
| 字段 | 默认 | UI |
|---|---|---|
| `SlideSeconds` | `15` | 轮换间隔(秒,≥0.1) |
| `SlideMode` | `0` | 轮换模式(循环/随机/往复) |
| `IsPauseOnRuleEnabled` | `false` | 满足规则时暂停 |
| `PauseRule` | `{}` | 规则集 |
| `IsStopOnRuleEnabled` | `false` | 满足规则时停止 |
| `StopRule` | `{}` | 规则集 |
| `UseOldPresentingBehavior` | `false` | 【预留】保留 schema、不给 UI(旧版呈现行为的兼容开关) |
| `IsAnimationEnabled` | `false` | 启用动画 |
- **【预留】字段处置**:`UseOldPresentingBehavior`、滚动容器的 `PauseOnRule`/`PauseRule`、课程表的 `ExtraInfo4ShowSecondsSeconds`/`ShowTomorrowSchedules` 均**保留 JSON schema 与默认值、迁移时原样写回,但不提供 UI 开关**。实现不得删除这些字段(会破坏旧配置反序列化),也不得为其臆造设置项。
- 边界:切换时不能让行高变化(所有项都叠在同一格,容器高度取最大者,且被 `MaxHeight` 钳制);只有一个子组件时不产生任何过渡动画。
### 5.10 分组容器(GroupComponent)
- 语义:把多个组件横向组合成一个整体(用于让它们共享一个"宽度固定/对齐"的约束)。
- 结构(`GroupComponent.axaml:13-33`):`ItemsControl` + `StackPanel`(`Orientation=Horizontal`,类 `components-panel`),`margin: 0 -6px`,子项 `ComponentPresenter` `Margin="6 0"`。
- 与"行直接放组件"的唯一区别:分组容器可以整体设置宽度/对齐/不透明度/背景/圆角,内部子组件单独再设各自的覆盖项。
- 设置:只有 `Children`(`GroupComponentSettings.cs`)。
- 边界:空分组不占宽度(自动塌缩为 0,不留 12px 空隙)。
### 5.11 堆叠容器(StackComponent)
- 语义:多个子组件**重叠**在同一位置显示(用于"层叠"效果,例如文字 + 背景图标)。
- 结构(`StackComponent.axaml:12-32`):`ItemsControl` + `Panel`(类 `components-panel`),`margin: 0 -6px`;`Panel` 的布局语义是"所有子元素占据同一矩形,各自按自身对齐方式摆放"。
- CSS 实现:
```css
.stack-panel { position: relative; display: block; }
.stack-panel > .component-host {
position: absolute; inset: 0; /* 所有子项同一矩形 */
display: flex; /* 由子项自身 justify/align 决定落点 */
align-items: center;
margin: 0 6px;
}
```
- 尺寸:容器宽高取所有子项测量值的最大者(Avalonia `Panel` 的行为);行高仍是 40px。
- 设置:只有 `Children`。
- 边界:子项之间不得互相拦截命中(普通模式全窗穿透,无影响);子项数量多时不要改变容器宽度以外的布局。
- 与轮播容器的区别要写进文档与 UI 提示:**轮播是"不同时刻显示不同子项",堆叠是"同一时刻全部显示"**。
### 5.12 分割线(SeparatorComponent)
- 视觉(`SeparatorComponent.axaml:10-14`):
```css
.node-separator { width: 2px; display: flex; align-items: center; justify-content: center; height: 100%; }
.node-separator .line { width: 2px; height: 25px; background: var(--text-fill-secondary); opacity: 0.8; }
```
- 源码用 `Grid Width="2"` 包一条 `Line`:`StrokeThickness=2`、`StartPoint 0,0` → `EndPoint 0,25`、`Stroke=TextFillColorSecondaryBrush`、`Opacity=0.8`、垂直/水平居中。
- 设置:无(只有通用设置)。
- 边界:位于行首/行尾时仍然占 2px + 两侧各 6px = 14px,不做特殊收边(与 ClassIsland 一致)。
### 5.13 组件设置 UI(组件设置页 + 高级设置页)
抽屉标题栏与页签见 4.8.6。两页内容:
**页 1「组件设置」**:渲染组件自己的 `SettingsType` 控件(`ComponentPresenter` 以 `IsPresentingSettings=true` 渲染,`EditModeView.axaml:287-290`),即本文每个组件的"设置项"清单。
**页 2「高级设置」**:按下面顺序渲染(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentAdvancedSettingsControl.axaml`):
1. 按规则隐藏(开关 + 说明"注意此处的隐藏不会释放组件资源");
2. 不透明度(Slider 0–1,步进 0.05);
3. 自定义背景颜色(开关 + ColorPicker);
4. 自定义背景不透明度(开关 + Slider);
5. 自定义背景圆角半径(开关 + Slider 0–20,步进 0.5,文案"调至最大数值时左右边缘为半圆");
6. 字体大小(总开关 `IsResourceOverridingEnabled` + 四个 NumberBox:次级/正文/强调/大号,范围 1–40,`ci:Field` 宽 175、后缀 `px`);
7. 字体颜色(开关 + ColorPicker);
8. 对齐方式(4 个图标按钮:左 `\uE03E`、居中 `\uE03A`、拉伸 `\uE034`、右 `\uE03C`);
9. 固定宽度(开关 + NumberBox);
10. 宽度限制(两个开关 + 两个 NumberBox:最大/最小);
11. 自定义边距(开关 + 4 个 NumberBox:左/上/右/下)。
`ci:Field` 后缀单位固定为 `px`;字号输入框的 `SpinButtonPlacementMode=Inline`、`Minimum=1`、`Maximum=40`(`EditModeView.axaml:418-427` 的等价写法)。
### 5.14 空数据与异常兜底(逐组件)
| 场景 | 必须表现 |
|---|---|
| 课表为空 / 无今日课表 | 课程表显示 `PlaceholderTextNoClass`(默认"今天没有课程。");不得显示空白胶囊 |
| 今日课程全部结束 | 显示 `PlaceholderTextAllClassEnded`(默认"今日课程已全部结束。"),或按设置隐藏(`HideFinishedClass`) |
| 天气数据缺失 | 天气组件隐藏温度/图标;只显示可用的预警/降水;不显示假值 |
| 天气图标加载失败 | 该预警显示"图裂"占位(红色背景 + 警告图标),不得整块消失 |
| 倒计时区间非法(end<start) | `Percent=0`、数值全 0、进度环不画 |
| 倒计时区间为 0 长度 | 同上,不除零 |
| 计时服务不可用 | 回退本地时间(时钟、日期、倒计时全部回退) |
| 滚动内容未溢出 | 不滚动、不抖动 |
| 轮播只有 1 个子项 | 不轮播、不播过渡动画 |
| 子组件全部被规则隐藏 | 容器塌缩为 0 宽,可行则整行隐藏(`IsAllComponentsHid`) |
| 设置对象为 `null` / 字段缺失 | 回退到该字段默认值(JSON 反序列化时补默认,不得抛异常) |
| 组件 ID 在注册表中找不到 | 渲染"缺失组件"占位:显示 `NameCache`(缓存名)或 ID,图标用警告图标,宽度 100px;不得崩溃、不得吞掉整行 |
### 5.15 Vue 组件结构与 CSS 变量
必须采用下面这套文件结构(与 ClassHelper 的 `ComponentNode.vue` 思路一致,`repos/ClassHelper-master/packages/desktop-client/src/island/ComponentNode.vue:212-293`):
```
src/island/components/
ComponentHost.vue // 宿主:宽度/对齐/透明度/隐藏/占位
registry.ts // ID → { name, icon, description, container, component, settingsSchema, defaults }
nodes/
DateNode.vue
ClockNode.vue
CountDownNode.vue
ScheduleNode.vue (+ LessonNode.vue / LessonSeparatorNode.vue)
WeatherNode.vue
TextNode.vue
RollingNode.vue
SlideNode.vue
GroupNode.vue
StackNode.vue
SeparatorNode.vue
ContainerNode.vue // 通用容器包装,按 kind 分发
```
统一 props 约定:`{ node: ComponentSettings, state: IslandState, now: number, schedules, timePoints }`;所有节点根元素类名固定为 `component-node node-<kind>`(与 ClassHelper 一致,便于主题覆盖与自动化断言)。
必须暴露的 CSS 变量(在 `:root` 与组件节点上均可覆盖)。**规范 token 名以 part 3 的 `--ci-*` 为准**,下表左侧为本文别名,落地时按右侧映射:
| 本文别名 | 规范 token | 值 |
|---|---|---|
| `--font-secondary` | `--ci-fs-secondary` | 14px |
| `--font-body` | `--ci-fs-body` | 16px |
| `--font-emphasized` | `--ci-fs-emphasized` | 18px |
| `--font-large` | `--ci-fs-large` | 20px |
| `--text-fill-primary` | `--ci-text-1` | 0.92 白 |
| `--text-fill-secondary` | `--ci-text-2` | 0.72 白 |
| `--accent` | `--ci-accent` / 填充 `--ci-accent-fill` | 默认 `#0a84ff`(用户强调色 DodgerBlue) |
| `--control-fill-default` | `--ci-fill-secondary` | 0.10 白 |
| `--control-strong-stroke-disabled` | `--ci-stroke-control` | 0.30 白 |
| `--rainy-color` | `--ci-weather-rain` | 降雨色 |
| `--aqi-1` … `--aqi-6` | `--ci-aqi-1` … `--ci-aqi-6` | AQI 六级色 |
| `--node-gap` | `--ci-component-gap` | 12px |
| 进度条高度 | `--ci-progress-height` | **3px**(倒计时进度条、课程进度条共用) |
| 高亮条 | `--ci-accent-fill` + opacity 0.4 | — |
```css
:root {
--font-secondary: 14px;
--font-body: 16px;
--font-emphasized: 18px;
--font-large: 20px;
--text-fill-primary: rgba(255,255,255,0.92);
--text-fill-secondary: rgba(255,255,255,0.72);
--accent: #0a84ff; /* 用户强调色,默认 DodgerBlue/Apple 蓝 */
--control-fill-default: rgba(255,255,255,0.10);
--control-strong-stroke-disabled: rgba(255,255,255,0.30);
--rainy-color: #4a9eff;
--aqi-1: #6fdd8b; --aqi-2: #ffd666; --aqi-3: #ff9f0a; --aqi-4: #ff6961; --aqi-5: #c77dff; --aqi-6: #b23a48;
--node-gap: 12px; /* = 两个 6px 外边距之和 */
}
```
组件节点通用样式:
```css
.component-node {
display: flex;
align-items: center; /* 所有组件在 40px 行内垂直居中 */
min-width: 0;
flex-shrink: 0;
white-space: nowrap;
}
.component-node.text, .component-node.schedule { white-space: normal; }
```
### 5.16 验收标准
1. 首次启动生成 1 行 2 组件(日期 + 课程表),日期显示形如 `周三 11/12`,课程表显示当天课程或占位符。
2. 11 个内置组件全部能从组件库拖入主界面,拖入后立刻可编辑、可删除,删除后无残留占位。
3. 时钟:显示 `HH:mm`;开启闪动后冒号 1 秒亮 1 秒灭;开启秒数后变 `HH:mm:ss` 且冒号常亮;数字等宽,切换时不水平抖动。
4. 倒计时:4 种时间源(固定时间/周期/今天/本周)各配一遍,数值、进度环、进度条、连接词显示正确;`%D/%H/%M/%S/%X/%P/%p/%L/%d/%h/%m/%s/%x` 全部 token 输出与源码规则一致(尤其 `%m/%s/%x` 的补零与向上取整语义)。
5. 倒计时进度环从 12 点方向顺时针填充,满 100% 时不出现缺口;紧凑模式只显示"名称 + 数值"。
6. 课程表:当前课程高亮、已结束课程淡化到 0.6、剩余时间药丸在阈值内出现;`HideFinishedClass` / `ShowCurrentLessonOnlyOnClass` / `FadeCompletedClasses` / `HighlightChangedClass` 各自开关验证;4 种"显示明天课表"模式行为正确。
7. 课程表空状态:无课表显示"今天没有课程。";全部结束显示"今日课程已全部结束。";`ShowPlaceholderOnEmptyClassPlan=false` 时两者都不显示。
8. 天气:6 种主信息逐一切换显示正确;有预警时按"总是图标/默认/总是全文"三种模式渲染;降水药丸在 `RainRemainingMinutes>0` 时出现、`=0` 时消失;模糊模式显示"约 X 分钟"、精确模式显示"Xmin"。
9. 文本:字号 16 与 30 时高度都不超过 40px;自定义颜色生效/关闭后回退;空文本不占宽。
10. 滚动容器:内容超出时按 `SpeedPixelPerSecond` 匀速滚动,`PauseSeconds` 停顿位置等于 `PauseOffsetX`;内容未超出时静止;命中"停止规则"后回到初始位置。
11. 轮播容器:`SlideSeconds` 到点切换;循环/随机/往复三种模式各验证一轮;开启动画时切换有 250ms 上下位移淡入淡出,关闭时无过渡;只有 1 个子项时不轮播。
12. 分组容器的子项横向排列,间距恒定 12px;堆叠容器的子项完全重叠在同一矩形且各自按对齐方式摆放;分割线为 2×25px、透明度 0.8。
13. 所有组件的"高级设置"项生效:固定宽度、最大/最小宽度、对齐、不透明度、自定义边距、字号覆盖、字体颜色覆盖;隐藏规则勾选后组件消失但在编辑模式显示斜条纹占位。
14. 组件 ID 不存在时显示"缺失组件"占位而不是白屏或崩溃。
15. 全部组件在 40px 行高内垂直居中,无任何组件把行撑高超过 40px。
+530
View File
@@ -0,0 +1,530 @@
## 6. 课程表 / 时间表 / 档案 / 科目 / 周次 / 调课 / 导入导出
本节规定「档案(Profile)—时间表(TimeLayout)—课表(ClassPlan)—课程(ClassInfo)—科目(Subject)」这一整条业务链的完整实现规格。技术栈按 ClassHelper 的 Electron + Vue 3 + TypeScript 实现:主进程负责文件读写与计算,渲染进程负责编辑界面;数据结构必须以 JSON 落盘,字段命名沿用本节给出的名字,不得自行改名。
### 6.1 数据结构(必须逐字段实现)
档案是**一个 JSON 文件**,存放在 `Profiles/` 目录下,默认文件名 `Default.json`(`repos/ClassIsland/ClassIsland/Models/Settings.cs:66`:`_selectedProfile = "Default.json"`;`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:48`:`ProfilePath = 应用根目录/Profiles`)。档案根对象必须包含以下字段(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:17-32`):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Name` | string | `""` | 档案名 |
| `TimeLayouts` | `Record<Guid, TimeLayout>` | `{}` | 时间表字典 |
| `ClassPlans` | `Record<Guid, ClassPlan>` | `{}` | 课表字典 |
| `Subjects` | `Record<Guid, Subject>` | `{}` | 科目字典 |
| `ClassPlanGroups` | `Record<Guid, ClassPlanGroup>` | 2 个内置项,见 6.1.6 | 课表群字典 |
| `SelectedClassPlanGroupId` | Guid | `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 当前启用的课表群 |
| `TempClassPlanGroupId` | Guid? | `null` | 临时课表群 |
| `TempClassPlanGroupExpireTime` | DateTime | `DateTime.Now` | 临时课表群失效日期 |
| `IsTempClassPlanGroupEnabled` | bool | `false` | 临时课表群开关 |
| `TempClassPlanGroupType` | 0/1 | `1`(Inherit) | 0=覆盖 Override,1=继承 Inherit |
| `TempClassPlanId` | Guid? | `null` | 临时课表(当天) |
| `TempClassPlanSetupTime` | DateTime | `DateTime.Now` | 临时课表设置时间 |
| `IsOverlayClassPlanEnabled` | bool | `false` | 临时层课表总开关 |
| `OverlayClassPlanId` | Guid? | `null` | 当前临时层课表 |
| `OrderedSchedules` | `Record<"yyyy-MM-dd", { ClassPlanId: Guid }>` | `{}` | 预定课表(按日期) |
| `Id` | Guid | `Guid.NewGuid()` | 档案 ID,用于「信任档案」 |
`TempClassPlanGroupType` 的两值语义必须严格按源码实现(`repos/ClassIsland/ClassIsland.Shared/Enums/TempClassPlanGroupType.cs:6-16`):`Override` 表示「覆盖现在启用的课表群」,`Inherit` 表示「继承现在启用的课表群;临时课表群中没有满足规则的课表时,回退到当前启用的课表群中查找」。
#### 6.1.1 时间表 TimeLayout
```
{
"Name": "新时间表", // 默认值,Profile.cs 同级模型 TimeLayout.cs:17
"Layouts": [ TimeLayoutItem, ... ], // 有序数组,按 StartTime 升序维护
"IsOverlay": false, // 是否临时层时间表
"OverlaySourceId": null // 临时层来源时间表 ID
}
```
`IsActivated`、`IsActivatedManually` 是运行期状态,`[JsonIgnore]`,**不得写入 JSON**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayout.cs:272-297`)。
#### 6.1.2 时间点 TimeLayoutItem
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `StartTime` | string(`HH:mm:ss`) | `00:00:00` | 时间点开始 |
| `EndTime` | string | `00:00:00` | 时间点结束;`TimeType` 为 2 或 3 时**强制等于 StartTime** |
| `TimeType` | int | `0` | 0=上课,1=课间,2=分割线,3=行动 |
| `IsHideDefault` | bool | `false` | 默认隐藏 |
| `DefaultClassId` | Guid | `Guid.Empty` | 默认科目 ID |
| `BreakName` | string | `""` | 自定义课间名;为空时显示 `BreakNameText`(`"课间休息"`) |
| `ActionSet` | ActionSet? | `null` | 仅当 `TimeType=3` 时使用,见第 8 节 |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayoutItem.cs:13-21,62-93,119-138,143-191,196-205`。`Last`(时长)= `EndTime - StartTime`,`[JsonIgnore]`(同文件 113-117)。序列化时必须使用 `HH:mm:ss` 文本,禁止用「一天内秒数」数字(旧字段 `StartSecond`/`EndSecond` 已 `[Obsolete(..., true)]`,见同文件 26-57,读取旧档案时需要迁移)。
#### 6.1.3 课表 ClassPlan
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Name` | string | `"新课表"` | 课表名 |
| `TimeLayoutId` | Guid | `Guid.Empty` | 引用 `Profile.TimeLayouts` 的键 |
| `Classes` | ClassInfo[] | `[]` | 与时间表中 **TimeType=0 的时间点一一对应、同序** |
| `TimeRule` | TimeRule | 见 6.1.4 | 触发规则 |
| `IsEnabled` | bool | `true` | 是否自动启用 |
| `IsOverlay` | bool | `false` | 是否临时层课表 |
| `OverlaySourceId` | Guid? | `null` | 临时层源课表 |
| `OverlaySetupTime` | DateTime | `DateTime.Now` | 临时层日期 |
| `AssociatedGroup` | Guid | `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 所属课表群 |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:15-27,393-607`。`IsActivated`、`ValidTimeLayoutItems`、`LastTimeLayoutCount`、`ClassPlans`、`TimeLayouts`、`TimeLayout` 均为运行期或引用属性,必须 `[JsonIgnore]`(同文件 35-46,110-120,357-391,427-435,527-537)。
`Classes` 与时间点的同步规则必须实现(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:451-491`):
1. 统计 `TimeLayout.Layouts` 中 `TimeType==0` 的个数 `l`。
2. `Classes.Count < l` 时用空 `ClassInfo` 补齐;`Classes.Count > l` 时从尾部删除多余项。
3. 逐项写入 `Index` 与 `CurrentTimeLayout`(运行期字段)。
4. **若 `Classes[i].SubjectId == Guid.Empty` 且对应时间点 `DefaultClassId != Guid.Empty`,则把 `DefaultClassId` 写入 `SubjectId`**。
5. 更新 `LastTimeLayoutCount = TimeLayout.Layouts.Count`。
#### 6.1.4 课表触发规则 TimeRule
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
| `WeekDay` | int | `0` | `(int)DayOfWeek`,0=周日、1=周一……6=周六。注意默认是**周日**,因为 `private int _weekDay = new();` 即 0 |
| `WeekCountDiv` | int | `0` | 0=不轮换;n=第 n 周启用 |
| `WeekCountDivTotal` | int | `2` | 轮换总周数(2=单双周) |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeRule.cs:13-16,20-69`。
#### 6.1.5 课程 ClassInfo
`{ "SubjectId": Guid, "IsChangedClass": bool, "IsEnabled": true }`。`SubjectId` 默认 `Guid.Empty`;`Index`、`CurrentTimeLayout`、`CurrentTimeLayoutItem`、`IsEmpty` 都是运行期字段,不得落盘(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassInfo.cs:12-113`)。`IsChangedClass` 表示「临时换课」标记,仅在临时换课模式下由界面写入。
#### 6.1.6 课表群 ClassPlanGroup 与科目 Subject
课表群 `{ "Name": "新课表群", "IsGlobal": false }`,两个内置群的 GUID 必须**硬编码**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlanGroup.cs:10-11,44-49`):
- 默认课表群:`ACAF4EF0-E261-4262-B941-34EA93CB4369`,名称 `"默认"`。
- 全局课表群:`Guid.Empty`(全 0),名称 `"全局课表群"`,`IsGlobal=true`。
档案构造时必须保证这两个群存在(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:44-59`)。解散课表群时把其中的课表移回默认群;删除课表群时连同课表一起删除;对默认群与全局群执行这两种操作必须抛出异常(`Profile.cs:104-137`)。
科目 `{ "Name": "", "Initial": "", "TeacherName": "", "IsOutDoor": false }`(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Subject.cs:10-14`)。必须实现三条内置常量科目:
- `Fallback`:`{ Initial: "?", Name: "???" }`(Subject.cs:79-83)——课程服务找不到科目时的兜底,界面上必须先判断是否等于它(例如「下节课是 ???」代表没有下一节课)。
- `Empty`:`{ Initial: "", Name: "" }`(Subject.cs:86-92)。
- `Breaking`:`{ Initial: "休", Name: "课间休息" }`(Subject.cs:95-101)——课间状态时 `CurrentSubject` 指向它,并把 `Name` 覆写为当前课间名。
`Subject.Name` 的 setter 必须实现「`Initial` 为空且新 Name 非空白时,自动取 Name 首字符写入 Initial」(Subject.cs:19-30)。`GetFirstName()` 必须实现教师姓氏提取(Subject.cs:107-140):含中文时优先匹配复姓表(两字),否则取首字;不含中文时取按空格切分后的最后一个单词;未填教师名返回空串。
### 6.2 当天应使用哪张课表:选择算法(必须逐分支实现)
每次主计时器 tick(默认 50ms,见 6.3)都要重新计算「当前课表」。优先级**严格按以下顺序**,命中即返回(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:143-201`):
1. **预定课表**:`Profile.OrderedSchedules[今天]` 存在,且对应 `ClassPlan` 存在,且(该课表不是临时层 或 `IsOverlayClassPlanEnabled`)→ 返回它。
2. **临时课表**:`Profile.TempClassPlanId != null` 且存在该课表,且 `TempClassPlanSetupTime.Date >= 今天` → 返回它。
3. **常规课表**:按下列过滤 + 排序后取第一个:
- 分组匹配:`AssociatedGroup` 等于 `ClassPlanGroup.GlobalGroupGuid` 或 `SelectedClassPlanGroupId`;若启用临时课表群且未过期,再按类型处理——`Inherit`:默认群 ∪ 临时群 ∪ 全局群;`Override`:临时群 ∪ 全局群。
- 组优先级排序(必须用 `OrderByDescending`):临时课表群=3 > 当前选中课表群=2 > 全局课表群=1 > 其他=0。
- 对每张候选课表执行 `CheckClassPlan`。
`CheckClassPlan(plan, date)` 的判定顺序(`LessonsService.cs:582-607`):
1. `plan.IsOverlay == true` → 不匹配。
2. `plan.IsEnabled == false` → 不匹配。
3. `plan.TimeRule.WeekDay != (int)date.DayOfWeek` → 不匹配。
4. `AssociatedGroup` 既不是全局群、也不是当前选中群、也不是临时群 → 不匹配。
5. `WeekCountDivTotal > Settings.MultiWeekRotationMaxCycle` → 不匹配(防止越过最大周期)。
6. `WeekCountDiv == 0` → **匹配**。
7. 否则计算 `GetCyclePositionsByDate(date)[WeekCountDivTotal]`,等于 `WeekCountDiv` 才算匹配。
实现时注意:若一张课表都没匹配上,`CurrentClassPlan` 必须为 `null`,且所有运行期状态回到「无课表」语义(`CurrentState=None`、`CurrentSubject=Fallback`、`CurrentTimeLayoutItem=Empty`),不得抛异常。
### 6.3 时间状态机与课程计算(主循环)
必须起一个**周期 50ms、`DispatcherPriority.Render` 优先级**的计时器(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:39-45`),每个 tick 依次触发 `PreMainTimerTicked` → 计算 → `PostMainTimerTicked`(同文件 373-388)。在 Electron 实现中映射为:主进程用 50ms `setInterval` 或(建议)`requestAnimationFrame`+定时器混合,通过 IPC 广播 tick 事件;渲染进程不得自己做第二套计时。
每次 tick 的计算步骤(`LessonsService.cs:390-527`):
1. 刷新档案引用、清理过期数据:`TempClassPlanSetupTime.Date < 今天` → `TempClassPlanId = null`;清理过期预定/临时层;清理过期临时课表群(见 6.7)。
2. 若 `IsClassPlanEnabled == false` → `CurrentClassPlan = null` 并结束。
3. 取当前课表;若当天预定课表本身是临时层 → `OverlayClassPlanId = 该课表`,否则置 `null`。
4. 取 `now = 当前本地时间(以校时服务为准).TimeOfDay`。
5. **当前时间点** = 有效时间点列表中第一个满足 `TimeType ∈ {0,1}` 且 `StartTime <= now <= EndTime` 的项。
6. 若命中且 `TimeType==0`:`CurrentState=OnClass`;按「上课时间点在教学点中的序号」映射到 `Classes[i]`,取 `SubjectId` 对应科目填入 `CurrentSubject`(映射算法:把 `TimeLayout.Layouts` 中 `TimeType==0` 的项按序建数组,取当前项的下标,见 `LessonsService.cs:531-541`)。
7. 若命中且 `TimeType==1`:`CurrentSubject = Subject.Breaking`,并把其 `Name` 设为 `CurrentTimeLayoutItem.BreakNameText`;`CurrentState=Breaking`;`IsLessonConfirmed=true`。
8. **下节课** = 有效时间点中第一个 `TimeType==0 && EndTime >= now` 的项;有则按步骤 6 的方法取科目写入 `NextClassSubject`。
9. **下个课间** = 有效时间点中第一个 `TimeType==1 && EndTime >= now` 的项。
10. 剩余时间:`OnClass` 状态下算 `OnBreakingTimeLeftTime = 下个课间.StartTime - now`;否则算 `OnClassLeftTime = 下节课.StartTime - now`;两种时间都必须**下限为 0**(`LessonsService.cs:529`)。
11. 若「下节课」和「下个课间」都为 `null` → `CurrentState = AfterSchool`。
12. 把结果统一写回所有属性(没算出的用默认值:`-1`、`None`、`Fallback`、`Empty`、`Zero`、`false`)。
13. **状态变化事件**:与上一次状态比较,不同才发事件;顺序固定为「先 `CurrentTimeStateChanged`,再按新状态发 `OnClass` / `OnBreakingTime` / `OnAfterSchool` 之一」。
必须同时实现的两个派生属性:
- `CurrentStatusIsBreaking`(课间)与 `IsLessonConfirmed`:仅当命中当前时间点时置 true,否则 false。提醒系统用它避免重复提醒(见第 7 节)。
- `ValidTimeLayoutItems`(有效时间点):仅保留 `TimeType ∈ {0,1,2}` 的时间点,然后按「课程是否启用」做**正向 + 反向两遍裁剪**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:54-98`):正序时一旦遇到 `ClassInfo.IsEnabled=false`,其后所有时间点都移除;逆序时一旦遇到 `IsEnabled=false`,其前所有时间点都移除。效果是「禁用一节早课,则它前面所有时间点都不显示;禁用一节晚课,则其后所有时间点都不显示」。必须实现该双向裁剪,否则「课程表」组件与倒计时会与真实课表不一致。
### 6.4 周次轮换(单双周 / 多周)
必须实现「基准周 + 周期 + 每周期偏移」三件套:
- `Settings.SingleWeekStartTime`:单周起始日,默认取**今天所在周的周日** `DateTime.Today.AddDays(-(int)Today.DayOfWeek)`(`repos/ClassIsland/ClassIsland/Models/Settings.cs:37-42`)。
- `Settings.MultiWeekRotationMaxCycle`:最大轮换周期,默认 `4`(Settings.cs:61)。
- `Settings.MultiWeekRotationOffset`:整型数组,默认 `[-1, -1, 0, 0, 0]`(Settings.cs:60);下标 = 周期长度,值 = 偏移周数;下标 0、1 固定 `-1` 占位。
计算函数必须逐字实现(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:619-635`):
```
totalElapsedWeeks = floor((date.Date - SingleWeekStartTime.Date).TotalDays / 7)
positions = [-1, -1]
for cycleLength in 2..MultiWeekRotationMaxCycle:
offset = MultiWeekRotationOffset[cycleLength] ?? 0
pos = (totalElapsedWeeks + offset) % cycleLength
if pos < 0: pos += cycleLength
positions[cycleLength] = pos + 1 // 1-based
```
注意返回值是 **2-first、1-based** 的数组(注释原文:`2-first, 1-based`,LessonsService.cs:614),`CheckClassPlan` 直接用 `positions[WeekCountDivTotal]` 与 `WeekCountDiv` 比较。
UI 侧「调整多周轮换偏移」必须反向求解偏移(对称逻辑,`repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:99-113`):
```
totalElapsedWeeks = floor((today.Date - SingleWeekStartTime).TotalDays / 7)
for cycleLength in 2..max:
cycleOffset = (用户选择的 0-based 位置 - totalElapsedWeeks) % cycleLength
if cycleOffset < 0: cycleOffset += cycleLength
MultiWeekRotationOffset[cycleLength] = cycleOffset
```
「清除」按钮把 `MultiWeekRotationOffset` 清空(数组为空),随后重新读取时按 `GetValueOrDefault → 0` 处理(同文件 115-119)。
轮换 UI 必须按此渲染(`repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:41-90`):从 2 到 `MultiWeekRotationMaxCycle` 每个周期生成一行;2 周时标题为 `"单双周:"`,其余为 `"{N}周轮换:"`(N 用中文数字);2 周时选项为 `["单周","双周"]`,否则为 `["1/N周","2/N周",...]`;控件 `MaxWidth=400`,行间距 `Margin=0,8,0,0`、`Spacing=4`。
课表编辑界面上的触发规则必须表现为两行(`repos/ClassIsland/ClassIsland/Controls/TimeRuleEditControl.axaml:15-52`):
1. 第一行文案 `"每 [周期] 的 [第几周]"`——只有 `WeekCountDiv>0` 时两个下拉框才可交互,否则整体淡化到 `Opacity=0.60`。
2. 第二行文案 `"当今天是:"` + 7 个 Chip 按钮(`UniformGrid Columns=7`),项为 `周日 周一 周二 周三 周四 周五 周六` 的顺序(与 `time.DayOfWeek` 的 0..6 对齐)。
### 6.5 时间表编辑(档案编辑窗口 · 时间表页)
窗口基础参数必须照抄:标题 `"档案编辑"`、`MinWidth=750`、`Width=1100`、`Height=600`、`EnableMicaWindow=True`(`repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml:23-30`)。左侧为主标签页,索引与内容固定为:0=课程表、1=时间表、2=科目、3=禁止事项(forbidden)、4=调课/日程调整、5=导入导出(`ProfileSettingsWindow.axaml.cs:246-261`)。右侧详情面板宽度 `332`(`ProfileSettingsWindow.axaml:53,202`)。
新增时间点的完整规则必须实现(`repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml.cs:983-1099`):
1. **基准时间** `baseSec`:若已选中时间点,上课/课间取「选中项 `EndTime`」,分割线/行动取「选中项 `StartTime`」;若未选中,默认 `08:00:00`(源码注释:按规定中学最早上课不得早于 8:00,见 1006-1009)。
2. **默认时长**:上课 = `Settings.DefaultOnClassTimePointMinutes`(默认 40 分钟);课间 = `Settings.DefaultBreakingTimePointMinutes`(默认 10 分钟);分割线与行动 = 0(Settings.cs:108-109,651,662)。
3. **插入位置**:遍历现有时间点,插入到第一个 `StartTime > 新项 StartTime` 的位置之前;没有则追加到末尾(989-998)。
4. **空间不足的处理**(1023-1078,必须逐条实现):
- 若下一个非分割线时间点的 `StartTime <= baseSec`:当选中项不是第 0 项 → 提示 `"没有合适的位置来插入新的时间点。"` 并放弃;当选中项是第 0 项 → 允许向前插:`baseSec = 选中项.StartTime - 默认时长`,并提示 `"已向前插入了新的时间点。"`。
- 若下一个时间点起点早于 `baseSec + 默认时长`:把时长截断为 `next.StartTime - baseSec`,提示 `"没有足够的空间完全插入该时间点,已缩短时间点长度。"`。
- 分割线:`baseSec = 选中项.EndTime`;若该时刻已存在分割线 → 警告 `"这里已经存在一条分割线。"` 并放弃。
- 行动:`baseSec = 选中项.EndTime`;若该时刻已存在行动 → 警告 `"这里已经存在一个行动。"` 并放弃。
5. 新项 `ActionSet`:仅 `TimeType==3` 时创建 `new ActionSet()`,其余为 `null`。
6. 插入后选中新项、滚动居中,并压入撤销栈。
必须实现撤销/重做栈(`ProfileSettingsWindow.axaml.cs:54-56,874-981`):`UndoEntry(IsAdd, Item, Layout, Index, Description)` 记录「新增/删除 + 项 + 所属时间表 + 索引 + 描述」,撤销/重做在 UI 上以历史列表呈现,支持「撤销到第 N 步」(`UndoToIndex` / `RedoToIndex`)。撤销新增 = 删除该项;撤销删除 = 在原索引插回。
删除时间点必须同步删除对应课程:`ClassPlan.RemoveTimePointSafe` 会先移除所有 `CurrentTimeLayoutItem == 该时间点` 的课程,再 `RefreshClassesList()`(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:515-522`)。
「应用到所有课表」功能:`Profile.OverwriteAllClassPlanSubject(timeLayoutId, timePoint, subjectId)`——遍历所有 `TimeLayoutId` 相同课表,`RefreshClassesList()` 后把命中该时间点的课程 `SubjectId` 全部改写(`Profile.cs:85-95`)。
时间表刻度尺与缩放:时间线列表必须支持缩放(源码有 `ButtonZoomIn/ButtonZoomOut`,`ProfileSettingsWindow.axaml.cs:1290-1311`),缩放范围与步长属实现细节【推断:源码未给出常量,建议 0.5x–3x、步长 0.1】。
### 6.6 课表编辑页与「换课 / 调课 / 临时层」
课表编辑页右侧面板(`ProfileSettingsWindow.axaml:53-140`)必须包含:
- `名称` 输入框。
- `时间表` 下拉框:`ItemsSource = TimeLayouts`,`SelectedValue = ClassPlan.TimeLayoutId`,项文本 = `Value.Name`,`ToolTip` = 时间表 GUID。
- `课表群` 下拉框:`SelectedValue = ClassPlan.AssociatedGroup`。
- `自动启用` 复选框(`IsChecked = IsEnabled`);当 `IsOverlay=true` 时该复选框禁用,并显示提示条 `"这是一个临时层课表,将在使用过后自动删除。"`。
- 三态说明文本(必须按 `IsEnabled`/`IsOverlay` 组合显示):`IsEnabled&&!IsOverlay` → `"将会根据触发规则自动启用该课表。"`;`!IsEnabled&&!IsOverlay` → `"该课表不会自动启用,只能通过临时课表或临时层手动启用该课表。"`;`IsOverlay` → `"临时层课表将忽略触发规则强制启用。"`(ProfileSettingsWindow.axaml:88-111)。
- `TimeRuleEditControl`(触发规则),仅当 `!IsOverlay && IsEnabled` 时可用(113-116)。
- `更多选项`:附加设置列表(`IAttachedSettingsHostService.ClassPlanSettingsAttachedSettingsControls`)。
**换课(修改单节课的科目)**:点击课程单元格右侧按钮弹出 Flyout(`ProfileSettingsWindow.axaml:415-446`),结构固定:
1. 标题 `"修改课程"`(Subtitle 样式,下边距 8)。
2. `换课成…` 下拉框:`ItemsSource = Subjects`,`SelectedValue = TargetSubjectIndex`(Guid)。
3. 复选框 `"永久换课"`:`IsChecked = !IsTempSwapMode`(注意取反绑定),受集控策略 `DisableProfileClassPlanEditing` 约束。
4. `确定` 按钮(Accent 样式):当 `TargetSubjectIndex == Guid.Empty` 时禁用。
5. 确认逻辑(`ProfileSettingsWindow.axaml.cs:1531-1550`):先 `GetTargetClassPlan(date, IsTempSwapMode, out guid)` 取得目标课表;写入 `Classes[index].SubjectId = TargetSubjectIndex`;若 `IsTempSwapMode` 为真,同时置 `Classes[index].IsChangedClass = true`;最后刷新日程。
**调课(两节课互换)** 必须实现两段式交互(`ProfileSettingsWindow.axaml.cs:1435-1477`):
1. 点「开始换课」记录起点 `ClassSwapStartPosition = (date, index)`,进入换课模式;若选中课程为空(`IsEmpty`)→ 提示 `"选择课程区域无效。"` 并结束。
2. 点「完成换课」记录终点,再用 `GetTargetClassPlan` 分别取起点/终点课表;任一为空或索引越界 → 直接返回(不报错、不改数据)。
3. 交换两个 `SubjectId`(元组解构赋值一次完成)。
4. 临时换课模式下,两侧都置 `IsChangedClass = true`。
5. 刷新日程视图。可随时「取消换课」退出模式。
**临时换课的目标课表解析** `GetTargetClassPlan(date, overlay, out guid)`(`ProfileSettingsWindow.axaml.cs:1479-1506`)必须按此实现:
1. `LessonsService.GetClassPlanByDate(date, out baseGuid)`;为空 → 返回 null。
2. 若 `!overlay` 或 `baseClassPlan.IsOverlay` → 直接返回当前课表。
3. 若该日期已有 `OrderedSchedules[date]` 且其课表是临时层 → 返回当前课表。
4. 否则调用 `CreateTempClassPlan(baseGuid, enableDateTime: date)` 创建「当天临时层」,返回新课表(失败返回 null)。
**创建临时层** `CreateTempClassPlan(id, timeLayoutId?, enableDateTime?)`(`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:235-280`):
1. 若该日期已有临时层预定 → 返回 `null`(不允许重复创建)。
2. 用 JSON 深拷贝源课表(必须经序列化/反序列化,保证引用隔离)。
3. 可选:同时深拷贝时间表,置 `IsOverlay=true`、`OverlaySourceId=源时间表ID`、`Name += "(临时层)"`,注册进 `TimeLayouts`,并把新课表的 `TimeLayoutId` 指向它。
4. 新课表:`IsOverlay=true`、`OverlaySourceId=源课表ID`、`Name += "(临时层)"`、`OverlaySetupTime=日期`。
5. `Profile.IsOverlayClassPlanEnabled = true`、`Profile.OverlayClassPlanId = 新ID`、写入 `ClassPlans`、`OrderedSchedules[date] = { ClassPlanId: 新ID }`。
**临时层与普通课表互转**:`ConvertToStdClassPlan(id)` 把 `IsOverlay=false`,若其时间表也是临时层则一并转正并去掉名称中的 `"(临时层)"` 后缀(`ProfileService.cs:344-360`)。
**过期清理** `CleanExpiredTempClassPlan()` 必须每 tick 调用(`ProfileService.cs:295-328`):
1. 删除所有 `OrderedSchedules` 中日期 < 今天的项。
2. 收集现存的 `OrderedSchedules[].ClassPlanId`,删除所有 `IsOverlay` 但未被引用的课表。
3. 汇总「未过期课表」引用的 `TimeLayoutId` 集合,删除所有 `IsOverlay` 但未被引用的时间表。
**「加载临时课表…」右键菜单**:主界面右键菜单项 `"加载临时课表…"`(`repos/ClassIsland/ClassIsland/MainWindow.axaml:101-103`)打开选择器,写入 `TempClassPlanId` 与 `TempClassPlanSetupTime`;次日自动失效(见 6.3 步骤 1)。
**临时课表群** `SetupTempClassPlanGroup(key, expireTime?)`(`ProfileService.cs:362-390`)必须实现到期日推算:
```
today = now; dow = today.DayOfWeek
dw = floor((today.Date - SingleWeekStartTime.Date).TotalDays / 7) + 1
dayOffset = 0
for classPlan in 该群所有课表:
w = dw % WeekCountDivTotal
baseOffset = WeekDay - dow
divOffset = (WeekCountDiv + WeekCountDivTotal - w) % WeekCountDivTotal
finalOffset = baseOffset + divOffset*7
if finalOffset < 0: finalOffset += 7
dayOffset = max(dayOffset, finalOffset)
TempClassPlanGroupExpireTime = expireTime ?? today.Date + dayOffset 天
TempClassPlanGroupId = key; IsTempClassPlanGroupEnabled = true
```
课表群管理面板必须提供:临时课表群名称与「清除」按钮、启用 ToggleSwitch、`失效日期` CalendarDatePicker(绑定 `TempClassPlanGroupExpireTime`)、`覆盖模式` 下拉(选项顺序 `覆盖`、`继承`,对应 0=Override、1=Inherit)、`新建课表群` 按钮(受集控策略禁用)、课表群列表(ProfileSettingsWindow.axaml:342-413)。
### 6.7 档案管理(多档案)
`Profiles/` 下每个 `*.json` 即一个档案。档案列表必须只显示 `.json` 文件(`ProfileSettingsWindow.axaml.cs:297-303`)。管理操作与约束:
- **切换档案**:写入 `Settings.SelectedProfile = 文件名`,**必须重启应用生效**(界面文案 `"切换档案需要重新启动应用"`,ProfileSettingsWindow.axaml:143)。
- **新建档案**:输入名称 → 目标路径 `Profiles/{名称}.json`;已存在则中止;创建后写入默认档案(含 `Assets/default-subjects.json` 内置默认科目)。
- **重命名 / 复制 / 删除**:删除前必须二次确认;删除当前档案要阻止或提示【推断:源码使用通用确认对话框,未特判当前档案】。
- **保存**:档案属性变更即触发保存(`Profile.PropertyChanged += SaveProfile`,ProfileService.cs:164);关闭档案窗口时再保存一次(`ProfileSettingsWindow.axaml.cs:264-274`,`e.Cancel = true` 后 `Hide()`,即窗口只隐藏不销毁)。
- **信任档案**:档案 ID 写入 `Settings.TrustedProfileIds` 后才允许自动化「时间点行动」等敏感行为(`ProfileService.cs:166-176,417-422`;`LessonsService` 之外,`AutomationService.cs:91` 首先检查 `IsCurrentProfileTrusted`)。首次加载未信任档案必须显示「信任」入口。
- **集控模式**:档案文件名固定为 `_management-profile.json`;依次拉取课表、时间表、科目三份档案并按版本号合并进本地档案,合并后深拷贝并 `RefreshTimeLayouts()`(`ProfileService.cs:78-137,160-163`)。
`Profile.RefreshTimeLayouts()` 必须每次把档案级字典注入到每张课表(`TimeLayouts`、`ClassPlans` 引用),否则课表拿不到时间表(`Profile.cs:272-279`,`LessonsService.cs:551` 每 tick 调用一次)。Electron 实现中把它做成「档案 store 的 hydrate 步骤」,在加载与任何课表增删后执行。
### 6.8 科目管理
科目页(主标签 2)必须提供:新增、复制、删除科目;每项字段 `名称`、`简称`(Initial)、`教师名`(TeacherName)、`是否户外课程`(IsOutDoor)。
必须实现的行为:
- 新增科目时默认名 `""`;用户输入名称后自动补 `Initial`(见 6.1.6)。
- `开启「编辑后自动跳到下一节」`:`Settings.IsProfileEditorClassInfoSubjectAutoMoveNextEnabled`,默认 `true`(`Settings.cs:181`)。
- 删除科目时必须把引用它的课程 `SubjectId` 置空或提示,不得留下悬空 GUID【推断:源码删除逻辑直接移除科目条目,课程引用会退化为 `Guid.Empty`,显示为「???」;实现时建议删除前提示引用数量】。
- 科目选择器在课表表格中以「科目简称 + 科目名」呈现【推断:`ScheduleDataGrid` 内部实现,未在本次读取范围内给出确切模板】。
### 6.9 导入 / 导出
当前版本**实际可用**的导入导出入口在档案窗口的「导入导出」页(主标签 5),按类型分组渲染:导入组、导出组,两组之间加分隔线,每个提供方一个 `NavigationViewItem`(`ProfileSettingsWindow.axaml.cs:1565-1627`)。注册表(`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:292-296`)为:
| ID | 名称 | 类型 |
|---|---|---|
| `classisland.profileTransfer.import.cses` | 从 CSES 导入 | Import |
| `classisland.profileTransfer.import.legacyV1` | 从 ClassIsland 1.x 导入 | Import |
| `classisland.profileTransfer.import.classWidgets` | 从 Class Widgets 1.2 导入 | Import |
| `classisland.profileTransfer.export.cses` | 导出到 CSES | Export |
**本实现必须在该页额外提供「Excel 导入」「Excel 导出」两项(P1,见 6.9.1)**:原版这两项受实验开关 `Settings.ExpIsExcelImportEnabled`(默认 `false`)控制且实现已废弃,本实现必须默认可见(或以独立向导入口提供),同时保留该设置字段做配置兼容;不得删除 CSES / 1.x / Class Widgets 三项 P0 入口。
导入提供方统一交互:选择源文件 → 选择「合并到当前档案」或「导入为新档案」→ 执行;导入为新档案时若目标 `Profiles/{名称}.json` 已存在必须报错中止(`CsesImportProvider.cs:51-59`);成功 `Toast("导入成功。")`,失败记录日志并 `Toast("无法导入 CSES 课表", exception)`。CSES 文件类型过滤为 `*.yml`、`*.yaml`,头部文案 `"CESE 文件路径"`(`CsesImportProvider.cs:30-40`,注意源码拼写为 `CESE`,实现时保持可配置以便修正)。
**CSES → ClassIsland 转换算法(P0,必须实现)**(`repos/ClassIsland/ClassIsland.Shared/Extensions/CsesExtensions.cs:23-119`,必须逐条实现):
1. 目标档案 = 合并档案或新建档案;建立「科目名 → 科目 Guid」缓存,先用目标档案已有科目名填充。
2. 遍历 CSES `Subjects`:名字已存在则复用 Guid,否则新建 `Subject`;写入 `Name`;`SimplifiedName` 非空才覆盖 `Initial`;`Teacher` 非空才覆盖 `TeacherName`。
3. 遍历 CSES `Schedules`:找一张「上课时间点数量与 `i.Classes.Count` 相等,且每个上课时间点的 `StartTime/EndTime` 全部与 CSES 一致」的时间表;找不到则新建时间表。
4. 为每节课建立 `ClassInfo { SubjectId }`;若是新时间表,则同时生成时间点:每节课一个 `TimeType=0`(起止取 CSES 的起止),相邻两节之间补一个 `TimeType=1`(前一节结束 → 后一节开始)。
5. 课表:`TimeLayoutId` 指向该时间表;`TimeRule.WeekCountDiv = i.Weeks`;`TimeRule.WeekDay = (int)i.EnableDay`;`Name = i.Name`;新 Guid 注册进 `ClassPlans`。
**ClassIsland → CSES 导出算法(P0,必须实现)**(CsesExtensions.cs:127-166):
1. 把目标档案中不存在(按名称比对)的科目追加进 CSES 科目,写 `Name/SimplifiedName/Teacher`。
2. `Profile.RefreshTimeLayouts()` 后,**只导出** `TimeLayout != null && IsEnabled && WeekCountDivTotal <= 2 && WeekCountDiv <= 2` 的课表(即单双周以内且已启用的课表)。
3. 每张课表导出为一个 `Schedule { Name, Weeks = (WeekType)WeekCountDiv, EnableDay = (DayOfWeek)WeekDay }`,并按 `Classes` 顺序填 `ClassInfo { StartTime, EndTime, Subject }`,`Subject` 取科目名,找不到时写空串。
#### 6.9.1 Excel 导入 / 导出(**P1:必须实现**,用 `xlsx` 重建)
**定位与来源边界**:ClassIsland 2.1 源码中两个 Excel 窗口整体被 `#if false` 屏蔽(`repos/ClassIsland/ClassIsland/Views/ExcelImportWindow.xaml.cs:1`、`repos/ClassIsland/ClassIsland/Views/ExcelExportWindow.xaml.cs:1,539`),DI 注册被注释(`App.Services.xaml.cs:145,204`),且仓库内已不存在 `ExcelImportViewModel` / `ExcelExportViewModel` 类型(全仓检索无命中,属不可编译的遗留骨架)。因此:
- **本实现必须独立重建该功能(P1,不是可选项)**:在主进程使用 **`xlsx`(SheetJS)** 读写 `.xlsx`,用 `fs` 存盘、`shell.openPath` 打开文件;**不得**引入 `unvell.ReoGrid`、`OfficeOpenXml`、WPF 控件或依赖 ClassIsland 的窗口实现。
- 源码里的 `ExcelImportWindow.xaml.cs` / `ExcelExportWindow.xaml.cs` **只作为布局与交互参考**(步骤机、拖放、单元格选择、导出参数),不得整段移植。
- **导入/导出向导的 UI(步骤、表单、预览面板)归 Part 11**(windows 负责);本节只规定**数据契约、列格式、映射算法、往返一致性与错误校验**,Part 11 的向导必须调用本节定义的契约层(建议单独 `excel-io.ts` 模块,纯函数、可单测)。
**文件与工作表约定**:
1. 文件扩展名 `.xlsx`;默认工作表 `课表`(第 1 个 sheet)。
2. 可选第 2 个 sheet `时间表`,用于承载时间点(保证往返可还原时间表,见 6.9.1.5)。
3. 兼容「单 sheet 模式」:只有 `课表` 时,导入必须要求用户在向导里另行选择/导入时间表(对应源码 `ImportTimeLayoutOnly` 流程,`ExcelImportWindow.xaml.cs:388-397`)。
**课表矩阵列格式(必须按此实现与校验)**:
| 位置 | 内容 |
|---|---|
| `A1` | 空或标题(导入忽略) |
| `B1..H1` | 星期名,**顺序固定 星期日、星期一 … 星期六**(对应 `(int)DayOfWeek` 0..6);**只写存在课表的星期**(源码 `GetWeekDayName` 与 `cellData[7]` 按 0..6 输出,`ExcelExportWindow.xaml.cs:340-350,369-435`) |
| `A2..A(n+1)` | 节次序号 `1..n`,**文本格式**(源码 `Cells[pos].Data = i.ToString(); DataFormat = Text`,437-442) |
| `B2..` 数据格 | 科目名;若启用教师名则在同格内用 `\n` 追加教师名(403-408) |
| 同一格多课表 | 用 `/` 连接科目名(403-408);教师名同样 `/` 连接 |
样式:`A` 列宽 32,数据列宽 = 导出参数 `ColWidth`;行高 = `RowHeight`;`TextWrap = BreakAll`;对齐 `HorAlign/VerAlign`;前景/背景色;边框 0=无、1=细实线、2=粗实线(`ExcelExportWindow.xaml.cs:446-472,456-458`)。
**`时间表` sheet 列格式**:A 列从 `A1` 起每行一个时间范围文本,支持 `HH:mm-HH:mm` 与 `HH:mm:ss-HH:mm:ss`;导入解析规则必须照抄:**提取文本中连续数字,个数必须为 4 或 6,否则该行跳过并记录警告**(`ExcelImportWindow.xaml.cs:428-450`);解析成功生成 `TimeType=0` 的上课时间点,随后在相邻两节之间自动补 `TimeType=1` 的课间(仅当 `下一节.StartTime - 上一节.EndTime > 0`,505-522)。
**导入映射规则**:
1. **科目匹配**:数据格文本 `text.Contains(subject.Name)` 即视为命中(源码 627-632);多个科目名同时命中取档案中第一个;`/` 分隔的多科目必须按段分别匹配。
2. **星期 → 课表**:每个星期列生成一张 `ClassPlan`,`TimeRule.WeekDay` = 该列对应 0..6,`TimeLayoutId` = 本次导入的时间表。
3. **节次 ↔ 时间点对齐**:课表行数必须 ≤ 时间表「上课时间点」数量;超出 → 报错并中止(不允许静默截断)。
4. **单双周**:若列中存在 `/` 合并(多课表同列),必须拆成多张课表并写入各自的 `WeekCountDiv/WeekCountDivTotal`;列首若带周次标记(见 6.9.1.5 新增约定),按标记写入。
5. **完成导入**:每张课表分配新 Guid;导入过程中被临时激活的时间表必须复位 `IsActivated=false`、`IsActivatedManually=false`(源码 `CompleteImport`,695-706)。
6. **只导时间表模式**:`ImportTimeLayoutOnly=true` 时只写入时间表,不创建课表(388-397)。
**往返一致性契约(必须显式实现并写进向导文案)**:
- 可往返:单张单周课表 + 科目名 + 教师名开关一致时,科目矩阵逐格可还原。
- **不可往返、必须补齐的项**:
1. 原版导出**完全不写时间点**(只写节次序号,437-442),因此原版导出文件无法还原时间表。本实现必须写 `时间表` sheet(或把 A 列改为 `HH:mm-HH:mm` 文本),否则导入必须强制让用户另选时间表。
2. 原版导出把同一星期的多张课表用 `/` 合并到同一格(403-408),**丢失单双周/多周信息**。本实现必须增加「周次」行(如 `单周`/`双周`/`2/4周`)或拆分为多行块,确保 `WeekCountDiv` 与 `WeekCountDivTotal` 可往返。
3. `Initial` 不导出 → 往返后由 `Name` 首字自动重算(见 6.1.6);`IsOutDoor` 不导出 → 往返后丢失,向导必须提示。
- 验收等价性:导出 → 导入 → 再导出,两次导出文件的**单元格文本矩阵必须逐格相等**(颜色/列宽/边框不要求)。
**导入前全量预检(任一硬错误不得写入档案)**:
1. 非 `.xlsx` / 缺少 `课表` sheet / 表头缺失 → 结构化错误并中止。
2. 时间范围数字个数 ≠ 4/6、时 >23、分/秒 >59、`Start >= End` → 逐行错误。
3. 节次序号不连续或重复 → 警告,允许继续但必须二次确认。
4. 列首不是合法星期名 → 该列跳过并在报告中列出。
5. 科目名未在档案中 → 逐格报告,提供「新建科目 / 置空 / 忽略该格」三选一。
6. 解析结果为空(无任何课表或时间点)→ 拒绝并提示。
7. 文件被占用 / 损坏 / 密码保护 → 捕获并以 dialog/toast 展示,不得崩溃。
**导出参数与默认值**:导出起点单元格由向导选择(对应源码 `ClassPlanStartPos`);参数含 `RowHeight`、`ColWidth`、`IsTeacherNameEnabled`、`HorAlign`、`VerAlign`、`ForegroundColor`、`BackgroundColor`、`BorderMode`;导出后支持「保存文件」与「保存并用 Excel 打开」(源码 `SaveFile`/`ButtonSaveAndOpenInExcel`,486-516)。由于 ViewModel 已缺失,以下默认值标【推断】:`RowHeight=24`、`ColWidth=80`、`IsTeacherNameEnabled=false`、`HorAlign=Center`、`VerAlign=Center`、前景 `#FF000000`、背景 `#FFFFFFFF`、`BorderMode=1`。以上默认值必须在向导中可见、可改,并持久化到 `Settings`(新增 `ExcelExportSettings` 分组,默认值即上表)。
### 6.10 附加设置(AttachedObjects)模型
附加设置**不是 1.7 遗留**,而是 2.x 仍在使用的机制:基类字段定义在 `repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:15`,2.x 的 `AttachedSettingsControlPresenter` 仍在读写它(`repos/ClassIsland/ClassIsland.Core/Controls/AttachedSettingsControlPresenter.axaml.cs:241` 读取、`:361` 回写)。实现时必须完整支持。
#### 6.10.1 定义与承载对象
`AttachableSettingsObject` 提供:
```json
"AttachedObjects": {
"<设置 GUID>": { /* 该附加设置的设置对象 */ }
}
```
- 属性名固定为 **`AttachedObjects`**(C# 属性名,System.Text.Json 默认序列化;键为 GUID 字符串,值是多态对象)(`AttachableSettingsObject.cs:15`)。
- 三个 API 必须等价实现(`AttachableSettingsObject.cs:23-61`):
- `GetAttachedObject<T>(id)`:取不到返回 `null`;若存的是 `JsonElement` 则反序列化为 `T`。
- `GetAttachedObject<T>(id, defaultValue)`:取到则**回写**并返回;取不到则**写入 default 并返回 default**(注意这会产生副作用,规范要求「读」不应改变档案,实现时请把该重载改成纯读,或在调用处显式接受副作用——以纯读为推荐,并在代码注释标注与源码差异)。
- `WriteAttachedObject<T>(id, o)`:直接写入。
- 必须继承 `AttachableSettingsObject` 的模型:`ClassPlan`(`ClassPlan.cs:13`)、`ClassInfo`(`ClassInfo.cs:10`)、`TimeLayout`(`TimeLayout.cs:13`)、`TimeLayoutItem`(`TimeLayoutItem.cs:11`)、`Subject`(`Subject.cs:8`)。
- **`Profile` 本身不继承该基类**(`Profile.cs:14` 直接继承 `ObservableRecipient`),因此**不得**把附加设置挂在档案根上。
#### 6.10.2 注册表与 GUID(必须硬编码,与提供方/渠道共用 GUID 空间)
附加设置控件用两个特性声明:`[AttachedSettingsUsage(AttachedSettingsTargets targets)]` 与 `[AttachedSettingsControlInfo(guid, name, iconGlyph, hasEnabledState=true)]`(`ClassIsland.Core/Attributes/AttachedSettingsUsage.cs:9-15`、`AttachedSettingsControlInfo.cs:10-44`)。`AttachedSettingsTargets` 是 `[Flags]`(`ClassIsland.Core/Enums/AttachedSettingsTargets.cs:6-33`):`None=0`、`Lesson=1`、`Subject=2`、`TimePoint=4`、`ClassPlan=8`、`TimeLayout=16`。
内置 4 个附加设置(注册于 `App.Services.xaml.cs:234-238`):
| GUID | 名称 | 图标 | 可附加目标 | 有启用开关 |
|---|---|---|---|---|
| `08F0D9C3-C770-4093-A3D0-02F3D90C24BC` | 上课提醒设置 | (空/默认) | ClassPlan \| TimeLayout \| Lesson \| Subject \| TimePoint | 是 |
| `58e5b69a-764a-472b-bcf7-003b6a8c7fdf` | 课程显示设置 | `\ue479` | ClassPlan \| TimeLayout \| Lesson \| Subject \| TimePoint | 是 |
| `8FBC3A26-6D20-44DD-B895-B9411E3DDC51` | 放学提醒设置 | `\ued34` | ClassPlan \| TimeLayout | 是 |
| `7625DE96-38AA-4B71-B478-3F156DD9458D` | 天气提醒设置 | `\ue4dc` | 仅 TimePoint | **否**(`hasEnabledState=false`) |
证据:`Controls/AttachedSettingsControls/ClassNotificationAttachedSettingsControl.axaml.cs:12-16`、`LessonControlAttachedSettingsControl.axaml.cs:11-15`、`AfterSchoolNotificationAttachedSettingsControl.axaml.cs:11-13`、`WeatherNotificationAttachedSettingsControl.axaml.cs:11-13`。
设置模型与默认值(`Models/AttachedSettings/*.cs`):
- `ClassNotificationAttachedSettings`:`IsAttachSettingsEnabled=false`,`IsClassOnNotificationEnabled=true`、`IsClassOnPreparingNotificationEnabled=true`、`IsClassOffNotificationEnabled=true`、`ClassPreparingDeltaTime=60`、`ClassOnPreparingText="准备上课,请回到座位并保持安静,做好上课准备。"`、`OutdoorClassOnPreparingText="下节课程为户外课程,请合理规划时间,做好上课准备。"`、`ClassOnPreparingMaskText="即将上课"`、`ClassOnMaskText="上课"`、`ClassOffMaskText="课间休息"`、`ClassOffOverlayText=""`(153 行文件,见 7.4.1)。
- `LessonControlAttachedSettings`:`IsAttachSettingsEnabled=false`、`ShowExtraInfoOnTimePoint=true`、`ExtraInfoType=0`、`IsCountdownEnabled=true`、`CountdownSeconds=60`、`ExtraInfo4ShowSecondsSeconds=0`、`ScheduleSpacing=1`、`ShowCurrentLessonOnlyOnClass=false`、`IsNonExactCountdownEnabled=false`(`ClassIsland.Core/Models/AttachedSettings/LessonControlAttachedSettings.cs:9-17`)。
- `AfterSchoolNotificationAttachedSettings`:`IsAttachSettingsEnabled=false`、`IsEnabled=true`、`NotificationMsg="今天的课程已结束,请同学们有序离开。"`(`Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:11-13`)。
- `WeatherNotificationAttachedSettings`:`IsAttachSettingsEnabled=**true**`、`AlertShowMode=Default`、`ForecastShowMode=Default`(`Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:11-13`)。
#### 6.10.3 读取优先级(必须逐层实现)
`GetAttachedSettingsByPriority<T>(id, subject, timeLayoutItem, classPlan, timeLayout)` 固定按 **Subject → TimeLayoutItem → ClassPlan → TimeLayout** 顺序逐层查找,返回**第一个 `IsAttachSettingsEnabled == true`** 的对象;全部未命中返回 `null`(`repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IAttachedSettingsHostService.cs:24-43`;官方文档 `repos/ClassIsland/doc/AttachedSettings.md:50-57` 的 mermaid 顺序同为 Subject → TimeLayoutItem → ClassPlan → TimeLayout)。
- 调用方传参顺序必须与上表一致:`GetAttachedSettingsByPriority(ProviderGuid, CurrentSubject, CurrentTimeLayoutItem, CurrentClassPlan, CurrentClassPlan?.TimeLayout)`(上课/下课/准备上课,见 7.4.1);天气按「当前时间点」查找(`WeatherService` 侧见 7.4.3)。
- `IsAttachSettingsEnabled` 的判定要兼容「JSON 里存的是 `JsonElement`」:`IAttachedSettings.GetIsEnabled(obj)` 在对象为 `JsonElement` 时读 `IsAttachSettingsEnabled` 属性是否为 `true`,为 `JsonElement` 但缺失该属性 → false(`ClassIsland.Shared/Interfaces/IAttachedSettings.cs:20-38`)。
- 不得实现「就近优先」以外的隐式规则(如按课程时长/科目类型自行加权)。
#### 6.10.4 写入与序列化纪律
- 编辑控件回写规则(必须照做,`AttachedSettingsControlPresenter.axaml.cs:354-362`):**仅当** `IsAttachSettingsEnabled == true` **或** 控件 `HasEnabledState == false` 时,才把设置对象写回 `TargetObject.AttachedObjects[ControlInfo.Guid]`;否则**不回写**(源码注释:以降低档案文件体积)。因此「关闭附加设置」在档案里表现为该 GUID 不存在,而不是存一个 `IsAttachSettingsEnabled=false` 的对象。
- 读取失败(类型不匹配/JSON 损坏)→ 丢弃该 GUID 的项、记日志、继续加载其余项,不得让整个档案加载失败【推断:源码 `GetAttachedObject` 直接 `Deserialize`,未捕获异常,实现必须补 try/catch】。
- 反序列化时必须按 GUID 在注册表中找到设置类型再反序列化;找不到类型时保留原始 JSON(插件可后续注册),不得丢弃。
- 附加设置 JSON 必须与课表/时间表/科目同文件存储(`Profiles/*.json`),**不得**放进 `Settings.json`,也不得用组件设置(`ComponentSettings`)替代。
#### 6.10.5 与 1.7 导入的兼容
`ClassIsland1ImportProvider` 导入 1.x 档案时必须迁移附加设置(`Controls/ProfileTransferProviders/ClassIsland1ImportProvider.cs:32-57` 调用 `ClassIslandV1ProfileTransferHelper`):
1. 1.x 的「附加设置」按对象分别存放;迁移时按**控件 GUID** 一一映射到 2.x 的 `AttachedObjects`(4 个内置 GUID 见 6.10.2)。
2. 未知 GUID 的附加设置必须原样保留(键与值都不丢),以便插件后续注册后仍可读取。
3. 迁移失败(JSON 结构不同)→ 记录警告并跳过该项,不得阻断整个导入【1.x JSON 的确切结构未在本次读取范围内确认,具体字段映射标【推断】,需补读 `ClassIslandV1ProfileTransferHelper` 后细化】。
#### 6.10.6 与档案分析 / 课表详细信息的关系
- 「课表详细信息」窗口的「附加设置结果」页签依赖 `ProfileAnalyzeService` 对象图 + 本节优先级链,展示某节课实际会用到哪些附加设置(见 6.11)。
- 节点状态用 `AttachedSettingsControlState`:`Disabled=0`、`Enabled=1`、`Override=2`(`ClassIsland.Core/Enums/AttachedSettingsControlState.cs:3-8`),由「在当前对象上是否存在该 GUID 且启用」推导:不存在 → Disabled;存在且启用但被更近层覆盖 → Override;最终生效 → Enabled【推导逻辑为【推断】,源码在 `ClassPlanDetailsWindow.axaml.cs` 中计算,未细读】。
- `ClassPlanDetailsWindow` 的节点状态条颜色:`Enabled` = `AccentFillColorDefaultBrush`、`Override` = `SystemFillColorCautionBrush`,列表项高度 36、圆角 4(`Views/ClassPlanDetailsWindow.axaml:108-166`)。
#### 6.10.7 边界
1. 同一 GUID 在多层存在但都未启用 → 等价于不存在。
2. `TimePoint` 级别的附加设置只有在其时间点被课程实际引用时才可能生效(未引用节点在档案分析里会提示「当前节点未被任何一个课表使用」,`AttachedSettingsControlPresenter.axaml.cs:209-213`)。
3. 删除科目/时间点/课表时,其 `AttachedObjects` 随之删除,不得残留在档案里(对象被整体移除即满足)。
4. 天气提醒附加设置 `hasEnabledState=false`:UI 不得显示启用开关;写回不受 `IsAttachSettingsEnabled` 限制(默认 true 仅为兼容)。
### 6.11 档案分析(可视化辅助)
必须实现档案对象图分析,用于「课表详细信息」窗口判定附加设置的生效顺序(`repos/ClassIsland/ClassIsland/Services/ProfileAnalyzeService.cs`):
- 节点类型:`ClassPlan`、`TimeLayout`(Target=TimeLayout)、`TimePoint`、`Lesson`(ClassInfo)、`Subject`(28-133)。
- 地址:`{ Guid, Index }`;时间点 Index 为其在 `Layouts` 中的下标,课程 Index 为其在 `Classes` 中的下标(`repos/ClassIsland/ClassIsland.Core/Models/ProfileAnalyzing/AttachableObjectAddress.cs`)。
- 边:课表 ↔ 时间表、时间表 ↔ 时间点、课表 ↔ 时间点、课表 ↔ 课程、时间点 ↔ 课程、课程 ↔ 科目;同时维护 `PreviousNodes` 与 `NextNodes`(双向),并为每类对象维护 `RelatedLessons`。
- 查询:`FindNextObjects(address, id)` 向下遍历、结果按 `Target` **降序**排序;`FindPreviousObjects` 向上遍历、按 `Target` **升序**排序;两者都可要求「附加设置已启用」。遍历时遇到 `Subject` 节点停止继续向上/向下扩散(`Walk` 中 `if (node.Target == Subject && !isInit) return;`,158-177)。
- 调试:支持导出 Mermaid `graph LR` 文本(139-156)。
「课表详细信息」窗口(`repos/ClassIsland/ClassIsland/Views/ClassPlanDetailsWindow.axaml:14-22`)参数:标题 `"课表详细信息"`、`Height=500`、`Width=800`;左列 `1*` 且 `MinWidth=300 MaxWidth=500`,中间分隔条宽 5,右列 `2*`。左侧表格列固定为「时间」(`StartTime-EndTime`)与「科目」;右侧为「附加设置结果」页签,先给出说明 `"以下的附加设置将按从上到下的顺序使用。"` 再列出节点,节点左侧 4px 状态条:`Enabled` 用 `AccentFillColorDefaultBrush`,`Override` 用 `SystemFillColorCautionBrush`,列表项高度 36、圆角 4。
### 6.12 边界与异常(必须处理)
1. **时间点重叠 / 逆序**:`StartTime > EndTime` 必须拒绝(源码 `EnsureTime` 抛 `ArgumentException("时间点起始时间无效。")`,`TimeLayoutItem.cs:95-101`),UI 层必须先校验再写模型。
2. **跨天**:时间点只在同一天内比较,不支持跨 0 点;若用户排出 `23:50-00:10` 必须提示非法。
3. **空课表**:`CurrentClassPlan == null`、`TimeLayout == null` 时所有派生状态必须安全降级,不得空引用。
4. **课表引用的时间表被删**:必须回退为 `TimeLayoutId = Guid.Empty` 并显示「未选择时间表」占位;不得崩溃。
5. **本周无匹配课表**:`CurrentClassPlan=null`,组件显示空态,`OnClass`/`OnBreakingTime`/`OnAfterSchool` 均不触发。
6. **时间表被多个课表共享**:修改时间表必须联动所有共享课表(`ClassPlan.TimeLayoutOnLayoutObjectChanged` 会 `RefreshClassesList`),且「应用到所有课表」的科目覆写只影响同一 `TimeLayoutId` 的课表。
7. **临时课表/临时层与预定课表冲突**:优先级固定为 预定课表 > 临时课表 > 常规课表;临时层覆盖临时课表的提示文案必须出现(`"临时课表被临时层课表覆盖。要使用临时课表,请暂时禁用或清除临时层课表。"`,ProfileSettingsWindow.axaml:283-292)。
8. **档案 JSON 损坏**:加载失败必须回退为空档案并保留原文件为 `.bak`,同时在设置页提示【推断:`ConfigureFileHelper.LoadConfig` 的容错细节未在本次读取范围内给出确切行为,实现时必须显式兜底】。
9. **并发写入**:同一档案的自动保存必须串行化(建议 300ms 防抖 + 单写队列),避免 Electron 主进程多窗口同时写导致截断。
### 6.13 验收标准
1. 新建档案后 `Profiles/*.json` 中同时存在默认课表群与全局课表群,GUID 与本节一致;删除/解散这两个群会被拒绝。
2. 构造「周一/周三/周五 + 单周 + 不轮换」「周二 + 2 周轮换第 2 周」两组课表,把系统时间改到对应日期与周次,`当前课表` 必须正好切换;把 `MultiWeekRotationMaxCycle` 改成 2 后,`WeekCountDivTotal=4` 的课表必须**永不启用**。
3. 时间表新增一节 40 分钟上课 + 10 分钟课间,`Classes` 自动补齐数量且顺序与上课时间点一致;删除时间点后对应课程同步消失,撤销后恢复原位。
4. 在 08:00 之前插入上课时间点会触发「向前插入」提示;插入分割线/行动到已存在时刻会被拒绝并给出对应警告文案。
5. 课表中「永久换课」只在当前课表写入 `SubjectId`;「临时换课」必须新建当天临时层课表(`IsOverlay=true`,名称带 `(临时层)`)并置 `IsChangedClass=true`,次日自动被清理。
6. 「换课」两段式交换后两张课表的两个 `SubjectId` 互换;起点/终点任一为空时不发生任何数据变更。
7. 关闭档案窗口再打开,所有编辑都已落盘;重启应用后 `SelectedProfile` 生效。
8. 从 CSES 导入后:科目不重复(同名复用)、时间表按上课起止自动匹配或新建、单双周与星期正确写入;导出到 CSES 时只包含已启用且单双周内的课表。
9. 课表详细信息窗口列出的附加设置顺序与生效顺序一致,状态条颜色符合 `Enabled`/`Override` 规则。
10. 全程不得出现「点击后无提示、数据半更新」的情况;所有校验失败都必须在 UI 上给出明确中文提示。
11. Excel 往返:导出 → 导入 → 再导出后,两次导出文件的**单元格文本矩阵逐格相等**;时间表可通过 `时间表` sheet(或 A 列 `HH:mm-HH:mm` 文本)完整还原;同一星期的单双周课表往返后 `WeekCountDiv/WeekCountDivTotal` 不丢。
12. Excel 预检:时间格式错误、起止逆序、列首非法星期、科目名缺失、解析结果为空这五类情况必须被拦截并给出可定位(行/列)的报告,且**档案文件未被写入**。
13. 附加设置优先级:在 Subject、TimePoint、ClassPlan、TimeLayout 四层分别写入同一 GUID 并启用,读取结果必须严格按 `Subject → TimeLayoutItem → ClassPlan → TimeLayout` 返回第一个启用项;未启用的层不得在档案里留下该 GUID 键。
14. 附加设置序列化:JSON 中键名为 `AttachedObjects`、键为 GUID 字符串;加载时未知 GUID 原样保留、损坏项被跳过且不影响整个档案加载;`Profile` 根上不得出现该字段。
+527
View File
@@ -0,0 +1,527 @@
## 7. 提醒系统(队列、提供者、气泡、强提醒、音效与 TTS)
本节规定「提醒(Notification)」全链路:请求模型 → 提供者注册 → 入队与优先级 → 主界面气泡播放(遮罩 + 正文)→ 全屏强调特效 → 音效与语音 → 取消与清理。实现必须让「提醒」成为一个**可被自动化行动、天气服务、上下课事件、集控消息共同复用**的统一管线,不得各写一套弹窗。
术语固定:**遮罩**(mask,气泡第一阶段,短文案)、**正文**(overlay,气泡第二阶段,详细内容)、**强调特效**(effect,全屏涟漪)、**置顶锁**(topmost lock,临时强制主界面置顶)。
### 7.1 核心数据结构
#### 7.1.1 提醒请求 NotificationRequest
必须实现以下字段(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:14-200`):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `MaskContent` | NotificationContent | 必填 | 遮罩内容(`[Required]`);无正文时只播它 |
| `OverlayContent` | NotificationContent? | `null` | 正文内容;为 null 表示不显示正文 |
| `RequestNotificationSettings` | NotificationSettings | `new()` | 本次提醒的专用覆盖设置 |
| `ChannelId` | Guid | `Guid.Empty` | 发送渠道 ID |
| `State` | `None/Queued/Playing/Paused/Cancelled/Completed` | `None` | 播放状态机,见 7.6 |
| `LeftProgress` | double | `1.0` | 剩余进度 0~1,驱动气泡底部进度条 |
| `MaskSession` / `OverlaySession` | NotificationPlayingSessionInfo | — | 分段计时会话(支持暂停/续播) |
| `CancellationToken` / `CompletedToken` | CancellationToken | — | 取消 / 完成令牌 |
| `IsPriorityOverride` | bool | `false` | **旧字段**,仅用于兼容:读写时映射到 `Level`(见 7.2) |
| `Level` | `NotificationLevel`(Emergency/High/Normal/Low) | `Normal` | 通知优先级等级,排序第二关键字(本实现字段,见 7.2) |
| `NotificationSourceGuid` / `ProviderSettings` / `ChannelSettings` | — | — | 由主机在 Setup 阶段回填,运行期字段 |
`NotificationContent`(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationContent.cs:16-109`)必须包含:`Content`(自定义内容组件)、`ContentTemplate` / `ContentTemplateResourceKey`(模板,可用资源键在运行时反射解析,见 `MainWindowLine.cs:658-665`)、`IsSpeechEnabled`(默认 `true`)、`SpeechContent`(默认 `""`)、`Color`(涟漪颜色,默认 null → 取主题强调色)、`Duration`(默认 **5 秒**)、`EndTime`(DateTime?,非空时**取代** Duration 计算剩余时间)。`IsTimingInit` 为内部字段。
三个必须实现的模板工厂(NotificationContent.cs:147-214):
- `CreateTwoIconsMask(text, leftIcon="lucide(\ue0ff)", rightIcon="lucide(\ue224)", hasRightIcon=true, factory?)`:生成 `TwoIconsMaskTemplateData`,`SpeechContent = text`,模板键 = `TwoIconsMaskTemplateData.TemplateResourceKey`。
- `CreateSimpleTextContent(text, factory?)`:`SimpleTextTemplateData`,`SpeechContent = text`。
- `CreateRollingTextContent(text, duration=20s, repeatCount=2, factory?)`:`RollingTextTemplate`(滚动字幕),`SpeechContent = text`,`Duration = duration`。
#### 7.1.2 提醒设置 NotificationSettings
默认值必须与源码一致(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationSettings.cs:11-17`):
| 字段 | 默认 |
|---|---|
| `IsNotificationEnabled` | `true` |
| `IsSpeechEnabled` | `false` |
| `IsNotificationEffectEnabled` | `true` |
| `IsNotificationSoundEnabled` | `false` |
| `NotificationSoundPath` | `""` |
| `IsNotificationTopmostEnabled` | `false` |
| `IsSettingsEnabled` | `false`(**总开关**:为 false 时这一层设置整段不生效) |
全局设置(`repos/ClassIsland/ClassIsland/Models/Settings.cs`)必须提供:`IsNotificationEnabled=true`、`NotificationProvidersEnableStates` / `NotificationProvidersPriority`(string 数组)/ `NotificationProvidersSettings` / `NotificationProvidersNotifySettings` / `NotificationChannelsNotifySettings`(四张以 GUID 字符串为键的字典)、`AllowNotificationSpeech=false`、`AllowNotificationEffect=true`、`AllowNotificationSound=false`、`AllowNotificationTopmost=true`、`IsSpeechEnabled=true`、`SpeechVolume=1.0`、`SpeechSource=0`、`SelectedSpeechProvider="classisland.speech.edgeTts"`、`EdgeTtsVoiceName="zh-CN-XiaoxiaoNeural"`、`IsNotificationSoundEnabled=true`、`NotificationSoundPath=""`、`NotificationSoundVolume=1.0`、`IsNotificationEffectEnabled=true`、`IsNotificationTopmostEnabled=true`、`NotificationEffectRenderingScale=1.0`、`IsNotificationEffectRenderingScaleAutoSet=false`、`NotificationUseStandaloneEffectUiThread=true`、`AnimationLevel=1`、`ExcludedWeatherAlerts=[]`、`ClassPrepareNotifySeconds=60`。
证据:`Settings.cs:43,88-94,135-156,158,182,149-151,142-145,1617,784,363`。
### 7.2 队列、优先级与去重(最终确定语义)
提醒主机必须维护一个按**三级排序键**确定顺序的队列(有序数组或最小堆均可,但比较结果必须唯一且稳定):
```
排序键 = (
providerPriorityIndex, // 1) 提供方在 NotificationProvidersPriority 中的下标,升序;下标小 = 先播
levelRank, // 2) 通知优先级等级,升序:Emergency=0 < High=1 < Normal=2 < Low=3
enqueueSeq // 3) 入队序号,升序(FIFO,先入先播)
)
```
- **第一关键字**:提供方优先级,由设置页拖动排序写入 `NotificationProvidersPriority`(数组下标)。
- **第二关键字**:通知优先级 `Level`,类型 `NotificationLevel`,默认 `Normal`;等级映射(本实现约定):集控消息 `payload.IsEmergency=true` → `Emergency`;气象预警 → `High`;上下课/放学/行动提醒 → `Normal`;插件可自行指定。
- **第三关键字**:同提供方、同等级时严格 FIFO。
**与 ClassIsland 2.1 的差异(必须写进实现说明与变更记录)**:源码只有 `IsPriorityOverride`(bool) 与从未参与比较的 `PriorityOverride`(int)(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:153-155`),且 `NotificationPriority.CompareTo` 在 `IsPriorityOverride=true` 时返回 1(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationPriority.cs:47-50`),在 .NET 最小堆语义下反而**更晚**出队,与「紧急插队」直觉相反。**本实现按上面的排序键修正语义(Emergency 必须最先播放)**,并做字段兼容:读取旧数据时 `IsPriorityOverride==true` → `Level=Emergency`;写出时 `Level ∈ {Emergency, High}` → `IsPriorityOverride=true`。不得再保留「与原版逐位一致」的备选实现。
入队去重(必须实现):
- 每次入队前检查 `EnqueuedRequests` 集合,**同一个 request 对象只允许入队一次**;重复调用直接返回(NotificationHostService.cs:178-183)。
- 出队时用 `PoppedRequests` 记录已弹出的请求,避免「链式提醒的头节点」被弹出两次(369-399)。
- 正文/遮罩播放完成、取消都会把 request 从 `PlayingNotifications`、`PoppedRequests`、`EnqueuedRequests` 中剔除(116-126, 488-522)。
链接提醒(必须实现):`ShowChainedNotifications(requests[])` 把多个请求串成一条链(NotificationHostService.cs:205-254):
1. 数组为空直接返回;全局提醒关闭则把每个 request 的 `CompletedTokenSource` 取消后返回。
2. 用 `CancellationTokenSource.CreateLinkedTokenSource` 生成 `RootCancellationTokenSource`(取消令牌)与 `RootCompletedTokenSource`(完成令牌),任一子请求取消即取消全部。
3. 为每个 request 建立 `ChainedHeadRequest = requests[0]`、`ChainedNextRequest = 下一个`、`RootCancellationTokenSource`、`RootCompletedTokenSource`,逐个 `SetupNotificationRequest` 并注册完成回调。
4. 优先尝试推送给消费者(见 7.5);无消费者时才逐个入队。
「准备上课 + 上课」就是一条链:准备上课提醒的正文以 `EndTime = 上课时间点.StartTime` 结束,随后立刻进入上课提醒(ClassNotificationProvider,见 7.4.1)。
### 7.3 提供者与渠道注册
提供者必须有 GUID、名称、图标、描述;渠道是提供者下的子分组(可独立配置),渠道也有 GUID。内置清单必须照抄:
| 提供者 | GUID | 图标 | 渠道 |
|---|---|---|---|
| 上下课提醒 | `08F0D9C3-C770-4093-A3D0-02F3D90C24BC` | `\uE958` | 准备上课 `CDDFE7FF-B904-4C73-B458-82793B2F66E9`(图标 `\ue21a`)、上课 `AFF5B9A4-037C-4A71-8563-C9EA87DDA75C`(`\uE479`)、下课 `77C9F3FB-0A2A-4B22-BDDF-3C333462B2F9`(`\ue4c3`) |
| 放学提醒 | `8FBC3A26-6D20-44DD-B895-B9411E3DDC51` | `\ued34` | 无 |
| 天气预警 | `7625DE96-38AA-4B71-B478-3F156DD9458D` | `\ue4db` | 无 |
| 集控提醒 | `0117fb4f-5374-434a-97fb-4f5374634a07` | `\uE821` | 无 |
| 行动提醒 | `4B12F124-8585-43C7-AFC5-7BBB7CBE60D6` | `\ue01e` | 无 |
证据:`repos/ClassIsland/ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs:22-30`、`AfterSchoolNotificationProvider.cs:16`、`WeatherNotificationProvider.cs:26`、`ManagementNotificationProvider.cs:15`、`ActionNotificationProvider.cs:7`。注册入口:`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:198-202`。
注册时必须自动补全四份持久化配置(`NotificationHostService.cs:79-114`):
1. `NotificationProvidersPriority` 中不存在该 GUID → **追加到末尾**(即新的提供方优先级最低)。
2. `NotificationProvidersSettings[guid] = null`(提供方私有设置对象,按需反序列化)。
3. `NotificationProvidersEnableStates[guid] = true`。
4. `NotificationProvidersNotifySettings[guid] = new NotificationSettings()`。
5. 建立注册信息对象 `{ Name, Description, ProviderGuid, ProviderInstance, ProviderSettings, NotificationChannels[] }`(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationProviderRegisterInfo.cs:14-56`)。
6. 遍历该提供者声明的所有 `NotificationChannelInfo`,为每个渠道创建 `NotificationChannel` 并注册:`NotificationChannelsNotifySettings[channelGuid] = new()`,挂到对应提供者的 `NotificationChannels` 列表(`NotificationHostService.cs:273-284,104-114`)。
设置三层覆盖(**极其重要,必须按此顺序取第一个 `IsSettingsEnabled==true` 的层**,`repos/ClassIsland/ClassIsland/Services/NotificationWorkerService.cs:109-124`):
```
candidates = [request.ChannelSettings, request.ProviderSettings, request.RequestNotificationSettings]
effective = GlobalSettings
for c in candidates: if (c != null && c.IsSettingsEnabled) { effective = c; break }
```
`action` 字段含义:`ChannelSettings`(渠道级,优先级最高)→ `ProviderSettings`(提供者级)→ `RequestNotificationSettings`(单次请求)。全局设置永远是兜底。
### 7.4 内置提供者行为(逐个实现)
#### 7.4.1 上下课提醒 ClassNotificationProvider
设置默认值(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/ClassNotificationSettings.cs:7-22`):
| 字段 | 默认 |
|---|---|
| `IsClassOnNotificationEnabled` | `true` |
| `IsClassOnPreparingNotificationEnabled` | `true` |
| `IsClassOffNotificationEnabled` | `true` |
| `InDoorClassPreparingDeltaTime` | `60`(秒) |
| `OutDoorClassPreparingDeltaTime` | `600`(秒) |
| `ClassOnPreparingText` | `"准备上课,请回到座位并保持安静,做好上课准备。"` |
| `OutdoorClassOnPreparingText` | `"下节课程为户外课程,请合理规划时间,做好上课准备。"` |
| `IsSpeechEnabledOnClassPreparing` / `OnClassOn` / `OnClassOff` | 均为 `true` |
| `ShowTeacherName` | `false` |
| `ClassOnPreparingMaskText` | `"即将上课"`(室内/户外两个字段同默认) |
| `ClassOnMaskText` | `"上课"` |
| `ClassOffMaskText` | `"课间休息"` |
| `ClassOffOverlayText` | `""` |
行为 A:**准备上课**(每个 `PostMainTimerTicked` 检测,ClassNotificationProvider.cs:63-100):
1. 仅当 `CurrentState ∈ {Breaking, None}` 时继续。
2. 触发条件:`0 < OnClassLeftTime <= Δ` 且 `IsClassOnPreparingNotificationEnabled`;`Δ` 取附加设置 → 否则按「下节课是否户外」取 600 或 60 秒。
3. 已提醒过(`IsClassPreparingNotified`)→ 不再提醒。
4. 计算剩余秒 `deltaTime`:`deltaTime = Δ - OnClassLeftTime`,若 `> 10 秒` 则用 `OnClassLeftTime`,否则用 `Δ`(`CalculateDeltaTime`,125-129)。遮罩朗读文本固定为 `"距上课还剩{格式化时长}。"`,遮罩持续 **3 秒**。
5. 若 `IsClassOnNotificationEnabled`,同时构造「上课」请求并入链。
6. 正文内容:`ClassPrepareNotifyOverlay`,包含左列 `"距上课还剩 N 秒"`(N 为 `OnClassLeftTime` 总秒数,加粗)与右列 `"下节课是:" + 科目名(`MainWindowLargeFontSize` 加粗)+ 可选教师名 + 起止时间(`hh:mm-hh:mm`)`;左右两列各占 `1*`,单元格内 `Margin=12 0`(`repos/ClassIsland/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml:19-46`)。
7. 正文 `EndTime` = 今天 + `NextClassTimeLayoutItem.StartTime`(即一直显示到上课时刻),朗读文本 = `"{正文文本} 下节课是:{科目名}[,由{姓氏}老师任教]。"`。
8. 条件不再满足时(例如时间被追溯改晚):取消已发出的准备上课请求,并把 `IsClassPreparingNotified` 复位。
行为 B:**上课**(响应 `OnClass` 事件,240-286):
1. 若 `IsClassOnNotificationEnabled=false`、`IsClassOnNotified=true`、当前时间点为空、或 `now - CurrentTimeLayoutItem.StartTime > 5 秒`(迟到超过 5 秒不提醒)→ 全部跳过。
2. 只发遮罩:`CreateTwoIconsMask(ClassOnMaskText, rightIcon="lucide(\ue54f)")`,默认持续 5 秒;`SpeechContent = ClassOnMaskText`。
3. 事件回调在「取消」时复位 `IsClassOnNotified`(**注意源码注册在 `CancellationToken` 上**,283 行)。
4. `OnClass` 事件处理末尾必须把 `IsClassPreparingNotified=false`、`IsClassOnNotified=false`(源码注释说明:避免「提醒过程中时间提前导致播完还没上课」造成重复提醒,244-247)。
行为 C:**下课**(响应 `OnBreakingTime`,199-238):
1. 校验:`IsClassOffNotificationEnabled` 且 `CurrentTimeLayoutItem != Empty` 且 `now - CurrentTimeLayoutItem.StartTime <= 5 秒`,否则跳过。
2. 正文文本 `overlayText = 附加设置的 ClassOffOverlayText ?? 全局设置`;`showOverlayText = 非空白`。
3. `是否还有下节课 = (NextClassSubject != Subject.Fallback)`。
4. 遮罩:`ClassOffNotification` 布局——左图标 `\ue0ff`(20×20,居中)、中间文字 = `CurrentTimeLayoutItem.BreakNameText`(`MainWindowEmphasizedFontSize`、加粗、左右 `Margin=8 0`)、右图标 `\ue250`(20×20);文字颜色 `TextOnAccentFillColorPrimaryBrush`。**遮罩时长:无下节课 5 秒;有下节课 2 秒**。朗读内容 = `BreakNameText`。
5. 正文:无下节课 → `null`;有下节课 → `ClassOffOverlay`:左列 `"本节{课间名}长 {人性化时长}"`(加粗时长,格式见下)、右列 `"下节课是:" + 科目名 + 可选教师 + 起止时间`;**正文时长:有自定义文本 20 秒,否则 10 秒**。朗读文本 = `"本节{课间名}常{时长},下节课是:{科目名}[,由{姓氏}老师任教]。{overlayText}"`(源码该句为「常」字,属笔误,实现时建议修正为「长」并记录)。
6. 人性化时长格式(`ClassNotificationProviderControl.axaml.cs:134-152`):`>0` 才有输出;小时用 `"N 小时"`;分钟在有秒时用 `"N 分"`、无秒时用 `"N 分钟"`;秒用 `"N 秒"`;以空格连接;`<=0` 返回 `"0 分钟"`。
7. 内容轮播:该控件的 `ListBox` 每 **10 秒**在「自定义 Element」与「Message 文本」之间切换(`Timer Interval = 10 秒`,仅当 `_key` 为 `ClassPrepareNotifyOverlay` 或 `ClassOffOverlay` 时启动,112-117);`Message` 为空时轮播仍然发生但显示空白,因此实现时必须保证这两类正文的 `Message` 非空或禁用轮播。
**附加设置(数据来源与字段;完整模型见 06 §6.10)**:
- 存储位置:附加设置挂在分别继承 `AttachableSettingsObject` 的 `ClassPlan` / `ClassInfo`(课程)/ `TimeLayout` / `TimeLayoutItem` / `Subject` 上,字段为 `AttachedObjects: Record<Guid, 设置对象>`(`repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:15`)。`Profile` 根不承载附加设置。
- 读取 API 与优先级:`GetAttachedSettingsByPriority(id, subject, timeLayoutItem, classPlan, timeLayout)` 固定按 **Subject → TimeLayoutItem → ClassPlan → TimeLayout** 逐层查找,返回第一个 `IsAttachSettingsEnabled == true` 的对象,全未命中返回 `null`(`repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IAttachedSettingsHostService.cs:24-43`;`repos/ClassIsland/doc/AttachedSettings.md:50-57`)。
- 本提供者使用的附加设置 GUID:**上课提醒设置 `08F0D9C3-C770-4093-A3D0-02F3D90C24BC`**(与提供方 GUID 相同),可附加目标 `ClassPlan | TimeLayout | Lesson | Subject | TimePoint`(`Controls/AttachedSettingsControls/ClassNotificationAttachedSettingsControl.axaml.cs:12-15`)。
- 生效字段:`IsClassOnNotificationEnabled`、`IsClassOnPreparingNotificationEnabled`、`IsClassOffNotificationEnabled`、`ClassPreparingDeltaTime`、`ClassOnPreparingText`、`OutdoorClassOnPreparingText`、`ClassOnPreparingMaskText`、`OutdoorClassOnPreparingMaskText`、`ClassOnMaskText`、`ClassOffMaskText`、`ClassOffOverlayText`(`Models/AttachedSettings/ClassNotificationAttachedSettings.cs:9-20`)。
- 覆盖规则:仅当附加设置 `IsAttachSettingsEnabled == true` 时整组覆盖提供方设置,否则回退 `Settings`(`ClassNotificationProvider.cs:103-123,199-265`)。
- 时间点来源:下课/上课用 `CurrentSubject` / `CurrentTimeLayoutItem`;准备上课用 `NextClassSubject` / `NextClassTimeLayoutItem`(`ClassNotificationProvider.cs:288-314`)。
#### 7.4.2 放学提醒 AfterSchoolNotificationProvider
- 响应 `OnAfterSchool`(`repos/ClassIsland/ClassIsland/Services/NotificationProviders/AfterSchoolNotificationProvider.cs:34-48`)。
- 条件:设置启用(附加设置优先)且 `now - 最后一个 TimeType∈{0,1} 时间点.EndTime <= 10 秒`。
- 附加设置:GUID `8FBC3A26-6D20-44DD-B895-B9411E3DDC51`(与提供方 GUID 相同),**只能附加到 ClassPlan | TimeLayout**;字段 `IsEnabled`(默认 `true`)、`NotificationMsg`(默认 `"今天的课程已结束,请同学们有序离开。"`);`IsAttachSettingsEnabled` 默认 **`false`**,因此默认走提供方设置(`Controls/AttachedSettingsControls/AfterSchoolNotificationAttachedSettingsControl.axaml.cs:11-13`、`Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:11-13`)。
- 遮罩:`CreateTwoIconsMask("放学", rightIcon: "\ued34")`,默认 5 秒,朗读「放学」。
- 正文:`CreateSimpleTextContent(NotificationMsg)`,**时长 30 秒**。
- 默认文案:`NotificationMsg = "今天的课程已结束,请同学们有序离开。"`,`IsEnabled = true`(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/AfterSchoolNotificationProviderSettings.cs:9-10`)。
- 正文布局:单行文本居中,`Margin=24 0`,`TextTrimming=CharacterEllipsis`(`Controls/NotificationProviders/AfterSchoolNotificationProvider.axaml:15-23`)。
#### 7.4.3 天气提醒 WeatherNotificationProvider
设置默认值(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/WeatherNotificationProviderSettings.cs:10-14`):`AlertShowMode=Disabled(0)`、`ForecastShowMode=Disabled(0)`、`IsAlertEnabled=true`、`IsForecastEnabled=true`、`WeatherAlertSpeed=7.0`(字/秒)。`NotificationModes` 枚举为 `Disabled=0, EnabledForce=1, Default=2`(`repos/ClassIsland/ClassIsland.Shared/Enums/NotificationModes.cs:6-19`)。
触发时机:仅 `OnClass` 与 `OnBreakingTime` 两个事件(WeatherNotificationProvider.cs:57-65,116-120)。每次事件都先 `ShowAlertsNotification()` 再 `ShowForecastNotification()`。
**气象预警**(122-164):
1. 天气未刷新(`IsWeatherRefreshed=false`)→ 跳过。
2. 取附加设置(按当前时间点)→ 无则用提供方设置。附加设置 GUID 为 `7625DE96-38AA-4B71-B478-3F156DD9458D`,**只能附加到 TimePoint**,且 `hasEnabledState=false`(没有启用开关,写回不受 `IsAttachSettingsEnabled` 限制);字段仅 `AlertShowMode`、`ForecastShowMode`(`NotificationModes`,默认均为 `Default`),`IsAttachSettingsEnabled` 默认 `true`(`Controls/AttachedSettingsControls/WeatherNotificationAttachedSettingsControl.axaml.cs:11-13`、`Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:11-13`)。读取入口仍是 `GetAttachedSettingsByPriority<WeatherNotificationAttachedSettings>(ProviderGuid, timeLayoutItem: CurrentTimeLayoutItem)`(`WeatherNotificationProvider.cs:71-73,126-128`)。
3. 关闭条件:`!Settings.IsAlertEnabled`,或附加设置 `AlertShowMode == Disabled`,或(附加设置 `AlertShowMode == Default` 且全局 `AlertShowMode == Disabled`)。
4. 对 `LastWeatherInfo.Alerts` 中**未在本次会话展示过**(`ShownAlerts` 以 `Detail` 文本去重)的每条预警:计算单次显示秒数 `t = Detail.Length / WeatherAlertSpeed`,**钳制到 [10, 90]**;遮罩 = 预警标题(图片图标 22×22 + 标题 `MainWindowEmphasizedFontSize` 加粗,`Margin=8 0`),`SpeechContent = 标题`,时长 **5 秒**;正文 = 预警详情(滚动文本),时长 **2t**,`SpeechContent = Detail`。展示后把 `Detail` 记入 `ShownAlerts`(会话内不再重复)。
5. 正文布局:左列 22×22 图标(`Margin=10 0 16 0`,`ZIndex=1`),右侧为裁剪容器内的滚动面板(`Margin=27 0 0 0`)(`Controls/NotificationProviders/WeatherNotificationProviderControl.axaml:18-46`)。
**三天天气预报**(67-93):`IsForecastEnabled` 与 `ForecastShowMode` 同上判定;遮罩与正文都用 `WeatherForecastNotificationProvider` 控件,正文时长 **15 秒**。
**逐小时预报**(102-114):基准时间 = `LastWeatherInfo.UpdateTime` **向上取整到整点**(`RoundUpToHour`,95-100),正文时长 15 秒。
天气数据侧的去重与过滤(必须同时实现,`repos/ClassIsland/ClassIsland/Services/WeatherService.cs:363-388`):先按 `Settings.ExcludedWeatherAlerts` 对 `Title`/`Detail` 做包含匹配过滤(空串忽略),再按 `Type` 分组只保留 `PubTime` 最新的一条。
#### 7.4.4 集控提醒 ManagementNotificationProvider
仅在集控模式下注册自身(构造函数 `base(false)` 后手工调用注册,`repos/ClassIsland/ClassIsland/Services/NotificationProviders/ManagementNotificationProvider.cs:24-39`)。收到 `CommandTypes.SendNotification` 指令时(41-80):
1. `repeatCounts = max(1, payload.RepeatCounts)`;`durationSeconds = payload.DurationSeconds <= 0 ? 5 : payload.DurationSeconds`。
2. `maskText = payload.MessageMask 为空白 ? "集控通知" : MessageMask`。
3. 遮罩:`CreateTwoIconsMask(maskText, rightIcon: "\uE7E7")`(默认 5 秒)。
4. 正文:`CreateRollingTextContent(messageContent, 时长 = durationSeconds * repeatCounts, repeatCounts)`(滚动 N 次)。
5. `Level = payload.IsEmergency ? Emergency : Normal`(同时为兼容置 `IsPriorityOverride = payload.IsEmergency`,见 7.2);`RequestNotificationSettings` 用 payload 的 `IsSpeechEnabled / IsEffectEnabled / IsSoundEnabled / IsTopmost` 全部覆盖,`IsSettingsEnabled = true`。
#### 7.4.5 行动提醒 ActionNotificationProvider
本身不主动发提醒,只作为「显示提醒」自动化行动的发送源暴露静态实例(`NotificationAction.cs:20-21,106`)。因此它必须注册、必须出现在设置页,但不需要自己的触发逻辑。
### 7.5 消费者(谁负责显示)
提醒主机自己不画气泡,而是推给「消费者」(`INotificationConsumer`,`repos/ClassIsland/ClassIsland.Core/Abstractions/INotificationConsumer.cs:9-31`):
- 消费者按 `priority` 插入有序列表,**priority 小在前**(`NotificationHostService.cs:436-459`)。
- 推送条件:`AcceptsNotificationRequests == true && QueuedNotificationCount <= 0`(337-367)。
- 主界面每个「行」(MainWindowLine)都是消费者:`AcceptsNotificationRequests = IsNotificationEnabled && !IsAllComponentsHid && !_isOverlayOpen && !_isUnloading`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:814`)。行级 `IsNotificationEnabled` 由编辑模式里「此行可以显示提醒」开关控制(`repos/ClassIsland/ClassIsland/MainWindow.axaml:425-433`)。
- 无消费者时退回队列;消费者播放完毕要主动 `PullNotificationRequests()` 拉后续(784-799)。
- 宿主在推送前如检测到有「未完成移交」的提醒(`_pendingRescheduleCount > 0`),必须暂缓推送(60, 344-348)。
### 7.6 播放流程(气泡两阶段 + 状态机)
单个 request 的播放(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:682-811`,必须逐步实现):
1. 若正在播(`_isOverlayOpen`)→ 直接返回;否则上锁。
2. 首次播放记录 `_firstProcessNotificationsTime`。若全局提醒关闭,或「静默启动」且距首次播放 ≤ 10 秒(`App.ApplicationCommand.Quiet`)→ 清空请求队列(698-704)。`-q` 即静默启动参数(`repos/ClassIsland/ClassIsland/Models/ApplicationCommand.cs` 的 `Quiet`)。
3. 队列循环:取出 ticket(含 request 与生效设置),置 `CurrentNotificationRequest`。
4. 若 `MaskContent.Duration > 0` 且未取消:
- 清除 `:mask-anim`、`:overlay-out`、`:mask-in`、`:mask-out` 伪类。
- 绑定遮罩内容(`MaskContent = request.MaskContent`,先解析 `ContentTemplateResourceKey` → `ContentTemplate`)。
- 若生效设置 `IsNotificationTopmostEnabled && 全局 AllowNotificationTopmost` → 主界面 `AcquireTopmostLock(ticket)`;否则 `ReleaseTopmostLock(ticket)`。
- 置 `:mask-anim`、`:mask-in`、清除 `:overlay-anim`。
- **播放强调特效**:仅当 `IsNotificationEffectEnabled && 全局 AllowNotificationEffect && !IsAllComponentsHid && IsMainWindowVisible && !request.MaskSession.HasSoundsPlayed` → 取当前行中心点,`PlayEffect(new RippleEffect(center, MaskContent.Color))`。
- `await ticket.ProcessMask()`(含语音、音效、`Task.Delay(duration)`)。
5. 遮罩结束后:
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置 `:overlay-anim`、`:mask-out`、`:overlay-out`,结束本次。
- 否则绑定正文、置 `:mask-out`、`:overlay-in`,`await ticket.ProcessOverlay()`。
6. 队列与宿主队列都空且本次确实播过 → 置 `:overlay-anim`、`:mask-out`;清空 `MaskContent/OverlayContent`;解锁;释放置顶锁。
7. 捕获 `TaskCanceledException` 只记录 trace,不得弹错误。
播放会话与计时(`repos/ClassIsland/ClassIsland/Services/NotificationWorkerService.cs:161-248`):
- 进入会话先 `SetupNotificationSessionTiming`:若 `EndTime != null` 且尚未初始化 → `Duration = EndTime - now`(负值钳 0);`SessionStartTime` 首次写入;重启计时 Stopwatch;会话持续时长 = `EndTime != null ? EndTime - now : Duration - SessionPlayedTime`(82-107)。
- 置 `request.State = Playing`。
- 语音:`isSpeechEnabled = 生效设置.IsSpeechEnabled && content.IsSpeechEnabled && 全局 AllowNotificationSpeech`,且该会话尚未播放过声音 → `SpeechService.EnqueueSpeechQueue(content.SpeechContent)`(178-182)。
- 音效:仅**遮罩**阶段(`isMask`)、生效设置 `IsNotificationSoundEnabled`、全局 `AllowNotificationSound`、且未播过声音时播放;`NotificationSoundPath` 为空 → 用内置默认音效 `avares://ClassIsland/Assets/Media/Notification/1.wav`(`INotificationProvider.DefaultNotificationSoundUri`,`repos/ClassIsland/ClassIsland.Shared/Interfaces/INotificationProvider.cs:37`),音量 = `Settings.NotificationSoundVolume`;播放失败只记日志(184-208)。
- 计时结束:若「无正文」或「当前是正文阶段」→ `State=Completed`、清空语音队列、取消音效令牌、取消 `CompletedTokenSource`(212-218)。注意**遮罩阶段结束不会结束整个提醒**,音效令牌挂在整条提醒的取消令牌上(132-134 注释明确说明)。
- 异常:`TaskCanceledException` → `State = 请求被取消 ? Cancelled : Paused`;其他异常 → `Paused`(222-234)。
- 进度:每个 `PostMainTimerTicked` 用「显式结束时间」或「已播放时间 + 本次 Stopwatch」反算 `LeftProgress = 1 - elapsed/Duration`(65-80)。
### 7.7 气泡视觉与动画参数(像素级)
容器与排版(`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:118-197,275-449`;ClassicTheme 对应 136-330 行结构相同):
- 行容器高度:`IslandContainerHeight = 40`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:16`)。
- 遮罩层 `Border#OverlayMask`:`Height = IslandContainerHeight`、`HorizontalAlignment/VerticalAlignment=Stretch`、`ClipToBounds=True`、`RenderTransformOrigin=Center`、文字前景 `TextOnAccentFillColorPrimaryBrush`、圆角 = 行圆角(由 `MainWindowStylesAssist.CornerRadius` 经 `SizeDoubleToCornerRadiusConverter` 计算,参数 1.0)。
- 遮罩内容外层 `Border Padding="12 0"`;内容 `ContentPresenter#OverlayMaskContent` 初始 `Opacity=0`,带 `ScaleTransform`。
- 正文层:外层 `Border ClipToBounds=True`,圆角同上;内部 `Panel` 依次叠「进度条 Canvas(贴底)」与 `ContentPresenter`(`MaxHeight = IslandContainerHeight`)。
- 正文进度的 `ProgressBar`:`Maximum=1`、`Value = CountdownProgressValue`(绑定 `CurrentNotificationRequest.LeftProgress`,`MainWindowLine.axaml:77`)、`MinWidth=0`、`Width` 绑定容器宽度,`Value` 用 `DoubleTransition Duration=0:0:0.05` + `LinearEasing`。
- 遮罩阴影:`:mask-in` 时 `BoxShadow = "0 8 32 0 #66000000"`(未分岛与分岛分别落在 `BackgroundBorderOverlayMask` / `OverlayMaskShadowRoot` 上),且该 Border 有 `BoxShadowsTransition Duration=0:0:0.1`。
遮罩进入动画(`:mask-in`,FluentTheme/Styles.axaml:313-371):
1. `SlantedMaskControl` 置 `IsOpened = true`(斜切遮罩展开)。
2. `OverlayMaskContent`:`Opacity 0→1`,`Delay=260ms`、`Duration=250ms`、`Easing = "0.25, 1, 0.5, 1"`(自定义贝塞尔,近似 QuartOut)。
3. `OverlayMaskContent` 缩放:`ScaleX/ScaleY 1.1→1.0`,`Delay=260ms`、`Duration=750ms`、同一条缓动曲线。
4. 遮罩背景 `BackgroundBorderOverlayMask`:`Opacity 0→1`,`Delay=600ms`、`Duration=100ms`、`FillMode=Forward`。
5. 原内容 `Grid#GridContentRoot`:`Opacity = 0`。
遮罩退出动画(`:mask-out`,372-417):
1. `SlantedMaskControl.IsOpened = false`(斜切遮罩向中间收拢)。
2. `OverlayMaskContent`:`Opacity 1→0`,`Duration=200ms`,`FillMode=Forward`;同时 `Scale 1.0→1.1`,`Duration=200ms`,`Easing="0.5, 0, 0.75, 0"`(约 QuartIn)。
3. 遮罩背景:`Opacity → 0`,`Duration=100ms`(并有静态 `Opacity=0` 兜底)。
正文淡入淡出(419-449):
- `:overlay-anim`:`Grid#GridOverlay` 用 `DoubleTransition Opacity 200ms + SineEaseIn`,置 `Opacity=0`。
- `:overlay-in`:`GridOverlay Opacity=1`,`GridContentRoot Opacity=0`。
- `:overlay-out`:`GridOverlay Opacity=0`,`GridContentRoot Opacity=1`。
斜切遮罩 `SlantedMaskControl`(`repos/ClassIsland/ClassIsland/Controls/SlantedMaskControl.cs`,必须完整实现):
- 5 个区域,水平权重 `[0, 0.1, 0.2, 0.4, 0.2, 0.1]`(前缀 0 占位,索引 0..5),每个区域占 `总宽 × weight`;区域宽 = `总宽 × weight[i+1] + 0.5`。
- 倾斜角固定 60°:`offset = tan(30°) × h`,`总宽 = 控件宽 + offset`;`SlantedHeight` 默认 `80`,超过控件高时取控件高,≤0 时取控件高。
- 每个区域绘制平行四边形:顶点为 `(c-w/2, 0)`、`(c+w/2, 0)`、`(c+w/2-offset, h)`、`(c-w/2-offset, h)`,其中 `c` 为区域中心、`w = 区域全宽 × 进度`;进度 < 0.0001 跳过。
- 展开(Open,两边→中间):区域 0、4 起始 `delay=0`;1、3 `delay = 0.75×StageStagger`;2(中间)`delay = StageStagger`。收起(Close,中间→两边)顺序相反。
- 动画总时长 = `StageStaggerMs + RegionDurationMs`;`RegionDurationMs` 默认 `300`,但界面里显式设为 **`360`**、`StageStaggerMs = 120`(`Styles.axaml:175-180`)。
- 缓动:展开 `QuarticEaseOut`,收起 `QuadraticEaseIn`;`FillMode=Forward`;结束后把所有区域进度钉到 1(展开)或 0(收起),避免插值残留。
- 动画必须可被新的 Open/Close 打断(内部 `CancellationTokenSource` 取消上一次)。
### 7.8 强提醒:全屏强调特效
特效元素:`RippleEffect`(`repos/ClassIsland/ClassIsland/Controls/NotificationEffects/RippleEffect.axaml/.cs`)。必须实现为一个**透明、不命中测试**的全屏控件,参数如下:
- 外观:`Ellipse StrokeThickness=200`,填充色 = `MaskContent.Color`,为 null 时取资源 `AccentFillColorDefaultBrush`;`IsHitTestVisible=False`;Canvas `ClipToBounds=True`(axaml:10-31)。
- 半径:计算控件中心到**四个顶点**距离取最大值,再 `Ceiling`,作为圆半径 `r`;`EllipseSize = Width = Height = r * 2`;`TranslateTransform` 用 `RippleEffectTranslationConverter` 把尺寸换算为位移,使圆心对齐点击中心。
- 展开动画:`Scale 0→1`,**Duration=600ms**,`QuadraticEaseIn`;同时 `Opacity 1→0`,**Duration=600ms**,`SineEaseIn`;均通过 Composition 层关键帧(`ElementComposition.GetElementVisual`)启动,保证 60fps 不占 UI 线程。
- 生命周期:动画开始后 `await Task.Delay(750)` 再抛 `EffectCompleted`(多出 150ms 让不透明度收尾)。
特效承载窗口 `TopmostEffectWindow`(`repos/ClassIsland/ClassIsland/Views/TopmostEffectWindow.axaml/.cs`):
- 基窗口:`Topmost=True`、`Height=1 Width=1`(真实尺寸由 `UpdateWindowPos` 覆盖)、`Background=Transparent`、`CanResize=False`、`SystemDecorations=None`、`TransparencyLevelHint=[Transparent]`;内容为 `ItemsControl` + `Grid` 面板(多个特效可叠加)(axaml:1-35)。
- 显示时机:`EffectControls.Count > 0` 时 `Show()`,为 0 时 `Hide()`;**不销毁窗口**(cs:50-67)。
- 每屏一个窗口:按「主界面所在屏幕」计算——`bounds = fullscreen ? screen.Bounds : screen.WorkingArea`;`Width = bounds.Width × scale - (fullscreen ? 1 : 0)`(减 1 像素规避全屏边界异常)、`Height = bounds.Height × scale`;`Position = (bounds.X, bounds.Y)`。`scale` 来自 `Settings.NotificationEffectRenderingScale`(默认 1.0;`IsNotificationEffectRenderingScaleAutoSet` 为 true 时按 DPI 自动推算,具体系数【推断:源码未给出公式】)。
- 窗口特性:`ShowActivated=false`、`ShowInTaskbar=false`、`Topmost=true`;平台层设 `Transparent | ToolWindow | Topmost | SkipManagement`(cs:120-128)。
- 关闭拦截:除 `OSShutdown` / `ApplicationShutdown` 外一律 `e.Cancel = true`(cs:111-118)。
- **编辑模式丢弃**:`ViewModel.IsEditMode == true` 时不播放新特效(cs:74-78)。
- 特效结束即从 `EffectControls` 移除(cs:90-98);若播放中进入编辑模式,允许已播放的结束。
- `Settings.NotificationUseStandaloneEffectUiThread`(默认 true)表示特效使用独立 UI 线程渲染;Electron 映射为**独立透明 BrowserWindow**(与灵动岛同款 `transparent:true, frame:false, focusable:false, skipTaskbar:true, alwaysOnTop:'screen-saver'`),并把它的 `setIgnoreMouseEvents(true)` 打开。该窗口不得使用 `setBackgroundMaterial()`,以免破坏逐像素透明。
**置顶锁**(必须实现,`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:1056-1092`):
- `AcquireTopmostLock(token)`:内部列表去重;当且仅当「从无到有」时置 `IsNotificationWindowExplicitShowed=true`;若 `Settings.WindowLayer == 0` 则立即 `UpdateWindowLayer()` 与 `ReCheckTopmostState()`。
- `ReleaseTopmostLock(token)`:移除 token;列表非空直接返回;列表为空且 `IsNotificationWindowExplicitShowed` 为真 → 复位标志、`SetBottom()`、`UpdateWindowLayer()`。
- 即:多个提醒同时存在时,只有最后一个释放才把主界面还原到原层级,避免闪烁。
### 7.9 声音、语音与音量
音效:
- 默认音效资源 `avares://ClassIsland/Assets/Media/Notification/1.wav`,仅在「遮罩阶段 + 未播过声音」时播一次;**音效与提醒同生命周期**:提醒被取消/正文结束才取消音效(关键行为,见 7.6)。
- 音量取全局 `NotificationSoundVolume`(0~1,滑块 `Minimum=0 Maximum=1 TickFrequency=0.01`,`Views/SettingPages/NotificationSettingsPage.axaml:313-318`)。
- 自定义音效:`NotificationSoundPath` 为空 → 用默认资源;非空 → 读文件;**若音效比提醒显示时长更长,多出来的部分被截断**(界面文案:`"设置自定义音效,留空以禁用。如果音效长于提醒显示时间,多出来的部分会被截断。"`,NotificationSettingsPage.axaml:329-337)。
- 播放实现:主进程持有唯一音频引擎(源码用 MiniAudio/SoundFlow,`repos/ClassIsland/ClassIsland/Services/AudioService.cs:20-136`),**共享默认播放设备并引用计数复用**(48-68),设备 `MasterMixer.Volume=1.0`,单次播放音量设在播放器上(106-108)。Electron 映射:用 `HTMLAudioElement` 或 `AudioContext` 在渲染进程播放,音量乘全局系数;多路并发必须共享一个 `AudioContext`。
语音(TTS):
- 语音层开关:全局 `AllowNotificationSpeech`(默认 **false**)必须为 true,且生效设置 `IsSpeechEnabled` 与 `content.IsSpeechEnabled` 三者为真才朗读(7.6 已述)。
- 语音源 `SpeechSource`:0 = 系统 TTS,1 = Edge TTS(源码在设置 `SpeechSource` 变化时会把 `SpeechProvider` 重置,Settings.cs:1443-1447)。默认提供方 `SelectedSpeechProvider = "classisland.speech.edgeTts"`。
- 系统 TTS(`repos/ClassIsland/ClassIsland/Services/SpeechService/SystemSpeechService.cs:27-34`):`SpeechSynthesizer.Volume = SpeechVolume * 100`(整数 0~100)。
- Edge TTS(`repos/ClassIsland/ClassIsland/Services/SpeechService/EdgeTtsService.cs`):默认音色 `"zh-CN-XiaoxiaoNeural"`(Settings.cs:138);缓存目录 `AppCache/EdgeTTS/{音色}/{文本 MD5}`(31,62);播放音量 `SpeechVolume`(142);找不到音色时记警告并跳过(168-172)。文本 → 音频结果必须缓存,重复提醒不得重复合成。
- 语音队列:`EnqueueSpeechQueue(text)` 入队、`ClearSpeechQueue()` 在提醒完成/取消时清空(NotificationWorkerService.cs:181,215)。
- 全局音量 `SpeechVolume` 默认 `1.0`,滑块 `Minimum=0 Maximum=1 TickFrequency=0.01`(NotificationSettingsPage.axaml:262-271);另有「测试朗读文本 / 测试语音」入口(273-283)。
- 语音引擎列表来自注册的语音提供方;设置页显示 `Name` 并用 `SettingsExpanderItem Content="语音引擎"` 包裹(237-259)。
**关于「静音时段」**:ClassIsland 2.1 **没有**独立的静音时段/免打扰时间表功能。可查到的相关行为只有两条:①全局 `IsNotificationEnabled` 总开关;②`-q/--quiet` 静默启动时,启动后 10 秒内的提醒会被丢弃(`MainWindowLine.cs:698-704`)。**不得**虚构一个「静音时段」配置项冒充原功能,也不得因此阻塞 P0/P1 验收。
若产品仍需要该能力,必须按**可选扩展处理:优先级 P2,默认关闭(`IsEnabled=false`)**,并在设置页显式标注「扩展功能」;推荐模型:
```json
"QuietHours": { "IsEnabled": false, "Ranges": [{ "Start": "22:00", "End": "06:30" }], "AllowTopmost": false, "AllowSound": false, "AllowSpeech": false }
```
以上为【推断/扩展】。P2 实现时还必须满足:命中静音时段时**提醒本身照常入队与显示**,只按开关屏蔽音效/语音/置顶/特效,不得静默丢弃提醒(与 `-q` 的「丢弃」语义区分开)。
### 7.10 取消与清理
- `CancelAllNotifications()`:清空队列并对每个出队请求取消 `CompletedTokenSource`;对所有正在播放的请求取消 `CancellationTokenSource` 与 `CompletedTokenSource`(`NotificationHostService.cs:322-335`)。
- 用户入口:主界面右键菜单 `"清除全部提醒"`(图标 `E029`),`IsVisible` 同时要求「欢迎窗口已关闭」与 `NotificationHostService.IsNotificationsPlaying`(`repos/ClassIsland/ClassIsland/MainWindow.axaml:86-94`;点击处理 `MainWindow.axaml.cs:1171-1174`)。
- 设置页入口:关闭「启用提醒」总开关后,新请求在入口即被取消(`NotificationHostService.cs:159-163`)。
- **`ClearNotificationButtonWindow`(清除提醒浮动按钮窗口)在当前源码中是 `#if false` 的 WPF 遗留实现**(`repos/ClassIsland/ClassIsland/Views/ClearNotificationButtonWindow.xaml:8-15` 仅有一个空 `Button`,`.xaml.cs:1-18` 整段被 `#if false` 包裹)。因此:**不得**实现这个独立窗口;清除提醒统一通过右键菜单 + `CancelAllNotifications` 完成。若产品坚持要有悬浮清除按钮,必须作为新功能设计(透明置顶小窗、`SizeToContent=WidthAndHeight`、点击即 `CancelAllNotifications`),并标注为扩展。
- 「重新入队」语义:ticket 被取消但 request 未被取消时,宿主必须把 request 重新入队(`isPlayed=true`)并在 `CancellationCompletedCompletionSource` 完成后再次尝试推送(`NotificationHostService.cs:488-522`)。这保证「切行/改布局」导致的中断不会丢提醒。
### 7.11 设置页(提醒)UI 规格
页面结构(`repos/ClassIsland/ClassIsland/Views/SettingPages/NotificationSettingsPage.axaml`):
1. 顶部 `SettingsExpander`:`Header="启用提醒"`,`Description="启用提醒后,将会在对重要事件发出醒目提醒。拖动提醒提供方以调整对应类型提醒的优先级。"`,右侧 `ToggleSwitch` 绑定 `Settings.IsNotificationEnabled`(66-70)。
2. `TabItem "提醒提供方"`(87):左侧为提供方列表(图标 24×24 `ProviderInstance.IconElement` + 名称 + 描述,支持拖动排序 → 写回 `NotificationProvidersPriority`),右侧详情形。
3. 提供方详情:
- 每个渠道一行(`Header="提醒渠道"`),显示渠道图标/名称/描述(148-186)。
- `启用提醒语音`(`AllowNotificationSpeech`)+ `默认启用`(`IsSpeechEnabled`)+ `语音引擎` + `语音音量` + 测试朗读/测试语音(224-283)。
- `启用提醒强调特效`(`AllowNotificationEffect`)+ `默认启用`(`IsNotificationEffectEnabled`)(286-299)。
- `启用提醒音效`(`AllowNotificationSound`)+ `默认启用`(`IsNotificationSoundEnabled`)+ `音效音量` + `自定义音效`(301-347)。
- `置顶提醒`(`AllowNotificationTopmost`)+ `默认启用`(`IsNotificationTopmostEnabled`)(353-368)。
4. 提供方自身设置页 `TabItem "基本设置"`:承载 `ProviderInstance.SettingsElement`;无设置时显示 `"该提醒提供方没有设置。"`(374-390)。
5. 每个提供方/渠道的高级覆盖区:`Header="对此提醒来源启用特殊高级设置"`,`Description="启用后,此处的设置将覆盖全局设置和提醒请求中要求的设置。"`,勾选后写 `ProviderSettings.IsSettingsEnabled = true`,其下再出现语音/特效/音效/音效路径/置顶五项(409-484)。
6. 拖动排序必须即时持久化,并影响后续队列顺序(不必重排已在队列中的项)。
### 7.12 边界与异常(必须处理)
1. **提醒关闭**:`IsNotificationEnabled=false` 时新请求立刻取消完成令牌,不得入队、不得响铃。
2. **无消费者**:主界面被隐藏 / 所有行都禁用提醒 / 正在播 / 正在编辑 → 请求留在队列,等消费者恢复后再推。
3. **遮罩时长 0**:`MaskContent.Duration <= 0` 时不进入遮罩阶段,直接按「无正文」收尾。
4. **正文时长为 0**:视为无正文。
5. **`EndTime` 早于当前时间**:`Duration` 钳为 0,立即结束,不得出现负数时长导致 `Task.Delay` 抛异常。
6. **语音/音效同时开启**:语音先入队播放,音效在遮罩阶段播放;两者混音不得互相打断;提醒结束时清空语音队列。
7. **音效文件不存在 / 损坏**:捕获异常、记日志、继续播放提醒,不得中断整条链路。
8. **集控消息超长**:滚动文本时长已达 90 秒上限(预警)或由 `DurationSeconds × RepeatCounts` 决定;必须防止构造出超长(建议单条提醒时长上限 300 秒)。
9. **特效窗口在编辑模式**:丢弃特效但继续播气泡(不得因特效缺失阻塞)。
10. **多显示器 / DPI 变更**:特效窗口每个显示器一个;屏幕变化时必须重算 `UpdateWindowPos`,否则会出现「特效只在主屏」或缩放错位。
11. **提醒风暴**:同一提供者在极短时间内连续发多条(如天气多条预警)必须靠「会话内已展示集合」抑制重复;队列本身不限制长度,但连续入队要在 UI 上表现为顺序播放而不是同时播放。
12. **应用退出**:正在播放的提醒必须允许被 `ApplicationShutdown` 打断(特效窗口关闭拦截需放行该 reason)。
### 7.13 验收标准
1. 触发上课事件:默认配置下先出现「即将上课」遮罩(斜切遮罩展开 + 文字缩放淡入),随后显示正文「距上课还剩 N 秒 / 下节课是:语文 08:00-08:40」,正文一直显示到上课时刻;随后自动接「上课」遮罩。
2. 遮罩进入动画的四个关键时间点可核对:内容 `delay 260ms / 250ms`、缩放 `delay 260ms / 750ms`、背景 `delay 600ms / 100ms`、阴影过渡 100ms。
3. 遮罩退出的斜切收拢方向为「中间→两边」,正文淡入淡出 200ms + SineEaseIn。
4. 强提醒涟漪:从行中心扩散到全屏,600ms 完成,同时 600ms 淡出,750ms 后移除元素;特效窗口覆盖主界面所在屏幕,`scale` 改变后窗口尺寸按比例变化。
5. 置顶锁:连续两条提醒时主界面保持置顶,直到最后一条结束才还原层级。
6. 关闭「启用提醒」后,任何提供者都发不出提醒;重新开启后历史队列不补播。
7. 优先级排序:把「提供方 A(优先级下标 0)+ Normal」「提供方 B(下标 1)+ Emergency」「提供方 A + Emergency」三条同时入队,播放顺序必须是 A+Emergency → B+Emergency → A+Normal(先比提供方下标,再比等级,最后 FIFO);集控紧急消息必须最先播放且不再有「反而排到队尾」的现象。
8. 语音:打开 `AllowNotificationSpeech` 后,遮罩文本被朗读一次;正文阶段不重复朗读同一段;Edge TTS 音色默认 `zh-CN-XiaoxiaoNeural`,第二次相同文本命中缓存不重新合成。
9. 音效:默认音效在遮罩阶段播放一次,正文阶段不再播放;自定义音效超长部分被截断;音量滑块 0~1 生效。
10. 天气预警:同一预警在一次会话中只提醒一次;显示时长被钳制在 10~90 秒;正文为详情滚动文本。
11. 主界面右键「清除全部提醒」只在有提醒正在播放时出现,点击后队列清空、当前提醒立即消失、置顶锁释放。
12. 全流程不出现「二次弹窗框架」或与系统通知重复弹出的现象;所有异常均有日志且不打断后续提醒。
13. 附加设置覆盖:给某课表挂「上课提醒设置」附加设置并启用、把 `ClassOnMaskText` 改为 `"上课了"`,该课表上下课提醒的遮罩文案必须变为 `"上课了"`;关闭附加设置后立即回退为提供方设置的 `"上课"`。
14. 附加设置优先级:同一课程在 Subject / TimePoint / ClassPlan / TimeLayout 四层都挂了同一 GUID 并启用,实际生效的必须是 Subject 层(顺序 Subject → TimePoint → ClassPlan → TimeLayout)。
15. 天气:断网时提醒链路不得异常中断,界面继续显示 `LastWeatherInfo` 与「更新于 HH:mm」;恢复网络后最迟一个刷新周期(5 分钟)内更新;坐标模式精度降级到 2 位时必须返回并提示 `IsPrecisionDegraded`。
16. 天气图标:把 `WeatherIconId` 依次切换为 4 个内置模板,天气组件与天气提醒的图标必须随之切换;填入不存在的 ID 时保留上一次模板并记 Warning,不得崩溃。
### 7.14 天气服务与数据模型
天气服务必须实现为一个**单例服务 + 单一数据缓存**,供「天气组件」「天气提醒」「天气规则(Part 08)」共用;禁止任何模块自行发请求。主进程负责 HTTP 请求与缓存落盘,渲染进程通过 IPC 订阅更新。
#### 7.14.1 刷新策略与缓存
- 定时刷新:`DispatcherTimer` 周期 **5 分钟**(`repos/ClassIsland/ClassIsland/Services/WeatherService.cs:41-44`),构造时 `UpdateTimer.Start()` 并**立即执行一次** `QueryWeatherAsync()`(69-71)。
- 刷新成功:写 `Settings.LastWeatherInfo = info`、`IsWeatherRefreshed = true`(386-389)。`IsWeatherRefreshed` 默认 `false`(46),所有依赖天气的提醒/规则必须先判它。
- 缓存:`LastWeatherInfo` 持久化在 `config.json`(ClassIsland 原生写入 `Settings.json`,字段 `_lastWeatherInfo = new()`,`Settings.cs:98`),因此**重启后先展示上次缓存**,直到首次刷新成功;界面必须展示 `UpdateTime`(`更新于 HH:mm`,`WeatherSettingsPage.axaml:87`)。
- 缓存丢失/为空:`LastWeatherInfo` 是新对象时所有数值为默认值(温度 `""`、天气码 `"99"`、预警空数组),UI 必须显示占位而不是 0。
- 自动位置:`AutoRefreshWeatherLocation=true` 时每次启动首次查询前调用一次 `LocationService.GetLocationAsync()`,经纬度四舍五入到 **4 位小数**写回设置(244-257);获取失败只记日志,继续用旧坐标。
- 刷新失败:捕获异常 → `WeatherQueryResult.IsSuccess=false` + `ErrorMessage`;`IsWeatherRefreshed` 保持原值;`finally` 中 `RulesetService.NotifyStatusChanged()`(391-400)。**源码无重试与退避**,唯一重试是下一个 5 分钟 tick;实现应补「失败后 30s/60s/120s 三次退避重试,之后回到 5 分钟周期」【推断/增强】。
- 并发:同一时刻只允许一个在途查询(源码靠单 Timer 隐式串行【推断】);实现必须加互斥锁,避免自动刷新与手动刷新同时请求导致 `LastWeatherInfo` 竞态。
#### 7.14.2 定位与城市来源
设置字段与默认值(`Settings.cs`):`CityId="weathercn:101010100"`(99)、`CityName="北京 (北京, 中国)"`(100)、`WeatherLongitude=0.0`(1838)、`WeatherLatitude=0.0`(1839)、`WeatherLocationSource=0`(1840,0=选择城市 / 1=坐标定位)、`AutoRefreshWeatherLocation=false`(1841)、`NoTLSWeatherRequests=false`(1842)、`WeatherIconId="classisland.weatherIcons.lucide"`(1849)、`ExcludedWeatherAlerts=[]`(158)。
- **坐标模式(source=1)**(`WeatherService.cs:265-323`):按精度 `[4,3,2,1,0]` 逐级降级请求 `{schema}://weatherapi.market.xiaomi.com/wtr-v3/location/city/geo?longitude=&latitude=&locale=zh_cn`;命中的判据是 `status == 0 && locationKey 非空`;命中即写回 `CityId/CityName`,`usedPrecision < 4` 时返回 `IsPrecisionDegraded=true` 与 `DegradedPrecision`;全部精度失败 → 抛 `"无法通过经纬度获取城市信息"`。
- **城市模式(source=0)**(`WeatherService.cs:324-344`):请求 `location/city/info?locationKey={CityId}&locale=zh_cn`,必须校验返回 `LocationKey == CityId`,取返回的 `latitude/longitude`;不匹配 → 记错误并返回失败。
- **城市搜索**(`GetCitiesByName`,414-445):名称为空 → `location/city/hots?locale=zh_cn`(热门城市);否则 `location/city/search?name={URL 编码}&locale=zh_cn`;结果映射为 `{ Name: "{name} ({affiliation})", CityId: locationKey }`,失败返回空数组(不抛)。
- 协议:`schema = NoTLSWeatherRequests ? "http" : "https"`(34)。设置页对「不使用 TLS」必须标注「不建议启用」(`WeatherSettingsPage.axaml:315-316`)。
- 位置来源 UI:`选择城市` / `坐标定位` 两项,后者仅在平台支持定位时显示(`WeatherSettingsPage.axaml:162-163`)。
#### 7.14.3 数据模型 JSON schema(字段名与类型必须一致)
`WeatherInfo`(`repos/ClassIsland/ClassIsland.Core/Models/Weather/WeatherInfo.cs:5-23`):
```json
{
"current": { CurrentWeather },
"alerts": [ WeatherAlert ],
"updateTime": 1720000000000,
"forecastDaily": { ForecastDaily },
"forecastHourly": { ForecastHourly },
"minutely": { Minutely },
"aqi": { AqiInfo }
}
```
派生字段:`UpdateTime = DateTimeOffset.FromUnixTimeMilliseconds(updateTime).LocalDateTime`(20)。
`CurrentWeather`(`CurrentWeather.cs:5-21`):`feelsLike`、`humidity`、`pressure`、`temperature`、`visibility` 均为 `ValueUnitPair { value: string, unit: string }`;`weather` 为字符串、默认 `"99"`;`pubTime`;`wind { direction: ValueUnitPair, speed: ValueUnitPair }`。`ValueUnitPair.ToString() = value + unit`(`ValueUnitPair.cs:14-17`)。
`ForecastDaily`(`ForecastDaily.cs:5-14`):`precipitationProbability { value: string[] }`、`temperature { value: RangedValue[] }`、`weather { value: RangedValue[] }`、`sunRiseSet { value: RangedValue[] }`;`RangedValue { from: string, to: string }`(`RangedValue.cs:5-9`)。
`ForecastHourly`(`ForecastHourly.cs:5-9`):`temperature { value: int[] }`、`weather { value: int[] }`。
`Minutely` / `MinutelyPrecipitation`(`Minutely.cs:5-8`、`MinutelyPrecipitation.cs:5-40`):`minutely.precipitation.value: number[]`;派生 `RainRemainingMinutes` 必须逐字实现:
```
空数组 → 0
若 value[0] > 0(正在下雨):从 i=0 起找第一个 value[i] <= 0 → 返回 -i(表示剩余 i 分钟)
否则:从 i=0 起找第一个 value[i] > 0 → 返回 i(表示 i 分钟后开始)
都没有 → 0
```
`WeatherAlert`(`WeatherAlert.cs:5-22`):`locationKey`、`alertId`、`pubTime`、`title`、`type`、`level`、`detail`、`images: Record<string,string>`;派生 `IsDefaultIcon`:`images["icon"]` 命中 4 个固定默认图标 URL(蓝/黄/橙/红预警)之一 → `1`,否则 `0`。
`AqiInfo`(`AqiInfo.cs:5-32`):`aqi: string` 默认 `"0.0"`;派生 `AqiLevel`:`≤50→1`、`≤100→2`、`≤150→3`、`≤200→4`、`≤300→5`、`>300→6`。
`CityInfo`(`CityInfo.cs:7-38`):`name`、`affiliation`、`locationKey`、`latitude`(字符串)、`longitude`(字符串)、`status`(默认 `-1`)。`City`:`{ Name, CityId }`(`City.cs`)。
`WeatherQueryResult`(`WeatherQueryResult.cs:6-29`):`IsSuccess`、`IsPrecisionDegraded`、`ErrorMessage`、`DegradedPrecision`。
天气文案:`GetWeatherTextByCode(code)` 查内嵌资源 `Assets/XiaomiWeather/xiaomi_weather_status.json`(`{ "weatherinfo": [ { "code": 0, "wea": "晴" }, … ] }`,含 0 晴、1 多云、2 阴、3 阵雨、4 雷阵雨、5 雷阵雨并伴有冰雹、6 雨夹雪、7 小雨、8 中雨、9 大雨、10 暴雨、11 大暴雨、12 特大暴雨、13 阵雪、14 小雪、15 中雪、16 大雪、17 暴雪、18 雾、19 冻雨、20 沙尘暴、21–28 组合降水、29 浮尘、30 扬沙、31 强沙尘暴、32 飑、33 龙卷风、34 弱高吹雪、35 轻雾、53 霾、301 雨、302 雪、99 未知),查不到返回 `"未知"`(`WeatherService.cs:405-412`、`XiaomiWeatherStatusCodeItem.cs:5-12`、`XiaomiWeatherStatusCodes.cs:5-10`)。
#### 7.14.4 预警与降水提醒的触发、去重
- 展示时机:仅在 `OnClass` 与 `OnBreakingTime` 两个事件(`WeatherNotificationProvider.cs:57-65,116-120`),见 7.4.3。
- 数据侧去重与过滤(`WeatherService.cs:363-388`,必须实现):
1. 用 `Settings.ExcludedWeatherAlerts` 对 `Title` 与 `Detail` 做**包含**匹配过滤,空字符串忽略。
2. 按 `Type` 分组,每组只保留 `PubTime` 最大的一条;被丢弃的旧预警记日志(`"已丢弃旧预警:…"`)。
3. `Alerts` 写回为去重后的集合。
- 提醒侧去重:`ShownAlerts` 以 `Detail` 文本记录「本次会话已展示」,同一 `Detail` 不重复提醒(`WeatherNotificationProvider.cs:141-163`)。
- 降水提醒:由规则 `classisland.weather.rainTime` 判定(语义见 Part 08 §8.8),数据来自 `minutely.precipitation.value` 与 `RainRemainingMinutes`;分钟数组长度由接口决定(默认 120 分钟)【推断:源码未断言长度】。
- 极端天气:源码**没有**独立的「极端天气等级」逻辑;`level` 只用于展示,`IsDefaultIcon` 仅判断是否默认图标。若产品需要「极端天气提醒」,必须用规则组合表达(例如 `hasWeatherAlert` + 标题关键字匹配),不得在天气服务里硬编码等级阈值【推断】。
#### 7.14.5 天气图标主题
- 注册 4 个内置模板(`App.Services.xaml.cs:287-290`):`classisland.weatherIcons.lucide` → `Lucide(默认)`、`classisland.weatherIcons.fluentDesign` → `Fluent Design`、`classisland.weatherIcons.sfSymbols` → `SF Symbols`、`classisland.weatherIcons.simpleText` → `纯文本`。
- 选择与缓存:`Settings.WeatherIconId`(默认 `classisland.weatherIcons.lucide`);服务启动时与 `WeatherIconId` 变化时重建 `SelectedWeatherIconTemplate`;找不到 ID → 记 Warning `"未找到 ID 为 {id} 的天气图标模板"` 并保持上一次模板(`WeatherService.cs:74-91`)。
- 模板注册集合为 `IWeatherService.RegisteredTemplates`(静态 `ObservableCollection<WeatherIconTemplateRegistryInfo>`);注册信息含 `Id/Name/Template`(`WeatherIconTemplateRegistryInfo.cs:10-33`)。
- 渲染:组件与提醒都必须通过 `SelectedWeatherIconTemplate` 渲染天气图标,禁止各自写死图标映射。
#### 7.14.6 断网降级与失败重试
- 断网:`Settings.IsNetworkConnect`(`Settings.cs:134`)存在但 `WeatherService` **不读取**;失败表现为请求异常 → 记日志 → 保留旧数据。UI 必须继续显示 `LastWeatherInfo` 与「更新于」时间,**不得清空**为占位。
- 重试:源码无显式重试,依赖 5 分钟周期;实现建议:失败后 30s/60s/120s 三次退避,之后回到 5 分钟周期;连续失败 ≥3 次在设置页显示「天气获取失败」提示【推断/增强】。
- TLS 降级:`NoTLSWeatherRequests=true` 时用 http;该开关必须默认关闭并在 UI 警示。
- 精度降级:坐标模式下必须把 `IsPrecisionDegraded/DegradedPrecision` 回传并提示用户「已降低定位精度到 N 位小数」。
- 数据损坏:`Settings.LastWeatherInfo` 反序列化失败时必须整体丢弃并重建空对象,不得让应用启动失败【推断】。
#### 7.14.7 服务与消费方分工(禁止重复实现)
| 消费方 | 职责 | 禁止 |
|---|---|---|
| 天气服务 | 请求、解析、缓存、去重、图标模板、定位 | 不得渲染 UI、不得直接发提醒 |
| 天气组件(Part 05) | 读 `LastWeatherInfo` + `SelectedWeatherIconTemplate` 渲染 | 不得发请求、不得改缓存 |
| 天气提醒(7.4.3) | 在 OnClass/OnBreakingTime 读缓存并推送 | 不得发请求 |
| 天气规则(Part 08) | 基于缓存判定布尔条件 | 不得发请求 |
#### 7.14.8 验收标准(天气服务)
1. 首次启动且无网络:`IsWeatherRefreshed=false`,界面显示占位;有缓存时显示缓存与「更新于」,提醒与规则均按 `IsWeatherRefreshed` 跳过。
2. 恢复网络后,最迟 5 分钟(或退避重试命中)内刷新成功,`LastWeatherInfo` 更新且组件/提醒同步变化。
3. 城市模式:修改 `CityId` 后刷新,城市名与新城市一致;非法 `CityId` 时返回失败且不清空旧数据。
4. 坐标模式:把坐标改到精度较低的位置,请求必须按 4→3→2→1→0 降级并回报 `IsPrecisionDegraded`。
5. 预警:同一 `type` 出现两条,只保留 `PubTime` 最新;被 `ExcludedWeatherAlerts` 命中的预警不进入 `Alerts`、不提醒。
6. 图标:4 个模板可切换并立即生效;非法 ID 保持上一次并记 Warning。
7. 降水:构造 `value` 数组「前 N 个 >0 后为 0」时 `RainRemainingMinutes = -N`;「前 N 个为 0 后 >0」时返回 `N`;全 0 返回 0。
8. AQI:41/51/101/151/201/301 分别落到 1/2/3/4/5/6 级。
+499
View File
@@ -0,0 +1,499 @@
## 8. 自动化(触发器 / 行动 / 规则集)
本节规定「自动化(Automation)」系统的完整实现:配置模型、加载与生命周期、触发与恢复语义、全部内置触发器、全部内置行动、规则集求值与全部内置规则、时间点行动,以及设置页 UI 与安全提示。自动化必须建立在第 6 节的课程服务与第 7 节的提醒之上,**不得**为其另建并行状态。
### 8.1 概念与总开关
- **工作流(Workflow)** = 一组触发器(Triggers) + 可选规则集(Ruleset) + 一个行动组(ActionSet)。
- **触发器(Trigger)** = 事件源,触发时发「执行」信号,恢复时发「恢复」信号。
- **行动(Action)** = 可执行单元,具备 `Invoke` 与(可选)`Revert`。
- **规则集(Ruleset)** = 布尔表达式,决定这次触发是否真的执行。
- **自动化配置方案(Automation Config)**:一组工作流存成一个 JSON,位于 `Automations/{方案名}.json`,默认方案名 `Default`(`repos/ClassIsland/ClassIsland/Services/AutomationService.cs:27-33`;`repos/ClassIsland/ClassIsland/Models/Settings.cs:162`:`_currentAutomationConfig = "Default"`)。
- 总开关 `Settings.IsAutomationEnabled`,默认 **`false`**(Settings.cs:160);首次进入设置页必须展示「使用须知」页(`Settings.IsAutomationWarningVisible` 默认 **`true`**,Settings.cs:161),用户点「同意并开始使用自动化」后写入 `IsAutomationEnabled=true` 且 `IsAutomationWarningVisible=false`(`repos/ClassIsland/ClassIsland/Views/SettingPages/AutomationSettingsPage.axaml.cs:161-166`)。
- **安全模式(Safe Mode)下禁止加载与执行自动化**:`AutomationService` 构造函数中 `if (App.ApplicationCommand.Safe) return;` 之后的代码(时间点行动订阅、规则集订阅)都不执行,`LoadConfig` 同样提前返回,即只读配置不装载触发器(AutomationService.cs:76-82,193-201)。实现时必须有等价的 `--safe-mode` 分支。
### 8.2 数据结构(必须逐字段实现)
#### 8.2.1 工作流 Workflow
```
{
"Triggers": [ TriggerSettings, ... ], // 默认 []
"IsConditionEnabled": false, // 是否启用规则集,默认 false
"Ruleset": Ruleset, // 默认 new Ruleset()
"ActionSet": ActionSet // 默认 new ActionSet()
}
```
证据:`repos/ClassIsland/ClassIsland.Core/Models/Automation/Workflow.cs:14-29`。三者都是可观察对象,UI 改动立即反映到模型并触发保存。
#### 8.2.2 触发器设置 TriggerSettings
`{ "Id": string, "Settings": object|null }`(`repos/ClassIsland/ClassIsland.Core/Models/Automation/TriggerSettings.cs:14-35`)。`Id` 变化时必须**卸载旧触发器实例并重新加载**(AutomationService.cs:360-369 的 `TriggerOnPropertyChanged`),因为不同触发器类型的设置类型不同。`AssociatedTriggerInfo` 与 `TriggerInstance` 是 `[JsonIgnore]` 运行期字段。
#### 8.2.3 行动组 ActionSet
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Name` | string | `"新行动组"` | 名称/备注 |
| `Actions` | ActionItem[] | `[]` | 注意 JSON 键名是 **`Actions`**(`[JsonPropertyName("Actions")]`),不是 `ActionItems` |
| `IsEnabled` | bool | `true` | 行动组启用 |
| `IsRevertEnabled` | bool | **`false`** | 是否启用恢复;关闭时若状态是 `IsOn` 立即回 `Normal` |
| `Status` | `Normal/Invoking/IsOn/Reverting` | `Normal` | 运行状态(自定义 JSON 转换器,序列化为可读字符串) |
| `Guid` | Guid | `Guid.NewGuid()` | 用于「设置叠层」标识(见 8.5.5) |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Automation/ActionSet.cs:77-128`;状态枚举 `repos/ClassIsland/ClassIsland.Shared/Enums/ActionSetStatus.cs:6-24`;`IsWorking = Status ∈ {Invoking, Reverting}`(ActionSet.cs:110)。
`ActionSet` 的生命周期方法(必须实现,行动服务会调用):
- `SetStartRunning(isInvoke)`:取消并释放上一次的 `InterruptCts`、把上一次 `RunningTcs` 置为取消;新建二者;`Status = isInvoke ? Invoking : Reverting`;把所有行动项 `IsCompleted=false`。
- `SetEndRunning(isInvoke)`:读取「是否被中断」→ 状态迁移:
- 未中断:`isInvoke && IsRevertEnabled ? IsOn : Normal`;
- 被中断且原状态 `Invoking` → `Normal`;
- 被中断且原状态 `Reverting` → `IsOn`;
- 其他保持。
释放 `InterruptCts`,`RunningTcs.TrySetResult`。
(ActionSet.cs:20-70。这两处状态迁移必须逐字实现,否则「恢复」会错乱。)
#### 8.2.4 行动项 ActionItem
`{ "Id": string, "Settings": object|null }`,运行期字段:`IsCompleted`、`IsWorking`、`Exception`、`Progress`(0~100, null 表示未报告)。`IsRevertEnabled` 恒为 `true`、`IsRevertActionItem` 恒为 `false`(v2 已弃用这两个字段的语义,源码用 `[JsonIgnore]` 固定返回,见 `repos/ClassIsland/ClassIsland.Shared/Models/Automation/ActionItem.cs:54-99`)。实现时必须保留 `Actions` 数组顺序 = 执行顺序。
#### 8.2.5 规则集 Ruleset / 规则组 RuleGroup / 规则 Rule
```
Ruleset {
"Mode": "Or" | "And", // 默认 "Or"
"IsReversed": false, // 取反,默认 false
"Groups": [ RuleGroup ] // 默认 [ { "Rules": [ {} ], "Mode": "And", "IsEnabled": true } ]
}
RuleGroup {
"Rules": [ Rule ], "Mode": "And"|"Or"(默认 And),
"IsReversed": false, "IsEnabled": true
}
Rule {
"Id": "", "Settings": null, "IsReversed": false
}
```
证据:`repos/ClassIsland/ClassIsland.Core/Models/Ruleset/Ruleset.cs:14-17,22-59`、`RuleGroup.cs:13-73`、`Rule.cs:11-56`、`repos/ClassIsland/ClassIsland.Core/Enums/RulesetLogicalMode.cs:6-15`。`State`(0=未判定、1=假、2=真)是 `[JsonIgnore]` 运行期字段,用于 UI 高亮。
### 8.3 加载与生命周期(必须实现资源纪律)
`AutomationService` 的加载规则(`AutomationService.cs:173-202,237-436`):
1. **读配置**:配置不存在 → 用空工作流数组新建并立即保存(`note="新建自动化配置"`)。
2. **安全模式**:直接返回,不装载触发器。
3. 逐个 `LoadWorkflow`:订阅 `Triggers.CollectionChanged`、`ActionSet.PropertyChanged`、`Unloading`;对每个触发器 `LoadTrigger`。
4. **卸载**(`UnloadWorkflow`/`UnloadTrigger`):必须调用触发器的 `UnLoaded()`、退订全部事件、把 `TriggerInstance` 置 null。源码注释明确「否则可能造成内存泄漏」(`repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/TriggerBase.cs:35-39`)。
5. 触发器 `Id` 改变 → 卸载再加载;`Workflows` 集合增删 → 增量加载/卸载。
6. **切换配置方案**:`Settings.CurrentAutomationConfig` 变化时先 `InterruptAllWorkflows()`(对每个工作流 `InterruptActionSetAsync`)再保存旧配置,然后加载新配置(`AutomationService.cs:59-74`)。
7. **关闭自动化总开关**:`IsAutomationEnabled` 变 false 时 `InterruptAllWorkflows()`(71-73)。
8. **行动组状态变化**:`ActionSet.Status` 变化且该组不在运行中时保存配置(`ActionSetOnPropertyChanged`,218-223),保证 `IsOn` 状态跨重启可恢复。
9. 触发器实例化:按 `Id` 从 DI 的键控服务取 `TriggerBase`;若触发器带设置控件且控件基类的泛型参数为设置类型,则把 JSON `JsonElement` 反序列化为该类型实例后注入(`ActivateTrigger`,394-436)。实现时等价于「`actionId → { providerClass, settingsSchema }` 注册表 + 反序列化」。
### 8.4 执行语义(触发 / 规则 / 恢复 / 中断)
#### 8.4.1 触发路径(必须逐步实现,`AutomationService.cs:117-133`)
```
onTrigger(trigger):
if (!Settings.IsAutomationEnabled) return
workflow = trigger.AssociatedWorkflow
if (!workflow.ActionSet.IsEnabled) return
if (workflow.ActionSet.IsRevertEnabled && workflow.ActionSet.Status != Normal) return // 已在运行/已开启则不重复执行
if (workflow.IsConditionEnabled && !RulesetService.IsRulesetSatisfied(workflow.Ruleset)) return
ActionService.InvokeActionSetAsync(workflow.ActionSet)
```
#### 8.4.2 恢复路径(触发器主动恢复,`AutomationService.cs:138-151`)
```
onTriggerRevert(trigger):
if (!Settings.IsAutomationEnabled) return
if (workflow.ActionSet.Status != IsOn) return
ActionService.RevertActionSetAsync(workflow.ActionSet)
```
#### 8.4.3 规则集变化导致的自动恢复(`AutomationService.cs:156-167`)
监听 `RulesetService.StatusUpdated`(由任意规则相关状态变化触发,见 8.4.5):对所有满足 `ActionSet.Status==IsOn && ActionSet.IsRevertEnabled && IsConditionEnabled` 且**当前规则集不再满足**的工作流执行 `RevertActionSetAsync`。
#### 8.4.4 行动执行的顺序与容错(`repos/ClassIsland/ClassIsland/Services/ActionService.cs:20-135`)
`InvokeActionSetAsync(actionSet, isRevertable = true)`:
1. 若状态是 `Reverting` → 先 `InterruptActionSetAsync`。
2. 若 `IsWorking` → 直接返回(**不排队**)。
3. `MigrateActionSet`(迁移旧格式,见 8.5.6)。
4. `SetStartRunning(true)`。
5. 逐项执行 `ActionItems.Where(x => !x.IsRevertActionItem)`,每项 `InvokeActionItemAsync(x, actionSet, isRevertable && actionSet.IsRevertEnabled)`。
6. `finally { SetEndRunning(true) }`。
`RevertActionSetAsync(actionSet)`:
1. 若状态是 `Invoking` → 先中断。
2. 若 `IsWorking` → 返回。
3. `SetStartRunning(false)`。
4. 逐项执行 `ActionItems.Where(x => x.IsRevertActionItem || x.IsRevertEnabled)`:`IsRevertActionItem` 的项**执行其 Invoke**(它是「恢复专用行动项」),`IsRevertEnabled` 的项执行 `RevertActionItemAsync`。
5. `finally { SetEndRunning(false) }`。
`InvokeActionItemAsync` 的守卫(93-112):
- 行动组已被取消(`InterruptCts.Token.IsCancellationRequested != false`)→ 跳过。
- 取不到提供方实例 / 找不到 `ActionInfos[Id]` → 跳过(不打断后续项)。
- `try { await provider.InvokeAsync(...) } catch { LogError }` —— **单个行动失败不得中断整个行动组**。
- `RevertActionItemAsync` 额外要求 `actionInfo.IsRevertable == true`,否则跳过(114-135)。
`ChangeableListForEachAsync`(137-173):执行过程中**允许行动列表被用户实时改动**。实现要求:每次迭代后重新取列表;列表发生变化时按「当前项在新列表中的索引 + 1」继续;当前项被删除则继续执行下一个;被取消则立即停止。这一步不能简单用 `for (i...)`,否则用户删除项会导致跳项或越界。
`InterruptActionSetAsync`(77-91):`await InterruptCts.CancelAsync()`,再 `await RunningTcs.Task`(等运行真正退出),异常只记日志。
**行动提供方的运行契约**(`repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/ActionBase.cs:20-148`):
- `OnInvoke()` / `OnRevert()` 必须先 `await base.OnInvoke()` / `base.OnRevert()`。
- 执行期间由框架设置 `ActionItem`、`ActionSet`、`IsRevertable`、`SettingsInternal`、`InterruptCts`;`SetStartRunning()` 清空异常与进度并置 `IsWorking=true`,`SetEndRunning()` 置 `IsCompleted=true`。
- 可长时运行的行动必须用 `InterruptCancellationToken` 响应中断,并实现 `OnInterrupted()`(或等价取消)。
- 行动抛出的异常会被记录到 `ActionItem.Exception`(供 UI 显示)并继续向外抛,由 `ActionService` 捕获记日志。
- 行动结束(含异常)后 `Progress` 必须清空。
#### 8.4.5 规则状态何时通知
任何影响规则结果的状态变化都必须调用 `NotifyStatusChanged()` 触发 `RulesetService.StatusUpdated`。源码中的触发点:课程服务(`CurrentTimeStateChanged` 与 `CurrentSubject` 变化,`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:238,268-273`)、天气刷新完成(`WeatherService.cs:399`)、前台窗口变化(`WindowRuleService.cs:31`)。实现时必须覆盖「时间流逝导致的规则变化」(例如每 tick 比较当前课表/科目/时间状态,变化才通知),否则「规则不再满足时恢复行动」不会及时生效。
### 8.5 内置行动(逐个实现,共 8 个)
注册表证据:`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:269-276`。
#### 8.5.1 广播信号 `classisland.broadcastSignal`
- 名称「广播信号」,图标 `\uE561`,设置类型 `SignalTriggerSettings`。
- 设置:`{ "SignalName": "", "IsRevert": false }`(`repos/ClassIsland/ClassIsland\Models\Automation\Triggers\SignalTriggerSettings.cs:7-31`)。
- 行为:发信号 `SignalName`;`IsRevert=false` 时发「执行信号」(接收方 `Trigger`),`IsRevert=true` 时发「恢复信号」(接收方 `TriggerRevert`)。恢复时**取反**发信号(`SignalTriggerHandlerService.cs:17-33`)。
- 边界:`SignalName` 为空时仍会广播空名信号,等于无效,建议 UI 校验非空。
#### 8.5.2 运行 `classisland.os.run`
- 名称「运行」,图标 `\uec2e`,`addDefaultToMenu: false`(菜单项由菜单树手工提供)。
- 设置:`{ "RunType": "Application|Command|File|Folder|Url", "Value": "", "Args": "" }`,`RunType` 用字符串枚举序列化(`repos/ClassIsland/ClassIsland/Models/Actions/RunActionSettings.cs:10-49`)。
- 行为(`repos/ClassIsland/ClassIsland/Services/Automation/Actions/RunAction.cs:15-78`):
| RunType | 行为 |
|---|---|
| `Application` | `Process.Start({ FileName = Value, Arguments = Args, UseShellExecute = true })` |
| `File` / `Folder` | `Process.Start({ FileName = Value, UseShellExecute = true })`(不支持 Args) |
| `Url` | 若 Value 非空白且不含 `:` 且不以 `\` 开头 → 自动补 `https://`;Windows 直接 `Process.Start(UseShellExecute)`,Linux `xdg-open`,macOS `open` |
| `Command` | 用 `cmd.exe /c "{command}"`(Windows)或 `/bin/bash -c "{command}"`(其他)执行;`CreateNoWindow=true`、重定向 stdout/stderr、**最大捕获 2000 字符**,超出追加 `"…(已截断)"` |
- `Command` 的失败语义:非 0 退出码 → 抛 `InvalidOperationException("命令执行失败 (退出代码: N)。\n标准输出:…\n错误输出:…")`;被中断 → 杀整棵进程树(`Kill(entireProcessTree: true)`)并抛「命令执行被中断」异常(含已捕获输出)。UI 必须能把该异常文本展示出来。
#### 8.5.3 显示提醒 `classisland.showNotification`
- 名称「显示提醒」,图标 `\ue02b`,`addDefaultToMenu: false`。
- 设置默认值(`repos/ClassIsland/ClassIsland/Models/Actions/NotificationActionSettings.cs:7-18`):
| 字段 | 默认 |
|---|---|
| `Content` | `""` |
| `Mask` | `""` |
| `IsContentSpeechEnabled` | `true` |
| `IsMaskSpeechEnabled` | `true` |
| `IsSoundEffectEnabled` | `true` |
| `IsTopmostEnabled` | `true` |
| `CustomSoundEffectPath` | `""` |
| `MaskDurationSeconds` | `5.0` |
| `ContentDurationSeconds` | `10.0` |
| `IsEffectEnabled` | `true` |
| `IsAdvancedSettingsEnabled` | **`false`** |
| `IsWaitForCompleteEnabled` | **`false`** |
- 行为(`NotificationAction.cs:23-108`):构造 `NotificationRequest`——
- `MaskContent = CreateTwoIconsMask(Mask, hasRightIcon:false, factory: 时长=MaskDurationSeconds, IsSpeechEnabled=IsMaskSpeechEnabled)`;
- `OverlayContent`:`Content` 为空或 `ContentDurationSeconds<=0` → `null`,否则 `CreateSimpleTextContent(Content, 时长=ContentDurationSeconds, 语种开关=IsContentSpeechEnabled)`;
- `RequestNotificationSettings = { IsSettingsEnabled = IsAdvancedSettingsEnabled, IsNotificationEffectEnabled = IsEffectEnabled, IsNotificationSoundEnabled = IsSoundEffectEnabled, IsNotificationTopmostEnabled = IsTopmostEnabled, NotificationSoundPath = CustomSoundEffectPath, IsSpeechEnabled = true }`。
- 通过「行动提醒」提供者(ActionNotificationProvider)发送。
- `IsWaitForCompleteEnabled=true` 时**等待提醒播放完成**再继续执行后续行动;等待期间若用户把该开关改为 false 必须立即解除等待(37-57);行动被中断时必须取消提醒(61-69)。
- 菜单提供两项:`"显示提醒…"`(等待=false)、`"显示提醒并等待…"`(等待=true)(`ActionService.cs:238-241`)。
#### 8.5.4 显示天气提醒 `classisland.notification.weather`
- 名称「显示天气提醒」,图标 `\uf44f`,`addDefaultToMenu: false`。
- 设置:`{ "NotificationKind": 0 }`(`Models/Actions/WeatherNotificationActionSettings.cs:7`)。
- `NotificationKind`:0=三天天气预报(`ShowWeatherForecastCore`)、1=气象预警(`ShowAlertsNotificationCore`)、2=逐小时预报(`ShowWeatherForecastHourlyCore`);必须在 UI 线程调用(`WeatherNotificationAction.cs:19-37`)。
- 菜单提供三项:`"三天天气预报"`、`"气象预警"`、`"逐小时天气预报"`(ActionService.cs:242-247)。
#### 8.5.5 应用设置 `classisland.settings`
- 名称「应用设置」,图标 `\uef27`,`addDefaultToMenu: false`。
- 设置:`{ "Name": "", "Value": null, "Mode": 0 }`(`Models/Actions/ModifyAppSettingsActionSettings.cs:10-40`)。
- 行为(`ModifyAppSettingsAction.cs:24-61`):
1. `Name` 为空 → 直接返回(不报错)。
2. 通过 `SettingsService.GetPropertyInfoByName(Name)` 反射取属性;找不到 → 抛 `KeyNotFoundException("应用设置中找不到属性“{Name}”。")`。
3. `Value == null` → 抛 `ArgumentNullException`。
4. 值转换 `ConvertToAssignableToSettingsType`:支持 `string/int/double/bool/Color` 与枚举直接转换;其余类型走 JSON 反序列化(容忍注释、尾逗号、大小写、数字字符串、枚举字符串)。若转换结果为 null → 抛异常。
5. **在 UI 线程执行**:若 `IsRevertable` 为真(行动组启用了恢复)→ `SettingsService.AddSettingsOverlay(ActionSet.Guid, Name, value)`(叠层,不覆盖原值);否则直接 `propertyInfo.SetValue`。
6. `OnRevert`:在 UI 线程 `RemoveSettingsOverlay(ActionSet.Guid, Name)`。
- 语义要求:叠层机制必须实现为「按行动组 GUID 分层覆盖」,读取设置值时取最上层有效值;行动组恢复后底层值自动复原。这是「临时改主题/改窗口位置,恢复后回到原值」的关键。
#### 8.5.6 等待时长 `classisland.action.sleep`
- 名称「等待时长」,图标 `\ue9a8`。
- 设置:`{ "Value": 5 }`(秒,double;`Models/Actions/SleepActionSettings.cs:6-16`)。
- 行为(`SleepAction.cs:16-68`):循环等待直到 `Value*1000` 毫秒;进度 0~100 实时上报(首次为 `10/Value`,之后为 `elapsed*100/target`);每段 `Task.Delay` 最多 1000ms 以保证 1 秒内响应中断与设置变更;`Value` 变化时立即按新目标重算进度;被取消则静默退出。
#### 8.5.7 退出 `classisland.app.quit` / 重启 `classisland.app.restart`
- 退出:「退出 ClassIsland」,图标 `\uE0DF`,无设置;行为 `AppBase.Current.Stop()`(`AppQuitAction.cs:7-13`)。
- 重启:「重启 ClassIsland」,图标 `\ue0bd`;设置 `{ "Value": false }`(`Value=true` 表示重启到安全模式【推断:源码只透传给 `AppBase.Current.Restart(Settings.Value)`,参数含义未在本文件注明】);行为 `AppBase.Current.Restart(Settings.Value)`(`AppRestartAction.cs:8-15`、`AppRestartActionSettings.cs:5-16`)。
- 两者 `addDefaultToMenu: false`;菜单项在「ClassIsland」组内手工提供(ActionService.cs:250-254)。
#### 8.5.8 迁移(必须实现,保证旧配置可用)
`MigrateActionItem` 必须把旧式「应用设置」行动 `classisland.settings.{PropertyName}` 迁移为新行动(`ActionService.cs:179-203`):
1. `Id` 以 `classisland.settings.` 开头才迁移。
2. 取后缀,首字母大写 → 新 `ModifyAppSettingsActionSettings.Name`。
3. `Id = "classisland.settings"`。
4. 若旧 `Settings` 是 JSON 且有 `Value` 属性 → 写入 `settings.Value = value.ToString()`。
`MigrateUnknownActionItem` 在 `ActionBase.GetInstance` 取不到提供方时被调用(ActionBase.cs:118-127),因此**未知 ID 必须先尝试迁移再判定失败**。
### 8.6 内置触发器(逐个实现,共 12 个)
注册表:`App.Services.xaml.cs:240-251`。触发器基类契约:`Loaded()` 订阅资源、`UnLoaded()` 退订;`Trigger()`/`TriggerRevert()` 由子类调用(`TriggerBase.cs:16-48`)。
| # | ID | 名称 | 图标 | 设置 | 触发条件 |
|---|---|---|---|---|---|
| 1 | `classisland.lifetime.startup` | 应用启动时 | `\ue067` | 无 | `Loaded()` 时若应用生命周期 `< Running` 立即 `Trigger()` |
| 2 | `classisland.lifetime.stopping` | 应用退出时 | `\ue0df` | 无 | 订阅 `AppStopping` 事件 |
| 3 | `classisland.lessons.onClass` | 上课时 | `\uE47A` | 无 | 订阅 `ILessonsService.OnClass` |
| 4 | `classisland.lessons.onBreakingTime` | 课间休息时 | `\ue4c4` | 无 | 订阅 `OnBreakingTime` |
| 5 | `classisland.lessons.onAfterSchool` | 放学时 | `\ued35` | 无 | 订阅 `OnAfterSchool` |
| 6 | `classisland.lessons.currentTimeStateChanged` | 当前时间状态变化时 | `\ue4d2` | 无 | 订阅 `CurrentTimeStateChanged` |
| 7 | `classisland.lessons.preTimePoint` | 特定时间点前 | `\ue4c6` | 见下 | 每 tick 计算「目标时刻 - 提前量」是否被跨过 |
| 8 | `classisland.cron` | cron | `\ue125` | 见下 | cron 表达式到点 |
| 9 | `classisland.signal` | 收到信号时 | `\ue40e` | 见下 | 收到同名信号 |
| 10 | `classisland.trayMenu` | 从托盘菜单运行时 | `\ue075` | 见下 | 托盘子菜单项被点击 |
| 11 | `classisland.uri` | 调用 Uri 时 | `\ueab0` | 见下 | 应用被特定 URI 调起 |
| 12 | `classisland.ruleSet.rulesetChanged` | 规则集更新时 | `\uf17e` | 无 | 订阅 `IRulesetService.StatusUpdated` |
证据:`repos/ClassIsland/ClassIsland/Services/Automation/Triggers/` 下各文件(AppStartupTrigger.cs:10-23、AppStoppingTrigger.cs:8-25、OnClassTrigger.cs:8-26、OnBreakingTimeTrigger.cs:8-26、OnAfterSchoolTrigger.cs:8-26、CurrentTimeStateChangedTrigger.cs:8-26、PreTimePointTrigger.cs:13-83、CronTrigger.cs:15-84、SignalTrigger.cs:8-37、TrayMenuTrigger.cs:11-52、UriTrigger.cs:8-40、RulesetChangedTrigger.cs:8-27)。
#### 8.6.1 特定时间点前 `classisland.lessons.preTimePoint`
设置(`Models/Automation/Triggers/PreTimePointTriggerSettings.cs:7-12`):
- `TargetState`:`0=None,1=OnClass,2=PrepareOnClass,3=Breaking,4=AfterSchool`,默认 **`OnClass`(1)**。
- `TimeSeconds`:提前秒数,默认 **`60`**,可为负数(负数直接不触发)。
算法(`PreTimePointTrigger.cs:33-82`,必须逐条实现):
1. `Loaded()` 时记录 `LastCheckTime = now`,订阅 `PostMainTimerTicked`。
2. 每 tick:若 `CurrentState == TargetState` → `TriggerRevert()` 并结束(**状态已到,恢复**)。
3. `TimeSeconds < 0` → 返回。
4. 求目标时刻 `targetStartTime`:
- `TargetState == AfterSchool`:取有效时间点中最后一个 `TimeType∈{0,1}` 的 `EndTime`;无 → 返回。
- `OnClass`:`NextClassTimeLayoutItem.StartTime`;`Breaking`:`NextBreakingTimeLayoutItem.StartTime`;其他 → `TimeLayoutItem.Empty`;为空 → 返回。
5. `targetTime = targetStartTime - TimeSeconds` 秒,拼成今天的时刻。
6. **跨点判定**:`LastCheckTime < targetTime && targetTime <= now` 才 `Trigger()`(保证只触发一次,且应用在跨点后才启动时不会误触发)。
7. `finally { LastCheckTime = now }`。
#### 8.6.2 cron `classisland.cron`
设置:`{ "CronExpression": "* * * * *" }`(`CronTriggerSettings.cs:7`)。行为(`CronTrigger.cs:25-84`):
1. `Loaded()` 时订阅设置变化并 `LoadCron()`。
2. `LoadCron()`:取消旧 worker → 解析表达式 → 记日志「cron 表达式解析成功,下次触发时间:…」→ 起一个 LongRunning 后台任务。
3. Worker 循环:`sleep = crontab.GetSleepTimeSpan(now)`;`await Task.Delay(sleep, token)`;到点在 UI 线程 `Trigger()`;直到被取消。
4. 解析失败(`TimeCrontabException`)→ 记 Warning 并停止该触发器,不崩溃。
5. `UnLoaded()` → 退订 + 取消 worker。
#### 8.6.3 收到信号时 `classisland.signal`
设置:`{ "SignalName": "", "IsRevert": false }`。行为(`SignalTrigger.cs:24-36`):收到信号事件时,名称不等于 `SignalName` 直接忽略;`e.Revert` 为真 → `TriggerRevert()`,否则 `Trigger()`。信号总线由 `SignalTriggerHandlerService` 提供,它与「广播信号」行动双向绑定(同名信号即可联动)。
#### 8.6.4 从托盘菜单运行时 `classisland.trayMenu`
设置:`{ "Header": "", "IsRevert": false }`(`TrayMenuTriggerSettings.cs:7-8`)。行为(`TrayMenuTrigger.cs:20-51`):
1. 每个触发器实例创建一个 `NativeMenuItem`,文本 = `Settings.Header`。
2. 挂到托盘图标的「更多选项」子菜单 `"运行工作流"`(图标 `\uedbf`)下;子菜单为空时显示禁用项 `"(空)"`(`TrayMenuTriggerHandlerService.cs:15-62`)。
3. `Header` 变化必须实时刷新菜单文本。
4. 点击:`IsRevert` 为真 → `TriggerRevert()`,否则 `Trigger()`。
5. 卸载时移除菜单项。
#### 8.6.5 调用 Uri 时 `classisland.uri`
设置:`{ "UriSuffix": "" }`(`UriTriggerSettings.cs:7`)。行为(`UriTrigger.cs:13-39`、`UriTriggerHandlerService.cs:14-27`):注册两个 URI 路由 `{AppScheme}://api/automation/run/...` 与 `.../revert/...`;把路由剩余路径段用 `/` 连接后与 `UriSuffix` 比较,相等则分别 `Trigger()` / `TriggerRevert()`。即:用户可通过协议链接直接触发某条自动化。
#### 8.6.6 规则集更新时 `classisland.ruleSet.rulesetChanged`
订阅 `IRulesetService.StatusUpdated` 即 `Trigger()`(不区分是哪个规则集),因此**必须配合规则集使用**,否则会在任意规则状态变化时反复触发。
### 8.7 规则集求值(算法必须逐字实现)
求值入口 `IsRulesetSatisfied(ruleset)`(`repos/ClassIsland/ClassIsland/Services/RulesetService.cs:109-147`):
1. `isSatisfied = (ruleset.Mode == And)`(And 初始 true,Or 初始 false)。
2. `Groups.Count <= 0` → `State=0` 并返回 **false**。
3. 先复位所有 Group/Rule 的 `State = 0`。
4. 只遍历 `IsEnabled` 的 Group:`res = IsRulesetGroupSatisfied(group)`;`group.State = BoolToRuleObjectState(res)`;`res == null` → 跳过。
5. And 模式下遇到 false 立即 break;Or 模式下遇到 true 立即 break。
6. `isSatisfied ^= ruleset.IsReversed`;`ruleset.State = 状态`;返回。
`IsRulesetGroupSatisfied`(74-107):
1. `groupSatisfied = (group.Mode == And)`。
2. 组内 `Id != ""` 的规则数 ≤ 0 → 返回 **`null`**(三值:未判定)。
3. 逐条 `res = IsRuleSatisfied(rule)`;`null` → 记 State 0 并 continue。
4. `result = (bool)res ^ rule.IsReversed`;`rule.State = result ? 2 : 1`。
5. And 遇 false / Or 遇 true 立即 break。
6. `groupSatisfied ^= group.IsReversed`;返回。
`IsRuleSatisfied`(36-72):
1. `Id == ""` → `null`。
2. 规则 ID 未注册 → 记 Warning `"找不到规则 {id} 的注册信息,已默认其结果为 false."`,返回 **false**(不是 null)。
3. 若该规则有设置类型:`settings = rule.Settings ?? Activator.CreateInstance(settingsType)`;若是 `JsonElement` 则反序列化。
4. 无处理程序 → 记 Warning 并返回 false。
5. 处理程序抛异常 → 记 Warning `"规则 {id} 的处理程序在处理过程中发生异常,已默认其结果为 false."` 并返回 false。
`BoolToRuleObjectState`:`true→2`、`false→1`、`null→0`(29-34)。**必须实现三值状态**,否则 UI 无法区分「不满足」与「未判定」。
### 8.8 内置规则(逐个实现,共 15 个)
注册表:`App.Services.xaml.cs:253-267`。名称/图标/设置类型必须照抄:
| # | ID | 名称 | 图标 | 设置类型与默认值 | 判定 |
|---|---|---|---|---|---|
| 1 | `classisland.test.true` | 总是为真 | — | 无 | 恒 true(测试用) |
| 2 | `classisland.test.false` | 总是为假 | — | 无 | 恒 false(测试用) |
| 3 | `classisland.windows.className` | 前台窗口类名 | `\uF4A2` | `StringMatchingSettings { Text:"", UseRegex:false }` | 匹配前台窗口类名 |
| 4 | `classisland.windows.text` | 前台窗口标题 | `\uF26B` | 同上 | 匹配前台窗口标题 |
| 5 | `classisland.windows.status` | 前台窗口状态是 | `\uEC83` | `WindowStatusRuleSettings { State:1 }` | 见下 |
| 6 | `classisland.windows.processName` | 前台窗口进程 | `\uF488` | `StringMatchingSettings` | 匹配前台窗口进程名 |
| 7 | `classisland.lessons.currentSubject` | 科目是 | `\uE215` | `CurrentSubjectRuleSettings { SubjectId: Guid.Empty }` | 当前科目 == 指定科目 |
| 8 | `classisland.lessons.nextSubject` | 下节课科目是 | `\uE217` | 同上 | 下节课科目 == 指定科目 |
| 9 | `classisland.lessons.previousSubject` | 上节课科目是 | `\uE226` | 同上 | 上一节课科目 == 指定科目 |
| 10 | `classisland.lessons.timeState` | 当前时间状态是 | `\uE4C4` | `TimeStateRuleSettings { State:1(OnClass) }` | 见下 |
| 11 | `classisland.weather.currentWeather` | 当前天气是 | `\uE4DC` | `CurrentWeatherRuleSettings { WeatherId:0, IsFuzzyMatch:false }` | 见下 |
| 12 | `classisland.weather.tomorrowWeather` | 明天天气是 | `\uE4DC` | 同上 | 见下 |
| 13 | `classisland.weather.hasWeatherAlert` | 存在气象预警 | `\uF431` | `StringMatchingSettings` | 匹配任一预警标题 |
| 14 | `classisland.weather.rainTime` | 距离降水开始/结束还剩 | `\uF43F` | `RainTimeRuleSettings { RainTimeMinutes:60, IsRemainingTime:false }` | 见下 |
| 15 | `classisland.weather.sunRiseSet` | 是否日出/日落 | `\uE150` | `SunRiseSetRuleSettings { TimeMinutes:60, IsSunset:false }` | 见下 |
**字符串匹配规则的语义**(`repos/ClassIsland/ClassIsland.Core/Models/Ruleset/StringMatchingSettings.cs:46-60`,必须精确):`UseRegex=false` 时用**完全相等** `str == Text`(不是包含);`UseRegex=true` 时用 `Regex.Match(str, Text).Success`,正则非法时返回 false。因此实现时文本框旁必须提供「使用正则」开关,并在正则非法时给出提示。
**窗口状态规则**(`Settings.State` 默认 **1**):0=正常、1=最大化、2=最小化、3=全屏(`WindowStatusRuleSettings.cs:9-18`)。判定实现(`WindowRuleService.cs:61-87`):先取主界面所在屏幕,分别判断「前台窗口是否全屏(该屏)」「是否最大化(该屏)」「是否最小化」:
- 0 正常 → `!(fullscreen || maximize || minimize)`
- 1 最大化 → `maximize && !fullscreen`
- 2 最小化 → `minimize`
- 3 全屏 → `fullscreen`
**科目规则**(`LessonsService.cs:295-360`):
- `currentSubject`:`Subjects[SubjectId]` 存在且 `CurrentSubject == 该科目`。
- `nextSubject`:`NextClassSubject == 该科目`。
- `previousSubject`:取当前时间表中「最后一个 `TimeType==0 && EndTime < now`」的时间点,按上课序号映射到课程并取科目,比较。若找不到上一节课或科目缺失 → false。
- 科目 Guid 不在档案中 → 一律 false(不得抛异常)。
**时间状态规则**(`LessonsService.cs:362-371`):`CurrentState == State`,或(`CurrentState == AfterSchool && State == None`)也算真。这是为了让「状态为无」的规则在放学后仍成立。
**天气规则**(`WeatherService.cs:93-221`):
- 所有天气规则都要求 `IsWeatherRefreshed == true`,否则 false。
- `currentWeather`:精确模式比字符串 `WeatherId == Current.Weather`;模糊模式用天气码集合交集(见下)。
- `tomorrowWeather`:需要 `ForecastDaily.Weather` 至少 2 天;精确模式比第 2 天的 `From` 或 `To`;模糊模式对二者分别判交集。
- 天气码扩展表(`ExpandWeatherCode`,205-221,必须照抄):
| 码 | 等价集合 |
|---|---|
| `21` | `7,8,21` |
| `22` | `8,9,22` |
| `23` | `9,10,23` |
| `24` | `10,11,24` |
| `25` | `11,12,25` |
| `26` | `14,15,26` |
| `27` | `15,16,27` |
| `28` | `16,17,28` |
| `301`(雨夹雪等) | `3,4,5,6,7,8,9,10,11,12,19,21,22,23,24,25,301` |
| `302` | `6,13,14,15,16,17,26,27,28,34,302` |
| 其他 | 仅自身 |
- `hasWeatherAlert`:任一预警 `Title` 匹配(同上字符串匹配语义)。
- `rainTime`:`baseTime = (IsRemainingTime ? -1 : 1) * LastWeatherInfo.Minutely.Precipitation.RainRemainingMinutes`;判定 `baseTime > 0 && baseTime <= RainTimeMinutes`。即:`IsRemainingTime=false` 表示「距降水开始还有 N 分钟内」;`true` 表示「距降水结束还剩 N 分钟内」。
- `sunRiseSet`:解析 `ForecastDaily.SunRiseSet` 中日期与今天相同的 `From`(日出)/`To`(日落);`IsSunset=false` → `now >= sunrise && now < sunset`(白天);`true` → `now >= sunset || now < sunrise`(夜间)。解析失败 → false。
### 8.9 时间点行动(TimeType = 3)
与「自动化」并列的第二条执行通道,**必须实现**(`AutomationService.cs:84-108`):
1. 仅在 `ProfileService.IsCurrentProfileTrusted == true` 时执行(未信任档案不执行时间点行动)。
2. 每个 `PostMainTimerTicked`:从当前课表时间表中筛出 `TimeType == 3` 且 `StartTime > LastActionRunTime.TimeOfDay && StartTime <= now.TimeOfDay` 的时间点(即「上次检查之后、本次检查之前」被跨过的行动时间点)。
3. 更新 `LastActionRunTime = now`(无论是否命中,避免重复)。
4. 对每个命中的时间点,若 `ActionSet != null` → `ActionService.InvokeActionSetAsync(actionSet, isRevertable: false)`(时间点行动**不可恢复**)。
5. `LastActionRunTime` 初始化为自动化服务启动时刻(79 行),因此启动前的时间点不会被补触发。
6. 每个时间点的 `ActionSet` 在时间表编辑器中编辑(见第 6 节,新增「行动」时间点时自动创建空 ActionSet)。
7. 时间点行动与「附加设置」互不影响:`TimeLayoutItem.AttachedObjects`(附加设置模型见 06 §6.10)不参与时间点行动的执行判定;反之行动也不会修改附加设置。两者只共享同一个 `TimeLayoutItem` 对象。
8. 时间点行动不受 `Settings.IsAutomationEnabled` 控制(该开关只控制「自动化工作流」通道),但受档案信任状态控制;安全模式下不执行。
### 8.10 设置页 UI(自动化)
页面 `AutomationSettingsPage`:`[SettingsPageInfo("automation", "自动化", "\ueEF1", "\ueEF0", Internal)]`、`[FullWidthPage]`(`Views/SettingPages/AutomationSettingsPage.axaml.cs:27-29`)。布局为「列表 + 详情」双栏(`ListDetailView`),必须实现:
1. **首次使用须知**(`Settings.IsAutomationWarningVisible == true` 时整页替换为该内容,`AutomationSettingsPage.axaml:333-400`):
- 顶部图标行:`Grid ColumnDefinitions="75,Auto,75"`,左 `/Assets/AppLogo.png`,中间 `FluentIcon \uE00C` 25×25(`Margin=16 0`),右 `FluentIcon \uEEF1` 75×75。
- 标题 `"自动化"`(Subtitle 样式,上边距 8)。
- 正文(行距 4):`" 欢迎使用自动化!\n 自动化可以使 ClassIsland 根据条件自动作出行动,\n 比如修改应用设置、打开应用,等等。"`。
- 链接按钮 `"阅读自动化帮助"`(透明按钮,图标 `\uEC2D`,指向 `https://docs.classisland.tech/app/automation.html`)。
- 警告块:`Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}" CornerRadius=4 Padding="10 10 10 14"`,内含图标 `\uF430` + 文本 `"警告"`,正文 `"自动化允许自动修改 ClassIsland 的各项设置,并调用\n 外部程序,可能有一定安全风险。\n 不当的自动化编写可能对正常教学造成影响,请勿滥用。"`。
- 底部 Accent 按钮 `"同意并开始使用自动化"`(图标 `\uE424`)。
2. **主界面**:
- 左上 `SettingsExpander`:`Header="启用自动化"`、`Description="自动化是一种可使 ClassIsland 自动作出行动的功能。"`,右侧「帮助…」按钮(图标 `\uE23C`)+ `ToggleSwitch` 绑定 `IsAutomationEnabled`(25-49)。
- 工具行:`"添加自动化"`(图标 `\uE00D`,计数为 0 时用强调色)、右侧配置方案 `ComboBox`(`MinWidth=120 MaxWidth=150`,`ItemsSource=AutomationService.Configs`,下拉展开时刷新列表)、`"新建…"`(`\uE689`)、`"打开…"`(`\uE88D`,打开配置文件夹)(50-85)。
- 左侧工作流列表:每项 `Height=42`,`Grid ColumnDefinitions="Auto 5* 2* Auto"`——左侧拖动柄(`TouchDragThumb Width=20`)、标题 `ActionSet.Name`(`FontSize=13`、`FontWeight=Medium`、可换行)、进度指示器(`WorkflowProgressIndicatorControl`,`Opacity 0→1` 过渡 100ms,`Tag=ActionSet.IsWorking`)、启用开关 `ToggleSwitch` 绑定 `ActionSet.IsEnabled`(110-155)。列表必须支持垂直拖动排序(`AdvancedItemDragBehavior`,垂直阈值 3px)。
3. **右侧详情**(`SelectedWorkflow != null` 时显示,197-296):
- 标题行:名称 `TextBox`(`MinWidth=150`,`Watermark="自动化名称"`)、`CheckBox "启用恢复"`(`ToolTip="该自动化是否启用恢复。"`)、`ActionSetRunningControl`(运行/中断按钮,由 Core 控件提供)、`"复制"`(图标 `\uE58B`,ToolTip `"创建该自动化的副本。"`)、`"删除"`(图标 `\uE61D`,前景 `IndianRed`,ToolTip `"删除该自动化。"`);三者之间用 `Separator Width=1 Height=25 Margin=4 0` 分隔。
- `Expander "当事件触发时"`(图标 `\uE84F`)→ `TriggerSettingsControl`,绑定 `Triggers`。
- `Expander "并且满足规则集时"`(图标 `\uF17E`)头部右侧为 `ToggleSwitch` 绑定 `IsConditionEnabled`;未启用时显示灰字 `"工作流运行时不会考虑规则集。"`,启用时显示 `RulesetControl`(`ShowTitle=False`)(249-271)。
- `Expander "触发行动"`(图标 `\uE01F`)→ `ActionControl`,绑定 `ActionSet`(272-279)。
- 若 `ActionSet.IsRevertEnabled`,行动编辑下方显示提示(图标 `\uE01D`)`"当规则集不再满足时,将自动恢复行动。"`(290-293)。
- 未选择工作流时显示空态:75×75 贴纸(`avares://ClassIsland/Assets/HoYoStickers/光辉矢愿_瞄准.png`,加载失败回退 `FluentIcon \uEEF1`,`FontSize=75`)+ 文案 `"在左侧选择或添加一个自动化,然后在此处进行设置。"` + 帮助链接(299-317)。
4. **删除必须可撤销**:删除工作流后弹 `ToastMessage`,文案 `"已删除自动化“{名称}”。"`,`Duration=10 秒`,带「撤销」按钮;撤销时把工作流插回原索引并重新选中(`AutomationSettingsPage.axaml.cs:39-67`)。
5. **复制工作流**:JSON 深拷贝后 `ActionSet.Guid = Guid.NewGuid()`,插入到原项之后并选中(69-78)。
6. **新建工作流**:`ActionSet = { Name: "新自动化", IsRevertEnabled: true }`(注意:新建默认**开启恢复**),插入到当前选中项之后并展开详情(117-133)。
7. **新建配置方案**:`ContentDialog` 标题 `"新建自动化配置方案"`,输入框标签 `"配置方案名称"`,后缀 `.json`;路径已存在则中止;创建后刷新列表并把 `CurrentAutomationConfig` 设为新名称(87-115)。
### 8.11 边界与异常(必须处理)
1. **总开关关闭**:所有触发路径第一行判断,直接返回;不装载触发器(安全模式同理)。
2. **行动组未启用**:触发时跳过。
3. **恢复开关与重复触发**:`IsRevertEnabled && Status != Normal` 时不再执行,防止重复叠加(例如反复「修改设置」)。
4. **规则集未启用**:`IsConditionEnabled=false` → 忽略规则集,永远执行。
5. **规则集为空**(`Groups.Count == 0`)→ 视为 false;组内无有效规则 → 三值 `null`(不参与逻辑)。
6. **未注册规则 / 处理程序异常** → false + Warning 日志,绝不抛出。
7. **未知触发器 ID**:实例化为 null,触发器被静默跳过(`LoadTrigger` 347-350 返回),配置保留不丢。
8. **未知行动 ID**:先迁移,仍未知则跳过(`InvokeActionItemAsync` 96-98)。
9. **单个行动异常**:记日志并继续后续行动;异常文本写进 `ActionItem.Exception` 供界面展示。
10. **长时行动的取消**:`Wait/Sleep/命令执行` 必须响应 `InterruptCancellationToken`;命令被中断要杀进程树。
11. **用户在执行过程中编辑行动列表**:按 8.4.4 的可变列表迭代算法处理。
12. **重启后状态恢复**:`ActionSet.Status == IsOn` 必须落盘(8.3 第 8 条),重启后仍可被规则集变化触发「恢复」。
13. **时间点行动重复执行**:靠 `LastActionRunTime` 单调推进避免;应用时间被大幅回拨时不得重复执行历史时间点(`StartTime > LastActionRunTime.TimeOfDay` 保证)。
14. **持久化失败**:配置写盘失败必须保留内存状态并在 UI 提示,不得因保存失败导致触发器被卸载。
### 8.12 验收标准
1. 首次进入自动化设置页看到「使用须知」,点「同意并开始使用自动化」后 `IsAutomationEnabled=true`、`IsAutomationWarningVisible=false`,刷新页面后不再出现。
2. 新建自动化默认名称 `"新自动化"` 且「启用恢复」为**勾选**状态;复制出来的自动化 `ActionSet.Guid` 与原项不同。
3. 建一条「上课时 → 显示提醒」的工作流:到上课时刻必须弹出提醒;把「启用恢复」打开并加规则集(例如「当前时间状态是 课间」),当状态变为课间时行动必须被恢复(若行动可恢复)。
4. 12 个触发器按本节表格的 ID/名称/图标出现在「添加触发器」列表中,且参数默认值一致(`preTimePoint.TimeSeconds=60`、`cron="* * * * *"`、`trayMenu.Header=""` 等)。
5. 8 个行动按本节表格出现在「添加行动」菜单中;「添加行动」菜单必须含分组「应用设置 / 运行 / 提醒 / ClassIsland」,且「应用设置」组只有 4 个快捷项(选择应用设置、组件配置方案、应用主题、窗口停靠位置)。
6. 运行「cmd 命令」失败时,`ActionItem.Exception` 包含退出码与标准输出/错误输出(各截断到 2000 字符)。
7. 「等待时长」行动的进度条在 0~100 之间单调变化,改设置立即按新目标重算。
8. 规则集:And/Or 与三层反转(规则、组、规则集)组合 8 种情况结果正确;空规则集返回 false;未注册规则返回 false 且只记一条 Warning。
9. 天气模糊匹配:`21` 与 `7` 判定为匹配(交集),`21` 与 `301` 判定为匹配(`301` 集合含 `21`),`21` 与 `302` 不匹配。
10. 时间点行动:把某时间点类型设为「行动」并挂行动组后,到点执行一次;重启应用后不补执行到期时间点。
11. 删除工作流后可点 Toast 上的「撤销」恢复到原位置,且触发器重新生效。
12. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 `"触发{行动项}时出现错误。"`。
+336
View File
@@ -0,0 +1,336 @@
## 9. 主题系统(内置主题、主题包格式、壁纸取色、主题预览、自定义颜色)
实现者必须把「主题」拆成**两层独立系统**,不得混为一谈:
1. **应用主题(App Theme)**:控制整个应用(设置中心、窗口、组件库)的明暗与强调色。对应 ClassIsland 的 `IThemeService` / `ThemeService`,只做三件事:切换 ThemeVariant(浅/深/跟随系统)、设置强调色、扩散强调色派生画刷。
2. **主界面主题(Island Theme / XamlTheme)**:只控制「岛」的形状、背景、描边、投影、遮罩动画与排版系数。对应 ClassIsland 的 `IXamlThemeService` + `XamlThemes/*`,它是**可安装、可叠加、可市场分发**的主题包。
两层必须有各自的状态、各自的持久化字段、各自的设置页面分区。**不得**把「换岛皮肤」实现成「换应用 accent」。
### 9.1 应用主题(明暗 + 强调色)
必须实现以下状态字段(与源码一致,便于数据迁移):
| 字段 | 类型 | 默认 | 含义 | 证据 |
|---|---|---|---|---|
| `Theme` | int | `2` | `0=跟随系统`、`1=明亮`、`2=黑暗` | `ClassIsland/Models/Settings.cs:32,979-989` |
| `PrimaryColor` | color | `#00BFFF` | 自定义强调色 | `Settings.cs:35,991-1001` |
| `SecondaryColor` | color | `#7FFFD4` | 次级强调色 | `Settings.cs:36,1003-1012` |
| `ColorSource` | int | `2` | `0=自定义`、`1=壁纸主题色`、`2=跟随系统`、`3=屏幕主题色` | `Settings.cs:83,1014-1023` |
| `TargetLightValue` | double | `0.6` | 取色目标明度 | `Settings.cs:85,1107-1116` |
| `WallpaperColorPlatte` | color[] | `[]` | 壁纸调色板(最多 5 色) | `Settings.cs:1025-1034` |
| `SelectedPlatteIndex` | int | `0` | 当前调色板选择 | `Settings.cs:1041-1050` |
| `IsWallpaperAutoUpdateEnabled` | bool | `false` | 壁纸变化时自动取色 | `Settings.cs:92,1052-1061` |
| `WallpaperAutoUpdateIntervalSeconds` | int | `60` | 自动取色轮询间隔(秒) | `Settings.cs:93,1063-1072` |
| `UseExperimentColorPickingMethod` | bool | `false` | 启用实验性取色算法 | `Settings.cs:1096-1105` |
切换逻辑必须严格实现(`ClassIsland/MainWindow.axaml.cs:743-764` 与 `ClassIsland/Services/ThemeService.cs:43-75`):
1. `themeMode=0` → 主题变体 `Default` 且 `preferSystemTheme=true`(系统变浅即浅、变深即深);
2. `themeMode=1/2` → 显式 `Light`/`Dark`,并 `preferSystemTheme=false`;
3. 强调色:`ColorSource=0` 用 `PrimaryColor`;`1`/`3` 用 `SelectedPlatte`;`2` 传 `null` 表示跟随系统强调色;
4. 强调色变化后**必须重算** `CustomizedAccentBarBackground1Brush` = 强调色 + alpha `0.10`(`ThemeService.cs:66-74`、`Settings.cs:1037-1039`);
5. 应用主题变化必须触发 `ThemeUpdated` 事件,通知所有依赖主题的组件重绘;事件名与语义照抄 `ClassIsland.Core/Abstractions/Services/IThemeService.cs:16`。
CSS 落地要求:
```css
:root { --ci-accent: #00bfff; --ci-accent-soft: color-mix(in srgb, var(--ci-accent) 10%, transparent); }
html.dark { /* 深色下的主题覆盖只允许改「派生值」,强调色本身仍由同一变量驱动 */ }
```
`--ci-accent` 必须由运行时 JS 写入 `document.documentElement.style`(与 ClassHelper 注入动效 token 的方式一致,见 `packages/desktop-client/src/renderer/composables/motion.ts:35-45`),**不得**通过重新生成样式表字符串实现。
### 9.2 内置主界面主题
必须内置且仅内置两套主界面主题,id 与名称不得更改(主题包 id 是持久化键):
| id | 名称 | 描述 | Banner(预览图) | 竖直安全区 | 证据 |
|---|---|---|---|---|---|
| `classisland.classic` | 经典 | ClassIsland 的经典外观。 | `avares://ClassIsland/Assets/XamlThemePreviews/classisland.classic.png` | `0` | `ClassIsland/App.Services.xaml.cs:297-305` |
| `classisland.fluent` | Fluent | 焕然一新的 ClassIsland 外观。 | `avares://ClassIsland/Assets/XamlThemePreviews/classisland.fluent.png` | `20` | `App.Services.xaml.cs:306-315` |
- 两套主题的作者均为 `ClassIsland`,URL 均为 `https://github.com/ClassIsland/ClassIsland`。
- 两张预览图均为 **1920×350 PNG**(实测文件头,`Assets/XamlThemePreviews/`),实现时必须按 1920×350 的比例裁切展示,不得拉伸变形。
- 默认启用列表必须只包含 `classisland.fluent`(`Services/XamlThemeService.cs:81-84`)。
- 首次启动写入 `EnabledThemes.json`(`XamlThemeService.cs:48`),内容为字符串数组,顺序即加载优先级。
#### 9.2.1 Fluent 与 Classic 的差异(逐项实现,不得混用)
| 差异项 | Fluent 主题 | Classic 主题 | 证据 |
|---|---|---|---|
| 行背景画刷 | `SolidBackgroundFillColorSecondaryBrush` | `SolidBackgroundFillColorBaseBrush` | `XamlThemes/FluentTheme/Styles.axaml:45`;`ClassicTheme/Styles.axaml:214` |
| 行背景描边 | `ControlElevationBorderBrush`,1px | 无显式描边(依赖背景色与投影) | `FluentTheme:46-47` |
| 行投影 | `BoxShadow 0 4 8 2 #48000000` | 常态无投影(`DropShadowEffect` Opacity=0),提醒时升为 `BlurRadius=8, Opacity=1` | `FluentTheme:49`;`ClassicTheme:36-45,283-287` |
| 圆角来源 | `MainWindowStylesAssist.CornerRadius / 1.0` | 无圆角(矩形裁剪) | `FluentTheme:48`;`ClassicTheme:46-59` |
| 背景宽度动画 | `300ms`,`cubic-bezier(0.65, 0, 0.35, 1.0)` | `550ms`,`cubic-bezier(0.00, 1.00, 0.50, 1.10)`(**带过冲**) | `FluentTheme:269`;`ClassicTheme:207` |
| 提醒遮罩实现 | 自绘斜切分块(`SlantedMaskControl`)+ 文字缩放 + 投影淡入 | 整块下压/上滑(`TranslateTransform Y=-50 → 0` / `0 → 50`) | `FluentTheme:175-197`;`ClassicTheme:78-91,142-154` |
| 提醒遮罩参数 | `RegionDurationMs=360`、`StageStaggerMs=120`;文字 250ms 延迟 260ms;缩放 1.1→1.0 用时 750ms 延迟 260ms;投影 100ms 延迟 600ms | 遮罩下压 200ms(QuadraticEaseIn);文字 100ms 延迟 200ms;退出 200ms 延迟 100ms + 文字 100ms | 见 part 14 表 #10–#14 |
| 分体主界面(行独立成岛) | 支持:`IsIslandSeperated=True` 时每行独立背景、独立 `border-radius`,组件左右外扩 `Margin="-2 0"`、最小宽 `40`、内容 `Margin="16 0"` | 不支持分体(无对应样式) | `FluentTheme:245-259`;`Settings.cs:1287-1296` |
| 内容裁剪 | 用 `clip-host` + `WidthDoubleToRectConverter` 生成按圆角的 Rect 裁剪 | 用 `Grid.Clip` 矩形裁剪,无圆角 | `FluentTheme:21-38`;`ClassicTheme:46-59` |
| `IslandContainerHeight` | `40` | `40` | `FluentTheme:65`;`ClassicTheme:16` |
| 未启用提醒时的行模板 | 最小模板(无遮罩层、无进度条) | 最小模板(同样精简) | `FluentTheme:203-232,275-278`;`ClassicTheme:158-196,219-222` |
**验收硬要求**:在两种主题下分别触发同一条提醒,动画形态必须明显不同(Fluent 为「斜切分块扫入 + 文字缩放」,Classic 为「整块下压 + 上滑退出」),且时长与上表逐毫秒一致。
### 9.3 主题包格式(必须实现安装/卸载/打包/市场)
#### 9.3.1 目录与文件
| 路径 | 内容 | 证据 |
|---|---|---|
| `<配置目录>/Themes/<主题id>/` | 已安装主题目录(每主题一个文件夹) | `Services/XamlThemeService.cs:47,440-447` |
| `<配置目录>/Themes/<id>/manifest.yml` | 主题清单(必需,缺失时回退为「目录名即 id/name」) | `XamlThemeService.cs:249-279` |
| `<配置目录>/Themes/<id>/Styles.axaml` | 主题样式入口(必需) | `XamlThemeService.cs:117-120` |
| `<配置目录>/Themes/<id>/banner.png`(或 manifest 指定) | 预览图 | `XamlThemeService.cs:271` |
| `<配置目录>/Themes/<id>/.uninstall` | 卸载标记(存在即下次启动删除整个目录) | `XamlThemeService.cs:413-423`;`ThemeInfo.cs:IsUninstalling` |
| `<配置目录>/EnabledThemes.json` | 启用主题 id 数组,顺序=加载顺序 | `XamlThemeService.cs:48,63,80-86` |
| `<缓存目录>/ThemePackages/<id>.zip` | 下载完成的主题包缓存,启动时自动安装 | `XamlThemeService.cs:49,390-411,426-450` |
| `<插件市场索引目录>/<源名>/themes.json` | 市场主题索引 | `XamlThemeService.cs:171-220` |
#### 9.3.2 `manifest.yml` 字段(逐字段实现,camelCase)
| 字段 | 类型 | 默认 | 必填 | 说明 | 证据 |
|---|---|---|---|---|---|
| `id` | string | `""` | 是 | 主题唯一 id(建议 `作者.主题`) | `ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs` |
| `name` | string | `""` | 是 | 显示名 | 同上 |
| `description` | string | `""` | 否 | 一句话描述 | 同上 |
| `version` | string | `"0.0.0.0"` | 否 | 语义化版本,用于市场更新判断 | 同上;`XamlThemeService.cs:201-206` |
| `author` | string | `""` | 否 | 作者 | 同上 |
| `url` | string | `""` | 否 | 项目地址 | 同上 |
| `banner` | string | `"banner.png"` | 否 | 预览图相对路径 | 同上 |
| `verticalSafeAreaPx` | double | `0` | 否 | 竖直安全区像素 | 同上 |
市场索引 `themes.json` 的结构必须为:
```json
{ "themes": [ { "manifest": { ...上表字段... },
"downloadUrl": "https://.../{root}/xxx.zip",
"downloadMd5": "……" } ] }
```
其中 `downloadUrl` 必须支持 `{root}` 占位符,由所选镜像替换(`XamlThemeService.cs:194,213`);下载后必须校验 MD5 再落盘(`XamlThemeService.cs:345-351`)。
#### 9.3.3 加载与覆盖规则
1. 启动时先注册内置主题(`IXamlThemeService.IntegratedThemes`,`ClassIsland.Core/Abstractions/Services/IXamlThemeService.cs:21`),再扫描 `Themes/` 目录加载外部主题;
2. 只加载 `EnabledThemes` 中列出的主题,且**按数组顺序依次叠加**:后加载主题覆盖前面主题的同名样式(`IXamlThemeService.cs:63-66`);
3. 任一主题样式加载失败必须**隔离**:标记 `IsError`、记录异常、继续加载后续主题,不得导致应用崩溃(`XamlThemeService.cs:112-133`);
4. 主题的 `VerticalSafeAreaPx` 取所有已启用主题的**最大值**(不是求和)(`XamlThemeService.cs:111-135`);
5. 主题安装/卸载/换序后必须提示「重启以应用更改」(`XamlThemeService.cs:232-236,369-374`);
6. 安全模式(`--safe`)下不得加载任何主题(`XamlThemeService.cs:87-90,101-104`)。
#### 9.3.4 打包
`PackageThemeAsync(id, outputPath)` 必须把主题目录整体 zip 成包,且不得在包内包含 `.uninstall`(`XamlThemeService.cs:452-466`)。实现时对应一个「导出主题包」命令。
### 9.4 主题包在 ClassHelper 栈中的落地格式(CSS 版)
由于实现栈是 Web 技术,主题包不再执行 XAML,必须改为「**样式清单 + 受控 CSS**」。实现者必须定义如下主题包结构(这是本节的核心交付物):
```
<主题id>/
manifest.yml(与 9.3.2 完全一致,保持兼容)
theme.json ← 新增,主题变量清单
theme.css ← 新增,受控 CSS(只允许声明 CSS 变量与白名单选择器)
banner.png(1920×350)
```
`theme.json` 必须包含:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `schemaVersion` | int | 是 | 固定 `1` |
| `id` / `name` / `version` / `author` / `description` | string | 是 | 与 manifest 一致(双写校验) |
| `verticalSafeAreaPx` | number | 否 | 与 manifest 一致 |
| `tokens` | object | 是 | `{ "island.bg": "#202020cc", "island.radius": "8px", ... }`,键名必须来自 9.4.1 白名单 |
| `styles` | string[] | 否 | 允许的主题 CSS 选择器 id(见 9.4.2),空表示只用 token |
| `fonts` | object | 否 | `{ "islandFamily": "...", "weight": 500 }` |
| `assets` | string[] | 否 | 主题自带资源相对路径 |
#### 9.4.1 Token 白名单(主题只能改这些,改别的无效)
必须允许覆盖:`--ci-accent`(仅当主题声明 `allowAccentOverride: true`,默认 false,防止主题抢走用户强调色)、`--ci-island-bg`、`--ci-island-bg-opacity`、`--ci-island-border-color/width`、`--ci-island-radius`、`--ci-island-shadow`、`--ci-island-font`、`--ci-island-fs-*`、`--ci-island-line-gap`、`--ci-island-pad-x`、`--ci-island-mask-*`、`--ci-island-progress-*`、`--ci-island-fade-opacity`。
必须禁止覆盖:`--ci-text-1..5`(由可访问性/自定义前景色决定)、`--ci-z-*`、`--ci-island-height`(固定 40,改它会破坏窗口几何)、`--ci-scale`、以及任何 `--ch-*` 应用外壳 token。
#### 9.4.2 受控选择器(styles 白名单)
允许:`.:island-root`、`.island-card`、`.island-background`、`.island-content`、`.island-overlay`、`.island-mask`、`.island-progress`、`.island-component`、`.island-separator`。禁止:`*`、`html`、`body`、`#app`、`[class*=...]`、任何 `@import`、任何 `url(http…)`、任何 `position: fixed`、任何 `z-index`。加载时必须用 CSSOM 解析并逐条校验,不合格条目丢弃并记录警告,不得整包拒绝(隔离原则同 9.3.3 第 3 条)。
### 9.5 XAML 主题 → CSS 主题的映射规则
实现者必须按下表做等价映射;遇到不在表内的 XAML 特性,先实现等价 CSS 再补文档,**不得**用「运行一段脚本改 DOM」绕过。
| ClassIsland(Avalonia) | 实现(Web) | 说明 |
|---|---|---|
| `ResourceDictionary` + `x:Key` | CSS 变量 + `theme.json.tokens` | 资源键名转 kebab-case 并加 `ci-` 前缀 |
| `DynamicResource Key` | `var(--ci-…)` | 运行时可变;`StaticResource` 则写成固定值 |
| `Style Selector="controls|MainWindowLine"` | `.island-line` | 控件类名 → 固定类名 |
| `Style Selector="^:mask-in"`(伪类) | `.island-line.is-mask-in` | 伪类 → 状态类 |
| `Style.Animations` + `KeyFrame` | `@keyframes` + `animation` | 时长/曲线/延迟逐值迁移(见 part 14) |
| `Transitions` + `DoubleTransition` | `transition` | 属性名映射见下 |
| `DoubleTransition Property="BackgroundWidth"` | `transition: width` / `inline-size` | 源码动画的是背景层宽度 |
| `DoubleTransition Property="Opacity"` | `transition: opacity` | — |
| `BoxShadowsTransition Property="BoxShadow"` | `transition: box-shadow` | — |
| `BrushTransition Property="Background"` | `transition: background-color` | — |
| `Easing="0.65, 0, 0.35, 1.0"` | `cubic-bezier(.65,0,.35,1)` | 逐值照抄 |
| `Easing="SineEaseIn"` | `cubic-bezier(.12,0,.39,0)`【推断:按 Avalonia SineEaseIn 定义换算】 | 或 `ease-in` 近似 |
| `QuadraticEaseIn/Out` | `cubic-bezier(.11,0,.5,0)` / `cubic-bezier(.5,1,.89,1)`【推断】 | 按 Avalonia 定义换算 |
| `ExponentialEase EaseOut` | `cubic-bezier(.16,1,.3,1)` 近似 | 用于闪屏/OOBE |
| `FillMode="Both"` | `animation-fill-mode: both` | 必须保留,否则延迟期会闪终态 |
| `IterationCount="Infinite"` | `animation-iteration-count: infinite` | — |
| `PlaybackDirection="Alternate"` | `animation-direction: alternate` | — |
| `Clip` + `WidthDoubleToRectConverter` | `border-radius` + `overflow:hidden` 的内层 | 圆角裁剪 |
| `OpacityMask` 渐变 | `mask-image: linear-gradient(...)` | 主题卡底部渐隐 |
| `DropShadowEffect` | `filter: drop-shadow(...)`(仅限非卡片元素) | 卡片禁止用 filter(会闪帧) |
| `AcrylicBlur` | `backdrop-filter: blur(30px) saturate(1.2)`【推断】 | 只用于编辑模式整窗底 |
| 加载顺序覆盖 | 同优先级下「后插入的 `<style>` 生效」 | 必须用一个 `<style id="ci-theme-runtime">` 承载全部主题变量,按数组顺序覆盖 |
映射的硬性边界(源码行为决定的):
- `IsIslandSeperated` 是 Fluent 主题专属能力:主题包只有在 `theme.json.capabilities` 里声明 `"separatedIsland": true` 时,才允许显示「分体主界面」开关(`ThemesSettingsPage.axaml:76-84` 的提示文案即此含义)。
- 主题只能改「主界面」,**绝不能**影响设置中心视觉;设置中心由应用主题控制。
- 主题的 `VerticalSafeAreaPx` 会直接改变窗口位置,必须参与窗口几何计算(见 part 04)。
### 9.6 壁纸/屏幕取色
必须实现「强调色来源」的完整算法,否则 `ColorSource=1/3` 形同虚设。源码中的取色实现目前位于 `ClassIsland/Services/WallpaperPickingService.cs`,但**整个文件被 `#if false` 包裹**(第 1 行 `#if false`、第 379 行 `#endif`),且依赖注入被注释(`App.Services.xaml.cs:62`)、调用点被注释(`App.axaml.cs:762`、`AppearanceSettingsPage.axaml.cs:32-33,53`)。因此:
- 现状 = **功能被停用**,只剩 `MainWindow.axaml.cs:744-763` 的分支读取 `SelectedPlatte`;
- 更强的证据:设置页「主题色来源」下拉里,`系统壁纸` 与 `屏幕主题色` 两项被写死为 `IsVisible="False" IsEnabled="False"`,用户根本无法选中(`Views/SettingPages/AppearanceSettingsPage.axaml:146-158`);
- 实现要求 = **按此算法重新实现为可选功能**(默认关闭),并把「壁纸取色」在设置页标注为实验性;若实现者判断风险过高,可只实现 `ColorSource=0(自定义)` 与 `2(跟随系统)` 两项,并在文档中明确声明 `1`/`3` 不支持;
- 本节算法细节依据 dead code,标注【依据停用实现】。
#### 9.6.1 取色流程
1. 触发时机:注册表 `Control Panel\Desktop` 变更、系统 `UserPreferenceChanged(Desktop)`、或定时器(间隔 = `WallpaperAutoUpdateIntervalSeconds`,默认 60s)(`WallpaperPickingService.cs:45-51,91-94,117-128,136-162`);
2. 取图:`ColorSource=3` 直接截取「岛所在显示器」的全屏;否则截取桌面窗口(类名默认 `Progman`,可被 `WallpaperClassName` 覆盖);兼容模式则直接读注册表 `WallPaper` 路径并等比缩放(`WallpaperPickingService.cs:164-222,253-261`);
3. 缩放:图片宽度统一转换(`ConvertToBitmapImage(bitmap, bitmap.Width)`,`WallpaperPickingService.cs:278`);
4. 主算法(旧):八叉树量化(`ClassIsland/ColorOctTreeNode.cs`)+ HSV 评分;
5. 备选算法(实验):`AccentColorHelper.GetAccentColor(bytes, w, h)`(`ClassIsland/Helpers/AccentColorHelper.cs`,注明来自 Starward 项目);
6. 结果:取评分前 **5** 色写入 `WallpaperColorPlatte`,并把 `SelectedPlatteIndex` 重置为 `0`(`WallpaperPickingService.cs:295-307,337-341`);
7. 失败兜底:`SelectedPlatte` 返回 `DodgerBlue`(`Settings.cs:1037-1039`)。
#### 9.6.2 八叉树评分公式(必须逐项实现)
```text
score(c) = (S + V × (-(V - right) × (V - left) × 4)) × log2(count)
其中 right = TargetLightValue - 0.5,left = TargetLightValue + 0.5
S = HSV 饱和度(0..1),V = HSV 明度(0..1),count = 该颜色像素数
```
排序规则:先按 `score` 降序,再按像素数 `count` 降序(`WallpaperPickingService.cs:326-336`)。HSV 转换公式(`WallpaperPickingService.cs:55-63`):
```text
max = max(R,G,B)/255, min = min(R,G,B)/255
S = max == 0 ? 0 : 1 - min/max
V = max
H 不参与评分(源码未计算)
```
实现要点:
- `TargetLightValue` 默认 `0.6`,即偏好「中等偏亮」的颜色;UI 上必须暴露该滑杆(0–1)。
- 必须是纯函数、可单测:输入 RGBA 像素数组与宽高,输出最多 5 个颜色,顺序稳定。
- 取色必须放在 Web Worker 或主进程执行,**不得**阻塞渲染线程(图片可能 4K)。
#### 9.6.3 取色 UI
必须实现「壁纸预览 + 调色板选择」窗口:展示当前壁纸截图与 5 个候选色块,点击某个色块即把 `SelectedPlatteIndex` 设为该索引并实时应用强调色。源码窗口 `ClassIsland/Views/WallpaperPreviewWindow.xaml(.cs)` 仍存在(构造函数注入 `WallpaperPickingService` 并调用 `GetWallpaperAsync()`,`WallpaperPreviewWindow.xaml.cs:24-40`),可作为布局参考。
### 9.7 自定义颜色(用户手动覆盖)
必须实现「全局」与「单行」两级覆盖,优先级:单行 > 全局 > 主题默认。
| 设置 | 全局字段 | 行级字段 | 默认 | 证据 |
|---|---|---|---|---|
| 自定义背景色开关 | `IsCustomBackgroundColorEnabled` | `IsCustomBackgroundColorEnabled` | `false` | `Settings.cs:33,1118-1127`;`MainWindowLineSettings.cs:39` |
| 背景色 | `BackgroundColor` | `BackgroundColor` | `#000000` | `Settings.cs:34,1129-1138`;`MainWindowLineSettings.cs:38` |
| 背景不透明度 | `Opacity` | `BackgroundOpacity` + 开关 | `0.5` / `0.5` | `Settings.cs:64`;`MainWindowLineSettings.cs:36-37` |
| 自定义前景色开关 | `IsCustomForegroundColorEnabled` | 同名 | `false` | `Settings.cs:1253-1262`;`MainWindowLineSettings.cs:34` |
| 前景色 | `CustomForegroundColor` | `ForegroundColor` | `DodgerBlue`=`#1E90FF` | `Settings.cs:203,1264-1273`;`MainWindowLineSettings.cs:35` |
| 圆角 | `RadiusX/Y` | `CustomCornerRadius` + 开关 | `8` / `0` | `Settings.cs:183-184`;`MainWindowLineSettings.cs:40-41` |
| 字号 4 档 | 4 个字段 | 同名 + `IsResourceOverridingEnabled` | 14/16/18/20 | `Settings.cs:197-200`;`MainWindowLineSettings.cs:29-33` |
| 行不透明度 | — | `Opacity` | `1.0` | `MainWindowLineSettings.cs:42` |
实现要求:
- 自定义背景色必须作用在「背景层」,不得作用在内容层,否则文字会被一起染色(`FluentTheme/Styles.axaml:51-56`)。
- 自定义前景色必须按 part 03 的 5 级 alpha 扩散,且要覆盖组件内部的 `Foreground` 与 `TextElement.Foreground` 两处。
- 行级覆盖必须使用「内联 CSS 变量 + 局部 class」,关闭时移除内联变量,与源码 `ClearValue` 语义一致(`MainWindowCustomizableNodeHelper.cs:39-67`)。
### 9.8 主题预览卡片(主题市场 UI)
主题设置页必须按以下规格实现(源码 `ClassIsland/Views/SettingPages/ThemesSettingsPage.axaml`):
- 顶部 `InfoBar`:提示「启用支持的主题(如 Fluent)后可在【外观】开启【分体主界面】」;带「前往【外观】设置」按钮;可关闭并持久化(`ThemesSettingsPage.axaml:76-84`)。
- 分类 TabStrip:`市场` / `已安装` 两个标签,紧凑样式(`compact`,字号 14、字重 Medium、最小高 28)(`ThemesSettingsPage.axaml:86-92`;`StylesBase.axaml:129-142`)。
- 工具栏:`刷新市场`、`重载主题`、`打开主题文件夹…`、`主题加载顺序`(打开右侧 Drawer,可拖拽排序)(`ThemesSettingsPage.axaml:94-111,35-67`)。
- 网格:横向 WrapPanel,单元高 `150`、目标宽 `328`、间距 `2`(`ThemesSettingsPage.axaml:137-146`)。
- 卡片:圆角 `4`、1px 描边、`overflow:hidden`;内容为 Banner(`UniformToFill` 铺满)+ 底部渐变遮罩(`#00ffffff`→0.65→`#00ffffff`→1.0→`#ffffffff`)+ 名称(16px Medium)+ 作者(12px)+ 操作区(`ThemesSettingsPage.axaml:147-172`)。
- 操作区按钮:28×28、圆角 14:错误(红色图标)、更多(打开文件夹/打包/卸载/撤销卸载)、安装、更新、重启以应用、启用开关(`ThemesSettingsPage.axaml:206-299`)。
- 首次进入主题页必须显示**安全警告**(主题可能执行任意代码),用户点击「同意并开始使用主题」后才展示列表;警告状态持久化(`IsThemeWarningVisible`,`Settings.cs:2391`;`ThemesSettingsPage.axaml:309-366`)。
- 未加载/加载失败的主题卡片必须 `opacity:.8` 并显示错误入口(`ThemesSettingsPage.axaml:176-185,208-213`)。
### 9.9 ClassHelper 侧映射
| ClassIsland 概念 | ClassHelper 落点 | 要求 |
|---|---|---|
| 应用主题 store | `packages/desktop-client/src/renderer/stores/ui.ts`(既有主题切换) | 增加 `accentSource`、`primaryColor`、`wallpaperPalette` 状态 |
| 主界面主题 store | 新增 `src/island/theme.ts` + `config.json` 字段 | 结构:`{ enabled: string[], active: string }`,与 `EnabledThemes.json` 兼容 |
| 主题包加载 | 主进程读 `themes/<id>/{manifest.yml,theme.json,theme.css}` | 用 `js-yaml` 解析 manifest;CSS 用 CSSOM 白名单校验后注入 `<style>` |
| 岛视觉 token | `classic-island.css` 的 `--wn-*`(黑/玻璃/着色三套预设,`classic-island.css:60-82`)+ `custom-island.css` 的 `--custom-color`/`--custom-radius` | 主题直接覆盖这些变量 |
| 主题预览 | 复用 `packages/ui` 的卡片组件 | 图片走 `AsyncImage` 等价组件(懒加载 + 占位 shimmer) |
| 主题切换动效 | `composables/motion.ts` 的 `useThemeReveal()` | 保持「圆形扩散」400–480ms;`prefers-reduced-motion` 与页面不可见时必须同步切换(`motion.ts:201-237`) |
| 持久化 | 本地 JSON 文件(主进程) | 禁止引入服务端依赖(ClassIsland 是本地单机应用) |
### 9.10 外观设置页(主题相关控件的精确规格)
「外观」设置页是应用主题的唯一入口,必须逐控件实现(源码 `ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml`)。控件全部使用 `SettingsExpander`(左侧图标 + 标题 + 描述,右侧控件),容器为 `StackPanel.settings-container`(`margin 12 12 18 12`、`max-width 960`、`spacing 4`),整页带 `.animated-intro` 错峰入场(`StylesBase.axaml:67-71`;`StackPanelIntroAnimation.axaml`)。
| 分组 | 控件 | 类型 | 范围/选项 | 默认 | 绑定字段 | 证据 |
|---|---|---|---|---|---|---|
| 基本 | 界面缩放 | Slider 180px + 数值气泡(F2) | 0.75–2.5,步进 0.05 | 1.0 | `Scale` | `AppearanceSettingsPage.axaml:27-39` |
| 基本 | 背景颜色 | ColorPicker + ToggleSwitch | 任意颜色 | `#000000`,开关关 | `BackgroundColor` / `IsCustomBackgroundColorEnabled` | `:43-58` |
| 基本 | 背景不透明度 | Slider 180px(F2) | 0–1,步进 0.05 | 0.5 | `Opacity` | `:61-72` |
| 基本 | 圆角半径 | Slider 180px(F1) | 0–20,步进 0.5 | 8 | `RadiusX` | `:75-87` |
| 基本 | 行间隔 | Slider 130px(F1)+ NumericUpDown 125px | 0–150,步进 5 | 5 | `MainWindowLineVerticalMargin` | `:90-109` |
| 基本 | 分体主界面 | ToggleSwitch | 开/关 | 关 | `IsIslandSeperated` | `:112-118` |
| 主题 | 应用主题 | ComboBox(跟随系统/明亮/黑暗) | 3 项,默认索引 2 | 黑暗 | `Theme` | `:123-135` |
| 主题 | 主题色来源 | ComboBox(自定义/系统壁纸/系统/屏幕主题色) | 后两项禁用隐藏,见 9.6 | 系统 | `ColorSource` | `:138-160` |
| 主题 | 强调色 | ColorPicker(仅 `ColorSource=0` 时可见) | 任意颜色 | `#00BFFF` | `PrimaryColor` | `:165-180` |
| 字体 | 自定义字体 | ComboBox(系统字体列表,单项高 32、下拉宽 280) | 系统字体或默认 | HarmonyOS Sans SC(默认) | `MainWindowFont` | `:405-448` |
| 字体 | 自定义字重 | ComboBox(Thin…ExtraBlack 共 11 档) | 100–900 | Medium(500) | `MainWindowFontWeight2` | `:449-480` |
| 字体 | 字体大小 | 4 × NumberBox(后缀 px,SpinButton 内联) | 1–40 | 14/16/18/20 | 4 个字号字段 | `:482-533` |
| 字体 | 字体颜色 | ColorPicker + ToggleSwitch | 任意颜色 | DodgerBlue,开关关 | `CustomForegroundColor` / `IsCustomForegroundColorEnabled` | `:536-551` |
| 字体 | 字体预览 / 字号预览 | 两个 Tab | 只读文本框 | — | — | `:553-592` |
实现硬要求:
- 任何设置改变必须**立即生效**,不得要求重启(主题包除外,见 9.3.3 第 5 条)。
- 颜色选择器必须支持「输入 HEX + 取色器 + 最近使用」,且透明度与 RGB 分开控制(源码用 Avalonia `ColorPicker`,Web 端可用同类组件)。
- 字号/缩放/圆角/不透明度四个滑块必须显示数值气泡(源码 `SliderDragTooltipAssist`,`StylesBase.axaml:178-191`),且开启 `IsSnapToTickEnabled`。
- 「强调色」控件必须跟随 `ColorSource` 显隐;不得在跟随系统时仍显示一个无效的颜色选择器。
- 字体列表必须包含「HarmonyOS Sans SC(默认)」这一项,且该项显示中文名而非字体内部名(`AppearanceSettingsPage.axaml:425-445`)。
### 9.11 主题调试、兼容性与安全边界
1. **调试入口**:开发者菜单提供「重载主题」(`ThemesSettingsPage.axaml:99-101`),必须能在不重启应用的情况下重新加载全部已启用主题的变量与样式;加载失败主题的错误信息必须可从卡片上的错误按钮打开抽屉查看完整堆栈(`ThemesSettingsPage.axaml:24-30,206-213`)。
2. **版本兼容**:`ThemeManifest.version` 必须能被语义化解析;市场比较使用 `Version.TryParse`(`XamlThemeService.cs:201-206`)。主题包必须声明 `minAppVersion`(新增字段,默认 `"0.0.0"`),低于该版本时禁用安装并提示。
3. **安全边界**:主题不得访问文件系统、网络与 Node API;在 Electron 下必须运行在「只读 CSS/JSON」模型中,禁止 `eval`、`new Function`、内联 `on*` 事件(与 ClassHelper preload 白名单纪律一致:`ClassHelper/AGENTS.md` 第 4 节「preload 仅暴露 contextBridge 白名单」)。违规主题必须整包拒绝安装,而不是降级运行。
4. **主题与提醒/组件的耦合**:主题可以改遮罩的颜色与时长,但**不得**改动提醒的排队与优先级逻辑;可以改组件容器的圆角与间距,但不得改组件内部的业务渲染。实现时把「主题变量」与「组件状态」严格分层。
5. **性能底线**:主题切换必须在 100ms 内完成变量注入;主题包 CSS 总大小上限建议 64KB,超过时提示;加载 20 个主题的总耗时不得超过 200ms(解析 + 校验)。
6. **降级**:若主题声明的 token 非法(如把圆角写成 `-8px`、把时长写成 `0ms`),必须回落到默认值并记录警告,不得静默忽略整份主题。
### 9.12 验收标准
1. **双层隔离**:切换主界面主题时,设置中心颜色/圆角/字体**完全不动**;切换应用明暗时,岛主题的圆角/描边样式**完全不动**。
2. **内置主题**:列表恰好 2 个内置主题(Fluent / 经典),Banner 比例 1920×350,Fluent 安全区为 20、经典为 0;默认只启用 Fluent。
3. **主题包安装**:把 zip 放入 `ThemePackages` 后重启,主题出现在「已安装」;`manifest.yml` 缺失时用目录名兜底;`Styles`/`theme.css` 语法错误时该主题标记为错误且其他主题照常工作。
4. **加载顺序**:拖动排序后 `EnabledThemes.json` 顺序变化,重启后叠加效果随顺序变化(后加载覆盖先加载)。
5. **卸载**:点「卸载」写入 `.uninstall` 并置灰,重启后目录被删除;「撤销卸载」可恢复。
6. **安全**:未同意安全警告前看不到主题列表;含 `@import`/`url(http…)`/`position:fixed`/`z-index` 的主题 CSS 被逐条拒绝且记录警告。
7. **壁纸取色**:给定一张固定测试图,算法稳定输出 5 色且顺序可复现;把 `TargetLightValue` 从 0.6 调到 0.2/0.9,首选色亮度相应变化;取色过程中 UI 不卡顿(主线程帧耗时 < 16ms)。
8. **自定义颜色优先级**:同时设置全局背景色与行级背景色,行级生效;关闭行级开关后回落全局;再关闭全局后回落主题。
9. **分体主界面**:Fluent 下开启分体后每行独立成岛、圆角独立、行间出现间距;经典主题下该开关不可用或无效并有说明。
10. **市场更新**:本地主题版本低于市场版本时出现「更新」按钮;下载完成后提示重启;MD5 不匹配时安装失败并报错。
11. **主题安全区**:启用 Fluent 后岛与屏幕边缘出现 20px 竖直安全区;同时启用多个主题时取最大值而非累加。
12. **不阻塞**:主题切换/加载不得造成超过 1 帧(16.7ms)的主线程长任务;大规模图片解码必须异步。
+384
View File
@@ -0,0 +1,384 @@
## 10. 设置中心
本节规定「应用设置」窗口(ClassIsland 对应 `SettingsWindowNew`)的全部实现细节。实现者必须把它实现为 ClassHelper desktop-client 渲染进程中的一个独立 BrowserWindow(Electron `BrowserWindow`,URL 固定为 `#/settings`),窗口内是 Vue 3 + Vue Router + Element Plus 的单页应用;所有页面、控件、默认值、禁用条件、生效时机必须逐项对齐本节的规格。所有设置项必须落到一个 Pinia store(store 名固定为 `useSettingsStore`),字段名沿用本节给出的 JSON 字段名(即 ClassIsland `Settings` 的属性名),以便与主界面(岛)共享。
### 10.1 设置窗口外壳(必须逐像素对齐)
1. **窗口参数**。必须创建宽度 `1240`、高度 `550`、最小宽度 `560` 的 BrowserWindow;`minWidth: 560`,`width: 1240`,`height: 550`,标题为「应用设置」,图标使用 `AppLogo.ico`,`show: false` 首帧就绪后再 `show()`(对应 `repos/ClassIsland/ClassIsland/Views/SettingsWindowNew.axaml:16-22`)。
2. **窗口样式**。必须无系统标题栏(`frame: false` / `titleBarStyle: 'hidden'`),启用 Mica/Acrylic 类背景(Windows 11 21H2+ 且 WinUI Composition 可用时才启用 Mica,其余平台退回纯色背景;证据 `repos/ClassIsland/ClassIsland.Core/Controls/MyWindow.cs:215-229`)。Electron 中不得调用会破坏逐像素透明与 Mica 的 `setBackgroundMaterial()`,改用 `backgroundMaterial: 'mica'`(Windows 11)或窗口样式类实现;不得同时叠加两层不透明背景。
3. **自定义标题栏**。高度必须为 `48px`,必须 `-webkit-app-region: drag`(对应 `viewModel.TitleBar.Height = 48`,`repos/ClassIsland/ClassIsland/Views/SettingsWindowNew.axaml.cs:135`),其内部所有按钮必须 `-webkit-app-region: no-drag`。标题栏从左到右必须依次为:
- 返回按钮:宽 `48px`、高 `40px`、`margin: 0 -8px`,tooltip「返回」,仅在路由可以后退时显示(`:127-136`);
- 导航窗格切换按钮:宽 `48px`,`margin: 0 -8px -8px`,仅在 NavigationView 折叠为 minimal 模式时显示(`:137-144`);
- 图标:`18×18`,`margin: 12px 4px`,`pointer-events: none`(`:146-153`);
- 标题文字:`font-size: 12px`,垂直居中,`pointer-events: none`(`:155-159`);
- 右侧区,间距 `8px`:需要重启按钮(`accent` 样式,tooltip「部分设置需要重启才能生效,点击以重启应用。」,文案「需要重启」,带重启图标)、版本号文本(`font-size: 14px`,次级前景色,tooltip「应用版本」)、「更多选项…」透明图标按钮(`:162-250`);
- 窗口控制按钮预留位:右侧必须保留 `140px` 空白区域(`:252`);在 macOS 上左侧还需预留 `60px` 红绿灯空位(`:120-126`)。
4. **「更多选项…」菜单**必须按以下顺序与文案渲染(`:176-249`):
1. 实验性功能…(图标 `E508`)→ 打开宽度 `332px` 的右侧抽屉;
2. 调试窗口规则…(`F485`)→ 打开 `WindowRuleDebugWindow`;
3. 创建快捷方式…(`E0B1`,仅 Windows/Linux,Windows/macOS 分支按源码 `OnPlatform`)→ 子菜单:创建桌面快捷方式(`E62F`)、创建开始菜单快捷方式(`E06F`)、创建快捷换课图标(`E15F`,仅 Windows);
4. 数据迁移…(`E083`)→ 打开 `DataTransferWindow` 模态对话框;
5. 分隔线;
6. 集控选项…(`E2B4`)→ 跳转 `classisland://app/settings/management?ci_keepHistory=true`;
7. 加入管理…(`E2B6`,未启用集控时显示)、退出管理…(`E2B8`,已启用时显示);
8. 分隔线;
9. 日志…(`E510`)→ 打开 `AppLogsWindow`;
10. 导出诊断数据(`E601`)→ 先弹确认对话框,再保存 `.zip`;
11. 重启到恢复模式(`F4C5`)→ 需「退出应用」授权等级;
12. 分隔线;
13. 打开日志目录…(`E889`)、打开数据目录…(`E881`)、打开应用目录…(`E885`)。
5. **打开行为**。必须单例:首次打开前必须校验「编辑设置」授权等级(`ManagementService.CredentialConfig.EditSettingsAuthorizeLevel`,默认「无」即不校验),校验失败直接返回、不显示窗口(`SettingsWindowNew.axaml.cs:458-480`)。已打开时若最小化则还原并 `focus()`。必须支持从 URI `classisland://app/settings/<pageId>[?ci_keepHistory=true]` 直接打开指定页(`OpenUri`,`:490-499`)。
6. **首帧遮罩**。窗口首次渲染完成前,必须以不透明底色覆盖整窗并居中显示 `96×96` 的 `AppLogo@384w.png`,导航完成后再淡出/移除(`SettingsWindowNew.axaml:258-264`,`ViewModel.IsCoverVisible` 初始 `true`)。
7. **关闭行为**。用户点关闭按钮时**不得退出应用**:必须拦截 `close` 事件,改为 `hide()`,并依次执行「保存设置」「保存组件配置」「保存自动化配置」;随后按缓存策略清理页面缓存并触发 GC(`SettingsWindowNew.axaml.cs:501-518`)。仅当关闭原因是应用退出或系统关机时才真正关闭。
8. **需要重启提示**。任何页面通过命令请求重启时,必须把 `IsRequestedRestart` 置 `true`,标题栏出现「需要重启」accent 按钮;点击后弹出模态对话框:标题「需要重启」,正文「部分设置需要重启以应用更改。」,主按钮「重启」,关闭按钮「取消」,默认聚焦主按钮;确认后调用应用重启(`SettingsWindowNew.axaml.cs:536-560`)。
9. **回声洞(加载彩蛋)**。当 `ShowEchoCaveWhenSettingsPageLoading === true` 且正在导航、且管理策略未禁用彩蛋时,页面左下角显示一行提示:`margin: 32px 8px 8px 16px`,`opacity: 0.75`,图标 `EA8A` + 文本,文本取 `Assets/Tellings.txt` 的逐行内容并随机洗牌、不重复播放;文案禁用时整块隐藏(`SettingsWindowNew.axaml:71-91`、`.cs:262-288`)。管理策略 `DisableEasterEggs` 为真时必须隐藏。
### 10.2 导航结构(左侧 NavigationView)
1. **形态**。必须使用 NavigationView 的 `Auto` 模式:宽屏时左侧固定导航窗格,宽度必须为 `283px`(`SettingsWindowNew.axaml:52-60` 的 `OpenPaneLength="283"`),内容区占剩余宽度;窄屏时折叠为汉堡菜单(minimal 模式),且此时**不得**显示 NavigationView 自带的窗格切换按钮,改用标题栏内的自定义切换按钮(`:62-64`)。
2. **响应式阈值**。窗口宽度 `< 800` 时进入压缩模式;窗口最大化时强制退出压缩模式(`SettingsWindowNew.axaml.cs:437-446`)。压缩模式下:标题容器与内容宽度自适应,不再受 `960px` 限制;宽度恢复后重新展开导航。
3. **导航项来源**。设置页面必须由「注册表」驱动:每个页面注册 `{id, name, unSelectedIconGlyph, selectedIconGlyph, category, groupId, hideDefault, useFullWidth, hidePageTitle}`;导航项按 `category` 枚举升序分块,块内保持注册顺序;**每个 category 块之间必须插入一条分隔线**(最后一个块后不加)(`SettingsWindowNew.axaml.cs:145-227`)。
4. **分类与顺序**(`SettingsPageCategory` 枚举 Internal=0、External=1、About=2、Debug=3;`repos/ClassIsland/ClassIsland.Core/Enums/SettingsWindow/SettingsPageCategory.cs:6-24`)。注册顺序决定同块内次序(`App.Services.xaml.cs:156-184`):
- 分组「通用」(id `classisland.general`,图标 `EF27`,展开后子项顺序):基本、时钟、存储、隐私、翻新与迎新、高级;
- 分组「主界面」(id `classisland.mainwindow`,图标 `EC85`):组件、外观、窗口;
- Internal 无分组:提醒、天气、自动化、更新(仅当 `UpdateService.AllowedPackageTypes` 包含当前打包类型时注册);
- External:插件、主题;
- Debug:测试页面(仅开发)、调试;
- About:关于 ClassIsland、集控、集控凭据设置、集控策略(后两者 `hideDefault = true`,不出现在默认导航中,仅通过 URI 打开)、出错啦!(`hideDefault = true`,错误页)。
5. **分组渲染规则**。同一分组只渲染一个可展开的父项,分组内页面为子项;首次导航到子项时父项必须自动展开;选中子项时父项必须显示 `IsChildSelected` 状态(`SettingsWindowNew.axaml.cs:849-877`)。
6. **过滤规则(管理员策略)**。满足以下任一条件的页面必须从导航中移除且不可通过 URI 进入:
- `HideDefault === true`;
- 策略 `DisableSettingsEditing` 为真且页面 category ∈ {Internal, External};
- 策略 `DisableDebugMenu` 为真且 category = Debug;
- `Settings.IsDebugOptionsEnabled === false` 且 category = Debug(`SettingsWindowNew.axaml.cs:150-158`)。
- 当 `DisableSettingsEditing` 为真时,窗口必须以「关于」页作为启动页(同文件 `:125-128`)。
7. **面包屑/标题**。内容区顶部必须有页面标题(`TitleTextBlockStyle`,字号约 28px【推断】),容器 `margin: 12px 16px 0`;当页面声明 `useFullWidth` 时不做 `960px` 限宽,否则标题容器 `max-width: 960px`;当页面声明 `hidePageTitle` 时整块不渲染(`SettingsWindowNew.axaml:92-104`)。
8. **后退栈**。默认每次从导航栏切换页面都必须清空后退栈(不允许跨页返回);仅当 URI 带 `ci_keepHistory=true` 时保留后退栈(`SettingsWindowNew.axaml.cs:372-395`)。ClassIsland **没有**「设置搜索/全局搜索框」,本实现**不得**擅自添加搜索框;若产品确需增强,只能作为 P2 可选增强(默认关闭、独立开关、并在界面标注为非原版功能)。
9. **导航动画与骨架**。页面切换必须播放淡入(`SettingsService.Settings.AnimationLevel >= 1` 时),并在导航期间把 `IsNavigating` 置真以暂停交互、显示回声洞。
### 10.3 页面容器与通用控件(必须映射到 Element Plus)
1. **页面容器**。所有普通设置页必须使用 `padding` 为 `12px 12px 18px 12px`、`max-width: 960px`、子项间距 `4px` 的纵向容器(`repos/ClassIsland/ClassIsland.Core/StylesBase.axaml:67-71`、`ThemeBase.axaml:37` 的 `SettingsContainerWidth = 960`)。在 CSS 中实现为 `.settings-container { margin: 12px 12px 18px 12px; max-width: 960px; display: flex; flex-direction: column; gap: 4px; }`。
2. **页面内分区标题**。使用「图标 + 文本」的行内标题(`IconText`),`margin: 0 0 4px`;分区之间使用 `Separator`(`margin: 0 12px 0 4px`)。Element Plus 中必须用 `<div class="settings-section-title">` 实现。
3. **SettingsExpander(可展开设置卡片)**。必须实现为「卡片行」组件:左侧为 `20×20` 图标(导航图标尺寸 `20`,见 `ThemeBase.axaml:42`)+ 标题 + 描述(次级文本,`font-size: 12px`,可换行),右侧为 `footer` 控件(开关/下拉/滑块/按钮/数值框);展开后下方追加若干 `SettingsExpanderItem` 子行,子行左边距约 `36px`【推断】。Element Plus 映射:`<el-collapse>` 或自定义 `.settings-card`(`background: CardBackgroundFillColorDefaultBrush`、`border: 1px solid CardStrokeColorDefaultBrush`、`border-radius: 4px`、行高约 `64px`【推断】)。
4. **开关**。所有布尔设置必须使用开关(ToggleSwitch),打开态文本「开」、关闭态文本「关」(`StylesBase.axaml:95-98`)。Element Plus 中必须使用 `<el-switch>` 并在需要文字时设置 `active-text="开" inactive-text="关"`;在卡片右侧显示时隐藏开关内部文本,只保留轨道(对应 `SettingsExpanderItem` 内 ToggleSwitch 的 `PART_ContentPresenter` 隐藏,`StylesBase.axaml:100-106`)。
5. **数值框(Field)**。带单位后缀的数值框必须使用「前缀/后缀字段」组件:宽度 `175px`,后缀如 `px`/`秒`/`周`/`天`/`次`/`ms`(见各页);数值框内部使用 `SpinButtonPlacementMode="Inline"`,最小值 `1`、最大值 `40`(字体大小)等按页指定(`AppearanceSettingsPage.axaml:485-496`)。Element Plus 映射:`<el-input-number controls-position="right">` 外面包一层 `.field`(后缀绝对定位在右侧,`padding-right: 24px`)。
6. **滑块**。带数值提示的滑块必须使用 `auto-tooltip` 行为:拖动时在指针位置显示格式化后的值(`StylesBase.axaml:178-191`),格式化字符串按页指定(`F1`/`F2`)。Element Plus `<el-slider :show-tooltip="true">` 已等价。
7. **抽屉(DrawerHost)**。设置页内的所有「编辑/管理」弹层必须使用右侧抽屉:`placement: right`,宽度由内容决定(见各页),带半透明遮罩(`SmokeFillColorDefaultBrush`)和 `box-shadow: 0 0 8px rgba(0,0,0,0.2)`(`repos/ClassIsland/ClassIsland.Core/Controls/DrawerHost.axaml:16-37`)。动画:`250ms cubic-bezier(0, 0, 0, 1)` 同时作用于位移与透明度,遮罩同步淡入(同文件 `:42-59`);`AnimationLevel === 0` 时不得播放动画。必须支持按 `Esc` 关闭、点击遮罩关闭(`DrawerHost.axaml.cs:78-89`、`:115-118`)。Element Plus 映射:`<el-drawer direction="rtl" :size="...">`,并把过渡曲线覆盖为 `cubic-bezier(0,0,0,1)`。
8. **卡片组(cardContainer)**。关于/集控/恢复等页面使用的「卡片列表」:子项间距 `1px`、首项圆角 `4px 4px 0 0`、末项圆角 `0 0 4px 4px`、单项圆角 `4px`(`StylesBase.axaml:255-263`)。
9. **其它通用类**:`l-instructive`(次级说明文本,`opacity` 约 0.75);`items-valign-center`(行内垂直居中);`animated-intro`(入场时子元素依次淡入,`AnimationLevel >= 1` 时启用);`Foreground="IndianRed"` 表示危险操作。实现时必须提供等价 CSS 类,颜色映射见 part 3。
### 10.4 每个设置页的逐项规格
以下每页都必须按「标题/图标 → 分区 → 控件 → 绑定字段 → 默认值 → 枚举 → 生效时机 → 禁用条件」实现。默认值来源统一为 `repos/ClassIsland/ClassIsland/Models/Settings.cs` 的字段初始化(下文以 `Settings.cs:行号` 标注)。
#### 10.4.1 基本(id `general`,图标无自定义 → 默认 `EF27/EF26`,分组「通用」)
源文件 `Views/SettingPages/GeneralSettingsPage.axaml`。
1. **顶部公告区**:当 `ShowSellingAnnouncement` 为真时显示 `Warning` 级 InfoBar,固定文案「ClassIsland 是开源免费的软件,官方没有提供任何形式的付费支持服务,源代码仓库地址在 https://github.com/ClassIsland/ClassIsland/。如果您通过有偿协助等付费方式取得本应用,在遇到问题时请在与卖家约定的服务框架下,优先向卖家求助。如果卖家没有提供您预期的服务,请退款或通过其它形式积极维护您的合法权益。」(`:32-36`)。
2. **行为分区(图标 `E84F`,标题「行为」)**:
- 开机自启:开关,绑定 `DesktopService.IsAutoStartEnabled`(桌面集成,不写入 Settings.json),说明「在您的系统启动时自动运行本应用。」(`:55-62`);
- 注册 Url 协议:开关,绑定 `DesktopService.IsUrlSchemeRegistered`,仅 Windows/Linux 显示,说明含 `classisland://`(`:64-76`);
- 学期开始时间:日期选择器,宽度 `120px`,绑定 `SingleWeekStartTime`,默认「今天所在周的周日」(`Settings.cs:37-38`),说明「设置学期首日,该日期将作为多周轮换课表的计算起点和每周的第一天。」;右侧附「调整多周轮换偏移…」按钮(图标 `E324`)打开飞层 `WeekOffsetSettingsControl`(`:78-117`);
- 最大多周轮换周数(子项):数值框最小 `2`、最大 `9`、格式 `0`、后缀「周」,绑定 `MultiWeekRotationMaxCycle`,默认 `4`(`Settings.cs:61`);
- 点击托盘图标行为:下拉,绑定 `TaskBarIconClickBehavior`,默认 Windows=`0`、其他平台=`4`(`Settings.cs:102`)。选项(5 项):打开主菜单(仅 Windows 可用)、打开档案编辑窗口、显示/隐藏主界面、打开换课窗口、打开设置窗口;macOS 隐藏整项;Linux 说明追加「此选项可能因为桌面环境差异而无法工作。」(`:121-161`);
- 教学安全模式:开关绑定 `IsCriticalSafeMode`(默认 `false`,`Settings.cs:207`),右侧下拉绑定 `CriticalSafeModeMethod`(仅在开启时显示,宽度 `135px`),选项:退出应用(0)、静默重启应用(1)、显示通知(2)、忽略(不推荐)(3);关闭时显示禁用状态的「显示崩溃报告」下拉(`:163-189`);
- 显示启动加载界面:开关绑定 `IsSplashEnabled`(默认 `false`,`Settings.cs:115`)。展开后子项:启动图标(文本框 `min-width: 150`、`max-width: 250`,水印「(默认图标)」,绑定 `SplashCustomLogoSource`,右侧附带图片文件选择按钮)、启动文字(文本框 `min-width: 200`、`max-width: 300`,水印「正在启动…」,绑定 `SplashCustomText`)、显示详细状态信息(开关,绑定 `ShowDetailedStatusOnSplash`,默认 `false`,`Settings.cs:222`)、「自定义启动界面功能已被您所在的组织禁用。」(当 `DisableSplashCustomize` 时替换上面三项)、「预览启动界面」(可点击行,图标 `E44E`,点击显示 5 秒启动界面并置 `IsSplashPreviewing`,行尾显示 24×24 进度环)(`:191-243`)。
3. **界面分区(图标 `F485`,标题「界面」)**:
- 隐藏窗口:TabControl(compact)切换 `HideMode`(默认 `0`)。
- 「基础模式」页:三个复选框——上课时(`HideOnClass`,默认 `false`)、前台是最大化窗口时(`HideOnMaxWindow`,默认 `false`)、前台是全屏窗口时(`HideOnFullscreen`,默认 `false`);
- 「高级模式」页:按钮「编辑规则集…」(图标 `F17E`)打开宽度 `600px` 的规则集抽屉(`HideRules`)(`:249-278`);
- 动画级别:下拉宽度 `120px`,绑定 `AnimationLevel`,默认 `1`(标准),选项:禁用(0)、标准(1)、华丽(2)。**修改后必须请求重启**(`GeneralSettingsPage.axaml.cs:37-44`)(`:281-293`);
- 禁用动画等待:开关绑定 `IsWaitForTransientDisabled`,默认 `false`,**修改后必须请求重启**(`Settings.cs:206`、`.axaml.cs:37-44`);
- 降低主界面进度条精度:开关绑定 `ReduceProgressAccuracy`,默认 `true`(`Settings.cs:1853`)(`:306-312`)。
#### 10.4.2 时钟(id `clock`,图标 `E4C4/E4C3`,分组「通用」)
源 `ClockSettingsPage.axaml`。
1. **顶部时钟**:文本 `HH:mm:ss`,`font-size: 36px`,`font-weight: 500`;下方为日期长格式文本(次级说明样式)(`:15-20`)。
2. 「使用精确时间」卡片:开关绑定 `IsExactTimeEnabled`(默认 `true`,`Settings.cs:140`),展开后子项:
- 「时间服务器」文本框绑定 `ExactTimeServer`,默认 `ntp.aliyun.com`(`Settings.cs:139`);
- 「立即同步」按钮(图标 `E0B5`),下方显示 `ExactTimeService.SyncStatusMessage` 状态文本(`:24-53`)。
3. 「时间偏移」卡片:右侧带「秒」后缀的数值框(`min-width: 147`,Inline 步进),绑定 `TimeOffsetSeconds`,默认 `0`(`Settings.cs:141`);展开后子项:
- 「自动时间偏移」:数值框(同样 `min-width: 147`,绑定 `TimeAutoAdjustSeconds`,默认 `0`,`Settings.cs:147`)与开关(绑定 `IsTimeAutoAdjustEnabled`,默认 `false`,`Settings.cs:146`)并排,开关关闭时数值框禁用;
- 「对齐铃声」(可点击行,无图标)→ 打开 `TimeAdjustmentWindow`(详见 part 11)(`:56-91`)。
#### 10.4.3 存储(id `storage`,图标 `E6B7/E6B6`,分组「通用」)
源 `StorageSettingsPage.axaml`。
1. 当存在配置文件错误(`ConfigureFileHelper.Errors.Count > 0`)时,顶部显示 Warning InfoBar:「ClassIsland 部分配置文件已损坏且无法加载,这些配置文件已恢复至默认值。」带「详细信息…」按钮(图标 `EC2E`)跳转 `classisland://app/config-errors`(`:17-28`)。
2. **备份分区(图标 `E081`,标题「备份」)**:
- 「启用自动备份」:开关绑定 `IsAutoBackupEnabled`,默认 `true`(`Settings.cs:187`)。展开后子项:
- 自动备份间隔:数值框最小 `0`、宽 `150px`、后缀「天」、Inline,绑定 `AutoBackupIntervalDays`,默认 `7`(`Settings.cs:191`);
- 自动备份上限:数值框最小 `0`、宽 `150px`、后缀「个」,绑定 `AutoBackupLimit`,默认 `16`;说明「设置为 0 则为无上限。」(`Settings.cs:190`);
- 上次成功自动备份:只读展示 `LastAutoBackupTime`(`Settings.cs:188`)。
- 「手动备份」卡片:右侧在有操作时显示 20×20 进度环,成功后显示 `E423` 图标(tooltip「备份成功。」)。子项:立即备份(点击,备份中禁用)、查看备份文件…(`EC2D`)、恢复备份…(`EC2D`,说明「重启到恢复模式并恢复到指定的备份。」)、备份文件占用大小(只读 `BackupFilesSize`,默认「计算中...」,`Settings.cs:189`)。手动备份内容必须包含 `Config/` 全部配置文件、`Profiles/` 档案文件与 `Settings.json`(`:76-107`)。
#### 10.4.4 隐私(id `privacy`,图标 `EF65/EF64`,分组「通用」)
源 `PrivacySettingsPage.axaml`。必须实现:
1. 标题「匿名使用情况分析」(`SubtitleTextBlockStyle`),正文「本应用将通过 SentrySDK 来收集本应用中的使用情况与错误信息,以改进本应用。您的信息会匿名上传,不会包含您的个人信息。您可以自行启用或禁用此功能。」;下方「更多信息请见《ClassIsland 隐私政策》。」(超链接打开隐私政策)(`:18-31`)。
2. 「启用匿名使用情况分析」卡片:开关绑定 `IsSentryEnabled`(存储于本地全局存储,默认 `true`;`Settings.cs:518-535`)。说明中必须保留「为了确保环境块完全刷新,可能需要重启计算机才能使本设置完全生效。」(`:33-39`)。策略 `CanPolicyDisable`(容器 Tag)语义:允许被集控禁用【推断】。
#### 10.4.5 翻新与迎新(id `refreshing`,图标 `E0B8/E0B9`,分组「通用」)
源 `RefreshingSettingsPage.axaml`。实现要点:
1. 「定期翻新」卡片:开关绑定 `IsRefreshingToastEnabled`(默认 `true`,`Settings.cs:1865`);说明「当在长时间未启动 ClassIsland 的寒假/暑假等学期结束后的长假后启动 ClassIsland 时,显示桌面通知询问是否重新设置应用的部分设置以适应新学期。」展开后子项为「默认翻新范围」(文本)+ 4 个复选框:
- 档案(`RefreshingScopes.Profile`),勾选后的副作用文案必须变为「将创建并切换到一个全新的配置文件。」;
- 课表(`RefreshingScopes.ProfileClassPlans`)、时间表(`RefreshingScopes.ProfileTimeLayouts`)——仅当「档案」未勾选时显示,并在其右侧提供「选择保留的内容…」按钮(图标 `F1A8`)打开 `RefreshingScopesConfigDialog`;「课表」「时间表」互斥逻辑:不勾选「档案」时才出现;
- 组件配置(`RefreshingScopes.Components`,副作用文案「将禁用已经启用的插件。」适用于插件项);
- 自动化(`RefreshingScopes.Automations`);
- 插件(`RefreshingScopes.Plugins`,勾选后描述为「将禁用已经启用的插件。」);
- 「立即翻新」(可点击行,图标 `E44E`)(`:14-58`)。
2. 「迎新模式」卡片:开关绑定 `RefreshingToastIsOnboardingGuide`(默认 `false`,`Settings.cs:1869`);说明必须包含「此选项在生效一次后将自动关闭。」;展开后子项:通知标题(文本框 `min-width: 120`、`max-width: 450`,绑定 `OnboardingToastTitle`,默认见 `RefreshingService.DefaultOnboardingToastTitle`)、通知内容(多行文本框,`AcceptsReturn`,绑定 `OnboardingToastBody`)、「重置为默认值」、「测试通知」(`:61-86`)。
3. 分区「通知选项」(图标 `E408`):
- 「通知显示间隔」:数值框最小 `1`、最大 `33550337`、格式 `0`、后缀「天」,绑定 `RefreshingToastThresholdDays`,默认 `20`(`Settings.cs:1866`);子项「在下次启动时显示通知」:开关绑定 `ShowRefreshingToastOnNextStart`(默认 `false`),开关开启时旁边额外显示「立即退出应用」按钮(`:92-118`);
- 「最高通知显示次数」:数值框最小 `1`、最大 `16`、后缀「次」,绑定 `MaxRefreshingToastCounts`,默认 `5`(`Settings.cs:1873`);子项显示「剩余通知次数:{LeftRefreshingToastCounts}」,当剩余次数 `!== 0` 时显示「清空」按钮(`:121-154`)。
#### 10.4.6 高级(id `advanced`,图标 `F49F/F49E`,分组「通用」)
源 `AdvancedSettingsPage.axaml`。
1. 顶部必须显示不可关闭的 Warning InfoBar:标题「小心!」,正文「修改本页面的设置存在风险,请谨慎操作。」(`:13-16`)。
2. 「自动禁用异常插件」开关:绑定 `AutoDisableCorruptPlugins`,默认 `true`(`Settings.cs:1844`)(`:18-25`)。
3. 「渲染模式」下拉(仅 Windows 显示):选项「默认(0)/软件(不推荐)(1)/AngleEgl(2)/Wgl(3)/Vulkan(4)」,**修改后重启生效**,此设置仅本设备生效(`:28-46`)。
4. 「使用系统标题栏」开关(仅 Windows 且非 arm64):需重启【推断】(`:49-61`)。
5. 「兼容缩放模式」开关(仅 Linux):忽略 `QT_SCREEN_SCALE_FACTORS` 等环境变量;需重启【推断】(`:63-74`)。
#### 10.4.7 组件(id `components`,图标 `E06F/E06F`,分组「主界面」)
源 `ComponentsSettingsPage.axaml`(644 行,非 `settings-container` 结构,必须自绘三栏式编辑器)。
1. **整体布局**:外层 `margin: 12px 0`,6 行:0 Auto(占位)、1 Auto(配置方案栏)、2 行高 `64`(最小 `43.2`、最大 `250`,行列表)、3 Auto(分隔条 `height: 2px`,`margin: 0 -12px`)、4 `*`(组件库/设置区)、5 Auto(InfoBar)(`:162-171`)。
2. **配置方案栏**:左侧说明文本「以下组件将显示在主界面上,从组件库拖动组件以添加组件。」;右侧:字段「配置方案」+ 下拉(`min-width: 150`、`max-width: 300`,绑定 `CurrentComponentConfig`,默认 `"Default"`,`Settings.cs:159`)、刷新按钮(`E0B5`)、新建…(`E00D`)、打开配置文件夹…(`E88D`)(`:174-205`)。
3. **主界面行列表**:每行高 `40px`,结构为「拖拽把手(宽 20)+ 组件横向列表 + 选中态操作区」。选中某行时右侧浮出操作区:`padding: 4px`、圆角 `4px`、背景 `AcrylicBackgroundFillColorBaseBrush`,内含 4 个按钮:主要行开关(`E994`,tooltip「此行是主要行」)、提醒开关(`E02B`,tooltip「此行可以显示提醒」)、在下方插入一行(`E00D`)、删除(`E61D`,`IndianRed`)(`:207-288`)。
4. **组件横向列表**:高度 `40px`,水平滚动;每项 = 拖拽把手 + `14×14` 图标 + 名称 + 选中时出现的「更多选项…」(`EBAC`)菜单与「查看子组件」(`EC30`,仅容器组件)与删除按钮(`E61D`)。「更多选项」菜单项固定顺序:向上移动一行(`E197`)、向下移动一行(`E0CB`)、移动到选中的容器(`E280`)、从容器组件移出(`E286`)、分隔线、包裹到新容器(子菜单列出全部容器组件,带图标)、创建副本(`EEB9`)(`:27-155`)。
5. **子组件视图**:选中容器组件并点击「查看子组件」后,在组件设置区上方出现卡片(圆角 `4px`、边框 `CardStrokeColorDefaultBrush`、背景 `CardBackgroundFillColorDefaultBrush`),头部 `padding: 8px`,含「返回上一层级」按钮(`E0C7`)、标题「此组件的子组件」、「关闭」按钮(`E671`)(`:296-340`)。
6. **下方 TabControl**:`compact`、`max-width: 960px`、内容拉伸,四个页签:
- 组件库(按来源分组,组名 `font-weight: 600`;每个组件项高 `50px`:拖拽把手宽 24 + 32×32 图标 + 名称 + 12px 描述;每组使用 WrapPanel,目标宽度 `225px` 自动换列)(`:348-413`);
- 组件设置(仅在选中组件时显示,渲染该组件的设置控件)(`:414-417`);
- 高级设置(选中组件的通用高级设置,含「按规则隐藏」开关与规则集编辑)(`:418-422`);
- 行设置(选中行时显示):按规则隐藏(开关+编辑规则集按钮)、分体主界面(下拉:继承(0)/禁用(1)/启用(2))、分区「外观」(图标 `EC4A`):行不透明度滑块(`0.1–1`,步进 `0.05`,`F2`)、自定义背景颜色(颜色选择器+开关)、自定义背景不透明度(滑块 `0~1`)、自定义背景圆角半径(滑块 `0–20`,步进 `0.5`,`F1`)、分区「字体」(图标 `F26F`):字体大小(1–40px 四档,覆盖外观设置)、字体颜色(颜色选择器+开关)(`:423-614`)。
7. 右下角浮动工具条(选中组件时):AnimatedIconButton「编辑…」(图标 `F17E`,仅在「按规则隐藏」开启时可点)+ 复选框「按规则隐藏」(绑定 `SelectedComponentSettings.HideOnRule`)(`:617-631`)。
8. 底部 InfoBar:「新功能:现在可以使用⌈编辑模式⌋更加直观地编辑主界面上的组件!」+「进入编辑模式」按钮 → `classisland://app/edit?fromSettingsPage=true`(`:634-642`)。
#### 10.4.8 外观(id `appearance`,图标 `E51E/E51D`,分组「主界面」)
源 `AppearanceSettingsPage.axaml`。必须严格按下列默认值实现(值来源 `Settings.cs`):
1. 「界面缩放」:滑块宽 `180px`,最小 `0.75`、最大 `2.5`、步进 `0.05`、吸附、拖动显示 `F2`,绑定 `Scale`,默认 `1.0`(`Settings.cs:86`)(`:27-40`)。
2. 「背景颜色」:颜色选择器(仅自定义开启时显示,`margin-right: 6px`)+ 开关,绑定 `BackgroundColor`(默认 `Black`)与 `IsCustomBackgroundColorEnabled`(默认 `false`)(`Settings.cs:33-34`)。
3. 「背景不透明度」:滑块 `0–1`、步进 `0.05`、`F2`,绑定 `Opacity`,默认 `0.5`(`Settings.cs:64`)。
4. 「圆角半径」:滑块 `0–20`、步进 `0.5`、`F1`,绑定 `RadiusX`,默认 `8.0`(`Settings.cs:183`;Y 半径 `RadiusY` 同为 `8.0`,本页只暴露 X)。
5. 「行间隔」:滑块 `0–150`、步进 `5`、`F1`,宽 `130px`;右侧数值框宽 `125px`、最小 `0`;绑定 `MainWindowLineVerticalMargin`,默认 `5`(`Settings.cs:219`)。
6. 「分体主界面」:开关绑定 `IsIslandSeperated`,默认 `false`(`Settings.cs:1852`),说明「启用后组件会分开。」。
7. 分区「主题」(图标 `E51E`,标题「主题」):
- 「应用主题」下拉:跟随系统(0)/明亮(1)/黑暗(2),绑定 `Theme`,默认 `2`(`Settings.cs:32`),每项带图标(`E5CB`/`F465`/`F44B`);
- 「主题色来源」下拉:自定义(0)/系统壁纸(1,禁用隐藏)/系统(2,默认)/屏幕主题色(3,禁用隐藏),绑定 `ColorSource`,默认 `2`(`Settings.cs:83`);
- 「强调色」颜色选择器:仅在 `ColorSource === 0` 时显示,绑定 `PrimaryColor`,默认 `#00BFFF`(`Colors.DeepSkyBlue`,`Settings.cs:35`);副色 `SecondaryColor` 默认 `#7FFFD4`(Aquamarine),本页未暴露【推断:仅由主题使用】。
8. 分区「字体」(图标 `F26F`,标题「字体」):
- 「自定义字体」下拉:`max-dropdown-height: 360px`,弹出层宽 `280px`,底部右对齐;每项按自身字体渲染、单行截断、`height: 20px`,默认项显示文本「HarmonyOS Sans SC(默认)」;绑定 `MainWindowFont`,默认 `MainWindow.DefaultFontFamilyKey`(`Settings.cs:95`);
- 「自定义字重」下拉:选项 Thin/ExtraLight/Light/Normal/Medium(默认)/SemiBold/Bold/ExtraBold/Black/ExtraBlack,每项以对应字重预览;绑定 `MainWindowFontWeight2`(整型,默认 `(int)FontWeight.Medium` = 500,`Settings.cs:101`);
- 「字体大小」四行:次级字体大小(默认 `14`)、正文字体大小(`16`)、强调字体大小(`18`)、大号字体大小(`20`);均为「px」后缀数值框,范围 `1–40`,宽 `175px`(`Settings.cs:197-200`);
- 「字体颜色」:颜色选择器(仅开启时)+ 开关,绑定 `CustomForegroundColor`(默认 `#1E90FF`,DodgerBlue)与 `IsCustomForegroundColorEnabled`(默认 `false`)(`Settings.cs:202-203`)。
9. 底部 TabControl(compact)两个页签:
- 「字体预览」:只读可编辑文本框,`font-size: 17px`、`padding: 8px`、居中;内容必须为四行:「当有一天你不再纠结于答案 当我们又重逢于天涯或沧海」/「语文 数学 英语 历史 物理 政治 化学 生物 地理」/「the quick brown fox jumps over a lazy dog」/「THE QUICK BROWN FOX JUMPS OVER A LAZY DOG」/「8:00-12:00 1234567890 +-*/」;应用当前字体与字重(`:553-569`);
- 「字号预览」:字段「测试文本」+ 四行分别以四种字号渲染同一文本(`:570-591`)。
#### 10.4.9 窗口(id `window`,图标 `F485/F484`,分组「主界面」)
源 `WindowSettingsPage.axaml`。
1. **停靠位置选择器**(`:24-127`):左侧为 `220px` 高的模拟屏幕边框(`border: 8px solid TextFillColorPrimaryBrush`、圆角 `4px`),屏幕底部叠加 `16px` 高的模拟任务栏(背景 `#99000000`,左端 `E064` 图标 `10×10`、`margin: 3px`,右端时间文本 `8px` Light)。屏幕内 6 个单选按钮,分两行三列,分别对应 `WindowDockingLocation` 值 `0`(左上)、`1`(上中,默认)、`2`(右上)、`3`(左下)、`4`(下中)、`5`(右下)(默认值 `Settings.cs:48`)。右侧:字段「向右偏移」(`WindowDockingOffsetX`,默认 `0`,`Settings.cs:49`)、「向下偏移」(`WindowDockingOffsetY`,默认 `0`,`Settings.cs:50`)、「显示器」下拉(列出所有屏幕:「图标 + 显示名 (宽x高)」,绑定 `WindowDockingMonitorIndex`,默认 `0`,`Settings.cs:51`)、「刷新显示器列表」按钮(`E0B5`)、说明文本「应用会以左图的位置显示在指定的显示器上。如果指定的显示器不存在,那么应用会显示在主显示器上。」。
2. **InfoBar**:RawInput 初始化失败时显示 Warning「初始化 RawInput 失败,将使用兼容模式判断光标位置。详细请查看日志。」;启用 RawInput 时显示「使用原始输入判断鼠标位置时,如果前台窗口的进程以管理员身份运行,可能会出现无法判定鼠标位置的情况。以管理员身份运行 ClassIsland 可以解决此问题。」(`:130-138`)。
3. **「窗口层级」**(`:141-188`):下拉默认选中 `WindowLayer`,默认 `1`(置顶);选项:置底(0)、置顶(1)。子项「窗口层级设置频率」下拉绑定 `WindowTopmostRecheckMode`(默认 `0`):窗口层级变化时(默认)(0)、前台窗口变化时(1)、「每 50ms」(2,后带橙色 `F431` 图标)、「每 1ms」(3,后带橙红色 `F431` 图标)。
4. **「指针移入淡化」**(`:191-236`):开关绑定 `IsMouseInFadingEnabled`(默认 `true`,`Settings.cs:193`);子项:
- 「使用原始输入」开关(仅 Windows),绑定 `UseRawInput`,默认 `false`(`Settings.cs:192`);
- 「触屏点击后自动恢复时间」:数值框 `width: 150px`、最小 `0`、后缀「ms」、Inline,绑定 `TouchInFadingDurationMs`,默认 `0`(`Settings.cs:195`);仅当启用原始输入时显示;
- 「反转指针移入淡化」开关:绑定 `IsMouseInFadingReversed`,默认 `false`(`Settings.cs:194`)。
5. **「使用原始屏幕尺寸」** 开关:绑定 `IsIgnoreWorkAreaEnabled`,默认 `false`(`Settings.cs:1835-1838`),说明「若启用,主界面调整位置时将忽略系统工作区设置(通常包括任务栏和部分顶栏应用)。」(`:239-245`)。
6. **「录制模式」** 开关:绑定 `IsScreenRecordingModeEnabled`,默认 `false`(`Settings.cs:1861`),说明必须包含「这会导致主界面窗口显示在任务栏中且主界面窗口只会在一个虚拟桌面上显示」(`:248-254`)。
7. **「阻止截图」**(仅 Windows):开关绑定 `IsWindowCaptureBlockingEnabled`,默认 `false`(`Settings.cs:1862`)(`:256-267`)。
#### 10.4.10 提醒(id `notification`,图标 `E02B/E02A`,分组:Internal 无分组)
源 `NotificationSettingsPage.axaml`(497 行)。必须实现为「左列表 + 右详情」两栏布局(`ListDetailView`,两栏等分;容器 `margin: 12px`;压缩阈值 `800px`,压缩时变为列表→详情可返回的单栏,见 `Controls/ListDetailView.axaml.cs:49-56`、`.axaml:46-71`)。
1. **左栏**:
- 「启用提醒」卡片:开关绑定 `IsNotificationEnabled`,默认 `true`(`Settings.cs:91`);说明「启用提醒后,将会在对重要事件发出醒目提醒。拖动提醒提供方以调整对应类型提醒的优先级。」(`:64-72`);
- 当所有主界面行都关闭提醒时显示 Warning InfoBar:「提醒可能无法显示」「您禁用了所有主界面行的提醒功能,这可能会导致所有提醒都无法显示。要修复此问题,您需要在【组件】页面至少为一个主界面行开启提醒。」+「前往【组件】页面」按钮(`:74-83`);
- TabControl(compact):页签「提醒提供方」= 提供方列表;页签「高级设置」= 全局提醒设置。
- 提供方列表项:拖拽把手 + `24×24` 图标(`margin: 6px 6px 10px 6px`)+ 名称(`font-size: 16px`,`font-weight: 700`)+ 描述(`12px` 次级)+ 右侧设置指示器;选中项的底部展开「提醒渠道」Expander(仅有渠道时显示),渠道列表项:`24×24` 图标 + 名称 `14px` Medium + 描述 `12px`(`:86-204`);
- 高级设置页:见下 4 小节。
2. **右栏**(选中提供方后):
- 页签「基本设置」:渲染该提供方的设置控件;无设置时居中显示 `72×72` 图标 `EF26` + 「该提醒提供方没有设置。」(`:372-390`);
- 页签「高级设置」:首行「对此提醒来源启用特殊高级设置」开关(绑定 `ProviderSettings.IsSettingsEnabled`,说明「启用后,此处的设置将覆盖全局设置和提醒请求中要求的设置。」),随后 InfoBar 提示同样文案;四个开关:启用提醒语音(`IsSpeechEnabled`)、启用提醒强调特效(`IsNotificationEffectEnabled`)、启用提醒音效(`IsNotificationSoundEnabled`,展开后含「自定义音效」文本框 + 文件选择)、置顶提醒(`IsNotificationTopmostEnabled`)(`:391-495`)。
3. **全局高级设置四组**:
- 「启用提醒语音」开关 `AllowNotificationSpeech`(默认 `false`,`Settings.cs:153`)→ 子项:复选框「默认启用」`IsSpeechEnabled`(默认 `true`,`Settings.cs:135`)、语音引擎下拉(`SelectedSpeechProvider`,默认 `"classisland.speech.edgeTts"`,`Settings.cs:1847`)、语音引擎设置控件(动态)、语音音量滑块(`0–1`,步进 `0.01`,`F2`,宽 `150px`,绑定 `SpeechVolume`,默认 `1.0`)、测试朗读文本框、测试语音按钮(`:223-281`);
- 「启用提醒强调特效」开关 `AllowNotificationEffect`(默认 `true`,`Settings.cs:154`)→ 子项「默认启用」`IsNotificationEffectEnabled`(默认 `true`,`Settings.cs:142`)(`:285-297`);
- 「启用提醒音效」开关 `AllowNotificationSound`(默认 `false`,`Settings.cs:155`)→ 子项:「默认启用」`IsNotificationSoundEnabled`(默认 `true`)、音效音量滑块(`NotificationSoundVolume`,默认 `1.0`,`Settings.cs:182`)、自定义音效(文本框 `150px`,水印「(默认音效)」,`NotificationSoundPath` 默认空,附音频文件选择按钮)(`:300-349`);
- 「置顶提醒」开关 `AllowNotificationTopmost`(默认 `true`,`Settings.cs:156`)→ 子项「默认启用」`IsNotificationTopmostEnabled`(默认 `true`,`Settings.cs:149`)(`:352-364`)。
#### 10.4.11 天气(id `weather`,图标 `F44F/F44E`,分组:Internal 无分组)
源 `WeatherSettingsPage.axaml`。
1. **天气预览卡**(`:68-150`):背景 `ControlFillColorDefaultBrush`,`padding: 20px`,圆角 `12px`,内部行间距 `16px`。三列:
- 左:天气图标(宽 `52–120`、高 `52`)+ 温度(`font-size: 32px` SemiBold,单位 `18px`)+ 「更新于 HH:mm」(`12px`,`opacity: 0.6`);
- 中(间距 `28px`):湿度、空气质量、风力三组,每组标签 `12px`(`opacity: 0.8`)+ 图标 `14px` + 数值 Medium;
- 右:刷新按钮 `40×40`(图标 `E0B5`);刷新中改为 40×40 进度环;
- 灾害预警列表:每条为 `Expander`(`padding: 12px 8px`,`margin-top: 4px`),头图为 `22×22` 图标 + 标题;展开显示详情(`13px`,`opacity: 0.8`)。
2. **位置设置**(`:154-275`):卡片右侧用 Chip ListBox 切换 `WeatherLocationSource`,两个选项「选择城市」(0)、「坐标定位」(1,仅平台支持定位时显示,默认 `0`,`Settings.cs:1840`)。子项:
- 城市选择(仅 0):显示 `CityName`(默认「北京 (北京, 中国)」,`Settings.cs:100`)+ 「编辑城市」按钮(`E7C9`,主色)→ 打开宽 `320px` 的城市搜索抽屉:标题「搜索城市」、搜索框(水印「搜索城市或省份…」,右侧加载时显示 24×24 进度环)、结果单选列表(每项 RadioButton)、空态「找不到符合条件的城市。请更换搜索关键字,然后再试一次。」(`:26-64`);
- 位置坐标(仅 1):经度/纬度文本框(`min-width: 75px`,前缀「经度/纬度」、后缀「°」),支持「显示坐标」遮罩(模糊 `blur(16px)` + `opacity: 0.75` + 禁止命中,过渡 `150ms` 曲线 `cubic-bezier(0,1,0,1)`)、「自动定位」按钮(`EBD2`)(`:191-258`);
- 自动获取位置(仅 1):开关 `AutoRefreshWeatherLocation`,默认 `false`(`Settings.cs:1841`)。
3. 分区「外观与高级设置」(图标 `E713`):
- 「天气图标包」下拉:选项来自注册的天气图标模板,绑定 `WeatherIconId`,默认 `"classisland.weatherIcons.lucide"`(`Settings.cs:1849`),`min-width: 120px`;
- 「排除的气象预警」:文本域(宽 `260px`、`max-height: 200px`、多行),绑定 `ExcludedWeatherAlerts`(字符串集合,一行一条);说明「包含以下字符串的预警将不会显示,一行一条排除项。」;
- 「不使用 TLS 协议获取天气」开关:绑定 `NoTLSWeatherRequests`,默认 `false`(`Settings.cs:1842`)。
4. 底部提示块(`ControlFillColorDefaultBrush`、圆角 `8px`、`padding: 12px`、`font-size: 12px`、`opacity: 0.8`):必须含两个跳转链接「【提醒】页面」→ `classisland://app/settings/notification`、「【组件】页面」→ `classisland://app/settings/components`(`:321-329`)。
#### 10.4.12 自动化(id `automation`,图标 `EEF1/EEF0`,分组:Internal 无分组)
源 `AutomationSettingsPage.axaml`。
1. **首次使用须知页**(`IsAutomationWarningVisible === true` 时整页替换,默认 `true`,`Settings.cs:161`):居中,顶部 75×75 应用图标 + 箭头(`E00C`,25px,左右 `margin: 16px`)+ 75×75 自动化图标;标题「自动化」;正文两行(带全角缩进)「 欢迎使用自动化!」「 自动化可以使 ClassIsland 根据条件自行动出行动,」「 比如修改应用设置、打开应用,等等。」;「阅读自动化帮助」链接按钮(`E C2D`,主色);警告框(`SystemFillColorCautionBackgroundBrush`,`padding: 10px 10px 14px`,圆角 `4px`):图标 `F430` + 「警告」+ 正文「自动化允许自动修改 ClassIsland 的各项设置,并调用」「 外部程序,可能有一定安全风险。」「 不当的自动化编写可能对正常教学造成影响,请勿滥用。」;底部 accent 按钮「同意并开始使用自动化」(`E424`)(`:333-400`)。
2. **主界面**(两栏 `ListDetailView`):左栏:
- 「启用自动化」卡片:开关绑定 `IsAutomationEnabled`(默认 `false`,`Settings.cs:160`),右侧带「帮助…」按钮(`E23C`,tooltip「打开自动化帮助文档…」);该卡片自适应宽度阈值必须设为 `495px`(`:25-49`);
- 操作行:「添加自动化」按钮(`E00D`,主色,tooltip「添加一个自动化。」);右侧配置方案下拉(`min-width: 120`、`max-width: 150`,绑定 `CurrentAutomationConfig`,默认 `"Default"`,`Settings.cs:162`,打开下拉时刷新列表)+「新建…」(`E689`)+「打开…」(`E88D`);
- 自动化列表:每项高 `42px`,结构 `拖拽把手(20) + 名称(13px Medium) + WorkflowProgressIndicator(透明度 0→1,过渡 100ms) + 启用开关`;列表项可拖拽排序(垂直方向阈值 `3px`)(`:87-157`)。
3. **右栏标题区**(选中自动化后):自动化名称文本框(`min-width: 150`、`font-size: 15px` Medium、水印「自动化名称」)+「启用恢复」复选框(tooltip「该自动化是否启用恢复。」)+ 运行状态控件 + 分隔竖线(`1×25px`,`TextFillColorDisabledBrush`)+「复制」按钮(`E58B`)+「删除」按钮(`E61D`,`IndianRed`);该组容器最小宽度按恢复开关状态为 `359px` 或 `391px`(`:161-217`)。
4. **右栏编辑器**(三个 Expander,均默认展开,间距 `margin: 2px 2px 2px 10px`,标题栏背景 `CustomizedAccentBarBackground1Brush`):
- 「当事件触发时」(图标 `E84F`):触发器设置控件(绑定 `Triggers`);
- 「并且满足规则集时」(图标 `F17E`):标题行右侧开关绑定 `IsConditionEnabled`;关闭时显示「工作流运行时不会考虑规则集。」;
- 「触发行动」(图标 `E01F`):行动设置控件(绑定 `ActionSet`);
- 若 `ActionSet.IsRevertEnabled` 为真,底部显示提示行(图标 `E01D`)「当规则集不再满足时,将自动恢复行动。」(`:223-323`)。
5. 未选中自动化时的空态:居中 `75×75` 贴纸(`光辉矢愿_瞄准.png`,缺失时回退图标 `EEF1`)+ 「在左侧选择或添加一个自动化,然后在此处进行设置。」+「阅读自动化帮助」链接(`:299-320`)。
#### 10.4.13 更新(id `update`,图标 `E161/E160`,分组:Internal 无分组;仅当打包类型允许更新时注册)
源 `UpdateSettingsPage.axaml`。
1. **状态头部**:左侧 `48px` FluentIcon,图标随 `LastUpdateStatus` 变化;当「正在下载更新」时图标以 `1s` 无限循环旋转(`cubic-bezier(0.50,0.25,0.50,0.75)`,0°→360°)(`:77-134`)。右侧第一行 `font-size: 24px` Medium 的状态文案;第二行:已是最新时显示「上次检查更新时间:{LastCheckUpdateTime}」,否则显示「发现新版本:{DistributionInfo.Version}」。
2. InfoBar:网络错误(Error,可关闭,标题「更新时发生错误,请检查您的网络连接并重试。」+ 异常消息)、部署错误(Error,可关闭,标题「部署时发生错误,请稍后重试。」)、已部署(Informational,标题「更新将在重启后应用」,正文「已成功部署更新,下次启动应用时将启动新版本。您也可以现在重启以立即使用新版本的 ClassIsland。」)(`:135-152`)。
3. 操作按钮(随状态切换,`AnimatedPanel` 自适应换行,非 Idle 时整体禁用):
- 已是最新:「检查更新」(accent);
- 有更新:「下载并安装」(accent SplitButton,Flyout 含「仅下载」)、「检查更新」;
- 已下载:「安装更新」(accent);
- 已部署:「重启并应用更新」(accent);
- 非「已是最新/已部署」状态另有一个「检查更新」普通按钮(`:153-225`)。
4. 下载进度区(非 Idle 时可见):`ProgressBar`(`max = DownloadingCountTotal`,`value = DownloadedCount`,非「下载更新」状态或进度不确定时为 indeterminate)+ 状态文案(`UpdateWorkingStatus` 映射);另有两个按钮「查看下载状态」(打开宽度 `450px` 的下载任务抽屉)、「取消」(仅下载中显示)(`:227-277`)。
5. TabControl(compact,Idle 时可用):
- 「更新日志」:Markdown 渲染 `NewVersionChangeLogDocument`;已是最新时显示 81×81 贴纸(`光辉矢愿_遨游.png`)+ 「已经是最新版啦,真棒,夸夸你哦♪」(禁用彩蛋时改为「真棒,您已更新到最新版本!」)+「查看更新日志」按钮(打开宽度 `500px` 的更新日志抽屉)(`:291-341`);
- 「更新设置」:「更新模式」下拉(从不自动更新(0)/自动检查更新并通知(1)/自动检查更新并下载(2)/自动检查更新并安装(3),绑定 `UpdateMode`,默认 `3`,`Settings.cs:77`);「更新通道」下拉(选项来自分发元数据,绑定 `SelectedUpdateChannelV3`,默认 `Guid.Empty`,`Settings.cs:1854`)+ 当前通道描述 + 「强制检查更新」(可点击行,图标 `E44E`,说明「强制将应用更新到当前通道上的最新版本,即使此版本比应用当前版本更旧。」)(`:342-385`);
- 「(debug)调试设置」页签仅开发版显示:覆盖 PDC 根 Url(`DebugPhainonRootUrlOverride`)、覆盖子频道(`DebugSubChannelOverride`)、覆盖元数据公钥(`DebugPublicKeyOverride`,多行)(`:386-399`)。
#### 10.4.14 插件(id `classisland.plugins`,图标 `E071/E071`,External;`[FullWidthPage]` 全宽)
源 `PluginsSettingsPage.axaml`(884 行)。
1. **首次使用警告页**(`IsPluginMarketWarningVisible`,默认 `true`,`Settings.cs:204`):居中,75×75 应用图标 + `E00C`(25px)+ 75×75 插件图标 `E070`;标题「插件」;正文「欢迎使用插件!插件可以扩展 ClassIsland 的功能,比如添加一个组件,或者和其它软件联动,等等。」;警告框(宽 `320px`,`SystemFillColorCautionBackgroundBrush`,圆角 `4px`,`padding: 8px`)文案必须完整保留(含「插件可以以 ClassIsland 的身份使用各项系统资源并请求任意权限……」);底部 accent 按钮「同意并开始使用插件」(`E424`)。整个主界面在此状态下必须 `IsEnabled=false`(`:509-511`、`:768-823`)。
2. **主布局**:两栏,左栏 `margin: 4px 0`:
- 顶部搜索框(水印「搜索插件…」,左侧 `EEF3` 图标,`padding: 26px 4px`、`border-thickness: 0`),支持 Enter/输入过滤(`:518-531`);
- 下方工具栏:TabStrip(市场(0)/已安装(1),compact)+「刷新」按钮(`E0B5`)+「更多选项…」(`EBAC`)菜单:从缓存重载插件源(`E0B5`)、管理插件源…(`E99A`)、插件更新设置…(`E161`)、分隔线、从本地安装…(`E071`)、分隔线、打开插件文件夹…(`E88D`);加载插件源时工具栏下方显示不确定 ProgressBar(`:532-576`);
- 插件列表项:`48×48` 图标(`margin-right: 6px`)+ 名称(`16px` Medium)+ 描述(`12px`)+ 右侧状态图标组(下载量、Stars、卸载待重启、需重启 `E0BC`、错误 `E808`、有更新 `E1A0`、已安装 `E5AC`,均 `16×16`)+ 操作按钮组(安装/更新 28×28 圆角按钮、进度环 28×28、重启按钮、启用复选框);本地插件项 `opacity` 规则:未加载本地插件 `0.75`、已加载 `1`、市场项 `1`(`:577-756`);
- 空列表模板:居中 `72×72` 贴纸(`光辉矢愿_不可以看.png`)+ 「找不到符合条件的插件,请调整筛选条件后重试」(本地且无插件时改为「还没有安装任何插件,不妨去插件市场看看吧」+「前往插件市场」按钮 `F08B`)(`:461-505`)。
3. **右栏详情**(选中插件):头部 `60×60` 图标(`margin-right: 8px`)+ 名称(`24px` Medium)+ 元信息行「版本 | 项目主页 | 作者 |」+ 状态胶囊(圆角 `999px`、`padding: 10px 3px`):已加载(`SystemFillColorSuccessBackgroundBrush`,`E426`)、已禁用(`CardBackgroundFillColorSecondaryBrush`,`E673`)、错误(`SystemFillColorCriticalBackgroundBrush`,`E673`)、未加载;操作区:已启用复选框、更新按钮(`E1A0`)、移除按钮(`E61D`)、撤销移除(`E625`)、安装(`E0D3`)、重启以应用更改(`E0BD`)、OS 不兼容 Warning InfoBar、下载进度条、下载失败 Error InfoBar(含「更换插件镜像源」链接)(`:30-221`);
- 详情 TabStrip(compact)三个页签:概览(Markdown 渲染插件 README,加载时顶部 3px 不确定进度条)、错误信息(仅异常时显示页签,可选择文本)、依赖项(DataGrid:列「插件 ID」`*`、列「必选?」`80px` 复选框列,`font-size: 14px`)(`:222-293`)。
4. **插件更新设置抽屉**(宽 `320px`):两个复选框「插件有更新时通知我」(`IsPluginsUpdateNotificationEnabled`,默认 `true`,`Settings.cs:1859`)、「自动更新插件」(`IsPluginsAutoUpdateEnabled`,默认 `true`,`Settings.cs:1858`)(`:297-308`)。
5. **管理插件源抽屉**(宽 `500px`):内置插件源镜像下拉(`OfficialSelectedMirror`,默认 `"github"`;镜像字典默认含 `github`→`https://github.com`、`ghproxy`→`https://mirror.ghproxy.com/https://github.com`、`moeyy`→`https://github.moeyy.xyz/https://github.com`,`Settings.cs:171-176`);复选框「忽略 SSL/TLS 证书错误」(`IgnoreSslForPluginMirrors`,默认 `false`);自定义插件源表格(列:Url `4*` 可编辑、镜像 `*` 下拉),上方「添加」「删除」按钮(`:310-384`)。
6. **安装确认对话框**:宽约 400【推断】,内容为插件预览卡片(图标 `48×48` 圆角 `4px`、名称 `16px` Bold、作者 `12px` `opacity: 0.7`、版本 `v{0}`、描述 `12px`),底部警告条「安装未知来源的插件可能存在风险。」(`SystemFillColorCautionBackgroundBrush`、`padding: 10px`、圆角 `6px`),`max-height: 400px` 滚动(`:387-459`)。
7. **拖拽安装遮罩**:整页半透明遮罩(`opacity: 0.75`)+ 居中卡片(`max-width: 420px`、圆角 `12px`、`padding: 20px`、`ControlFillColorDefaultBrush` 背景、`CardStrokeColorDefaultBrush` 边框):`80px` 图标(合法 `E162` / 非法 `EA39`)+ 粗体提示 + 副提示 + 「共 N 个文件,识别到 M 个插件包」(`12px`,`opacity: 0.55`)。解析本地插件时另显示全屏遮罩 + 64×64 进度环 + 「正在解析...」(`:825-882`)。
#### 10.4.15 主题(id `classisland.themes`,图标 `E6AF/E6AE`,External)
源 `ThemesSettingsPage.axaml`。
1. **首次使用警告页**(`IsThemeWarningVisible`,默认 `true`,`Settings.cs:1848`):75×75 应用图标 + `E00C` + 主题图标 `E6AF`(75×75);标题「主题」;正文「欢迎使用主题!主题可以修改甚至完全重塑 ClassIsland 主界面的外观。您可以使用主题功能打造出独一无二的 ClassIsland 界面。」;警告框宽 `320px`(`SystemFillColorCautionBackgroundBrush`,圆角 `4px`,`padding: 8px`)文案必须完整保留(含「主题在加载过程中会转译为可执行代码,可以以 ClassIsland 的身份和权限执行任意代码」);accent 按钮「同意并开始使用主题」(`E424`)(`:309-366`)。
2. **InfoBar**(同意后可见):消息「在启用了支持的主题(如 Fluent)后,您可以在直接【外观】设置中开启【分体主界面】选项使主界面分开。……」,右侧「前往【外观】设置」链接;受 `IsThemeSeparateInfoVisible` 控制可关闭(`:77-84`)。
3. **工具栏**:TabStrip(市场(0)/已安装(1),compact);右侧 4 个透明按钮:刷新市场(`E0B5`)、重载主题(`E0BD`)、打开主题文件夹…(`E88D`)、主题加载顺序(`E13F`)(`:86-112`)。
4. **主题卡片网格**:ListBox + WrapPanel(`item-height: 150px`,行为按目标宽度 `328px` 自动换列)。卡片:圆角 `4px`、边框 `1px`、裁剪溢出;结构 = 横幅图(`UniformToFill`)+ 底部渐变遮罩(不透明遮罩从 `0%`/`65%` 透明到 `100%` 不透明)+ 底部文字区(名称 `16px` Medium + 作者 `12px` + 网站按钮 `E905`)+ 右侧操作(错误 `E808` 红色、更多选项 `EBAC`:打开主题文件夹…/打包主题…/卸载主题/撤销卸载、安装按钮 `E0D2` 28×28 主色圆角 `14px`、更新按钮 `E1A0`、下载进度环 28×28、重启按钮 `E0BC`、启用开关);未加载的本地主题 `opacity: 0.8`(`:120-307`)。
5. **主题加载顺序抽屉**(宽 `300px`):标题「主题加载顺序」,说明「主题将按照以下的顺序加载,后加载的主题可以覆盖前面主题的样式。拖动以对主题进行排序。」;列表项 = 拖拽把手 + 主题名;仅支持垂直拖拽(水平阈值 `33550336` 即禁用、垂直阈值 `3px`)(`:35-67`)。
6. **错误信息抽屉**(宽 `450px`):标题「错误信息」+ 只读等宽文本框(`:24-30`)。
#### 10.4.16 调试(id `debug`,图标 `E2C8/E2C7`,Debug)
源 `DebugPage.axaml`。仅当 `IsDebugOptionsEnabled` 为真且未禁用调试菜单时出现在导航中;页内必须整体受 `DisableDebugMenu` 控制可见性。
1. 顶部不可关闭 Error InfoBar:「仅供调试,除非你能知道您在做什么,请不要使用以下按钮。」(`:17-20`)。
2. 「启用调试菜单」卡片:开关 `IsDebugOptionsEnabled`(默认 `false`,`Settings.cs:79`);子项:调试菜单开关 `IsDebugEnabled`(默认 `false`)、主窗口调试界面开关 `IsMainWindowDebugEnabled`(默认 `false`)。
3. 「调试时间偏移」:日期选择器 + 时间选择器(含秒)+「重置」按钮;修改后必须以 `DebugTimeOffsetSeconds`(默认 `0`,`Settings.cs:112`)驱动全局时间;`DebugTimeSpeed` 默认 `1.0`(`Settings.cs:111`)。
4. 「课程服务」四个可点击行:触发上课、触发下课、启动主计时器、停止主计时器。
5. 「应用设置」三个可点击行:覆盖设置版本、显示插件使用须知(重置 `IsPluginMarketWarningVisible = true`)、显示自动化使用须知(重置 `IsAutomationWarningVisible = true`)。
6. 「功能测试」:转储课表关系图、打开 Markdown 文档、显示测试通知、测试定位、「正在集控管理」开关(只读显示)。
7. 「垃圾回收」:整卡可点击,执行 `GC.Collect()`。
8. 「时间流速」:数值框(`min-width: 102`,绑定 `DebugTimeSpeed`)+ 重置按钮(`E4CC`)。
9. 「危险操作」:崩溃测试、崩溃测试(延迟 3 秒)、崩溃测试(Task)、崩溃测试(扔到栈顶)、`Environment.FailFast();`(`:22-130`)。
#### 10.4.17 关于 ClassIsland(id `about`,图标 `E9E4/E9E3`,About)
源 `AboutSettingsPage.axaml`。必须实现为「吸顶大图 + 卡片组」:
1. **头部**:StickyScrollViewer(折叠后高度 `58px`),头部内容最大高度 `300px`,横幅图 `BannerMain_App2.png` 居中最大宽 `960px`,底部叠加标题「关于 ClassIsland」(`TitleTextBlockStyle`);头部背景为 `#0d1111`,左右各一块径向渐变(`#ff0c0e1f` → `#ff0d1111`),渐变光晕出现动画:延迟 `250ms`、时长 `200ms`、曲线 `cubic-bezier(0,0,0,1)`,透明度 `0 → 1`;横幅文字区不透明度随滚动由 `StickyScrollViewer.HeaderFadingOpacity` 渐隐(`:117-186`)。
2. 四张卡片(默认全部展开):
- 「ClassIsland」卡片:`AppLogo.png` 图标,右侧显示版本号(`12px`,次级色);子项正文「本项目基于 GNU General Public License v3.0 获得许可。」+「开放源代码许可」「隐私政策」超链接(超链接 `padding: 4px`、`margin: 2px`)+「Copyright (c) 2023-{当前年份} HelloWRC」;子项「常用链接」:项目主页、帮助文档、GitHub、加入社区;
- 「遇到问题?」卡片:问题反馈/功能建议、功能投票、查看诊断信息(诊断信息抽屉 `550×400`,等宽字体只读文本框);
- 「鸣谢」卡片:贡献人员(抽屉宽 `300px`,含说明、刷新按钮、名单列表:头像 `32×32` 圆角 `16px` + 姓名 `15px` + 贡献 `11px` Light)、第三方库(抽屉宽 `300px`,列表项由图标 `E07D`、包名 `14px` Bold、网站/许可证链接组成;许可证文本抽屉为等宽只读文本框);
- 「回声洞」卡片:可点击行内逐字打字机效果显示随机文案(点击后重播)(`:188-309`);
- 底部居中文案(`13px`,次级):「谨以此应用,献给现在或曾经陪伴着我的同学们。愿时光流转,各位能与所念之人重逢。」(`:311-316`)。
#### 10.4.18 集控 / 集控凭据 / 集控策略(id `management`、`management.credentials`、`management.policy`,About,`hideDefault`)
源 `ManagementSettingsPage.axaml`、`ManagementCredentialsSettingsPage.axaml`、`ManagementPolicySettingsPage.axaml`。
1. **集控页**三组卡片:信息卡(标题「集控」+ 说明;受管时追加「您目前正在受 {OrganizationName} 的管理。」)、「加入管理」(未受管时显示;点击打开 `JoinManagementDialog`)、「了解更多」(跳转文档);「管理」卡(未受管时显示):「密码保护设置…」→ `classisland://app/settings/management.credentials?ci_keepHistory=true`、「限制策略…」→ `classisland://app/settings/management.policy?ci_keepHistory=true`;「客户端信息」卡:CUID(只读)、受管时显示 ID;受管时另显示「服务端信息」卡(服务端类型)(`:17-115`)。
2. **集控策略页**(必须整页在未通过「编辑策略」授权时保持锁定:`IsEnabled` 取 `IsLocked` 的反):
- InfoBar:「部分策略设置可能需要重启才能生效。」+「重启应用」按钮;
- 分区「档案」(图标 `E699`):禁止编辑档案(`DisableProfileEditing`)、禁止编辑课表(`DisableProfileClassPlanEditing`)、禁止编辑时间表(`DisableProfileTimeLayoutEditing`)、禁止编辑科目(`DisableProfileSubjectsEditing`),说明文案必须保留「临时换课和启用临时课表功能不受影响。」;
- 分区「应用设置」(图标 `EF27`):禁止编辑应用设置(`DisableSettingsEditing`)、禁止自定义启动加载界面(`DisableSplashCustomize`)、禁用调试菜单(`DisableDebugMenu`);
- 分区「杂项」(图标 `EBAA`):允许退出集控(`AllowExitManagement`)、禁用彩蛋(`DisableEasterEggs`)(`:17-111`)。
3. **集控凭据页**:
- 分区「认证」(图标 `EAF2`):「用户认证方式」「管理员认证方式」两个 `CredentialEditControl`(可增删认证方式、可拖拽排序);
- 分区「操作授权等级」(图标 `E350`):8 行「左标签 + 右下拉(无/用户/管理员)」,标签与绑定依次为:编辑授权设置(`EditAuthorizeSettingsAuthorizeLevel`)、编辑策略(`EditPolicyAuthorizeLevel`)、退出集控(`ExitManagementAuthorizeLevel`)、编辑课表(`EditProfileAuthorizeLevel`)、编辑应用设置(`EditSettingsAuthorizeLevel`)、换课(`ChangeLessonsAuthorizeLevel`)、退出应用(`ExitApplicationAuthorizeLevel`);图标依次 `E350/E2B4/E2B8/E6BB/EF27/E15F/E0DF`(`:19-99`)。
#### 10.4.19 出错啦 / 404 页(id `_error`,About,`hideDefault`)
源 `ErrorSettingsPage.axaml`。居中布局(`spacing: 8px`):`80×80` 贴纸(`帕姆_哭哭.png`,缺失回退 `E6BF` 80px)→ 导航异常时显示「欧呦,出错啦!」(Subtitle)+「在尝试导航到请求的的页面时发生了一个未知错误,详细请见应用日志。」(13px);页面不存在时显示「404」(Subtitle)+「找不到请求的页面。」(13px)(`:11-34`)。
### 10.5 设置持久化、缓存与叠层(行为级要求)
1. **即时保存**。任何设置项的值发生变化后,必须**立刻**把整个配置(`config.json`,ClassIsland 对应 `Settings.json`)写盘(ClassIsland 在每个属性 setter 触发 `OnPropertyChanged` 后调用 `SaveSettings(propertyName)`,见 `repos/ClassIsland/ClassIsland/Services/SettingsService.cs:261-280`、`:171-179`)。实现时:Pinia store 的每个 action 必须在修改状态后调度一次持久化(Electron 主进程写 `userData/config/Settings.json`),允许 `requestAnimationFrame` 级别的合并,但**不得**设置超过 300ms 的防抖【推断:300ms 为工程上限,源码为同步写盘】。
2. **禁止自动保存的字段**。标注 `[JsonIgnore]` 的派生字段(如 `IsSentryEnabled` 实际存于本地全局存储、`ShowSellingAnnouncement`)必须写入独立的 `localStorage`/`global.json`,不得写进 `Settings.json`(`Settings.cs:518-535`、`:2615-2628`)。
3. **页面缓存策略**。`SettingsPagesCachePolicy`:`0` = 不缓存页面实例;`1` = 仅在设置窗口打开期间缓存;`>=2` = 跨打开会话缓存【推断:源码仅在 `>=1` 缓存、`<=1` 时关闭窗口清空,`2` 语义由此推得】(`Settings.cs:213`、`SettingsWindowNew.axaml.cs:425-428`、`:513-516`)。实现时映射为 Vue 的 `<keep-alive :include>` 控制:`0` 不缓存该页组件;`1` 缓存到窗口关闭;`2` 缓存到进程结束。
4. **设置叠层(SettingsOverlay)**。必须实现「叠层」机制以支持自动化/集控临时改设置:`SettingsOverlays` 为 `Dictionary<propertyName, OrderedDictionary<key, value>>`,JSON 键名必须序列化为 `SettingsOverlay`(旧版兼容,`Settings.cs:263-273`)。`AddSettingsOverlay(guid, name, value)` 的语义:首次写入时把当前值保存到键 `"@"`,随后写入 `guid` 键并立即应用;`RemoveSettingsOverlay(guid, name)` 移除后必须以集合中最后一个值回滚属性,集合仅剩 1 项(即只剩 `"@"`)时删除整个属性条目(`SettingsService.cs:191-259`)。任何用户在设置界面手动修改某属性时,必须移除该属性的全部叠层(同文件 `:261-266`)。
5. **管理员策略限制**。策略值来自集控清单(本地无集控时来自 `Management.json`),设置页必须在渲染时读取;对禁止编辑的页面必须整页禁用(如提醒页 `IsEnabled = !DisableSettingsEditing`,`NotificationSettingsPage.axaml:44`),而不是仅隐藏控件。策略项与设置项冲突时,以策略为准。
6. **重启类设置**。至少以下设置必须标记为「需要重启」并触发重启提示:`AnimationLevel`、`IsWaitForTransientDisabled`(`GeneralSettingsPage.axaml.cs:37-44`)、渲染模式、使用系统标题栏、兼容缩放模式(`AdvancedSettingsPage`,源码通过 ViewModel 写入设备级配置;重启需求为【推断】)。实现时必须提供统一的 `requestRestart(reason)` 接口。
7. **不存在的功能**。源码中**没有**设置搜索、没有「恢复默认值」全局按钮、没有设置项级重置按钮。实现者不得编造;如需增加,必须作为 P2 可选增强并在文档中标注。
8. **文案常量集中**。设置窗口与全部设置页的中文文案必须集中放在常量文件(固定路径 `src/renderer/constants/texts.zh-CN.ts`,按页面分节,与 part 11 的其它窗口共用同一文件),组件内只引用常量键、不得散落中文字面量;文案内容以本 part 与 part 11 给出的中文原文为准,后续接入 i18n 时以该常量文件为唯一来源。
### 10.6 键盘与交互细节
1. `Esc`:优先关闭打开的抽屉;无抽屉时关闭「更多选项」菜单;再无效时不做任何事(不得关闭设置窗口)。
2. 后退:`Alt+←` 不做映射(源码无此绑定)【推断:FluentAvalonia NavigationView 默认无该快捷键】;后退仅通过标题栏/页面内返回按钮触发。
3. 拖动排序(组件行、提醒提供方、自动化、主题顺序)必须实现「水平方向禁用」的排序行为:`HorizontalDragThreshold = 33550336`、`VerticalDragThreshold = 3`,即只允许垂直拖动(多页一致,如 `NotificationSettingsPage.axaml:92-102`、`ThemesSettingsPage.axaml:54-65`)。
4. 悬停提示必须使用 `ToolTip`,延迟约 400ms【推断】;滑块拖动时必须实时显示数值 tooltip。
### 10.7 验收标准
实现完成后,必须逐条自测并全部通过:
1. 设置窗口首次打开宽 `1240`、高 `550`;把宽度拖到 `799` 时进入压缩导航,拖到 `800` 时恢复,最大化时导航始终展开。
2. 左侧导航顺序与本文 10.2 第 4 条完全一致;「通用」与「主界面」为可展开分组,分组之间与分类块之间存在分隔线;「关于」块排在最后且不出现「集控凭据设置/集控策略/出错啦」。
3. 打开「外观」页,把「背景不透明度」拖到 `0.30` 后关闭设置窗口再重开,值仍为 `0.30`,且 `config.json` 中 `opacity` 为 `0.3`(即时保存生效)。
4. 打开「组件」页,能从组件库拖拽一个组件到某一行、能插入行、能删除行、能切换「主要行/提醒」开关,且变更立即写入组件配置。
5. 打开「提醒」页,窗口宽度 `< 800` 时变为单栏、选中提供方后进入详情并可返回。
6. 打开「天气」页,点击「编辑城市」弹出自右侧宽 `320px` 的抽屉,`Esc` 可关闭,遮罩点击可关闭,动画时长 `250ms`。
7. 打开「主题」首次警告页点「同意并开始使用主题」后,警告页不再出现且主题网格可加载。
8. 触发任何「需要重启」的设置后,标题栏出现「需要重启」按钮,点击弹出标题「需要重启」的对话框,「重启」按钮为默认按钮。
9. 管理策略勾选「禁止编辑应用设置」并重启后:非关于类设置页从导航消失,窗口默认落在「关于 ClassIsland」。
10. 所有数值与枚举必须与本文列出的默认值一致(可用 `config.json` 全文比对;每一处偏差都算失败)。
+351
View File
@@ -0,0 +1,351 @@
## 11. 其它窗口与向导(闪屏 / OOBE / 崩溃 / 恢复 / 更新 / 换课 / 导入导出 / 迁移 / 教程 / 授权 / 日志)
本节规定除主界面(岛)与设置窗口之外的全部用户可见窗口。实现技术栈为 ClassHelper desktop-client 的 Electron 多窗口模型:**每个窗口必须是一个独立 BrowserWindow + 独立渲染入口**(或同一个渲染进程内的独立路由 + 独立 BrowserWindow,二者择一但同一类窗口必须一致),窗口参数、模态关系、键盘行为、关闭行为必须完全按本节实现。所有窗口必须复用统一窗口外壳组件(对应 ClassIsland 的 `MyWindow`):Windows 11 21H2+ 且支持 WinUI Composition 时启用 Mica(背景透明 + `windowTransparencyLevel: 'mica'`),否则使用纯色背景 `SolidBackgroundFillColorBaseBrush`;开发版/开源水印以绝对定位浮层实现(`repos/ClassIsland/ClassIsland.Core/Controls/MyWindow.cs:215-229`、`:178-198`)。
### 11.1 通用窗口规则(所有窗口必须遵守)
1. **标题栏**。除显式声明「无边框/透明」的窗口外,一律使用自定义标题栏,高度 `48px`(与设置窗口一致;`SettingsWindowNew.axaml.cs:135`)。窗口标题必须写入窗口标题属性(用于任务栏与 Alt+Tab)。
2. **模态**。设置、欢迎向导、数据迁移、Excel 导入/导出、授权、日志等窗口必须作为「主窗口的模态/非模态子窗口」处理:非模态窗口(设置、日志、教学中心、档案编辑)关闭时为 `hide()`;模态窗口(欢迎向导、数据迁移、更新进度、任务对话框)关闭时真正 `destroy()`,并把结果回传调用方。
3. **默认快捷键(所有窗口)**:`F3` 循环切换渲染调试浮层(无 → FPS → FPS+布局/渲染耗时),`Shift+F3` 直接进入第二级;`F6` 切换触摸模式(`Shift+F6` 同时抑制自动触摸判定);`F7` 关闭当前窗口全部 Toast(`MyWindow.cs:115-166`)。实现者必须在每个窗口注册同样的开发者快捷键(生产构建可移除)。
4. **Toast/通知条**。所有窗口必须支持右上/底部 Toast 队列(标题、正文、级别:灾难/错误/警告/信息/成功、是否可关闭、是否自动关闭);Toast 必须以 Adorner 形式浮于窗口内容之上,不改变布局(`MyWindow.cs:185-188`)。
5. **对话框**。普通确认类对话框必须使用 Fluent 风格 ContentDialog:标题、正文、主按钮、次按钮、关闭按钮、默认按钮;内容宽度约 `320–548px`【推断:FluentAvalonia 默认】,居中于父窗口并带遮罩。需要展示 Markdown/复杂内容时使用 TaskDialog(带标题栏、按钮列表、可勾选扩展区)。
6. **文案常量集中**。全部窗口文案必须集中存放在常量文件(固定路径 `src/renderer/constants/texts.zh-CN.ts`,按窗口分节),组件内只允许引用常量键、不得散落中文字面量;文案内容以本节给出的中文原文为准,后续接入 i18n 时以该常量文件为唯一来源。
### 11.2 欢迎向导(OOBE)`WelcomeWindow`
源:`Views/WelcomeWindow.axaml:14-20`、`WelcomeWindow.axaml.cs:62-243`、`Views/WelcomePages/*`。
1. **窗口参数**:`800×600`,`CenterScreen`,`CanResize=false`,`ShowAsDialog=true`(模态体验),启用 Mica,标题「ClassIsland」,Linux 下窗口类型必须为 `Normal`(`:14-20`)。内容区必须只有一个导航 Frame,无标题栏文字、无额外 chrome。
2. **页面序列**(必须支持三种启动模式,并按条件裁剪页面;`WelcomeWindow.axaml.cs:62-90`):
- 完整 OOBE(首次启动,`IsOnboarding=true` 且非翻新):欢迎页 → 许可条款 → 翻新页 → 基本页 → 颜色主题页 → 外观页 → 系统页 → 完成页(共 8 页);
- 翻新模式(`isRefreshing=true`):保留翻新页,去掉欢迎页/许可页/系统页;
- 翻新完成回访(`isRefreshCompleted=true`):同时去掉翻新页;
- 非 OOBE 且非翻新:许可页/欢迎页/系统页/翻新页全部去掉,仅剩基本/颜色/外观/完成。
3. **导航命令**:前进/后退/完成必须是三个路由命令;前进时索引 `min(index+1, count-1)`,后退时 `max(index-1, 0)`(`:126-142`)。所有页面共享同一个 ViewModel(设置、快捷方式选项、翻新范围),页面切换时不得重建状态。
4. **欢迎页(WelcomePage)**:整页 `Grid` 三行(`*, Auto, *`)。第 0 行:应用图标 `80×80`,底部对齐、距文字 `8px`;第 1 行:「ClassIsland」11 个字母逐字水平排列,`font-size: 32px`、`font-weight: Medium`,字间初始 `4px` 间距;第 2 行:主按钮(图标 `E130`,`48×48`、圆角 `24px`、accent,tooltip「下一步」)居中,下方 `margin: 8px`;页面底部居中显示版本号(`opacity: 0.75`)+ 三个超链接「数据迁移」「故障排除/恢复备份」「加入管理」(超链接 `padding: 4px`)(`WelcomePages/WelcomePage.axaml:26-101`)。
5. **欢迎页开场动画(必须逐帧还原)**:页面加载后先播放 OOBE 片头动画(`OobeIntroAnimationControl`),动画结束后页面内容容器才以 `200ms` 淡入(透明度 0→1,`WelcomePage.axaml:11-25`、`.axaml.cs:41-44`)。片头动画规格(`Controls/OobeIntroAnimationControl.axaml:13-144`、`.axaml.cs:25-165`):
- 结构:11 个 `32×32` 方块(间距 `4px`、背景 `ControlStrongFillColorDefaultBrush`、初始 `opacity: 0`、`translateY(50px)`、`transform-origin: 0 12px`)与 11 个字母(`font-size: 32px`、Medium、`min-width: 32px`、初始 `opacity: 0`、3D 翻转 `rotateX`)叠放在同一中心位置;
- 方块入场:逐个出现,延迟按 `delay_i = sin((i+2)/(n+2) * π/2) * 500 / n` 毫秒(n=11)计算;每个方块动画时长 `delay*9*1.5+750ms`,关键帧:`0%` 透明且下移 `50px` → `delay*9 ms` 时完全不透明且归位(`cubic-bezier(0.25,1,0.5,1)`)→ 停留 `+750ms` → 随 `rotateX 0°→90°` 翻出并消失(`cubic-bezier(0.32,0,0.67,0)`);第 6 个方块(索引 6)必须改成「宽度放大到 `2.17` 且变 `#00bfff`」的特殊高亮动画(`:44-58`、`:65-124`);
- 字母入场:与方块交错,单个字母动画时长 `delay*9*2+750ms`,在方块翻出后由 `rotateX(-90°)` 翻入(`cubic-bezier(0.33,1,0.68,1)`);
- 收尾:全部字母出现后,字母容器的字间距从 `4px` 过渡到 `0`、每个字母 `min-width` 从 `32px` 过渡到 `0`,时长 `700ms`、曲线 `cubic-bezier(0.33,1,0.68,1)`,随后整页 `opacity 0→1`、`scale 1.25→1.0`(时长 `3.0s`,曲线 `cubic-bezier(0,0.83,0.17,0.90)`);
- 动画期间必须禁用「下一步」交互【推断:源码未显式禁用,但按钮在动画下层】。
6. **许可条款页(LicensePage)**:页面内容 `max-width: 600px`、居中;标题「同意许可条款」(Subtitle)+ 说明「要继续使用 ClassIsland,您必须阅读并同意以下许可条款。」;两个复选框,文案分别为「我已阅读并同意《ClassIsland 开源许可》。」「我已阅读并同意《ClassIsland 隐私政策》。」,书名号部分是超链接,点击打开许可/隐私对话框;主按钮(`48×48` 圆角 24 accent,「下一步」)**仅在两个复选框都勾选时可用**(`WelcomePages/LicensePage.axaml:9-43`)。
7. **翻新页(RefreshingPage)**:标题「翻新」+ 说明「选择要重置的内容。选中的内容将被重置至初始状态,重置前应用将创建一个备份,以便还原至重置前的状态。」;5 个复选框,均带图标与描述:档案(`E699`,「包含课表、时间表等信息。」)、课表(`E6B1`,缩进 `22px`,仅当「档案」未勾选时显示)、时间表(`F0EB`,缩进 `22px`,同上)、组件配置(`E06F`,「主界面显示的内容。」)、自动化(`EEF1`,「应用在特定情况下自动执行的内容。」)、插件(`E071`,「可以扩展应用的功能。」);「档案」未勾选时另显示超链接「选择要保留的课表和时间表…」;执行翻新期间所有复选框禁用,并显示「正在翻新,请稍后…」+ 不确定进度条(`WelcomePages/RefreshingPage.axaml:15-90`)。
8. **基本页(GeneralPage)**:`max-width: 600px`;标题「基本」+「配置 ClassIsland 的基本设置。」;两条设置:「学期开始时间」(日期选择器 `min-width: 120px`,绑定 `SingleWeekStartTime`)、「匿名使用情况分析」(开关,绑定 `IsSentryEnabled`,说明与隐私页一致);主按钮「下一步」(`WelcomePages/GeneralPage.axaml:11-49`)。
9. **颜色主题页(ColorThemePage)**:标题「颜色主题」+「设置 ClassIsland 的主题外观。」;横向 ListBox 三张主题预览卡(每项 `margin: 0 8px 0 4px`,预览图宽 `144px`、高约 `75px`):第一张为「跟随系统」(左半暗色 `ThemeDemo` 宽 `144` 裁剪到 `0..72`,右半亮色裁剪到 `72..144`),第二张「明亮」(整幅亮色),第三张「黑暗」(整幅暗色);每张卡下方有 RadioButton 文案「跟随系统/明亮/黑暗」,选中项对应 `Theme`=0/1/2;底部「上一步」「下一步」两个 `48×48` 圆角按钮(`WelcomePages/ColorThemePage.axaml:22-79`)。
10. **外观页(AppearancePage)**:标题「外观」+「调整 ClassIsland 主界面的外观。」;三条设置:界面缩放(滑块 `0.75–2.5`,步进 `0.05`,宽 `180px`,`F2`)、背景不透明度(`0–1`,步进 `0.05`,`F2`)、圆角半径(`0–20`,步进 `0.5`,`F1`);底部「上一步」「下一步」(`WelcomePages/AppearancePage.axaml:12-79`)。
11. **系统页(SystemPage)**:标题「系统」+「设置ClassIsland的系统集成功能,这些功能可以优化您使用本软件的体验。」;复选框(`padding: 8px 0`,含图标与描述):开机自启(`E342`,macOS 隐藏)、创建桌面快捷方式(`E62F`,macOS 隐藏)、创建开始菜单快捷方式(`E064`,deb 包与 macOS 隐藏)、注册 Url 协议(`EAB0`,仅 Windows)、「在桌面创建换课快捷方式」(`E15F`,仅 Windows,缩进 `22px`,且仅在勾选「注册 Url 协议」时可用);底部「上一步」「下一步」(`WelcomePages/SystemPage.axaml:16-100`)。
12. **完成页(FinishPage)**:`Carousel` 宽 `600px`,三页:① 「点击任务栏托盘图标可以打开应用主菜单。」+ 截图 `Demo_MainMenu.png`;② 「要编辑课表和时间点,请在应用主菜单中点击【编辑档案】。」+ 截图 `Demo_OpenProfileSettingsWindow.png`;③ 85×85 贴纸(`白厄_再见.png`,回退 `E31A`)+「完成」+「恭喜!应用的基本设置已完成,感谢您选用 ClassIsland。点击【完成图标】以结束设置向导。」。Carousel 页面切换必须使用「水平滑动 `300ms` `cubic-bezier(0.76,0,0.24,1)` + 交叉淡入 `300ms`」的复合过渡;底部按钮:非最后一页显示「上一步」「下一步」,最后一页显示「上一步」+「完成」(图标 `E423`,`48×48` 圆角 24 accent)(`WelcomePages/FinishPage.axaml:14-98`)。
13. **完成行为**:点击完成必须依次执行:置 `CanClose=true`、`IsWizardCompleted=true`;若为 OOBE:在 Windows 创建开机启动/开始菜单/桌面快捷方式(按系统页勾选)并注册 Url 协议,Linux 写 `~/.config/autostart/cn.classisland.app.desktop`、`~/.local/share/applications/cn.classisland.app.desktop`、桌面 `ClassIsland.desktop` 并复制图标;重置教程完成状态;关闭 `RefreshingToastIsOnboardingGuide`;若为翻新:清零剩余通知次数;最后关闭窗口(`WelcomeWindow.axaml.cs:186-218`)。
14. **关闭保护**:OOBE 模式下用户点关闭必须弹出模态对话框:标题「退出 ClassIsland」,正文「您需要完成设置才能开始使用本应用。关闭此窗口将直接退出应用。」,主按钮「退出」,次按钮「取消」;确认后关闭窗口并退出应用(`.axaml.cs:220-243`)。
15. **欢迎页底部三个入口**:「数据迁移」→ 把向导页面序列替换为 [欢迎页, 许可页, 数据迁移页] 并前进;「故障排除/恢复备份」→ 以参数 `-m -r` 重启到恢复模式;「加入管理」→ 打开 `JoinManagementDialog`(`WelcomePage.axaml.cs:46-69`)。
### 11.3 启动闪屏 `SplashWindow`
源:`Views/SplashWindow.axaml:1-100`、`.axaml.cs:41-130`、`Services/SplashService.cs`。
1. **窗口参数**:`400×100`,`CenterScreen`,`Topmost`,`SystemDecorations=None`,背景 `Transparent`,`ShowInTaskbar=false`,`ShowActivated=false`,`TransparencyLevelHint=AcrylicBlur,Transparent`,强制暗色主题;窗口圆角约 `8px`【推断:Fluent 默认】,内容容器 `padding: 17px`、背景 `#88111111`、`border: 1px` 描边(`ControlStrokeColorOnAccentDefaultBrush`)。
2. **布局**:4 行 `*, 8, *, Auto`。第 0 行:Logo(`height: 24px`,`margin-right: 8px`)+「ClassIsland」文字(`20px` Medium,仅当未自定义 Logo 时显示;自定义 Logo 时只显示图片);第 2 行:`ProgressRing` `24×24` + 状态文本(`16px` Regular,绑定 `SplashStatus`);第 2 行左下角叠加版本号(`9px`,`opacity: 0.5`,`margin: -16px 0 0 -14px`);第 3 行:`400×4` 白色进度条(`margin-bottom: -17px`)。
3. **入场动画**:Logo 与状态行分别做「从右侧 `32px` 滑入 + 淡入」:位移 `300ms` `ExponentialEaseInOut`(`cubic-bezier(0.87,0,0.13,1)` 近似【推断】),透明度 `300ms` `CubicEaseOut`;状态行延迟 `150ms`,Logo 延迟 `0ms`(`.axaml.cs:53-54`、`:101-121`)。
4. **进度条动画**:进度值 `p`(0–100)变化时,以合成动画把白色填充条的 `X` 偏移从当前值补间到 `-400*(1-p/100)`;时长 = `max((p - 上次p)/8, 0.5)` 秒;结束(`EndSplash`)时以 `150ms` 补间到 100%(`.axaml.cs:64-82`、`:123-130`);缓动 `CubicEaseOut`。
5. **文案**:默认「正在启动…」;用户自定义 `SplashCustomText` 非空时必须用它替换;仅当 `ShowDetailedStatusOnSplash=true` 时才允许用详细启动阶段文案覆盖(`SplashService.cs:27-33`、`:89-92`)。集控策略 `DisableSplashCustomize` 为真时必须清空自定义 Logo/文字/详细状态。
6. **启用条件**:仅当 `Settings.IsSplashEnabled=true`(默认 `false`,`Models/Settings.cs:115`)时显示;该窗口不是主窗口,必须由主进程在应用初始化最早阶段创建,并在主窗口显示后销毁。
### 11.4 崩溃窗口 `CrashWindow`
源:`Views/CrashWindow.axaml:1-99`、`.cs:16-86`。
1. **窗口参数**:`800×450`,`CenterScreen`,启用 Mica,标题「ClassIsland 崩溃报告」,图标 `AppLogo.ico`。
2. **布局**(`Grid margin: 12px`,6 行 2 列):
- 头部:`48×48` 贴纸(`光辉矢愿_不可以看.png`,回退 `E809`),`margin: 0 -8px 0 0`;标题 `TitleTextBlockStyle`「ClassIsland 发生错误 ヽ(*。>Д<)o゜」(禁用彩蛋时改为「ClassIsland 发生错误」),`margin: 8px 0 16px 16px`;
- 正文(`14px`,可换行):「ClassIsland 碰到了严重错误而无法正常运行。您可以保存下方的错误信息并向他人寻求帮助。如果您认为这是由软件本身的错误所致,请点击下方“反馈问题”按钮。」
- 错误信息:只读多行文本框,`font-size: 13px`、无边框、`padding: 0`、顶部对齐(`TextBoxCrashInfo`);
- 当异常不可忽略时显示 Error InfoBar:「这个异常无法被忽略,您需要重启或退出应用。很抱歉对您的使用造成不便。」;
- 分隔线(`margin: -12px 8px`);
- 底部工具条:背景 `LayerOnMicaBaseAltFillColorDefaultBrush`,`margin: -12px 0 -12px -12px`,左右 `padding: 12px`。左侧按钮:调试(`E2C8`,仅 critical 时显示,点击 `Debugger.Launch()`)、忽略(`E671`,仅在允许忽略时显示,点击关闭窗口)、退出应用(`E0DF`)、重启应用(`E0BD`,accent);右侧按钮:复制(`E58B`,全选并复制错误信息)、反馈问题(`ED11`)。
3. **反馈问题**:必须打开浏览器到 `https://github.com/ClassIsland/ClassIsland/issues/new`,并带查询参数 `template=BugReport.yml&stacktrace=<URL 编码的崩溃信息>&app_version=<版本>&os_version=<系统版本>`(`.cs:66-77`)。
4. **退出语义**:critical 时「退出应用」必须调用 `Environment.Exit(1)`;非 critical 时走正常退出流程(`.cs:42-52`)。
### 11.5 恢复模式 `RecoveryWindow` 与恢复页
源:`Views/RecoveryWindow.axaml:12-21`、`Views/RecoveryPages/HomePage.axaml:1-85`、`RecoverBackupPage.axaml:1-45`。
1. **进入方式**:以命令参数 `-m -r` 重启(等价于「重启到恢复模式」);恢复模式窗口为主窗口:`450×600`,`CenterScreen`,标题「恢复」,图标 `AppLogo.png`,内容区为导航 Frame(`CacheSize=0`)。
2. **主页(HomePage)**:`margin: 8px`,纵向间距 `6px`;标题「选择一个选项」+「选择一个操作以继续。」(`margin: 0 2px 10px`)。三组「卡片组」(子项间距 `1px`、首尾圆角 `4px`),全部为可点击行(`SettingsExpanderItem`),右侧统一 `E44E` 箭头图标:
- 第 1 组:继续使用 ClassIsland(`E131`);以安全模式继续使用 ClassIsland(`E131`,描述「禁用所有插件和自动化功能。」);以诊断模式继续使用 ClassIsland(`E131`,描述「在日志中记录详细的信息,并启用控制台输出。」);打开日志目录(`E889`,`EC2E`);
- 第 2 组:恢复备份(`E195`,描述「从先前的备份恢复处于正确状态的配置文件。」);回滚(`E135`,描述「回滚到之前的 ClassIsland 版本」,默认禁用);
- 第 3 组:清除临时文件(`E623`,描述「删除所有缓存和临时文件。」);重置应用设置(`E0BD`,描述「重置应用的设置信息,不影响档案、组件、自动化、插件等的配置。」);重置全部配置(`E6B9`,描述「重置应用的所有配置,包括应用设置、组件配置、自动化配置、插件设置等,不影响档案配置。」);重置全部信息(`E12F`,描述「重置应用的所有信息,恢复到安装前的状态。」)。
3. **恢复备份页(RecoverBackupPage)**:三行布局:顶部 `margin: 8px` 一行:返回按钮(图标 `E109`,tooltip「返回」,`margin-right: 16px`,working 时禁用)+ 标题「选择要恢复的备份」;中部为备份列表(`IsEnabled` 随 working 反转),working 时列表顶部叠加不确定进度条;底部(`margin: 8px`):两个单选按钮「增量恢复:替换备份中已有的文件,保留新添加的文件」(默认,值 0)与「完整恢复:完整恢复到选定备份,不保留新添加的文件」(值 1,`margin-top: 2px`);右下 accent 按钮「恢复到选定备份」(图标 `E195`),未选择备份时禁用。
4. **安全/诊断模式标识**:以安全模式启动时,主界面与标题必须标明当前处于安全模式;诊断模式必须写出更详细日志并附带控制台输出【推断:由启动参数控制,UI 标识在源码中未直接可见】。
### 11.6 更新相关窗口与流程
1. **更新进度窗口 `UpdateProgressWindow`**(`Views/UpdateProgressWindow.xaml:8-38`):`300×100`,`WindowStartupLocation=Manual`、初始位置 `left: 50, top: 50`(主进程可再调整到屏幕角落),标题「正在更新 ClassIsland…」,`ResizeMode=NoResize`,背景 `SolidBackgroundFillColorBaseBrush`,字体 `HarmonyOS Sans` 14px。内容 `margin: 16px`,两行:第一行两行文本「正在更新 ClassIsland……」+ 动态进度文本(`ProgressText`);第二行不确定进度条(`margin-top: 8px`)。关闭行为必须被拦截(`Closing` 事件取消关闭),防止更新中途被关掉。
2. **自动更新模式**(`Settings.UpdateMode`,默认 `3`,`Models/Settings.cs:77`):
- `0`:只允许手动检查;
- `1`:启动时自动检查,发现更新只提示;
- `2`:自动检查并自动下载;
- `3`:自动检查、下载并安装(下次启动生效)。
对应设置页见 part 10.4.13。
3. **更新状态机**(`UpdateStatus`):已是最新 / 有更新 / 已下载 / 已部署;按钮与文案切换规则见 part 10.4.13。更新包校验必须使用 `UpdateArtifactHash`(默认空,`Models/Settings.cs:157`),并按通道元数据公钥校验签名【推断:由 `UpdateService` 实现,源码见 `Services/UpdateService.cs`】。
4. **部署后提示**:部署完成必须通过 InfoBar 提示「更新将在重启后应用」,并允许「重启并应用更新」;`AutoInstallUpdateNextStartup` 默认 `true`(`Models/Settings.cs:78`),即下次启动自动安装。
### 11.7 换课窗口 `ClassChangingWindow`
源:`Views/ClassChangingWindow.axaml:1-273`、`.cs:53-192`。
1. **窗口参数**:`600×250`,`MinWidth=730`、`MinHeight=175`,`SizeToContent=WidthAndHeight`,`CenterScreen`,启用 Mica,标题「换课」,图标 `AppLogo.png`。根 Grid `MinWidth=730`、`MinHeight=175`。
2. **顶部标题条(AppBar)**:`padding: 8px`,内含「换课」标题(`TitleTextBlockStyle`,Medium,`margin-right: 16px`)+ 目标课表按钮(图标 `E6B1` + 课表名,tooltip「要换课的课表」)+ 箭头图标 `E131` + 操作提示文本。提示文本随步骤切换:第 0 步「选择要调换的课程。」;第 1 步「选择要与刚刚选择的课程调换的课程。」(`:219-256`)。
3. **主体 Carousel**(两页):过渡必须为「水平滑动 `300ms` `cubic-bezier(0.76,0,0.24,1)` + 交叉淡入 `300ms`」(`:52-60`)。
- 第 0 页:`LessonsListBox` 居中(当前课表全部课程),`margin: 0 8px`,`SelectionChanged` 后若合法值则自动进入第 1 步;
- 第 1 页:`margin: 8px`,三列 `Auto, Auto, *`:左列为课程卡片(`LessonControlExpanded`,`height: 40px`,展示当前选中的课程/时间点/科目);中列为「替换 / 交换」TabStrip(`compact toggle`;选中「替换」= `IsSwapMode=false`,选中「交换」= `IsSwapMode=true`);右列为二级 Carousel(垂直滑动 `300ms` 同曲线 + 交叉淡入):「替换」页 = 字段「换课成…」+ 科目下拉(`min-width: 100px`);「交换」页 = 第二个课程选择列表(`:61-140`)。
4. **底部工具条**:背景 `LayerOnMicaBaseAltFillColorDefaultBrush`,左右 `margin: 6px`。左侧:取消(图标 `E671`,`IsCancel`,tooltip「取消换课操作。」)、上一步(`E109`,仅第 1 步可见)、超链接「高级换课...」(tooltip「打开调课界面,支持跨天的换课选项和更复杂的调课方案。」→ 打开档案编辑窗口的 `classisland://app/profile/adjustment` 并关闭本窗口)。右侧:下一步(`E131`,accent,仅在第 0 步且已选中课程时显示)、复选框「永久换课」(`margin: 4px -8px`;tooltip「如果选择,将不会在换课时创建临时层课表,而会直接应用到原课表上。」;仅当课表不是临时层时显示;受 `DisableProfileTimeLayoutEditing`/`DisableProfileEditing` 策略禁用)、「确认换课」按钮(`E424`,accent,`margin-left: 12px`)(`:146-216`)。
5. **交互与数据逻辑**:
- 选择课程时必须把行索引换算成「上课时间点索引」(仅统计 `TimeType==0` 的上课项),换算失败必须 Toast 警告「选择的课程无效。」(`.cs:45-73`);
- 「交换」模式:互换两节课的 `SubjectId`;「替换」模式:把第一节课的 `SubjectId` 改为目标科目,目标科目为空则直接返回(`.cs:136-157`);
- **临时层语义**:未勾选「永久换课」且当前课表不是临时层时,必须先检查是否已存在临时层课表:若存在,弹出确认对话框(标题「覆盖当前的临时层」,正文「当前已经存在一个临时层课表,如果继续换课,那么该临时层将被覆盖。是否继续?」,主按钮「继续」、次按钮「取消」,默认主按钮);确认后清除临时层,再为新换课创建临时层(`.cs:99-119`);
- 换课完成后必须保存档案,并在集控受管时上报审计事件 `ClassChangeCompleted`(字段:ChangeMode、ClassPlanId、SourceClassIndex、SourceClassSubjectId、TargetClassIndex、TargetClassSubjectId、WriteToSourceClassPlan)(`.cs:159-172`);
- `Esc` 必须等价于取消(取消按钮 `IsCancel=true`);
- 窗口 `Unloaded` 时必须清空 `DataContext`(`.cs:183-186`)。
### 11.8 课表详细信息 `ClassPlanDetailsWindow`
> 本窗口**必须实现(P1)**,不得省略:它是课表/时间点附加设置的唯一可视化诊断入口。
源:`Views/ClassPlanDetailsWindow.axaml:1-199`。
1. **窗口参数**:`800×500`,启用 Mica,标题「课表详细信息」,图标默认。
2. **布局**:两行(`Auto, *`)、三列(`1*` 最小 `300` 最大 `500`、`5px` 分隔、`2*`),内容区背景 `LayerOnMicaBaseAltFillColorDefaultBrush` 铺满三列。
3. **左列**:只读 DataGrid(水平网格线),列:「时间」(起止时间 `HH:mm:ss-HH:mm:ss`,`margin: 4px`)、「科目」(科目名);`font-size: 14px`;选中行绑定 `SelectedLesson`。
4. **中列**:`GridSplitter`,方向 Columns。
5. **右列**(选中课程后):「附加设置结果」页签:
- 顶部一行:字段「要查看的附加设置」+ 下拉(`min-width: 150px`,每项「图标 + 名称」,选项来自全部已注册附加设置控件)+「刷新」按钮(`E0B5`);
- 说明文本(`margin: 4px`):「以下的附加设置将按从上到下的顺序使用。」+ 汇总文案;
- 列表:每项高 `36px`,左侧 `4px` 宽状态条(启用 = 主色渐变半透明块 + 主色竖条;被覆盖 = 警告色竖条),右侧渲染该附加设置的只读展示控件;状态条所在行必须有 `opacity: 0.25` 的高亮背景层(`:101-173`)。
6. **标题条**:「课表详细信息」(`font-size: 24px`,Medium,`margin: 8px`);右上「刷新」按钮(`E0B5`)。
### 11.9 Excel 导入向导 `ExcelImportWindow`
> **实现定性(必读)**:本实现**必须**把「从表格导入课表」做成**可用功能,优先级 P1**,不得以「遗留/可选」为由跳过。事实背景:ClassIsland 源码中该窗口的 XAML 仍在仓库(`Views/ExcelImportWindow.xaml`,992 行),但代码隐藏与 DI 注册均已停用(`Views/ExcelImportWindow.xaml.cs:1` 与 `Views/ExcelExportWindow.xaml.cs:1` 以 `#if false` 包裹整份代码,`App.Services.xaml.cs:204` 的 `services.AddTransient<ExcelImportWindow>()` 被注释)。**ClassIsland 源码中该窗口已停用,本实现以源码布局为参考重建**。技术约束:读写 `.xlsx` 必须使用纯 JavaScript 的 `xlsx` 库(SheetJS)或等价实现,**不得**引入 ReoGrid、WPF、`System.Windows.Forms` 或任何 .NET 互操作依赖;表格预览/编辑网格必须用 Vue 组件重建(虚拟滚动表格或 canvas 网格),交互与本文一致。
源:`Views/ExcelImportWindow.xaml:1-992`、`.cs:320-420`、`:746-822`(源码已停用,本实现以 XAML 布局与流程为规格依据)。
1. **窗口参数与入口**:默认 `WindowState=Maximized`(设计值 `450×800`),标题「从表格导入课表」,允许拖放文件,关闭前需保存清理;主容器为 `DialogHost` 等价的全窗遮罩容器。必须提供可到达的 UI 入口(建议放在档案编辑窗口的课表列表工具栏「从表格导入…」;入口的归属与位置归 part 06,但本窗口不得成为不可达的死功能)。
2. **三区域布局**(`Grid` 列定义随状态切换,`:182-221`):
- 未选择文件时:左列宽 `0`、中列宽 `0`、右列占满(引导卡居中);
- 已选择文件后:左列 `*`(表格)、中列 `5px`(分隔条实际宽 `10px`)、右列宽 `500px`(`min-width: 350`、`max-width: 800`);
- 行定义:第 0 行 Auto(页头)、第 1 行 `*`。
3. **页头**:普通状态为 `ColorZone`(PrimaryMid,`padding: 16px`,Elevation Dp4,`margin-bottom: 4px`)内「从表格导入课表」(Headline6);选区模式时页头变为「在下方表格中选择区域」+ 右侧当前选区(等宽字体,Headline6)+「确定」按钮(`Check` 图标),并叠加呼吸动画:选区模式下页头底部高亮条在 `0.1 → 0.25 → 0.1` 之间循环,单次 `1s`、`QuadraticEase EaseOut`、无限重复(`:64-82`、`:238-272`)。
4. **表格区**:必须用 Vue 网格组件重建,实现源码中 ReoGrid 的以下能力:可切换只读、显示并切换工作表标签(标签宽度 `500`)、文本默认黑色、支持单元格区域选择与合并/取消合并;单元格右键菜单固定 6 项:复制(`Ctrl+C`)、粘贴(`Ctrl+V`)、剪切(`Ctrl+X`)、清除内容(`Del`)、合并单元格、取消单元格合并(源码对应 `:274-308`;**不得引入 ReoGrid 依赖**)。
5. **右列向导容器**:`ScrollViewer`(`margin: 8px`)+ `Transitioner`(页面切换用 `CircleWipe` 圆形擦除过渡),共 **17 个页面(索引 0–16)**,页面标题一律 `MaterialDesignHeadline5TextBlock`、`margin-bottom: 12px`,正文 `TextBlock` 自动换行、`margin-bottom: 4px`:
- 0 「选择文件」:居中 `FileTableOutline` 75×75 图标 + 「从表格导入课表」(20px Bold)+「要开始导入,请将表格文件拖入到本窗口」+「或者」+「浏览文件…」按钮(`FolderOutline`);文件选择过滤器必须为「表格文件(*.xlsx)|*.xlsx」,标题「选择要导入的表格文件」(`:336-357`、`.cs:746-759`);
- 1 「加载中」:81×81 `帕姆_点赞` 贴纸 + 「正在加载你的表格……」+「坐和放宽,滚回功率,加载马上就好!」(13px,删除线);
- 2 「欢迎」:说明 +「开始」按钮(`ArrowRight`;若以「仅导入时间表」模式打开则直接跳到 5);
- 3 「询问界面形式」:「您希望本向导如何引导您?」+ 两个选项「详细」(对每个选项和流程做出详细解释,适用于刚接触本软件的新手)/「简洁」(简化流程和语言说明,提供简洁的体验,适用于熟悉本软件的用户)+「上一步」;
- 4 「从哪里导入时间表?」:三个选项:「我的时间表在这个文件中」/「我的时间表另一个文件中」/「我想手动创建时间表」+「上一步」;
- 5 「选择时间安排区域」:说明「点击下方文本框以从表格中选择包含时间点安排信息的区域。选区只能为单列或单行选区。Tips:若选区遇到合并的单元格,可以在单元格上右键取消单元格合并。」+ 区域选择文本框(水印「选择时间安排区域」)+ 无效时红色警告「选择区域无效。」+「下一步」(仅在区域合法且非选区模式时可用);合法性规则:选区必须为单行或单列(`.cs:761-777`);
- 6 「手动创建时间表」:共用「选择时间表」说明 + 时间表下拉(按 `Key` 选中)+「打开档案编辑窗口」按钮(`FileDocumentEditOutline`)+「下一步」(未选择时间表时禁用);
- 7 「时间表导入结果」:时间表名称输入框(Hint「时间表名称」)+「刷新」「编辑」按钮 + 只读时间线预览(`TimeLineListControl`,`Scale=3`,支持拖动平移)+ 导航按钮 +「跳过课表导入」(仅非「仅时间表」模式);
- 8 「选择课程区域」:区域选择文本框(水印「选择课程区域」)+ 无效警告 + 导航按钮(区域合法时可用);
- 9 「选择行列对应关系」:「点击下方高亮圆形按钮以选择读取课表的方向。」+ 切换按钮(未选中显示 `ArrowRight`、选中显示 `ArrowDown`)+ 文案「每竖列为一个课表。」/「每横行为一个课表。」+ 导航按钮;
- 10 「如何导入时间对应关系?」:两个选项「自动识别」「手动指定」;
- 11 「时间对应关系」:左右两个多选列表:左侧为识别区域(每行复选框内容为 `{值}`),右侧为该表格时间点(`HH:mm:ss-HH:mm:ss`);说明「设置每行对应的时间点。在识别课表时,仅会识别左侧选中的区域。在右侧选择该表格中包含的时间点,选中的时间点在识别时会按照顺序匹配左侧选择的区域。」+「下一步」;
- 12 「选择创建课表的区域」:「将从选择的区域创建课表。」+ 课表来源列表(选中项左边显示 Check 图标)+ 导航按钮(未选择来源时禁用);
- 13 「课表导入结果」:「已识别选中区域上的课程。请检查识别结果是否正确,并修改不正确的课程。」+ 课表名称输入框(Hint「课表名称」)+ DataGrid(行头宽 `150`;列「时间」只读、列「科目」为可编辑科目下拉)+ 导航按钮;
- 14 「课表设置」:「调整刚刚导入的课表的触发条件等设置。」+ 名称输入框 + 时间表下拉(禁用)+ 复选框「自动启用」+ 说明「当课表设置为自动启用时,将会根据触发规则自动启用该课表。否则只能通过临时课表或临时层手动启用该课表。」+ 启用时显示时间规则编辑控件;
- 15 「已导入的课表」:列表(`FileChartOutline` + 名称)+ 三个可点击项:「继续导入课表。」(`ArrowRight`)、「使用新的时间表继续导入课表。」(`UndoVariant`)、「完成导入。」(`Check`);
- 16 「完成」:`光辉矢愿_回眸` 贴纸(63×63)+「恭喜!您已经完成了课表导入!现在您可以关闭本页面了。」+「完成」按钮。
页面跳转映射与返回映射必须按源码实现(`.cs:345-420`),例如:`TimeLayoutSource→4`、`ImportTimeLayoutFromThisFile→5`、`CreateTimeLayoutManually→6`、`TimePointImportResult→7`、`SelectSubjectsPosition→8`(仅时间表模式为 16)、`SelectVerticalMode→9`、`RowClassesTimeRelationshipImportAuto/Man→11`、`SelectClassPlanArea→12`、`PreviewClassPlan→13`、`ClassPlanDetails→14`、`AllClassPlansView→15`、`Finish→16`;返回时 `3/4→2`、`5/6→4`、`7→5`、`8→7`(时间表来源=0)或 `8→9`(来源=2)、`9→6`、`10→8`、`11→10`、`12→11`、`13→12`、`14→13`、`15→14`、`16→15`。
6. **拖放**:拖入文件时整窗显示遮罩(`SolidBackgroundFillColorBaseBrush`,`opacity: 0.8`)+ 居中 `FileImport` 64×64 图标 +「将表格文件拖入到此处,松手即可开始导入。」;仅当拖入数据包含文件且尚未选择文件时显示(`:972-986`、`.cs:289-291`)。
7. **错误对话框**(全窗遮罩对话框):「无法打开文件」(标题「无法打开文件」,正文包含文件名与异常信息,按钮「确定」);「无法以兼容模式打开」;「兼容性警告」(「无法打开文件“…”因为本文件中有一些不兼容的格式和字体。您可以尝试以兼容模式打开。若选择以兼容模式打开,应用将为此文件创建一个临时副本,并移除副本中不兼容的格式和字体。」,按钮「取消」「在兼容模式中继续」)。用 `xlsx` 库实现时,「兼容模式」等价于:以 `cellStyles:false`/忽略样式与公式的方式二次解析同一文件,失败才进入「无法以兼容模式打开」(源码对应 `:85-164`)。
8. **关闭行为**:关闭窗口时必须取消选中时间表的激活状态(`SelectedTimeLayout.IsActivated=false`、`IsActivatedManually=false`),避免导入中断留下脏数据(`.cs:801-805`)。
### 11.10 Excel 导出窗口 `ExcelExportWindow`
> **实现定性(必读)**:本实现**必须**把「导出到 Excel 表格」做成**可用功能,优先级 P1**。事实背景:源码中该窗口 XAML 保留、代码隐藏位于 `#if false`(`Views/ExcelExportWindow.xaml.cs:1`)、未在 DI 中注册(`App.Services.xaml.cs:205` 被注释)。**ClassIsland 源码中该窗口已停用,本实现以源码布局为参考重建**。技术约束:必须使用 `xlsx`(SheetJS)或等价纯 JS 库生成/读取 `.xlsx`,不得依赖 ReoGrid/WPF;表格网格必须用 Vue 组件重建,并支持本文列出的单元格样式(字体、字号、粗斜体、下划线、水平对齐、合并/取消合并、字体色、背景色、框线、行高列宽)。
源:`Views/ExcelExportWindow.xaml:1-301`。
1. **窗口参数**:`1400×700`,默认 `WindowState=Maximized`,标题「导出到 Excel 表格」,背景 `SolidBackgroundFillColorBaseBrush`,字体 HarmonyOS Sans 14px。
2. **布局**:三列 `*`、`Auto`、`360px`(最小 `300`、最大 `400`);三行 `Auto`、`Auto`、`*`。第 0 行为 Snackbar 层,第 1 行为工具栏(跨三列),第 2 行为表格 + 右侧设置面板。
3. **表格区**:必须用 Vue 网格组件重建(不得引入 ReoGrid),实现可编辑单元格、工作表标签(标签宽度 `500`)、区域选择、合并/取消合并,以及复制/粘贴/剪切/清除内容(带快捷键提示)右键菜单。
4. **工具栏(普通模式)**:保存…(`ContentSaveOutline`)、保存并在默认 Excel 文件编辑器中打开(`MicrosoftExcel`)、分隔线、撤销(`Undo`,随 `CanUndo` 禁用)、重做(`Redo`)、分隔线、字体下拉(宽 `150px`,可编辑,每项用自身字体渲染)、字号数值框(宽 `75px`,后缀 `px`)、减小字号、增大字号、加粗/斜体/下划线(多选切换)、分隔线、水平对齐(无/左/居中/右/两端)、分隔线、合并单元格、取消合并。
5. **工具栏(选区模式)**:「完成选择」按钮(`Check`)+ 分隔线 +「当前选区:」+ 选区坐标(等宽字体 `16px`)。
6. **右侧面板**:`TabControl`,页签位于右侧(`TabStripPlacement=Right`),每个页签 `min-width: 42px`、`height: 42px`、内边距 `4px`:
- 「课表」页(图标 `FileChartOutline`):标题「课表」+「基准位置」选择框(进入选区模式)+ 课表多选列表(CheckBox + 课表名);
- 「样式」页(图标 `StyleOutline`):标题「样式」+ 复选框「包含教师名称」+ 下拉「水平对齐方式」(无/左/居中/右/两端)+ 下拉「垂直对齐方式」(默认/顶部/居中/低端)+「字体颜色」颜色选择器 +「背景颜色」颜色选择器 + 下拉「单元格框线」(无/全框线/全粗框线)+ 并排数值框「行高」「列宽」(后缀 `px`)+「刷新内容」按钮;
- 「保存」页(图标 `ContentSaveOutline`):标题「保存」+「保存到 Excel 表格」+ 说明 +「保存并在默认 Excel 文件编辑器中打开」+ 说明。
7. **Snackbar**:所有保存/错误提示必须以底部/顶部 Snackbar 显示(MaterialDesign Snackbar),不得使用阻塞对话框。
### 11.11 数据迁移 `DataTransferWindow` / `DataTransferPage`
源:`Views/DataTransferWindow.axaml:1-20`、`.cs:13-57`、`Views/DataTransferPage.axaml:1-273`、`.axaml.cs:54-120`。
1. **窗口参数**:`800×600`,`CenterScreen`,`ShowAsDialog=true`,`CanResize=false`,`ShowInTaskbar=true`,启用 Mica,标题「数据迁移」,图标 `AppLogo.png`;内容整体替换为 `DataTransferPage`。
2. **页面**:`ScrollViewer` 内 `Grid max-width: 600px` 居中 + `Carousel` 宽 `600px`(5 页,页面过渡为水平滑动 `300ms` `cubic-bezier(0.76,0,0.24,1)` + 交叉淡入 `300ms`):
- 页 0「数据迁移」:标题 +「向 ClassIsland 导入或从 ClassIsland 导出数据。」+ TabControl:
- 导入页签三个可点击卡片:ClassIsland 2.x(应用图标)、ClassIsland 1.7.0.x(应用图标)、Class Widgets 1.2(`E06F`);
- 导出页签:InfoBar 说明「此处的数据导出功能主要用于在不同 ClassIsland 实例间转移数据。若需导出诊断信息,请前往【应用设置】->【更多选项…】->【导出诊断数据】;若需备份应用数据,请使用【应用设置】->【通用】->【存储】中的备份功能。」+ 卡片「ClassIsland 2.x」;
- 页 1「选择要导入的数据 / 选择要导出的数据」:显示来源描述文本 +「选择要导入的信息 / 选择要导出的信息」+ 3 个复选框:课表配置(`E699`,「包括课表内容、时间安排、科目定义等信息。」)、应用设置(`EF27`,「包括应用的各个首选项,如隐藏设置、颜色设置、提醒/铃声设置、天气信息等。」)、其它配置(`E06F`,「组件、自动化等配置文件。」)+「上一步」「下一步」(`48×48` 圆角 24);
- 页 2「导入来源 / 导出目标」:「选择要导入的数据来源。 / 选择要导出到的目标。」+ 卡片「浏览…」(`E88D`,说明「手动浏览导入位置 / 手动浏览导出位置」)+「上一步」;
- 页 3「正在导入 / 正在导出」:「请坐和放宽,导入很快就能完成。」+ `ProgressRing` `48×48`(不确定);
- 页 4「导入完成 / 导出完成」:「已成功完成导入。/ 已成功完成导出。」+ 完成按钮(`E423`,`48×48` 圆角 24 accent)。
所有文案必须随「导入/导出」模式动态切换(通过容器 `.export` 类实现,`:80-88` 等)。
3. **导入来源语义**:
- ClassIsland 1.x:说明「支持从 1.x 版本的 ClassIsland 导入课表、组件配置、自动化配置、应用设置、部分插件和主题等数据。」;浏览时选择旧实例的 `ClassIsland.exe`,以该可执行文件所在目录为根(`.cs:77-114`);
- ClassIsland 2.x:说明「支持从 ClassIsland 2.x 数据文件导入课表、组件配置、自动化配置、应用设置、部分插件和主题等数据。」;
- 导出:说明「支持导出为可以导入到其它 ClassIsland 2 实例的文件格式。」;
- Class Widgets 1:说明「支持从 Class Widgets 1 导入全部课表信息和大部分配置。」;
- 导入前若需要重启(如替换正在使用的配置),必须弹出模态确认「重启以继续」(`.cs:116-120`)。
4. **外部调用**:应用必须支持以 `-i` 等参数在启动时执行迁移并把结果(`ImportStage`、`ImportName`)传入页面;`ImportComplete` 时必须直接把页面切到索引 4(`.cs:52-56`)。
### 11.12 授权窗口 `AuthorizeWindow` 与集控加入对话框
1. **AuthorizeWindow**(`Views/AuthorizeWindow.axaml:14-152`):宽 `500`,`SizeToContent=Height`,`CanResize=false`,`MinHeight=300`、`MaxHeight=700`,`CenterScreen`,`Topmost=true`,`ShowAsDialog=true`,`CanMaximize=false`,标题「ClassIsland」。内容 `margin: 16px`,三行两列:
- 头部(普通模式):「验证您的身份」(Subtitle)+「要继续操作,请通过下列的方式验证您的身份。」;编辑模式:「编辑认证项目」+「编辑进行此操作需要的认证方式。」;
- 编辑模式工具行:「添加认证方式」(`E00D`)+ 认证方式下拉(`min-width: 100px`,每项「图标 + 名称」);
- 认证方式列表:每项两行:第一行 `20×20` 图标(主色)+「用{名称}继续」(14px Medium)+ 右侧(选中且编辑模式时显示)拖拽把手与删除按钮(`E671`,`IndianRed`,点击弹出 Flyout:「要删除此认证方式吗?」「此操作无法撤销。」+「删除」accent 按钮);第二行渲染该认证方式的输入控件(如密码框);列表项可垂直拖拽排序(水平阈值禁用、垂直阈值 `3px`);
- 底部:两列等宽按钮「取消」(`IsCancel`)与「确定」(accent,仅编辑模式显示);
- 认证完成必须通过 `CompleteAuthorizeCommand` 路由命令触发,成功后关闭并返回结果。
2. **密码认证控件**(`Controls/AuthorizeProvider/PasswordAuthorizeProvider.axaml:15-66`):密码输入框(`PasswordChar=●`,水印「密码」,`margin-top: 8px`,按 Enter 直接提交)+(编辑模式下)确认密码框(水印「确认密码」,失焦/输入时校验)+ 右侧「确定」圆形按钮(`32×32`、圆角 `16px`、accent,tooltip「确定」);错误提示行(红色):密码不正确。/ 确认密码与密码不一致。/ 请输入密码。;已设置密码时显示「密码已设定。」+「更改密码」按钮(`E7C9`)。
3. **JoinManagementDialog**(`Controls/JoinManagementDialog.axaml:12-64`):宽 `400`,`SizeToContent=Height`,`CanResize=false`,标题「加入管理」。内容 `margin: 16px`:标题「加入管理」+ 说明「将当前ClassIsland实例连接到管理组织。」+ 配置文件行(左「配置文件:{路径}」单行截断 + 右侧文件选择按钮,文件类型为集控配置文件)+ 字段「ID(可选)」+ 错误文本(红色,按需显示)+ 不确定进度条(工作中)+ 底部两列按钮「取消」(`IsCancel`)「连接」(默认按钮,accent,仅当配置已加载时可用)。
### 11.13 教学(教程)窗口组
1. **教学中心 `TutorialCenterWindow`**(`Views/TutorialCenterWindow.axaml:14-248`、`.cs:31-84`):`1240×550`,启用 Mica,标题「教学中心」,图标 `AppLogo.ico`。结构:
- 顶部 TabStrip(`navigation` 样式,`font-size: 20px`)按「教程组」切换;右上「停止当前教程」按钮(`F087`,仅教程运行中显示);
- 内容区背景 `LayerOnMicaBaseAltFillColorDefaultBrush`;`TabControl`(`navigation`)承载当前教程组的 StickyScrollViewer;
- 头部大图:最大高度 `200px`,横幅 `DefaultTutorialGroupBanner.webp` 右对齐;背景为 `#2bbdbc` 叠加 60° 旋转的 `#19ffffff` 棋盘格纹理(`DestinationRect 64×64`,格子 32×32);头部底部标题行(`margin: 12px 8px 18px`):组标题(TitleTextBlockStyle)+ 组描述(14px 次级);头部随滚动折叠,折叠高度等于标题行高度;
- 教程卡片网格:WrapPanel 自动换列(目标宽度 `328px`),卡片:圆角 `4px`、裁剪;横幅图高 `100px`(`UniformToFill`)+ 内容区(`padding: 12px 8px 12px`,间距 `6px`):标题 `16px` Medium、描述 `12px` 次级、底部进度条(高度紧贴底部,`max = 段落数`,`value = 已完成段落数`);
- 点击卡片打开右侧详情 Pane:`SplitView` 右置、`DisplayMode=Inline`、`OpenPaneLength=328px`、`CompactPaneLength=0`;Pane 内 `padding: 12px`:教程标题 `20px` Medium、描述、进度文本「已完成 X/Y 个段落。」、「播放教程」accent 按钮(教程运行中禁用)+「或者,选择下方的段落播放:」+ 段落列表(已完成段落标题加删除线、斜体、`opacity: 0.75`,并在标题后显示主色「已完成」;每项右侧悬停/选中时显示「播放」按钮 `EDB9`);
- 关闭窗口时若因用户操作则 `hide()`(保持单例),应用退出时正常销毁(`.cs:74-84`)。
2. **教程播放控制条 `TutorialControllerWindow`**(`Views/TutorialControllerWindow.axaml:1-46`):无系统装饰条(`SystemDecorations=BorderOnly`)、透明背景 + `AcrylicBlur`、`SizeToContent=WidthAndHeight`、`Topmost`、`ShowInTaskbar=false`、`CanResize=false`、标题「教程控制窗口」。内容:半透明底色 `SolidBackgroundFillColorBaseBrush`(`opacity: 0.5`)+ 横向排列(`margin: 12px 4px`,间距 `4px`):「教学进行中…」(`E959`,`opacity: 0.75`)+「跳过」按钮(`E444`,透明样式);跳过按钮点击弹出 Flyout(`max-width: 328px`,间距 `8px`):「跳过教程」(粗体)+「是否确定跳过本段落教程?您稍后可以在【主菜单】->【教学…】中重看本段教程。」+「跳过」accent 按钮。该窗口位置必须由主进程放在主界面附近(右下角、距屏幕边缘 `16px`【推断】),并在教程结束时销毁。
3. **教程编辑器 `TutorialEditorWindow`**(`Views/TutorialEditorWindow.axaml:15-377`):`WindowState=Maximized`,标题「教程编辑器」。结构:
- 菜单栏(跨 3 列):「文件」含 打开…(`Ctrl+O`)、保存…(`Ctrl+S`)、另存为…(`Ctrl+Shift+S`)、分隔线、教程章节信息…(`Ctrl+I`);「运行」含 运行当前教程(`F5`)、从当前段落开始运行(`Ctrl+F5`)、分隔线、停止运行(`Shift+F5`);
- 列定义 `220px, Auto, *, Auto, 328px`,行定义 `Auto, Auto, *, Auto`;
- 第 1 列:教程列表(顶部「添加/复制/删除」按钮,图标 `E00D/E58B/E61D`,删除为 `IndianRed`;列表项「图标 + 标题」,可垂直拖拽排序);
- 第 3 列:段落列表(同上按钮);第 5 列:语句 DataGrid(列:标题 `*`、内容 `3*`、目标选择器 `2*`;`RowHeaderWidth=24`,行头为拖拽把手;支持行拖拽排序);
- 最右侧 `328px`:选中对象的属性面板;教程组属性:ID/标题/描述;教程属性:ID/标题/描述/Banner(水印「(默认)」);段落属性:基本页(ID/标题/描述/TopLevel 类名/复选框「启用屏障」)+「进入事件」页;语句属性:基本页(标题/内容/头图/图标/目标控件选择器/教学提示摆放模式 14 种(Auto/Top/Bottom/Left/Right/TopRight/TopLeft/BottomRight/BottomLeft/LeftTop/LeftBottom/RightTop/RightBottom/Center)/指向目标/高亮目标/使目标具有模态/可等待被外部推动/Tag/使用 LightDismiss 代替右侧按钮/右侧按钮/左侧按钮)+「进入事件」「左按钮事件」「右按钮事件」三个行动编辑页 +「脚本」页(启用脚本开关 +「编辑脚本…」按钮打开脚本编辑器)。
- 关闭窗口时必须拦截并处理未保存修改(`Closing` 事件),窗口失焦时不得自动关闭。
4. **教程脚本编辑器 `TutorialScriptEditWindow`**(`Views/TutorialScriptEditWindow.axaml:1-29`):`800×520`,`MinWidth=480`、`MinHeight=320`,标题「编辑教程脚本」。顶部一行(`margin: 8px`,间距 `6px`):「Lua 教程脚本」(`E943`)+ 复选框「启用脚本」;主体为代码编辑器:显示行号、深色主题(背景 `#1E1E1E`、前景 `#D4D4D4`)、等宽字体 `JetBrains Mono, Cascadia Code, Consolas, Menlo, Monospace`、`font-size: 14px`、`margin: 0 8px 8px`。脚本语言为 Lua;失焦/输入时不得丢失内容,关闭时保存。
5. **教程行动类型(TutorialActionKind)映射表(必须逐值实现,JSON 数字不可改)**:
枚举定义:`ClassIsland.Core/Enums/Tutorial/TutorialActionKind.cs:6-44`,共 **9 项(0–8)**;模型字段:`TutorialAction.Kind`(`ClassIsland.Core/Models/Tutorial/TutorialAction.cs:18`)、`StringParameter`(同文件 `:23`,字符串参数)、`ActionSet`(同文件 `:29`,仅 `InvokeActionSet` 使用,序列化时空值省略)。ClassIsland 的 JSON 由 `System.Text.Json` 默认选项写出(`ClassIsland.Shared/Helpers/ConfigureFileHelper.cs:30-36` 只设置了 `WriteIndented`,未注册字符串枚举转换器),因此 `Kind` 在 JSON 中是**数字**;本实现读写教程 JSON 时必须使用 `Kind`(PascalCase)与数字值,**不得**改成 `kind` 或字符串。
| 数值 | 枚举名 | 中文语义(编辑器文案) | 触发来源 / 控制条入口 | 执行语义与边界(源码证据) |
|---|---|---|---|---|
| 0 | `None` | 无 | 任意动作数组 | 什么都不做(`TutorialService.cs:622-623`)。 |
| 1 | `NextSentence` | 下一句 | 语句右按钮 / LightDismiss / TeachingTip 关闭 / 聚光灯点击(均以参数 1 调用 `PostSentenceWorks`) | 若当前段还有下一句则进入下一句;否则尝试进入下一段;已是最后一段则停止教学(`TutorialService.cs:624-626`、`:678-691`;按钮参数注入 `:404-416`;`PostSentenceWorks` `:586-600`;聚光灯 `:503-506`)。**初始化动作中不执行**(`isInit` 守卫)。 |
| 2 | `PreviousSentence` | 上一句 | 语句左按钮(以参数 0 调用 `PostSentenceWorks`) | 若存在上一句则回退到上一句;否则尝试回退到上一段;再失败则停止教学(`TutorialService.cs:635-647`)。默认左按钮文字为空、默认动作为 `PreviousSentence`(`TutorialSentence.cs:84-94`)。初始化动作中**允许执行**(源码未加 `!isInit` 守卫)。 |
| 3 | `NextParagraph` | 下一段 | 任意动作数组 | 进入下一段并先把当前段落标记为已完成;若已是最后一段则停止教学(`TutorialService.cs:627-634`、`:693-707`)。**初始化动作中不执行**。 |
| 4 | `PreviousParagraph` | 上一段 | 任意动作数组 | 回退一段;已到第一段则停止教学(`TutorialService.cs:648-655`、`:709-724`)。**初始化动作中不执行**。 |
| 5 | `JumpParagraph` | 跳转到段落 | 任意动作数组 + `StringParameter` | 参数为段落路径:含 `/` 时按 `tutorialId/paragraphId` 全局查找,只有段落 id 时在当前教程内查找;查不到时**静默忽略**(`TutorialService.cs:656-663`、`ParseParagraphPath` `:519-553`)。编辑器仅在选中该项时显示参数输入框(`Controls/TutorialEditor/TutorialActionsEditControl.axaml:64-79`)。**初始化动作中不执行**。 |
| 6 | `Stop` | 停止 | 任意动作数组 | 立即 `StopTutorial()`:关闭控制条、清理教学提示、标记当前段落完成、清空当前教程/段落/句子(`TutorialService.cs:664-665`、`:565-575`)。初始化动作中**也执行**。 |
| 7 | `InvokeActionSet` | 执行行动 | 任意动作数组 + `ActionSet` | 调用自动化服务执行动作集;`ActionSet == null` 时忽略(`TutorialService.cs:667-668`)。编辑器在切换到该项时必须初始化 `ActionSet`(`TutorialActionsEditControl.axaml.cs:62-65`)。初始化动作中**也执行**。 |
| 8 | `InvokeUri` | 执行 Uri | 任意动作数组 + `StringParameter` | 把参数作为 Uri 交给 Uri 导航服务(支持 `classisland://` 内部路由);参数非法时源码会抛异常(`TutorialService.cs:670-671`)——本实现必须捕获、写 warning 日志并继续,不得崩溃。初始化动作中**也执行**。 |
| ≥9 | 未知值 | — | — | 源码 `default:` 抛 `ArgumentOutOfRangeException`(`TutorialService.cs:673-674`);**本实现必须改为:忽略该动作并写 warning 日志(含 Kind 数值与动作索引),继续执行后续动作**。 |
执行上下文与顺序(必须一致):
- 动作数组共 4 组:段落 `InitializeActions`(段落开始时执行,`TutorialService.cs:244-251`)、语句 `InitializeActions`(语句开始时执行,`:362-379`)、语句 `LeftButtonActions`、语句 `RightButtonActions`;每组内按数组顺序**同步串行**执行(`:608-616`),执行期间 `_isInvokingActions=true`,此时外部推进(`Next` 命令/LightDismiss)必须被忽略(`:202-212`)。
- 「初始化动作」等价于 `isInit=true`:`NextSentence`/`NextParagraph`/`PreviousParagraph`/`JumpParagraph` 被跳过,其余照常执行;`LeftButtonActions`/`RightButtonActions` 等价于 `isInit=false`。
- 控制条(`TutorialControllerWindow`)的「跳过」按钮**不经过 Kind 枚举**:它等价于 `SkipTutorial()`(把当前段落标记为已完成并停止教学,`TutorialControllerWindow.axaml.cs:54-57`、`TutorialService.cs:577-584`)。
- Lua 脚本不是 Kind 的执行者,但可在 5 个钩子里干预:`onInitializing` 返回 `false` 会中止该语句初始化(**不执行** InitializeActions)、`onLeftButtonClicked` 返回 `false` 会阻止左按钮动作、其余钩子失败只记错误;脚本全局变量固定为 `service`(教程服务,可直接调 `service:PostSentenceWorks(0|1)`、`service:JumpToParagraph("tutorialId/paragraphId")`、`service:InvokeActions(...)`)与 `context`(`TutorialService.cs:454-500`、`:586-616`)。脚本异常必须弹出错误 Toast 并写 error 日志,不得中断整个教程。
JSON 兼容性与 TypeScript 常量(必须照抄):
```json
{ "Kind": 5, "StringParameter": "classisland.getStarted/welcome", "ActionSet": null }
```
```ts
// src/shared/tutorial-action-kind.ts —— 数字必须与 ClassIsland 的 TutorialActionKind 完全一致
export const TutorialActionKind = {
None: 0,
NextSentence: 1,
PreviousSentence: 2,
NextParagraph: 3,
PreviousParagraph: 4,
JumpParagraph: 5,
Stop: 6,
InvokeActionSet: 7,
InvokeUri: 8,
} as const;
export type TutorialActionKindValue = (typeof TutorialActionKind)[keyof typeof TutorialActionKind];
// 读取时兼容三种来源:数字(ClassIsland 原生)、枚举名字符串(插件手写)、未知值
export function normalizeTutorialActionKind(raw: unknown): TutorialActionKindValue | null;
```
读取规则:字段名必须以 `Kind`/`StringParameter`/`ActionSet` 精确匹配(.NET 默认 `PropertyNameCaseInsensitive = false`,写错大小写会读不到);`Kind` 既接受 `0–8` 数字,也接受枚举名字符串(大小写敏感)作为容错;**写出时必须写数字**,以保证导回 ClassIsland 时兼容。未知值/未知名字符串:`normalizeTutorialActionKind` 返回 `null`,调用方跳过该动作并在日志中记录 `unknown TutorialActionKind: <raw>`(warning 级别),**禁止崩溃或静默改变 Kind**。
### 11.14 日志窗口 `AppLogsWindow`
源:`Views/AppLogsWindow.axaml:1-178`、`.cs`。
1. **窗口参数**:`1200×600`,启用 Mica,标题「日志」,单例(关闭即 `hide()`,重复打开时 `show()`+`focus()`)。
2. **布局**:三行(`Auto, *, Auto`):
- 顶部左侧为筛选 Chip 列表(多选可切换):灾难(`E84E`)、错误(`E808`)、警告(`F430`)、信息(`E9E4`)、调试(`E2C7`)、追踪(`E50F`);默认选中级别必须与日志级别配置一致【推断:默认全部开启】;
- 顶部右侧搜索框(水印「筛选日志…」,`min-width: 200px`),输入即时过滤;
- 中部 DataGrid(只读、水平网格线、背景 `LayerOnMicaBaseAltFillColorDefaultBrush`,`font-size: 13px`):列「时间」(Auto,`margin: 2px 9px 0`)、「等级」(Auto:胶囊样式——灾难/错误使用 `SystemFillColorCriticalBackgroundBrush` + 粗体,警告使用 `SystemFillColorCautionBackgroundBrush` + 粗体,调试/追踪 `opacity: 0.75`,均配图标与中文级别名)、「区域」(Auto,`max-width: 250px`,可换行可选择)、「内容」(`*`,可换行可选择);
- 底部右对齐按钮:清空日志(`E61D`)、复制选中(`E58B`),均为透明按钮。
3. **行为**:日志必须来自主进程统一日志总线(`IPC` 推送),行数上限固定为 `10000` 条并在超出时裁掉最旧条目【推断:上限值由工程约定给出,源码未硬编码】;关闭时必须取消订阅、停止追加,避免内存泄漏。
### 11.15 其余窗口与对话框(逐个规格)
1. **配置文件错误窗口 `ConfigErrorsWindow`**(`Views/ConfigErrorsWindow.xaml:1-106`):`1200×600`,标题「配置文件错误」,纯色背景 + HarmonyOS Sans 14。结构:第 0 行 ToolBar(`ToolBarTray.IsLocked=true`)含「恢复备份」按钮(`Restore` 图标);第 1 行说明文本「下方的配置文件因意外断电等原因已损坏且无法加载,现已自动加载备份或使用默认值。您可以重启到恢复模式并从备份中恢复配置文件。」;第 2 行 DataGrid(只读、像素滚动、禁止重排列)四列:状态图标(警告=黄色 `WarningOutline`,错误=红色 `ErrorOutline`)、「路径」、状态文本(警告「已恢复至上次正确的配置」/错误「已重置为默认配置」)、「操作」(复制详细信息按钮)。打开方式:设置→存储页 InfoBar 的「详细信息…」或 URI `classisland://app/config-errors`。
2. **实例已存在窗口 `InstanceExistedWindow`**(`Views/InstanceExistedWindow.xaml:1-54`):`800×450`,`CenterScreen`,`ResizeMode=NoResize`,标题「ClassIsland 正在运行」。三行:说明文本(两段:已在运行请通过托盘图标操作;若看不到主界面可能是选择了隐藏主界面、规则隐藏或自动化修改)、演示图(`OpenTaskBarMenuDemo`,指引用户点托盘)、右下按钮「重启应用」(图标 `Restart`)与「我知道了」(图标 `Close`,纯色背景按钮样式)。
3. **窗口选择器 `WindowsPicker`**(`Views/WindowsPicker.xaml:1-149`):`1300×600`,标题「选择壁纸窗口」,用于选择壁纸层窗口类名。结构:第 0 行 Material ColorZone 标题栏(PrimaryMid,Dp4,`padding: 16px`):「选择窗口」(Headline6)+ 右侧「刷新」按钮(`Refresh`,24px)+「更多选项…」弹出框(含复选框「仅显示全屏窗口」);第 1 行窗口缩略图网格(WrapPanel):每项含截图(`height: 150px`,`Uniform`)、左下角 `32×32` 应用图标(圆角 4)、右下角选中标记(`40×40` 圆形主色 + 白色 Check,带 `BlurRadius=6, ShadowDepth=0` 阴影)、下方四行文本:描述(18px Bold)、类名、窗口标题、进程名(均 13px);第 2 行分隔线;第 3 行底栏(`margin: 8px`):`WindowMaximize` 图标 + 文本框(Hint「窗口类名」)+ 右侧「取消」「确定」按钮(`Cancel`/`Check` 图标);加载中显示全屏遮罩 + `StarRailLoadingControl`。
4. **壁纸预览窗口 `WallpaperPreviewWindow`**(`Views/WallpaperPreviewWindow.xaml:1-74`):`800×450`,`CenterOwner`,标题「壁纸提取结果预览」。内容:整窗壁纸图(`Uniform`、高质量缩放);底部悬浮操作条(半透明底 `opacity: 0.8`,`margin: 8px`):「刷新」按钮(左,工作中禁用)、「关闭」按钮(右,`IsCancel`);工作中底部显示不确定进度条;数据未就绪时整窗显示加载遮罩(`TeyvatLoadingControl` 右下角)。
5. **顶层特效窗口 `TopmostEffectWindow`**(`Views/TopmostEffectWindow.axaml:1-35`):`1×1`、`Topmost`、无边框(`SystemDecorations=None`)、背景透明,标题「顶层效果窗口」。它在运行时会由主进程调整为覆盖**整个屏幕**(含任务栏)的透明置顶窗口,用于全屏提醒特效;内容为 `ItemsControl`(面板为 `Grid`,所有特效控件叠放并拉伸),每个特效控件外层包 `Border` 拉伸。关闭行为必须拦截(除应用退出外不关闭)。
6. **清除通知按钮窗口 `ClearNotificationButtonWindow`(不实现)**:该窗口在 ClassIsland 2.1 中由 `#if false` 包裹、内容仅为空按钮,属于遗留未完成代码。本实现**不得**照搬该窗口;清除提醒必须在主界面(岛)的右键菜单中提供「清除全部提醒」菜单项(该菜单的完整交互归 part 04 / part 07)。源码仅作反例记录:`Views/ClearNotificationButtonWindow.xaml:1-15`、`Views/ClearNotificationButtonWindow.xaml.cs`。
7. **翻新范围对话框 `RefreshingScopesConfigDialog`**(`Views/RefreshingScopesConfigDialog.axaml:1-60`):`400×600`,`CenterOwner`,`ShowAsDialog=true`,`CanResize=false`,标题「编辑翻新范围」。三行(`Auto * Auto`,`margin: 12px`):头部「选择翻新时要保留的内容」(Subtitle)+「以下内容在翻新时不会被删除。」(次级);TabControl(compact)两个页签「课表」「时间表」,各为多选列表(每项 CheckBox + 名称,`SelectionMode=Multiple,Toggle`);底部(`margin: -12px 0 -12px -12px`,背景 `LayerOnMicaBaseAltFillColorDefaultBrush`)右侧「确定」accent 按钮(`IsCancel=true`)。
8. **窗口规则调试工具 `WindowRuleDebugWindow`**(`Views/WindowRuleDebugWindow.axaml:1-30`):`400` 宽、`SizeToContent=Height`、`Topmost`、启用 Mica,标题「窗口规则调试工具」。内容为可选择文本的多行信息:窗口句柄、窗口类名、窗口标题、窗口状态、窗口进程名(每行「标签:值」)。窗口加载后必须持续监听前台窗口变化并刷新。
9. **截图辅助窗口 `ScreenshotHelperWindow`**(`Views/ScreenshotHelperWindow.axaml:1-37`,仅开发/内部):标题「截图」。四个字段:窗口下拉(每项「标题 (Id=…) [Type]」)、保存文件夹文本框、缩放数值框、底部「刷新」「截图」按钮(截图为主色)。实现可选,但不得在正式版暴露给普通用户。
10. **功能调试窗口 `FeatureDebugWindow`**(`Views/FeatureDebugWindow.xaml:1-246`,仅开发):`800×450`,标题「FeatureDebugWindow」,TabControl 多页(WeatherIcon、ScheduleCalendar、MarkdownTest、SecureDesktop、AuthorizeWindow 等),用于内部测试;正式构建必须移除入口。
11. **开发者门户 `DevPortalWindow`**(`Views/DevPortalWindow.axaml:1-269`,仅开发):`1200×700`,`CenterScreen`,启用 Mica,标题「DevPortal」。顶部 TabControl(`navigation` 样式,覆盖整窗,距顶 `48px` 的底色层),页签:首页(调试时间偏移、测试选择器解析、测试教程、贴纸高亮演示、StartSplash/EndSplash)、控件(ChipListBox、Toast、Shimmer、平行四边形/斜切遮罩动画、贴纸、TutorialBarrier 等)、提醒、LDV、OOBE_Intro、Markdown(左编辑器右预览)、调课日历、WeatherIcon、StickyScrollViewer、竖直抽屉、SDG。正式构建必须不可达。
12. **通知中心**:ClassIsland **没有**独立的通知中心窗口;提醒以「提醒队列 + 主界面提醒气泡 + 全屏强调特效」形式呈现(见 part 07)。实现者不得创建通知中心窗口,以免与主界面提醒语义冲突;若要提供历史通知列表,必须作为可选扩展并明确不属于 ClassIsland 原版功能。
### 11.16 全局快捷键与关闭行为总表
| 场景 | 快捷键 | 行为 |
|---|---|---|
| 任意窗口 | `F3` / `Shift+F3` | 切换/升级渲染调试浮层(FPS → FPS+布局/渲染耗时) |
| 任意窗口 | `F6` / `Shift+F6` | 切换触摸模式 / 同时抑制自动触摸判定 |
| 任意窗口 | `F7` | 关闭当前窗口所有 Toast |
| 设置窗口 | `Esc` | 关闭最上层抽屉/菜单,不关闭窗口 |
| 换课窗口 | `Esc` | 取消并关闭 |
| 授权窗口 | `Enter`(密码框内) | 提交认证 |
| 数据迁移/欢迎向导 | `Enter`(默认按钮) | 执行默认操作 |
| 教程编辑器 | `Ctrl+O`/`Ctrl+S`/`Ctrl+Shift+S`/`Ctrl+I` | 打开/保存/另存为/章节信息 |
| 教程编辑器 | `F5`/`Ctrl+F5`/`Shift+F5` | 运行当前教程/从当前段落运行/停止运行 |
| Excel 导入导出表格 | `Ctrl+C`/`Ctrl+V`/`Ctrl+X`/`Del` | 复制/粘贴/剪切/清除内容 |
| 日志窗口 | 无 | 通过筛选与搜索操作 |
关闭行为必须满足:
1. 非模态单例窗口(设置、日志、教学中心):关闭 = 隐藏;应用退出时统一销毁。
2. 模态窗口(欢迎向导、数据迁移、Excel 导入/导出、更新进度、授权、翻新范围):关闭 = 销毁并回传结果;更新进度窗口在更新过程中必须拒绝关闭。
3. 换课窗口:`Esc`/取消 = 直接关闭且不写入数据;确认后关闭并持久化。
4. 顶层特效窗口:仅由提醒系统控制生命周期,用户不可关闭。
5. 崩溃窗口:必须允许用户选择「忽略」继续(非 critical)或「退出/重启」。
### 11.17 验收标准
实现完成后必须逐项验证(全部通过才算合格):
1. 首次启动进入 OOBE:窗口 `800×600` 居中、不可缩放;片头字母/方块动画时长与顺序符合 11.2 第 5 条;欢迎页「下一步」可前进;OOBE 关闭时弹出「退出 ClassIsland」确认框,取消后仍停留在向导内。
2. 许可页未勾选两个复选框时「下一步」不可用;勾选后可用;系统页「在桌面创建换课快捷方式」在未勾选「注册 Url 协议」时不可用。
3. 完成页 Carousel 有 3 页,过渡为水平滑动+交叉淡入 `300ms`;最后一页按钮变为「完成」,点击后关闭窗口并按勾选项创建快捷方式。
4. 开启「显示启动加载界面」后启动:闪屏为 `400×100` 顶部居中、无边框、半透明、`17px` 内边距;Logo 与状态行分别延迟 `0/150ms` 滑入;进度条从左侧填充至 100% 后闪屏关闭。
5. 制造一次未处理异常:崩溃窗口 `800×450`,错误文本可选择复制;「反馈问题」打开 GitHub issue 页面且 URL 携带 stacktrace/app_version/os_version;critical 异常时「退出应用」使进程以退出码 1 结束。
6. 以恢复模式启动:恢复窗口 `450×600`,主页 10 个操作项齐全;进入「恢复备份」后列表、两种恢复模式单选、右下按钮布局与禁用规则符合 11.5 第 3 条。
7. 打开换课窗口:`MinWidth=730`;选中课程后自动进入第 2 步;「替换/交换」切换时右侧内容垂直滑动 `300ms`;存在临时层时确认「覆盖当前的临时层」;勾选「永久换课」后直接改写原课表;取消后课表未变化。
8. 打开 Excel 导入:拖入 `.xlsx` 后进入页面 2(欢迎),按导航映射可完整走到页面 16「完成」;选择非单行/单列区域时出现红色「选择区域无效。」且不可下一步;选区模式页头有呼吸高亮动画;导入结果写入档案后可在课表中看到对应课程;实现中不得出现 ReoGrid/WPF/`System.Windows.Forms` 依赖。
9. 打开 Excel 导出:窗口最大化,右侧三个页签在右边缘;「样式」页可改字体颜色/背景/框线并「刷新内容」;保存后的 `.xlsx` 在 Excel/WPS 中可正常打开且课表内容、合并单元格、边框与列宽行高与界面一致。
10. 打开数据迁移:窗口 `800×600` 不可缩放且为模态;导入/导出两组文案随模式切换;ClassIsland 1.x 导入需选择旧版 `ClassIsland.exe`。
11. 打开日志窗口:五类筛选与搜索同时生效;灾难/错误/警告行有不同背景色;「复制选中」只复制选中行;关闭后再次打开仍为同一窗口实例且日志继续累积。
12. 打开教学中心:`1240×550`;教程卡片按 `328px` 目标宽度自动换列;点击卡片右侧滑出 `328px` 详情栏;「播放教程」启动后出现顶部「停止当前教程」按钮与置顶「教学进行中…」控制条。
13. 所有窗口在 Windows 11 上呈现 Mica 背景、无系统标题栏;在不支持 Mica 的环境退化为纯色背景且不出现黑边或透视异常。
14. 所有模态窗口在有父窗口时居中于父窗口;所有窗口的 `Esc`、`Enter`、`F3/F6/F7` 行为符合 11.16 表。
15. 教程行动类型:构造覆盖 `Kind = 0–8` 全部 9 个值的测试教程 JSON(含 `JumpParagraph` 的两种路径形式、`InvokeUri` 的 `classisland://` 路由、`InvokeActionSet` 的空/非空 `ActionSet`、以及段落与语句两处 `InitializeActions` 中的 `NextSentence/NextParagraph/PreviousParagraph/JumpParagraph` 应被跳过),逐值验证行为与 11.13 第 5 条映射表一致;再注入 `Kind = 9`、`Kind = -1`、`Kind = "NotAKind"` 与 `kind`(小写键)等异常数据,要求:不崩溃、不卡死、未知动作被跳过、日志中出现 `unknown TutorialActionKind` warning,且后续动作照常执行。
+316
View File
@@ -0,0 +1,316 @@
## 12. 插件系统、跨进程通信、单实例与自动更新
### 12.1 目标与边界
1. 你必须实现一套**进程隔离、权限受控、可市场分发**的插件系统,能力范围对齐 ClassIsland 的插件体系(组件、提醒提供者、自动化触发器/行动、设置页、主题、信息提供者),但实现形态必须是 Electron 下的「npm 风格动态模块 + utilityProcess 宿主」,不得沿用 .NET 程序集加载。
2. 你必须实现完整的跨进程通信层:主进程 ↔ 应用窗口渲染进程 ↔ 岛渲染进程 ↔ 插件宿主进程 ↔ 更新助手。**任何进程间只传纯数据**,不得传函数、类实例、Proxy。
3. 你必须实现单实例、URI 协议唤起、自动更新、崩溃自愈与看门狗。这些能力的失败模式直接决定"教室里能不能无人值守跑一学期"。
4. 你**不得**把插件能力做成"直接在主进程 eval 用户代码"。插件崩溃/死循环不得拖垮主进程,这是本部分的第一验收项。
### 12.2 ClassIsland 插件体系事实(必须对齐的语义)
**包格式与目录**:
- 插件包扩展名 `.cipx`,本质是 zip。依据:`repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IPluginService.cs:17`、`repos/ClassIsland/ClassIsland/Services/PluginService.cs:79-107`。
- 包内必须含 `manifest.yml`;解压后安装到 `Plugins/<插件Id>/`;包缓存目录为 `Cache/PluginPackages/`;插件自己的设置目录为 `Config/Plugins/<插件Id>/`。依据:`PluginService.cs:35-44`。
- 安装流程:扫描包缓存里的 `*.cipx` → 读取 `manifest.yml`(`IgnoreUnmatchedProperties` + **CamelCase 命名约定**)→ 删除同名旧目录(保留 `.disabled` 状态)→ 解压 → 删除包文件。依据:`PluginService.cs:57-107`。
- 启用/禁用状态用插件目录下的标记文件表达:`.disabled` 存在即禁用;`.uninstall` 存在表示"下次启动时删除该插件目录"。依据:`repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginInfo.cs:47-100`。
**清单字段(`manifest.yml`,YAML 键为 camelCase)**:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `entranceAssembly` | string | `""` | 入口程序集(本实现改为 `main.mjs` 入口文件) |
| `name` | string | `""` | 显示名称 |
| `id` | string | `""` | 插件唯一 ID(本实现要求反向域名式,如 `com.example.weather`) |
| `description` | string | `""` | 描述 |
| `icon` | string | `icon.png` | 图标相对路径 |
| `readme` | string | `README.md` | 自述文件相对路径 |
| `url` | string? | `null` | 项目地址 |
| `version` | string | `""` | 插件版本(semver) |
| `apiVersion` | string | `""` | 目标宿主 API 版本;本实现要求 `>=2.0.0` |
| `author` | string | `""` | 作者 |
| `dependencies` | `{id, isRequired}[]` | `[]` | 依赖;`isRequired` 默认 `true` |
| `supportedOSPlatforms` | string[] | 全平台 | 有效值 `windows`/`linux`/`osx` |
依据:`repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginManifest.cs:12-75`、`repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginDependency.cs:6-17`。
**清单命名兼容(强制)**:本实现统一写 **camelCase**(与 ClassIsland `manifest.yml` 的 CamelCase 约定一致);同时必须**兼容读取** PascalCase 旧清单(`Id`/`Name`/`EntranceAssembly`/`ApiVersion`/`SupportedOSPlatforms` 等)。加载时必须先做 `camelCase ↔ PascalCase` 归一化再校验;同名键以 camelCase 为准(不得让旧拼写覆盖新值);无法识别的键忽略并记 debug 日志。依据:`PluginService.cs:68-72`(`IgnoreUnmatchedProperties` + `CamelCaseNamingConvention`)。
**加载规则**:
- 按依赖拓扑排序加载;必选依赖缺失或不可加载 → 该插件置为 `Error`;检测到循环依赖必须抛错并拒绝整组。依据:`PluginService.cs:288-339`。
- `apiVersion < 2.0.0.0` → 拒绝加载并记录 `Error` 与异常说明(对齐 `PluginService.cs:168-173`)。
- 加载状态枚举固定四种:`NotLoaded`/`Loaded`/`Disabled`/`Error`,并带 `Exception` 供设置页展示。依据:`repos/ClassIsland/ClassIsland.Core/Enums/PluginLoadStatus.cs`。
- 每个插件有独立的"注册上下文":插件在 `Initialize` 期间注册的组件会自动归属该插件。依据:`PluginService.cs:211-221`(`ComponentRegistryService.CurrentRegisteringPlugin`)。
- 加载失败/损坏的插件必须在下次启动自动禁用(`Settings.AutoDisableCorruptPlugins` 默认 `true`,`Settings.CorruptPluginsDisabledLastSession` 记录上次会话是否发生过自动禁用)。依据:`repos/ClassIsland/ClassIsland/Models/Settings.cs:1844-1845`。
**插件市场**:
- 索引结构 `PluginIndex { plugins[], downloadMirrors{ name: root } }`;条目在 `PluginInfo` 之上增加 `downloadUrl` 与 `downloadMd5`。依据:`repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginIndex.cs:12-21`、`PluginIndexItem.cs:8-19`。
- 下载镜像:镜像根替换下载链接里的模板;内置回退镜像 `github`、`ghproxy`、`moeyy`;官方索引地址带时间戳参数 `https://get.classisland.tech/d/ClassIsland-Ningbo-S3/classisland/plugin/index.zip?time={time}`;索引默认 7 天刷新一次。依据:`repos/ClassIsland/ClassIsland/Services/PluginMarketService.cs:43-48,66-69,212`。
- 设置项:`userPluginIndexes`、`additionalPluginIndexes`、`pluginIndexSelectedMirrors`、`officialSelectedMirror`(默认 `github`)、`ignoreSslForPluginMirrors`(默认 `false`)、`isPluginsAutoUpdateEnabled`(默认 `true`)、`isPluginsUpdateNotificationEnabled`(默认 `true`)、`isPluginMarketWarningVisible`。依据:`Settings.cs:166-178,1858-1859,204`。
- SDK 打包:插件项目 `CreateCipx=true` 时,构建后自动把输出目录压成 `<项目名>.cipx`,并在 `checksums.md` 里写一行 HTML 注释形式的 MD5 JSON(`<!-- CLASSISLAND_PKG_MD5 {...} -->`)。依据:`repos/ClassIsland/ClassIsland.PluginSdk/ClassIsland.PluginSdk.targets`(`CreateCipx` Target)与 `generate-md5.ps1`。
### 12.3 本实现的插件规范(必须逐项落地)
**包与目录**:
1. 插件包扩展名沿用 `.cipx`(zip),内部结构固定:
```text
my-plugin.cipx
├─ manifest.yml # 必须存在;字段见下表
├─ main.mjs # 必须存在;ESM 入口,默认导出 activate/deactivate
├─ icon.png # 可选,默认 icon.png
├─ README.md # 可选
└─ assets/ # 可选静态资源(只允许相对路径引用)
```
2. 运行期目录:`<userData>/plugins/<id>/`(解压后)、`<userData>/plugin-cache/packages/*.cipx`(下载缓存)、`<userData>/plugin-data/<id>/`(插件配置与缓存)、`<userData>/plugin-cache/index.json`(市场索引缓存)。必须与 13 部分的目录规划一致。
3. 启用状态用 `<pluginDir>/.disabled` 标记,卸载用 `<pluginDir>/.uninstall` 标记;**不得**用配置里的布尔值表达启用状态(否则用户手工操作目录后状态会不一致)。依据:`PluginInfo.cs:47-100`。
4. 插件数据写入必须由宿主代理:插件调用 `ctx.storage.set(key, value)`,宿主写到 `<userData>/plugin-data/<id>/storage.json`,**不得**给插件直接 `fs` 访问。
**清单(`manifest.yml`,camelCase,字段与默认值必须与 12.2 表一致,另加以下扩展)**:
```yaml
id: com.example.weather-plus
name: 天气增强
description: 提供逐小时降水提醒
author: Example
version: 1.2.0
apiVersion: 2.0.0
icon: icon.png
readme: README.md
entry: main.mjs
url: https://example.com/weather-plus
supportedOSPlatforms: [windows, linux, osx]
dependencies:
- id: com.classisland.core-miniinfo
isRequired: true
permissions:
- notifications:push # 允许发提醒
- automation:register # 允许注册触发器/行动
- components:register # 允许注册岛组件
- settings:page # 允许注册设置页
- storage:read
- storage:write
- network:api.example.com # 仅允许访问列出的域名(精确匹配,禁止通配)
```
5. `permissions` 是**本实现在 ClassIsland 基础上的强制安全增强**:ClassIsland 的插件是 DLL 全信任,本实现必须默认拒绝、按声明放行。任何未声明的能力调用都必须抛 `PluginPermissionError` 并写 warn 日志;设置页必须在启用前展示权限清单并二次确认。**【推断】**
6. `apiVersion` 必须是 semver;宿主 API 当前版本固定为 `2.0.0`,`apiVersion` 主版本不同即拒绝加载;次版本号高于宿主时给出"可能不兼容"警告但仍允许(与 ClassIsland 的"最高版本门槛"语义相反,此处以兼容性优先【推断】)。
7. 依赖解析必须:先校验所有 `isRequired` 依赖存在且 `Loaded`;再做拓扑排序;循环依赖必须报错并列出环上的 ID;可选依赖缺失只记 info。依据:`PluginService.cs:306-339`。
**生命周期与宿主**:
1. 插件宿主必须是 `utilityProcess.fork(path.join(__dirname,'plugin-host.js'))` 启动的独立进程;插件代码在该进程内用 `await import(pathToFileURL(entry))` 加载。**不得**在主进程 `import()` 插件代码。
2. 宿主暴露给插件的 API 对象(`ctx`)只允许包含:`ctx.manifest`、`ctx.apiVersion`、`ctx.log`、`ctx.events`、`ctx.storage`、`ctx.notifications`、`ctx.components`、`ctx.automation`、`ctx.settings`、`ctx.themes`、`ctx.commands`、`ctx.timers`。禁止暴露 `process`、`require`、`fs`、`child_process`、`net`。
3. 插件导出的入口签名固定:
```js
export async function activate(ctx) { /* 注册能力;不要在这里做耗时 I/O */ }
export async function deactivate(ctx) { /* 清理定时器与监听器 */ }
export const manifest = {/* 可选:与 manifest.yml 一致 */};
```
4. 宿主必须为每个插件维护独立状态机:`discovered → validating → disabled | loading → loaded | error → restart-required`。状态与错误信息通过 IPC `classisland:plugins:list` 交给设置页渲染。
5. 插件加载超时必须为 5 秒;单个插件崩溃后宿主必须重启该插件(最多 2 次),仍失败则置 `error` 并在**下一次启动**写入 `.disabled`(对齐 `AutoDisableCorruptPlugins` 语义)。同一次运行内不得无限重启。
6. 启用/禁用/卸载必须标记"需要重启",并在 UI 上明确显示「重启 ClassIsland 后生效」;不得热替换已加载插件(热替换会留下旧的全局监听器)。依据:`PluginInfo.RestartRequired`(`PluginInfo.cs:159-172`)。
7. 插件注册的扩展点必须进入统一注册表,重复 ID 时后注册者失败并记录错误(不得静默覆盖)。依据:ClassIsland 的 `ComponentRegistryService`/`RulesetRegistryExtensions` 单注册表模式。
**扩展点(必须全部实现并映射)**:
| ClassIsland 扩展点 | 插件 API | 说明 |
|---|---|---|
| 组件(`ComponentBase` 派生 + 注册表) | `ctx.components.register({id, name, group, settingsSchema, render})` | 组件设置必须用声明式 schema;`render` 是可序列化描述或受控的 Vue 组件名,不得注入任意 DOM |
| 提醒提供者(`NotificationProvider`) | `ctx.notifications.registerProvider({id, name, request})` | 提供者只能"请求提醒",是否播放由宿主队列决定 |
| 自动化触发器 | `ctx.automation.registerTrigger({id, name, schema, setup})` | 触发回调可发信号给宿主 |
| 自动化行动 | `ctx.automation.registerAction({id, name, schema, run})` | 行动执行受权限约束(执行外部程序需 `exec` 权限) |
| 设置页 | `ctx.settings.registerPage({id, title, schema, section})` | 只能是声明式 schema,由宿主统一表单渲染;**不得**提供任意 HTML/iframe(见下方安全红线) |
| 主题 | `ctx.themes.registerManifest(themeManifest)` | 主题包格式见 9 部分 |
| 迷你信息提供者 | `ctx.miniInfo.registerProvider({id, name, fetch})` | 天气/温度等文本信息,带超时与缓存 |
| 设置叠层 | `ctx.settings.overlay({key, value})` | 对齐 ClassIsland `AddSettingsOverlay` 的"临时覆盖并自动还原"语义(`repos/ClassIsland/ClassIsland/Services/SettingsService.cs:191-259`) |
**设置页安全红线(强制)**:插件设置页**只能**是声明式 schema(字段名、类型、默认值、校验规则、布局分组、可见性条件),由宿主用统一表单渲染;**禁止**插件注入任意 HTML/CSS/脚本,**禁止**用 iframe/webview 承载插件设置页,**禁止**插件直接访问 DOM 或 `window`。schema 允许的控件仅限:文本、多行文本、数字、开关、下拉、单选、多选、颜色、时间、文件路径(由宿主弹选择器)、按钮(只能触发宿主声明允许的行动)。任何试图绕过 schema 的插件必须加载失败并记 error。
**插件市场(本实现)**:
1. 索引 JSON schema 必须为:
```json
{
"plugins": [{
"id": "com.example.weather-plus",
"name": "天气增强",
"description": "……",
"author": "Example",
"version": "1.2.0",
"apiVersion": "2.0.0",
"supportedOSPlatforms": ["windows"],
"downloadUrl": "{root}/example/weather-plus/releases/download/v1.2.0/weather-plus.cipx",
"downloadMd5": "…32位十六进制…",
"downloadSha256": "…64位十六进制…",
"downloadSize": 1048576,
"repoOwner": "example",
"repoName": "weather-plus",
"assetsRoot": "master",
"artifactName": "weather-plus.cipx"
}],
"downloadMirrors": { "github": "https://github.com", "ghproxy": "https://mirror.ghproxy.com/https://github.com" }
}
```
2. `downloadUrl` 必须支持 `{root}` 模板,按当前选中镜像替换;镜像失败必须按列表回退,不得只试一个。
3. 索引必须支持"多源":**默认不内置在线官方索引**,只有用户源(`userPluginIndexes`);内置源只允许是随安装包分发的离线示例索引。每源独立记录"最后刷新时间/错误/是否过期";7 天未刷新时后台刷新,失败保留旧索引并标 `stale`。依据:`PluginMarketService.cs:43-69,107-120`(ClassIsland 的刷新语义)+ 本应用默认离线策略【推断】。
4. 下载必须**同时校验 MD5 与 SHA-256**:MD5 用于兼容 ClassIsland 生态,SHA-256 用于本实现的安全底线;任一不符即删除文件并报错。不得"只校验 MD5"。
5. 下载完成后必须走与安装包相同的原子流程(`.part` → 校验 → rename),不得把半截 zip 交给解压器。
6. 插件更新必须遵守「上课不打断」:`IsPluginsAutoUpdateEnabled=true` 时,只在**非上课时段**且设置窗口不可见时才后台下载;安装必须等用户确认或下次启动。依据:ClassIsland 默认值(`Settings.cs:1858-1859`)+ 本应用"零打扰"原则。
7. **默认离线(强制)**:安装包只能内置一份离线示例索引(`resources/plugin-index.example.json`,0–2 条示例,默认不可安装或只指向本地文件)。用户必须**手动添加索引 URL 并二次确认**后才允许联网;确认弹窗必须写明"将从该地址下载并执行第三方代码,请确认来源可信"。网络失败、索引 404、源不可达均不得影响本机已安装插件与本地索引。主题市场遵循同一默认策略(详见 9 部分)。
### 12.4 跨进程通信(IPC)设计(必须完整实现)
**通道命名与组织**:
1. 通道名必须为 `classisland:<domain>:<action>` 的 kebab 形式,全部集中在 `src/main/channels.ts`(**零 import 叶子模块**)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/channels.ts:16-28`。
2. 通道分三类,不得混用:
- `invoke/handle`:请求-响应(读写配置、查询状态、执行命令);
- `send/on`:单向事件(岛事件、心跳、交互上报);
- `webContents.send`:主进程→渲染进程推送(状态、外观、进度)。
3. 必须实现的通道清单(最小集,新增通道必须同表登记):
| 通道 | 方向 | 用途 |
|---|---|---|
| `classisland:config:get` / `:save` / `:reset` | 双向 | 读/局部写/重置配置;`:save` 必须剔除更新安装字段 |
| `classisland:app:info` | 双向 | 版本、Electron/Chrome/Node 版本、平台、userData、配置路径 |
| `classisland:app:get-autolaunch` / `:set-autolaunch` | 双向 | 开机自启(以系统登录项为准) |
| `classisland:app:open-external` / `:open-log-dir` / `:open-devtools` | 双向 | 受控系统能力 |
| `classisland:app:window` | 双向 | 显示/隐藏/最小化/聚焦设置窗口 |
| `classisland:profile:list` / `:load` / `:save` / `:create` / `:delete` / `:import` / `:export` | 双向 | 档案增删改查与导入导出 |
| `classisland:schedule:get` / `:save` | 双向 | 课表/时间表读写 |
| `classisland:notification:list` / `:clear` / `:test` | 双向 | 提醒列表与测试提醒 |
| `classisland:automation:list` / `:save` / `:run` | 双向 | 自动化配置与手动触发 |
| `classisland:plugins:list` / `:enable` / `:disable` / `:uninstall` / `:install-package` / `:market-refresh` | 双向 | 插件管理 |
| `classisland:update:check` / `:download` / `:install` / `:progress` | 双向 | 更新 |
| `classisland:backup:list` / `:create` / `:restore` | 双向 | 备份 |
| `island:push` / `island:remove-notification` / `island:class-state` / `island:get-state` | 双向 | 主窗口→岛 |
| `island:set-appearance` / `island:get-appearance` | 双向 | 外观热更新 |
| `island:action` / `island:set-interactive` / `island:set-hit-rect` / `island:set-touch-mode` / `island:alive` / `island:report-size` / `island:viewport` | 岛→主进程 | 用户操作、命中、触摸、心跳、尺寸上报 |
| `island:state` / `island:appearance` / `island:visibility` / `island:schedules` / `island:time-points` / `island:layout` / `island:editing` | 主进程→岛 | 状态与数据推送 |
| `island:mark-read` / `island:mark-all-read` / `island:open-editor` | 主进程→应用窗口 | 岛侧操作回流到应用窗口/编辑器 |
依据:`repos/ClassHelper-master/packages/desktop-client/src/main/channels.ts:16-28`、`src/preload/island.ts:64-121`、`src/preload/index.ts:25-99`(通道命名与成对关系)。
**Payload 与错误约定**:
4. 所有跨桥接数据必须是结构化克隆可序列化值:无函数、无 Symbol、无 Proxy、无 `Date` 之外的自定义类;`Error` 必须转成 `{code, message, detail?}` 普通对象。依据:`src/preload/index.ts:17-24` 的注释。
5. 单个 IPC 消息必须 ≤ 8MB;大文件(备份、导出档案、主题包)一律"传路径不传内容",由主进程读文件。
6. 所有 `invoke` 处理必须:校验 sender 必须是本应用的窗口(比对 `event.senderFrame.url` 白名单)→ 校验参数(类型、范围、长度)→ 执行业务 → 返回 `{ok:true, data}` 或 `{ok:false, error}`。**不得让异常穿透 IPC 抛给渲染层**(生产构建的堆栈会泄漏内部路径)。
7. 每次 `invoke` 在渲染层必须包一层 10 秒超时;超时必须提示"操作超时,请重试",不得让按钮永久转圈。
8. 高频事件必须节流:外观滑块 ≤ 20 次/秒(50ms)、岛状态推送 100ms 合并(尾部保留最后一次)、心跳 1 次/秒。依据:`island.ts:216-224`、`auto-update.ts:216-219` 的节流写法。
9. 事件订阅必须可取消:preload 上的 `onXxx(handler)` 必须返回 `() => void` 移除监听;组件卸载时调用。依据:`src/preload/index.ts:32-36,56-60`。
**岛窗口专用通信纪律**:
10. 岛渲染进程只允许两类操作:订阅主进程推来的状态/外观/课表,以及上报用户操作与命中信息。不得让岛直接读写配置。依据:`src/preload/island.ts:10-13` 的注释。
11. 命中控制必须"只上报 true,关闭由主进程按真实光标位置决定":双向下发会让窗口在"接收/穿透"之间抖动,表现为"点了没反应"。依据:`src/types/desktop.d.ts:221-228`。
12. 岛必须每秒发一次心跳(`alive()`);主进程超过 3 秒未收到心跳即判定"幽灵窗口",隐藏并重建。依据:`src/main/island.ts:425-430,541-599`(`lastAliveAt`、`checkRendererAlive` 思路)。
13. 触摸屏必须上报 `setTouchMode(navigator.maxTouchPoints > 0)`;触摸模式下窗口必须贴合岛体(岛体+10px 阴影留白)并始终接收输入。依据:`src/main/island.ts:245-316`。
14. 主进程必须对岛窗口做导航白名单与 `window-open(e) = deny`;岛是置顶窗口,被导航走等于把 `window.island` 桥交给陌生页面。依据:`src/main/island.ts:32-50,516-522`。
**可选本地只读 HTTP API(外部程序集成,默认关闭)**:
15. 本实现**不恢复** ClassIsland 的命名管道 IPC;如需让外部程序(脚本、班级大屏看板、第三方展示页)读取当前课程,只能启用"本地只读 HTTP API",且必须满足以下全部约束:
- 只监听 `127.0.0.1`(不得监听 `0.0.0.0`、不得绑定局域网地址);
- 端口**随机分配**(`listen(0)` 后读取实际端口),端口与开关写入 `config.localApi`,默认 `enabled=false`;
- 开启时必须生成**一次性 token**(随机 32 字节,base64url),请求必须带 `Authorization: Bearer <token>`;token 在设置页可见/可重置,关闭即失效;
- **只提供读接口**:`GET /v1/status`(当前时间点/上下课状态/剩余时间)、`GET /v1/schedule/today`(当天课表)、`GET /v1/profile/summary`(档案名/科目表摘要);**不得提供任何写操作**(不得改课表、不得发提醒、不得触发自动化、不得改配置);
- 响应必须含 CORS 白名单(默认不放开跨域;放开需用户在设置页显式勾选并提示风险);
- 每次启用/停用必须写审计日志;非法 token 返回 401 且限流(≥10 次/分钟即临时封禁)。
依据:ClassIsland 的 `IPublicLessonsService`/`IPublicProfileService` 是"读服务为主"的公开契约(`repos/ClassIsland/ClassIsland.Shared.IPC/Abstractions/Services/IPublic*.cs`),本实现以只读 HTTP 等价替代,不引入写能力。**【推断】**
### 12.5 单实例与 URI 协议(必须实现)
1. 单实例:`app.requestSingleInstanceLock()`;未拿到锁必须 `app.quit()` 并立即返回;拿到锁必须监听 `second-instance` → 显示/聚焦主窗口(若被最小化先 `restore`)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/index.ts:176-182,279-285`。
2. `second-instance` 必须解析新实例的 `argv`:若带 `classisland://` URI,转交现有实例导航;若带 `--hidden` 只做唤醒不抢焦点。参考 `repos/ClassIsland/ClassIsland/Program.cs:88-110,141-159` 的"第二次启动把 URI 通过 IPC 发给已运行实例后退出"语义。
3. 必须注册 `classisland://` 协议:Windows 用 `app.setAsDefaultProtocolClient('classisland')` 并在 NSIS 安装器中登记;macOS 监听 `open-url`;Linux 通过 `.desktop` 的 `MimeType=x-scheme-handler/classisland`。
4. URI 语法固定:`classisland://<host>/<path>?<query>`;允许的 host 与行为:
- `app/settings/<page>` → 打开设置页(例:`classisland://app/settings/update` 对齐 ClassIsland `UpdateService.cs:326`);
- `app/notification` → 打开提醒历史;
- `lesson/current` → 显示当前课程详情;
- `profile/import?path=<urlencoded>` → 导入档案(必须二次确认);
- `action/<id>` → 触发自动化行动(仅当该行动声明 `uriTrigger=true` 且用户在设置中允许)。
未知 host/path 必须忽略并提示「无法识别的链接」,不得静默执行。URI 长度上限 2048,必须 URL 解码后再解析,禁止 `..` 路径穿越。
5. 单实例锁必须与"冒烟/隔离配置"兼容:验收时有独立的 `userData`,否则会被真实实例的锁挡住而"静默退出(退出码 0、无输出)"。依据:`src/main/index.ts:29-32` 注释。
### 12.6 自动更新(必须按此流程实现)
**来源与通道**:
1. 更新通道至少三档:`stable`(默认)、`beta`、`dev`;通道描述含 `id`、`name`、`description`、`rootUrl`。**默认只接受 `https://`**;镜像/自定义源必须由用户显式开启并二次确认"非官方来源风险",默认不启用。清单必须校验 SHA-256;清单签名可选,一旦提供则必须是 Ed25519(带 `keyId`),客户端内置公钥表(支持多 keyId,便于轮换),验签失败必须拒绝整个更新;未签名清单必须在设置页显著标注"未签名发布"并写审计日志。依据:`repos/ClassIsland/ClassIsland/Services/AppUpdating/UpdateService.cs:660-669`(`DetachedSignatureProcessor` 的验签语义)。
2. 检查更新必须支持:启动后延迟 30 秒检查一次、之后每 6 小时一次、设置页手动检查、`classisland://app/settings/update` 深链进入。检查间隔与 `LastCheckUpdateTime` 必须持久化,避免频繁联网。依据:`Settings.cs:75-78`。
3. 更新状态必须走固定枚举:`UpToDate`(已是最新)、`UpdateAvailable`(发现新版)、`UpdateDownloaded`(已下载)、`UpdateDeployed`(已部署待重启);工作状态:`Idle`、`Checking`、`Downloading`、`Extracting`。依据:`repos/ClassIsland/ClassIsland.Shared/Enums/UpdateStatus.cs`、`UpdateWorkingStatus.cs`。
**清单与下载**:
4. 更新清单必须包含:`version`、`releaseNotes`、`pubDate`、`minOsVersion`、`files[{path,size,sha256,url}]`、`installer`(整包)或 `components`(分组件增量);清单可选附带 `signature{alg:"ed25519", keyId, sig}`,只要附带就必须校验通过。**不得**只给一个"最新版本号"就让客户端猜下载地址。
5. 下载必须:先查磁盘空间(≥ 3 倍包大小)→ 写 `.part` → 流式写盘(不得整包读进内存)→ 校验长度 → 校验 SHA-256 → `rename` 为正式文件。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:342-394`。
6. 下载必须支持断点续传与超时(单包 10 分钟);网络失败必须按 1/2/4/8 分钟退避重试 3 次,仍失败则置 `failed` 并展示错误码,不得无限重试。依据:`auto-update.ts:124-125,407-414`。
7. 增量更新必须至少支持"按文件 SHA-256 跳过未变化文件"的差分(对齐 `UpdateService.cs:447-485` 的 `AllowDiffUpdate` 语义);差分失败必须自动回退到整包下载,不得让用户停在半更新状态。
**安装时机与安全**:
8. 安装策略三档,必须严格实现:
- `immediate`:下载校验完成后立即安装(仍须满足"不在上课"除非用户显式确认);
- `after-school`:到达 `updateNotBefore`(放学时间)后且不在上课才安装;
- `idle`(默认):不在上课、且主窗口/设置窗口隐藏满 5 分钟(`CLIENT_UPDATE_IDLE_MS`)才安装。依据:`packages/shared/src/constants.ts:677`、`auto-update.ts:474-506`。
9. 关闭自动更新(`autoUpdate=false`)时,**不得**安装任何更新,但仍可展示"有可用更新"的提示;不得静默强装。依据:`auto-update.ts:280-296,458`。
10. 安装必须交给独立助手,并采用**固定安装目录 + 回滚副本**(不做 side-by-side 多版本并存):主进程把安装包路径、SHA-256、版本、回滚目录写入 `update-plan.json` → 生成/校验助手 → 助手就绪回执 → 主进程退出 → 助手等待旧进程结束 → 先把当前安装目录的关键文件(`ClassIsland.exe`、`resources/app.asar` 等)复制到 `updates/rollback/<旧版本>/` → 替换/安装 → 启动新版并检查版本 → 写 `install-result.json`(成功/失败+错误)→ 失败用回滚副本恢复旧版并重新拉起。依据:`auto-update.ts:516-573`(就绪→commit→退出→结果文件)与 `repos/ClassIsland/ClassIsland/Models/AppUpdating/DeploymentLock.cs`(记录已存在文件与组件根目录以便回滚)。
11. 安装过程中必须避免"文件被占用":不得覆盖正在运行的 `exe`/`asar`;助手必须从 `updates/` 目录运行。依据:`auto-update.ts:75-88`(专用目录、只接受目录内 exe)。
12. 安装包路径必须做双重校验:`path.dirname(realpath(file)) === realpath(updatesDir)` 且扩展名 `.exe`;任何不符必须拒绝执行并清空待安装记录。依据:`auto-update.ts:477-490`。
13. 更新失败必须留下可诊断证据:`update.log`(含 HTTP 状态、哈希期望/实际、助手退出码),并在设置页"更新"页展示最近一次失败原因(可复制)。
14. 版本回退必须保留上一版安装包与 `updates/rollback/<旧版本>/` 回滚副本及"上次可用版本"标记;新版启动失败(连续 2 次崩溃)必须提示"回退到上一版本"并给出按钮。**【推断】**
15. 插件更新与主程序更新必须**分开**:插件更新不得要求重启整个应用(除非插件声明 `restartRequired`);主程序更新必须重启。两者进度不得互相覆盖同一个 `policy.json`。
### 12.7 崩溃自愈与看门狗(必须实现)
1. 主进程必须监听以下事件并分别处理(不得合并成一个"崩溃就重启"):
| 事件 | 处理 | 限流 |
|---|---|---|
| 设置窗口 `render-process-gone` | 静默销毁并重建窗口;保留配置与状态 | 10 分钟内最多 3 次,超限只记日志 |
| 设置窗口 `unresponsive` 超过 10 秒 | 重建窗口;期间 `responsive` 恢复则取消 | 同上 |
| 岛 `render-process-gone` | 隐藏死窗口 → 重建岛窗口 → 重推状态 | 同上 |
| 岛心跳超时(>3 秒) | 判定幽灵窗口 → 隐藏并重建 | 同上 |
| `child-process-gone` | 记录 type/reason/exitCode;插件宿主崩溃按插件粒度重启 | — |
| 主进程 `uncaughtException` | 记录完整堆栈;仅当"不在上课且所有窗口不可见"才自动重启 | 10 分钟内最多 3 次 |
| 内存超限 | 1.5GB(`MemoryLimitBytes=1500000000`)时触发一次自重启(带静默参数),并记录 `-psmk` 语义的"上次因 MLE 退出"标记 | 每次启动最多 1 次 |
依据:`repos/ClassHelper-master/packages/desktop-client/src/main/crash-guard.ts:20-25,49-75,103-184`;`repos/ClassIsland/ClassIsland/Services/MemoryWatchDogService.cs:16-70`(60 秒检查一次、1.5GB 阈值、用 `-q -m -psmk` 重启)。
2. 重建窗口前必须 `hide()` 再 `destroy()`,避免留下一帧残影或白屏。依据:`crash-guard.ts:139-145`。
3. 冒烟/自动化验收模式下**绝对禁止** `app.relaunch()`(会把验证父进程搞乱);这条必须写进代码分支并以环境变量控制。依据:`crash-guard.ts:153-159`。
4. 岛窗口自愈必须同时处理"渲染进程没了"和"窗口还在但点不动"两种形态;后者只能靠心跳发现。依据:`island.ts:583-599`。
5. 所有自愈操作必须写日志:`reason`、`exitCode`、本窗口期第几次、是否达到限流上限。日志是教室现场排障的唯一证据。
### 12.8 验收标准(第 12 部分)
1. 插件安装/启用/禁用/卸载全流程可用:安装后重启生效、`.disabled` 标记正确、卸载后目录被删除;同一插件重复安装不丢用户配置。
2. 依赖排序正确:A 依赖 B 时先加载 B;必选依赖缺失时 A 状态为 `Error` 且错误信息可在设置页看到;循环依赖给出明确错误。
3. 插件崩溃不会导致主进程退出:杀死插件宿主进程后,主进程存活、岛继续刷新、设置页显示"插件已停止"。
4. 未声明权限的插件调用受控 API 时必须被拒绝并有日志,且不会抛到渲染进程。
5. 插件市场能读取索引、切换镜像、下载并校验 MD5+SHA-256;校验失败时文件被删除并报错。
6. 通道白名单:渲染进程无法调用未登记通道;`window.desktop` 中不存在 `ipcRenderer`/`fs`/`process`。
7. 任意跨桥接传参若含 Vue 响应式对象,必须抛出可读错误(`An object could not be cloned.`)而不是静默失效;所有调用点已展开为纯数据。
8. 第二次启动应用不产生第二个实例,而是唤起已有窗口;`classisland://app/settings/update` 能在已运行实例中打开更新页。
9. 更新全流程:检查 → 下载 → 校验 → 等待空闲 → 助手安装 → 新版启动写回执;`autoUpdate=false` 时绝不安装;上课时段绝不安装。
10. 更新助手失败时能回滚并写 `install-result.json`,设置页能展示失败原因。
11. 主窗口渲染进程崩溃后 10 分钟内最多重建 3 次,之后停止并可人工介入;日志含原因与次数。
12. 内存看门狗在超过 1.5GB 时自重启,且重启后保留配置、跳过一次引导、记录 MLE 标记。
13. 岛心跳超时能触发重建;重建后状态、课表、外观全部恢复(不出现空白岛)。
14. 所有新增 IPC 通道都登记在 `channels.ts` 与 12.4 表中,且类型定义在 `packages/shared/src/ipc.ts` 中单一来源。
15. 可选本地 HTTP API 默认关闭;开启后只监听 `127.0.0.1`、端口随机、请求必须带一次性 token、只存在 `GET /v1/status`、`GET /v1/schedule/today`、`GET /v1/profile/summary` 三个只读接口,任何写请求必须返回 405;关闭后端口立即不可达。
16. 插件市场默认只有离线示例索引;添加外部索引 URL 必须二次确认后才联网;断网/源不可达时本机插件与本地索引不受影响。
17. 插件设置页只能渲染声明式 schema;插件若尝试注入 HTML/iframe 或直接操作 DOM,必须被拒绝并记 error。
18. 插件清单 camelCase 写入、PascalCase 旧清单可正常加载(同一插件两种清单导入后行为一致)。
19. 更新默认只接受 `https://`;未签名清单在设置页显示"未签名发布"标记;签名清单的 `keyId` 未在公钥表内时拒绝更新。
20. 更新安装使用固定安装目录,替换前生成 `updates/rollback/<旧版本>/` 回滚副本;安装失败能回滚并写 `install-result.json`;磁盘上不出现 side-by-side 并存的多个 `app-<版本>` 目录。
+673
View File
@@ -0,0 +1,673 @@
## 13. 数据模型、存储、备份与迁移
### 13.1 存储总则(必须遵守)
1. 本应用是**纯本地单机应用**,所有设置、档案、课表、组件、自动化、提醒历史、主题、插件配置必须存在本机,**不得**依赖任何服务器;断网、无账号、首次启动都必须完整可用。
2. 存储根目录必须是 `app.getPath('userData')`(Windows 为 `%APPDATA%\ClassIsland`,macOS 为 `~/Library/Application Support/ClassIsland`,Linux 为 `~/.config/ClassIsland`)。**不得**把用户数据写进安装目录(安装目录可能只读、且升级会被整体替换)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/config.ts:165-167`(`path.join(app.getPath('userData'), 'config.json')`)与 `repos/ClassIsland/ClassIsland.Core/CommonDirectories.cs:16-28`(ClassIsland 的 `AppRootFolderPath`/`Config`/`Logs` 划分)。
3. 所有持久化文件必须是 **UTF-8 无 BOM**、LF 换行、两空格缩进的 JSON;字段名大小写必须按第 13.3/13.4 节的约定(ClassIsland 兼容文件用 PascalCase,本应用运行期配置用 camelCase)。读入时必须**同时容忍**紧凑格式(无缩进、无换行)与带注释之外的一切合法 JSON;**不得**写注释(ClassIsland 的 `examples/example.json` 带注释只是示例,真实加载器不支持注释,见 `repos/ClassIsland/examples/example.json:1-2`)。
4. 所有写入必须**原子化**:写 `<file>.tmp` → `fsync` → `rename` 覆盖目标;禁止直接 `writeFile` 覆盖正在被读的文件。依据:`repos/ClassIsland/ClassIsland.Shared/Helpers/ConfigureFileHelper.cs:182-188`(`FileStream.Flush(true)` 后关闭)。
5. 每次成功读取 JSON 后必须把原文件复制为 `<file>.bak`;读取失败时自动回退加载 `.bak`,成功后把 `.bak` 复制回目标(对齐 `ConfigureFileHelper.LoadConfigUnWrapped` 的语义,见 `ConfigureFileHelper.cs:63-96`)。若 `.bak` 也失败,必须使用**全默认值**并把错误加入"配置错误列表"(`ConfigError{ Path, Code, IsFallback, Exception }`,见 `repos/ClassIsland/ClassIsland.Shared/Models/ConfigError.cs`),在设置页"诊断/存储"中展示。
6. 所有配置对象必须"**缺失字段用默认值补齐、未知字段忽略、越界值夹紧**"。禁止因为新增字段就让老配置文件加载失败。依据:`repos/ClassIsland/ClassIsland/Services/SettingsService.cs:63-118`(加载失败只记日志并继续)与 `config.ts:198-233`(逐字段归一化)。
7. 所有写入必须经过**单一写入入口**(`storage.ts` 的 `saveXxx()`),禁止业务代码直接 `fs.writeFile`;该入口负责合并写、备份、事件通知。依据:`config.ts:235-346`。
### 13.2 目录与文件清单(必须逐项命名一致)
```text
<userData>/ # app.getPath('userData')
├─ config.json # 运行期设置(camelCase;对应 ClassIsland Settings.json)
├─ Settings.json # ClassIsland 格式兼容文件(PascalCase;仅只读导入/导出,运行期不读写)
├─ data/
│ ├─ profiles/
│ │ ├─ Default.json # Profile 档案(PascalCase,与 ClassIsland 同构)
│ │ └─ <档案名>.json
│ ├─ component-layouts/
│ │ └─ Default.json # ComponentProfile(PascalCase)
│ ├─ automations/
│ │ └─ Default.json # Workflow[] 数组(PascalCase)
│ ├─ themes/
│ │ ├─ <主题Id>/ # 主题包解压目录(manifest、样式、资源)
│ │ └─ EnabledThemes.json # string[],启用中的主题 Id
│ ├─ plugins/ # 插件解压目录:<插件Id>/
│ ├─ plugin-data/ # 插件私有存储:<插件Id>/storage.json
│ ├─ notification-history.json # 通知历史(用于提醒历史视图)
│ └─ generated/
│ └─ default-subjects.json # 首次启动写入的默认科目表
├─ backups/ # 自动/手动备份 zip
├─ logs/
│ └─ main.log # 超过 1MB 轮转为 main.log.1
├─ cache/
│ ├─ plugin-index/ # 插件市场索引缓存
│ ├─ theme-packages/ # 主题包下载缓存 <id>.zip
│ └─ update/ # 更新清单/元数据缓存
└─ updates/ # 待安装更新包、policy.json、install-result.json
```
依据:`repos/ClassIsland/ClassIsland/Services/PluginService.cs:35-44`(插件三目录)、`XamlThemeService.cs:47-49`(主题两目录 + `EnabledThemes.json`)、`AutomationService.cs:27-32`(自动化目录 + `<CurrentAutomationConfig>.json`)、`ComponentsService.cs:28-58`(`ComponentLayouts/<CurrentComponentConfig>.json`)、`ProfileService.cs:48-58`(`Profiles/` 与 `<SelectedProfile>`)、`FileFolderService.cs:23-49`(需要预创建的目录清单)、`auto-update.ts:131-133`(`updates/`)、`logger.ts:32-39`(`logs/main.log`)、`config.ts:165-167`(`config.json`)。
### 13.3 `config.json` 完整 schema(字段级,含默认值)
`config.json` 是 ClassIsland `Settings.json` 的超集;下表的"ClassIsland 对应字段"必须保留语义,默认值必须与源码一致。**任何字段缺失都必须落到默认值,任何越界值都必须夹紧到合法区间。**
**`Settings.json` 兼容层(强制)**:运行期只读写 `config.json`;`Settings.json` 只作为「ClassIsland 格式导入/导出兼容层」存在——导入时**只读**解析(`PascalCase` → 本实现字段映射),导出时生成一份 ClassIsland 可识别的 `Settings.json`(`PascalCase`)。**禁止双向双写**(不得同时写 `config.json` 与 `Settings.json`,否则两份文件必然会分叉)。导入映射必须保留历史拼写,尤其是 `IsIslandSeperated`(ClassIsland 源码就是少一个 `a`,`repos/ClassIsland/ClassIsland/Models/Settings.cs:1852`)——本实现内部字段可写作 `isIslandSeparated`,但导入/导出映射表必须双向接受原拼写。**【推断】**
#### 13.3.1 外观与主界面
| 字段 | 类型 | 默认值 | 合法区间/枚举 | ClassIsland 依据 |
|---|---|---|---|---|
| `theme` | number | `2` | `0` 亮 / `1` 暗 / `2` 跟随系统 | `Settings.cs:32` |
| `primaryColor` | string | `#00BFFF` | `#RRGGBBAA` | `Settings.cs:35`(DeepSkyBlue) |
| `secondaryColor` | string | `#7FFFD4` | 同上 | `Settings.cs:36`(Aquamarine) |
| `isCustomBackgroundColorEnabled` | boolean | `false` | — | `Settings.cs:33` |
| `backgroundColor` | string | `#000000FF` | 同上 | `Settings.cs:34`(Colors.Black) |
| `isCustomForegroundColorEnabled` | boolean | `false` | — | `Settings.cs:202` |
| `customForegroundColor` | string | `#1E90FF` | 同上 | `Settings.cs:203`(DodgerBlue) |
| `opacity` | number | `0.5` | `0.1`–`1` | `Settings.cs:64` |
| `scale` | number | `1.0` | `0.75`–`2.5` | `Settings.cs:86` |
| `radiusX` / `radiusY` | number | `8` / `8` | `0`–`20` | `Settings.cs:183-184` |
| `mainWindowFont` | string | 跟随系统默认字体 | 合法字体族名 | `Settings.cs:95` |
| `mainWindowFontWeight` | number | `500`(Medium) | `100`–`900` | `Settings.cs:101` |
| `mainWindowSecondaryFontSize` | number | `14` | `8`–`96` | `Settings.cs:197` |
| `mainWindowBodyFontSize` | number | `16` | 同上 | `Settings.cs:198` |
| `mainWindowEmphasizedFontSize` | number | `18` | 同上 | `Settings.cs:199` |
| `mainWindowLargeFontSize` | number | `20` | 同上 | `Settings.cs:200` |
| `mainWindowLineVerticalMargin` | number | `5` | `0`–`150` | `Settings.cs:219` |
| `isIslandSeperated` | boolean | `false` | — | `Settings.cs:1852`(拼写保留) |
| `reduceProgressAccuracy` | boolean | `true` | — | `Settings.cs:1853` |
| `animationLevel` | number | `1` | `0` 关闭 / `1` 标准 / `2` 全部 | `Settings.cs:1851` |
| `isScreenRecordingModeEnabled` | boolean | `false` | — | `Settings.cs:1861` |
| `isWindowCaptureBlockingEnabled` | boolean | `false` | — | `Settings.cs:1862` |
#### 13.3.2 窗口、停靠与隐藏
| 字段 | 类型 | 默认值 | 说明 | 依据 |
|---|---|---|---|---|
| `windowDockingLocation` | number | `1` | `0`–`5`:左上/中上/右上/左下/中下/右下 | `Settings.cs:48` |
| `windowDockingOffsetX/Y` | number | `0`/`0` | 停靠偏移(px) | `Settings.cs:49-50` |
| `windowDockingMonitorIndex` | number | `0` | 多显示器索引 | `Settings.cs:51` |
| `windowLayer` | number | `1` | 窗口层级(普通/置顶/桌面) | `Settings.cs:52` |
| `isMouseClickingEnabled` | boolean | `false` | 鼠标穿透(true = 点击穿透) | `Settings.cs:53` |
| `hideOnClass` | boolean | `false` | 上课自动隐藏 | `Settings.cs:45` |
| `hideOnMaxWindow` | boolean | `false` | 有最大化窗口时隐藏 | `Settings.cs:63` |
| `hideOnFullscreen` | boolean | `false` | 全屏时隐藏 | `Settings.cs:54` |
| `excludedFullscreenWindow` | string[] | `[]` | 全屏检测排除的进程名 | `Settings.cs:56-59` |
| `hideMode` | number | `0` | `0` 基本(上课/最大化/全屏);`1` 高级(规则集) | `Settings.cs:185` |
| `hideRules` | Ruleset | 空规则集 | 见 13.5.3 | `Settings.cs:186` |
| `useRawInput` | boolean | `false` | 原始输入(兼容某些输入法) | `Settings.cs:192` |
| `isMouseInFadingEnabled` | boolean | `true` | 鼠标进入时淡化 | `Settings.cs:193` |
| `isMouseInFadingReversed` | boolean | `false` | 反向淡化 | `Settings.cs:194` |
| `touchInFadingDurationMs` | number | `0` | 触摸淡化时长 | `Settings.cs:195` |
| `isCompatibleWindowTransparentEnabled` | boolean | `false` | 兼容透明(老显卡) | `Settings.cs:196` |
| `windowTopmostRecheckMode` | number | `0` | 置顶复查策略 | `Settings.cs:1860` |
| `showCurrentLessonOnlyOnClass` | boolean | `false` | — | `Settings.cs:209` |
| `isSwapMode` | boolean | `true` | 换课模式 | `Settings.cs:210` |
**坐标口径(强制)**:`windowDockingOffsetX/Y`、`scale`、`radiusX/Y`、所有字号与组件尺寸/间距字段一律以 **DIP(CSS px)** 存储与解释;窗口 bounds 也按 DIP 交给 Electron。**禁止**把 `devicePixelRatio` 乘进这些字段;只有截图/像素级视觉验收时才用 `devicePixelRatio` 把 DIP 换算成物理像素(与 04 部分同一口径)。
#### 13.3.3 课程与时间
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `selectedProfile` | string | `"Default.json"` | `Settings.cs:66` |
| `singleWeekStartTime` | string(ISO) | 首次启动时写入当天 00:00 | `Settings.cs:60`(与其相关的周次计算见 ProfileService) |
| `multiWeekRotationOffset` | number[5] | `[-1,-1,0,0,0]` | `Settings.cs:60` |
| `multiWeekRotationMaxCycle` | number | `4` | `Settings.cs:61` |
| `classPrepareNotifySeconds` | number | `60` | `Settings.cs:43` |
| `isClassPrepareNotificationEnabled` | boolean | `true` | `Settings.cs:47` |
| `isClassChangingNotificationEnabled` | boolean | `true` | `Settings.cs:46` |
| `isClassOffNotificationEnabled` | boolean | `true` | `Settings.cs:55` |
| `showDate` | boolean | `true` | `Settings.cs:44` |
| `showExtraInfoOnTimePoint` | boolean | `true` | `Settings.cs:103` |
| `extraInfoType` | number | `0` | `Settings.cs:104`(0 持续时间/1 已过/2 剩余/3 已过百分比/4 剩余时间/5 倒计时) |
| `isCountdownEnabled` | boolean | `true` | `Settings.cs:105` |
| `countdownSeconds` | number | `60` | `Settings.cs:106` |
| `extraInfo4ShowSecondsSeconds` | number | `300` | `Settings.cs:107` |
| `defaultOnClassTimePointMinutes` | number | `40` | `Settings.cs:108` |
| `defaultBreakingTimePointMinutes` | number | `10` | `Settings.cs:109` |
| `isNonExactCountdownEnabled` | boolean | `false` | `Settings.cs:221` |
#### 13.3.4 提醒、语音与时间同步
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `isNotificationEnabled` | boolean | `true` | `Settings.cs:91` |
| `notificationProvidersEnableStates` | map<string,bool> | `{}` | `Settings.cs:88` |
| `notificationProvidersPriority` | string[] | `[]` | `Settings.cs:89` |
| `notificationProvidersSettings` | map<string,any> | `{}` | `Settings.cs:90` |
| `notificationProvidersNotifySettings` | map<string,NotificationSettings> | `{}` | `Settings.cs:143` |
| `notificationChannelsNotifySettings` | map<string,NotificationSettings> | `{}` | `Settings.cs:1846` |
| `isNotificationEffectEnabled` | boolean | `true` | `Settings.cs:142` |
| `isNotificationTopmostEnabled` | boolean | `true` | `Settings.cs:149` |
| `notificationEffectRenderingScale` | number | `1.0` | `Settings.cs:150` |
| `isNotificationEffectRenderingScaleAutoSet` | boolean | `false` | `Settings.cs:151` |
| `isNotificationSoundEnabled` | boolean | `true` | `Settings.cs:144` |
| `notificationSoundPath` | string | `""` | `Settings.cs:145` |
| `notificationSoundVolume` | number | `1.0` | `Settings.cs:182` |
| `allowNotificationSpeech/Effect/Sound/Topmost` | boolean | `false`/`true`/`false`/`true` | `Settings.cs:153-156` |
| `notificationUseStandaloneEffectUiThread` | boolean | `true` | `Settings.cs:1837` |
| `isSpeechEnabled` | boolean | `true` | `Settings.cs:135` |
| `speechVolume` | number | `1.0` | `Settings.cs:136` |
| `speechSource` | number | `0` | `Settings.cs:137` |
| `selectedSpeechProvider` | string | `"classisland.speech.edgeTts"` | `Settings.cs:1847` |
| `edgeTtsVoiceName` | string | `"zh-CN-XiaoxiaoNeural"` | `Settings.cs:138` |
| `isSystemSpeechSystemExist` | boolean | `false`(运行时探测) | `Settings.cs:133` |
| `exactTimeServer` | string | `"ntp.aliyun.com"` | `Settings.cs:139` |
| `isExactTimeEnabled` | boolean | `true` | `Settings.cs:140` |
| `timeOffsetSeconds` | number | `0` | `Settings.cs:141` |
| `isTimeAutoAdjustEnabled` | boolean | `false` | `Settings.cs:146` |
| `timeAutoAdjustSeconds` | number | `0` | `Settings.cs:147` |
| `lastTimeAdjustDateTime` | string(ISO) | 当前时间 | `Settings.cs:148` |
#### 13.3.5 组件、自动化、主题
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `currentComponentConfig` | string | `"Default"` | `Settings.cs:159` |
| `isAutomationEnabled` | boolean | `false` | `Settings.cs:160` |
| `currentAutomationConfig` | string | `"Default"` | `Settings.cs:162` |
| `isAutomationWarningVisible` | boolean | `true` | `Settings.cs:161` |
| `selectedPlatte` | string | `#1E90FFFF` | `Settings.cs:80`(DodgerBlue) |
| `selectedPlatteIndex` | number | `0` | `Settings.cs:81` |
| `wallpaperColorPlatte` | string[] | 5 个 DodgerBlue | `Settings.cs:82` |
| `colorSource` | number | `2` | `Settings.cs:83`(0 无/1 壁纸/2 手动) |
| `wallpaperClassName` | string | `""` | `Settings.cs:84` |
| `targetLightValue` | number | `0.6` | `Settings.cs:85` |
| `isWallpaperAutoUpdateEnabled` | boolean | `false` | `Settings.cs:92` |
| `wallpaperAutoUpdateIntervalSeconds` | number | `60` | `Settings.cs:93` |
| `weatherIconId` | string | `"classisland.weatherIcons.lucide"` | `Settings.cs:1849` |
| `isRollingComponentWarningVisible` | boolean | `true` | `Settings.cs:1850` |
| `isThemeWarningVisible` | boolean | `true` | `Settings.cs:1848` |
| `isThemeSeparateInfoVisible` | boolean | `true` | `Settings.cs:1875` |
#### 13.3.6 天气
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `cityId` | string | `"weathercn:101010100"` | `Settings.cs:99` |
| `cityName` | string | `"北京 (北京, 中国)"` | `Settings.cs:100` |
| `lastWeatherInfo` | object | `{}` | `Settings.cs:98` |
| `weatherLatitude` / `weatherLongitude` | number | `0`/`0` | `Settings.cs:1838-1839` |
| `weatherLocationSource` | number | `0` | `Settings.cs:1840` |
| `autoRefreshWeatherLocation` | boolean | `false` | `Settings.cs:1841` |
| `noTLSWeatherRequests` | boolean | `false` | `Settings.cs:1842` |
| `excludedWeatherAlerts` | string[] | `[]` | `Settings.cs:158` |
| `selectedMiniInfoProvider` | string? | 内置天气提供者 Guid | `Settings.cs:97` |
| `miniInfoProviderSettings` | map<string,any> | `{}` | `Settings.cs:96` |
#### 13.3.7 更新、插件、备份、诊断
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `updateMode` | number | `3` | `Settings.cs:77` |
| `autoInstallUpdateNextStartup` | boolean | `true` | `Settings.cs:78` |
| `selectedUpdateChannelV2` | string | `"stable"` | `Settings.cs:217` |
| `selectedUpdateMirrorV2` | string | `"main"` | `Settings.cs:216` |
| `selectedUpdateChannelV3` | string(Guid) | `Guid.Empty` | `Settings.cs:1854` |
| `lastCheckUpdateTime` | string(ISO) | `DateTime.MinValue` | `Settings.cs:75` |
| `lastUpdateStatus` | number | `0`(UpToDate) | `Settings.cs:76` |
| `updateArtifactHash` | string | `""` | `Settings.cs:157` |
| `ignoredUpdateVersion` | string | `""` | `config.ts:76`(本应用字段,兼容旧行为) |
| `userPluginIndexes` | string[] | `[]` | `Settings.cs:167` |
| `additionalPluginIndexes` | map<string,string> | `{}` | `Settings.cs:168` |
| `pluginIndexes` | object[] | `[]` | `Settings.cs:169` |
| `pluginIndexSelectedMirrors` | map<string,string> | `{}` | `Settings.cs:166` |
| `officialSelectedMirror` | string | `"github"` | `Settings.cs:170` |
| `officialIndexMirrors` | map<string,string> | `{github: https://github.com, ghproxy: …, moeyy: …}` | `Settings.cs:171-176` |
| `ignoreSslForPluginMirrors` | boolean | `false` | `Settings.cs:177` |
| `isPluginMarketWarningVisible` | boolean | `true` | `Settings.cs:204` |
| `isPluginsAutoUpdateEnabled` | boolean | `true` | `Settings.cs:1858` |
| `isPluginsUpdateNotificationEnabled` | boolean | `true` | `Settings.cs:1859` |
| `autoDisableCorruptPlugins` | boolean | `true` | `Settings.cs:1844` |
| `corruptPluginsDisabledLastSession` | boolean | `false` | `Settings.cs:1845` |
| `lastRefreshPluginSourceTime` | string(ISO) | `MinValue` | `Settings.cs:180` |
| `isAutoBackupEnabled` | boolean | `true` | `Settings.cs:187` |
| `lastAutoBackupTime` | string(ISO) | 当前时间 | `Settings.cs:188` |
| `autoBackupLimit` | number | `16` | `Settings.cs:190` |
| `autoBackupIntervalDays` | number | `7` | `Settings.cs:191` |
| `backupFilesSize` | string | `"计算中..."` | `Settings.cs:189` |
| `firstLaunchTime` | string(ISO) | 当前时间 | `Settings.cs:127` |
| `diagnosticStartupCount` | number | `0` | `Settings.cs:128` |
| `diagnosticCrashCount` | number | `0` | `Settings.cs:129` |
| `diagnosticLastCrashTime` | string(ISO) | `MinValue` | `Settings.cs:130` |
| `diagnosticMemoryKillCount` | number | `0` | `Settings.cs:131` |
| `diagnosticLastMemoryKillTime` | string(ISO) | 当前时间 | `Settings.cs:132` |
| `developerMode` | boolean | `false` | `config.ts:79`(本应用字段,决定是否显示开发者页) |
| `careMode` | boolean | `false` | `config.ts:80`(关怀模式) |
| `isDebugEnabled` | boolean | `false` | `Settings.cs:65` |
| `isDebugOptionsEnabled` | boolean | `false` | `Settings.cs:79` |
| `isDebugConsoleEnabled` | boolean | `false` | `Settings.cs:118` |
| `debugAnimationScale` | number | `1.0` | `Settings.cs:110` |
| `debugTimeSpeed` | number | `1.0` | `Settings.cs:111` |
| `debugTimeOffsetSeconds` | number | `0` | `Settings.cs:112` |
| `isSplashEnabled` | boolean | `false` | `Settings.cs:115` |
| `splashCustomText` | string | `""` | `Settings.cs:116` |
| `showDetailedStatusOnSplash` | boolean | `false` | `Settings.cs:222` |
| `isWelcomeWindowShowed` | boolean | `false` | `Settings.cs:68` |
| `onboardingVersion` | number | `0` | `config.ts:41` |
| `isReportingEnabled` | boolean | `true` | `Settings.cs:69` |
| `isFallbackModeEnabled` | boolean | `true` | `Settings.cs:94` |
| `isCriticalSafeMode` | boolean | `false` | `Settings.cs:207` |
| `criticalSafeModeMethod` | number | `0` | `Settings.cs:1836` |
| `trustedProfileIds` | string(Guid)[] | `[]` | `Settings.cs:220` |
| `taskBarIconClickBehavior` | number | Windows `0`,其它 `4` | `Settings.cs:102` |
| `settingsPagesCachePolicy` | number | `0` | `Settings.cs:213` |
| `isRefreshingToastEnabled` | boolean | `true` | `Settings.cs:1865` |
| `refreshingToastThresholdDays` | number | `20` | `Settings.cs:1866` |
| `maxRefreshingToastCounts` | number | `5` | `Settings.cs:1873` |
**字段级兼容规则(必须实现)**:
1. 新增字段一律有默认值,缺失即用默认值(`SettingsService.LoadSettingsAsync` 缺失文件时直接返回 `new Settings()`,见 `SettingsService.cs:73-77`)。
2. 字段类型不匹配时用默认值覆盖并记 warn,不得整体抛错(对齐 `config.ts:198-233` 每个字段逐个 `typeof` 判断)。
3. 枚举越界值回落到默认值(如 `theme` 非 `0/1/2` → `2`)。
4. 语义变更或废弃的字段必须保留读取(忽略)一个版本,禁止删除后导致老配置迁移失败。**不得**删除 `isIslandSeperated` 这类历史拼写字段(`Settings.cs:1852`)。
5. `deviceKey` 类"机器身份"字段一旦生成必须永不变;如由外部传入不同值必须拒绝并记 warn(对齐 `config.ts:501-505`)。
### 13.4 档案(Profile)schema — 与 ClassIsland 完全同构
档案文件:`data/profiles/<SelectedProfile>`,默认 `Default.json`。**字段名必须保留 PascalCase**,以便直接导入/导出 ClassIsland 档案。
#### 13.4.1 Profile 根对象
| 字段 | 类型 | 默认值 | 说明 | 依据 |
|---|---|---|---|---|
| `Name` | string | `""` | 档案名称 | `Profile.cs:227-236` |
| `Id` | string(Guid) | 新建 Guid | 档案 ID(用于"信任档案"判断) | `Profile.cs:452-463` |
| `TimeLayouts` | map<Guid,TimeLayout> | `{}` | 时间表字典,键为 Guid | `Profile.cs:241-250` |
| `ClassPlans` | map<Guid,ClassPlan> | `{}` | 课表字典 | `Profile.cs:255-270` |
| `Subjects` | map<Guid,Subject> | 首次启动写入默认科目表 | 科目字典 | `Profile.cs:284-293` |
| `EditingSubjects` | Subject[] | `[JsonIgnore]` 不落盘 | 编辑期临时集合 | `Profile.cs:298-308` |
| `IsOverlayClassPlanEnabled` | boolean | `false` | 是否启用临时层课表 | `Profile.cs:313-322` |
| `OverlayClassPlanId` | string(Guid)? | `null` | 当前临时层课表 ID | `Profile.cs:327-337` |
| `TempClassPlanId` | string(Guid)? | `null` | 临时课表 ID | `Profile.cs:342-351` |
| `TempClassPlanSetupTime` | string(ISO) | 当前时间 | 临时课表设置时间 | `Profile.cs:356-365` |
| `ClassPlanGroups` | map<Guid,ClassPlanGroup> | 必含默认群 + 全局群 | 课表群 | `Profile.cs:370-379` |
| `SelectedClassPlanGroupId` | string(Guid) | 默认群 Guid `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 当前选中群 | `Profile.cs:384-393`、`ClassPlanGroup.cs:47-52` |
| `TempClassPlanGroupId` | string(Guid)? | `null` | 临时课表群 | `Profile.cs:398-407` |
| `TempClassPlanGroupExpireTime` | string(ISO) | 当前时间 | 临时群失效时间 | `Profile.cs:412-421` |
| `IsTempClassPlanGroupEnabled` | boolean | `false` | — | `Profile.cs:426-435` |
| `TempClassPlanGroupType` | number | `Inherit` | `Inherit`/`Override` 等(枚举) | `Profile.cs:440-449` |
| `OrderedSchedules` | map<DateTime,OrderedSchedule> | `{}` | 预定课表(日期→课表 ID) | `Profile.cs:468-477` |
**初始化规则**:新档案必须自动加入两个群:默认群(`ACAF4EF0-E261-4262-B941-34EA93CB4369`,名称"默认")与全局群(`Guid.Empty`,名称"全局课表群",`IsGlobal=true`)。依据:`Profile.cs:44-59`。
#### 13.4.2 TimeLayout(时间表)
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `Name` | string | `"新时间表"` | `TimeLayout.cs:17` |
| `Layouts` | TimeLayoutItem[] | `[]` | `TimeLayout.cs:254-267` |
| `IsOverlay` | boolean | `false` | `TimeLayout.cs:26-35` |
| `OverlaySourceId` | string(Guid)? | `null` | `TimeLayout.cs:40-49` |
| `IsActivated` / `IsActivatedManually` | boolean | `[JsonIgnore]` 不落盘 | `TimeLayout.cs:272-297` |
#### 13.4.3 TimeLayoutItem(时间点)
| 字段 | 类型 | 默认值 | 说明 | 依据 |
|---|---|---|---|---|
| `StartTime` | TimeSpan 字符串 | `"00:00:00"` | 开始时间(当天内的偏移) | `TimeLayoutItem.cs:62-77` |
| `EndTime` | TimeSpan 字符串 | `"00:00:00"` | 结束时间;`TimeType` 为 2/3 时强制等于 `StartTime` | `TimeLayoutItem.cs:82-93`、`:70-73` |
| `TimeType` | number | `0` | `0` 上课 / `1` 课间 / `2` 分割线 / `3` 行动 | `TimeLayoutItem.cs:128-138` |
| `IsHideDefault` | boolean | `false` | 默认隐藏(仅上课类型有效) | `TimeLayoutItem.cs:143-153` |
| `DefaultClassId` | string(Guid) | `Guid.Empty`(空字符串也解析为 Empty) | 默认科目 | `TimeLayoutItem.cs:158-169`、`GuidEmptyFallbackConverter` |
| `BreakName` | string | `""` | 自定义课间名;展示用 `BreakNameText`(空则"课间休息") | `TimeLayoutItem.cs:180-191,174-175` |
| `ActionSet` | ActionSet? | `null` | 仅 `TimeType=3` 时有意义 | `TimeLayoutItem.cs:196-205` |
| `StartSecond` / `EndSecond` | string | `[Obsolete]` | 旧版字段,读取时忽略、写出时不得生成 | `TimeLayoutItem.cs:26-57` |
| `attachedObjects` | map<Guid, object> | `{}`(导入时接受 `AttachedObjects` 原拼写) | **一等公民的附加设置字典**(课级提醒/天气/课程显示等),见 13.4.8 | `repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:10-15` |
**校验规则**:`StartTime > EndTime` 必须拒绝(对齐 `TimeLayoutItem.EnsureTime`,`TimeLayoutItem.cs:95-101`);同一时间表内时间点必须按 `StartTime` 升序,重叠允许但必须保留排序;分割线/行动点不占课程位(见 13.4.4 的"有效时间点")。
#### 13.4.4 ClassPlan(课表)
| 字段 | 类型 | 默认值 | 说明 | 依据 |
|---|---|---|---|---|
| `Name` | string | `"新课表"` | — | `ClassPlan.cs:440-449` |
| `TimeLayoutId` | string(Guid) | `Guid.Empty` | 引用时间表 | `ClassPlan.cs:396-407` |
| `TimeRule` | TimeRule | `{WeekDay:0, WeekCountDiv:0, WeekCountDivTotal:2}` | 周次规则 | `ClassPlan.cs:412-421`、`TimeRule.cs:13-15` |
| `Classes` | ClassInfo[] | `[]` | 课程列表,长度 = 时间表中上课类型点数 | `ClassPlan.cs:426-435,451-491` |
| `IsActivated` | boolean | `[JsonIgnore]` 不落盘 | 当前激活 | `ClassPlan.cs:527-537` |
| `IsOverlay` | boolean | `false` | 临时层课表 | `ClassPlan.cs:542-551` |
| `OverlaySourceId` | string(Guid)? | `null` | 临时层源课表 | `ClassPlan.cs:556-565` |
| `OverlaySetupTime` | string(ISO) | 当前时间 | — | `ClassPlan.cs:570-579` |
| `IsEnabled` | boolean | `true` | 是否默认启用 | `ClassPlan.cs:584-593` |
| `AssociatedGroup` | string(Guid) | 默认群 Guid | 所属课表群 | `ClassPlan.cs:598-607` |
**有效时间点规则**(必须实现 `ValidTimeLayoutItems`):只取 `TimeType ∈ {0,1,2}`;从前后两个方向扫描 `ClassInfo.IsEnabled`,把被禁用课程"夹住"的时间点全部隐藏;`TimeType=3` 的行动点永不显示。依据:`ClassPlan.cs:54-98`。
#### 13.4.5 ClassInfo(一节课)
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `SubjectId` | string(Guid) | `Guid.Empty`(空串也解析为 Empty) | `ClassInfo.cs:70-80` |
| `IsChangedClass` | boolean | `false` | `ClassInfo.cs:85-94` |
| `IsEnabled` | boolean | `true` | `ClassInfo.cs:99-108` |
| `Index` / `CurrentTimeLayout` / `IsEmpty` | — | `[JsonIgnore]` 不落盘 | `ClassInfo.cs:21-64,110-121` |
#### 13.4.6 Subject(科目)
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `Name` | string | `""` | `Subject.cs:19-30` |
| `Initial` | string | `""`(`Name` 变化且 `Initial` 为空时自动取首字) | `Subject.cs:35-44` |
| `TeacherName` | string | `""` | `Subject.cs:49-58` |
| `IsOutDoor` | boolean | `false` | `Subject.cs:63-72` |
内置常量必须实现:`Fallback = {Name:"???", Initial:"?"}`、`Empty = {Name:"", Initial:""}`、`Breaking = {Name:"课间休息", Initial:"休"}`。依据:`Subject.cs:79-101`。教师姓氏提取必须支持复姓表(万俟/司马/上官/欧阳……)与英文姓名取最后一个单词。依据:`Subject.cs:14,107-140`。
#### 13.4.7 课表群与预定
- `ClassPlanGroup { Name: "新课表群", IsGlobal: false }`;默认群 Guid `ACAF4EF0-E261-4262-B941-34EA93CB4369`;全局群 Guid `Guid.Empty`;解散群时课表移到默认群,删除群时群内课表一并删除。依据:`ClassPlanGroup.cs`、`Profile.cs:104-137`。
- `OrderedSchedule { ClassPlanId: Guid }`;`OrderedSchedules` 的键是**日期**(本地日期,序列化为 `"2026-11-12T00:00:00"`)。
- 临时层规则:每天启动/跨天时必须 `CleanExpiredTempClassPlan()`:删除今天以前的 `OrderedSchedules`;删除未被任何预定引用的临时课表;删除未被引用的临时时间表。依据:`ProfileService.cs:295-328`。
#### 13.4.8 附加设置(`attachedObjects`)——一等公民
**定位**:附加设置**不是** 1.7 遗留字段,而是 ClassIsland 2.x 仍在使用的核心机制:可附着对象(`AttachableSettingsObject`)自带 `Dictionary<Guid, object?> AttachedObjects`,键是"附加设置提供者的 Guid",值是设置对象;界面通过 `AttachedSettingsControlPresenter` 读写它(`repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:10-15`、`repos/ClassIsland/ClassIsland.Core/Controls/AttachedSettingsControlPresenter.axaml.cs:241-253`)。本实现必须把它升级为**一等公民字段**,供 06/07 部分的"课级提醒/课级附加行动/天气提醒"直接引用。
**承载对象**(均必须有 `attachedObjects` 字段):
| 对象 | 目标枚举 | 说明 |
|---|---|---|
| `Profile`(档案级) | — | 本实现扩展的档案级附加设置;ClassIsland 2.x 的可附着基类未覆盖档案,档案级为本实现新增【推断】 |
| `ClassPlan`(课表级) | `ClassPlan` | 整张课表的附加设置 |
| `ClassInfo`(课级) | `Lesson` | 单节课的附加设置(最常用,例如这节课用自定义提醒文案) |
| `TimeLayout`(时间表级) | `TimeLayout` | 整张时间表的附加设置 |
| `TimeLayoutItem`(时间点级) | `TimePoint` | 单个时间点的附加设置 |
| `Subject`(科目级) | `Subject` | 科目的附加设置 |
字段定义:`attachedObjects: map<GuidString, object>`,默认 `{}`;序列化字段名 **`attachedObjects`(camelCase)**;导入 ClassIsland 档案时**接受原拼写 `AttachedObjects`**(PascalCase),导出/写出统一 camelCase。值必须是普通 JSON 对象(结构化克隆可序列化),不得存函数或类实例。依据:`AttachableSettingsObject.cs:12-15`(字典键为 Guid);`AttachedSettingsControlPresenter.axaml.cs:245-253`(目标对象 → `AttachedSettingsTargets` 的映射)。
**已知附加设置 Guid 与设置对象类型(必须内置映射)**:
| Guid | 名称 | 设置对象类型 | 允许附着目标 | 依据 |
|---|---|---|---|---|
| `58e5b69a-764a-472b-bcf7-003b6a8c7fdf` | 课程显示设置 | `LessonControlAttachedSettings` | ClassPlan / TimeLayout / Lesson / Subject / TimePoint | `LessonControlAttachedSettingsControl.axaml.cs:10-14`、`AttachedSettingsTargets.cs` |
| `08f0d9c3-c770-4093-a3d0-02f3d90c24bc` | 上课提醒设置 | `ClassNotificationAttachedSettings` | ClassPlan / TimeLayout / Lesson / Subject / TimePoint | `ClassNotificationAttachedSettingsControl.axaml.cs:11-15` |
| `8fbc3a26-6d20-44dd-b895-b9411e3ddc51` | 放学提醒设置 | `AfterSchoolNotificationAttachedSettings` | ClassPlan / TimeLayout | `AfterSchoolNotificationAttachedSettingsControl.axaml.cs:10-12` |
| `7625de96-38aa-4b71-b478-3f156dd9458d` | 天气提醒设置 | `WeatherNotificationAttachedSettings` | TimePoint | `WeatherNotificationAttachedSettingsControl.axaml.cs:10-12` |
**设置对象字段(默认值必须逐项一致)**:
- `LessonControlAttachedSettings`:`IsAttachSettingsEnabled=false`、`ShowExtraInfoOnTimePoint=true`、`ExtraInfoType=0`、`IsCountdownEnabled=true`、`CountdownSeconds=60`、`ExtraInfo4ShowSecondsSeconds=0`、`ScheduleSpacing=1`、`ShowCurrentLessonOnlyOnClass=false`、`IsNonExactCountdownEnabled=false`。依据:`repos/ClassIsland/ClassIsland.Core/Models/AttachedSettings/LessonControlAttachedSettings.cs:7-17`。
- `ClassNotificationAttachedSettings`:`IsClassOnNotificationEnabled=true`、`IsClassOnPreparingNotificationEnabled=true`、`IsClassOffNotificationEnabled=true`、`ClassPreparingDeltaTime=60`、`ClassOnPreparingText="准备上课,请回到座位并保持安静,做好上课准备。"`、`OutdoorClassOnPreparingText="下节课程为户外课程,请合理规划时间,做好上课准备。"`、`ClassOnPreparingMaskText="即将上课"`、`OutdoorClassOnPreparingMaskText="即将上课"`、`ClassOnMaskText="上课"`、`ClassOffMaskText="课间休息"`、`ClassOffOverlayText=""`、`IsAttachSettingsEnabled=false`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/ClassNotificationAttachedSettings.cs:7-24`。
- `AfterSchoolNotificationAttachedSettings`:`IsEnabled=true`、`NotificationMsg="今天的课程已结束,请同学们有序离开。"`、`IsAttachSettingsEnabled=false`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:7-14`。
- `WeatherNotificationAttachedSettings`:`AlertShowMode=Default`、`ForecastShowMode=Default`、`IsAttachSettingsEnabled=true`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:7-17`。
- 所有设置对象都带 `IsAttachSettingsEnabled`:**只有它为 true 时附加设置才生效**;为 false 时按空对象处理并允许回落到全局设置。
**读写与兼容规则(强制)**:
1. 读取时必须"按目标对象类型过滤 Guid":若某 Guid 允许的目标不包含当前对象类型,保留数据但**不得生效**(不能因为档案来自别处就崩溃)。
2. 未知 Guid 必须原样保留(不删除、不报错),以便插件卸载后重装仍能恢复设置;UI 显示为"未知附加设置(可删除)"。
3. 值里出现 `null` 时按"未设置"处理;导出的 JSON 必须省略值为 `null` 的键(对齐 `object?` 语义)。
4. 导入 ClassIsland 档案的 `AttachedObjects` 时:键 Guid 统一小写、值按上表类型反序列化;未知 Guid 的 `JsonElement` 原样保留;**不得**丢弃。
5. 导出为 ClassIsland 档案时字段名写 `AttachedObjects`(PascalCase),以保持双向可导入。
6. 附加设置必须参与 `ProfileService.SaveProfile` 的保存触发(对象属性变化 → 档案保存),并在 UI 中提供"清除该对象全部附加设置"的入口。
### 13.5 组件布局(ComponentProfile)schema
文件:`data/component-layouts/<CurrentComponentConfig>.json`,默认 `Default.json`。依据:`ComponentsService.cs:28-58,122-166`。
#### 13.5.1 顶层
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Lines` | MainWindowLineSettings[] | 1 行 + 2 个默认组件 | 主界面行集合 |
默认组件配置的固定内容:一行,含两个组件,`Id` 分别为 `df3f8295-21f6-482e-bada-fa0e5f14bb66`(日期)与 `1db2017d-e374-4bc6-9d57-0b4adf03a6b8`(时间)。依据:`ComponentsService.cs:33-52`。**这两个 GUID 必须保留**(导入 ClassIsland 组件布局时按 GUID 匹配)。
#### 13.5.2 MainWindowLineSettings(主界面行)
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `Children` | ComponentSettings[] | `[]` | `MainWindowLineSettings.cs` |
| `IsMainLine` | boolean | `false` | 同上 |
| `IsNotificationEnabled` | boolean | `true` | 同上 |
| `IsResourceOverridingEnabled` | boolean | `false` | 同上 |
| `MainWindowSecondaryFontSize` / `Body` / `Emphasized` / `Large` | number | `14/16/18/20` | 同上 |
| `IsCustomForegroundColorEnabled` / `ForegroundColor` | boolean / string | `false` / `#1E90FFFF` | 同上 |
| `BackgroundOpacity` / `IsCustomBackgroundOpacityEnabled` | number / boolean | `0.5` / `false` | 同上 |
| `BackgroundColor` / `IsCustomBackgroundColorEnabled` | string / boolean | `#000000FF` / `false` | 同上 |
| `CustomCornerRadius` / `IsCustomCornerRadiusEnabled` | number / boolean | `0` / `false` | 同上 |
| `Opacity` | number | `1.0` | 同上 |
| `IsVisible` | boolean | `true` | 同上 |
| `HideOnRule` | boolean | `false` | 同上 |
| `HidingRules` | Ruleset | 空规则集 | 同上 |
| `IslandSeparationMode` | number | `0` | 同上(`0` 默认 / `1` 禁用 / `2` 启用) |
#### 13.5.3 ComponentSettings(单个组件)
| 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|
| `Id` | string | 组件 GUID(小写) | `ComponentSettings.cs:56-66` |
| `NameCache` | string | `""`(组件未加载时显示) | `:71-80` |
| `Settings` | object? | 组件专属设置对象 | `:85-95` |
| `HideOnRule` | boolean | `false` | `:100-109` |
| `HidingRules` | Ruleset | 空规则集 | `:114-123` |
| `IsResourceOverridingEnabled` | boolean | `false` | `:149-158` |
| `MainWindowSecondaryFontSize` / `Body` / `Emphasized` / `Large` | number | `14/16/18/20` | `:161-180` 起 |
| `ForegroundColor` / `IsCustomForegroundColorEnabled` | string / boolean | `#1E90FFFF` / `false` | `:28-29` |
| `IsMinWidthEnabled` / `MinWidth` | boolean / number | `false` / `100` | `:30-31` |
| `IsMaxWidthEnabled` / `MaxWidth` | boolean / number | `false` / `300` | `:32-33` |
| `IsFixedWidthEnabled` / `FixedWidth` | boolean / number | `false` / `200` | `:34-35` |
| `HorizontalAlignment` | enum | `Stretch` | `:36`(`Left/Center/Right/Stretch`) |
| `RelativeLineNumber` | number | `0` | `:37` |
| `IsVisible` | boolean | `true` | `:38` |
| `BackgroundOpacity` / `IsCustomBackgroundOpacityEnabled` | number / boolean | `0.5` / `false` | `:40-41` |
| `BackgroundColor` / `IsCustomBackgroundColorEnabled` | string / boolean | `#000000FF` / `false` | `:42-43` |
| `CustomCornerRadius` / `IsCustomCornerRadiusEnabled` | number / boolean | `8` / `false` | `:44-45` |
| `Opacity` | number | `1.0` | `:46` |
| `IsCustomMarginEnabled` + `MarginLeft/Top/Right/Bottom` | boolean + number×4 | `false` + `0` | `:47-51` |
组件专属设置对象必须在 `data/component-layouts/` 同一文件内序列化,其字段名按组件设置模型:`ClockComponentSettings`、`CountDownComponentSettings`、`LessonControlSettings`、`RollingComponentSettings`、`SlideComponentSettings`、`StackComponentSettings`、`GroupComponentSettings`、`TextComponentSettings`、`WeatherComponentSettings`。依据:`repos/ClassIsland/ClassIsland/Models/ComponentSettings/*.cs`。**组件设置的字段名与默认值必须逐项对齐这 9 个模型**(具体见第 5 部分组件规格)。
**组件迁移规则**:加载组件布局时,若组件 `Id` 命中 `ComponentRegistryService.MigrationPairs`,必须把 `Id` 改成新 GUID、`IsMigrated=true`、`MigrationSource=旧 GUID`(两者均 `[JsonIgnore]` 不落盘),迁移后立即保存。依据:`ComponentsService.cs:140-161`、`ComponentSettings.cs:125-128`。
### 13.6 自动化配置 schema
文件:`data/automations/<CurrentAutomationConfig>.json`,默认 `Default.json`;顶层类型是 **Workflow 数组**。依据:`AutomationService.cs:27-32,173-208,441`。
| 层级 | 字段 | 类型 | 默认值 | 依据 |
|---|---|---|---|---|
| Workflow | `Triggers` | TriggerSettings[] | `[]` | `Workflow.cs:12-14` |
| Workflow | `IsConditionEnabled` | boolean | `false` | `Workflow.cs:17-18` |
| Workflow | `Ruleset` | Ruleset | 空规则集 | `Workflow.cs:21-22` |
| Workflow | `ActionSet` | ActionSet | 新行动组 | `Workflow.cs:25-26` |
| TriggerSettings | `Id` | string | `""` | `TriggerSettings.cs:14-30` |
| TriggerSettings | `Settings` | object? | `null` | `TriggerSettings.cs:33-45` |
| ActionSet | `Name` | string | `"新行动组"` | `ActionSet.cs:74-77` |
| ActionSet | `Actions` | ActionItem[] | `[]` | `ActionSet.cs:79-83`(序列化名必须为 `Actions`) |
| ActionSet | `IsEnabled` | boolean | `true` | `ActionSet.cs:88` |
| ActionSet | `IsRevertEnabled` | boolean | `false` | `ActionSet.cs:93` |
| ActionSet | `Status` | number | `Normal` | `ActionSet.cs:104-105` |
| ActionSet | `Guid` | string(Guid) | 新建 Guid | `ActionSet.cs:115` |
| ActionItem | `Id` | string | `""` | `ActionItem.cs:43` |
| ActionItem | `Settings` | object? | `null`(null 时不写该字段) | `ActionItem.cs:50-51` |
**规则集(Ruleset)嵌套结构**(必须精确实现):
```json
{
"Mode": 0,
"IsReversed": false,
"Groups": [
{
"Rules": [{ "IsReversed": false, "Id": "classisland.lessons.currentTimeStateChanged", "Settings": {} }],
"Mode": 1,
"IsReversed": false,
"IsEnabled": true
}
]
}
```
- `Ruleset.Mode` 默认 `Or`(`Ruleset.cs:14`)、`RuleGroup.Mode` 默认 `And`(`RuleGroup.cs:14`);两者均支持 `IsReversed`;`RuleGroup.IsEnabled` 默认 `true`;`Ruleset.Groups` 默认含一个"一个空规则"的组(`Ruleset.cs:16`)。`State` 是运行期字段(`[JsonIgnore]`),不得落盘。
- 触发器 ID 必须使用以下注册表 ID(导入 ClassIsland 自动化时按此匹配):
| 触发器 | ID | 依据 |
|---|---|---|
| 上课时 | `classisland.lessons.onClass` | `OnClassTrigger.cs:8` |
| 课间休息时 | `classisland.lessons.onBreakingTime` | `OnBreakingTimeTrigger.cs:8` |
| 放学时 | `classisland.lessons.onAfterSchool` | `OnAfterSchoolTrigger.cs:8` |
| 当前时间状态变化 | `classisland.lessons.currentTimeStateChanged` | `CurrentTimeStateChangedTrigger.cs:8` |
| 特定时间点前 | `classisland.lessons.preTimePoint` | `PreTimePointTrigger.cs:13` |
| 应用启动时 | `classisland.lifetime.startup` | `AppStartupTrigger.cs:10` |
| 应用退出时 | `classisland.lifetime.stopping` | `AppStoppingTrigger.cs:8` |
| cron | `classisland.cron` | `CronTrigger.cs:15` |
| 收到信号时 | `classisland.signal` | `SignalTrigger.cs:8` |
| 调用 Uri 时 | `classisland.uri` | `UriTrigger.cs:8` |
| 从托盘菜单运行 | `classisland.trayMenu` | `TrayMenuTrigger.cs:11` |
| 规则集更新时 | `classisland.ruleSet.rulesetChanged` | `RulesetChangedTrigger.cs:8` |
行动 ID:`classisland.showNotification`(显示提醒)、`classisland.notification.weather`(天气提醒)、`classisland.os.run`(运行)、`classisland.action.sleep`(等待)、`classisland.app.quit`(退出)、`classisland.app.restart`(重启)、`classisland.settings`(应用设置)。依据:`repos/ClassIsland/ClassIsland/Services/Automation/Actions/*.cs` 的 `ActionInfo` 特性。
### 13.7 提醒数据
1. **运行期队列不落盘**(重启丢失可接受);**通知历史必须落盘**到 `data/notification-history.json`,结构为:
```json
{
"schemaVersion": 1,
"items": [
{
"id": "uuid",
"providerId": "classisland.notification.class",
"title": "上课提醒",
"content": "语文课 08:00-08:45",
"priority": 3,
"channelId": "classisland.channel.desktop",
"createdAt": "2026-11-12T07:59:00",
"read": false,
"speech": "准备上课",
"overlayDurationMs": 5000,
"maskDurationMs": 5000
}
]
}
```
2. 历史保留条数由设置项 `notificationHistoryLimit` 控制:默认 `500`,合法区间 `100–5000`;超限按最旧优先滚动删除(不得一次性清空)。"清空通知"必须二次确认并写审计日志。落盘必须**批量节流**:累积 ≥ 5 条或距上次写入 ≥ 5 秒才落盘一次,退出时强制 flush;不得每来一条通知就写一次盘。**【推断】**(数量上限为工程决策,ClassIsland 不落盘历史。)
3. 提醒设置对象 `NotificationSettings` 的字段与默认值必须为:`IsNotificationEnabled=true`、`IsSpeechEnabled=false`、`IsNotificationEffectEnabled=true`、`IsNotificationSoundEnabled=false`、`NotificationSoundPath=""`、`IsSettingsEnabled=false`、`IsNotificationTopmostEnabled=false`。依据:`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationSettings.cs:11-17`。
4. 提醒播放会话字段:`OverlayContent`、`MaskContent`、`OverlayDuration`(默认 5s)、`MaskDuration`(默认 5s)、`TargetOverlayEndTime`、`TargetMaskEndTime`、`OverlaySpeechContent`、`MaskSpeechContent`、`IsSpeechEnabled`(默认 true)、`RequestNotificationSettings`、`ChannelId`、`State`、`LeftProgress`(默认 1.0)。依据:`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:14-160`、`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationRequest.cs:16-20`。
5. 优先级排序必须与 ClassIsland 一致:`IsPriorityOverride` 最高(插队);否则先比 `Priority`(数值小优先),再比入队 `Index`。依据:`NotificationPriority.CompareTo`(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationPriority.cs`)。
### 13.8 主题数据
1. 主题目录:`data/themes/<主题Id>/`(解压后),启用列表 `data/themes/EnabledThemes.json`(`string[]`),主题包缓存 `cache/theme-packages/<主题Id>.zip`。依据:`XamlThemeService.cs:47-49,392-440`。
2. 主题清单 `ThemeManifest` 字段与默认值:`Id=""`、`Name=""`、`Author=""`、`Url=""`、`Description=""`、`Version="0.0.0.0"`、`Banner="banner.png"`、`VerticalSafeAreaPx=0`。依据:`repos/ClassIsland/ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs`。
3. 启用/禁用主题后必须重建样式表并持久化;**禁用全部主题时必须回落到内置主题**,不得白屏。
4. 主题包安装必须:校验 zip 路径安全 → 校验清单必填字段 → 解压到 `data/themes/<Id>/` → 若已存在则保留用户自定义值(按字段合并,不整目录覆盖)。
5. **默认离线(强制)**:主题市场与插件市场同一策略——安装包**不内置在线官方索引**,只内置离线示例索引;用户必须手动添加索引 URL 并二次确认后才联网;网络失败不得影响本机已安装主题与 `EnabledThemes.json`。依据:与 12.3 第 7 条同一产品决策【推断】。
### 13.9 其他存储
1. **机器级全局存储**:`Global.json`(键值对字符串),ClassIsland 放在 `%LOCALAPPDATA%\ClassIsland\Global\Global.json`;本实现放 `<userData>/global.json`,用于"上次运行版本、是否首次启动、Sentry 开关、MLE 标记"等不适合放进用户配置的信息。依据:`repos/ClassIsland/ClassIsland.Core/Services/GlobalStorageService.cs`。
2. **设置叠层**:自动化/插件临时覆盖设置时必须记录"原值 + 覆盖者 Guid",移除覆盖者后按栈顶恢复;不得直接改配置后忘记恢复。依据:`repos/ClassIsland/ClassIsland/Services/SettingsService.cs:191-259`。
3. **信任档案列表**:`trustedProfileIds` 默认 `[]`;首次加载未信任档案时必须提示"该档案来自外部,是否信任";不允许自动化在未信任档案下运行(对齐 `AutomationService` 对 `IsCurrentProfileTrusted` 的检查)。依据:`ProfileService.cs:166-176,398-422`。
4. **缓存目录可安全清空**:`cache/` 下任何内容删除后不得影响功能(下次自动重建);设置页"清理缓存"不得删除 `data/`、`backups/`、`config.json`。
### 13.10 备份、恢复与迁移(必须实现)
**加载时备份**:
1. 每次读取 JSON 成功 → 复制为 `.bak`;失败 → 用 `.bak` 恢复并记入错误列表。依据:`ConfigureFileHelper.cs:63-96`。
2. `.bak` 只保留一份(覆盖式),不做多版本滚动;多版本由 `backups/` 承担。
**整包备份**:
1. 备份内容固定为:`config.json`、`Settings.json`(若存在)、`data/` 全目录、`data/themes/EnabledThemes.json`;**不得**包含 `cache/`、`logs/`、`updates/`、插件包缓存。依据:`repos/ClassIsland/ClassIsland/Services/FileFolderService.cs:115-168`(备份 `Config/` + `Profiles/` + `Settings.json`,排除缓存/日志)。
2. 备份文件名:自动备份 `Auto_Backup_yy-MMM-dd_HH-mm-ss.zip`,手动备份 `Backup_yy-MMM-dd_HH-mm-ss.zip`。依据:`FileFolderService.cs:128-132`。
3. 自动备份策略:`isAutoBackupEnabled=true` 时,距上次备份 ≥ `autoBackupIntervalDays`(默认 7 天)才执行;只保留最近 `autoBackupLimit`(默认 16)份 `Auto_*`,按文件修改时间倒序删除多余。依据:`FileFolderService.cs:83-113`。
4. 备份写入必须"先写 `.tmp` 再 rename";备份失败不得影响启动与当前数据(只记日志并在设置页提示)。
5. 恢复流程:选择备份 → **先自动创建一份"恢复前快照"** → 校验 zip 结构(必须含 `config.json` 或 `Settings.json` + `data/profiles/Default.json`)→ 解压到临时目录 → 原子替换 → 提示"重启后生效" → 重启应用。恢复失败必须回滚到快照。
6. 备份必须可在资源管理器里直接解压查看(不得加密、不得用私有格式)。
**版本迁移**:
1. 必须在 `config.json` 写入 `schemaVersion`(当前 `1`),并在 `data/profiles/*.json` 内写 `SchemaVersion`(当前 `1`,允许缺省视为 1)。**这是本实现的工程必需字段**(ClassIsland 没有,但迁移流水线需要显式版本号)。**【推断】**
2. 迁移执行顺序:读取文件 → 记录 `fromVersion` → 逐级执行 `migrations[fromVersion → fromVersion+1]` → 写回 → 备份迁移前文件到 `backups/migrate-<from>-to-<to>-<时间>.zip`。任一步失败必须中止并保留原文件,提示"迁移失败,已保留原配置"。
3. ClassIsland 形迁移规则必须实现(依据 `repos/ClassIsland/ClassIsland/Services/SettingsService.cs:134-168`):
- 旧版本 `LastAppVersion < 2.0.0.2`:重新登记 URL 协议(`classisland://`)。
- 旧版本 `LastAppVersion < 2.0.3.0`:重置教程初始化标记(`WillMigrateInitTutorial`)。
- 旧版本 `LastAppVersion < 2.0.3.3`:重写开机自启快捷方式(先禁用再启用)。
- 迁移完成后必须把 `LastAppVersion` 更新为当前版本(`SettingsService.cs:112`)。
4. 从 ClassIsland 导入必须支持两种入口:`--importV1 <dir>`(1.x)、`--importV2 <dir>`(2.x);导入前必须自动备份当前数据;导入后必须展示"导入结果清单"(成功项/跳过项/失败项及原因),不得静默覆盖。依据:`repos/ClassIsland/ClassIsland/Program.cs:60-65`。
5. 组件迁移:`ComponentRegistryService.MigrationPairs` 必须内置一份"旧 GUID → 新 GUID"映射;导入旧组件布局时按映射改写并保存。依据:`ComponentsService.cs:140-161`。
6. 空 GUID 兼容:所有 `Guid` 字段读到 `""` 必须解析为 `Guid.Empty`;写出时 `Guid.Empty` 写成 `""`(对齐 `GuidEmptyFallbackConverter` 的双向规则)。依据:`repos/ClassIsland/ClassIsland.Shared/JsonConverters/GuidEmptyFallbackConverter.cs`。
7. 颜色兼容:读入支持 `#RRGGBB`、`#RRGGBBAA`、`#AARRGGBB` 三种写法(ClassIsland 写出为 `#RRGGBBAA`,Alpha 在尾部);写出统一 `#RRGGBBAA` 大写。依据:`repos/ClassIsland/ClassIsland.Core/Converters/ColorHexJsonConverter.cs`(`AlphaComponentPosition.Trailing`)。
8. 时间兼容:`DateTime` 写出为 ISO `yyyy-MM-ddTHH:mm:ss`(无时区,本地时间);`TimeSpan` 写出为 `hh:mm:ss`(超过 24h 用 `d.hh:mm:ss`);读入必须同时接受带时区与不带时区的 ISO 字符串,并按本地时区归一。依据:`repos/ClassIsland/examples/example.json:10-11` 与 System.Text.Json 默认 `TimeSpan` 格式。
9. 枚举兼容:所有枚举按**数字**落盘(如 `TimeType`、`UpdateStatus`、`RulesetLogicalMode`),不得写出名字;读到未知数字必须夹紧到合法范围并记 warn。依据:ClassIsland 使用默认 `System.Text.Json` 序列化(`ConfigureFileHelper.SerializerOptions = new()`,`ConfigureFileHelper.cs:30`),枚举默认输出数字。
10. 迁移必须**幂等**:同一版本重复执行不得产生二次修改(用 `schemaVersion` 与字段存在性双重判断)。
**Excel 导入导出(P1 必须实现,xlsx 重建)**:
11. 必须用 `xlsx`(SheetJS)在**主进程/utilityProcess** 中重建 ClassIsland 的 Excel 导入导出能力;ClassIsland 的 `unvell.ReoGrid` + WPF 窗口实现(`repos/ClassIsland/ClassIsland/Views/ExcelImportWindow.xaml.cs:29-44`、`ExcelExportWindow.xaml.cs:23-43`)**只作字段与流程参考**,不得照搬 UI。
12. 导入最低能力:读取 `.xlsx` → 选择工作表 → 识别"行 = 节次 / 列 = 星期"的网格 → 生成 `ClassPlan` + 对应 `TimeLayout`;必须支持合并单元格、空节次、科目名去重、教师名写入 `Subject.TeacherName`;解析失败必须给出行列级错误提示,且**不得**覆盖现有档案(先写入临时档案,用户确认后替换)。
13. 导出最低能力:把当前档案/课表写成 `.xlsx`(一个工作表一张课表,首行星期、首列节次与起止时间),首行冻结;导出文件默认落 `下载/ClassIsland-课表-<日期>.xlsx`,由用户选择路径。
14. 导入导出必须遵守 P1 数据兼容:导出的 xlsx 再导入必须得到等价档案(科目/时间表/课表/周次规则不丢);单文件上限 8MB(对齐 ClassHelper 服务端表格上限,见 `repos/ClassHelper-master/AGENTS.md` §2);导入前自动备份当前档案。
### 13.11 读写一致性与错误处理
1. 所有加载函数必须返回"数据 + 警告列表",不得只返回数据;设置页与诊断页必须能展示"哪些文件回退了备份、哪些字段被重置"。
2. 阻止"写入正在读取的文件":每个文件维护内存版本号,写入前比较版本;发现外部修改(用户在资源管理器里改了 JSON)必须提示"配置已被外部修改,是否重新加载",禁止静默覆盖用户改动。**【推断】**
3. 任何写入失败必须:记 error(含路径、错误码、可用磁盘空间)→ 提示用户 → 保留旧文件不变(不得留下半截文件)。
4. 磁盘空间不足(< 100MB)时禁止自动备份并提示"清理磁盘";备份与导出前必须检查可用空间。
5. 读取时必须限制单文件大小(配置文件 ≤ 8MB、档案 ≤ 32MB、备份 zip ≤ 512MB),超限拒绝并提示,防止恶意文件把内存打爆。**【推断】**
### 13.12 天气数据 schema(与 07 天气服务对齐)
1. **缓存位置与字段**:最后一份天气必须持久化在 `config.json` 的 `lastWeatherInfo`(与 ClassIsland 把 `Settings.LastWeatherInfo` 当缓存一致,见 `repos/ClassIsland/ClassIsland/Services/WeatherService.cs:48-51,387`),并附加 `lastWeatherInfoUpdatedAt`(ISO 本地时间)与 `lastWeatherInfoStale`(boolean)。缓存**不得**因查询失败被清空:失败时保留旧值并标记 `stale`,由 UI 展示"天气数据可能已过期"。
2. **刷新节奏**:天气服务必须每 5 分钟检查一次(`WeatherService.cs:41-44`)、启动后立即查询一次(`:71`);查询失败按 30s/60s/120s 退避重试 3 次后停到下个周期。请求默认使用 `https://`;仅当用户显式开启 `noTLSWeatherRequests` 时才允许 `http://`(对齐 `WeatherService.cs:34`)。
3. **来源抽象**:不得把第三方接口写死在渲染层。必须定义 `WeatherProvider` 接口(`id`、`name`、`requiresNetwork`、`fetch()`),内置"小米天气"参考实现并允许替换:城市信息 `GET {base}/location/city/info?locationKey=<cityId>&locale=zh_cn`、全量天气 `GET {base}/weather/all?latitude=<lat>&longitude=<lon>&locationKey=<cityId>&days=15&appKey=<key>&sign=<sign>&isGlobal=false&locale=zh_cn`(依据 `WeatherService.cs:327,358-360`)。`base/appKey/sign` 必须可配置、不得出现在渲染进程产物中;默认天气提示"需联网"。
4. **预警清洗**:查询结果必须按 `excludedWeatherAlerts`(默认 `[]`)过滤——`Title` 或 `Detail` 包含排除词即剔除(`WeatherService.cs:363-374`);同一 `Type` 只保留 `PubTime` 最新一条(`:376-385`);`alerts` 按 `pubTime` 倒序展示。
5. **图标与码表**:`weatherIconId` 默认 `classisland.weatherIcons.lucide`(`Settings.cs:1849`);天气码到文案的映射必须来自内置资源 `Assets/XiaomiWeather/xiaomi_weather_status.json`(`WeatherService.cs:228-233`),并提供 `getWeatherTextByCode(code)` 等价函数。
6. **数据结构(字段名必须与 07 一致,JSON 键为 camelCase;ClassIsland 原始模型用 `[JsonPropertyName]` 指定小写键,可直接对齐)**:
| 类型 | 字段 | 类型 | 默认值 |
|---|---|---|---|
| `WeatherInfo` | `current` | `CurrentWeather` | 空对象 |
| | `alerts` | `WeatherAlert[]` | `[]` |
| | `updateTime` | number(Unix 毫秒) | `0` |
| | `forecastDaily` | `ForecastDaily` | 空对象 |
| | `forecastHourly` | `ForecastHourly` | 空对象 |
| | `minutely` | `Minutely` | 空对象 |
| | `aqi` | `AqiInfo` | 空对象 |
| `CurrentWeather` | `feelsLike` / `humidity` / `pressure` / `temperature` / `visibility` | `ValueUnitPair {value:string, unit:string}` | `{"",""}` |
| | `weather` | string(天气码) | `"99"` |
| | `pubTime` | string(ISO) | 查询时间 |
| | `wind` | `WindInfo {direction:ValueUnitPair, speed:ValueUnitPair}` | 空对象 |
| `WeatherAlert` | `locationKey` / `alertId` / `title` / `type` / `level` / `detail` | string | `""` |
| | `pubTime` | string(ISO) | 当前时间 |
| | `images` | `map<string,string>` | `{}`(至少含 `icon`) |
| `ForecastDaily` | `precipitationProbability` | `StatusValueBase<string[]>` | `{value:[]}` |
| | `temperature` / `weather` / `sunRiseSet` | `StatusValueBase<RangedValue[]>`(`RangedValue {from,to}`) | `{value:[]}` |
| `ForecastHourly` | `temperature` / `weather` | `StatusValueBase<number[]>` | `{value:[]}` |
| `Minutely` | `precipitation` | `MinutelyPrecipitation {value:number[]}` | `{value:[]}` |
| `AqiInfo` | `aqi` | string | `"0.0"` |
| `WeatherQueryResult` | `isSuccess` / `isPrecisionDegraded` | boolean | `false` |
| | `errorMessage` | string? | `null` |
| | `degradedPrecision` | number | `0` |
7. **派生字段(不得落盘,读取时计算)**:`WeatherInfo.updateTime` → 本地时间;`MinutelyPrecipitation.rainRemainingMinutes`(首值为 0 时返回"接下来多少分钟开始下雨",首值 >0 时返回负的"还有多少分钟停雨",算法见 `repos/ClassIsland/ClassIsland.Core/Models/Weather/MinutelyPrecipitation.cs`);`AqiInfo.aqiLevel`(1–6,按 50/100/150/200/300 分档,见 `AqiInfo.cs`)。
8. **与 07 的契约**:07 天气服务必须复用本节字段名与派生字段;规则集 handler ID 必须为 `classisland.weather.currentWeather`、`classisland.weather.tomorrowWeather`、`classisland.weather.hasWeatherAlert`、`classisland.weather.rainTime`、`classisland.weather.sunRiseSet`(`WeatherService.cs:64-68`);提醒文案与图标渲染由 07 定义,但**读取的数据结构只能是本节 schema**。
### 13.13 验收标准(第 13 部分)
1. 首次启动能在 `<userData>/` 下生成完整的目录结构与默认 `config.json`、`Default.json` 档案、`Default.json` 组件布局、默认科目表;目录清单与 13.2 完全一致。
2. 删除 `config.json` 后应用能正常启动并重建默认配置;删除 `data/` 后能重建默认档案与组件布局,不白屏、不崩溃。
3. 故意把 `config.json` 改成非法 JSON 后启动:应用回退到 `.bak`;若 `.bak` 也非法则使用默认值并进入"配置错误"提示,不得启动失败。
4. `Settings.json`/档案的字段名、类型、默认值与 13.3/13.4 表一致;用 ClassIsland 的真实档案 JSON 导入后,时间表、课表、科目、周次规则全部正确解析(含 `TimeType=3` 与 `IsHideDefault`、`AttachedObjects`)。**并且**:运行期只写 `config.json`,`Settings.json` 只读导入/单独导出、不双向双写;`IsIslandSeperated` 原拼写能被导入与导出映射。
5. 写入过程中断电(模拟 `rename` 前杀进程)不会损坏原文件;`.tmp` 残留有清理机制。
6. 自动备份:把 `lastAutoBackupTime` 改为 8 天前,启动后生成 `Auto_Backup_*.zip`;备份超过 16 份时最旧的 `Auto_*` 被删除,手动 `Backup_*` 不受影响。
7. 备份 zip 内必须含 `config.json`、`data/profiles/Default.json`、`data/component-layouts/Default.json`、`data/automations/Default.json`,且不含 `cache/`、`logs/`、`updates/`。
8. 恢复备份:选择备份 → 自动生成恢复前快照 → 恢复 → 重启后数据与备份一致;中途失败能回滚。
9. 迁移流水线:把 `schemaVersion` 改为 0 的旧结构文件,启动后能迁移到 1 并保留原字段;迁移失败时原文件不被破坏。
10. 组件布局导入旧 GUID 后按 `MigrationPairs` 改写,且迁移后立即保存、不重复迁移。
11. 空 GUID、`#RRGGBB`、带时区 ISO、数字枚举四类兼容用例全部通过(读写往返不丢信息)。
12. 档案切换/删除后 `config.selectedProfile` 指向有效文件;指向不存在文件时回落到 `Default.json` 并提示。
13. 自动化配置按 `Workflow[]` 落盘;触发器/行动 ID 与 13.6 表一致;规则集 `Mode`/`IsReversed`/`IsEnabled` 往返不丢。
14. 通知历史落盘、默认上限 500(可配 100–5000)、超限滚动删除、写入节流(≥5 条或 ≥5s)、清空需二次确认;历史文件损坏时回退为空列表并记警告,不影响提醒播放。
15. 所有写入路径都能在世界可读的 JSON 中直接看到内容(无私有封装、无加密),且 UTF-8 无 BOM、LF 换行。
16. 附加设置:Profile/ClassPlan/ClassInfo/TimeLayout/TimeLayoutItem/Subject 都能读写 `attachedObjects`;4 个已知 Guid 的设置对象默认值正确;导入 `AttachedObjects`(PascalCase)后可按目标生效;未知 Guid 不丢失;导出写回 `AttachedObjects`。
17. 天气:`lastWeatherInfo` 缓存可持久化、失败时保留旧值并标 `stale`;`WeatherInfo`/`WeatherAlert`/`ForecastDaily`/`ForecastHourly`/`Minutely`/`AqiInfo`/`WeatherQueryResult` 字段与 13.12 完全一致;预警按排除词过滤并按 Type 保留最新;5 个天气规则 ID 可被 07 使用。
18. Excel:能从 `.xlsx` 导入生成课表(先临时档案、确认后替换、导入前备份),能导出 `.xlsx` 再导入得到等价档案;8MB 上限;所有解析在 utilityProcess 或 worker 中执行,不阻塞主进程。
19. 坐标:`config.json` 中所有几何字段(`windowDockingOffsetX/Y`、`scale`、`radiusX/Y`、字号、间距)均为 DIP 数值;把 `scale` 由 1 改为 1.5 后窗口与岛按 CSS px 等比放大,不出现 devicePixelRatio 二次换算。
20. 主题市场默认离线:无内置在线官方索引;手动添加 URL 需二次确认;断网时本机已安装主题与 `EnabledThemes.json` 正常工作。
+346
View File
@@ -0,0 +1,346 @@
## 14. 动画与动效总表
本节是「动效施工图」。实现者必须逐条实现下表的触发、属性、起止值、时长、曲线、延迟与实现方式,**不得**自行「优化」时长或曲线。所有数值均来自 ClassIsland 源码;标注【推断】的为源码未直接给出、由同类动画或 Web 平台换算得出。
### 14.0 全局动效规则
#### 14.0.1 动画级别(AnimationLevel)
必须实现 3 档,默认 **标准(1)**(`ClassIsland/Models/Settings.cs:1851`;`Views/SettingPages/GeneralSettingsPage.axaml:280-293` 的三项为「禁用 / 标准 / 华丽」,绑定 `SelectedIndex`):
| 级别 | 文案 | 行为要求 | 证据 |
|---|---|---|---|
| 0 | 禁用 | `IThemeService.IsTransientDisabled = true`(禁用过渡动画),Popup 入场、抽屉/竖抽屉动画、Workflow 等全部关闭;但**主界面**的提醒动画与部分控件不在此列(设置项描述明确写「禁用动画选项不适用于应用主界面和部分控件」) | `App.axaml.cs:725`;`IThemeService.cs:44`;`PopupIntroAnimationBehavior.cs:67`;`DrawerHost.axaml.cs:70`;`GeneralSettingsPage.axaml:283` |
| 1 | 标准(默认) | 列表进入动画关闭、Popup 入场可用;不启用「行/组件淡入」「WrapPanel 尺寸过渡」等高成本动画 | `StackPanelIntroAnimationBehavior.cs:46`;`DrawerHost.axaml.cs:70` |
| 2 | 华丽 | 额外启用:组件/行淡入(250–300ms)、WrapPanel 子项位置隐式动画(225ms 默认) | `MainWindowLine.cs:382-400`;`ComponentPresenter.axaml.cs:224-243`;`WrapPanelResizingAnimationAssist.cs:51-56` |
实现细节:
- 该设置必须持久化并在启动时同步到全局单例(对齐 `IThemeService.AnimationLevel` 静态属性),任何动画入口先查级别再播放(源码模式见 `MainWindowLine.cs:384`)。
- 「禁用动画等待」(`IsWaitForTransientDisabled`)是另一独立开关:默认阻塞操作会等待动画播完,开启后不等待(`IThemeService.cs:46-49`;`Settings.cs:773-782`)。Web 实现等价为「不 await 动画 Promise」。
- 必须同时响应系统 `prefers-reduced-motion: reduce`:将其视为「级别 0 + 时长压到 1ms」(与 ClassHelper `renderer/styles/index.css:909-917` 一致),且**加载指示器必须放慢而不是停**(`index.css:919-930`)。
#### 14.0.2 缓动别名表(必须集中定义,禁止各组件手写)
| 别名 | cubic-bezier | 用途 | 来源 |
|---|---|---|---|
| `--ci-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | 绝大多数进出场(ClassHelper 主缓动) | `ClassHelper/packages/shared/src/motion.ts:24` |
| `--ci-ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | 对称来回(抽屉推拉、内容互换) | `motion.ts:26` |
| `--ci-ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | 侧向抽屉 | `motion.ts:28` |
| `--ci-ease-fl-1` | `cubic-bezier(0.65, 0, 0.35, 1)` | Fluent 行宽过渡/L 型 S 曲线 | `FluentTheme/Styles.axaml:269` |
| `--ci-ease-fl-2` | `cubic-bezier(0.25, 1, 0.5, 1)` | 遮罩文字淡入、行淡入 | `FluentTheme/Styles.axaml:333`;`MainWindowLine.cs:397` |
| `--ci-ease-fl-3` | `cubic-bezier(0.5, 0, 0.75, 0)` | 遮罩退出缩放 | `FluentTheme/Styles.axaml:389` |
| `--ci-ease-popup` | `cubic-bezier(0.22, 1, 0.36, 1)` | Popup 入场(Semi 风格) | `PopupIntroAnimationBehavior.cs:77,86` |
| `--ci-ease-tutorial` | `cubic-bezier(0.16, 1, 0.3, 1)` | 教程高亮/聚光 | `TutorialHighlightControl.axaml:13` |
| `--ci-ease-list` | `cubic-bezier(0.00, 1.00, 0.00, 1.00)` | 列表错峰入场 | `StackPanelIntroAnimation.axaml:22` |
| `--ci-ease-text` | `cubic-bezier(0.33, 1, 0.68, 1)` | OOBE 文字收拢、字符翻入 | `OobeIntroAnimationControl.axaml:93,105`;`.axaml.cs:151` |
| `--ci-ease-block` | `cubic-bezier(0.76, 0, 0.24, 1)` | OOBE 方块放大 | `OobeIntroAnimationControl.axaml:48` |
| `--ci-ease-root` | `cubic-bezier(0.00, 0.83, 0.17, 0.90)` | OOBE 整场缩放 | `OobeIntroAnimationControl.axaml:16` |
| `--ci-ease-char-out` | `cubic-bezier(0.32, 0, 0.67, 0)` | OOBE 字符翻出 | `OobeIntroAnimationControl.axaml.cs:117` |
| `--ci-ease-spin` | `cubic-bezier(0.50, 0.25, 0.50, 0.75)` | 更新图标旋转 | `UpdateSettingsPage.axaml:90` |
| `--ci-ease-overshoot` | `cubic-bezier(0.00, 1.00, 0.50, 1.10)` | 经典主题行宽过冲 | `ClassicTheme/Styles.axaml:207` |
| `--ci-ease-step` | `cubic-bezier(0, 0, 0, 1)` | 抽屉/关于页 (`0,0 0,1`) | `DrawerHost.axaml:46-48`;`AboutSettingsPage.axaml:137` |
| Avalonia `SineEaseIn` | `cubic-bezier(0.12, 0, 0.39, 0)`【推断】 | 遮罩层淡入/淡出 | `FluentTheme/Styles.axaml:299` |
| Avalonia `SineEaseOut` | `cubic-bezier(0.61, 1, 0.88, 1)`【推断】 | 图标宽度/进度透明度 | `AnimatedIconButton.axaml:24` |
| Avalonia `SineEaseInOut` | `cubic-bezier(0.37, 0, 0.63, 1)`【推断】 | 呼吸/进度过渡 | `WorkflowProgressIndicatorItemControl.axaml:19,32,60` |
| Avalonia `QuadraticEaseIn` | `cubic-bezier(0.11, 0, 0.5, 0)`【推断】 | 经典遮罩下压、涟漪放大 | `ClassicTheme/Styles.axaml:271`;`RippleEffect.axaml.cs:94` |
| Avalonia `QuadraticEaseOut` | `cubic-bezier(0.5, 1, 0.89, 1)`【推断】 | 经典遮罩上滑 | `ClassicTheme/Styles.axaml:311` |
| Avalonia `ExponentialEaseOut` | `cubic-bezier(0.16, 1, 0.3, 1)`【推断】 | 闪屏滑入 | `SplashWindow.axaml.cs:111,116` |
| Avalonia `CubicEaseOut` | `cubic-bezier(0.33, 1, 0.68, 1)`【推断】 | 闪屏进度 | `SplashWindow.axaml.cs:75` |
| Avalonia `CubicEaseInOut` | `cubic-bezier(0.65, 0, 0.35, 1)`【推断】 | WrapPanel 默认隐式动画 | `WrapPanelResizingAnimationAssist.cs:40` |
#### 14.0.3 时长常量表
| 常量 | 值 | 使用场景 |
|---|---|---|
| `--ci-dur-instant` | 90ms | 按压反馈、行透明度 |
| `--ci-dur-fast` | 100–140ms | 行/组件淡入淡出、抽屉内容、遮罩退出 |
| `--ci-dur-base` | 150–250ms | Popup、遮罩文字、列表滑入 |
| `--ci-dur-slow` | 300–360ms | 行宽过渡(Fluent)、遮罩分块、组件淡入 |
| `--ci-dur-overshoot` | 550ms | 行宽过渡(经典) |
| `--ci-dur-long` | 750ms | OOBE 字符、教程高亮、遮罩缩放 |
| `--ci-dur-loop` | 1000–3000ms | 循环/呼吸/旋转/OOBE 整场 |
#### 14.0.4 动效编排原则(实现时逐条遵守)
1. **进场慢、退场快**:进场承担「让用户理解发生了什么」的职责,可以 200–300ms;退场承担「别挡路」的职责,必须 100–150ms 结束。源码中提醒遮罩文字淡入 250ms、淡出 200ms;抽屉 250ms;弹窗退出 140ms(`index.css:638-644`)都遵循此规律。实现者不得把退场做得比进场更慢。
2. **延迟用于建立因果**:遮罩分块先扫入(0–360ms),文字随后(延迟 260ms),投影最后(延迟 600ms);OOBE 字符按正弦曲线错峰。延迟必须表达「谁先谁后」,**不得**所有元素同一时刻启动。
3. **错峰必须封顶**:列表入场 25ms/项(源码)、ClassHelper 35ms/项且第 9 项起封顶、页面块 30ms/块且封顶 180ms(`motion.ts:175-179`;`index.css:857-883`)。超过 10 项仍线性累加会让最后一项等超过 1 秒,属于缺陷。
4. **状态变化必须可打断**:任何「进/出」成对动画必须共用同一元素、同一属性、可反向;反向时用新动画直接覆盖旧动画(不要排队)。遮罩的 in/out 伪类互斥实现(`MainWindowLine.cs:726-791`)就是范例。
5. **不在动画里做业务**:动画只表达状态,不得在 `animationend` 里触发网络请求、不得用动画时长做业务计时;业务定时器与动画解耦。
6. **动效不得改变布局语义**:动画结束后元素的最终几何必须与「无动画直接跳到终态」完全一致;判定方法:动画播放中与播放后分别测量 `getBoundingClientRect`,最终值必须逐像素相等。
7. **一致性优先于炫技**:同一个交互(如「抽屉打开」)在全应用只能有一种时长与曲线;新交互必须复用 `--ci-ease-*` 与 `--ci-dur-*`,不得新增私有曲线。
### 14.1 A. 主界面(岛)动效
主界面动效分三类:**状态过渡**(A1–A4、A8–A9)、**几何过渡**(A5–A7、A11)、**数据过渡**(A10、A12)。状态过渡可直接用 CSS transition;几何过渡必须避免回流(见 14.7 第 1 条);数据过渡(进度条)必须做「比例插值」而不是「每帧重算 DOM」。三类不得混用同一个动画实例,否则打断时会互相污染。
| 编号 | 名称 | 触发 | 变化属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| A1 | 窗口背景换色 | 应用明暗主题切换 | `background` | 旧色 → 新色 | 200ms | 默认线性 | 无 | CSS `transition: background-color 200ms` | `MainWindow.axaml:40-44` |
| A2 | 行淡入 | 行由隐藏变可见 | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 无 | WAAPI/composition `opacity`,`AnimationLevel≥2` | `MainWindowLine.cs:336-344,382-401` |
| A3 | 行透明度过渡 | 悬停淡出开关生效 | `opacity` | 1 ↔ 0.05 | 100ms | 默认 | 无 | CSS transition | `MainWindowLine.axaml:51,73-75`;`FluentTheme/Styles.axaml:268` |
| A4 | 组件淡入 | 组件挂载/由隐藏变可见 | `opacity` | 0 → 1 | 300ms | `(0.65,0,0.35,1)` | 无 | composition `opacity`,`AnimationLevel≥2` | `ComponentPresenter.axaml.cs:224-243,263-266` |
| A5 | 行宽过渡(Fluent) | 组件内容变化导致背景宽度变化 | `width` | 旧宽 → 新宽 | 300ms | `(0.65,0,0.35,1)` | 无 | CSS transition(背景层绝对定位,禁止回流文字) | `FluentTheme/Styles.axaml:264-273` |
| A6 | 行宽过渡(经典) | 同上 | `width` | 旧宽 → 新宽 | 550ms | `(0,1,0.5,1.1)` 过冲 | 无 | 同上;曲线必须带过冲,不得改成 ease-out | `ClassicTheme/Styles.axaml:202-211` |
| A7 | WrapPanel 子项位置过渡 | 子项增删/重排 | `offset`(x/y) | 旧位置 → 新位置 | 默认 225ms;主界面覆盖 300ms | `CubicEaseInOut`;主界面 `(0.65,0,0.35,1)` | 无 | composition 隐式动画;Web 用 FLIP(先读后写)| `WrapPanelResizingAnimationAssist.cs:45-49,130-137`;`MainWindow.axaml:197-198` |
| A8 | 编辑模式背景切换 | 进入/退出编辑模式 | 窗口背景、`backdrop-filter` | 透明 → 亚克力 | 200ms | 默认 | 无 | CSS transition(只动背景层) | `MainWindow.axaml:177-181`;`MainWindow.axaml.cs:510-517` |
| A9 | 拖动项抬升 | 拖动开始/结束 | `opacity`、`z-index` | 1 → 0.5(拖动中) | 即时 | — | 无 | 直接切 class(不做动画) | `MainWindow.axaml:478-481` |
| A10 | 缩放重置 | 点击「重置视图」 | `transform: scale` | 当前 → 1.0 | 200ms【推断】 | `(0.16,1,0.3,1)` | 无 | WAAPI 动画 transform | `MainWindow.axaml:606-609`;ZoomBorder `MinZoom 0.5/MaxZoom 3/ZoomSpeed 1.2`(`:199-209`) |
| A11 | 分体主界面切换 | 开启/关闭分体 | 行内边距、圆角、背景宽 | 合并 ↔ 分体 | 与 A5 同步 300ms | 同 A5 | 无 | class 切换 + 宽度过渡 | `FluentTheme/Styles.axaml:245-259` |
| A12 | 进度条值过渡 | 每秒/每 50ms 更新 | `value`/`transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 无 | CSS transition `transform` | `FluentTheme/Styles.axaml:137-145`;`ClassicTheme/Styles.axaml:123-131` |
### 14.2 B. 提醒与强提醒动效
提醒是「岛」最重要的动效场景,必须实现两套完全不同的遮罩形态(Fluent/经典),并共用同一套队列与遮罩生命周期状态机。状态机必须严格按源码实现:`:mask-anim` → `:mask-in` →(可选)`:overlay-anim`/`:overlay-in` → `:mask-out` → `:overlay-out`,任一状态切换都通过类名驱动(`Controls/MainWindowLine.cs:724-791`)。**禁止**用 JS 计时器手动改元素 style 来「模拟」状态机,否则队列切换时会错乱。
强提醒特效是**独立置顶窗口**(`TopmostEffectWindow`),不属于岛 DOM;特效元素播放完成后自行移除,窗口内元素清空即隐藏窗口(`TopmostEffectWindow.axaml.cs:50-67,90-98`)。特效必须支持 `NotificationEffectRenderingScale` 缩放(默认 1.0,大屏自动降到 0.75/0.5,见 part 03 表)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| B1 | Fluent 遮罩斜切扫入 | `:mask-in` | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | `QuarticEaseOut`(≈`(0.16,1,0.3,1)`【推断】) | 段间 120ms:区 0/4 同起,1/3 延迟 90ms,2 延迟 120ms | canvas/SVG 自绘 + rAF 或 WAAPI 驱动进度 | `SlantedMaskControl.cs:166-202,225-251`;`FluentTheme/Styles.axaml:175-180` |
| B2 | Fluent 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS animation `both` | `FluentTheme/Styles.axaml:329-340` |
| B3 | Fluent 遮罩文字缩放 | `:mask-in` | `scale` | 1.1 → 1.0 | 750ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS transform animation | `FluentTheme/Styles.axaml:341-352` |
| B4 | Fluent 遮罩投影淡入 | `:mask-in` | 背景层 `opacity`、`box-shadow` | 0 → 1;阴影 `0 8 32 #66000000` | 100ms | 默认 | 延迟 600ms | CSS animation + `transition: box-shadow 100ms` | `FluentTheme/Styles.axaml:169-173,355-367` |
| B5 | Fluent 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 200ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:376-386` |
| B6 | Fluent 遮罩文字放大退出 | `:mask-out` | `scale` | 1.0 → 1.1 | 200ms | `(0.5,0,0.75,0)` | 无 | CSS animation | `FluentTheme/Styles.axaml:387-398` |
| B7 | Fluent 遮罩背景淡出 | `:mask-out` | 背景层 `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:401-409` |
| B8 | 覆盖层淡入/淡出 | `:overlay-in`/`:overlay-out` | `opacity` | 0 ↔ 1 | 200ms | `SineEaseIn`≈`(0.12,0,0.39,0)`【推断】 | 无 | CSS transition | `FluentTheme/Styles.axaml:419-451` |
| B9 | 内容上滑进入(遮罩层内) | 遮罩内内容切换 | `translateY` | `-50` → 0 | 200ms | `SineEaseIn` | 无 | CSS transition | `ClassicTheme/Styles.axaml:237-249,266-281` 相关;主窗口 `TranslateTransform Y=-50`(`ClassicTheme/Styles.axaml:87,148`) |
| B10 | Classic 遮罩下压 | `:mask-in` | `translateY` | -50 → 0 | 200ms | `QuadraticEaseIn` | 无 | CSS animation `both` | `ClassicTheme/Styles.axaml:265-281` |
| B11 | Classic 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 100ms | 默认 | 延迟 200ms | CSS animation | `ClassicTheme/Styles.axaml:288-300` |
| B12 | Classic 遮罩上滑退出 | `:mask-out` | `translateY` | 0 → 50 | 200ms | `QuadraticEaseOut` | 延迟 100ms | CSS animation `forward` | `ClassicTheme/Styles.axaml:305-318` |
| B13 | Classic 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `ClassicTheme/Styles.axaml:319-331` |
| B14 | Classic 行投影淡入 | `:mask-in` | `effect`(`DropShadowEffect`) | opacity 0 → 1,blur 8 | 100ms | 线性 | 无 | CSS `box-shadow` transition(禁止 filter) | `ClassicTheme/Styles.axaml:34-45,283-287` |
| B15 | 提醒进度条 | 提醒剩余时间更新 | `value` | 旧 → 新 | 50ms | 线性 | 无 | CSS transition `transform: scaleX` | `FluentTheme/Styles.axaml:137-145` |
| B16 | 强提醒涟漪扩散 | 提醒开始(特效开启) | `scale` | 0 → 1 | 600ms | `QuadraticEaseIn` | 无 | WAAPI `transform`,原点=岛中心 | `RippleEffect.axaml.cs:86-96` |
| B17 | 强提醒涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | `SineEaseIn` | 无 | WAAPI `opacity` | `RippleEffect.axaml.cs:102-106` |
| B18 | 强提醒结束回调 | 特效完成 | — | — | 总 750ms | — | 600ms 动画后等 150ms | `setTimeout(750)` 后移除特效元素 | `RippleEffect.axaml.cs:108-109`;`TopmostEffectWindow.axaml.cs:90-98` |
| B19 | 逐字滚动提醒 | 提醒内容超宽 | `offset.x` | 右边界 → 左边界循环 | `Duration / RepeatCount` | 线性 | 无 | WAAPI 无限循环,每帧线性位移 | `RollingTextTemplate.axaml.cs:39-62`;`RollingTextTemplateData.RepeatCount` 默认 2 |
| B20 | 滚动提醒重排修正 | 容器尺寸变化 | `offset.x` | 按当前进度重算 | 即时重播 | 线性 | 无 | 重新 `startAnimation`(源码在 LayoutUpdated 重设) | `RollingTextTemplate.axaml.cs:75-80` |
### 14.3 C. 启动、OOBE、欢迎动效
启动链路是「闪屏 → 主界面」两段式:闪屏自身有滑入与进度动画,主界面出现时行/组件按动画级别淡入(A2/A4)。OOBE 是唯一允许使用 3 秒级长动画的场景,必须整段可跳过(用户点击任意处立即进入下一步),且在 `AnimationLevel=0` 时直接呈现终态(`OobeIntroAnimationControl.axaml.cs:161-164` 只在 Loaded 时播放;实现时须先判断级别)。
OOBE 的字符动画是「11 个方块 + 11 个字符」双轨并行的复杂编排,必须按源码公式逐字计算延迟(C6),不得改成固定 50ms 等间隔——源码用正弦分布让中部字符更密、两端更疏(`OobeIntroAnimationControl.axaml.cs:37`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| C1 | 闪屏 Logo 滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | `ExponentialEaseInOut`≈`(0.16,1,0.3,1)`【推断】 | 0ms | WAAPI transform | `SplashWindow.axaml.cs:101-119` |
| C2 | 闪屏状态栏滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | 同上 | 150ms | WAAPI transform | `SplashWindow.axaml.cs:53-54,109-119` |
| C3 | 闪屏淡入 | 闪屏启动 | `opacity` | 0 → 1 | 300ms | `CubicEaseOut` | 与 C1/C2 同延迟 | WAAPI opacity | `SplashWindow.axaml.cs:114-120` |
| C4 | 闪屏进度条 | 启动进度更新 | `translateX` | `-400·(1-p)` 到目标 | `max((Δp)/8, 0.5)s`;结束时 150ms | `CubicEaseOut` | 无 | WAAPI transform | `SplashWindow.axaml.cs:64-82,126` |
| C5 | OOBE 整场进场 | 欢迎页加载 | `scale`、`opacity` | 1.25 → 1.0;0 → 1 | 3000ms | `(0,0.83,0.17,0.90)` | 无 | CSS animation `both` | `OobeIntroAnimationControl.axaml:14-30` |
| C6 | OOBE 字符错峰翻入 | 进场后逐字 | `translateY`、`opacity` | 50 → 0;0 → 1 | `delay·9·1.5 + 750` ms | `(0.25,1,0.5,1)`(入) | 逐字延迟 `sin((i+2)/(n+2)·π/2)·500/n` ms | WAAPI 逐元素动画,串行 await | `OobeIntroAnimationControl.axaml.cs:30-54,65-124` |
| C7 | OOBE 字符翻出 | 同一字符动画后段 | `rotate3D.angleX`、`opacity` | 0 → 90°;1 → 0 | 同上 | `(0.32,0,0.67,0)` | 内嵌于 C6 关键帧 | WAAPI 关键帧(0%/timeMs/timeMs+750/100%) | `OobeIntroAnimationControl.axaml.cs:92-118` |
| C8 | OOBE 文字块翻入 | 字符对转向后 | `rotate3D.angleX`、`opacity` | -90° → 0;0 → 1 | `delay·9·2 + 750` ms | `(0.33,1,0.68,1)` | 内嵌 | WAAPI 关键帧 | `OobeIntroAnimationControl.axaml.cs:126-158` |
| C9 | OOBE 方块放大 | 第 7 个方块 | `scaleX`、`background` | 1 → 2.17;灰 → `#00BFFF` | 250ms | `(0.76,0,0.24,1)` | 延迟 307ms | CSS animation | `OobeIntroAnimationControl.axaml:44-58` |
| C10 | OOBE 文字收拢 | 动画末尾 | `gap`/`letter-spacing`、`min-width` | 4px → 0;32px → 0 | 700ms | `(0.33,1,0.68,1)` | 无 | CSS animation | `OobeIntroAnimationControl.axaml:88-116` |
| C11 | 欢迎页内容淡入 | 欢迎页显示 | `opacity` | 0 → 1 | 200ms | 默认 | 无 | CSS animation `both` | `Views/WelcomePages/WelcomePage.axaml:11-24` |
| C12 | 欢迎页 Logo 布局 | 欢迎页显示 | 尺寸 | 80×80 | — | — | — | 静态,非动画 | `WelcomePage.axaml:29-33` |
### 14.4 D. 弹层、抽屉、列表与设置动效
本组是「应用外壳」动效,必须复用 ClassHelper 既有 token 与 Element Plus 覆盖层(`renderer/styles/index.css:567-756` 已把弹窗/抽屉/浮层/消息条的过渡统一替换为弹簧与 `--ch-ease-out`)。实现时**不得**再写第二套弹窗动画;新弹层一律继承既有 class。
列表错峰(D12)必须是「真实逐项启动」而不是一次性 `animation-delay` 批量:源码用 25ms 间隔的 DispatcherTimer 逐项置位(`StackPanelIntroAnimationBehavior.cs:74-90`),逐项启动才能保证「列表在动画过程中被重新排序/过滤」时新项不等待旧延迟。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| D1 | Popup 淡入 | 任意 Popup 打开 | `opacity` | 0 → 1 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI(`AnimationLevel≥1`) | `PopupIntroAnimationBehavior.cs:73-78` |
| D2 | Popup 缩放淡入 | 同上 | `scale` | 0.925 → 1.0 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI;`transform-origin` 按 placement 决定(Bottom→上中、Right→左中、Pointer→左上、Center→中心) | `PopupIntroAnimationBehavior.cs:80-112` |
| D3 | 横抽屉推入 | 抽屉打开 | `translateX`、`opacity` | 抽屉宽 → 0;0 → 1 | 250ms | `cubic-bezier(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:42-59` |
| D4 | 横抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:52-58,68-70` |
| D5 | 竖抽屉推入 | 抽屉打开/收起 | `translateY`(Canvas.Bottom)、`height` | 关闭高度 → 0 | 250ms | `(0,0,0,1)` | 无 | CSS transition(用 transform 代替 bottom/height) | `VerticalDrawer.axaml:29-36,136-155` |
| D6 | 竖抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `VerticalDrawer.axaml:126-134,150-153` |
| D7 | 图标按钮展开 | 展开/收起 | `width`、文字 `opacity` | 图标宽 ↔ 全宽;0 ↔ 1 | `Duration` 属性(默认【推断】200ms) | `SineEaseOut`(宽)/`SineEaseInOut`(文字) | 无 | CSS transition | `AnimatedIconButton.axaml:22-46` |
| D8 | 图标按钮底色 | 悬停/主题变化 | `background` | 旧色 → 新色 | 150ms | 默认 | 无 | CSS transition | `AnimatedIconButton.axaml:81-88` |
| D9 | Shimmer 骨架淡出 | 内容加载完成 | `opacity` | 1 → 0(骨架);0 → 1(内容) | 200ms | `(0.65,0,0.35,1)` | 无 | CSS transition | `Shimmer.axaml:22-38,43-54` |
| D10 | 列表滑入 | 选中项变化 | `translateY`、`opacity` | -40 → 0;0 → 1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `SlidingListBox.axaml:38-55` |
| D11 | 列表滑出 | 同上 | `translateY`、`opacity` | 0 → 40;1 → 0 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation | `SlidingListBox.axaml:57-74` |
| D12 | 堆叠面板错峰入场 | 面板首次加载 | `translateY`、`opacity` | 50 → 0;0 → 1 | 750ms | `(0,1,0,1)` | **每项间隔 25ms**(DispatcherTimer) | rAF/`setInterval(25ms)` 逐项加 class | `StackPanelIntroAnimationBehavior.cs:74-90`;`StackPanelIntroAnimation.axaml:11-35` |
| D13 | 工作流进度条颜色 | 状态变化 | `background` | 灰 ↔ accent | 500ms | `SineEaseInOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:16-22` |
| D14 | 工作流不确定态呼吸 | 进行中且无进度 | `opacity` | 0.85 → 0.4 → 0.85 | 750ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `WorkflowProgressIndicatorItemControl.axaml:28-44` |
| D15 | 工作流确定性进度 | 有进度值 | `value` | 旧 → 新 | 180ms | `SineEaseInOut` | 无 | CSS transition transform | `WorkflowProgressIndicatorItemControl.axaml:57-67` |
| D16 | 工作流完成淡入 | 完成后 | `opacity` | 0 → 1 | 300ms | `SineEaseOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:63-65` |
| D17 | TeachingTip 箭头循环 | 教学提示显示 | `translateY`、`rotateY` | 0→-8→0;0→180° | 1000ms | `(0.5,1,0.89,1)`/`(0.11,0,0.5,0)`/`(0.66,0,0.34,1)` | 无限 | CSS animation infinite | `MyTeachingTipStyles.axaml:223-251` |
| D18 | 教程高亮入场 | 教程步骤开始 | `margin`、`opacity` | -64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation `both` | `TutorialHighlightControl.axaml:11-24` |
| D19 | 教程高亮呼吸循环 | 教程步骤持续 | `margin`、`opacity` | -64→0→0;0→1→1 | 1750ms | `(0.16,1,0.3,1)` | 延迟 1750ms,无限 | CSS animation infinite | `TutorialHighlightControl.axaml:25-44` |
| D20 | 教程聚光入场 | 教程步骤开始 | `margin`、`opacity` | 64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `TutorialSpotlightControl.axaml:11-35` |
| D21 | 更新图标旋转 | 下载更新中 | `rotate` | 0 → 360° | 1000ms | `(0.5,0.25,0.5,0.75)` | 无限 | CSS animation infinite | `UpdateSettingsPage.axaml:88-99` |
| D22 | 关于页头部淡入 | 页面加载 | `opacity` | 0 → 1 | 200ms | `(0,0,0,1)` | 延迟 250ms | CSS animation `both` | `AboutSettingsPage.axaml:133-147` |
| D23 | 拖拽高亮脉冲 | 拖动组件中 | `opacity` | 0.1 → 0.6 → 0.1 | 1000ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `EditableComponentsListBox.axaml:307-335` |
| D24 | 设置面板显隐 | 子设置展开 | `opacity` | 0 ↔ 1 | 200ms | `(0,1,0,1)` | 无 | CSS transition | `WeatherSettingsPage.axaml:207-214` |
| D25 | 自动化项显隐 | 条件切换 | `opacity` | 0 ↔ 1 | 100ms | 默认 | 无 | CSS transition | `AutomationSettingsPage.axaml:140-142` |
| D26 | 列表项淡入(课表) | 课表项状态变化 | `opacity` | 0 ↔ 1 | 150ms | 默认 | 无 | CSS transition | `LessonsListBox.axaml:69-77` |
### 14.5 E. 组件动效
组件动效的原则是「**动内容不动容器**」:滚动、滑动、倒计时进度都在组件内部完成,绝不触发行高/行宽变化(否则会触发 A5/A6 的行宽过渡,产生连锁抖动)。滚动组件的速度单位是「像素/秒」,必须由设置项驱动并做下限保护(`Math.Clamp(SpeedPixelPerSecond, 1, int.MaxValue)`,`RollingComponent.axaml.cs:96`),暂停时长默认 10 秒,暂停位置默认 0(`RollingComponentSettings.cs`)。
时钟、天气图标、时间线色块**不做**动画:它们是高频更新的信息元素,任何过渡都会让「时间在走」变得可感知延迟。源码只在字号/颜色上做静态样式(`ClockComponent.axaml:14`、`WeatherComponent.axaml:261-305`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| E1 | 滚动组件滚动 | 内容宽于容器 | `translateX` | 0 → -(W+offset) → 容器宽 → 0 | `(暂停+滚动)` 秒;速度 40px/s | 线性 | 暂停 10s(默认) | WAAPI 关键帧无限循环 | `Controls/Components/RollingComponent.axaml.cs:85-135`;`RollingComponentSettings.cs`(`SpeedPixelPerSecond=40`、`PauseSeconds=10`) |
| E2 | 滚动组件重置 | 停止滚动/规则命中 | `translateX` | 归零 | 1s(空动画占位) | 线性 | 无 | 用 1s 静止关键帧覆盖旧动画 | `RollingComponent.axaml.cs:137-157` |
| E3 | 滚动组件停止规则 | 规则命中 | 滚动状态 | 滚动 → 停止 | 即时 | — | 无 | 取消动画并归位 | `RollingComponent.axaml.cs:171-179` |
| E4 | 滑动组件切换 | 选中项变化 | `translateY`、`opacity` | -40→0 / 0→40;0↔1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `Controls/Components/SlideComponent.axaml:69-107` |
| E5 | 滑动组件禁用动画 | 设置关闭动画 | — | 立即切换 | 0ms | — | — | 关闭时直接切 `display` | `SlideComponent.axaml:51-53` |
| E6 | 倒计时进度 | 计时更新 | 进度值 | 旧 → 新 | 50ms(同 A12) | 线性 | 无 | CSS transition;降低精度时按 `max(10, 总秒/500)` 取整 | `CountDownComponent.axaml.cs:288-295`;`Settings.cs:850` |
| E7 | 时钟秒变化 | 秒变化 | 文本内容 | — | 无动画 | — | — | 直接替换文本(不要做数字翻转动画) | `ClockComponent.axaml:14`(仅字号) |
| E8 | 天气图标切换 | 天气数据更新 | 图标字形 | 旧 → 新 | 无动画 | — | — | 直接替换(4 套模板,见 part 15) | `Controls/WeatherIcons/*.axaml` |
| E9 | 时间线色块 | 时间点类型 | 颜色/高度 | 类型色 | 无动画 | — | — | 静态 | `Themes/Generic.xaml:65-128` |
| E10 | 时间线装饰器出现 | 悬停/选中 | 透明度 | 0 → 1 | 200ms【推断】 | 默认 | 无 | 由行为控制(`TimeLineListItemBehavior`),Web 用 CSS transition | `Themes/Generic.xaml:190-290` |
| E11 | 提醒遮罩分块绘制 | 遮罩打开 | 5 段平行四边形宽度 | 0 → 全宽 | 同 B1 | 同 B1 | 同 B1 | canvas 绘制(每帧按进度重绘) | `SlantedMaskControl.cs:273-343` |
### 14.6 F. ClassHelper 既有动效(必须复用,不得重造)
ClassHelper 已经沉淀了一套完整动效体系,**必须原样复用**,只在「ClassIsland 特有场景」上做增量:
| 既有能力 | 位置 | 复用方式 |
|---|---|---|
| 动效 token 注入 | `packages/shared/src/motion.ts` → 两端 `composables/motion.ts` | 在 `app.mount()` **之前**写入 `:root`(首帧即就位,无 FOUC) |
| 弹簧曲线转 CSS `linear()` | `motion.ts:152-164` | 直接调用 `springLinearEasing()`,禁止手写采样点 |
| 减少动态效果 | `composables/motion.ts:53-72` | 主界面/管理中心统一使用 `useReducedMotion()` |
| 悬停能力检测 | `composables/motion.ts:81-99` | 所有 hover-only 效果必须先过 `useHoverCapable()`(教室触摸屏幽灵 hover) |
| 数字滚动 | `composables/motion.ts:129-167` | 倒计时/统计数字直接复用 `useCountUp()` |
| 错峰入场 | `composables/motion.ts:175-179` | 列表/卡片直接复用 `useStaggerIn()` |
| 整页换肤 | `composables/motion.ts:201-237` | 应用主题切换复用 `useThemeReveal()` |
硬约束:**不得**在岛渲染进程里引入主渲染进程的 `--ch-*` 动效 token(ClassHelper 明确「岛是另一个渲染进程,刻意不参与这里的动效体系」,`motion.ts:29-31`)。岛必须使用自己的 `--island-*` 与 `SpringValue`。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| F1 | 岛尺寸弹簧 | 模式/尺寸变化 | `width`、`height`、`radius` | 当前 → 目标 | 由物理积分决定 | 弹簧 `stiffness=0.10`、`damping=0.68` | 无 | 每帧 `SpringValue.step()`,60fps 单位 | `ClassHelper/src/island/spring.ts:26-114`;`IslandApp.vue:216-322` |
| F2 | 岛换向限速 | 目标方向反转 | `velocity` | 反向保留 35%,帧位移 ≤ 剩余 20% | 每帧 | — | 无 | `redirectVelocity` | `spring.ts:66-82` |
| F3 | 岛零过冲钳制 | 越过目标 | `value`、`velocity` | 吸附目标、速度清零 | 即时 | — | 无 | `step()` 内判定 | `spring.ts:100-107` |
| F4 | 岛淡入(窗口) | 窗口显示 | 窗口 `opacity` | 当前 → 目标 | 每帧 +0.16,16ms 步进(约 60–110ms) | 线性 | 无 | 主进程 `setOpacity` 定时步进 | `src/main/island.ts:1674-1691` |
| F5 | 岛淡出(窗口) | 窗口隐藏 | 窗口 `opacity` | 当前 → 0 | 每帧 -0.2,16ms 步进(约 50–80ms) | 线性 | 无 | 主进程 `setOpacity`,到 ≤0.02 后 `hide()` | `island.ts:1693-1715` |
| F6 | 岛显示方式 | 显示 | 焦点 | `showInactive()` 不抢焦点 | 即时 | — | 无 | 主进程 | `island.ts:1426,1458,1490` |
| F7 | 弹窗弹簧入场 | 弹窗打开 | `transform`、`opacity` | `translateY(-12px) scale(.97)` → 0/1 | `SPRING_PANEL` 自然落定(约 463ms) | 弹簧 `linear()` 或 `cubic-bezier(.16,1,.3,1)` 回退 | 无 | CSS animation | `index.css:596-605,634-636`;`motion.ts:52,220-230` |
| F8 | 弹窗快速退出 | 弹窗关闭 | `transform`、`opacity` | → `translateY(-6px) scale(.985)` | 140ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `index.css:607-616,642-644` |
| F9 | 抽屉弹簧 | 抽屉打开/关闭 | `transform`、`opacity` | 侧边 ↔ 0 | `SPRING_PANEL` 463ms / `--ch-dur-base` 200ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:699-703` |
| F10 | 浮层缩放 | 下拉/选择器 | `transform`、`opacity` | `scale(.95)` → 1 | `SPRING_SWAP` 约 263ms | 弹簧 | 无 | CSS transition | `index.css:710-737`;`motion.ts:50,228-229` |
| F11 | 消息条进出 | 消息出现/消失 | `transform`、`opacity`、`top/bottom` | 位移 | 弹簧 263ms / 140ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:739-756` |
| F12 | 按钮按压 | 按下/抬起 | `transform: scale` | 1 → 0.97 → 1 | 按下 90ms;抬起 `SPRING_PRESS` 约 194ms【推断】 | 按下 ease-out;抬起弹簧 | 无 | CSS transition(按下与基础规则分离) | `index.css:769-782`;`motion.ts:48,178` |
| F13 | 卡片抬升 | 悬停(仅 hover 设备) | `transform: translateY`、`box-shadow` | 0 → -1px | 263ms | 弹簧/ease-out | 无 | CSS transition,**必须**包在 `@media (hover:hover) and (pointer:fine)` 内 | `index.css:791-802` |
| F14 | 列表错峰入场 | 列表渲染 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 每项 35ms,第 9 项起封顶 | CSS animation + 内联 `--ch-reveal-delay` | `index.css:814-828`;`motion.ts:175-179` |
| F15 | 页面块错峰入场 | 路由切换 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 30ms/块,封顶 180ms | CSS animation `nth-child` | `index.css:846-883` |
| F16 | 整页换肤扩散 | 切换主题 | `clip-path: circle()` | 0px → 最远角半径 | 480ms(320×1.5) | `(0.16,1,0.3,1)` | 无 | View Transition API + WAAPI | `composables/motion.ts:201-237` |
| F17 | 侧栏折叠 | 折叠/展开 | `width`、`gap` | 全宽 ↔ 64–80px | 260ms | `cubic-bezier(0.4,0,0.2,1)` | 无 | CSS transition | `index.css:56-75` |
| F18 | 减少动态效果 | 系统偏好 | 全部时长 | → 1ms | 1ms | — | — | 全局媒体查询覆盖(加载指示器例外,放慢到 1.6s) | `index.css:904-930` |
### 14.7 实现方式规范(必须遵守)
1. **优先使用合成器友好属性**:只对 `transform` 与 `opacity` 启 CSS transition/WAAPI。源码中 `BackgroundWidth`(A5/A6)与 `Margin`(D18/D19)属于布局属性,Web 实现必须做等价转换:
- 行宽过渡:背景层绝对定位于内容层之下,宽度用 `clip-path: inset(0 calc(100% - var(--w)) 0 0 round <r>)` 或 `transform: scaleX()`(配合 `transform-origin: left`)实现,**不得**对包含文字的容器直接 `transition: width`(会导致逐帧重排)。
- 教程高亮:把 `margin` 动画换成 `transform: scale()`(围绕中心放大)或 `inset` 的 `clip-path` 动画,视觉等价。
2. **弹簧动画**:岛尺寸/圆角必须用 ClassHelper 的 `SpringValue`(F1–F3),不得改用 CSS 弹簧曲线;两条路径不能混用(ClassHelper `motion.ts:29-33` 明确「岛是另一个渲染进程,不参与本动效体系」)。
3. **弹簧曲线用于 CSS 时**:过冲曲线只能用于 `transform`/`clip-path`,**禁止用于 `opacity`**(会被裁剪到 [0,1],观感变成「到顶就停」)(`motion.ts:148-151`)。
4. **`filter` 禁忌**:卡片类元素禁止用 `filter: drop-shadow` 做投影(会新建渲染表面,开合瞬间闪帧),必须用 `box-shadow`(`classic-island.css:114-120`)。
5. **`will-change`**:只允许在动画进行中的元素上临时添加 `will-change: transform, opacity`,动画结束(`animationend`/`transitionend`)立即移除;禁止全局常驻。
6. **禁止动画的属性**:`z-index`、`display`(用 `visibility`/`opacity` 或先 `display` 后下一帧启动)、`pointer-events`。需要「显隐 + 动画」时用双 rAF 或 `requestAnimationFrame` 起手。
7. **中断语义**:任何进场动画被反向触发时必须能平滑打断(`FillMode.Both/Forward` 对应 `animation-fill-mode`;用 `getAnimations().forEach(a => a.cancel())` 清理旧动画)。
8. **统一调度**:动画必须在 `AnimationLevel=0` 时统一短路(除主界面提醒);同一时刻同一元素不得叠加两条竞争动画。
9. **帧率与预算**:岛内动画(弹簧步进)必须跑在独立渲染进程的 rAF 上;主渲染进程的 60fps 预算为 16.7ms,任何单帧主线程任务禁止超过 8ms;动画期间不得读取 `offsetWidth` 等强制同步布局属性。
10. **禁止「看起来像动画的定时器」**:所有时长必须来自 14.0.3 常量表,不得在组件里写 `300`、`0.3s` 之类字面量。
#### 14.7.1 几何动画的 FLIP 流程(必须按此实现)
源码大量动画布局属性(宽度、间距、边距),Web 端必须用 FLIP 换算成合成器动画:
1. **First**:在状态改变前读取元素 `getBoundingClientRect()` 并缓存(只在必要时读一次,避免强制同步布局)。
2. **Last**:改完 class/数据后,下一帧再读一次新几何。
3. **Invert**:计算 `dx = old.left - new.left`、`dy = old.top - new.top`、`sx = old.width / new.width`,对元素设置 `transform: translate(dx,dy) scale(sx)` 并临时关掉 transition。
4. **Play**:强制一次重排(读 `offsetHeight` 或 `getBoundingClientRect`),再恢复 transition 并清空 transform,让浏览器从「反演位置」过渡到「最终位置」。
FLIP 的硬性要求:整个流程必须在**同一帧内**完成读取与写入的分离(先批量读、再批量写),否则会触发多次回流;对列表批量移动时必须逐个元素做,不得在父容器上做 scale(会把文字一起拉伸)。
#### 14.7.2 行宽过渡的推荐实现(替换源码的 BackgroundWidth 动画)
源码动画的是背景层 `Width`(`FluentTheme/Styles.axaml:81`、`ClassicTheme/Styles.axaml:63`),因为背景层只承载底色与描边,不承载文字。Web 端必须照此分层:
```html
<div class="island-line" style="--line-w: 420px; --line-r: 8px">
<div class="island-bg"></div> <!-- 绝对定位;只动画这一层 -->
<div class="island-content">…</div>
</div>
```
```css
.island-bg{
position:absolute; inset:0 auto 0 0; width:var(--line-w);
border-radius:var(--line-r); background:var(--ci-island-bg);
transition: width var(--ci-line-w-dur) var(--ci-line-w-ease);
will-change: width; /* 仅动画期间由 JS 加上 */
}
```
- Fluent:`--ci-line-w-dur:300ms; --ci-line-w-ease:cubic-bezier(.65,0,.35,1)`。
- 经典:`--ci-line-w-dur:550ms; --ci-line-w-ease:cubic-bezier(0,1,0.5,1.1)`。
- 内容层宽度变化必须瞬时(不加过渡),否则文字会在动画中抖动。
- 背景层宽度变化会触发布局,但因为它只有底色、且 `contain: strict` 可限制影响范围,实测可接受;若追求纯合成器动画,可改用 `clip-path: inset(0 calc(100% - var(--line-w)) 0 0 round var(--line-r))`,但圆角与描边的贴合需要额外验证。
#### 14.7.3 斜切遮罩的 canvas 实现要点
若选择 canvas 复刻 `SlantedMaskControl`:
- 每个区段的几何:中心点 `rCenter`、当前宽度 `currentW = fullW × progress`、斜切偏移 `offset = tan(30°) × h`(`SlantedMaskControl.cs:281-343`)。
- 绘制顺序:区 0/4(两端)→ 区 1/3 → 区 2(中间),与 B1 的交错延迟一致。
- 进度驱动:`requestAnimationFrame` 中累加时间,按 `progress = easeOutQuart(t)` 重算并 `ctx.clearRect` + 重绘;`RegionDurationMs=360`、`StageStaggerMs=120` 必须可配置(主题可覆盖,见 part 09 的 `--ci-island-mask-*`)。
- 绘制完成后必须停止 rAF,不得常驻渲染循环;遮罩关闭时按 B5–B7 反向播放。
- 若用 DOM/SVG 实现,5 个 `polygon` 的 `points` 用 CSS 变量驱动 `clip-path`,但注意 `clip-path` 的百分比无法表达斜切角,必须用 `path()` 或 `polygon()` 的绝对像素值并在 resize 时重算。
#### 14.7.4 动效的测试钩子(必须提供)
为便于验收,实现必须暴露以下测试接口(仅开发构建可用):
- `window.__ciMotion.getState(id)`:返回某动画的 `{playing, elapsed, duration, easing}`。
- `window.__ciMotion.list()`:返回当前所有运行中动画的 id 与元素选择器。
- `window.__ciMotion.setLevel(0|1|2)`:运行时切换动画级别。
- `window.__ciMotion.disable()`:等价 `prefers-reduced-motion`。
这些钩子必须不影响生产构建(通过 `import.meta.env.DEV` 或独立 dev 入口注入)。
### 14.8 与源码的差异说明(必须写进实现注释)
- 源码用 Avalonia `Transitions`(属性级隐式动画)与 `Animation`(关键帧)两套机制;Web 端对应 `transition` 与 `@keyframes`/WAAPI。二者的「延迟 + 填充模式」语义必须一一对应,特别是 `FillMode="Both"`(对应 `animation-fill-mode: both`),漏掉会在延迟期间闪出终态(`StackPanelIntroAnimation.axaml:22`、`FluentTheme/Styles.axaml:332`)。
- 源码 `ClassicTheme` 的行宽过渡带过冲(`1.10`),这是「经典主题手感」的一部分,**不得**为了「统一」改成 Fluent 的曲线。
- 源码 `SlantedMaskControl` 用 `canvas` 逐帧重绘 5 段平行四边形(`SlantedMaskControl.cs:273-343`),Web 端可以用 canvas 2D 或 5 个 `clip-path` 的多边形,但**分块权重必须保持** `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]`(`SlantedMaskControl.cs:298`),且斜切角固定 60°(偏移 `tan(30°)·h`,`:284`)。
- 源码的时间轴用 `DispatcherTimer(25ms)` 驱动列表项逐个入场(`StackPanelIntroAnimationBehavior.cs:74-90`);Web 端用 `setTimeout` 链或 `requestAnimationFrame` 时间片,间隔仍然 25ms,不得改成 CSS `animation-delay` 批量(会与「列表顺序变化」脱节)。
#### 14.8.1 性能预算与降级矩阵
| 场景 | 目标帧率 | 单帧主线程预算 | 降级顺序 | 说明 |
|---|---|---|---|---|
| 岛弹簧(独立渲染进程) | 60fps | 该进程 8ms | ① 降低步进频率到 30fps ② 关闭圆角形变只动尺寸 ③ 直接落位 | 必须在独立进程,不得与主界面共用事件循环 |
| 提醒遮罩(canvas 重绘) | 60fps | 4ms | ① 减到 3 段 ② 降到 30fps ③ 改为纯透明度过渡 | 重绘面积大,必须先算好路径再绘制 |
| 列表错峰入场 | 60fps | 8ms | ① 只动画 opacity ② 减少同屏项数 ③ 直接显示 | 禁止对每项做 FLIP |
| 窗口级淡入淡出(主进程) | 30fps 足够 | — | 直接落位 | 主进程 `setOpacity` 步进不占渲染线程 |
| 整页换肤(View Transition) | 60fps | 16ms | ① 缩短到 320ms ② 关掉圆形扩散改交叉淡入 ③ 直接切换 | 文档不可见时必须同步切换(`motion.ts:195-200`) |
降级必须是**自动**的:连续 30 帧低于 45fps 时自动降一级,并在 5 秒后尝试恢复;降级不得改变最终视觉终态,只改变过程。
#### 14.8.2 可访问性要求
1. **减少动态效果**:必须响应系统偏好,且提供应用内「动画级别=禁用」作为等效手段(两者任一命中即降级)。
2. **不得用闪烁传达信息**:任何闪烁频率必须低于 3Hz;源码没有任何闪烁动画,实现也不得新增。
3. **动画不得是唯一反馈**:按钮按压、开关切换等必须同时有颜色/状态变化,不能只靠缩放。
4. **焦点不丢**:动画期间焦点元素不得被移除;抽屉/弹窗关闭后焦点必须回到触发按钮。
5. **文字可读**:动画过程中文字不得低于 4.5:1 对比度(遮罩淡入时文字必须等遮罩底色到位后再出现,源码用延迟 260ms 实现,见 B2)。
6. **超大字号**:用户把 4 档字号调到 40px 时,所有含固定高度的动画容器必须允许内容溢出或自适应,不得裁切文字。
### 14.9 验收标准
1. **逐条对照**:本节 A1–A12、B1–B20、C1–C12、D1–D26、E1–E11、F1–F18 共 **99 条**动效全部实现;抽测任意 10 条,时长误差 ≤ 1 帧(16.7ms),曲线肉眼无差异。
2. **动画级别**:切到「禁用」后,抽屉/Popup/列表错峰动画不再播放(主界面提醒动画保留);切到「华丽」后行淡入与组件淡入出现;切回「标准」后二者消失。
3. **减少动态效果**:系统开启 `prefers-reduced-motion` 后,全部动画在 1 帧内完成,页面不出现闪烁或「终态先行」;加载指示器仍在转(放慢至 1.6s)。
4. **打断测试**:连续快速开关同一抽屉/Popup/遮罩 10 次,无卡死、无重影、无「停在半路」;遮罩快速 in→out 切换后元素状态正确复位。
5. **性能**:动画期间主线程帧耗时 P95 < 8ms、掉帧 < 2 帧/秒;岛弹簧动画在独立渲染进程内不掉帧(对照 ClassHelper 的实现位置)。
6. **填充模式**:所有带延迟的动画在延迟期间不显示终态(检查 D12、D18、B2、C6)。
7. **曲线一致性**:`--ci-ease-*` 只在一处定义;`grep` 组件样式不得出现 `cubic-bezier(` 字面量(动画表定义处除外)。
8. **层叠与裁剪**:遮罩动画期间文字不被裁切、圆角外的像素不泄漏;分体主界面下每行动画独立,不出现整排一起抖动。
9. **焦点与命中**:动画期间点击穿透状态正确(非交互态不拦截点击,交互态可点);窗口淡出后不残留不可见但可点击的层。
10. **可复现**:同一操作重复 20 次,动画起始/结束时刻的视觉状态一致,无累积误差(尤其是 A5/A6 的行宽与 E1 的滚动偏移)。
+314
View File
@@ -0,0 +1,314 @@
## 15. 资源清单(图标 / 字体 / 音效 / 天气图标 / 教程 / 本地化)
本节要求实现者**按清单准备并放置资源**,不得缺项、不得自行替换为网络图片。所有路径均以 ClassIsland 仓库为参照;实现时统一放到 `packages/desktop-client/src/renderer/assets/`(渲染进程资源)与 `packages/desktop-client/resources/`(打包资源)两处,并通过构建脚本生成多尺寸产物(见 15.9)。
### 15.1 应用图标
#### 15.1.1 主图标(多尺寸 PNG)
| 文件 | 尺寸 | 用途 | 证据 |
|---|---|---|---|
| `Assets/AppLogo.png` | 192×192 | 应用内窗口图标、关于页、欢迎页 Logo | `ClassIsland/Assets/AppLogo.png`;`App.axaml:29-31`;`WelcomePage.axaml:29-33` |
| `Assets/AppLogo@48w.png` | 48×48 | 小尺寸(任务栏/列表) | 实测文件头 |
| `Assets/AppLogo@384w.png` | 384×384 | 中尺寸(设置页/关于页展示) | 实测文件头 |
| `Assets/AppLogo@768w.png` | 768×768 | 大尺寸(高分屏) | 实测文件头 |
| `Assets/AppLogo_Fade.png` | 192×193 | 淡入用的半透明 Logo(启动/遮罩) | 实测文件头 |
| `Assets/AppLogo_Monochrome.png` | 2715×2003 | 单色 Logo(水印/背景纹样) | 实测文件头 |
| `Assets/AppLogo_AppLogo.svg` | 矢量 | 可缩放图标源 | 文件名 |
| `Assets/AppLogo.ico` | 多尺寸 ICO:16/24/32/48/64/256×2 | Windows 可执行文件图标 | 解析 ICO 头得 7 个条目 |
| `Assets/FreedesktopIcons/AppLogo@128w.png` | 128×128 | Linux Freedesktop 图标 | 实测文件头 |
| `AppLogo@32w.png`(`ClassIsland.Desktop/Assets/`) | 32×32 | Windows 任务栏/托盘小图标 | 实测文件头 |
#### 15.1.2 macOS 图标集(`ClassIsland.Desktop/Assets/AppLogo.xcassets/AppIcon.appiconset/`)
必须提供:16×16(1x/2x)、32×32(1x/2x)、128×128(1x/2x)、256×256(1x/2x)、512×512(1x/2x)、以及无后缀的 192/384/768 三档。实测目录内共 16 个 PNG,命名遵循 `AppLogo_<尺寸>_<倍率>.png` 与 `AppLogo@<宽>w.png` 两种规则,实现时按 `electron-builder` 的 `icon.icns` 要求重新生成即可,但**必须保留这两个命名族**以免资源引用失效。
#### 15.1.3 Windows 打包瓦片(`ClassIsland.Packaging/Images/`)
| 资源 | 尺寸(100%/200%/400%) | 用途 |
|---|---|---|
| `Square44x44Logo` | 44 / 88 / 176 | 开始菜单小图标 |
| `Square44x44Logo.targetsize-16/24/48/256` | 16/24/48/256 | 任务栏/Alt-Tab 变体 |
| `Square150x150Logo` | 150 / 300 / 600 | 开始菜单中等图标 |
| `Wide310x150Logo` | 310×150 / 620×300 / 1240×600 | 宽幅磁贴 |
| `LargeTile` | 310 / 620 / 1240 | 大磁贴 |
| `SmallTile` | 71 / 142 / 284 | 小磁贴 |
| `StoreLogo` | 50 / 100 / 200 | 商店图标 |
| `SplashScreen` | 620×300 / 1240×600 / 2480×1200 | 系统级启动图(与自绘闪屏无关) |
| `LockScreenLogo` | 48×48 | 锁屏 |
(以上均为实测文件头尺寸;`ClassIsland/Images/` 下另有一份 `.scale-200` 版本,实现时只保留打包用的那份即可。)
#### 15.1.4 其他图标
| 资源 | 尺寸 | 用途 |
|---|---|---|
| `Assets/Marketplace/Logo.png` | 512×512 | 插件/主题市场 Logo |
| `Assets/Marketplace/Logo@256w/128w/64w.png` | 256/128/64 | 市场图标多尺寸 |
| `Assets/Marketplace/Logo.svg` | 矢量 | 市场矢量源 |
| `ClassIsland.ExamplePlugin/icon.png` | 510×510 | 示例插件图标(插件必须自带 510×510 图标) |
| `ClassIsland.Launcher/Assets/AppLogo.ico` | 18 条目 ICO(16–256) | 启动器图标 |
验收要求:所有图标必须**本地打包**、离线可用;PNG 必须由同一矢量源派生,不得出现「不同尺寸风格不一致」;ICO 必须内嵌 16/24/32/48/64/256 六档。
### 15.2 字体
#### 15.2.1 正文/界面字体:HarmonyOS Sans SC
| 文件 | 字重 | 用途 | 证据 |
|---|---|---|---|
| `Assets/Fonts/HarmonyOS_Sans_SC_Light.ttf` | 300 | 轻量标题/水印 | 文件名 |
| `Assets/Fonts/HarmonyOS_Sans_SC_Regular.ttf` | 400 | 正文 | 文件名 |
| `Assets/Fonts/HarmonyOS_Sans_SC_Medium.ttf` | 500 | 默认字重(岛内主文字) | `Settings.cs:101` |
| `Assets/Fonts/HarmonyOS_Sans_SC_Bold.ttf` | 700 | 标题/强调 | 文件名 |
- 字体族注册名:`HarmonyOS Sans SC`;全局资源键 `AppFont`,字体栈见 part 03 §3.2.1(`Assets/Fonts/Fonts.axaml:3`;`Assets/Fonts/Fonts.xaml:3`)。
- **许可**:`Assets/Fonts/LICENSE.txt` 为「HarmonyOS Sans Fonts License Agreement」,版权归华为;分发时必须**原样保留**该许可文件,且不得单独售卖字体。应用本体为 GPL-3.0(`LICENSE.txt`),字体许可与代码许可分离。
- 实现要求:把 4 个 ttf 放进 Electron 打包资源,`@font-face` 声明 4 个 `font-weight`,`font-display: swap`;**禁止**声明 `font-display: block`(会白屏到字体加载完)。
#### 15.2.2 图标字体(三套,必须全部内置)
| 字体 | 文件 | 大小 | 附带映射 | 用途 | 证据 |
|---|---|---|---|---|---|
| Fluent System Icons | `ClassIsland.Core/Assets/Fonts/FluentSystemIcons-Resizable.ttf` | 1,472,748 B | `FluentSystemIcons-Resizable.json`(262,830 B) | 全局 UI 图标(Segoe Fluent 风格) | `AppBase.cs:142`;`ThemeBase.axaml:46` |
| Lucide | `ClassIsland.Core/Assets/Fonts/lucide.ttf` | 715,192 B | `lucide.json`(45,030 B) | 天气图标(默认)、部分 UI 图标 | `AppBase.cs:147`;`ThemeBase.axaml:47` |
| SF Symbols | `ClassIsland.Core/Assets/Fonts/SF-Symbols.ttf` | 174,616 B | — | 天气图标(Apple 风格) | `AppBase.cs:152` |
- 字体族名分别为 `FluentSystemIcons-Resizable`、`lucide`、`SF-Symbols`;Avalonia 资源 URI 形如 `avares://ClassIsland.Core/Assets/Fonts/#lucide`,Web 实现改为 `@font-face { font-family:'lucide'; src:url('lucide.ttf') }` 并保留同名 `font-family`。
- JSON 映射文件必须作为图标选择器数据源(Codepoint/名称表),不得只带 ttf 不带 json——图标选择器需要按名称搜索。
- 许可:应用整体 GPL-3.0;Fluent System Icons 与 Lucide 通常为 MIT/ISC【推断:仓库内未放独立许可文件,按上游项目许可执行】;SF Symbols 字体来自 Apple 符号集,**仅允许在 Apple 平台或已获授权场景使用**,跨平台分发时须评估并在「关于/许可」页声明。
#### 15.2.3 字体相关的硬性要求
1. 字体必须在应用启动**首帧前**注册完成(`document.fonts.ready` 前不得渲染最终布局,可用 `font-display: swap` + 骨架屏规避跳动)。
2. 不得使用系统缺失字体作为唯一来源;`HarmonyOS Sans SC` 必须随包分发。
3. 用户自定义字体(`MainWindowFont`)只允许从**系统已安装字体**中选择(源码 `AppearanceSettingsPage.axaml:409-446` 枚举系统字体),实现时用 `queryLocalFonts()`(需权限)或主进程枚举字体列表,二者都不可用时至少提供内置 4 字重 + 3 图标字体。
4. 字体文件不得被 CSP 阻止:`font-src 'self'` 必须放行本地资源。
### 15.3 音效
| 资源 | 大小 | 用途 | 默认设置 | 证据 |
|---|---|---|---|---|
| `Assets/Media/Notification/1.wav` | 130,012 B | 提醒音效(唯一内置音效) | `IsNotificationSoundEnabled=true`;`NotificationSoundPath=""`(表示使用内置音效);`NotificationSoundVolume=1.0` | 实测文件;`Settings.cs:144-145,1584-1593` |
| 自定义音效 | 用户文件 | 用户可指定本地音频路径 | 支持 wav/mp3 等系统可解码格式【推断】 | `Settings.cs:1496-1505` |
| 语音合成 | 系统/Edge TTS | 提醒朗读 | `IsSpeechEnabled=true`;`SpeechVolume=1.0`;`SpeechSource=0`;Edge TTS 默认音色 `zh-CN-XiaoxiaoNeural` | `Settings.cs:135-139,1406-1428,1452-1461` |
实现要求:
- 音频必须用 `HTMLAudioElement` 或 Web Audio 播放,**不得**在主进程用系统播放器;播放前需用户手势解锁(Electron 可用 `webPreferences.autoplayPolicy: 'no-user-gesture-required'`,但必须在配置中显式声明)。
- 音量 = `NotificationSoundVolume × 系统音量`,范围 0–1;语音音量独立(`SpeechVolume`)。
- 同一条提醒**只播一次**音效(源码用 `MaskSession.HasSoundsPlayed` 去重,`MainWindowLine.cs:746-751`);提醒队列切换时不得重复触发。
- 语音提供方必须可插拔:系统语音(Windows SAPI)、Edge TTS、GPT-SoVITS 三选一(`App.Services.xaml.cs:281-285` 注册三个 provider),默认走系统语音或 Edge TTS,且必须支持「无网络时降级到系统语音」。
- 内置音效 1.wav 必须打包进 `resources/audio/`,不得 CDN 引用。
### 15.4 天气图标(4 套模板)
#### 15.4.1 模板注册表
| 模板 id | 显示名 | 默认 | 实现方式 | 尺寸参数 | 证据 |
|---|---|---|---|---|---|
| `classisland.weatherIcons.lucide` | Lucide(默认) | ✅ `WeatherIconId="classisland.weatherIcons.lucide"` | `Viewbox` + `LucideIcon` | 16×16,FontSize 16,Margin 1 | `App.Services.xaml.cs:287`;`Settings.cs:2106`;`LucideWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.fluentDesign` | Fluent Design | — | `Viewbox` + `FluentIcon` | 20×20,FontSize 24,Margin `0 1.2 0 -1.2` | `App.Services.xaml.cs:288`;`FluentDesignWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.sfSymbols` | SF Symbols | — | `Viewbox` + `SFSymbolsIcon` | 16×16 | `App.Services.xaml.cs:289`;`SFSymbolsWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.simpleText` | 纯文本 | — | 文本 + 分隔符 `~` | 文本随字号 | `App.Services.xaml.cs:290`;`SimpleTextWeatherIconTemplate.axaml` |
实现要求:模板必须可插拔(插件可注册新模板),模板注册信息至少包含 `{id, name, renderer}`;切换模板立即生效且不需要重启(源码 `WeatherService.LoadWeatherIconTemplate()` 在设置变化时重新取模板,`Services/WeatherService.cs:84-90`)。
#### 15.4.2 天气代码 → 名称 → 图标(必须完整映射,共 40 个代码)
数据源:小米天气状态表 `Assets/XiaomiWeather/xiaomi_weather_status.json`;图标映射:`ClassIsland/Converters/WeatherConverters.cs`。下表前端三列(Fluent/Lucide/SF)为源码私有区码位,后端实现时请**原样保留码位**,Web 端用 `data-icon` 名称 + 本地字体渲染。
| code | 天气名 | Fluent 码位 | Lucide 码位 | SF 码位 |
|---|---|---|---|---|
| 0 | 晴 | `\uf465` | `\ue2b1` | `\uFE42` |
| 1 | 多云 | `\uf44f` | `\ue216` | `\uFDC9` |
| 2 | 阴 | `\uf43d` | `\ue217` | `\uFDF1` |
| 3 | 阵雨 | `\uf455` | `\ue2fb` | `\uFDE9` |
| 4 | 雷阵雨 | `\uf46b` | `\ue090` | `\uFDCD` |
| 5 | 雷阵雨并伴有冰雹 | `\uf46b` | `\ue08f` | `\uFDDD` |
| 6 | 雨夹雪 | `\uf459` | `\ue094` | `\uFDD4` |
| 7 | 小雨 | `\uf43f` | `\ue08e` | `\uFDED` |
| 8 | 中雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 9 | 大雨 | `\uf453` | `\ue092` | `\uFDE5` |
| 10 | 暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 11 | 大暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 12 | 特大暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 13 | 阵雪 | `\uf45d` | `\ue376` | `\uFDD9` |
| 14 | 小雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 15 | 中雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 16 | 大雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 17 | 暴雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 18 | 雾 | `\uf443` | `\ue214` | `\uFDE1` |
| 19 | 冻雨 | `\uf461` | `\ue08f` | `\uFDD5` |
| 20 | 沙尘暴 | `\uf441` | `\ue218` | `\uFDB1` |
| 21 | 小雨-中雨 | `\uf453` | `\ue092` | `\uFDED` |
| 22 | 中雨-大雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 23 | 大雨-暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 24 | 暴雨-大暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 25 | 大暴雨-特大暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 26 | 小雪-中雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 27 | 中雪-大雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 28 | 大雪-暴雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 29 | 浮尘 | `\uf449` | `\ue0f4` | `\uFE2E` |
| 30 | 扬沙 | `\uf441` | `\ue0f4` | `\uFE2E` |
| 31 | 强沙尘暴 | `\uf441` | `\ue218` | `\uFE2E` |
| 32 | 飑 | `\uf463` | `\ue093` | `\uFDAD` |
| 33 | 龙卷风 | `\uf463` | `\ue218` | `\uFDA3` |
| 34 | 弱高吹雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 35 | 轻雾 | `\uf443` | `\ue214` | `\uFDE1` |
| 53 | 霾 | `\uf449` | `\ue0f4` | `\uFDE1` |
| 301 | 雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 302 | 雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 99 及未知 | 未知 | `\uee31` | `\ue091` | `\uFD8C` |
实现硬要求:
- 未知代码必须回落到「未知」图标,**不得**抛异常或显示空白。
- 纯文本模板必须实现「今天~明天」的 `~` 分隔符逻辑(模板内 `Separator` 仅在 `To` 场景可见,`SimpleTextWeatherIconTemplate.axaml`),且文本颜色跟随 `--ci-text-*`。
- 天气图标颜色:晴天/温度等常规图标用前景色;雨天图标可选用 `--ci-weather-rain`(亮 `#0000FF` / 暗 `#00BFFF`),见 part 03。
### 15.5 贴纸资源(HoYoStickers)
`Assets/HoYoStickers/` 内共 **22** 个 PNG,按「角色_动作」命名,用于教程/提醒的图标表达式 `sticker(名字)`(`App.axaml.cs:253` 注册 `IconExpressionHelper` 的 `sticker` 处理器)。实现时必须支持 `sticker(帕姆_嗨)` 这种写法并渲染对应 PNG。
| 角色 | 尺寸 | 文件(动作) |
|---|---|---|
| 光辉矢愿 | 162×162(6 个) | `不可以看`、`回眸`、`小喇叭`、`想我了吗`、`瞄准`、`遨游` |
| 帕姆 | 510×510(5 个)/450×450(1 个) | `不可以`、`哭哭`、`嗨`(450)、`注意`、`点赞`、`震惊` |
| 昔涟 | 510×510(4 个) | `回眸`、`守护`、`收到`、`爱` |
| 白厄 | 510×510(6 个) | `再见`、`我吗`、`战斗`、`掉线`、`没事` |
实现要求:贴纸必须按**原始比例**缩放(`object-fit: contain`),不得拉伸;建议在 UI 中统一缩放到 96×96 或 128×128,并保留 `image-rendering: -webkit-optimize-contrast` 以求清晰。图标表达式解析器必须支持 `fluent(码位)`、`lucide(码位)`、`sfsymbols(码位)`、`bitmap(路径)`、`sticker(名字)` 五种前缀(`App.axaml.cs:250-260`),未知前缀回落为文本。
### 15.6 教程资源
#### 15.6.1 教程定义文件(JSON)
内置教程组:`Assets/Tutorials/classisland.getStarted.json`(另有 `classisland.sp.json`、`classisland.test.json` 仅作开发样例,**不要**在正式版启用)。数据结构(逐字段实现):
| 层级 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 顶层 | `Id` | string | 教程组 id,如 `classisland.getStarted` |
| 顶层 | `Title` / `Description` | string | 组标题/描述(中文) |
| 顶层 | `Tutorials` | array | 教程条目列表 |
| 教程 | `Id` / `Title` / `Description` | string | 教程 id/标题/描述 |
| 教程 | `ActualBanner` / `Banner` | string | Banner 资源 URI(`avares://`) |
| 教程 | `Ordinary` | int | 是否为普通教程(0/1) |
| 教程 | `Paragraphs` | array | 段落列表 |
| 段落 | `Id` / `Title` / `Description` | string | 段落标识/标题/描述 |
| 段落 | `TopLevelClassName` | string | 目标窗口类名(如 `ClassIsland.MainWindow`) |
| 段落 | `IsBarrierEnabled` | bool | 是否启用教程遮罩屏障 |
| 段落 | `InitializeActions` | array | 进入段落时执行的行动 |
| 段落 | `Content` | array | 每屏内容 |
| 内容 | `Title` / `Content` | string | 标题/正文(中文) |
| 内容 | `IconExpression` | string | 图标表达式(如 `sticker(帕姆_嗨)`) |
| 内容 | `HeroImage` | string | 大图 URI(可空) |
| 内容 | `TargetSelector` / `PointToTarget` / `ModalTarget` | string/bool | 指向的目标与是否模态 |
| 内容 | `RightButtonText` / `LeftButtonText` | string | 按钮文案(可空) |
| 内容 | `RightButtonActions` / `LeftButtonActions` / `InitializeActions` | array | 行动列表,元素为 `{Kind:int, StringParameter:string}` |
| 内容 | `WaitForNextCommand` / `UseLightDismiss` | bool | 交互策略 |
| 内容 | `Tag` / `Script` / `Xmlns` | string/object | 扩展位 |
(来源:`Assets/Tutorials/classisland.getStarted.json:1-200+`。行动 `Kind` 的具体枚举含义在源码 `TutorialActionKind` 中定义,实现时必须对照该枚举逐值映射,不得自行编号;本节不臆造枚举值。)
#### 15.6.2 教程图片
| 组/条目 | 文件 | 大小 |
|---|---|---|
| welcome | `banner.webp` | 42,664 B |
| welcome | `mainMenu.webp` / `openAppSettings.webp` / `openEditMode.webp` / `openProfileEditor.webp` / `openTutorial.webp` | 约 25–27 KB/张 |
| componentsEditing | `banner.webp` / `ruleset.webp` | 28,292 / 48,738 B |
| profileEditing | `banner.webp` / `时间表.webp` / `科目.webp` / `课表.webp` / `课表与时间表关系.webp` | 25,900–109,912 B |
另有默认图:`Assets/DefaultTutorialBanner.webp`(10,416 B)、`Assets/DefaultTutorialGroupBanner.webp`(46,540 B),用作无 Banner 时的兜底。
要求:图片统一用 WebP(保留 `.webp` 扩展名),懒加载;教程播放时按窗口宽度自适应,不得变形。
### 15.7 本地化
#### 15.7.1 现状(必须如实实现,不得虚构更多语言)
ClassIsland v2 的本地化是**局部**的,只覆盖两个命名空间(`Assets/Localization/`):
| 命名空间 | 资源文件 | 语言 |
|---|---|---|
| `SettingsPage`(设置页) | `Localization.SettingsPage.resx`(默认/中文)、`.zh-Hans.resx`、`.zh-Hant.resx`、`.en.resx` | 简体中文、繁体中文、英文 |
| `SettingsPage/General`(常规设置子页) | 同上 4 个 | 同上 |
| `TrayMenu`(托盘菜单) | 同上 4 个 | 同上 |
- 默认语言为中文(`resx` 无文化后缀文件即默认),并强制 `Thread.CurrentThread.CurrentUICulture = zh-CN`(`ClassIsland/App.axaml.cs:512-513`)。
- 其余界面文案**硬编码中文**,没有资源化。因此「支持多语言」不是现状能力。
#### 15.7.2 实现要求(在 Web 栈中的等价方案)
1. 必须建立 `i18n` 层(推荐 `vue-i18n`),语言键命名与 resx 的 `Key` 保持一致,便于数据迁移。
2. 首期必须提供 **zh-Hans(默认)、zh-Hant、en** 三份文案;其余界面文案随实现逐步资源化,**不得**伪造「已支持 N 种语言」。
3. 语言切换必须持久化到配置,并立即刷新界面(无需重启);日期/时间格式必须跟随语言(`Intl.DateTimeFormat`)。
4. 本地化文件不得包含 HTML 标签;带占位符的文案必须使用命名参数(`{name}`),禁止字符串拼接。
5. 字体必须覆盖三语:英文用 HarmonyOS Sans SC 的拉丁字形即可;繁体中文若依赖系统字体兜底,必须在 fallback 栈中显式列出(`PingFang TC`、`Microsoft JhengHei`)。
### 15.8 其他资源与文档
| 资源 | 用途 | 要求 | 证据 |
|---|---|---|---|
| `Assets/Documents/ChangeLog.md` | 更新日志(关于页展示) | 必须随版本更新,按版本倒序 | 文件名 |
| `Assets/Documents/Privacy_.md` | 隐私政策(关于页展示) | 必须原样保留;含 Sentry/上报说明 | 文件名 |
| `LICENSE.txt`(根、各项目、Assets) | GPL-3.0 许可证 | 必须随安装包分发 | `LICENSE.txt` |
| `Assets/TrustedPublicKeys/ClassIsland.MetadataPublisher.asc` | 更新/市场元数据签名公钥 | 必须内置,用于校验更新包 | 文件名 |
| `Assets/ShortcutTemplates/ClassSwap.url`、`cn.classisland.app.desktop` | 换课/桌面快捷方式模板 | Windows `.url` 与 Linux `.desktop` 两套 | 文件名 |
| `Assets/Highlighting/Lua.xshd` | 自动化脚本 Lua 语法高亮定义 | 编辑器必须使用该配色 | 文件名 |
| `Assets/default-subjects.json` | 默认科目表(23 个科目,含固定 GUID:语文/数学/英语/历史/政治/物理/化学/生物/地理/信息技术/体育/自习/通用技术/音乐/美术/选修课/社团/心理/早读/班会/周测 等) | 必须原样内置,GUID 不得重新生成(迁移依赖) | `Assets/default-subjects.json` |
| `Assets/AsciiLogo.txt` | ASCII 艺术 Logo(控制台/崩溃页) | 6 行,宽 63 字符 | 文件内容 |
| `Assets/Tellings.txt` | 关于页/加载页随机「一句话」(含彩蛋) | 至少 20 条,需保留「关于页连续点击图标 10 次开调试」等提示 | 文件内容 |
| `Assets/dependencies.g.json` | NuGet 依赖许可清单(供「许可」页展示) | Web 栈需生成 `licenses.json` 等价物(npm 依赖 + 字体许可) | `Models/NugetLicenseInfo.cs`、`Views/WelcomePages/LicensePage.axaml` |
| `Assets/XiaomiWeather/xiaomi_weather_status.json` | 天气代码→中文名映射(40 条) | 与 15.4.2 表一致 | 文件内容 |
| `Assets/BannerMain_App2.png` | 2031×600 | 关于页/更新页主视觉 | 实测文件头 |
| `Assets/Demo_MainMenu.png`、`Demo_OpenProfileSettingsWindow.png` | 1608×878 | 教程/引导演示图 | 实测文件头 |
| `Assets/ClassIsland.ai` | 矢量源文件 | 设计源文件,可不打包 | 文件名 |
| `Assets/XamlThemePreviews/classisland.fluent.png`、`classisland.classic.png` | 1920×350 | 主题市场预览图(见 part 09) | 实测文件头 |
### 15.9 ClassHelper 侧资源落点
| 资源类别 | 落点 | 生成/校验方式 |
|---|---|---|
| 应用图标 | `build/classhelper.png`(源)+ 设计导出的 `.ico` | 用 `pnpm icons` 派生其余尺寸(与 ClassHelper 现有约定一致:图标源是 `build/classhelper.png` 与设计导出的 `.ico`,`pnpm icons` 派生其余资源) |
| Web 品牌图 | `packages/web-admin/public/logo.png` 或站点 `/logo.png` | Web 用 `/logo.png`,客户端用 `BRAND_LOGO_URL` 常量(避免 `file://` 绝对路径问题) |
| 字体 | `packages/desktop-client/resources/fonts/` | 4 个 HarmonyOS TTF + 3 个图标 TTF + 2 个 JSON 映射;构建时校验文件存在与哈希 |
| 音效 | `packages/desktop-client/resources/audio/1.wav` | 校验大小 130,012 B(或替换为等价资源但保持单文件、可循环) |
| 贴纸 | `packages/desktop-client/resources/stickers/` | 22 个 PNG,文件名保持中文原名(打包后可用 URL 编码访问) |
| 教程 | `packages/desktop-client/resources/tutorials/` | JSON + WebP;启动时校验 id 唯一 |
| 主题预览 | `packages/desktop-client/resources/theme-previews/` | 1920×350 PNG 两张 |
| 许可清单 | `packages/desktop-client/resources/licenses.json` | 构建时从 `package.json`/`node_modules` 生成;必须包含字体与图标字体的许可条目 |
### 15.10 资源命名、加载与缓存规范
1. **命名**:图片用 `kebab-case` 或保持中文原名(贴纸、教程图必须保留原名,因为教程 JSON 以名字引用);字体文件名保持上游原名(`HarmonyOS_Sans_SC_Medium.ttf`),不得改成 `medium.ttf` 之类丢失来源信息的名字;音效用 `1.wav` 这类稳定短名,便于设置里持久化路径。
2. **目录约定**:`renderer/assets/{icons,fonts,audio,stickers,tutorials,theme-previews}/`,每类一个目录;禁止在组件目录里散落资源文件。
3. **加载方式**:
- 图标字体、正文 TTF 在应用启动时通过 `@font-face` 预加载(`crossorigin` 同源),并用 `document.fonts.load()` 显式触发;
- 贴纸/教程图/主题预览图走懒加载(`loading="lazy"` + IntersectionObserver),未加载时显示 `Shimmer` 骨架(part 14 D9);
- 音效在首次提醒时按需加载并缓存 `AudioBuffer`,不得每次播放都重新 `fetch`。
4. **缓存**:所有资源 URL 必须带内容哈希(构建期生成 `asset-manifest.json`),Electron 生产环境用 `file://` + 哈希避免陈旧缓存;开发环境禁用强缓存。
5. **失败降级**:图片加载失败必须显示「图裂了」占位(ClassIsland 的行为:`SystemFillColorCriticalBackgroundBrush` 底 + 提示「喔唷,图裂啦」,`StylesBase.axaml:214-223`),不得留白或抛错;字体加载失败必须回落系统字体并保持布局不跳变(用 `font-display: swap` + 固定行高)。
6. **安全**:禁止加载 `http(s)` 远程图片作为 UI 资源;所有资源必须走打包路径,CSP 中 `img-src 'self' data:`、`font-src 'self'`、`media-src 'self'`。
7. **引用完整性**:构建时扫描 CSS/TS/JSON 中的资源引用,所有引用必须命中 `asset-manifest.json`;反向扫描资源目录,未被引用的资源要么删除要么登记为「运行时动态加载」白名单。
### 15.11 资源版本与许可核对
- 「关于」页必须提供「许可」入口,展示:应用许可(GPL-3.0)、字体许可(HarmonyOS Sans License)、图标字体许可(Fluent/Lucide/SF)、贴纸素材来源、第三方依赖(npm 依赖清单)。ClassIsland 用生成文件 `dependencies.g.json` + `LicensePage.axaml` 展示 NuGet 依赖,Web 实现必须生成等价的 `licenses.json`。
- 每次发版必须核对:新增/替换的资源是否引入新的许可义务;贴纸、Banner、Demo 图是否为项目自有或有授权(HoYoStickers 为《崩坏:星穹铁道》同人素材,**仅可作为内置彩蛋、不得用于商业宣传**)。
- 版本号变更时必须同步「关于」页的 `AppVersionLong` 展示(源码用编译期常量 `AppBase.AppVersionLong`,Web 端改为读 `package.json.version`)。
补充说明:ClassIsland 的「关于」页还会展示更新日志、隐私政策与致谢名单,这些内容都是随包分发的 Markdown 文档。实现时不得改为在线拉取——教室环境常常断网,关于页必须在离线时完整可读。更新日志与隐私政策的内容以 `Assets/Documents/` 下的文件为准,发版时同步更新,不允许只改版本号不改日志。
### 15.12 验收标准
1. **完整性**:本节列出的资源 100% 存在于仓库;用脚本比对清单与文件,缺一项即失败(允许同一资源用等价格式替换,但必须在清单中登记变更)。
2. **图标**:`electron-builder` 打包后 Windows exe/installer、Linux AppImage、macOS icns 三端图标均正确;ICO 内嵌 6 档尺寸;任务栏/开始菜单/关于页三处图标一致。
3. **字体**:断网启动,界面仍显示 HarmonyOS Sans SC(非回退字体);图标字体全部可渲染(抽查 Fluent/Lucide/SF 各 3 个码位);`document.fonts.check()` 对 4 个正文字重与 3 个图标族返回 true。
4. **字体许可**:安装包内包含 `fonts/LICENSE.txt`(HarmonyOS 许可)与 `licenses.json`;「关于 → 许可」页可查看并可滚动到底。
5. **音效**:默认配置下提醒有声音;把音量调到 0 静音;自定义音效路径失效时回落内置 1.wav 且不崩溃;同一条提醒只响一次。
6. **天气图标**:4 套模板均可切换且立即生效;40 个天气代码逐个渲染正确(截图对比名称与图标);未知代码 99 显示「未知」图标。
7. **贴纸**:`sticker(帕姆_嗨)` 在教程中正确显示;22 张贴纸全部可加载(无 404);比例不变形。
8. **教程**:`classisland.getStarted` 三个教程组可完整播放到结束;跳过后再次进入从第一步开始;教程图片无拉伸、无破图。
9. **本地化**:切换 en/zh-Hans/zh-Hant 后设置页与托盘菜单文案切换且立即生效;日期格式随之变化;其余页面若无翻译必须回落中文而不是显示 key。
10. **离线**:在完全断网、无缓存的干净环境中启动,所有资源(字体/图标/音效/贴纸/教程图)均本地可用,控制台无对外请求(除用户主动触发的天气/更新功能)。
11. **体积**:字体 + 图标 + 音效 + 贴纸 + 教程图的总增量不超过 30MB 打包后体积;超过时必须说明取舍(例如贴纸按需下载,但 ClassIsland 的语义是离线可用,默认应全量内置)。
+139
View File
@@ -0,0 +1,139 @@
## 16. 质量门禁与验收清单
> 本部分是硬性验收标准。你必须逐条执行并报告结果;任何一项未通过都不得声称「已完成」。所有自动验证必须在无头/离屏模式下执行,禁止弹出可见窗口、禁止播放声音、禁止操作真实课表数据。
### 16.1 工程与构建门禁
- [ ] `pnpm install --frozen-lockfile` 在 Node 20.19+ 下成功;`pnpm-lock.yaml` 已提交。
- [ ] `pnpm -r typecheck` 0 错误;`pnpm lint` 0 错误(warning 允许但需在报告中列出)。
- [ ] `pnpm build` 产出:主进程 `dist/main`、preload `dist/preload`、两个渲染入口(`index.html`、`island.html`)以及静态资源;`island.html` 与 `index.html` 分别可独立加载。
- [ ] 打包产物:Windows NSIS/portable 安装后能启动;卸载不残留用户配置(或按需保留并提示);macOS/Linux 至少构建成功。
- [ ] 依赖清单固定版本并说明用途;无重复的同类库(例如不得同时使用两个日期库、两个状态库)。
- [ ] 仓库内无 `node_modules`、无构建产物、无日志、无密钥;`.gitignore` 完整。
- [ ] README 包含:环境要求、开发命令、构建命令、打包命令、目录说明、数据目录位置、插件/主题开发入口。
### 16.2 像素级视觉验收(主界面)
在 1920×1080、缩放 100% 的无头离屏截图(`--headless --screenshot` 或 Playwright Electron)下,与下列基准逐项比对(容差:颜色 ±2/255,位置/尺寸 ±1px,透明度 ±1%):
- [ ] 岛默认位置:屏幕中上侧;上边距 = `WindowDockingOffsetY`(默认 0)+ 系统工作区顶部安全区;水平居中误差 0px。
- [ ] 默认行宽:内容自适应(`Width=auto`);编辑模式下 `MinWidth = ActualClientBound.Width`;圆角 8px(`RadiusX=RadiusY=8`,可用设置改)。
- [ ] 背景不透明度 0.5(`Opacity=0.5`);默认深色背景(`BackgroundColor` 默认黑,未启用自定义背景色时使用主题背景);背景模糊/毛玻璃在支持的平台上启用,不支持时降级为半透明纯色且视觉不突变。
- [ ] 行垂直间距 5px(`MainWindowLineVerticalMargin=5`);组件水平外边距 6px(`Margin="6 0"`)。
- [ ] 字号:次要 14、主体 16、强调 18、超大 20(可由设置覆盖);默认字重 Medium(`MainWindowFontWeight2=500`);字体为系统字体回退链。
- [ ] 进度条:位于当前课程行底部,宽度=行宽,高度见 4.4(建议 3px),颜色=强调色;进度精度默认降低(`ReduceProgressAccuracy=true`,即按整秒/整分更新,不逐帧抖动)。
- [ ] 隐藏/显示、编辑模式进入退出、上下文菜单弹出均无白屏、无黑边、无窗口阴影残影。
- [ ] 深色/浅色主题各截一张、Fluent/Classic 主题各截一张、125%/150%/200% 缩放各一张,全部与第 3、4、9 部分的规格一致。
### 16.3 功能行为验收(按模块)
**主界面与窗口**
- [ ] 6 个停靠位置、X/Y 偏移、多显示器索引生效;切换显示器后岛跟随。
- [ ] 不抢焦点:显示/切换页面时,当前前台应用(记事本/浏览器)不失去焦点,Alt+Tab 不出现岛。
- [ ] 任务栏:默认不显示;仅当「窗口模式」或「录屏模式」开启时显示。
- [ ] 鼠标穿透:开启后点击岛不触发任何事件;右键菜单仍可通过托盘打开;触摸设备长按可打开菜单。
- [ ] 隐藏规则:`HideOnClass` 上课隐藏、`HideOnMaxWindow` 前台最大窗口隐藏、`HideOnFullscreen` 全屏隐藏(排除进程名单生效)、高级规则集命中隐藏;任何隐藏后进入编辑模式必须强制显示。
- [ ] 临时显示:通过托盘/右键/自动化「显示主界面」在隐藏期间强制显示,并保持到超时或手动隐藏(行为与第 4.5 节一致)。
- [ ] 关闭再启动:位置/尺寸/缩放/透明度/主题/静默状态全部保持。
- [ ] 单实例:重复启动时旧实例收到信号并显示主界面(或按设置行为),新进程 1s 内退出。
- [ ] 开机自启开关生效(注册表/LaunchAgent/autostart 三平台),关闭后删除启动项。
**组件**
- [ ] 全部内置组件可添加、设置、删除、拖拽排序、嵌套;配置重新加载后视觉与行为一致。
- [ ] 每个组件的默认值、空数据兜底、超长文本截断/滚动、隐藏规则、显示优先级符合第 5 部分。
- [ ] 组件动画(时钟、倒计时、滚动、滑动、天气图标)在 60fps 下无抖动;浏览器 DevTools 无 layout thrash 警告。
- [ ] 组件在编辑模式下可被选中并显示虚线框(1.5px、虚线 1,1.5、圆角描边、Opacity 0.75);退出编辑后完全消失。
**课表/档案**
- [ ] 时间点类型:上课/课间/分割线/行动;时间点可拖拽改时间、可快速录入、可跨天;校验不允许重叠/倒序。
- [ ] 周次轮换:单周、双周、1–9 周自定义轮换、偏移、起始周日期;跨学期/跨年正确。
- [ ] 临时课表:单日、跨天、按日期区间;预定课表到点自动启用、结束自动恢复;临时课表与正式课表优先级符合第 6 部分。
- [ ] 换课:同日/跨日、按节次交换、恢复;影响提醒与当前课程。
- [ ] Excel 导入:支持 xlsx/xls/csv;模板列映射;错误单元格定位到行列;导入前预览;取消不落库。
- [ ] Excel 导出:导出当前档案课表,格式可再导入(往返一致)。
- [ ] CSES JSON 导入:字段映射与失败提示。
- [ ] 多档案:新建/复制/重命名/删除/切换;删除有二次确认与备份;默认档案在无课时兜底。
- [ ] 档案损坏/版本过旧:提示可恢复,不允许静默清空。
**提醒**
- [ ] 队列:高优先级插队、同级先进先出、同 id 去重、最长显示时长、可手动清除。
- [ ] 上课提醒:根据时间表在上课时间点触发;课前 N 秒(默认 60s)触发准备提醒;下课前倒计时(默认 60s)显示;下课提醒;放学提醒。
- [ ] 提醒内容:学科名、教师名、课间名、下一节课程、天气预警、自定义文本;布局与字号符合第 7 部分。
- [ ] 强提醒(Topmost):全屏/大区域遮罩、超时自动关闭、点击/按键关闭、在岛所在的显示器上显示;不抢焦点。
- [ ] 音效:内置音效播放、音量、单次/循环;无音频设备时降级不报错;`-q/--quiet` 静默启动后 10 秒内丢弃提醒(ClassIsland 2.1 无独立「静音时段」,不得虚构该功能;如实现 P2 免打扰需默认关闭并标【推断】)。
- [ ] TTS:系统语音/Edge TTS 可切换、语速/音量/音色、失败回退;不阻塞 UI。
- [ ] 提醒设置:按提供者启用/排序/参数;修改后立即生效。
- [ ] 提醒历史:最近 N 条可查、可清空。
**自动化**
- [ ] 触发器全部可用(时间点/CRON/信号/URI/托盘菜单/窗口状态/当前课程/天气/日出日落)。
- [ ] 行动全部可用(提醒/运行程序/打开文件/打开网页/关机/睡眠/重启/修改设置/广播信号/切换课表/发通知);危险行动有二次确认与超时保护;测试模式禁止真实关机。
- [ ] 规则集:AND/OR/NOT、嵌套、比较运算、空值处理;求值结果与第 8 部分一致。
- [ ] 执行日志:时间、触发源、行动、结果、耗时;失败重试有上限;不会无限循环。
- [ ] 自动化关闭时不执行任何行动。
**设置与窗口**
- [ ] 每个设置页与控件、默认值、枚举项与第 10 部分清单一致;搜索可命中设置标题/关键词。
- [ ] 设置即时生效(防抖 300ms 内);需要重启的项明确提示并提供重启按钮。
- [ ] 设置导入/导出、恢复默认、覆盖层(SettingsOverlay)按预期工作。
- [ ] OOBE 可完整走通、可跳过、可从设置再次打开;OOBE 未完成时主窗口不出现或被引导遮罩。
- [ ] 所有次级窗口的尺寸/标题/按钮/关闭行为符合第 11 部分;Esc 关闭;关闭设置不退出应用。
- [ ] 崩溃窗口、恢复模式、更新进度、数据迁移、教程、授权、日志窗口可正常打开/关闭,无内存泄漏(关闭后窗口对象被释放)。
**主题/资源/本地化**
- [ ] 内置主题切换全覆盖、无残留样式、无闪烁;非法主题包被拒绝且不崩溃。
- [ ] 若实现 P2 壁纸取色(ClassIsland 2.1 已停用、默认关闭),取色结果稳定且浅色/深色下前景对比度 ≥ 4.5:1(WCAG AA);色盲安全抽查;未实现时不得在设置页露出可用的取色入口(按源码禁用隐藏)。
- [ ] 图标/字体/音效资源齐全,缺失时使用兜底资源;无 404。
- [ ] 中文文案 100% 覆盖;文案集中在常量文件;ClassIsland 2.1 只有 3 种语言且仅覆盖设置页与托盘菜单(其余硬编码中文),因此本实现交付「完整 zh-CN + 文案集中」,不得宣称全量多语言;若要加 en-US 只能作为 P2 增量且不得遗漏中文兜底。
### 16.4 动画验收
- [ ] 第 14 部分动画总表中的每一项都实现,时长误差 ≤ 16ms,曲线一致(用 DevTools Animations 面板或离屏逐帧截图验证关键帧)。
- [ ] 岛出现/隐藏、行展开/折叠、提醒进出、强提醒、OOBE 转场、主题切换、设置页切换全部流畅;长任务不阻塞动画(用 Performance 面板确认帧率 ≥ 55fps)。
- [ ] `prefers-reduced-motion` / 设置中的「减少动画」(`AnimationLevel=0`)生效:仅保留必要的透明度变化,位移与缩放归零。
- [ ] 动画结束后无残留 `will-change`、无未清理的 `requestAnimationFrame`/定时器。
- [ ] 弹簧动画参数与 `spring.ts` 一致(stiffness 0.10、damping 0.68、snap 0.05px、换向保留 35%),不得用 CSS 过渡近似替代(行高/尺寸变化场景)。
### 16.5 数据完整性与容错
- [ ] 所有写入原子化(临时文件 + rename);断电/强杀后配置仍可读。
- [ ] 自动备份:默认开启、每 7 天一次、最多保留 16 份(`IsAutoBackupEnabled=true`、`AutoBackupIntervalDays=7`、`AutoBackupLimit=16`),可从备份恢复。
- [ ] 配置版本迁移:旧版本字段兼容,迁移前备份,迁移失败自动回滚并提示。
- [ ] 配置校验:非法 JSON、未知字段、越界数值(如半径 -1、透明度 >1)被修正或拒绝,不崩溃。
- [ ] 崩溃恢复:渲染进程崩溃后自动重建(限流:1 分钟内最多 3 次,超过则进入安全模式);GPU 崩溃降级软件渲染;安全模式下禁用插件/主题并提示。
- [ ] 数据目录可配置;卸载/重置时不误删用户手动备份。
### 16.6 性能与资源
- [ ] 冷启动到岛可见 ≤ 3s(SSD,不联网)。
- [ ] 空闲 CPU 平均 ≤ 1%(岛可见、时钟每秒刷新);内存(主进程+两个渲染进程)≤ 300MB。
- [ ] 连续运行 24h 无内存增长(增长 ≤ 10%)、无句柄泄漏、无崩溃。
- [ ] 提醒密集场景(1 分钟 20 条)不卡顿、不丢提醒、不重复。
- [ ] 打包体积:Windows 安装包 ≤ 150MB(不含可选模型/音色资源)。
- [ ] 首屏构建资源 ≤ 2MB(gzip);岛渲染进程启动 ≤ 500ms。
### 16.7 平台与兼容性
- [ ] Windows 10 21H2+、Windows 11:透明/置顶/点击穿透/多显示器/DPI 125%/150%/200% 正常。
- [ ] macOS 12+:透明窗口、置顶、全屏隐藏、通知权限(如使用系统通知)。
- [ ] Linux X11:透明窗口、置顶、点击穿透;Wayland 明确提示不支持并给出降级说明。
- [ ] 触摸设备:单击/长按/滑动不冲突;编辑模式可拖拽。
- [ ] 屏幕阅读器可读出设置控件;键盘 Tab 顺序合理;所有按钮有 `aria-label` 或可见文本。
### 16.8 安全验收
- [ ] `nodeIntegration: false`、`contextIsolation: true`、`sandbox: true`(或提供等效隔离);preload 仅暴露白名单 API。
- [ ] 所有 IPC 通道校验参数类型与来源窗口;渲染层不能任意读写文件/执行命令。
- [ ] CSP 严格(`default-src 'self'`;`connect-src` 仅允许天气/更新/插件市场域名);无 `unsafe-eval`(Vue 生产构建)。
- [ ] 更新包必须校验哈希/签名;拒绝非 HTTPS 下载(本地镜像可通过设置允许但需明确警告)。
- [ ] 自动化行动执行命令有白名单/确认;日志脱敏(不含完整路径中的用户名/令牌)。
- [ ] 插件默认无 Node 权限;插件权限声明在清单中,安装时展示。
- [ ] 本地数据不上传;崩溃上报默认关闭/需同意(`IsReportingEnabled` 默认 true 但仅上报匿名崩溃,需在隐私页说明)。
### 16.9 交付物清单
- [ ] 源码 + `pnpm-lock.yaml` + 构建脚本。
- [ ] `README.md`、`docs/architecture.md`、`docs/data-schema.md`、`docs/plugin-api.md`、`docs/theme-api.md`。
- [ ] 示例档案(含 3 周轮换)、示例主题、示例插件(P1)。
- [ ] 无头测试:单元测试(时间/课表/规则/配置校验)+ 组件测试(岛渲染快照)+ 端到端测试(启动、开设置、切主题、导入课表)。
- [ ] 验收报告:逐条勾选 16.1–16.9,附截图路径与测试输出摘要;未通过项必须列出原因与修复计划。
+96
View File
@@ -0,0 +1,96 @@
## 17. 开发里程碑与实现顺序
> 必须按 M0→M8 顺序推进;每个里程碑结束都要能「无头启动 + 通过该阶段验收」。禁止先堆功能再补架构。
### M0 工程脚手架与进程骨架(0.5–1 天)
- 交付:
- pnpm monorepo:`pnpm-workspace.yaml`(`packages/*`)、根 `package.json`(`packageManager: pnpm@11.8.0`)、`tsconfig.base.json`、Prettier/ESLint。
- `packages/desktop-client`:Electron 主进程入口、`island.html` 与 `index.html` 两个 Vite 入口、preload 白名单、单实例锁、托盘、日志、`config.json` 原子读写、默认 `settings.json`。
- `packages/shared`:类型定义、常量、schema、时间工具;`packages/ui`:`tokens.css`、基础控件。
- 验证(无头):`pnpm typecheck`、`pnpm lint`、`pnpm build` 通过;主进程以 `--headless` 启动后**不创建任何可见窗口**,5 秒内输出 `app-ready` 并自动退出;配置文件按 schema 生成且二次启动可读回。
- 完成标准:`config.json` 字段完整、原子写、损坏时自动回退默认值并记录日志;窗口/托盘代码有开关(headless 模式全部跳过)。
### M1 岛窗口 + 时钟引擎 + 基础显示(1–2 天)
- 交付:
- 独立岛 BrowserWindow:透明、无边框、置顶、不抢焦点、默认不显示在任务栏、按 `WindowDockingLocation` 6 个位置停靠、多显示器选择、缩放、DPI 处理、关闭/隐藏动画。
- 岛结构:多行容器(`Lines`)、行背景、连续圆角、背景不透明度、毛玻璃/纯色、进度条、遮罩层宿主。
- 时间引擎:NTP 对时(可关闭)、手动偏移、倍速调试、休眠唤醒后重算。
- 默认组件:日期 + 时钟 + 欢迎文本;右键原生菜单(完整项,见 4.7)。
- 验证:1920×1080@100% 下离屏截图与第 4 部分基准逐项比对;切换 6 个停靠位置、125%/150% 缩放截图无模糊/偏移;岛空闲 CPU < 1%。
- 完成标准:无边框、透明、点击穿透可切换、圆角与阴影正确、菜单可用、时钟跨分钟无跳变。
### M2 组件系统(2–3 天)
- 交付:
- 组件注册表、Component 数据模型(`Id/Name/Type/Settings/Children/IsVisible/HidingRules`)、组件设置面板、组件的添加/删除/拖拽排序/嵌套。
- 内置组件按第 5 部分顺序实现:分隔线 → 文本 → 日期 → 时钟 → 倒计时 → 课程表 → 天气 → 滚动 → 滑动 → 堆叠 → 分组 → 容器。
- 编辑模式:选中框、拖动把手、行设置抽屉、添加组件面板、组件设置弹层。
- 验证:每个组件在编辑模式可增删改、保存后重载不丢失;空数据/超长文本/多语言/深色主题截图验收;拖拽排序有动画且不闪烁。
- 完成标准:组件配置 JSON 与第 13 部分 schema 一致;关闭编辑模式无选中框残留。
### M3 课程表与档案系统(3–4 天)
- 交付:
- 时间表编辑器(时间点增删、上课/课间/分割线/行动、分钟级拖拽、快速录入、跨天)。
- 课表编辑器(周模板、单日覆盖、多周轮换 1–9 周、群组、预定/临时课表、单日/跨天换课)。
- 档案管理(新建/复制/删除/导入/导出/切换、默认档案、档案校验)。
- Excel 导入(模板下载、表头映射、预览、错误定位)、Excel 导出、CSES JSON 导入。
- 课程状态机:当前/下一节、课间、放学、跨天、提前上课/拖堂、时间点行动。
- 验证:构造 3 周轮换课表 + 临时换课 + 跨天场景,无头单元测试断言当前课程/剩余时间/下一节;Excel 往返导入导出数据一致;损坏档案提示可恢复。
- 完成标准:课程计算函数覆盖率 ≥ 90%;所有档案操作有备份。
### M4 提醒系统(2–3 天)
- 交付:
- 提醒队列与优先级、去重、排队/插队、提醒历史、清除全部提醒。
- 内置提醒提供者:上课、下课、课前准备(提前 N 秒)、放学、天气预警、天气预报、管理通知、自定义(行动触发)。
- 提醒 UI:气泡(行内展开/胶囊)、强提醒全屏特效;音效、TTS(系统 TTS/Edge TTS)、音量;-q/--quiet 静默启动后 10 秒内丢弃提醒(无独立静音时段,不得虚构)。
- 提醒设置页与各提供者设置。
- 验证:无头注入假时间,断言 6:00–23:00 全时间点提醒触发一次且仅一次;队列优先级符合第 7 部分;气泡/强提醒动画时长与曲线与第 14 部分一致;关闭音效后不播放。
- 完成标准:重复提醒按 id 去重;上课期间普通提醒按规则暂存/延后;不阻塞主线程。
### M5 设置中心 + OOBE(2–3 天)
- 交付:
- 设置窗口:左侧导航(分组 + 图标 + 搜索)、右侧页面、面包屑/返回、即时保存、重启提示、恢复默认。
- 全部设置页与控件、默认值、禁用条件(见第 10 部分)。
- OOBE(8 页):欢迎 → 许可证 → 翻新/迎新 → 基本 → 颜色 → 外观 → 系统 → 完成;支持跳过与再次打开。
- 验证:设置项与第 10 部分清单逐项对照;改变关键设置(圆角/透明度/字号/停靠/主题)后岛即时生效;重启后保持;搜索命中正确。
- 完成标准:设置写入防抖 ≤ 300ms;连续修改不产生大量磁盘写(合并写入)。
### M6 主题系统(+ 可选壁纸取色 P2)(1–2 天)
- 交付:
- 内置 Fluent/Classic 主题的完整 CSS 变量与组件样式差异。
- 主题包格式(`theme.json` + `theme.css` + `preview.png`)、安装/卸载/启用、冲突处理。
- 自定义背景色/前景色;系统深浅色跟随。
- P2(可选,默认关闭):壁纸取色——屏幕/壁纸采样、主色提取、深浅色适配、调色板候选(ClassIsland 2.1 已停用该功能,只给算法与数据结构,不纳入 P1 验收)。
- 验证:切换主题后主界面/设置/弹窗全部生效且无闪烁;非法主题包不崩溃;若实现 P2 取色,则抽查取色结果与壁纸截图的视觉一致性。
- 完成标准:主题变量覆盖第 3 部分全部 token;主题预览图自动生成或提供占位。
### M7 自动化 + 天气 + 高级窗口(2–3 天)
- 交付:
- 自动化引擎:触发器轮询/事件、行动执行、规则集求值、执行日志、失败重试/限流、危险行动二次确认。
- 内置触发器与行动完整清单(见第 8 部分);规则类型与嵌套。
- 天气服务:定位/城市、当前/逐小时/3 天、预警、降水提醒、缓存与失败回退、图标主题。
- 次级窗口:换课、ClassPlanDetails、Excel 导入/导出向导、崩溃/恢复、更新进度、时间调整、数据迁移、教程、关于、日志、调试。
- 验证:构造自动化场景(时间点触发提醒、窗口全屏时隐藏岛、下雨提醒)无头断言;断网时天气显示缓存并提示;危险行动(关机/睡眠)在测试模式下不得真正执行。
- 完成标准:自动化配置可导出/导入;执行记录可查;异常不会导致应用退出。
### M8 插件、更新、恢复与打磨(2–4 天)
- 交付:
- 插件宿主(P1):清单、权限、启用/禁用、生命周期、扩展点(组件/提醒提供者/触发器/行动/设置页)、示例插件。
- 更新:检查/通道/镜像/下载/哈希校验/静默安装/失败回滚;`UpdateService` 对应 `update.ts`。
- 崩溃守卫与恢复模式:渲染进程崩溃自动重建(限流)、GPU 崩溃降级软件渲染、配置损坏恢复、安全模式。
- 教程系统、文案集中(zh-CN;en-US 为 P2 可选)、无障碍、性能优化、打包脚本、文档、示例主题。
- 验证:打包产物在干净系统安装运行;断网/拔电源模拟崩溃后数据不丢;更新流程灰度与回滚;插件启用后无卡顿。
- 完成标准:全部 P0/P1 验收项通过;安装包体积与内存占用达标(见 16.6)。
### 里程碑总览表
| 里程碑 | 核心风险 | 完成标志 |
|---|---|---|
| M0 | 进程模型/透明窗口/配置原子写 | headless 启动 < 5s,无可见窗口 |
| M1 | 透明置顶窗口跨平台差异、DPI | 6 停靠位置像素级正确 |
| M2 | 组件递归模型与编辑模式 | 组件配置闭环 |
| M3 | 课表状态机/周次/导入 | 单元测试覆盖核心算法 |
| M4 | 提醒去重与音视频调度 | 全时间点场景通过 |
| M5 | 设置项数量庞大 | 设置清单 100% 对照 |
| M6 | 主题变量覆盖与取色 | 切换无残留 |
| M7 | 自动化安全/天气容错 | 危险行动有保护 |
| M8 | 插件沙箱/更新安全 | 打包安装可用 |
+52
View File
@@ -0,0 +1,52 @@
## 18. 禁止事项与实现边界
> 以下为硬性红线。违反任一项即视为交付不合格。
### 18.1 关于「不打扰」的硬性要求
1. **禁止任何会显示可见窗口或发出声音的自动验证**:自动化测试、冒烟脚本、构建检查必须在 `--headless` / 离屏 / 隐藏窗口模式下运行;不得创建可见的岛、托盘气泡、系统通知、声音。验证脚本必须默认静默,需要人工观察时由显式参数开启(例如 `--visual`),且不得作为 CI 默认。
2. **禁止在开发/构建过程中自动创建桌面快捷方式、开机自启项、全局快捷键**;这些只能由用户在设置中主动开启。
3. **禁止把主窗口做成会抢占焦点或进入任务栏的普通窗口**:默认 `focusable=false`、`skipTaskbar=true`、`show=false` 直到首帧准备好;不得使用 `setAlwaysOnTop` 之外的 API 强制置顶到系统级(如 Windows 的 `SetWindowPos HWND_TOPMOST` 需与 Electron 的置顶设置一致,避免与全屏应用冲突)。
4. **禁止用 `setBackgroundMaterial()`(Electron 的 Windows 材质 API)破坏逐像素透明**;透明背景必须由无边框透明窗口 + CSS 自绘实现,以保证圆角与阴影的正确性。
5. **禁止自动播放声音**:提醒音效仅在提醒触发时播放;调试/测试静音;首次启动不播放。
### 18.2 技术红线
6. **禁止让 Element Plus 的全局默认样式进入岛渲染进程**:`island.html` 不得引入 Element Plus 的全局 CSS;岛使用自研 CSS + 设计 tokens。Element Plus 仅用于设置中心等管理型界面。
7. **禁止在渲染进程开启 Node 集成**;preload 不得暴露 `ipcRenderer`、`require`、`process`、文件系统能力;跨桥只传纯数据(不得传 Vue Proxy/函数/类实例)。
8. **禁止把动画交给 `setInterval`/`setTimeout` 逐帧驱动**(弹簧动画用 rAF + 物理积分;其他用 CSS transition/keyframes/Web Animations)。
9. **禁止用 JS 直接改 `width/height/top/left` 触发同步布局做动画**;优先 `transform/opacity`,尺寸变化用 FLIP 或弹簧 + `ResizeObserver` 批量更新。
10. **禁止使用 `unsafe-eval`、`new Function`、远程加载并执行 JS**;CSP 必须严格;不得通过 `file://` 加载用户可写目录中的可执行脚本。
11. **禁止把核心功能依赖云端**:断网时课表、提醒、自动化、组件、设置、主题必须全部正常;天气/更新/插件市场允许降级。
12. **禁止把敏感信息(令牌、密码、完整用户路径)写入日志或打包进安装包**;`check-bundle-secrets` 式构建门禁必须保留(若实现管理端可选模块)。
13. **禁止硬编码绝对路径**;所有资源使用相对/`app://` 协议;品牌图片通过打包资源 URL 引用。
14. **禁止引入与原技术栈冲突的框架**(如再引入 React/Angular、再引入第二套 UI 组件库做岛);确需引入库时必须说明不可替代性并锁定版本。
### 18.3 数据与安全红线
15. **禁止未备份就迁移/覆盖用户数据**:任何 schema 升级、导入、恢复操作先写备份,失败可回滚;删除档案/主题/插件必须二次确认。
16. **禁止静默删除或重置用户配置**;配置损坏时保留原文件为 `.bak` 并提示。
17. **禁止在测试中使用真实班级数据**;测试必须使用临时目录与夹具数据;测试结束清理临时数据,不得触碰用户真实配置目录。
18. **禁止自动化静默执行危险行动**:关机/重启/注销/锁屏/删除文件等行动必须二次确认(或明确的免确认设置),测试模式必须假执行;执行前做权限与参数校验,执行后写审计日志。
19. **禁止插件/主题获得默认 Node 权限**;插件代码在受限环境运行(独立进程或 Worker + 白名单 API),安装前展示权限。
20. **禁止执行任意 URL/命令**:仅允许显式协议(http/https)与用户指定程序路径;路径参数做规范化与存在性校验,防止命令注入。
### 18.4 质量与流程红线
21. **禁止用占位符/`TODO` 交付核心功能**:所有 P0/P1 功能必须真实可用;未完成项必须在验收报告中显式列出,不得隐藏。
22. **禁止伪造验收结果**:截图、日志、测试输出必须来自实际运行;未验证的项标注「未验证」。
23. **禁止为了通过验收修改验收标准或删除测试**;标准以本提示词为准。
24. **禁止跳过像素级检查**:主界面、设置窗口、提醒、OOBE 至少各保留一份基准截图与 diff 报告。
25. **禁止把「默认值」写成 0/空导致功能不可见**:所有默认值必须与第 10、13 部分的默认值表一致。
26. **禁止破坏兼容性**:数据 schema、插件 API、主题变量一旦发布,只允许增量变更;破坏性变更必须提供迁移与版本字段。
### 18.5 本提示词未明确时的裁决顺序
当实现细节与本提示词冲突或缺失时,按以下优先级裁决:
1. 本提示词明确写出的数值与行为(最高优先级)。
2. ClassIsland 的公开文档与截图中可见的行为(可参考 `docs.classisland.tech`,但不得引入其代码)。
3. ClassHelper 已实现的桌面岛约定(`repos/ClassHelper-master/packages/desktop-client/src/island` 与 `src/main/island.ts`)。
4. Fluent Design / iOS 灵动岛的一般设计规范。
5. 你的工程判断(必须在代码注释与交付说明中标注「推断」)。
+7
View File
@@ -0,0 +1,7 @@
## 19. 结语(给实现者的最后要求)
- 先读完本提示词全部章节,列出你认为有歧义的地方并一次性向用户确认;不要边猜边写核心架构。
- 先搭出可无头启动、可截图对比的骨架,再逐模块填充;每完成一个里程碑就运行第 16 部分对应验收,不要最后一次性补测试。
- 代码组织保持「主进程薄、引擎纯函数、渲染层无副作用」:所有业务计算(时间、课表、规则)放在 `packages/shared` 并可单元测试;主进程只做编排与系统集成;渲染层只做展示与交互。
- 交付时提供一份「与 ClassIsland 的差异说明」:列出本实现因技术栈不同而做的等价替换(例如 Avalonia 控件 → Vue 组件、XAML 资源字典 → CSS 变量、插件 DLL → npm 插件包),以及未实现的 P2 能力。
- 最后再对照第 16 部分做一次完整自检,并对照第 18 部分确认没有触碰任何红线;确认无误后再声称完成。
Whitespace-only changes.
+839
View File
@@ -0,0 +1,839 @@
# ClassIsland 复刻工程:给 ChatGPT 的完整实现提示词(ClassHelper 技术栈版)
> **文档性质**:这是一份「提示词」而非分析报告。请把本文件(或按章节分卷)直接投喂给 ChatGPT,要求它按第 17 部分的里程碑从零实现一个 ClassIsland 等价应用;实现技术栈采用 ClassHelper 桌面端(Electron 44 + Vue 3.5 + Element Plus + Pinia + Vite 8 + TypeScript 5.9 + pnpm monorepo),以等价替换 ClassIsland 原项目的 C# / .NET 8 / Avalonia / FluentAvalonia / XAML。
>
> **信息来源**:ClassIsland v2.1 源码(`repos/ClassIsland`,C#/Avalonia)与 ClassHelper 源码(`repos/ClassHelper-master`,TypeScript/Electron)。文中所有确切数值、资源名、行为均尽量标注 `文件:行号` 证据;无源码依据处标注【推断】。ClassIsland 的截图与文档仅用于校对视觉语义,不复制其代码。
>
> **重要说明**:本提示词要求实现者的一切自动验证(构建、冒烟、截图、动画检查)必须**无头/离屏运行,禁止弹出可见窗口、禁止播放声音、禁止干扰用户桌面**。这是本项目的强制纪律,也是验收的一部分(见第 18 部分)。
## 使用说明(给人类读者,不投喂给模型)
1. **整体投喂**:全文约 200k tokens,需使用上下文 ≥ 200k 的模型(如 GPT-5 级长上下文 / Gemini 2.5 Pro)才能一次粘贴;将本文件从「## 0.」到结尾整体粘贴,尾附一句:「请严格按此提示词实现,先给出实施计划与文件树,再按里程碑推进。」
2. **分卷投喂**:按 `## 0` + `## 1` + `## 2`(基础)→ `## 3`~`## 5`(视觉与组件)→ `## 6`~`## 8`(业务)→ `## 9`~`## 13`(系统与数据)→ `## 14`~`## 19`(动画、资源、验收)的顺序分批发送;每批发完后要求模型复述关键参数,避免长上下文丢失。
3. **推荐追问**:「请先输出 20 个你认为最可能歧义的点」「请给出 packages/desktop-client 的完整文件树与每个文件的职责」「请先实现 M0/M1 并给出无头验证脚本」。
4. **证据约定**:文中 `repos/ClassIsland/...` 与 `repos/ClassHelper-master/...` 是规格来源路径,不是让你照抄的运行时依赖;实现时应把这些规格转写为 TypeScript/CSS。
## 目录
- 使用说明(给人类读者,不投喂给模型)
- 0. 给 ChatGPT 的任务声明(从本节开始执行)
- 1. 项目总纲:产品定位、总体体验与系统架构
- 1.1 你的角色与最终目标
- 1.2 产品定位
- 1.3 目标用户与典型场景
- 1.4 十条不可妥协的体验原则
- 1.5 功能全景(必须实现的模块)
- 1.6 总体技术架构(ClassHelper 栈)
- 1.7 信息架构与导航
- 1.8 首次启动与默认状态
- 1.9 本提示词的使用方式与验收总则
- 2. 技术栈与工程结构
- 2.1 技术选型总则(必须逐条遵守)
- 2.2 依赖清单(版本必须按此锁定)
- 2.3 仓库目录结构(必须逐项落地)
- 2.4 进程模型(必须五类进程分离)
- 2.5 构建管线(命令与产物必须一致)
- 2.6 应用启动时序(必须按此顺序,不得提前读配置)
- 2.7 窗口参数(像素级基线)
- 2.8 主进程核心模块(ClassIsland 服务层在 Electron 下的等价物)
- 2.9 渲染层挂载顺序(必须逐行对齐)
- 2.10 岛渲染进程的技术约定(必须与 ClassIsland 视觉对齐)
- 2.11 自动更新与打包(工程结构的一部分)
- 2.12 ClassIsland → ClassHelper 技术映射决策表(必须按此实现)
- 2.13 第三方库等价映射(.NET/Avalonia → npm,必须按此实现)
- 2.14 共享包(`packages/shared`)的工程约定
- 2.15 共享 UI 包(`packages/ui`)与样式令牌工程
- 2.16 主进程并发与 I/O 纪律(直接决定"卡不卡")
- 2.17 安全基线(不得为了"方便"削弱任何一条)
- 2.18 环境变量、命令行参数与调试开关
- 2.19 性能预算(超预算必须优化,不得降级功能)
- 2.20 本地化与文案工程
- 2.21 验收标准(第 2 部分)
- 3. 设计系统 Token 总表(颜色 / 字体 / 圆角 / 间距 / 阴影 / 层级 / DPI / 深浅色)
- 3.1 颜色 Token
- 3.2 字体 Token
- 3.3 圆角 Token
- 3.4 间距 Token
- 3.5 透明与模糊 Token
- 3.6 层级(z-index)Token
- 3.7 DPI 与缩放规则
- 3.8 深浅色规则
- 3.9 ClassHelper 侧落点(实现映射表)
- 3.10 落地顺序与常见错误
- 3.11 验收标准
- 4. 主界面(岛):窗口、行、背景、进度、遮罩、编辑模式
- 4.1 术语与实现映射总则
- 4.2 窗口(BrowserWindow)逐项规格
- 4.3 几何:包围盒、可见内容盒与编辑画布
- 4.4 DOM 层级与 CSS 变量
- 4.5 主界面行(MainWindowLine)
- 4.6 主界面的缩放(裁决:只做缩放,不做平移)
- 4.7 提醒宿主、遮罩与进度条(行内)
- 4.8 编辑模式
- 4.9 隐藏与显示规则(完整判定)
- 4.10 右键菜单(原生菜单)与托盘
- 4.11 本部分动画总表(照此实现,不得自创)
- 4.12 验收标准
- 5. 组件系统:全部内置组件逐个规格
- 5.1 组件系统总则
- 5.2 日期(DateComponent)
- 5.3 时钟(ClockComponent)
- 5.4 倒计时(CountDownComponent)
- 5.5 课程表(ScheduleComponent)
- 5.6 天气简报(WeatherComponent)
- 5.7 文本(TextComponent)
- 5.8 滚动容器(RollingComponent)
- 5.9 轮播容器(SlideComponent)
- 5.10 分组容器(GroupComponent)
- 5.11 堆叠容器(StackComponent)
- 5.12 分割线(SeparatorComponent)
- 5.13 组件设置 UI(组件设置页 + 高级设置页)
- 5.14 空数据与异常兜底(逐组件)
- 5.15 Vue 组件结构与 CSS 变量
- 5.16 验收标准
- 6. 课程表 / 时间表 / 档案 / 科目 / 周次 / 调课 / 导入导出
- 6.1 数据结构(必须逐字段实现)
- 6.2 当天应使用哪张课表:选择算法(必须逐分支实现)
- 6.3 时间状态机与课程计算(主循环)
- 6.4 周次轮换(单双周 / 多周)
- 6.5 时间表编辑(档案编辑窗口 · 时间表页)
- 6.6 课表编辑页与「换课 / 调课 / 临时层」
- 6.7 档案管理(多档案)
- 6.8 科目管理
- 6.9 导入 / 导出
- 6.10 附加设置(AttachedObjects)模型
- 6.11 档案分析(可视化辅助)
- 6.12 边界与异常(必须处理)
- 6.13 验收标准
- 7. 提醒系统(队列、提供者、气泡、强提醒、音效与 TTS)
- 7.1 核心数据结构
- 7.2 队列、优先级与去重(最终确定语义)
- 7.3 提供者与渠道注册
- 7.4 内置提供者行为(逐个实现)
- 7.5 消费者(谁负责显示)
- 7.6 播放流程(气泡两阶段 + 状态机)
- 7.7 气泡视觉与动画参数(像素级)
- 7.8 强提醒:全屏强调特效
- 7.9 声音、语音与音量
- 7.10 取消与清理
- 7.11 设置页(提醒)UI 规格
- 7.12 边界与异常(必须处理)
- 7.13 验收标准
- 7.14 天气服务与数据模型
- 8. 自动化(触发器 / 行动 / 规则集)
- 8.1 概念与总开关
- 8.2 数据结构(必须逐字段实现)
- 8.3 加载与生命周期(必须实现资源纪律)
- 8.4 执行语义(触发 / 规则 / 恢复 / 中断)
- 8.5 内置行动(逐个实现,共 8 个)
- 8.6 内置触发器(逐个实现,共 12 个)
- 8.7 规则集求值(算法必须逐字实现)
- 8.8 内置规则(逐个实现,共 15 个)
- 8.9 时间点行动(TimeType = 3)
- 8.10 设置页 UI(自动化)
- 8.11 边界与异常(必须处理)
- 8.12 验收标准
- 9. 主题系统(内置主题、主题包格式、壁纸取色、主题预览、自定义颜色)
- 9.1 应用主题(明暗 + 强调色)
- 9.2 内置主界面主题
- 9.3 主题包格式(必须实现安装/卸载/打包/市场)
- 9.4 主题包在 ClassHelper 栈中的落地格式(CSS 版)
- 9.5 XAML 主题 → CSS 主题的映射规则
- 9.6 壁纸/屏幕取色
- 9.7 自定义颜色(用户手动覆盖)
- 9.8 主题预览卡片(主题市场 UI)
- 9.9 ClassHelper 侧映射
- 9.10 外观设置页(主题相关控件的精确规格)
- 9.11 主题调试、兼容性与安全边界
- 9.12 验收标准
- 10. 设置中心
- 10.1 设置窗口外壳(必须逐像素对齐)
- 10.2 导航结构(左侧 NavigationView)
- 10.3 页面容器与通用控件(必须映射到 Element Plus)
- 10.4 每个设置页的逐项规格
- 10.5 设置持久化、缓存与叠层(行为级要求)
- 10.6 键盘与交互细节
- 10.7 验收标准
- 11. 其它窗口与向导(闪屏 / OOBE / 崩溃 / 恢复 / 更新 / 换课 / 导入导出 / 迁移 / 教程 / 授权 / 日志)
- 11.1 通用窗口规则(所有窗口必须遵守)
- 11.2 欢迎向导(OOBE)`WelcomeWindow`
- 11.3 启动闪屏 `SplashWindow`
- 11.4 崩溃窗口 `CrashWindow`
- 11.5 恢复模式 `RecoveryWindow` 与恢复页
- 11.6 更新相关窗口与流程
- 11.7 换课窗口 `ClassChangingWindow`
- 11.8 课表详细信息 `ClassPlanDetailsWindow`
- 11.9 Excel 导入向导 `ExcelImportWindow`
- 11.10 Excel 导出窗口 `ExcelExportWindow`
- 11.11 数据迁移 `DataTransferWindow` / `DataTransferPage`
- 11.12 授权窗口 `AuthorizeWindow` 与集控加入对话框
- 11.13 教学(教程)窗口组
- 11.14 日志窗口 `AppLogsWindow`
- 11.15 其余窗口与对话框(逐个规格)
- 11.16 全局快捷键与关闭行为总表
- 11.17 验收标准
- 12. 插件系统、跨进程通信、单实例与自动更新
- 12.1 目标与边界
- 12.2 ClassIsland 插件体系事实(必须对齐的语义)
- 12.3 本实现的插件规范(必须逐项落地)
- 12.4 跨进程通信(IPC)设计(必须完整实现)
- 12.5 单实例与 URI 协议(必须实现)
- 12.6 自动更新(必须按此流程实现)
- 12.7 崩溃自愈与看门狗(必须实现)
- 12.8 验收标准(第 12 部分)
- 13. 数据模型、存储、备份与迁移
- 13.1 存储总则(必须遵守)
- 13.2 目录与文件清单(必须逐项命名一致)
- 13.3 `config.json` 完整 schema(字段级,含默认值)
- 13.4 档案(Profile)schema — 与 ClassIsland 完全同构
- 13.5 组件布局(ComponentProfile)schema
- 13.6 自动化配置 schema
- 13.7 提醒数据
- 13.8 主题数据
- 13.9 其他存储
- 13.10 备份、恢复与迁移(必须实现)
- 13.11 读写一致性与错误处理
- 13.12 天气数据 schema(与 07 天气服务对齐)
- 13.13 验收标准(第 13 部分)
- 14. 动画与动效总表
- 14.0 全局动效规则
- 14.1 A. 主界面(岛)动效
- 14.2 B. 提醒与强提醒动效
- 14.3 C. 启动、OOBE、欢迎动效
- 14.4 D. 弹层、抽屉、列表与设置动效
- 14.5 E. 组件动效
- 14.6 F. ClassHelper 既有动效(必须复用,不得重造)
- 14.7 实现方式规范(必须遵守)
- 14.8 与源码的差异说明(必须写进实现注释)
- 14.9 验收标准
- 15. 资源清单(图标 / 字体 / 音效 / 天气图标 / 教程 / 本地化)
- 15.1 应用图标
- 15.2 字体
- 15.3 音效
- 15.4 天气图标(4 套模板)
- 15.5 贴纸资源(HoYoStickers)
- 15.6 教程资源
- 15.7 本地化
- 15.8 其他资源与文档
- 15.9 ClassHelper 侧资源落点
- 15.10 资源命名、加载与缓存规范
- 15.11 资源版本与许可核对
- 15.12 验收标准
- 16. 质量门禁与验收清单
- 16.1 工程与构建门禁
- 16.2 像素级视觉验收(主界面)
- 16.3 功能行为验收(按模块)
- 16.4 动画验收
- 16.5 数据完整性与容错
- 16.6 性能与资源
- 16.7 平台与兼容性
- 16.8 安全验收
- 16.9 交付物清单
- 17. 开发里程碑与实现顺序
- M0 工程脚手架与进程骨架(0.5–1 天)
- M1 岛窗口 + 时钟引擎 + 基础显示(1–2 天)
- M2 组件系统(2–3 天)
- M3 课程表与档案系统(3–4 天)
- M4 提醒系统(2–3 天)
- M5 设置中心 + OOBE(2–3 天)
- M6 主题系统(+ 可选壁纸取色 P2)(1–2 天)
- M7 自动化 + 天气 + 高级窗口(2–3 天)
- M8 插件、更新、恢复与打磨(2–4 天)
- 里程碑总览表
- 18. 禁止事项与实现边界
- 18.1 关于「不打扰」的硬性要求
- 18.2 技术红线
- 18.3 数据与安全红线
- 18.4 质量与流程红线
- 18.5 本提示词未明确时的裁决顺序
- 19. 结语(给实现者的最后要求)
## 0. 给 ChatGPT 的任务声明(从本节开始执行)
### 0.1 你的任务
从零实现一个名为 **ClassIsland**(实现代号 `classisland-ts`)的跨平台桌面应用:它常驻班级多媒体屏幕,以悬浮「岛」显示当天课表、当前课程、倒计时、日期时间、天气等信息;在上下课等重要时间点发出包含音效、语音与全屏特效的提醒;并提供课表编辑、档案管理、组件系统、主题、自动化、插件、自动更新等完整能力。灵感来源是 iOS 灵动岛(Dynamic Island),但它是为教室大屏与「不打扰授课」场景重新设计的桌面工具。
你必须交付**可安装、可离线运行、可长时间无人值守运行**的 Electron 应用,而不是网页 Demo 或原型。视觉与交互规格必须严格按本提示词执行,尤其是第 3(设计 tokens)、4(主界面)、5(组件)、14(动画)部分中的数值。
### 0.2 不可协商的技术栈
- pnpm@11.8.0 monorepo;Node.js >= 20.19;TypeScript ~5.9.3(strict)。
- Electron ^44.3.0 + electron-builder ^26.15.3;主进程/preload 用 esbuild 打包,渲染层用 Vite ^8.3.0 多入口(`index.html` 应用窗口、`island.html` 岛)。
- Vue ^3.5.42 + Vue Router ^5.3.1 + Pinia ^4.0.3;Element Plus ^2.14.5 仅用于设置/管理类界面,**不得进入岛的渲染进程**。
- 三个进程角色:主进程(窗口/调度/配置/更新/IPC)、岛渲染进程(常驻、透明、逐像素自绘)、应用窗口渲染进程(按需创建)。
- 复用 ClassHelper 已验证的实现模式:独立 `island.html` + 独立透明 BrowserWindow、连续圆角 `squircle.ts`、弹簧动画 `spring.ts`、`config.json` 原子读写、contextBridge 白名单、`tokens.css` 设计变量、自研 `auto-update.ts`(禁止 electron-updater)、崩溃守卫 `crash-guard.ts`。
- 不引入服务端依赖;所有业务数据本地存储;可选管理端联动只能作为 P2 可裁剪子包。
### 0.3 硬性纪律(违反即不合格)
1. 所有自动验证必须无头/离屏执行;不得显示可见窗口、托盘气泡、系统通知或声音;需要人工观察的验证用显式 `--visual` 参数开启且不得作为默认。
2. 渲染层零 Node 权限:preload 只暴露 contextBridge 白名单,不暴露 `ipcRenderer`;`nodeIntegration=false`、`contextIsolation=true`、`sandbox=true`。
3. 主窗口(岛)不得抢焦点、默认不进入任务栏;不得使用 `setBackgroundMaterial()` 等破坏逐像素透明的 API。
4. 动画必须 60fps:优先 transform/opacity,禁止 `setInterval` 逐帧驱动、禁止逐帧改布局属性。
5. 核心功能离线可用;数据写入原子化并自动备份;迁移前先备份。
6. 不得伪造验收结果;未验证项必须显式标注。
### 0.4 交付物
- 完整源码 + `pnpm-lock.yaml` + 构建/打包脚本;`README.md` 含运行、构建、打包、数据目录说明。
- `docs/architecture.md`、`docs/data-schema.md`、`docs/plugin-api.md`、`docs/theme-api.md`。
- 至少 1 个示例课表档案、1 个示例主题、1 个示例插件(P1)。
- 无头测试(单元 + 组件 + 端到端)与第 16 部分的验收报告。
### 0.5 工作方式
1. 通读本提示词后,先输出:实施计划、完整文件树、你识别出的歧义点(一次性提问)。
2. 按第 17 部分的 M0→M8 顺序实现;每个里程碑结束都交付「可无头启动 + 通过该阶段验收」的状态。
3. 每完成一个模块,写清楚:实现了什么、证据(文件与行号)、未实现/推断项、如何验证。
4. 遇到本提示词未覆盖的细节:按第 18.5 节的优先级裁决,并在代码注释标注「推断」。
5. 不得把不同模块混在一起「顺手重构」;保持提交/变更范围单一、可回滚。
### 0.6 术语与阅读顺序
- **岛(Island / MainWindow)**:常驻悬浮主界面;由一行或多行「行(Line)」组成。
- **行(MainWindowLine)**:承载组件的横向容器,拥有自己的背景、进度条、遮罩与提醒气泡。
- **组件(Component)**:最小显示单元(日期、时钟、倒计时、课程表、天气等),可嵌套。
- **档案(Profile)**:一套课表/时间表/科目的配置集合,对应一个 JSON 文件。
- **课表(ClassPlan)** 与 **时间表(TimeLayout)**:见第 6 部分。
- **提醒(Notification)**、**自动化(Automation)**、**规则集(Ruleset)**、**主题(XamlTheme→CSS 主题包)**、**插件(Plugin)**:见对应章节。
- 阅读顺序:0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19。
---
## 1. 项目总纲:产品定位、总体体验与系统架构
### 1.1 你的角色与最终目标
你是一名资深跨平台桌面应用工程师,精通 Electron、Vue 3、TypeScript 与现代桌面 UI 动效实现。你的任务是从零实现一个**班级多媒体屏幕课表信息显示工具**,本提示词称其为 **ClassIsland(实现代号 `classisland-ts`)**。它常驻在教室大屏/讲台电脑上,以悬浮「岛」的形式显示当天课表、当前课程、倒计时、天气、日期时间等信息,并在上课、下课、课前准备、放学、天气变化等重要时间点发出提醒,同时提供课表编辑、组件自定义、主题、自动化、插件扩展等一整套能力。
最终交付必须是一个**可安装、可离线运行、可长时间无人值守运行**的 Windows / macOS / Linux 桌面应用,而不是网页 Demo。你必须严格按本提示词的数值、交互、动画与数据结构实现;凡本提示词给出确切数值的地方,不得用「差不多」的默认样式替代。
### 1.2 产品定位
- 一句话定位:**给班级多媒体屏幕用的、灵感来自 iOS 灵动岛的课表与班级信息悬浮显示工具。**
- 使用环境:Windows 10+(主目标)、macOS 11+、Linux X11(不支持 Wayland);屏幕常见 1920×1080、2560×1440、3840×2160,缩放 100%–200%,也可能是希沃等触摸一体机。
- 使用方式:开机自启,常驻桌面最上层;上课时悬浮在课件上方但不遮挡关键内容;教师可通过右键菜单/托盘快速进入编辑、换课、设置;放学后可由自动化或管理策略隐藏。
- 核心价值:**零打扰**(不弹系统级窗口、不抢焦点、可点击穿透)、**秒级准确**(网络对时 + 本地时钟补偿)、**一眼可读**(大字号、强对比、信息分层)、**完全可定制**(组件、主题、课表、提醒、自动化)。
- 明确不是:不是 Web 管理系统、不是手机 App、不是需要登录云服务的 SaaS。除天气、更新、可选管理端外,全部功能必须离线可用。
### 1.3 目标用户与典型场景
1. **班主任/任课老师**:用 Excel 导入课表,设置单双周/多周轮换、临时换课;设置上下课提醒音效与语音;用自动化在午休时关闭屏幕。
2. **电教委员/学生**:日常只看岛,不需要任何操作;断网、重启后应用自动恢复;不误触、不遮挡投影。
3. **学校管理员**:通过主题/配置批量部署;可选管理端下发策略(本提示词的 P2 可选模块,默认不依赖服务器)。
4. **插件开发者**:按插件规范扩展组件、提醒提供者、自动化触发器/行动、设置页。
### 1.4 十条不可妥协的体验原则
1. **像素级还原本提示词的视觉规格**:圆角、透明度、字号阶梯、间距、进度条、阴影必须逐项落实;默认参数见第 3、4 部分。
2. **绝不打扰授课**:主窗口默认不抢焦点(`show: false` 显示、`focusable: false`)、不进入任务栏、可配置鼠标穿透;任何自动验证/冒烟必须无头运行,不得创建可见窗口。
3. **60fps 丝滑动画**:所有过渡优先使用 CSS transform/opacity(合成层),禁止用 JS 逐帧改布局;弹簧动画采用本提示词给定的物理参数。
4. **时间必须准**:所有课程状态以「带补偿的当前时间」计算;支持 NTP 对时、手动时间偏移、时间倍速调试;跨天、跨周、闰秒、系统休眠唤醒都要正确。
5. **离线优先、数据安全**:配置、课表、提醒全部本地存储;写入采用原子替换 + 自动备份;任何迁移前先备份,迁移失败可回滚。
6. **组件化与可组合**:主界面的每一行、每一个显示单元都是组件;组件可嵌套(分组/堆叠/滚动/滑动)、可单独设置、可按规则隐藏。
7. **默认即好看**:首次启动走完 OOBE 后,即使不导入课表也要显示一个可用的岛(日期、时间、欢迎信息),不能白屏。
8. **可恢复**:渲染进程崩溃、GPU 崩溃、配置损坏、误删数据时,应用必须能自愈或进入恢复模式;崩溃后不得反复重启刷屏。
9. **可扩展**:插件/主题/提醒提供者/自动化行动全部通过注册表扩展,核心不写死;公共 API 保持向后兼容。
10. **可访问与本地化**:中文为默认语言(ClassIsland 2.1 的界面文案为硬编码中文);支持高对比、减少动画(`AnimationLevel`)、键盘操作、触摸操作;文案集中到常量/词条文件,预留 i18n key 体系,不得把字符串散落在组件里。
### 1.5 功能全景(必须实现的模块)
> 优先级:P0 = 首版必须;P1 = 首个稳定版必须;P2 = 可分期但提示词要求预留架构。
| 模块 | 能力清单 | 优先级 |
|---|---|---|
| 主界面(岛) | 多行岛、背景/圆角/透明度/毛玻璃、课程信息行、进度条、倒计时、遮罩/提醒宿主、编辑模式、隐藏规则、点击穿透、多显示器停靠、缩放、窗口置顶 | P0 |
| 组件系统 | 日期、时钟、倒计时、课程表、天气、文本、滚动、滑动、堆叠、分组、分隔线、容器;组件设置、显示/隐藏规则、多行组件、轮播 | P0 |
| 课程表与档案 | 时间表(节次/课间/分割线/行动)、课表(单日/多日/周模板)、多周轮换(1–9 周)、科目与教师、临时课表/预定课表、单日与跨天换课、CSES 导入导出(P0)、Excel 导入导出(P1,用 xlsx 重建;ClassIsland 2.1 源码中该功能被 `#if false` 停用,布局仅作参考)、多档案 | P0/P1 |
| 提醒系统 | 提醒队列与优先级、上/下课/课前/放学提醒、天气类提醒、自定义提醒、气泡与强提醒特效、音效、TTS 语音、提醒历史与清除(ClassIsland 2.1 无独立「静音时段」;只有全局提醒开关与 `-q` 静默启动后 10s 丢弃提醒,不得虚构静音时段,P2 扩展需默认关闭) | P0 |
| 设置中心 | 通用/外观/组件/提醒/自动化/天气/窗口/时钟/主题/更新/存储/插件/管理/隐私/高级/关于/调试,即时保存、设置覆盖层、按注册顺序导航(ClassIsland 2.1 无搜索框,本实现不编造搜索;如需可标 P2 增强) | P0 |
| OOBE 向导 | 8 页:欢迎、许可、翻新/迎新、基本、颜色、外观、系统、完成;可跳过、可重复打开 | P0 |
| 主题系统 | 内置 Fluent/Classic 两套主题、主题包(CSS 变量 + 资源 + 预览图)、自定义强调色/背景色/前景色、深浅色跟随系统;壁纸取色在 ClassIsland 2.1 中已整体停用(`WallpaperPickingService` 全文 `#if false`,设置页对应项禁用隐藏),本实现只作为 P2 实验性能力(默认关闭、给算法与数据结构、不纳入 P1 验收) | P1(壁纸取色 P2) |
| 自动化 | 触发器(时间点/CRON/信号/URI/托盘菜单/窗口状态/当前课程/天气/日出日落)、行动(提醒/运行程序/打开文件网页/关机/睡眠/重启/修改设置/广播信号/切换课表等)、规则集(嵌套条件) | P1 |
| 天气 | 当前天气、逐小时/未来 3 天预报、降水提醒、极端天气预警、定位/城市选择、天气图标主题 | P1 |
| 插件系统 | 插件包格式、清单、权限、生命周期、组件/提醒/触发器/行动/设置页扩展点、插件市场(可选)、启用/禁用/更新 | P1 |
| 调试与诊断 | 日志查看、配置校验、崩溃上报(可选 Sentry)、渲染模式切换、时间调试、动画调试、窗口规则调试、教程系统 | P1 |
| 更新与运维 | 自动检查更新、镜像/通道、断点下载、哈希校验、静默安装、崩溃恢复、单实例、开机自启 | P1 |
| 管理端联动 | 可选远程策略、管理凭据、通知通道(P2,默认关闭且不得成为运行前置依赖) | P2 |
### 1.6 总体技术架构(ClassHelper 栈)
**必须采用 ClassHelper 的技术栈与工程约定**(来源:`repos/ClassHelper-master/package.json`、`pnpm-workspace.yaml`、各 `packages/*/package.json`、`AGENTS.md`):
- 包管理器:`pnpm@11.8.0`,Node.js >= 20.19;根 `package.json` 为版本单一来源。
- 语言:TypeScript ~5.9.3,开启 `strict`;Vue 3.5 SFC(`<script setup lang="ts">`),禁止 `any` 泛滥。
- 桌面壳:Electron ^44.3.0 + electron-builder ^26.15.3;Windows 目标 NSIS/portable,macOS dmg/zip,Linux AppImage/deb(任选其一即可,但构建脚本必须齐全)。
- 渲染层:Vue ^3.5.42 + Vue Router ^5.3.1 + Pinia ^4.0.3 + Element Plus ^2.14.5(仅设置/管理类界面使用 Element Plus;主界面「岛」使用手写 CSS 与自定义组件,**不得**让 Element Plus 的默认样式污染岛)。
- 构建:Vite ^8.3.0(`@vitejs/plugin-vue` ^6.0.8),多入口构建:`index.html`(设置/主窗口)与 `island.html`(独立岛渲染进程)。
- 进程模型(必须三进程分离):
1. **主进程 `src/main`**:窗口管理、托盘、全局快捷键、单实例、配置读写、调度(时间引擎/提醒调度/自动化)、自动更新、系统集成、IPC 服务端。
2. **岛渲染进程 `island.html` → `src/island`**:只负责画「岛」,常驻内存,通过 contextBridge 暴露的最小 API 接收状态;窗口透明、点击穿透由主进程控制。
3. **应用窗口渲染进程 `index.html` → `src/renderer`**:设置中心、档案编辑、OOBE 向导、关于、日志、调试、恢复等;按需创建,关闭即销毁(pinia 状态持久化到主进程)。
- 共享包:`packages/shared`(类型、常量、i18n 词条、数据 schema、纯函数业务逻辑:时间/周次/课表解析)、`packages/ui`(CSS tokens、基础 Vue 控件、主题)。
- 目录结构必须为 pnpm monorepo(`packages/*`),至少包含:`packages/desktop-client`(Electron 应用)、`packages/shared`、`packages/ui`。若实现插件宿主,可增加 `packages/plugin-sdk`(P1)。
- 复用 ClassHelper 已验证的桌面岛关键实现(可直接参照其代码组织与参数):
- 独立 `island.html` 渲染入口 + 独立透明 BrowserWindow(见 `repos/ClassHelper-master/packages/desktop-client/src/main/island.ts`)。
- `squircle.ts` 连续圆角(超椭圆指数 4.2,每角 14 段,半径 `min(r, w/2, h/2)`)。
- `spring.ts` 弹簧动画(`stiffness=0.10`、`damping=0.68`、换向动量保留 35%、单帧位移 ≤ 剩余距离 20%、snap 阈值 0.05px、60fps 帧单位)。
- `config.json` + `config.ts` 的原子读写、版本迁移、默认值合并(参照 `src/main/config.ts`)。
- preload 只暴露 contextBridge 白名单,不暴露 `ipcRenderer`;渲染层不得直接访问 `fs`/`child_process`。
- `renderer/composables/motion.ts` 的统一动效 token;`packages/ui/src/tokens.css` 的颜色/圆角/动效变量体系。
- **不得引入 ClassHelper 的服务端依赖**:本应用是本地单机应用;Express/Prisma/Socket.IO 仅在你选择实现 P2「管理端联动」时才可作为可选子包,且主流程不得依赖网络。
- 允许使用成熟开源库完成非核心能力(如 `xlsx` 解析 Excel、`yaml` 读取插件清单、`adm-zip` 解压插件/主题包);**更新器必须自研**(参照 ClassHelper 的 `src/main/auto-update.ts` + `update-installer.ts`,禁止引入 electron-updater);不要引入 `node-pty`、`sharp` 等原生模块。所有依赖必须列明版本并锁定 `pnpm-lock.yaml`。
**逻辑分层(进程内)**:
```
main
├─ config/ 配置读写、迁移、备份(Settings/Profiles/Automations/Components/Themes)
├─ windows/ island / app / splash / topmost-effect / class-changing / oobe ...
├─ engine/
│ ├─ clock NTP 对时 + 偏移 + 倍速;对外只暴露当前时间
│ ├─ lessons 课表解析、当前/下一节、时间点状态机、周次计算
│ ├─ notification 提醒队列、提供者、优先级、去重、TTS/音效
│ ├─ automation 触发器轮询、行动执行、规则集求值
│ ├─ weather 天气拉取、缓存、预警
│ └─ update 更新检查/下载/安装
├─ ipc/ contextBridge 白名单 + 通道校验 + 节流
└─ tray/ 托盘菜单、开机自启、单实例
renderer(island) IslandApp.vue + 组件渲染器 + 动画
renderer(app) SettingsWindow / ProfileEditor / OOBE / About / Logs / Recovery ...
```
### 1.7 信息架构与导航
- 主界面(岛)是唯一常驻界面,无标题栏、无任务栏图标、无边框。右键(或长按触摸)弹出**原生上下文菜单**:ClassIsland(关于)、教学…、帮助文档…、显示/隐藏主界面、清除全部提醒、更多选项…、编辑档案…、加载临时课表…、换课…、编辑主界面…、应用设置…、重启、退出(调试模式下追加 dev__Debug 子菜单)。菜单项文案、图标、可见条件必须与第 4 部分一致。
- 托盘图标提供左键行为(默认打开主菜单/打开档案编辑/显示隐藏主界面/打开换课/打开设置,见设置项 `TaskBarIconClickBehavior` 默认值:Windows 0,其他平台 4)。
- 应用窗口为 Fluent 风格的单窗口 + 页面栈:设置中心(左侧导航列表 + 面包屑返回;ClassIsland 2.1 **无设置搜索框**,本实现不得编造搜索,如做需明确标 P2 增强)、档案编辑器(左侧档案树 + 右侧编辑区)、OOBE(无侧栏的 8 页步骤向导)、关于、日志、调试、恢复模式;提醒不设独立「通知中心」窗口,由主界面行内气泡 + 强提醒特效承载。
- 所有次级窗口居中显示、可用 Esc 关闭、标题栏使用系统样式(Fluent 主题下自绘标题栏);关闭设置窗口不退出应用,仅隐藏。
- URL Scheme:`classisland://app/...` 用于深链(关于页、设置页等),由单实例机制转发到已运行实例。
### 1.8 首次启动与默认状态
1. 启动时先显示无边框闪屏(可选,默认关闭;打开时 1.2s 内渐显 Logo 与应用名,可显示加载状态),随后显示 OOBE。
2. OOBE 完成后写 `config.json`(并兼容导出 ClassIsland 的 `Settings.json`)的 `IsWelcomeWindowShowed=true`,主界面以默认参数出现:位置=中上侧(`WindowDockingLocation=1`)、缩放 100%、背景不透明度 0.5、圆角 8px、字体默认系统字体(Windows 为微软雅黑 UI 回退链)、主体字号 16、次要 14、强调 18、超大 20、行间距 5px。
3. 默认组件配置为 1 行 2 组件:日期(DF3F8295-21F6-482E-BADA-FA0E5F14BB66)+ 课程表(1DB2017D-E374-4BC6-9D57-0B4ADF03A6B8,依据 ComponentsService.cs:33-52)。未加载档案时课程表组件显示「今天没有课程。」等占位符,而不是白屏或欢迎文本;加载课表后显示课程信息。
4. 默认设置必须保证:岛可见、不穿透、不抢焦点、不遮挡全屏应用(`HideOnFullscreen=false`,可开启)、提醒开启、音效开启但音量 100%、调试关闭。
### 1.9 本提示词的使用方式与验收总则
- 本提示词按「总纲 → 技术栈 → 设计系统 → 主界面 → 组件 → 课表/提醒/自动化 → 主题/设置/窗口 → 动画/资源 → 插件/数据 → 验收」组织。实现时必须全部通读,再按第 17 部分的里程碑顺序开发。
- 每一部分末尾都有「验收标准」;你必须逐条自测并在交付说明中报告结果。自测必须无头执行(`--headless` / 隐藏窗口 / CI 模式),**不得弹出可见窗口、不得占用用户桌面、不得发出声音**;像素级验收使用 Playwright/`electron` 的离屏截图模式完成。
- 任何你没有把握的数值,优先服从本提示词;本提示词未覆盖的细节,按「Fluent Design + 灵动岛」的直觉补齐,并在代码注释中说明。
- 交付必须包含:完整源码、`README.md`(含运行/构建/打包命令)、`docs/`(数据 schema 与插件 API)、至少 1 个示例主题与 1 个示例插件(P1)、以及一套无头自动化测试(单元测试 + 关键交互的组件测试)。
---
## 2. 技术栈与工程结构
### 2.1 技术选型总则(必须逐条遵守)
1. 你必须用 **TypeScript + Electron + Vue 3** 实现本应用,工程形态必须是 **pnpm monorepo**,不得做成单包项目、不得用 Webpack、不得用 Electron Forge、不得用 `electron-vite` 之外的脚手架替代本文规定的手写构建管线。
2. 你必须**完整沿用 ClassHelper 桌面客户端的工程约定**:主进程/preload 用 esbuild 打包为 CommonJS,渲染进程用 Vite 多入口打包,打包用 electron-builder;共享类型、常量、纯函数放 `packages/shared`;共享 Vue 控件与样式令牌放 `packages/ui`;Electron 应用全部代码放 `packages/desktop-client`。依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-2`、`repos/ClassHelper-master/packages/desktop-client/package.json:8-18`。
3. 你**不得**把 ClassHelper 的服务端(Express/Prisma/Socket.IO)引入主流程。ClassIsland 是本地单机应用,一切业务数据必须落在本机;仅当实现「可选管理端联动」时才允许把 `packages/server` 作为可裁剪子包,且主流程不得依赖网络。依据:`repos/ClassHelper-master/packages/server/package.json:19-40` 的依赖清单,以及总纲的 P2 定义。
4. 你必须把「版本」收敛为**单一来源**:根 `package.json` 的 `version` 是唯一手写值,所有子包版本由脚本同步;日常功能提交不得涨版本。依据:`repos/ClassHelper-master/AGENTS.md`「根 package.json 是版本单一来源」与 `repos/ClassHelper-master/docs/dev/release.md:8-10`。
5. 你必须保持「渲染层零 Node 能力」:渲染进程不得 `require('node:fs')`、不得访问 `process` 环境变量之外的 Node API,所有文件、系统、更新、托盘能力一律经主进程 IPC。依据:`repos/ClassHelper-master/AGENTS.md`「preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer」。
### 2.2 依赖清单(版本必须按此锁定)
以下版本是硬约束,`pnpm-lock.yaml` 必须锁定到这些 semver 区间;不得因为"想用新 API"而升级大版本。
| 用途 | 包名 | 版本 | 证据 |
|---|---|---|---|
| 包管理器 | pnpm | `11.8.0`(`packageManager` 字段) | `repos/ClassHelper-master/package.json:6` |
| Node 运行时 | Node.js | `>=20.19.0` | `repos/ClassHelper-master/package.json:7-9` |
| 桌面壳 | `electron` | `^44.3.0` | `repos/ClassHelper-master/packages/desktop-client/package.json:32` |
| 打包 | `electron-builder` | `^26.15.3` | 同上 `:34` |
| UI 框架 | `vue` | `^3.5.42` | 同上 `:26` |
| 路由 | `vue-router` | `^5.3.1` | 同上 `:27` |
| 状态 | `pinia` | `^4.0.3` | 同上 `:24` |
| 组件库(仅设置/管理界面) | `element-plus` | `^2.14.5` | 同上 `:23` |
| 图标 | `@element-plus/icons-vue` | `^2.3.2` | 同上 `:21` |
| 构建 | `vite` | `^8.3.0` | 同上 `:37` |
| Vue 插件 | `@vitejs/plugin-vue` | `^6.0.8` | 同上 `:32` |
| 主进程打包 | `esbuild` | `^0.28.2` | 同上 `:35` |
| 语言 | `typescript` | `~5.9.3` | 根 `package.json:74` |
| 类型检查 | `vue-tsc` | `^3.3.11` | `desktop-client/package.json:38` |
| Node 类型 | `@types/node` | `^22.20.2` | 同上 `:31` |
允许额外引入的第三方库(必须写进依赖清单并锁版本):`xlsx`(Excel 导入导出;**P1 必须实现**,用 xlsx 库重建 ClassIsland 的 ReoGrid/WPF 版导入导出,ClassHelper 服务端同款 `0.18.5`,见 `packages/server/package.json:38`)、`adm-zip` 或 `yauzl`(`.cipx`/主题包解压)、`yaml`(读取插件 `manifest.yml`,见 `packages/server/package.json:40`)、`electron-log` 可选但**不强制**(本文要求自研 `logger.ts`,理由见 2.8)。禁止引入:`electron-updater`(本文的更新模型是自研的哈希校验 + 独立安装助手,见 2.11 与 12 部分)、`better-sqlite3` 等原生模块(`electron-builder.yml:37-38` 明确 `npmRebuild: false`、无原生模块)、任何需要在线服务的 SDK。
`pnpm-workspace.yaml` 必须显式声明允许执行构建脚本的依赖,否则 pnpm 11 会因 `ERR_PNPM_IGNORED_BUILDS` 直接失败:
```yaml
packages:
- 'packages/*'
allowBuilds:
esbuild: true
electron: true
vue-demi: true
electron-winstaller: false
verifyDepsBeforeRun: false
```
依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-26`(含注释解释 electron-winstaller 为何必须显式为 false)。
### 2.3 仓库目录结构(必须逐项落地)
你必须在仓库根建立如下结构;标注「新增」的是本应用特有模块,其余照抄 ClassHelper 的组织方式。
```text
classisland-ts/
├─ package.json # 版本单一来源、pnpm@11.8.0、engines>=20.19
├─ pnpm-workspace.yaml
├─ pnpm-lock.yaml
├─ tsconfig.base.json # target ES2023、strict、verbatimModuleSyntax
├─ .prettierrc.json # LF、单引号、100 列
├─ scripts/
│ ├─ version.mjs # 版本唯一修改入口(--bump patch|minor|major)
│ ├─ check-icons.mjs # 图标资源完整性门禁
│ └─ lib/electron-env.mjs # 跨平台解析 electron 可执行文件
├─ packages/
│ ├─ shared/ # 三端共享:类型/常量/schema/纯函数
│ │ └─ src/
│ │ ├─ index.ts # 统一导出(照抄 packages/shared/src/index.ts)
│ │ ├─ constants.ts # 尺寸/默认值/枚举/映射表
│ │ ├─ types.ts # DTO 与持久化类型
│ │ ├─ motion.ts # OK 动效令牌(纯常量+纯函数)
│ │ ├─ island-layout.ts # 岛行/组件方案 schema + normalize(新增扩展)
│ │ ├─ schedule.ts # 时间表/课表/周次纯函数(新增)
│ │ ├─ time.ts # 本地日期/日键/时间补偿(新增)
│ │ ├─ notifications.ts # 提醒 DTO/优先级(新增)
│ │ ├─ automation.ts # 自动化 DTO(新增)
│ │ ├─ plugins.ts # 插件清单/权限/扩展点类型(新增)
│ │ └─ ipc.ts # IPC 通道名与 payload 类型(新增,主进程与 preload 共用)
│ ├─ ui/ # 共享 Vue 控件与样式令牌
│ │ └─ src/
│ │ ├─ index.ts # registerSharedUi(app)
│ │ ├─ tokens.css # 基础色/圆角/间距(照抄)
│ │ ├─ theme.css # 语义色 + html.dark(照抄后扩展)
│ │ └─ *.vue # DatePicker/PageHeader/FullScreenGuide 等
│ └─ desktop-client/ # Electron 应用
│ ├─ package.json # main: dist/main/index.js
│ ├─ electron-builder.yml
│ ├─ index.html # 应用窗口(设置/编辑/OOBE)
│ ├─ island.html # 岛渲染入口(必须独立)
│ ├─ vite.config.mts # 双入口、base './'、outDir dist/renderer、5174
│ ├─ tsconfig.json # 渲染层(DOM lib)
│ ├─ tsconfig.node.json # 主进程/preload(node lib)
│ ├─ scripts/
│ │ ├─ dev.mjs # vite 就绪后起 electron(VITE_DEV_SERVER_URL)
│ │ ├─ build-main.mjs # esbuild -> dist/main + dist/preload
│ │ ├─ build-update-helper.mjs # 编译独立更新助手(新增,见 2.11)
│ │ ├─ dist-win.mjs # electron-builder 包装 + 产物归档
│ │ └─ check-bundle-secrets.mjs # 打包不得含凭据门禁
│ └─ src/
│ ├─ main/
│ │ ├─ index.ts # 启动编排(照抄 ClassHelper 顺序)
│ │ ├─ config.ts # config.json 读写/归一化/合并写
│ │ ├─ channels.ts # IPC 通道名叶子模块(零 import)
│ │ ├─ ipc.ts # ipcMain.handle 注册表
│ │ ├─ island.ts # 岛窗口控制器(ClassIsland 主界面宿主)
│ │ ├─ main-window.ts # 设置/编辑窗口管理(新增)
│ │ ├─ tray.ts # 系统托盘
│ │ ├─ logger.ts # main.log + 1MB 轮转
│ │ ├─ crash-guard.ts # 崩溃自愈
│ │ ├─ auto-update.ts # 下载/校验/空闲安装
│ │ ├─ update-installer.ts # 安装助手就绪/提交/回滚
│ │ ├─ storage.ts # 数据文件读写/备份/迁移(新增,见 13 部分)
│ │ ├─ scheduler.ts # 时间引擎:上课/下课/准备/放学(新增)
│ │ ├─ automation.ts # 自动化运行时(新增)
│ │ ├─ notifications.ts # 提醒队列/宿主(新增)
│ │ ├─ plugins.ts # 插件宿主(新增,见 12 部分)
│ │ └─ platform/ # 开机自启、单实例、DPI、全屏检测
│ ├─ preload/
│ │ ├─ index.ts # contextBridge 暴露 window.desktop
│ │ └─ island.ts # contextBridge 暴露 window.island
│ ├─ island/ # 岛渲染进程(手写 CSS,零 Element Plus)
│ │ ├─ main.ts # 注入动效令牌后 mount
│ │ ├─ LayoutIslandApp.vue # 选择经典/自定义渲染
│ │ ├─ spring.ts # SpringValue 帧积分
│ │ ├─ squircle.ts # 连续圆角路径
│ │ ├─ classic-island.css # 经典岛样式
│ │ └─ components/ # 岛内组件(日期/时钟/课程/进度/提醒)
│ ├─ renderer/ # 应用窗口渲染进程
│ │ ├─ main.ts # 挂载顺序见 2.9
│ │ ├─ router/index.ts # createWebHashHistory
│ │ ├─ stores/ # pinia:ui/notifications/onboarding/profile/settings
│ │ ├─ views/ # 设置中心、档案编辑、OOBE、关于…
│ │ ├─ composables/motion.ts # applyMotionTokens/useReducedMotion/useCountUp…
│ │ ├─ api/ # 本地数据访问(经 preload IPC,不含网络)
│ │ └─ styles/index.css # 语义色令牌 + 全局样式
│ └─ types/desktop.d.ts # 主进程↔渲染进程桥接契约
```
依据:`repos/ClassHelper-master/packages/desktop-client/package.json:7-18`(入口与脚本)、`vite.config.mts`(入口与产物)、`src/main/*`、`src/preload/*`、`src/island/*`、`src/renderer/*` 的既有布局。
### 2.4 进程模型(必须五类进程分离)
1. **主进程(`src/main/index.ts`)**:唯一持有窗口、托盘、配置、定时器、文件系统、自动更新、插件宿主的进程。它必须承载 ClassIsland 的「Core 服务层」职责:时间引擎、课表状态机、提醒宿主、自动化、存储与备份、插件注册表。
2. **应用窗口渲染进程(`index.html`)**:设置中心、档案编辑器、OOBE、关于页、日志、调试、恢复。按需创建;关闭窗口必须只隐藏不退出(ClassIsland 主窗口关闭即隐藏的逻辑改成「设置窗口关闭=销毁,托盘常驻」)。
3. **岛渲染进程(`island.html`)**:常驻内存,只画岛。必须独立 `BrowserWindow`,独立 preload(`dist/preload/island.js`),独立 CSS,**不得**加载 Element Plus。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:480-512`(窗口参数与 preload)、`src/island/main.ts:1-30`(零 UI 库)。
4. **插件/自动化隔离进程(`utilityProcess`,Electron 44 内置)**:插件代码、用户自动化脚本、外部命令执行必须跑在这个进程里,崩溃不得拖垮主进程;与主进程之间用 `MessagePort` 传纯数据。ClassIsland 用 `PluginLoadContext` 做程序集隔离(`repos/ClassIsland/ClassIsland/Services/PluginService.cs:186-235`),在 Electron 下等价物就是 utilityProcess + 动态 `import()`。
5. **独立更新助手(`update-helper.exe`)**:Windows 下由构建脚本生成的小型可执行程序,负责"旧进程退出后替换文件、失败回滚、写 `install-result.json`"。不得由主进程自己替换正在运行的 exe。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:516-564`(就绪→commit→退出)与 `scripts/build-main.mjs:12`(构建时产出助手)。
### 2.5 构建管线(命令与产物必须一致)
**主进程与 preload**:用 esbuild 打成 CJS,`platform: 'node'`、`target: 'node20'`、`external: ['electron']`、`define process.env.NODE_ENV`;产物固定为:
- `dist/main/index.js`
- `dist/preload/index.js`
- `dist/preload/island.js`
- `dist/main/update-helper.exe`(构建时由 `build-update-helper.mjs` 生成)
依据:`repos/ClassHelper-master/packages/desktop-client/scripts/build-main.mjs:14-46`。
**渲染进程**:用 Vite 双入口构建:`index.html`(应用窗口)与 `island.html`(岛);`base: './'`(Electron 以 `file://` 加载,必须相对路径)、`outDir: 'dist/renderer'`、`emptyOutDir: true`、`sourcemap: false`、`chunkSizeWarningLimit: 2000`;dev 服务器固定 `127.0.0.1:5174`、`strictPort: true`。依据:`repos/ClassHelper-master/packages/desktop-client/vite.config.mts:13-39`。
**开发模式**:`scripts/dev.mjs` 必须先探测 5174 端口可用,再以 `VITE_DEV_SERVER_URL=http://127.0.0.1:5174` 启动 Electron;主进程读到该变量时 `loadURL` 并打开 DevTools,读不到时 `loadFile('../renderer/index.html')`;岛的开发地址是 `${devUrl}/island.html`。依据:`src/main/index.ts:18`、`:137-145`、`:221`;`scripts/dev.mjs:21-60`。
**打包**:`electron-builder.yml` 必须包含:
| 项 | 值 | 证据 |
|---|---|---|
| appId | `com.classisland.desktop`(从 ClassHelper 改名) | `electron-builder.yml:1` |
| productName | `ClassIsland` | `:2` |
| 语言包 | 仅 `zh-CN`(删除其余 54 个 locales) | `:14-15` |
| files | `dist/**/*`、`package.json`、排除 `node_modules`、排除 `*.map` | `:30-34` |
| asar | `true` | `:36` |
| npmRebuild | `false`(无原生模块) | `:37-38` |
| compression | `normal` | `:39` |
| win target | `nsis` x64 + `portable` x64 | `:42-47` |
| artifactName | `${productName}-${version}-${arch}.${ext}` | `:52` |
| nsis | `oneClick:false`、`perMachine:false`、`allowToChangeInstallationDirectory:true`、桌面+开始菜单快捷方式 | `:54-61` |
| portable | `${productName}-${version}-${arch}-portable.${ext}` | `:63-64` |
**必须建立的门禁脚本**(在 CI/发布前按序执行,任何一步失败即中止):
1. `pnpm build:shared`(先建共享包,再建下游——依据 ClassHelper 根脚本 `package.json:11-17`)。
2. `pnpm typecheck`(`vue-tsc --noEmit` + `tsc --noEmit -p tsconfig.node.json`,依据 `desktop-client/package.json:16`)。
3. `pnpm lint`(ESLint + 自定义 authz/资源检查)。
4. `pnpm check:icons`(图标资源完整性)。
5. `pnpm build:desktop`(= check-icons + build:main + vite build + check:secrets,依据 `desktop-client/package.json:10-13`)。
### 2.6 应用启动时序(必须按此顺序,不得提前读配置)
1. 解析命令行参数(`--hidden` 静默启动、`--safe` 安全模式、`--diagnostic` 诊断模式、`--recovery` 恢复模式、`--importV1/--importV2` 导入)。
2. 若存在 `ELECTRON_SMOKE_PROFILE`(仅自动化验收使用),`app.setPath('userData', profile)`;正常启动不得设置。
3. `app.requestSingleInstanceLock()`;拿不到锁立刻 `app.quit()`,拿到锁后监听 `second-instance` 并聚焦/唤起已有窗口。依据:`src/main/index.ts:176-182`。
4. `app.whenReady()` 之后依次:`logger.setVerbose(config.developerMode)` → `registerIpcHandlers()` → `registerIslandIpc()` → 应用开机自启登录项 → 创建设置窗口(隐藏态可配)→ `installCrashGuard()` → 加载渲染页 → 注册退出回调 → `island.init()` → 创建托盘。依据:`src/main/index.ts:184-262`。
5. 退出时统一资源清理:停止定时器 → `flushConfigSync()`(配置同步落盘)→ 销毁托盘 → 销毁岛 → 销毁全部窗口 → `app.quit()`,并用 `setTimeout(() => app.exit(0), 1500)` 兜底。依据:`src/main/index.ts:291-325`。
**关键纪律**:配置必须在 `app.setPath('userData')` 之后**懒加载**,不得在模块顶层读盘(否则读到错误 profile 的 config.json)。依据:`src/main/config.ts:261-272` 的注释与实现。
### 2.7 窗口参数(像素级基线)
**岛窗口(ClassIsland 主界面)**必须满足:
- `width/height` 使用"固定包围盒 + 窗内形变",**以第 4 部分 4.3 为唯一实现口径**:`winW = 目标屏幕宽(DIP)`,`winH = contentMaxHeight + 2 × safePadding`(`safePadding ≥ 20`,其中包含阴影/光晕余量,参考 ClassHelper 的 `ISLAND_SHADOW_PAD = 10` 双侧口径);窗口在开合、行增删与提醒展开过程中尺寸不变,形变全部发生在渲染层画布内,只有显示器/DPI 变化、行数超出上限、编辑模式进出、偏移/缩放/停靠设置变化时才 `setBounds` 且节流 ≥100ms。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:72-112,480-512`(固定包围盒思想)与第 4 部分 4.3(ClassIsland 全屏宽窗口适配)。
- 窗口标志:`frame:false`、`transparent:true`、`resizable:false`、`movable:false`、`minimizable:false`、`maximizable:false`、`fullscreenable:false`、`skipTaskbar:true`、`hasShadow:false`、`focusable:false`、`alwaysOnTop:true`、`type:'toolbar'`。依据:`src/main/island.ts:480-504`;其中 `focusable` 必须为 `false`(ClassIsland 主窗口不抢焦点,见 04 §4.2)。
- `webPreferences`:`preload` 指向 `dist/preload/island.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`:505-511`。
- 创建后必须:`setAlwaysOnTop(appearance.alwaysOnTop,'screen-saver')`、`setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true})`、`setWindowOpenHandler(()=>({action:'deny'}))`、`will-navigate` 白名单校验、默认 `setIgnoreMouseEvents(true,{forward:true})`、**禁止** `setBackgroundMaterial()`。依据:`:514-527`。
- 定位:按 ClassIsland 的 6 个停靠位(`WindowDockingLocation`:0 左上 / 1 中上 / 2 右上 / 3 左下 / 4 中下 / 5 右下)+ `WindowDockingOffsetX/Y`(默认 0)+ `WindowDockingMonitorIndex`(默认 0);ClassHelper 的 `top-center` 等 6 个枚举与 `marginX/marginY=8` 只作参考,**不得**替代 ClassIsland 的停靠与偏移语义。依据:`repos/ClassIsland/ClassIsland/Models/Settings.cs:1810-1833`。
- **坐标口径(强制)**:所有窗口 bounds、停靠偏移、边距、组件尺寸与间距必须使用 **DIP(CSS px)**;`BrowserWindow` 的 `x/y/width/height`、`screen.getPrimaryDisplay().workArea` 全部按 DIP 传入与读取。**禁止**在业务布局代码里用 `devicePixelRatio` 做坐标换算;只有"截图/像素级视觉验收"时才用 `devicePixelRatio` 把 DIP 换回物理像素。依据:【推断】(与 04 部分统一口径,避免窗口定位与组件布局出现两套坐标系)。
- **非 Windows 平台的命中降级(必须实现)**:`BrowserWindow.setShape()` 仅在 Windows 生效;macOS/Linux 必须降级为"整窗默认 `setIgnoreMouseEvents(true, { forward: true })`,岛可见区域之外不接收鼠标,指针进入岛体时由渲染层上报 `set-interactive` 打开命中",不得为平台差异改变岛的视觉尺寸或停靠位置。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:278-316`(`setShape` 只在 `process.platform === 'win32'` 分支执行)。
**设置窗口**必须采用 **ClassIsland 的尺寸(第 10 部分为唯一口径)**:`width 1240`、`height 550`、`minWidth 560`、`frame:false`(Windows 11 21H2+ 可用 Mica,否则退回纯色背景)、`center:true`、`minHeight 550`,并补上 `preload: dist/preload/index.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`repos/ClassIsland/ClassIsland/Views/SettingsWindowNew.axaml`(第 10 部分 10.1)与 `src/main/index.ts:63-88`(窗口骨架参考)。
### 2.8 主进程核心模块(ClassIsland 服务层在 Electron 下的等价物)
| ClassIsland 概念 | ClassHelper/Electron 实现位置 | 必须实现的职责 |
|---|---|---|
| `AppBase` / `App` 启动编排 | `src/main/index.ts` | 单实例、窗口、托盘、退出清理、崩溃自愈 |
| `SettingsService`(`Settings.json` → `config.json`) | `src/main/config.ts` | `config.json` 为运行期唯一设置源;`Settings.json` 只做 ClassIsland 格式只读导入/导出兼容层(不双向双写);默认值合并、字段归一化、120ms 合并写、退出同步落盘、字段白名单 |
| `ProfileService`(档案) | `src/main/storage.ts` | 档案/课表/时间表读写、临时层清理(对应 `ProfileService.CleanExpiredTempClassPlan`,`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:295-328`) |
| `ComponentsService` | `src/main/storage.ts` + `src/island/*` | 组件方案 JSON 读写、迁移、注册表驱动渲染 |
| `LessonsService`(时间引擎) | `src/main/scheduler.ts` | 每 200ms 推进一次状态机、计算下一时间点/剩余时间、触发上/下课事件 |
| `NotificationHostService` | `src/main/notifications.ts` | 队列、优先级、播放会话、强提醒遮罩 |
| `AutomationService` | `src/main/automation.ts` | 触发器/行动/规则集、信号与 URI |
| `PluginService` | `src/main/plugins.ts` | 插件发现/加载/启用/卸载/依赖排序 |
| `AppLogger` | `src/main/logger.ts` | 控制台 + `app.getPath('logs')/main.log`,单文件 1MB 轮转、保留 `main.log.1` |
| `IpcService` | `src/main/ipc.ts` + `channels.ts` | 通道注册、事件下发、数据校验 |
日志必须照抄 ClassHelper 的轮转与容错口径:`LOG_MAX_BYTES = 1024*1024`、`main.log → main.log.1`、写入失败静默忽略、`app.getPath('logs')` 在 ready 前调用会抛所以路径必须懒解析。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/logger.ts:21-67`。**不得**用 `console.log` 作为唯一排障手段:打包后的 GUI 没有可见控制台。
### 2.9 渲染层挂载顺序(必须逐行对齐)
应用窗口入口 `src/renderer/main.ts` 必须按以下顺序执行,**顺序错一个都会出现白闪或首帧无动画**:
1. `createApp(App)`;
2. 注册用到的 Element Plus 图标(只注册用到的,不全量 293 个);
3. `app.use(createPinia())`;
4. `app.use(router)`;
5. `app.use(ElementPlus, { locale: zhCn })`;
6. `registerSharedUi(app)`(来自 `@classhelper/ui`);
7. 冒烟钩子(若 `window.desktop?.smokeTest`,用 `await import()` 动态加载,禁止静态 import 进主 chunk);
8. `applyMotionTokens()`(把动效令牌写进 `:root`,必须在 `mount` 之前);
9. `await router.isReady()`;
10. `app.mount('#app')`。
依据:`repos/ClassHelper-master/packages/desktop-client/src/renderer/main.ts:17-57`;动效令牌注入理由见 `docs/dev/motion.md:53-59`。
路由必须用 `createWebHashHistory()`,因为生产环境是 `file://`,history 模式会 404。依据:`src/renderer/router/index.ts:5-8,110-113`。路由守卫必须做两件事:未初始化时进 OOBE;开发者页仅当 `developerMode` 为 true 时可进并做运行时兜底(读取 pinia 状态,不每次跨 contextBridge)。依据:`:115-133`。
### 2.10 岛渲染进程的技术约定(必须与 ClassIsland 视觉对齐)
1. **岛内不得引入 Element Plus、不得引入任何图标字体**;只用 Vue + 手写 CSS + 内联 SVG。依据:`src/island/main.ts:6-10`。
2. `island.html` 必须带与主窗口一致的 CSP,且 `html, body { margin:0; padding:0; background:transparent }`。依据:`repos/ClassHelper-master/packages/desktop-client/island.html:6-21`。
3. 岛必须**再注入一次**动效令牌(两个渲染进程不共享 `:root`),漏掉会让 `transition` 简写整条失效。依据:`src/island/main.ts:13-24`、`docs/dev/motion.md:58-59`。
4. 动画时长与曲线**以第 14 部分为准**(ClassIsland 的 Storyboard/Transition 规格);弹簧动画(`SpringValue`:`stiffness=0.10`、`damping=0.68`、`momentumRetention=0.35`、`maxDistanceRatio=0.2`、`maxDt=6`、`snapThreshold=0.05px`、帧单位 `1000/60`、零过冲钳制)只用于第 14 部分明确标注为「spring/跟手」的交互(如拖拽预览、内容宽度跟随),**不得**用它替代第 14 部分给出确定时长的规格动画。依据:`repos/ClassHelper-master/packages/desktop-client/src/island/spring.ts:26-30,41-47,85-114` 与第 14 部分。
5. 圆角实现:**默认必须使用 CSS `border-radius`(等同 Avalonia `CornerRadius`,半径取 `Settings.RadiusX`,默认 8)**,以保证与 ClassIsland 的直角圆弧逐像素一致;ClassHelper 的连续圆角(squircle:超椭圆指数 `4.2`、每角 `14` 段、半径 `min(r,w/2,h/2)`)只能由主题包显式启用(P2),不得作为默认,否则 8px 角的曲率会与 ClassIsland 原版不同。依据:`repos/ClassIsland/ClassIsland/MainWindow.axaml:282` 与 `XamlThemes/FluentTheme/Styles.axaml:48`;对照实现见 `src/island/squircle.ts:21-24,44-81`。
6. 岛组件的几何基线必须集中到 `packages/shared/src/island-layout.ts` 的 `CLASSISLAND_ISLAND_METRICS`:`rowHeight 40`、`horizontalPadding 12`、`radius 8`、`opacity 0.5`、`scale 1`(0.75–2.5)、`secondaryFontSize 14`、`bodyFontSize 16`、`emphasizedFontSize 18`、`largeFontSize 20`、`rowSpacing 5`、`fadedOpacity 0.05`。依据:`repos/ClassHelper-master/packages/shared/src/island-layout.ts:19-36`。
7. 岛形态尺寸增量必须集中常量:`ISLAND_SIZE_DELTA = { expanded:{w:156,h:186}, urgent:{w:172,h:202}, call:{w:188,h:218} }`、`ISLAND_SHADOW_PAD=10`、`ISLAND_SLIVER_WIDTH=6`。依据:`packages/shared/src/constants.ts:336-351`。
8. **参考值(不是本应用默认值)**:ClassHelper 既有「经典岛」外观默认值 `width 268`、`height 44`、`radius 20`、`fontSize 13`、`opacity 1`、`accent #0a84ff`、`style 'black'`、`position 'top-center'`、`marginX/marginY 8`、`idleSliver false`、`followCursorDisplay false`(范围:高度 36–72、宽 220–420、圆角 8–32、透明度 0.4–1、字号 11–20、速度 0.5–2、marginX 0–200、marginY 0–160,依据 `constants.ts:265-302`)。本应用(ClassIsland 复刻)的默认值**必须**按第 3、4、10 部分:行高 40、圆角 8、字号 14/16/18/20、透明度 0.5、停靠位 `WindowDockingLocation=1`、偏移 0,**不得**套用 ClassHelper 经典岛的 268×44 胶囊默认值。
9. 岛内 `:hover` 规则必须包在 `@media (hover: hover) and (pointer: fine)` 内,否则希沃触摸屏会出现"点过之后永远高亮"的幽灵 hover。依据:`docs/dev/motion.md:80-81`。
10. 阴影常驻、只随形态改参数,不得在形态切换时增删 `filter`(会新建合成表面导致闪帧)。依据:`docs/dev/winisland-design-tokens.md:25-26`。
### 2.11 自动更新与打包(工程结构的一部分)
- 更新目录:`<userData>/updates`;下载先写 `.part` 再校验后 `rename`;`policy.json` 记录服务端下发的更新策略。依据:`src/main/auto-update.ts:59-133`。
- 下载超时 10 分钟;文件名必须匹配 `^[A-Za-z0-9._-]+$`(挡住路径穿越);下载完必须校验 SHA-256,服务端未给哈希则**拒绝安装**。依据:`:124-128,320-391`。
- 安装时机三档:`immediate` 立即;`after-school` 不早于 `updateNotBefore`;`idle` 需主窗口隐藏满 5 分钟(`CLIENT_UPDATE_IDLE_MS = 5*60*1000`)。依据:`packages/shared/src/constants.ts:677`、`auto-update.ts:474-506`。
- 安装必须交给独立助手:先生成/校验助手 → 助手就绪 → 主进程退出 → 助手替换文件 → 写 `install-result.json`(失败时主进程下次启动读回并展示 `failed` 状态)。依据:`auto-update.ts:516-573`。
- **安装目录策略:不做 side-by-side 多版本并存**。必须使用固定安装目录(NSIS 装到固定路径);更新助手在替换前把当前安装目录的关键文件(`ClassIsland.exe`、`resources/app.asar` 等)复制到 `updates/rollback/<版本>/`,替换失败或新版启动失败时用该副本回滚并重新拉起旧版。ClassIsland 的 `app-<版本>` + `.current` 多版本方案(`repos/ClassIsland/ClassIsland.Launcher/Program.cs:30-57`、`UpdateService.cs:841-855`)只作为"可回滚"语义参考,**不照搬目录结构**。**【推断】**
- **来源安全**:更新下载默认只接受 `https://`;镜像/自定义源必须由用户显式开启并提示"非官方来源风险",默认不启用。清单必须校验 SHA-256;签名可选(Ed25519,带 `keyId`,支持公钥轮换),详见 12.6。
- 打包产物必须做"凭据门禁":`dist/renderer` 内不得出现任何账号/口令字面量(`scripts/check-bundle-secrets.mjs`),因为 asar 可直接解包。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:13`、`src/preload/index.ts:43-54`。
### 2.12 ClassIsland → ClassHelper 技术映射决策表(必须按此实现)
| ClassIsland(C#/Avalonia) | 本实现(Electron/Vue) | 关键差异与对策 |
|---|---|---|
| Avalonia `Window` + `TransparencyLevelHint=AcrylicBlur` | `BrowserWindow{transparent:true, frame:false}` + CSS 半透明背景 | Electron 下**禁止** `setBackgroundMaterial()`,也**禁止** `backdrop-filter`(透明窗口下 Chromium 采样不可靠,会闪/发灰);毛玻璃效果用**预混合半透明纯色**实现(ClassHelper 的 glass 同样是半透明色、不采样桌面模糊,见 `docs/dev/winisland-design-tokens.md:23`);编辑模式按第 4 部分用全屏半透明纯色遮罩 |
| XAML `Style`/`ResourceDictionary` 主题 | `packages/ui/src/theme.css` + `html.dark` + CSS 变量 | 主题包改为"CSS 变量清单 + 资源目录 + 预览图",见 9 部分 |
| `Storyboard`/`Transition`(如 `BackgroundWidth 300ms cubic-bezier(0.65,0,0.35,1)`) | CSS `transition`/`@keyframes` + `motion.ts` 令牌 | 令牌数值单一来源放 `packages/shared/src/motion.ts`;不得在组件里手写曲线字面量 |
| `DispatcherTimer` 时间引擎 | 主进程 `setInterval` + `setTimeout` 精确对齐 + 系统休眠唤醒修正 | Node 定时器会漂移:必须以"当前时间与下一时间点差值"重排,不得累加计数 |
| `Mutex("Global\\ClassIsland.Lock")` 单实例 | `app.requestSingleInstanceLock()` | 二次启动必须唤起已有窗口(`second-instance`),不得静默退出 |
| `dotnetCampus.Ipc` 命名管道(`ClassIsland.IPC.v2.Server`) | `ipcMain/ipcRenderer` + contextBridge;**可选**本地只读 HTTP API | 不恢复命名管道。外部只读集成如启用,必须:只监听 `127.0.0.1`、端口随机分配、默认关闭、开启后需一次性 token 鉴权、只提供读接口(当前课程/课表/状态),**不得提供任何写操作**。详见 12.4 |
| 插件 `.cipx`(zip + `manifest.yml` + `.dll`) | 同构 zip(`.cipx`)+ `manifest.yml` + ESM 入口 `.mjs` | 插件必须在 utilityProcess 内 `import()`,不得污染主进程;清单统一 camelCase、兼容读取 PascalCase 旧清单;见 12 部分 |
| `Settings.json`(`CommonDirectories.AppRootFolderPath`) | `<userData>/config.json` 为主 + `Settings.json` 导入/导出兼容层 | 运行期只读写 `config.json`;`Settings.json` 只做 ClassIsland 格式的**只读导入**与导出,**不双向双写**;`IsIslandSeperated` 等历史拼写必须保留(见 13 部分) |
| `Profiles/<name>.json` | `<userData>/data/profiles/<name>.json` | 保留 PascalCase 字段名以兼容导入导出 ClassIsland 档案 |
| `Backups/Auto_*.zip` | `<userData>/backups/` | 自动备份 7 天一次、最多 16 份,包内含 `Config/` + `Profiles/` + `Settings.json`(若存在;见 13 部分) |
| 独立 Launcher + 多版本目录 | electron-builder NSIS + 固定安装目录 + 独立更新助手 + 回滚副本 | 不做 side-by-side 多版本;见 2.11 |
| `PackageRoot` 环境变量 | `app.getPath('userData')` + `process.resourcesPath` | 所有路径必须用 Electron API 解析,禁止硬编码 `C:\` |
| `Color`(Avalonia)序列化为 `#AARRGGBB` | 字符串 `#RRGGBB`/`#AARRGGBB` | 解析必须容忍 6 位与 8 位、容忍大小写,写出统一 8 位(AlphaComponentPosition.Trailing) |
### 2.13 第三方库等价映射(.NET/Avalonia → npm,必须按此实现)
ClassIsland 依赖的一批 .NET 库在本技术栈下必须换成下表等价物;不得因为"找不到同名库"就自研底层协议。
| ClassIsland 依赖 | 本实现替代 | 使用约束 |
|---|---|---|
| `System.Text.Json` + `ConfigureFileHelper` | Node `JSON.parse/stringify` + 自研 `storage.ts` | 必须保留"写入前浅拷贝快照、写入中不阻塞 UI、退出前同步落盘"语义(见 2.16) |
| `YamlDotNet`(插件 `manifest.yml`) | `yaml`(npm,服务端同款) | 解析必须 `ignoreUnknownKeys`:未知字段不得导致插件加载失败(对齐 `repos/ClassIsland/ClassIsland/Services/PluginService.cs:68-72` 的 `IgnoreUnmatchedProperties`) |
| `NAudio`/系统音频播放 | 渲染层 `HTMLAudioElement`,主进程只下发"播放/停止/音量" | 音效文件必须走 `file://` 本地资源;音量 0–1,默认 1.0(`Settings.NotificationSoundVolume`) |
| `System.Speech`/`EdgeTtsSharp` TTS | Windows 用 `System.Speech` 等价方案:优先系统 TTS(Windows `SAPI` 通过 PowerShell 启动会污染进程,改为可插拔 TTS 服务,默认关闭),可选在线 TTS 必须由用户显式开启 | 离线时不得阻塞提醒播放,TTS 失败只记日志 |
| `RestSharp`/`HttpClient` | Node 内置 `fetch`(Electron 44/Node 20+) | 必须显式 `AbortSignal.timeout(...)`、`redirect:'error'`(对齐 `auto-update.ts:193-199`) |
| `Downloader`(分片并行下载) | 自研流式下载 + 4 并发分片 | 必须支持 `.part` 临时文件、断点续传可选、进度节流 250ms(对齐 `auto-update.ts:216-219`) |
| `Octokit`(GitHub API) | 不用:更新源改为自建静态 JSON 清单 + 固定 URL 模板 | 若保留 GitHub 通道,必须只读、无 token、失败回退到主通道 |
| `SharpZipLib`(部署包解压) | `adm-zip` 或 Node `zlib` + 自研 zip 读取 | 解压前必须校验路径不越出目标目录(对齐 `UpdateService.cs:733-743` 的 `IsSafePath`) |
| `CommunityToolkit.Mvvm`(`ObservableObject`/`RelayCommand`) | Vue `ref/reactive/computed` + `watch` | 不得把 Vue 响应式代理跨 contextBridge 传递(见 2.15 数据纯净化) |
| `DynamicData`(集合变换) | `computed` + 普通数组操作 | 大数据量列表必须避免在 `computed` 里做 O(n²) 操作 |
| `MoonSharp`(Lua 教程脚本) | 禁用脚本引擎;教程改为声明式 JSON 步骤 | 不得为了教程引入任意代码执行能力(安全边界见 2.17) |
| `Sentry`(崩溃上报) | 默认关闭;开启时只上报匿名崩溃栈,不含课表与个人信息 | 与 ClassIsland 的 `IsReportingEnabled` 默认值保持一致(默认 true,但必须可在设置中关闭) |
| `dotnetCampus.Ipc` | Electron IPC(见 12 部分) | 通道名必须与 `channels.ts` 单一来源 |
| `HotAvalonia`(热重载) | Vite HMR | 仅开发期启用;生产包不得包含 HMR 客户端 |
### 2.14 共享包(`packages/shared`)的工程约定
1. `packages/shared` 必须是**纯 TypeScript**:`type: module`、`main: ./dist/index.js`、`types: ./dist/index.d.ts`、构建用 `tsc -p tsconfig.json`,产物只含 `dist`。依据:`repos/ClassHelper-master/packages/shared/package.json:4-18`。
2. `tsconfig` 的 `lib` 不得包含 `DOM`:共享包会被主进程(Node)与渲染进程(浏览器)同时消费,任何 `document`/`window`/`localStorage` 引用都会让主进程侧编译失败。依据:`repos/ClassHelper-master/packages/shared/src/motion.ts:10-16` 的显式约束与 `docs/dev/motion.md:30-37`。
3. 入口必须单一:`packages/shared/src/index.ts` 只做 `export *`/`export type *` 转发;新增模块必须同步加一行导出。依据:`packages/shared/src/index.ts:1-16`。
4. 命名约定:类型用 `PascalCase`;常量全大写下划线;函数用 `camelCase`;持久化 JSON 字段必须与 ClassIsland 原名一致(如 `TimeLayouts`、`ClassPlans`、`WeekCountDivTotal`),以便直接导入导出 ClassIsland 档案;IPC 内部 DTO 用 `camelCase` 并在 `types.ts` 注明与持久化字段的映射。**【推断】**(ClassIsland 的 JSON 为 PascalCase,见 `repos/ClassIsland/examples/example.json:4-19`。)
5. 全部"归一化"函数必须放在 shared:例如 `normalizeIslandLayouts(input)` 负责把任意 IPC/导入数据夹紧到合法区间(最多 12 行、200 节点、6 层、颜色必须 `#rrggbb`、未知 kind 丢弃、重复 id 重命名)。依据:`packages/shared/src/island-layout.ts:256-403`。
6. `motion.ts` 必须保持"纯常量 + 纯函数":只导出缓动曲线、弹簧参数、时长令牌、`motionCssVars()`;DOM 注入放各端 `composables/motion.ts`。依据:`packages/shared/src/motion.ts:136-241`。
### 2.15 共享 UI 包(`packages/ui`)与样式令牌工程
1. `packages/ui` 必须以源码形式被消费:`exports` 直接指向 `./src/index.ts`、`./src/theme.css`、`./src/tokens.css`,不产出 dist;`peerDependencies` 只允许 `vue` 与 `element-plus`。依据:`repos/ClassHelper-master/packages/ui/package.json`。
2. `tokens.css` 只放"基础令牌"(品牌色、圆角、间距、阴影),且必须提供 `html.dark` 覆盖;不得在 `html.dark` 里改圆角与动效令牌(几何必须两套主题一致,否则切换时布局跳动)。依据:`packages/ui/src/tokens.css:1-21` 与 `docs/dev/motion.md:123-130`。
3. 基础令牌必须至少包含:`--ch-ui-primary:#5d6bfa`、`--ch-ui-bg:#f7f8fa`、`--ch-ui-surface:#fff`、`--ch-ui-surface-alt:#f3f5f9`、`--ch-ui-border:#e6e9ef`、`--ch-ui-text:#242936`、`--ch-ui-muted:#737c8e`、`--ch-ui-radius:8px`、`--ch-ui-gap:16px`、`--ch-ui-shadow:0 8px 30px rgb(30 40 70 / 8%)`;深色覆盖 `bg #14161a`、`surface #1d2027`、`surface-alt #252934`、`border #343946`、`text #e9edf5`、`muted #a2abbd`、`shadow 0 8px 30px rgb(0 0 0 / 25%)`。依据:`packages/ui/src/tokens.css:1-21`。
4. 语义色令牌必须与总纲第 3 部分一致:`--ch-surface`、`--ch-surface-alt`、`--ch-surface-sunken`、`--ch-surface-hover`、`--ch-text`、`--ch-text-secondary`、`--ch-text-muted`、`--ch-text-placeholder`。组件里一律引用变量,禁止写死 `#fff`/`rgba(0,0,0,...)`(固定深色区域除外:登录页、启动过渡页、岛本身、全屏作业看板、accent 渐变上的白字)。依据:`docs/dev/motion.md:107-138`。
5. 改令牌时必须"只改值",禁止对样式表跑批量替换:把 `#303133` 全量替换成 `var(--ch-text)` 会把令牌定义本身改成自引用,导致整条自定义属性失效。依据:`docs/dev/motion.md:128-130`。
6. Element Plus 的全站过渡必须改写 `--el-transition-*` 变量(300/200ms → 200/140ms,缓动换成 `EASE_OUT`),不得改组件源码。依据:`docs/dev/motion.md:65-66`。
### 2.16 主进程并发与 I/O 纪律(直接决定"卡不卡")
1. **主进程是 Electron 的消息泵**:任何同步文件 I/O 都会同时卡住窗口动画。必须遵守:
- 配置写入必须"内存缓存 + 异步合并写 + 退出同步兜底":写盘合并窗口 `CONFIG_WRITE_DEBOUNCE_MS = 120`,写入串行化(`writing` 标志),写入期间的改动进 `pendingWrite` 再补写一次;`flushConfigSync()` 在 `before-quit` 调用。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/config.ts:235-346`。
- 读取配置必须只读一次内存缓存,不得每次 `getConfig()` 都 `readFileSync + JSON.parse`(ClassHelper 明确记录过这条导致侧栏展开卡顿)。依据:`config.ts:235-250` 的注释。
2. **禁止在主进程做 CPU 重活**:Excel 解析、主题包解压、插件加载、备份打包必须放 `utilityProcess` 或 `node:worker_threads`;主进程只负责调度与 IPC 转发。
3. **定时器纪律**:时间引擎的 tick 周期固定 200ms(ClassIsland 主计时器同量级【推断】),每次 tick 只用"当前时间 - 时间点边界"做判断,禁止累加 count 推断时间;系统休眠唤醒后必须立即重算一次(监听 `powerMonitor` 的 `resume`)。
4. **写入磁盘必须原子**:所有 JSON 先写 `<file>.tmp`,`fsync` 后 `rename` 覆盖;备份进程写 `<Auto_*.zip>.tmp` 再改名。依据:`repos/ClassIsland/ClassIsland/Shared/Helpers/ConfigureFileHelper.cs:182-188`(`FileStream.Flush(true)` 后关闭)。
5. **数据目录结构固定**:`<userData>/` 下只允许出现 `config.json`、`data/`、`logs/`、`updates/`、`backups/`、`themes/`、`plugins/`、`cache/` 八个顶层条目;不得把临时文件写到安装目录(安装目录可能只读)。
6. **退出清理必须幂等**:托盘退出、`before-quit`、`will-quit` 都可能触发同一次清理,清理函数必须可重入(ClassHelper 用 `isQuitting` 标志 + 各资源自带 `isDestroyed()` 判断,见 `src/main/index.ts:287-341`)。
### 2.17 安全基线(不得为了"方便"削弱任何一条)
1. 渲染进程必须启用 `contextIsolation: true`、`nodeIntegration: false`、`sandbox: true`、`webSecurity: true`;两个窗口都要。依据:`src/main/index.ts:79-87`、`src/main/island.ts:505-511`。
2. preload **只**通过 `contextBridge.exposeInMainWorld` 暴露显式白名单方法,绝不暴露 `ipcRenderer` 本体;不得暴露 `require`、`process`、`Buffer`。依据:`src/preload/index.ts:17-101`、`src/preload/island.ts:10-123`。
3. 通道名必须集中在**零依赖叶子模块** `src/main/channels.ts`:preload 在 `sandbox: true` 下不能 `require('node:fs')` 等内建模块,一旦 import 了主进程模块,esbuild 会把整条依赖链打进 preload 导致"加载即失败、`window.desktop === undefined`"。依据:`src/main/channels.ts:1-28` 的注释与实现。
4. 跨桥接只能传**纯数据**:传 Vue `ref`/`reactive`(Proxy)会在进入 contextBridge 之前抛 `An object could not be cloned.`。所有调用点必须展开成字面量,且 preload 返回的订阅函数必须可取消(`onXxx` 返回取消函数或由主进程移除监听)。依据:`src/types/desktop.d.ts:144-152`、`src/preload/index.ts:32-36`。
5. 导航必须白名单精确比对:开发态比对 `origin`,生产态比对"去 hash/search 后的精确 `file://` URL",不得用 `startsWith`。窗口必须 `setWindowOpenHandler(() => ({action:'deny'}))`,外链一律 `shell.openExternal`。依据:`src/main/index.ts:42-61,90-100`、`src/main/island.ts:32-50,516-522`。
6. 两个 HTML 入口都必须带 CSP:`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'`。依据:`repos/ClassHelper-master/packages/desktop-client/index.html:20-23`、`island.html:6-10`。
7. 敏感数据(登录令牌、管理凭据)必须用 `safeStorage.encryptString` 加密后 base64 落盘;`safeStorage` 不可用时必须降级明文并写 warn 日志(不得静默)。依据:`src/main/config.ts:169-196`。
8. 更新域名/下载 URL 必须来自配置白名单,渲染进程不得指定任意 URL 下载或执行;安装包路径必须校验"位于 `updates` 目录内且 realpath 一致"。依据:`auto-update.ts:477-490`。
9. 插件代码必须在隔离进程运行、只能访问 manifest 声明的权限(文件读写范围、网络域名、能否弹提醒、能否执行外部程序);未声明即拒绝。见 12 部分。
10. 发布前必须跑 `check-bundle-secrets`:`dist/renderer` 与 `dist/main` 中不得出现账号/口令/私钥字面量。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:10-13`。
### 2.18 环境变量、命令行参数与调试开关
| 名称 | 作用 | 约束 |
|---|---|---|
| `ELECTRON_SMOKE_PROFILE` | 把 `userData` 指到隔离目录 | 仅自动化验收使用;正常启动必须不设置 |
| `ELECTRON_SMOKE_TEST=1` | 冒烟模式标记 | 必须只影响"不弹窗、不写登录项、不自动重启"等行为,不得影响业务逻辑 |
| `VITE_DEV_SERVER_URL` | 开发态渲染地址 | 生产构建不得定义;主进程读到它才 `loadURL` |
| `--hidden` | 开机自启静默启动 | 因 Electron 44 在 Windows 上 `setLoginItemSettings` 没有 `openAsHidden`,必须自己带参数(依据 `src/main/index.ts:22-27,147-173`) |
| `--safe` | 安全模式 | 禁用全部第三方插件与主题、跳过自动化、禁用更新安装,仅加载内置能力 |
| `--recovery` | 恢复模式 | 允许从备份还原配置/档案,禁用写操作直到用户确认 |
| `--diagnostic` | 诊断模式 | 日志提到 debug、结束时在桌面生成诊断包(配置、日志、版本、插件列表,**不含**个人信息) |
| `--uri <classisland://...>` | 单实例 URI 导航 | 第二次启动时把 URI 转发给已运行实例后退出(参考 `repos/ClassIsland/ClassIsland/Program.cs:43-68,141-159`) |
| `--importV1/--importV2/<path>` | 导入旧版配置 | 导入前必须备份;失败可回滚 |
环境变量与参数必须在 `app.whenReady()` 之前解析完毕,之后不得再读 `process.argv` 做行为分支(避免窗口创建后又改变模式导致状态不一致)。
### 2.19 性能预算(超预算必须优化,不得降级功能)
| 指标 | 预算 | 测量方法 |
|---|---|---|
| 冷启动到岛首帧 | ≤ 2.5s(SSD 教室机) | 主进程打点:`app.ready` → 岛渲染进程 `did-finish-load` → 首帧 `requestAnimationFrame` 回传 |
| 常驻内存(主进程 + 岛渲染 + 设置窗口) | ≤ 350MB(对齐 ClassHelper 文档 250–350MB,见 `docs/get-started/requirements.md`) | 任务管理器观察 30 分钟 |
| 空闲 CPU | ≤ 1%(岛常驻,1 秒一次时钟 tick) | 任务管理器观察 10 分钟 |
| 岛动画帧率 | 60fps(掉帧率 <1%) | DevTools Performance 录制形变过程 |
| 渲染进程包体 | 应用窗口 ≤ 3.5MB(gzip 前)、岛 ≤ 800KB | `dist/renderer/assets` 体积 |
| 设置页首屏 | ≤ 400ms(路由切换) | Performance 面板 |
| 配置写入 | 单次 ≤ 5ms,且不阻塞主线程 > 1 帧 | 主进程日志打点 |
任何一项超标都必须优化实现(例如把计算移到 `computed` 缓存、拆 chunk、减少重渲染),**不得**通过删除动画、降低帧率、去掉 blur 等方式"达标"。
### 2.20 本地化与文案工程
1. 默认语言 zh-CN;文案必须集中在 `packages/shared/src/i18n/`(或 `locales/zh-CN.json`),组件内禁止硬编码中文字符串作为唯一来源(允许以 key 调用 `t('...')`)。
2. 日期/星期/时间格式必须走 `Intl.DateTimeFormat`(`zh-CN`),星期文本必须为"周一…周日";`Settings.ShowDate` 默认 true(`repos/ClassIsland/ClassIsland/Models/Settings.cs:44`)。
3. 数字与百分比必须用 `Intl.NumberFormat`;倒计时显示格式为 `mm:ss`,超过 1 小时显示 `hh:mm:ss`。
4. 文案长度必须按"最长的中文条目"预留宽度,禁止用英文长度估算导致换行溢出。
5. 所有错误提示必须给出"发生了什么 + 用户可以做什么",不得只抛出异常类名;配置加载失败必须落到"使用默认配置并提示可恢复备份"。
### 2.21 验收标准(第 2 部分)
实现者必须逐条自查,全部通过才算完成本节:
1. `pnpm install` 后可成功执行 `pnpm -r typecheck` 与 `pnpm build:desktop`,产物包含 `dist/main/index.js`、`dist/preload/index.js`、`dist/preload/island.js`、`dist/renderer/index.html`、`dist/renderer/island.html`。
2. 依赖版本与 2.2 表逐项一致(用 `pnpm list -r --depth 0` 核对),Node 版本 `>=20.19.0`,`pnpm --version` 输出 `11.8.0`。
3. 仓库为 pnpm workspace,`packages/*` 至少含 `shared`、`ui`、`desktop-client` 三个包,根 `package.json` 是唯一手写版本号。
4. 主进程启动时序与 2.6 一致;`config.json` 在 `app.setPath('userData')` 之后才被首次读取(代码审查可验证)。
5. 岛窗口参数与 2.7 列表逐项一致(逐行对照 `new BrowserWindow` 选项),且代码中不出现 `setBackgroundMaterial`。
6. 岛渲染进程产物中不含 Element Plus 与图标字体(检查 `dist/renderer/assets` 体积与 import 图)。
7. `applyMotionTokens()` 在应用窗口与岛入口都被调用,且都在 `app.mount()` 之前。
8. 打包产物通过 `electron-builder --win nsis portable` 生成 setup 与 portable 两个 exe,语言包仅含 `zh-CN.pak`,安装包内不含任何凭据字符串。
9. 单实例:第二次启动不产生第二套窗口/托盘,而是唤起已有实例。
10. 主进程日志写入 `app.getPath('logs')/main.log`,超过 1MB 轮转为 `main.log.1`,且退出前配置已同步落盘(`flushConfigSync` 被调用)。
**补充验收项(工程结构层面)**:
11. 仓库内不得出现硬编码绝对路径(`C:\`、`D:\`、`/home/`),所有路径必须经 `app.getPath`、`path.join`、`process.resourcesPath` 解析;代码审查用 `grep -rn "C:\\\\" packages/` 应为零命中。
12. 图标资源必须"源文件 + 派生文件"分离:设计导出的 `build/classisland.ico`/`.png` 为唯一源,其余尺寸由 `pnpm icons` 派生;`electron-builder.yml` 必须显式指定 `icon: build/classisland.ico`,不得依赖 electron-builder 的默认约定。依据:`repos/ClassHelper-master/packages/desktop-client/electron-builder.yml:48-52`。
13. 托盘必须存在且行为固定:左键/双击显示主窗口;右键菜单至少含「打开设置」「编辑岛」「隐藏/显示岛」「重启应用」「退出」;托盘图标缺失时必须用空图标兜底并记 warn,不得崩溃。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/tray.ts:31-54`。
14. 开机自启必须"配置值"与"系统登录项"双向一致:设置页读的是 `app.getLoginItemSettings().openAtLogin`,不是配置文件里的值;写入失败时配置仍保存并提示"下次启动重试"。依据:`src/main/ipc.ts:66-99`。
15. 所有代码必须通过 Prettier 检查(LF 换行、单引号),且不得为了过检查对历史文件做批量格式化;提交前 `pnpm format:check` 必须通过。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,861 @@
## 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 的状态/空态/主题/响应式行为逐一验证。
@@ -0,0 +1,599 @@
## 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 的像素/动画验收需要稳定的基准图与固定测试数据,禁止用真实班级数据。
@@ -0,0 +1,968 @@
## 14. 动画与动效总表
本节是「动效施工图」。实现者必须逐条实现下表的触发、属性、起止值、时长、曲线、延迟与实现方式,**不得**自行「优化」时长或曲线。所有数值均来自 ClassIsland 源码;标注【推断】的为源码未直接给出、由同类动画或 Web 平台换算得出。
### 14.0 全局动效规则
#### 14.0.1 动画级别(AnimationLevel)
必须实现 3 档,默认 **标准(1)**(`ClassIsland/Models/Settings.cs:1851`;`Views/SettingPages/GeneralSettingsPage.axaml:280-293` 的三项为「禁用 / 标准 / 华丽」,绑定 `SelectedIndex`):
| 级别 | 文案 | 行为要求 | 证据 |
|---|---|---|---|
| 0 | 禁用 | `IThemeService.IsTransientDisabled = true`(禁用过渡动画),Popup 入场、抽屉/竖抽屉动画、Workflow 等全部关闭;但**主界面**的提醒动画与部分控件不在此列(设置项描述明确写「禁用动画选项不适用于应用主界面和部分控件」) | `App.axaml.cs:725`;`IThemeService.cs:44`;`PopupIntroAnimationBehavior.cs:67`;`DrawerHost.axaml.cs:70`;`GeneralSettingsPage.axaml:283` |
| 1 | 标准(默认) | 列表进入动画关闭、Popup 入场可用;不启用「行/组件淡入」「WrapPanel 尺寸过渡」等高成本动画 | `StackPanelIntroAnimationBehavior.cs:46`;`DrawerHost.axaml.cs:70` |
| 2 | 华丽 | 额外启用:组件/行淡入(250–300ms)、WrapPanel 子项位置隐式动画(225ms 默认) | `MainWindowLine.cs:382-400`;`ComponentPresenter.axaml.cs:224-243`;`WrapPanelResizingAnimationAssist.cs:51-56` |
实现细节:
- 该设置必须持久化并在启动时同步到全局单例(对齐 `IThemeService.AnimationLevel` 静态属性),任何动画入口先查级别再播放(源码模式见 `MainWindowLine.cs:384`)。
- 「禁用动画等待」(`IsWaitForTransientDisabled`)是另一独立开关:默认阻塞操作会等待动画播完,开启后不等待(`IThemeService.cs:46-49`;`Settings.cs:773-782`)。Web 实现等价为「不 await 动画 Promise」。
- 必须同时响应系统 `prefers-reduced-motion: reduce`:将其视为「级别 0 + 时长压到 1ms」(与 ClassHelper `renderer/styles/index.css:909-917` 一致),且**加载指示器必须放慢而不是停**(`index.css:919-930`)。
#### 14.0.2 缓动别名表(必须集中定义,禁止各组件手写)
| 别名 | cubic-bezier | 用途 | 来源 |
|---|---|---|---|
| `--ci-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | 绝大多数进出场(ClassHelper 主缓动) | `ClassHelper/packages/shared/src/motion.ts:24` |
| `--ci-ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | 对称来回(抽屉推拉、内容互换) | `motion.ts:26` |
| `--ci-ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | 侧向抽屉 | `motion.ts:28` |
| `--ci-ease-fl-1` | `cubic-bezier(0.65, 0, 0.35, 1)` | Fluent 行宽过渡/L 型 S 曲线 | `FluentTheme/Styles.axaml:269` |
| `--ci-ease-fl-2` | `cubic-bezier(0.25, 1, 0.5, 1)` | 遮罩文字淡入、行淡入 | `FluentTheme/Styles.axaml:333`;`MainWindowLine.cs:397` |
| `--ci-ease-fl-3` | `cubic-bezier(0.5, 0, 0.75, 0)` | 遮罩退出缩放 | `FluentTheme/Styles.axaml:389` |
| `--ci-ease-popup` | `cubic-bezier(0.22, 1, 0.36, 1)` | Popup 入场(Semi 风格) | `PopupIntroAnimationBehavior.cs:77,86` |
| `--ci-ease-tutorial` | `cubic-bezier(0.16, 1, 0.3, 1)` | 教程高亮/聚光 | `TutorialHighlightControl.axaml:13` |
| `--ci-ease-list` | `cubic-bezier(0.00, 1.00, 0.00, 1.00)` | 列表错峰入场 | `StackPanelIntroAnimation.axaml:22` |
| `--ci-ease-text` | `cubic-bezier(0.33, 1, 0.68, 1)` | OOBE 文字收拢、字符翻入 | `OobeIntroAnimationControl.axaml:93,105`;`.axaml.cs:151` |
| `--ci-ease-block` | `cubic-bezier(0.76, 0, 0.24, 1)` | OOBE 方块放大 | `OobeIntroAnimationControl.axaml:48` |
| `--ci-ease-root` | `cubic-bezier(0.00, 0.83, 0.17, 0.90)` | OOBE 整场缩放 | `OobeIntroAnimationControl.axaml:16` |
| `--ci-ease-char-out` | `cubic-bezier(0.32, 0, 0.67, 0)` | OOBE 字符翻出 | `OobeIntroAnimationControl.axaml.cs:117` |
| `--ci-ease-spin` | `cubic-bezier(0.50, 0.25, 0.50, 0.75)` | 更新图标旋转 | `UpdateSettingsPage.axaml:90` |
| `--ci-ease-overshoot` | `cubic-bezier(0.00, 1.00, 0.50, 1.10)` | 经典主题行宽过冲 | `ClassicTheme/Styles.axaml:207` |
| `--ci-ease-step` | `cubic-bezier(0, 0, 0, 1)` | 抽屉/关于页 (`0,0 0,1`) | `DrawerHost.axaml:46-48`;`AboutSettingsPage.axaml:137` |
| Avalonia `SineEaseIn` | `cubic-bezier(0.12, 0, 0.39, 0)`【推断】 | 遮罩层淡入/淡出 | `FluentTheme/Styles.axaml:299` |
| Avalonia `SineEaseOut` | `cubic-bezier(0.61, 1, 0.88, 1)`【推断】 | 图标宽度/进度透明度 | `AnimatedIconButton.axaml:24` |
| Avalonia `SineEaseInOut` | `cubic-bezier(0.37, 0, 0.63, 1)`【推断】 | 呼吸/进度过渡 | `WorkflowProgressIndicatorItemControl.axaml:19,32,60` |
| Avalonia `QuadraticEaseIn` | `cubic-bezier(0.11, 0, 0.5, 0)`【推断】 | 经典遮罩下压、涟漪放大 | `ClassicTheme/Styles.axaml:271`;`RippleEffect.axaml.cs:94` |
| Avalonia `QuadraticEaseOut` | `cubic-bezier(0.5, 1, 0.89, 1)`【推断】 | 经典遮罩上滑 | `ClassicTheme/Styles.axaml:311` |
| Avalonia `ExponentialEaseOut` | `cubic-bezier(0.16, 1, 0.3, 1)`【推断】 | 闪屏滑入 | `SplashWindow.axaml.cs:111,116` |
| Avalonia `CubicEaseOut` | `cubic-bezier(0.33, 1, 0.68, 1)`【推断】 | 闪屏进度 | `SplashWindow.axaml.cs:75` |
| Avalonia `CubicEaseInOut` | `cubic-bezier(0.65, 0, 0.35, 1)`【推断】 | WrapPanel 默认隐式动画 | `WrapPanelResizingAnimationAssist.cs:40` |
#### 14.0.3 时长常量表
| 常量 | 值 | 使用场景 |
|---|---|---|
| `--ci-dur-instant` | 90ms | 按压反馈、行透明度 |
| `--ci-dur-fast` | 100–140ms | 行/组件淡入淡出、抽屉内容、遮罩退出 |
| `--ci-dur-base` | 150–250ms | Popup、遮罩文字、列表滑入 |
| `--ci-dur-slow` | 300–360ms | 行宽过渡(Fluent)、遮罩分块、组件淡入 |
| `--ci-dur-overshoot` | 550ms | 行宽过渡(经典) |
| `--ci-dur-long` | 750ms | OOBE 字符、教程高亮、遮罩缩放 |
| `--ci-dur-loop` | 1000–3000ms | 循环/呼吸/旋转/OOBE 整场 |
#### 14.0.4 动效编排原则(实现时逐条遵守)
1. **进场慢、退场快**:进场承担「让用户理解发生了什么」的职责,可以 200–300ms;退场承担「别挡路」的职责,必须 100–150ms 结束。源码中提醒遮罩文字淡入 250ms、淡出 200ms;抽屉 250ms;弹窗退出 140ms(`index.css:638-644`)都遵循此规律。实现者不得把退场做得比进场更慢。
2. **延迟用于建立因果**:遮罩分块先扫入(0–360ms),文字随后(延迟 260ms),投影最后(延迟 600ms);OOBE 字符按正弦曲线错峰。延迟必须表达「谁先谁后」,**不得**所有元素同一时刻启动。
3. **错峰必须封顶**:列表入场 25ms/项(源码)、ClassHelper 35ms/项且第 9 项起封顶、页面块 30ms/块且封顶 180ms(`motion.ts:175-179`;`index.css:857-883`)。超过 10 项仍线性累加会让最后一项等超过 1 秒,属于缺陷。
4. **状态变化必须可打断**:任何「进/出」成对动画必须共用同一元素、同一属性、可反向;反向时用新动画直接覆盖旧动画(不要排队)。遮罩的 in/out 伪类互斥实现(`MainWindowLine.cs:726-791`)就是范例。
5. **不在动画里做业务**:动画只表达状态,不得在 `animationend` 里触发网络请求、不得用动画时长做业务计时;业务定时器与动画解耦。
6. **动效不得改变布局语义**:动画结束后元素的最终几何必须与「无动画直接跳到终态」完全一致;判定方法:动画播放中与播放后分别测量 `getBoundingClientRect`,最终值必须逐像素相等。
7. **一致性优先于炫技**:同一个交互(如「抽屉打开」)在全应用只能有一种时长与曲线;新交互必须复用 `--ci-ease-*` 与 `--ci-dur-*`,不得新增私有曲线。
### 14.1 A. 主界面(岛)动效
主界面动效分三类:**状态过渡**(A1–A4、A8–A9)、**几何过渡**(A5–A7、A11)、**数据过渡**(A10、A12)。状态过渡可直接用 CSS transition;几何过渡必须避免回流(见 14.7 第 1 条);数据过渡(进度条)必须做「比例插值」而不是「每帧重算 DOM」。三类不得混用同一个动画实例,否则打断时会互相污染。
| 编号 | 名称 | 触发 | 变化属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| A1 | 窗口背景换色 | 应用明暗主题切换 | `background` | 旧色 → 新色 | 200ms | 默认线性 | 无 | CSS `transition: background-color 200ms` | `MainWindow.axaml:40-44` |
| A2 | 行淡入 | 行由隐藏变可见 | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 无 | WAAPI/composition `opacity`,`AnimationLevel≥2` | `MainWindowLine.cs:336-344,382-401` |
| A3 | 行透明度过渡 | 悬停淡出开关生效 | `opacity` | 1 ↔ 0.05 | 100ms | 默认 | 无 | CSS transition | `MainWindowLine.axaml:51,73-75`;`FluentTheme/Styles.axaml:268` |
| A4 | 组件淡入 | 组件挂载/由隐藏变可见 | `opacity` | 0 → 1 | 300ms | `(0.65,0,0.35,1)` | 无 | composition `opacity`,`AnimationLevel≥2` | `ComponentPresenter.axaml.cs:224-243,263-266` |
| A5 | 行宽过渡(Fluent) | 组件内容变化导致背景宽度变化 | `width` | 旧宽 → 新宽 | 300ms | `(0.65,0,0.35,1)` | 无 | CSS transition(背景层绝对定位,禁止回流文字) | `FluentTheme/Styles.axaml:264-273` |
| A6 | 行宽过渡(经典) | 同上 | `width` | 旧宽 → 新宽 | 550ms | `(0,1,0.5,1.1)` 过冲 | 无 | 同上;曲线必须带过冲,不得改成 ease-out | `ClassicTheme/Styles.axaml:202-211` |
| A7 | WrapPanel 子项位置过渡 | 子项增删/重排 | `offset`(x/y) | 旧位置 → 新位置 | 默认 225ms;主界面覆盖 300ms | `CubicEaseInOut`;主界面 `(0.65,0,0.35,1)` | 无 | composition 隐式动画;Web 用 FLIP(先读后写)| `WrapPanelResizingAnimationAssist.cs:45-49,130-137`;`MainWindow.axaml:197-198` |
| A8 | 编辑模式背景切换 | 进入/退出编辑模式 | 窗口背景、`backdrop-filter` | 透明 → 亚克力 | 200ms | 默认 | 无 | CSS transition(只动背景层) | `MainWindow.axaml:177-181`;`MainWindow.axaml.cs:510-517` |
| A9 | 拖动项抬升 | 拖动开始/结束 | `opacity`、`z-index` | 1 → 0.5(拖动中) | 即时 | — | 无 | 直接切 class(不做动画) | `MainWindow.axaml:478-481` |
| A10 | 缩放重置 | 点击「重置视图」 | `transform: scale` | 当前 → 1.0 | 200ms【推断】 | `(0.16,1,0.3,1)` | 无 | WAAPI 动画 transform | `MainWindow.axaml:606-609`;ZoomBorder `MinZoom 0.5/MaxZoom 3/ZoomSpeed 1.2`(`:199-209`) |
| A11 | 分体主界面切换 | 开启/关闭分体 | 行内边距、圆角、背景宽 | 合并 ↔ 分体 | 与 A5 同步 300ms | 同 A5 | 无 | class 切换 + 宽度过渡 | `FluentTheme/Styles.axaml:245-259` |
| A12 | 进度条值过渡 | 每秒/每 50ms 更新 | `value`/`transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 无 | CSS transition `transform` | `FluentTheme/Styles.axaml:137-145`;`ClassicTheme/Styles.axaml:123-131` |
### 14.2 B. 提醒与强提醒动效
提醒是「岛」最重要的动效场景,必须实现两套完全不同的遮罩形态(Fluent/经典),并共用同一套队列与遮罩生命周期状态机。状态机必须严格按源码实现:`:mask-anim` → `:mask-in` →(可选)`:overlay-anim`/`:overlay-in` → `:mask-out` → `:overlay-out`,任一状态切换都通过类名驱动(`Controls/MainWindowLine.cs:724-791`)。**禁止**用 JS 计时器手动改元素 style 来「模拟」状态机,否则队列切换时会错乱。
强提醒特效是**独立置顶窗口**(`TopmostEffectWindow`),不属于岛 DOM;特效元素播放完成后自行移除,窗口内元素清空即隐藏窗口(`TopmostEffectWindow.axaml.cs:50-67,90-98`)。特效必须支持 `NotificationEffectRenderingScale` 缩放(默认 1.0,大屏自动降到 0.75/0.5,见 part 03 表)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| B1 | Fluent 遮罩斜切扫入 | `:mask-in` | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | `QuarticEaseOut`(≈`(0.16,1,0.3,1)`【推断】) | 段间 120ms:区 0/4 同起,1/3 延迟 90ms,2 延迟 120ms | canvas/SVG 自绘 + rAF 或 WAAPI 驱动进度 | `SlantedMaskControl.cs:166-202,225-251`;`FluentTheme/Styles.axaml:175-180` |
| B2 | Fluent 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS animation `both` | `FluentTheme/Styles.axaml:329-340` |
| B3 | Fluent 遮罩文字缩放 | `:mask-in` | `scale` | 1.1 → 1.0 | 750ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS transform animation | `FluentTheme/Styles.axaml:341-352` |
| B4 | Fluent 遮罩投影淡入 | `:mask-in` | 背景层 `opacity`、`box-shadow` | 0 → 1;阴影 `0 8 32 #66000000` | 100ms | 默认 | 延迟 600ms | CSS animation + `transition: box-shadow 100ms` | `FluentTheme/Styles.axaml:169-173,355-367` |
| B5 | Fluent 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 200ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:376-386` |
| B6 | Fluent 遮罩文字放大退出 | `:mask-out` | `scale` | 1.0 → 1.1 | 200ms | `(0.5,0,0.75,0)` | 无 | CSS animation | `FluentTheme/Styles.axaml:387-398` |
| B7 | Fluent 遮罩背景淡出 | `:mask-out` | 背景层 `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:401-409` |
| B8 | 覆盖层淡入/淡出 | `:overlay-in`/`:overlay-out` | `opacity` | 0 ↔ 1 | 200ms | `SineEaseIn`≈`(0.12,0,0.39,0)`【推断】 | 无 | CSS transition | `FluentTheme/Styles.axaml:419-451` |
| B9 | 内容上滑进入(遮罩层内) | 遮罩内内容切换 | `translateY` | `-50` → 0 | 200ms | `SineEaseIn` | 无 | CSS transition | `ClassicTheme/Styles.axaml:237-249,266-281` 相关;主窗口 `TranslateTransform Y=-50`(`ClassicTheme/Styles.axaml:87,148`) |
| B10 | Classic 遮罩下压 | `:mask-in` | `translateY` | -50 → 0 | 200ms | `QuadraticEaseIn` | 无 | CSS animation `both` | `ClassicTheme/Styles.axaml:265-281` |
| B11 | Classic 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 100ms | 默认 | 延迟 200ms | CSS animation | `ClassicTheme/Styles.axaml:288-300` |
| B12 | Classic 遮罩上滑退出 | `:mask-out` | `translateY` | 0 → 50 | 200ms | `QuadraticEaseOut` | 延迟 100ms | CSS animation `forward` | `ClassicTheme/Styles.axaml:305-318` |
| B13 | Classic 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `ClassicTheme/Styles.axaml:319-331` |
| B14 | Classic 行投影淡入 | `:mask-in` | `effect`(`DropShadowEffect`) | opacity 0 → 1,blur 8 | 100ms | 线性 | 无 | CSS `box-shadow` transition(禁止 filter) | `ClassicTheme/Styles.axaml:34-45,283-287` |
| B15 | 提醒进度条 | 提醒剩余时间更新 | `value` | 旧 → 新 | 50ms | 线性 | 无 | CSS transition `transform: scaleX` | `FluentTheme/Styles.axaml:137-145` |
| B16 | 强提醒涟漪扩散 | 提醒开始(特效开启) | `scale` | 0 → 1 | 600ms | `QuadraticEaseIn` | 无 | WAAPI `transform`,原点=岛中心 | `RippleEffect.axaml.cs:86-96` |
| B17 | 强提醒涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | `SineEaseIn` | 无 | WAAPI `opacity` | `RippleEffect.axaml.cs:102-106` |
| B18 | 强提醒结束回调 | 特效完成 | — | — | 总 750ms | — | 600ms 动画后等 150ms | `setTimeout(750)` 后移除特效元素 | `RippleEffect.axaml.cs:108-109`;`TopmostEffectWindow.axaml.cs:90-98` |
| B19 | 逐字滚动提醒 | 提醒内容超宽 | `offset.x` | 右边界 → 左边界循环 | `Duration / RepeatCount` | 线性 | 无 | WAAPI 无限循环,每帧线性位移 | `RollingTextTemplate.axaml.cs:39-62`;`RollingTextTemplateData.RepeatCount` 默认 2 |
| B20 | 滚动提醒重排修正 | 容器尺寸变化 | `offset.x` | 按当前进度重算 | 即时重播 | 线性 | 无 | 重新 `startAnimation`(源码在 LayoutUpdated 重设) | `RollingTextTemplate.axaml.cs:75-80` |
### 14.3 C. 启动、OOBE、欢迎动效
启动链路是「闪屏 → 主界面」两段式:闪屏自身有滑入与进度动画,主界面出现时行/组件按动画级别淡入(A2/A4)。OOBE 是唯一允许使用 3 秒级长动画的场景,必须整段可跳过(用户点击任意处立即进入下一步),且在 `AnimationLevel=0` 时直接呈现终态(`OobeIntroAnimationControl.axaml.cs:161-164` 只在 Loaded 时播放;实现时须先判断级别)。
OOBE 的字符动画是「11 个方块 + 11 个字符」双轨并行的复杂编排,必须按源码公式逐字计算延迟(C6),不得改成固定 50ms 等间隔——源码用正弦分布让中部字符更密、两端更疏(`OobeIntroAnimationControl.axaml.cs:37`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| C1 | 闪屏 Logo 滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | `ExponentialEaseInOut`≈`(0.16,1,0.3,1)`【推断】 | 0ms | WAAPI transform | `SplashWindow.axaml.cs:101-119` |
| C2 | 闪屏状态栏滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | 同上 | 150ms | WAAPI transform | `SplashWindow.axaml.cs:53-54,109-119` |
| C3 | 闪屏淡入 | 闪屏启动 | `opacity` | 0 → 1 | 300ms | `CubicEaseOut` | 与 C1/C2 同延迟 | WAAPI opacity | `SplashWindow.axaml.cs:114-120` |
| C4 | 闪屏进度条 | 启动进度更新 | `translateX` | `-400·(1-p)` 到目标 | `max((Δp)/8, 0.5)s`;结束时 150ms | `CubicEaseOut` | 无 | WAAPI transform | `SplashWindow.axaml.cs:64-82,126` |
| C5 | OOBE 整场进场 | 欢迎页加载 | `scale`、`opacity` | 1.25 → 1.0;0 → 1 | 3000ms | `(0,0.83,0.17,0.90)` | 无 | CSS animation `both` | `OobeIntroAnimationControl.axaml:14-30` |
| C6 | OOBE 字符错峰翻入 | 进场后逐字 | `translateY`、`opacity` | 50 → 0;0 → 1 | `delay·9·1.5 + 750` ms | `(0.25,1,0.5,1)`(入) | 逐字延迟 `sin((i+2)/(n+2)·π/2)·500/n` ms | WAAPI 逐元素动画,串行 await | `OobeIntroAnimationControl.axaml.cs:30-54,65-124` |
| C7 | OOBE 字符翻出 | 同一字符动画后段 | `rotate3D.angleX`、`opacity` | 0 → 90°;1 → 0 | 同上 | `(0.32,0,0.67,0)` | 内嵌于 C6 关键帧 | WAAPI 关键帧(0%/timeMs/timeMs+750/100%) | `OobeIntroAnimationControl.axaml.cs:92-118` |
| C8 | OOBE 文字块翻入 | 字符对转向后 | `rotate3D.angleX`、`opacity` | -90° → 0;0 → 1 | `delay·9·2 + 750` ms | `(0.33,1,0.68,1)` | 内嵌 | WAAPI 关键帧 | `OobeIntroAnimationControl.axaml.cs:126-158` |
| C9 | OOBE 方块放大 | 第 7 个方块 | `scaleX`、`background` | 1 → 2.17;灰 → `#00BFFF` | 250ms | `(0.76,0,0.24,1)` | 延迟 307ms | CSS animation | `OobeIntroAnimationControl.axaml:44-58` |
| C10 | OOBE 文字收拢 | 动画末尾 | `gap`/`letter-spacing`、`min-width` | 4px → 0;32px → 0 | 700ms | `(0.33,1,0.68,1)` | 无 | CSS animation | `OobeIntroAnimationControl.axaml:88-116` |
| C11 | 欢迎页内容淡入 | 欢迎页显示 | `opacity` | 0 → 1 | 200ms | 默认 | 无 | CSS animation `both` | `Views/WelcomePages/WelcomePage.axaml:11-24` |
| C12 | 欢迎页 Logo 布局 | 欢迎页显示 | 尺寸 | 80×80 | — | — | — | 静态,非动画 | `WelcomePage.axaml:29-33` |
### 14.4 D. 弹层、抽屉、列表与设置动效
本组是「应用外壳」动效,必须复用 ClassHelper 既有 token 与 Element Plus 覆盖层(`renderer/styles/index.css:567-756` 已把弹窗/抽屉/浮层/消息条的过渡统一替换为弹簧与 `--ch-ease-out`)。实现时**不得**再写第二套弹窗动画;新弹层一律继承既有 class。
列表错峰(D12)必须是「真实逐项启动」而不是一次性 `animation-delay` 批量:源码用 25ms 间隔的 DispatcherTimer 逐项置位(`StackPanelIntroAnimationBehavior.cs:74-90`),逐项启动才能保证「列表在动画过程中被重新排序/过滤」时新项不等待旧延迟。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| D1 | Popup 淡入 | 任意 Popup 打开 | `opacity` | 0 → 1 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI(`AnimationLevel≥1`) | `PopupIntroAnimationBehavior.cs:73-78` |
| D2 | Popup 缩放淡入 | 同上 | `scale` | 0.925 → 1.0 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI;`transform-origin` 按 placement 决定(Bottom→上中、Right→左中、Pointer→左上、Center→中心) | `PopupIntroAnimationBehavior.cs:80-112` |
| D3 | 横抽屉推入 | 抽屉打开 | `translateX`、`opacity` | 抽屉宽 → 0;0 → 1 | 250ms | `cubic-bezier(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:42-59` |
| D4 | 横抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:52-58,68-70` |
| D5 | 竖抽屉推入 | 抽屉打开/收起 | `translateY`(Canvas.Bottom)、`height` | 关闭高度 → 0 | 250ms | `(0,0,0,1)` | 无 | CSS transition(用 transform 代替 bottom/height) | `VerticalDrawer.axaml:29-36,136-155` |
| D6 | 竖抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `VerticalDrawer.axaml:126-134,150-153` |
| D7 | 图标按钮展开 | 展开/收起 | `width`、文字 `opacity` | 图标宽 ↔ 全宽;0 ↔ 1 | `Duration` 属性(默认【推断】200ms) | `SineEaseOut`(宽)/`SineEaseInOut`(文字) | 无 | CSS transition | `AnimatedIconButton.axaml:22-46` |
| D8 | 图标按钮底色 | 悬停/主题变化 | `background` | 旧色 → 新色 | 150ms | 默认 | 无 | CSS transition | `AnimatedIconButton.axaml:81-88` |
| D9 | Shimmer 骨架淡出 | 内容加载完成 | `opacity` | 1 → 0(骨架);0 → 1(内容) | 200ms | `(0.65,0,0.35,1)` | 无 | CSS transition | `Shimmer.axaml:22-38,43-54` |
| D10 | 列表滑入 | 选中项变化 | `translateY`、`opacity` | -40 → 0;0 → 1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `SlidingListBox.axaml:38-55` |
| D11 | 列表滑出 | 同上 | `translateY`、`opacity` | 0 → 40;1 → 0 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation | `SlidingListBox.axaml:57-74` |
| D12 | 堆叠面板错峰入场 | 面板首次加载 | `translateY`、`opacity` | 50 → 0;0 → 1 | 750ms | `(0,1,0,1)` | **每项间隔 25ms**(DispatcherTimer) | rAF/`setInterval(25ms)` 逐项加 class | `StackPanelIntroAnimationBehavior.cs:74-90`;`StackPanelIntroAnimation.axaml:11-35` |
| D13 | 工作流进度条颜色 | 状态变化 | `background` | 灰 ↔ accent | 500ms | `SineEaseInOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:16-22` |
| D14 | 工作流不确定态呼吸 | 进行中且无进度 | `opacity` | 0.85 → 0.4 → 0.85 | 750ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `WorkflowProgressIndicatorItemControl.axaml:28-44` |
| D15 | 工作流确定性进度 | 有进度值 | `value` | 旧 → 新 | 180ms | `SineEaseInOut` | 无 | CSS transition transform | `WorkflowProgressIndicatorItemControl.axaml:57-67` |
| D16 | 工作流完成淡入 | 完成后 | `opacity` | 0 → 1 | 300ms | `SineEaseOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:63-65` |
| D17 | TeachingTip 箭头循环 | 教学提示显示 | `translateY`、`rotateY` | 0→-8→0;0→180° | 1000ms | `(0.5,1,0.89,1)`/`(0.11,0,0.5,0)`/`(0.66,0,0.34,1)` | 无限 | CSS animation infinite | `MyTeachingTipStyles.axaml:223-251` |
| D18 | 教程高亮入场 | 教程步骤开始 | `margin`、`opacity` | -64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation `both` | `TutorialHighlightControl.axaml:11-24` |
| D19 | 教程高亮呼吸循环 | 教程步骤持续 | `margin`、`opacity` | -64→0→0;0→1→1 | 1750ms | `(0.16,1,0.3,1)` | 延迟 1750ms,无限 | CSS animation infinite | `TutorialHighlightControl.axaml:25-44` |
| D20 | 教程聚光入场 | 教程步骤开始 | `margin`、`opacity` | 64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `TutorialSpotlightControl.axaml:11-35` |
| D21 | 更新图标旋转 | 下载更新中 | `rotate` | 0 → 360° | 1000ms | `(0.5,0.25,0.5,0.75)` | 无限 | CSS animation infinite | `UpdateSettingsPage.axaml:88-99` |
| D22 | 关于页头部淡入 | 页面加载 | `opacity` | 0 → 1 | 200ms | `(0,0,0,1)` | 延迟 250ms | CSS animation `both` | `AboutSettingsPage.axaml:133-147` |
| D23 | 拖拽高亮脉冲 | 拖动组件中 | `opacity` | 0.1 → 0.6 → 0.1 | 1000ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `EditableComponentsListBox.axaml:307-335` |
| D24 | 设置面板显隐 | 子设置展开 | `opacity` | 0 ↔ 1 | 200ms | `(0,1,0,1)` | 无 | CSS transition | `WeatherSettingsPage.axaml:207-214` |
| D25 | 自动化项显隐 | 条件切换 | `opacity` | 0 ↔ 1 | 100ms | 默认 | 无 | CSS transition | `AutomationSettingsPage.axaml:140-142` |
| D26 | 列表项淡入(课表) | 课表项状态变化 | `opacity` | 0 ↔ 1 | 150ms | 默认 | 无 | CSS transition | `LessonsListBox.axaml:69-77` |
### 14.5 E. 组件动效
组件动效的原则是「**动内容不动容器**」:滚动、滑动、倒计时进度都在组件内部完成,绝不触发行高/行宽变化(否则会触发 A5/A6 的行宽过渡,产生连锁抖动)。滚动组件的速度单位是「像素/秒」,必须由设置项驱动并做下限保护(`Math.Clamp(SpeedPixelPerSecond, 1, int.MaxValue)`,`RollingComponent.axaml.cs:96`),暂停时长默认 10 秒,暂停位置默认 0(`RollingComponentSettings.cs`)。
时钟、天气图标、时间线色块**不做**动画:它们是高频更新的信息元素,任何过渡都会让「时间在走」变得可感知延迟。源码只在字号/颜色上做静态样式(`ClockComponent.axaml:14`、`WeatherComponent.axaml:261-305`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| E1 | 滚动组件滚动 | 内容宽于容器 | `translateX` | 0 → -(W+offset) → 容器宽 → 0 | `(暂停+滚动)` 秒;速度 40px/s | 线性 | 暂停 10s(默认) | WAAPI 关键帧无限循环 | `Controls/Components/RollingComponent.axaml.cs:85-135`;`RollingComponentSettings.cs`(`SpeedPixelPerSecond=40`、`PauseSeconds=10`) |
| E2 | 滚动组件重置 | 停止滚动/规则命中 | `translateX` | 归零 | 1s(空动画占位) | 线性 | 无 | 用 1s 静止关键帧覆盖旧动画 | `RollingComponent.axaml.cs:137-157` |
| E3 | 滚动组件停止规则 | 规则命中 | 滚动状态 | 滚动 → 停止 | 即时 | — | 无 | 取消动画并归位 | `RollingComponent.axaml.cs:171-179` |
| E4 | 滑动组件切换 | 选中项变化 | `translateY`、`opacity` | -40→0 / 0→40;0↔1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `Controls/Components/SlideComponent.axaml:69-107` |
| E5 | 滑动组件禁用动画 | 设置关闭动画 | — | 立即切换 | 0ms | — | — | 关闭时直接切 `display` | `SlideComponent.axaml:51-53` |
| E6 | 倒计时进度 | 计时更新 | 进度值 | 旧 → 新 | 50ms(同 A12) | 线性 | 无 | CSS transition;降低精度时按 `max(10, 总秒/500)` 取整 | `CountDownComponent.axaml.cs:288-295`;`Settings.cs:850` |
| E7 | 时钟秒变化 | 秒变化 | 文本内容 | — | 无动画 | — | — | 直接替换文本(不要做数字翻转动画) | `ClockComponent.axaml:14`(仅字号) |
| E8 | 天气图标切换 | 天气数据更新 | 图标字形 | 旧 → 新 | 无动画 | — | — | 直接替换(4 套模板,见 part 15) | `Controls/WeatherIcons/*.axaml` |
| E9 | 时间线色块 | 时间点类型 | 颜色/高度 | 类型色 | 无动画 | — | — | 静态 | `Themes/Generic.xaml:65-128` |
| E10 | 时间线装饰器出现 | 悬停/选中 | 透明度 | 0 → 1 | 200ms【推断】 | 默认 | 无 | 由行为控制(`TimeLineListItemBehavior`),Web 用 CSS transition | `Themes/Generic.xaml:190-290` |
| E11 | 提醒遮罩分块绘制 | 遮罩打开 | 5 段平行四边形宽度 | 0 → 全宽 | 同 B1 | 同 B1 | 同 B1 | canvas 绘制(每帧按进度重绘) | `SlantedMaskControl.cs:273-343` |
### 14.6 F. ClassHelper 既有动效(必须复用,不得重造)
ClassHelper 已经沉淀了一套完整动效体系,**必须原样复用**,只在「ClassIsland 特有场景」上做增量:
| 既有能力 | 位置 | 复用方式 |
|---|---|---|
| 动效 token 注入 | `packages/shared/src/motion.ts` → 两端 `composables/motion.ts` | 在 `app.mount()` **之前**写入 `:root`(首帧即就位,无 FOUC) |
| 弹簧曲线转 CSS `linear()` | `motion.ts:152-164` | 直接调用 `springLinearEasing()`,禁止手写采样点 |
| 减少动态效果 | `composables/motion.ts:53-72` | 主界面/管理中心统一使用 `useReducedMotion()` |
| 悬停能力检测 | `composables/motion.ts:81-99` | 所有 hover-only 效果必须先过 `useHoverCapable()`(教室触摸屏幽灵 hover) |
| 数字滚动 | `composables/motion.ts:129-167` | 倒计时/统计数字直接复用 `useCountUp()` |
| 错峰入场 | `composables/motion.ts:175-179` | 列表/卡片直接复用 `useStaggerIn()` |
| 整页换肤 | `composables/motion.ts:201-237` | 应用主题切换复用 `useThemeReveal()` |
硬约束:**不得**在岛渲染进程里引入主渲染进程的 `--ch-*` 动效 token(ClassHelper 明确「岛是另一个渲染进程,刻意不参与这里的动效体系」,`motion.ts:29-31`)。岛必须使用自己的 `--island-*` 与 `SpringValue`。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| F1 | 岛尺寸弹簧 | 模式/尺寸变化 | `width`、`height`、`radius` | 当前 → 目标 | 由物理积分决定 | 弹簧 `stiffness=0.10`、`damping=0.68` | 无 | 每帧 `SpringValue.step()`,60fps 单位 | `ClassHelper/src/island/spring.ts:26-114`;`IslandApp.vue:216-322` |
| F2 | 岛换向限速 | 目标方向反转 | `velocity` | 反向保留 35%,帧位移 ≤ 剩余 20% | 每帧 | — | 无 | `redirectVelocity` | `spring.ts:66-82` |
| F3 | 岛零过冲钳制 | 越过目标 | `value`、`velocity` | 吸附目标、速度清零 | 即时 | — | 无 | `step()` 内判定 | `spring.ts:100-107` |
| F4 | 岛淡入(窗口) | 窗口显示 | 窗口 `opacity` | 当前 → 目标 | 每帧 +0.16,16ms 步进(约 60–110ms) | 线性 | 无 | 主进程 `setOpacity` 定时步进 | `src/main/island.ts:1674-1691` |
| F5 | 岛淡出(窗口) | 窗口隐藏 | 窗口 `opacity` | 当前 → 0 | 每帧 -0.2,16ms 步进(约 50–80ms) | 线性 | 无 | 主进程 `setOpacity`,到 ≤0.02 后 `hide()` | `island.ts:1693-1715` |
| F6 | 岛显示方式 | 显示 | 焦点 | `showInactive()` 不抢焦点 | 即时 | — | 无 | 主进程 | `island.ts:1426,1458,1490` |
| F7 | 弹窗弹簧入场 | 弹窗打开 | `transform`、`opacity` | `translateY(-12px) scale(.97)` → 0/1 | `SPRING_PANEL` 自然落定(约 463ms) | 弹簧 `linear()` 或 `cubic-bezier(.16,1,.3,1)` 回退 | 无 | CSS animation | `index.css:596-605,634-636`;`motion.ts:52,220-230` |
| F8 | 弹窗快速退出 | 弹窗关闭 | `transform`、`opacity` | → `translateY(-6px) scale(.985)` | 140ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `index.css:607-616,642-644` |
| F9 | 抽屉弹簧 | 抽屉打开/关闭 | `transform`、`opacity` | 侧边 ↔ 0 | `SPRING_PANEL` 463ms / `--ch-dur-base` 200ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:699-703` |
| F10 | 浮层缩放 | 下拉/选择器 | `transform`、`opacity` | `scale(.95)` → 1 | `SPRING_SWAP` 约 263ms | 弹簧 | 无 | CSS transition | `index.css:710-737`;`motion.ts:50,228-229` |
| F11 | 消息条进出 | 消息出现/消失 | `transform`、`opacity`、`top/bottom` | 位移 | 弹簧 263ms / 140ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:739-756` |
| F12 | 按钮按压 | 按下/抬起 | `transform: scale` | 1 → 0.97 → 1 | 按下 90ms;抬起 `SPRING_PRESS` 约 194ms【推断】 | 按下 ease-out;抬起弹簧 | 无 | CSS transition(按下与基础规则分离) | `index.css:769-782`;`motion.ts:48,178` |
| F13 | 卡片抬升 | 悬停(仅 hover 设备) | `transform: translateY`、`box-shadow` | 0 → -1px | 263ms | 弹簧/ease-out | 无 | CSS transition,**必须**包在 `@media (hover:hover) and (pointer:fine)` 内 | `index.css:791-802` |
| F14 | 列表错峰入场 | 列表渲染 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 每项 35ms,第 9 项起封顶 | CSS animation + 内联 `--ch-reveal-delay` | `index.css:814-828`;`motion.ts:175-179` |
| F15 | 页面块错峰入场 | 路由切换 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 30ms/块,封顶 180ms | CSS animation `nth-child` | `index.css:846-883` |
| F16 | 整页换肤扩散 | 切换主题 | `clip-path: circle()` | 0px → 最远角半径 | 480ms(320×1.5) | `(0.16,1,0.3,1)` | 无 | View Transition API + WAAPI | `composables/motion.ts:201-237` |
| F17 | 侧栏折叠 | 折叠/展开 | `width`、`gap` | 全宽 ↔ 64–80px | 260ms | `cubic-bezier(0.4,0,0.2,1)` | 无 | CSS transition | `index.css:56-75` |
| F18 | 减少动态效果 | 系统偏好 | 全部时长 | → 1ms | 1ms | — | — | 全局媒体查询覆盖(加载指示器例外,放慢到 1.6s) | `index.css:904-930` |
### 14.7 实现方式规范(必须遵守)
1. **优先使用合成器友好属性**:只对 `transform` 与 `opacity` 启 CSS transition/WAAPI。源码中 `BackgroundWidth`(A5/A6)与 `Margin`(D18/D19)属于布局属性,Web 实现必须做等价转换:
- 行宽过渡:背景层绝对定位于内容层之下,宽度用 `clip-path: inset(0 calc(100% - var(--w)) 0 0 round <r>)` 或 `transform: scaleX()`(配合 `transform-origin: left`)实现,**不得**对包含文字的容器直接 `transition: width`(会导致逐帧重排)。
- 教程高亮:把 `margin` 动画换成 `transform: scale()`(围绕中心放大)或 `inset` 的 `clip-path` 动画,视觉等价。
2. **弹簧动画**:岛尺寸/圆角必须用 ClassHelper 的 `SpringValue`(F1–F3),不得改用 CSS 弹簧曲线;两条路径不能混用(ClassHelper `motion.ts:29-33` 明确「岛是另一个渲染进程,不参与本动效体系」)。
3. **弹簧曲线用于 CSS 时**:过冲曲线只能用于 `transform`/`clip-path`,**禁止用于 `opacity`**(会被裁剪到 [0,1],观感变成「到顶就停」)(`motion.ts:148-151`)。
4. **`filter` 禁忌**:卡片类元素禁止用 `filter: drop-shadow` 做投影(会新建渲染表面,开合瞬间闪帧),必须用 `box-shadow`(`classic-island.css:114-120`)。
5. **`will-change`**:只允许在动画进行中的元素上临时添加 `will-change: transform, opacity`,动画结束(`animationend`/`transitionend`)立即移除;禁止全局常驻。
6. **禁止动画的属性**:`z-index`、`display`(用 `visibility`/`opacity` 或先 `display` 后下一帧启动)、`pointer-events`。需要「显隐 + 动画」时用双 rAF 或 `requestAnimationFrame` 起手。
7. **中断语义**:任何进场动画被反向触发时必须能平滑打断(`FillMode.Both/Forward` 对应 `animation-fill-mode`;用 `getAnimations().forEach(a => a.cancel())` 清理旧动画)。
8. **统一调度**:动画必须在 `AnimationLevel=0` 时统一短路(除主界面提醒);同一时刻同一元素不得叠加两条竞争动画。
9. **帧率与预算**:岛内动画(弹簧步进)必须跑在独立渲染进程的 rAF 上;主渲染进程的 60fps 预算为 16.7ms,任何单帧主线程任务禁止超过 8ms;动画期间不得读取 `offsetWidth` 等强制同步布局属性。
10. **禁止「看起来像动画的定时器」**:所有时长必须来自 14.0.3 常量表,不得在组件里写 `300`、`0.3s` 之类字面量。
#### 14.7.1 几何动画的 FLIP 流程(必须按此实现)
源码大量动画布局属性(宽度、间距、边距),Web 端必须用 FLIP 换算成合成器动画:
1. **First**:在状态改变前读取元素 `getBoundingClientRect()` 并缓存(只在必要时读一次,避免强制同步布局)。
2. **Last**:改完 class/数据后,下一帧再读一次新几何。
3. **Invert**:计算 `dx = old.left - new.left`、`dy = old.top - new.top`、`sx = old.width / new.width`,对元素设置 `transform: translate(dx,dy) scale(sx)` 并临时关掉 transition。
4. **Play**:强制一次重排(读 `offsetHeight` 或 `getBoundingClientRect`),再恢复 transition 并清空 transform,让浏览器从「反演位置」过渡到「最终位置」。
FLIP 的硬性要求:整个流程必须在**同一帧内**完成读取与写入的分离(先批量读、再批量写),否则会触发多次回流;对列表批量移动时必须逐个元素做,不得在父容器上做 scale(会把文字一起拉伸)。
#### 14.7.2 行宽过渡的推荐实现(替换源码的 BackgroundWidth 动画)
源码动画的是背景层 `Width`(`FluentTheme/Styles.axaml:81`、`ClassicTheme/Styles.axaml:63`),因为背景层只承载底色与描边,不承载文字。Web 端必须照此分层:
```html
<div class="island-line" style="--line-w: 420px; --line-r: 8px">
<div class="island-bg"></div> <!-- 绝对定位;只动画这一层 -->
<div class="island-content">…</div>
</div>
```
```css
.island-bg{
position:absolute; inset:0 auto 0 0; width:var(--line-w);
border-radius:var(--line-r); background:var(--ci-island-bg);
transition: width var(--ci-line-w-dur) var(--ci-line-w-ease);
will-change: width; /* 仅动画期间由 JS 加上 */
}
```
- Fluent:`--ci-line-w-dur:300ms; --ci-line-w-ease:cubic-bezier(.65,0,.35,1)`。
- 经典:`--ci-line-w-dur:550ms; --ci-line-w-ease:cubic-bezier(0,1,0.5,1.1)`。
- 内容层宽度变化必须瞬时(不加过渡),否则文字会在动画中抖动。
- 背景层宽度变化会触发布局,但因为它只有底色、且 `contain: strict` 可限制影响范围,实测可接受;若追求纯合成器动画,可改用 `clip-path: inset(0 calc(100% - var(--line-w)) 0 0 round var(--line-r))`,但圆角与描边的贴合需要额外验证。
#### 14.7.3 斜切遮罩的 canvas 实现要点
若选择 canvas 复刻 `SlantedMaskControl`:
- 每个区段的几何:中心点 `rCenter`、当前宽度 `currentW = fullW × progress`、斜切偏移 `offset = tan(30°) × h`(`SlantedMaskControl.cs:281-343`)。
- 绘制顺序:区 0/4(两端)→ 区 1/3 → 区 2(中间),与 B1 的交错延迟一致。
- 进度驱动:`requestAnimationFrame` 中累加时间,按 `progress = easeOutQuart(t)` 重算并 `ctx.clearRect` + 重绘;`RegionDurationMs=360`、`StageStaggerMs=120` 必须可配置(主题可覆盖,见 part 09 的 `--ci-island-mask-*`)。
- 绘制完成后必须停止 rAF,不得常驻渲染循环;遮罩关闭时按 B5–B7 反向播放。
- 若用 DOM/SVG 实现,5 个 `polygon` 的 `points` 用 CSS 变量驱动 `clip-path`,但注意 `clip-path` 的百分比无法表达斜切角,必须用 `path()` 或 `polygon()` 的绝对像素值并在 resize 时重算。
#### 14.7.4 动效的测试钩子(必须提供)
为便于验收,实现必须暴露以下测试接口(仅开发构建可用):
- `window.__ciMotion.getState(id)`:返回某动画的 `{playing, elapsed, duration, easing}`。
- `window.__ciMotion.list()`:返回当前所有运行中动画的 id 与元素选择器。
- `window.__ciMotion.setLevel(0|1|2)`:运行时切换动画级别。
- `window.__ciMotion.disable()`:等价 `prefers-reduced-motion`。
这些钩子必须不影响生产构建(通过 `import.meta.env.DEV` 或独立 dev 入口注入)。
### 14.8 与源码的差异说明(必须写进实现注释)
- 源码用 Avalonia `Transitions`(属性级隐式动画)与 `Animation`(关键帧)两套机制;Web 端对应 `transition` 与 `@keyframes`/WAAPI。二者的「延迟 + 填充模式」语义必须一一对应,特别是 `FillMode="Both"`(对应 `animation-fill-mode: both`),漏掉会在延迟期间闪出终态(`StackPanelIntroAnimation.axaml:22`、`FluentTheme/Styles.axaml:332`)。
- 源码 `ClassicTheme` 的行宽过渡带过冲(`1.10`),这是「经典主题手感」的一部分,**不得**为了「统一」改成 Fluent 的曲线。
- 源码 `SlantedMaskControl` 用 `canvas` 逐帧重绘 5 段平行四边形(`SlantedMaskControl.cs:273-343`),Web 端可以用 canvas 2D 或 5 个 `clip-path` 的多边形,但**分块权重必须保持** `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]`(`SlantedMaskControl.cs:298`),且斜切角固定 60°(偏移 `tan(30°)·h`,`:284`)。
- 源码的时间轴用 `DispatcherTimer(25ms)` 驱动列表项逐个入场(`StackPanelIntroAnimationBehavior.cs:74-90`);Web 端用 `setTimeout` 链或 `requestAnimationFrame` 时间片,间隔仍然 25ms,不得改成 CSS `animation-delay` 批量(会与「列表顺序变化」脱节)。
#### 14.8.1 性能预算与降级矩阵
| 场景 | 目标帧率 | 单帧主线程预算 | 降级顺序 | 说明 |
|---|---|---|---|---|
| 岛弹簧(独立渲染进程) | 60fps | 该进程 8ms | ① 降低步进频率到 30fps ② 关闭圆角形变只动尺寸 ③ 直接落位 | 必须在独立进程,不得与主界面共用事件循环 |
| 提醒遮罩(canvas 重绘) | 60fps | 4ms | ① 减到 3 段 ② 降到 30fps ③ 改为纯透明度过渡 | 重绘面积大,必须先算好路径再绘制 |
| 列表错峰入场 | 60fps | 8ms | ① 只动画 opacity ② 减少同屏项数 ③ 直接显示 | 禁止对每项做 FLIP |
| 窗口级淡入淡出(主进程) | 30fps 足够 | — | 直接落位 | 主进程 `setOpacity` 步进不占渲染线程 |
| 整页换肤(View Transition) | 60fps | 16ms | ① 缩短到 320ms ② 关掉圆形扩散改交叉淡入 ③ 直接切换 | 文档不可见时必须同步切换(`motion.ts:195-200`) |
降级必须是**自动**的:连续 30 帧低于 45fps 时自动降一级,并在 5 秒后尝试恢复;降级不得改变最终视觉终态,只改变过程。
#### 14.8.2 可访问性要求
1. **减少动态效果**:必须响应系统偏好,且提供应用内「动画级别=禁用」作为等效手段(两者任一命中即降级)。
2. **不得用闪烁传达信息**:任何闪烁频率必须低于 3Hz;源码没有任何闪烁动画,实现也不得新增。
3. **动画不得是唯一反馈**:按钮按压、开关切换等必须同时有颜色/状态变化,不能只靠缩放。
4. **焦点不丢**:动画期间焦点元素不得被移除;抽屉/弹窗关闭后焦点必须回到触发按钮。
5. **文字可读**:动画过程中文字不得低于 4.5:1 对比度(遮罩淡入时文字必须等遮罩底色到位后再出现,源码用延迟 260ms 实现,见 B2)。
6. **超大字号**:用户把 4 档字号调到 40px 时,所有含固定高度的动画容器必须允许内容溢出或自适应,不得裁切文字。
### 14.9 验收标准
1. **逐条对照**:本节 A1–A12、B1–B20、C1–C12、D1–D26、E1–E11、F1–F18 共 **99 条**动效全部实现;抽测任意 10 条,时长误差 ≤ 1 帧(16.7ms),曲线肉眼无差异。
2. **动画级别**:切到「禁用」后,抽屉/Popup/列表错峰动画不再播放(主界面提醒动画保留);切到「华丽」后行淡入与组件淡入出现;切回「标准」后二者消失。
3. **减少动态效果**:系统开启 `prefers-reduced-motion` 后,全部动画在 1 帧内完成,页面不出现闪烁或「终态先行」;加载指示器仍在转(放慢至 1.6s)。
4. **打断测试**:连续快速开关同一抽屉/Popup/遮罩 10 次,无卡死、无重影、无「停在半路」;遮罩快速 in→out 切换后元素状态正确复位。
5. **性能**:动画期间主线程帧耗时 P95 < 8ms、掉帧 < 2 帧/秒;岛弹簧动画在独立渲染进程内不掉帧(对照 ClassHelper 的实现位置)。
6. **填充模式**:所有带延迟的动画在延迟期间不显示终态(检查 D12、D18、B2、C6)。
7. **曲线一致性**:`--ci-ease-*` 只在一处定义;`grep` 组件样式不得出现 `cubic-bezier(` 字面量(动画表定义处除外)。
8. **层叠与裁剪**:遮罩动画期间文字不被裁切、圆角外的像素不泄漏;分体主界面下每行动画独立,不出现整排一起抖动。
9. **焦点与命中**:动画期间点击穿透状态正确(非交互态不拦截点击,交互态可点);窗口淡出后不残留不可见但可点击的层。
10. **可复现**:同一操作重复 20 次,动画起始/结束时刻的视觉状态一致,无累积误差(尤其是 A5/A6 的行宽与 E1 的滚动偏移)。
---
## 15. 资源清单(图标 / 字体 / 音效 / 天气图标 / 教程 / 本地化)
本节要求实现者**按清单准备并放置资源**,不得缺项、不得自行替换为网络图片。所有路径均以 ClassIsland 仓库为参照;实现时统一放到 `packages/desktop-client/src/renderer/assets/`(渲染进程资源)与 `packages/desktop-client/resources/`(打包资源)两处,并通过构建脚本生成多尺寸产物(见 15.9)。
### 15.1 应用图标
#### 15.1.1 主图标(多尺寸 PNG)
| 文件 | 尺寸 | 用途 | 证据 |
|---|---|---|---|
| `Assets/AppLogo.png` | 192×192 | 应用内窗口图标、关于页、欢迎页 Logo | `ClassIsland/Assets/AppLogo.png`;`App.axaml:29-31`;`WelcomePage.axaml:29-33` |
| `Assets/AppLogo@48w.png` | 48×48 | 小尺寸(任务栏/列表) | 实测文件头 |
| `Assets/AppLogo@384w.png` | 384×384 | 中尺寸(设置页/关于页展示) | 实测文件头 |
| `Assets/AppLogo@768w.png` | 768×768 | 大尺寸(高分屏) | 实测文件头 |
| `Assets/AppLogo_Fade.png` | 192×193 | 淡入用的半透明 Logo(启动/遮罩) | 实测文件头 |
| `Assets/AppLogo_Monochrome.png` | 2715×2003 | 单色 Logo(水印/背景纹样) | 实测文件头 |
| `Assets/AppLogo_AppLogo.svg` | 矢量 | 可缩放图标源 | 文件名 |
| `Assets/AppLogo.ico` | 多尺寸 ICO:16/24/32/48/64/256×2 | Windows 可执行文件图标 | 解析 ICO 头得 7 个条目 |
| `Assets/FreedesktopIcons/AppLogo@128w.png` | 128×128 | Linux Freedesktop 图标 | 实测文件头 |
| `AppLogo@32w.png`(`ClassIsland.Desktop/Assets/`) | 32×32 | Windows 任务栏/托盘小图标 | 实测文件头 |
#### 15.1.2 macOS 图标集(`ClassIsland.Desktop/Assets/AppLogo.xcassets/AppIcon.appiconset/`)
必须提供:16×16(1x/2x)、32×32(1x/2x)、128×128(1x/2x)、256×256(1x/2x)、512×512(1x/2x)、以及无后缀的 192/384/768 三档。实测目录内共 16 个 PNG,命名遵循 `AppLogo_<尺寸>_<倍率>.png` 与 `AppLogo@<宽>w.png` 两种规则,实现时按 `electron-builder` 的 `icon.icns` 要求重新生成即可,但**必须保留这两个命名族**以免资源引用失效。
#### 15.1.3 Windows 打包瓦片(`ClassIsland.Packaging/Images/`)
| 资源 | 尺寸(100%/200%/400%) | 用途 |
|---|---|---|
| `Square44x44Logo` | 44 / 88 / 176 | 开始菜单小图标 |
| `Square44x44Logo.targetsize-16/24/48/256` | 16/24/48/256 | 任务栏/Alt-Tab 变体 |
| `Square150x150Logo` | 150 / 300 / 600 | 开始菜单中等图标 |
| `Wide310x150Logo` | 310×150 / 620×300 / 1240×600 | 宽幅磁贴 |
| `LargeTile` | 310 / 620 / 1240 | 大磁贴 |
| `SmallTile` | 71 / 142 / 284 | 小磁贴 |
| `StoreLogo` | 50 / 100 / 200 | 商店图标 |
| `SplashScreen` | 620×300 / 1240×600 / 2480×1200 | 系统级启动图(与自绘闪屏无关) |
| `LockScreenLogo` | 48×48 | 锁屏 |
(以上均为实测文件头尺寸;`ClassIsland/Images/` 下另有一份 `.scale-200` 版本,实现时只保留打包用的那份即可。)
#### 15.1.4 其他图标
| 资源 | 尺寸 | 用途 |
|---|---|---|
| `Assets/Marketplace/Logo.png` | 512×512 | 插件/主题市场 Logo |
| `Assets/Marketplace/Logo@256w/128w/64w.png` | 256/128/64 | 市场图标多尺寸 |
| `Assets/Marketplace/Logo.svg` | 矢量 | 市场矢量源 |
| `ClassIsland.ExamplePlugin/icon.png` | 510×510 | 示例插件图标(插件必须自带 510×510 图标) |
| `ClassIsland.Launcher/Assets/AppLogo.ico` | 18 条目 ICO(16–256) | 启动器图标 |
验收要求:所有图标必须**本地打包**、离线可用;PNG 必须由同一矢量源派生,不得出现「不同尺寸风格不一致」;ICO 必须内嵌 16/24/32/48/64/256 六档。
### 15.2 字体
#### 15.2.1 正文/界面字体:HarmonyOS Sans SC
| 文件 | 字重 | 用途 | 证据 |
|---|---|---|---|
| `Assets/Fonts/HarmonyOS_Sans_SC_Light.ttf` | 300 | 轻量标题/水印 | 文件名 |
| `Assets/Fonts/HarmonyOS_Sans_SC_Regular.ttf` | 400 | 正文 | 文件名 |
| `Assets/Fonts/HarmonyOS_Sans_SC_Medium.ttf` | 500 | 默认字重(岛内主文字) | `Settings.cs:101` |
| `Assets/Fonts/HarmonyOS_Sans_SC_Bold.ttf` | 700 | 标题/强调 | 文件名 |
- 字体族注册名:`HarmonyOS Sans SC`;全局资源键 `AppFont`,字体栈见 part 03 §3.2.1(`Assets/Fonts/Fonts.axaml:3`;`Assets/Fonts/Fonts.xaml:3`)。
- **许可**:`Assets/Fonts/LICENSE.txt` 为「HarmonyOS Sans Fonts License Agreement」,版权归华为;分发时必须**原样保留**该许可文件,且不得单独售卖字体。应用本体为 GPL-3.0(`LICENSE.txt`),字体许可与代码许可分离。
- 实现要求:把 4 个 ttf 放进 Electron 打包资源,`@font-face` 声明 4 个 `font-weight`,`font-display: swap`;**禁止**声明 `font-display: block`(会白屏到字体加载完)。
#### 15.2.2 图标字体(三套,必须全部内置)
| 字体 | 文件 | 大小 | 附带映射 | 用途 | 证据 |
|---|---|---|---|---|---|
| Fluent System Icons | `ClassIsland.Core/Assets/Fonts/FluentSystemIcons-Resizable.ttf` | 1,472,748 B | `FluentSystemIcons-Resizable.json`(262,830 B) | 全局 UI 图标(Segoe Fluent 风格) | `AppBase.cs:142`;`ThemeBase.axaml:46` |
| Lucide | `ClassIsland.Core/Assets/Fonts/lucide.ttf` | 715,192 B | `lucide.json`(45,030 B) | 天气图标(默认)、部分 UI 图标 | `AppBase.cs:147`;`ThemeBase.axaml:47` |
| SF Symbols | `ClassIsland.Core/Assets/Fonts/SF-Symbols.ttf` | 174,616 B | — | 天气图标(Apple 风格) | `AppBase.cs:152` |
- 字体族名分别为 `FluentSystemIcons-Resizable`、`lucide`、`SF-Symbols`;Avalonia 资源 URI 形如 `avares://ClassIsland.Core/Assets/Fonts/#lucide`,Web 实现改为 `@font-face { font-family:'lucide'; src:url('lucide.ttf') }` 并保留同名 `font-family`。
- JSON 映射文件必须作为图标选择器数据源(Codepoint/名称表),不得只带 ttf 不带 json——图标选择器需要按名称搜索。
- 许可:应用整体 GPL-3.0;Fluent System Icons 与 Lucide 通常为 MIT/ISC【推断:仓库内未放独立许可文件,按上游项目许可执行】;SF Symbols 字体来自 Apple 符号集,**仅允许在 Apple 平台或已获授权场景使用**,跨平台分发时须评估并在「关于/许可」页声明。
#### 15.2.3 字体相关的硬性要求
1. 字体必须在应用启动**首帧前**注册完成(`document.fonts.ready` 前不得渲染最终布局,可用 `font-display: swap` + 骨架屏规避跳动)。
2. 不得使用系统缺失字体作为唯一来源;`HarmonyOS Sans SC` 必须随包分发。
3. 用户自定义字体(`MainWindowFont`)只允许从**系统已安装字体**中选择(源码 `AppearanceSettingsPage.axaml:409-446` 枚举系统字体),实现时用 `queryLocalFonts()`(需权限)或主进程枚举字体列表,二者都不可用时至少提供内置 4 字重 + 3 图标字体。
4. 字体文件不得被 CSP 阻止:`font-src 'self'` 必须放行本地资源。
### 15.3 音效
| 资源 | 大小 | 用途 | 默认设置 | 证据 |
|---|---|---|---|---|
| `Assets/Media/Notification/1.wav` | 130,012 B | 提醒音效(唯一内置音效) | `IsNotificationSoundEnabled=true`;`NotificationSoundPath=""`(表示使用内置音效);`NotificationSoundVolume=1.0` | 实测文件;`Settings.cs:144-145,1584-1593` |
| 自定义音效 | 用户文件 | 用户可指定本地音频路径 | 支持 wav/mp3 等系统可解码格式【推断】 | `Settings.cs:1496-1505` |
| 语音合成 | 系统/Edge TTS | 提醒朗读 | `IsSpeechEnabled=true`;`SpeechVolume=1.0`;`SpeechSource=0`;Edge TTS 默认音色 `zh-CN-XiaoxiaoNeural` | `Settings.cs:135-139,1406-1428,1452-1461` |
实现要求:
- 音频必须用 `HTMLAudioElement` 或 Web Audio 播放,**不得**在主进程用系统播放器;播放前需用户手势解锁(Electron 可用 `webPreferences.autoplayPolicy: 'no-user-gesture-required'`,但必须在配置中显式声明)。
- 音量 = `NotificationSoundVolume × 系统音量`,范围 0–1;语音音量独立(`SpeechVolume`)。
- 同一条提醒**只播一次**音效(源码用 `MaskSession.HasSoundsPlayed` 去重,`MainWindowLine.cs:746-751`);提醒队列切换时不得重复触发。
- 语音提供方必须可插拔:系统语音(Windows SAPI)、Edge TTS、GPT-SoVITS 三选一(`App.Services.xaml.cs:281-285` 注册三个 provider),默认走系统语音或 Edge TTS,且必须支持「无网络时降级到系统语音」。
- 内置音效 1.wav 必须打包进 `resources/audio/`,不得 CDN 引用。
### 15.4 天气图标(4 套模板)
#### 15.4.1 模板注册表
| 模板 id | 显示名 | 默认 | 实现方式 | 尺寸参数 | 证据 |
|---|---|---|---|---|---|
| `classisland.weatherIcons.lucide` | Lucide(默认) | ✅ `WeatherIconId="classisland.weatherIcons.lucide"` | `Viewbox` + `LucideIcon` | 16×16,FontSize 16,Margin 1 | `App.Services.xaml.cs:287`;`Settings.cs:2106`;`LucideWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.fluentDesign` | Fluent Design | — | `Viewbox` + `FluentIcon` | 20×20,FontSize 24,Margin `0 1.2 0 -1.2` | `App.Services.xaml.cs:288`;`FluentDesignWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.sfSymbols` | SF Symbols | — | `Viewbox` + `SFSymbolsIcon` | 16×16 | `App.Services.xaml.cs:289`;`SFSymbolsWeatherIconTemplate.axaml` |
| `classisland.weatherIcons.simpleText` | 纯文本 | — | 文本 + 分隔符 `~` | 文本随字号 | `App.Services.xaml.cs:290`;`SimpleTextWeatherIconTemplate.axaml` |
实现要求:模板必须可插拔(插件可注册新模板),模板注册信息至少包含 `{id, name, renderer}`;切换模板立即生效且不需要重启(源码 `WeatherService.LoadWeatherIconTemplate()` 在设置变化时重新取模板,`Services/WeatherService.cs:84-90`)。
#### 15.4.2 天气代码 → 名称 → 图标(必须完整映射,共 40 个代码)
数据源:小米天气状态表 `Assets/XiaomiWeather/xiaomi_weather_status.json`;图标映射:`ClassIsland/Converters/WeatherConverters.cs`。下表前端三列(Fluent/Lucide/SF)为源码私有区码位,后端实现时请**原样保留码位**,Web 端用 `data-icon` 名称 + 本地字体渲染。
| code | 天气名 | Fluent 码位 | Lucide 码位 | SF 码位 |
|---|---|---|---|---|
| 0 | 晴 | `\uf465` | `\ue2b1` | `\uFE42` |
| 1 | 多云 | `\uf44f` | `\ue216` | `\uFDC9` |
| 2 | 阴 | `\uf43d` | `\ue217` | `\uFDF1` |
| 3 | 阵雨 | `\uf455` | `\ue2fb` | `\uFDE9` |
| 4 | 雷阵雨 | `\uf46b` | `\ue090` | `\uFDCD` |
| 5 | 雷阵雨并伴有冰雹 | `\uf46b` | `\ue08f` | `\uFDDD` |
| 6 | 雨夹雪 | `\uf459` | `\ue094` | `\uFDD4` |
| 7 | 小雨 | `\uf43f` | `\ue08e` | `\uFDED` |
| 8 | 中雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 9 | 大雨 | `\uf453` | `\ue092` | `\uFDE5` |
| 10 | 暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 11 | 大暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 12 | 特大暴雨 | `\uf453` | `\ue093` | `\uFDE4` |
| 13 | 阵雪 | `\uf45d` | `\ue376` | `\uFDD9` |
| 14 | 小雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 15 | 中雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 16 | 大雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 17 | 暴雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 18 | 雾 | `\uf443` | `\ue214` | `\uFDE1` |
| 19 | 冻雨 | `\uf461` | `\ue08f` | `\uFDD5` |
| 20 | 沙尘暴 | `\uf441` | `\ue218` | `\uFDB1` |
| 21 | 小雨-中雨 | `\uf453` | `\ue092` | `\uFDED` |
| 22 | 中雨-大雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 23 | 大雨-暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 24 | 暴雨-大暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 25 | 大暴雨-特大暴雨 | `\uf453` | `\ue093` | `\uFDE5` |
| 26 | 小雪-中雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 27 | 中雪-大雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 28 | 大雪-暴雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 29 | 浮尘 | `\uf449` | `\ue0f4` | `\uFE2E` |
| 30 | 扬沙 | `\uf441` | `\ue0f4` | `\uFE2E` |
| 31 | 强沙尘暴 | `\uf441` | `\ue218` | `\uFE2E` |
| 32 | 飑 | `\uf463` | `\ue093` | `\uFDAD` |
| 33 | 龙卷风 | `\uf463` | `\ue218` | `\uFDA3` |
| 34 | 弱高吹雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 35 | 轻雾 | `\uf443` | `\ue214` | `\uFDE1` |
| 53 | 霾 | `\uf449` | `\ue0f4` | `\uFDE1` |
| 301 | 雨 | `\uf453` | `\ue092` | `\uFDE9` |
| 302 | 雪 | `\uf45b` | `\ue094` | `\uFDD9` |
| 99 及未知 | 未知 | `\uee31` | `\ue091` | `\uFD8C` |
实现硬要求:
- 未知代码必须回落到「未知」图标,**不得**抛异常或显示空白。
- 纯文本模板必须实现「今天~明天」的 `~` 分隔符逻辑(模板内 `Separator` 仅在 `To` 场景可见,`SimpleTextWeatherIconTemplate.axaml`),且文本颜色跟随 `--ci-text-*`。
- 天气图标颜色:晴天/温度等常规图标用前景色;雨天图标可选用 `--ci-weather-rain`(亮 `#0000FF` / 暗 `#00BFFF`),见 part 03。
### 15.5 贴纸资源(HoYoStickers)
`Assets/HoYoStickers/` 内共 **22** 个 PNG,按「角色_动作」命名,用于教程/提醒的图标表达式 `sticker(名字)`(`App.axaml.cs:253` 注册 `IconExpressionHelper` 的 `sticker` 处理器)。实现时必须支持 `sticker(帕姆_嗨)` 这种写法并渲染对应 PNG。
| 角色 | 尺寸 | 文件(动作) |
|---|---|---|
| 光辉矢愿 | 162×162(6 个) | `不可以看`、`回眸`、`小喇叭`、`想我了吗`、`瞄准`、`遨游` |
| 帕姆 | 510×510(5 个)/450×450(1 个) | `不可以`、`哭哭`、`嗨`(450)、`注意`、`点赞`、`震惊` |
| 昔涟 | 510×510(4 个) | `回眸`、`守护`、`收到`、`爱` |
| 白厄 | 510×510(6 个) | `再见`、`我吗`、`战斗`、`掉线`、`没事` |
实现要求:贴纸必须按**原始比例**缩放(`object-fit: contain`),不得拉伸;建议在 UI 中统一缩放到 96×96 或 128×128,并保留 `image-rendering: -webkit-optimize-contrast` 以求清晰。图标表达式解析器必须支持 `fluent(码位)`、`lucide(码位)`、`sfsymbols(码位)`、`bitmap(路径)`、`sticker(名字)` 五种前缀(`App.axaml.cs:250-260`),未知前缀回落为文本。
### 15.6 教程资源
#### 15.6.1 教程定义文件(JSON)
内置教程组:`Assets/Tutorials/classisland.getStarted.json`(另有 `classisland.sp.json`、`classisland.test.json` 仅作开发样例,**不要**在正式版启用)。数据结构(逐字段实现):
| 层级 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 顶层 | `Id` | string | 教程组 id,如 `classisland.getStarted` |
| 顶层 | `Title` / `Description` | string | 组标题/描述(中文) |
| 顶层 | `Tutorials` | array | 教程条目列表 |
| 教程 | `Id` / `Title` / `Description` | string | 教程 id/标题/描述 |
| 教程 | `ActualBanner` / `Banner` | string | Banner 资源 URI(`avares://`) |
| 教程 | `Ordinary` | int | 是否为普通教程(0/1) |
| 教程 | `Paragraphs` | array | 段落列表 |
| 段落 | `Id` / `Title` / `Description` | string | 段落标识/标题/描述 |
| 段落 | `TopLevelClassName` | string | 目标窗口类名(如 `ClassIsland.MainWindow`) |
| 段落 | `IsBarrierEnabled` | bool | 是否启用教程遮罩屏障 |
| 段落 | `InitializeActions` | array | 进入段落时执行的行动 |
| 段落 | `Content` | array | 每屏内容 |
| 内容 | `Title` / `Content` | string | 标题/正文(中文) |
| 内容 | `IconExpression` | string | 图标表达式(如 `sticker(帕姆_嗨)`) |
| 内容 | `HeroImage` | string | 大图 URI(可空) |
| 内容 | `TargetSelector` / `PointToTarget` / `ModalTarget` | string/bool | 指向的目标与是否模态 |
| 内容 | `RightButtonText` / `LeftButtonText` | string | 按钮文案(可空) |
| 内容 | `RightButtonActions` / `LeftButtonActions` / `InitializeActions` | array | 行动列表,元素为 `{Kind:int, StringParameter:string}` |
| 内容 | `WaitForNextCommand` / `UseLightDismiss` | bool | 交互策略 |
| 内容 | `Tag` / `Script` / `Xmlns` | string/object | 扩展位 |
(来源:`Assets/Tutorials/classisland.getStarted.json:1-200+`。行动 `Kind` 的具体枚举含义在源码 `TutorialActionKind` 中定义,实现时必须对照该枚举逐值映射,不得自行编号;本节不臆造枚举值。)
#### 15.6.2 教程图片
| 组/条目 | 文件 | 大小 |
|---|---|---|
| welcome | `banner.webp` | 42,664 B |
| welcome | `mainMenu.webp` / `openAppSettings.webp` / `openEditMode.webp` / `openProfileEditor.webp` / `openTutorial.webp` | 约 25–27 KB/张 |
| componentsEditing | `banner.webp` / `ruleset.webp` | 28,292 / 48,738 B |
| profileEditing | `banner.webp` / `时间表.webp` / `科目.webp` / `课表.webp` / `课表与时间表关系.webp` | 25,900–109,912 B |
另有默认图:`Assets/DefaultTutorialBanner.webp`(10,416 B)、`Assets/DefaultTutorialGroupBanner.webp`(46,540 B),用作无 Banner 时的兜底。
要求:图片统一用 WebP(保留 `.webp` 扩展名),懒加载;教程播放时按窗口宽度自适应,不得变形。
### 15.7 本地化
#### 15.7.1 现状(必须如实实现,不得虚构更多语言)
ClassIsland v2 的本地化是**局部**的,只覆盖两个命名空间(`Assets/Localization/`):
| 命名空间 | 资源文件 | 语言 |
|---|---|---|
| `SettingsPage`(设置页) | `Localization.SettingsPage.resx`(默认/中文)、`.zh-Hans.resx`、`.zh-Hant.resx`、`.en.resx` | 简体中文、繁体中文、英文 |
| `SettingsPage/General`(常规设置子页) | 同上 4 个 | 同上 |
| `TrayMenu`(托盘菜单) | 同上 4 个 | 同上 |
- 默认语言为中文(`resx` 无文化后缀文件即默认),并强制 `Thread.CurrentThread.CurrentUICulture = zh-CN`(`ClassIsland/App.axaml.cs:512-513`)。
- 其余界面文案**硬编码中文**,没有资源化。因此「支持多语言」不是现状能力。
#### 15.7.2 实现要求(在 Web 栈中的等价方案)
1. 必须建立 `i18n` 层(推荐 `vue-i18n`),语言键命名与 resx 的 `Key` 保持一致,便于数据迁移。
2. 首期必须提供 **zh-Hans(默认)、zh-Hant、en** 三份文案;其余界面文案随实现逐步资源化,**不得**伪造「已支持 N 种语言」。
3. 语言切换必须持久化到配置,并立即刷新界面(无需重启);日期/时间格式必须跟随语言(`Intl.DateTimeFormat`)。
4. 本地化文件不得包含 HTML 标签;带占位符的文案必须使用命名参数(`{name}`),禁止字符串拼接。
5. 字体必须覆盖三语:英文用 HarmonyOS Sans SC 的拉丁字形即可;繁体中文若依赖系统字体兜底,必须在 fallback 栈中显式列出(`PingFang TC`、`Microsoft JhengHei`)。
### 15.8 其他资源与文档
| 资源 | 用途 | 要求 | 证据 |
|---|---|---|---|
| `Assets/Documents/ChangeLog.md` | 更新日志(关于页展示) | 必须随版本更新,按版本倒序 | 文件名 |
| `Assets/Documents/Privacy_.md` | 隐私政策(关于页展示) | 必须原样保留;含 Sentry/上报说明 | 文件名 |
| `LICENSE.txt`(根、各项目、Assets) | GPL-3.0 许可证 | 必须随安装包分发 | `LICENSE.txt` |
| `Assets/TrustedPublicKeys/ClassIsland.MetadataPublisher.asc` | 更新/市场元数据签名公钥 | 必须内置,用于校验更新包 | 文件名 |
| `Assets/ShortcutTemplates/ClassSwap.url`、`cn.classisland.app.desktop` | 换课/桌面快捷方式模板 | Windows `.url` 与 Linux `.desktop` 两套 | 文件名 |
| `Assets/Highlighting/Lua.xshd` | 自动化脚本 Lua 语法高亮定义 | 编辑器必须使用该配色 | 文件名 |
| `Assets/default-subjects.json` | 默认科目表(23 个科目,含固定 GUID:语文/数学/英语/历史/政治/物理/化学/生物/地理/信息技术/体育/自习/通用技术/音乐/美术/选修课/社团/心理/早读/班会/周测 等) | 必须原样内置,GUID 不得重新生成(迁移依赖) | `Assets/default-subjects.json` |
| `Assets/AsciiLogo.txt` | ASCII 艺术 Logo(控制台/崩溃页) | 6 行,宽 63 字符 | 文件内容 |
| `Assets/Tellings.txt` | 关于页/加载页随机「一句话」(含彩蛋) | 至少 20 条,需保留「关于页连续点击图标 10 次开调试」等提示 | 文件内容 |
| `Assets/dependencies.g.json` | NuGet 依赖许可清单(供「许可」页展示) | Web 栈需生成 `licenses.json` 等价物(npm 依赖 + 字体许可) | `Models/NugetLicenseInfo.cs`、`Views/WelcomePages/LicensePage.axaml` |
| `Assets/XiaomiWeather/xiaomi_weather_status.json` | 天气代码→中文名映射(40 条) | 与 15.4.2 表一致 | 文件内容 |
| `Assets/BannerMain_App2.png` | 2031×600 | 关于页/更新页主视觉 | 实测文件头 |
| `Assets/Demo_MainMenu.png`、`Demo_OpenProfileSettingsWindow.png` | 1608×878 | 教程/引导演示图 | 实测文件头 |
| `Assets/ClassIsland.ai` | 矢量源文件 | 设计源文件,可不打包 | 文件名 |
| `Assets/XamlThemePreviews/classisland.fluent.png`、`classisland.classic.png` | 1920×350 | 主题市场预览图(见 part 09) | 实测文件头 |
### 15.9 ClassHelper 侧资源落点
| 资源类别 | 落点 | 生成/校验方式 |
|---|---|---|
| 应用图标 | `build/classhelper.png`(源)+ 设计导出的 `.ico` | 用 `pnpm icons` 派生其余尺寸(与 ClassHelper 现有约定一致:图标源是 `build/classhelper.png` 与设计导出的 `.ico`,`pnpm icons` 派生其余资源) |
| Web 品牌图 | `packages/web-admin/public/logo.png` 或站点 `/logo.png` | Web 用 `/logo.png`,客户端用 `BRAND_LOGO_URL` 常量(避免 `file://` 绝对路径问题) |
| 字体 | `packages/desktop-client/resources/fonts/` | 4 个 HarmonyOS TTF + 3 个图标 TTF + 2 个 JSON 映射;构建时校验文件存在与哈希 |
| 音效 | `packages/desktop-client/resources/audio/1.wav` | 校验大小 130,012 B(或替换为等价资源但保持单文件、可循环) |
| 贴纸 | `packages/desktop-client/resources/stickers/` | 22 个 PNG,文件名保持中文原名(打包后可用 URL 编码访问) |
| 教程 | `packages/desktop-client/resources/tutorials/` | JSON + WebP;启动时校验 id 唯一 |
| 主题预览 | `packages/desktop-client/resources/theme-previews/` | 1920×350 PNG 两张 |
| 许可清单 | `packages/desktop-client/resources/licenses.json` | 构建时从 `package.json`/`node_modules` 生成;必须包含字体与图标字体的许可条目 |
### 15.10 资源命名、加载与缓存规范
1. **命名**:图片用 `kebab-case` 或保持中文原名(贴纸、教程图必须保留原名,因为教程 JSON 以名字引用);字体文件名保持上游原名(`HarmonyOS_Sans_SC_Medium.ttf`),不得改成 `medium.ttf` 之类丢失来源信息的名字;音效用 `1.wav` 这类稳定短名,便于设置里持久化路径。
2. **目录约定**:`renderer/assets/{icons,fonts,audio,stickers,tutorials,theme-previews}/`,每类一个目录;禁止在组件目录里散落资源文件。
3. **加载方式**:
- 图标字体、正文 TTF 在应用启动时通过 `@font-face` 预加载(`crossorigin` 同源),并用 `document.fonts.load()` 显式触发;
- 贴纸/教程图/主题预览图走懒加载(`loading="lazy"` + IntersectionObserver),未加载时显示 `Shimmer` 骨架(part 14 D9);
- 音效在首次提醒时按需加载并缓存 `AudioBuffer`,不得每次播放都重新 `fetch`。
4. **缓存**:所有资源 URL 必须带内容哈希(构建期生成 `asset-manifest.json`),Electron 生产环境用 `file://` + 哈希避免陈旧缓存;开发环境禁用强缓存。
5. **失败降级**:图片加载失败必须显示「图裂了」占位(ClassIsland 的行为:`SystemFillColorCriticalBackgroundBrush` 底 + 提示「喔唷,图裂啦」,`StylesBase.axaml:214-223`),不得留白或抛错;字体加载失败必须回落系统字体并保持布局不跳变(用 `font-display: swap` + 固定行高)。
6. **安全**:禁止加载 `http(s)` 远程图片作为 UI 资源;所有资源必须走打包路径,CSP 中 `img-src 'self' data:`、`font-src 'self'`、`media-src 'self'`。
7. **引用完整性**:构建时扫描 CSS/TS/JSON 中的资源引用,所有引用必须命中 `asset-manifest.json`;反向扫描资源目录,未被引用的资源要么删除要么登记为「运行时动态加载」白名单。
### 15.11 资源版本与许可核对
- 「关于」页必须提供「许可」入口,展示:应用许可(GPL-3.0)、字体许可(HarmonyOS Sans License)、图标字体许可(Fluent/Lucide/SF)、贴纸素材来源、第三方依赖(npm 依赖清单)。ClassIsland 用生成文件 `dependencies.g.json` + `LicensePage.axaml` 展示 NuGet 依赖,Web 实现必须生成等价的 `licenses.json`。
- 每次发版必须核对:新增/替换的资源是否引入新的许可义务;贴纸、Banner、Demo 图是否为项目自有或有授权(HoYoStickers 为《崩坏:星穹铁道》同人素材,**仅可作为内置彩蛋、不得用于商业宣传**)。
- 版本号变更时必须同步「关于」页的 `AppVersionLong` 展示(源码用编译期常量 `AppBase.AppVersionLong`,Web 端改为读 `package.json.version`)。
补充说明:ClassIsland 的「关于」页还会展示更新日志、隐私政策与致谢名单,这些内容都是随包分发的 Markdown 文档。实现时不得改为在线拉取——教室环境常常断网,关于页必须在离线时完整可读。更新日志与隐私政策的内容以 `Assets/Documents/` 下的文件为准,发版时同步更新,不允许只改版本号不改日志。
### 15.12 验收标准
1. **完整性**:本节列出的资源 100% 存在于仓库;用脚本比对清单与文件,缺一项即失败(允许同一资源用等价格式替换,但必须在清单中登记变更)。
2. **图标**:`electron-builder` 打包后 Windows exe/installer、Linux AppImage、macOS icns 三端图标均正确;ICO 内嵌 6 档尺寸;任务栏/开始菜单/关于页三处图标一致。
3. **字体**:断网启动,界面仍显示 HarmonyOS Sans SC(非回退字体);图标字体全部可渲染(抽查 Fluent/Lucide/SF 各 3 个码位);`document.fonts.check()` 对 4 个正文字重与 3 个图标族返回 true。
4. **字体许可**:安装包内包含 `fonts/LICENSE.txt`(HarmonyOS 许可)与 `licenses.json`;「关于 → 许可」页可查看并可滚动到底。
5. **音效**:默认配置下提醒有声音;把音量调到 0 静音;自定义音效路径失效时回落内置 1.wav 且不崩溃;同一条提醒只响一次。
6. **天气图标**:4 套模板均可切换且立即生效;40 个天气代码逐个渲染正确(截图对比名称与图标);未知代码 99 显示「未知」图标。
7. **贴纸**:`sticker(帕姆_嗨)` 在教程中正确显示;22 张贴纸全部可加载(无 404);比例不变形。
8. **教程**:`classisland.getStarted` 三个教程组可完整播放到结束;跳过后再次进入从第一步开始;教程图片无拉伸、无破图。
9. **本地化**:切换 en/zh-Hans/zh-Hant 后设置页与托盘菜单文案切换且立即生效;日期格式随之变化;其余页面若无翻译必须回落中文而不是显示 key。
10. **离线**:在完全断网、无缓存的干净环境中启动,所有资源(字体/图标/音效/贴纸/教程图)均本地可用,控制台无对外请求(除用户主动触发的天气/更新功能)。
11. **体积**:字体 + 图标 + 音效 + 贴纸 + 教程图的总增量不超过 30MB 打包后体积;超过时必须说明取舍(例如贴纸按需下载,但 ClassIsland 的语义是离线可用,默认应全量内置)。
---
## 16. 质量门禁与验收清单
> 本部分是硬性验收标准。你必须逐条执行并报告结果;任何一项未通过都不得声称「已完成」。所有自动验证必须在无头/离屏模式下执行,禁止弹出可见窗口、禁止播放声音、禁止操作真实课表数据。
### 16.1 工程与构建门禁
- [ ] `pnpm install --frozen-lockfile` 在 Node 20.19+ 下成功;`pnpm-lock.yaml` 已提交。
- [ ] `pnpm -r typecheck` 0 错误;`pnpm lint` 0 错误(warning 允许但需在报告中列出)。
- [ ] `pnpm build` 产出:主进程 `dist/main`、preload `dist/preload`、两个渲染入口(`index.html`、`island.html`)以及静态资源;`island.html` 与 `index.html` 分别可独立加载。
- [ ] 打包产物:Windows NSIS/portable 安装后能启动;卸载不残留用户配置(或按需保留并提示);macOS/Linux 至少构建成功。
- [ ] 依赖清单固定版本并说明用途;无重复的同类库(例如不得同时使用两个日期库、两个状态库)。
- [ ] 仓库内无 `node_modules`、无构建产物、无日志、无密钥;`.gitignore` 完整。
- [ ] README 包含:环境要求、开发命令、构建命令、打包命令、目录说明、数据目录位置、插件/主题开发入口。
### 16.2 像素级视觉验收(主界面)
在 1920×1080、缩放 100% 的无头离屏截图(`--headless --screenshot` 或 Playwright Electron)下,与下列基准逐项比对(容差:颜色 ±2/255,位置/尺寸 ±1px,透明度 ±1%):
- [ ] 岛默认位置:屏幕中上侧;上边距 = `WindowDockingOffsetY`(默认 0)+ 系统工作区顶部安全区;水平居中误差 0px。
- [ ] 默认行宽:内容自适应(`Width=auto`);编辑模式下 `MinWidth = ActualClientBound.Width`;圆角 8px(`RadiusX=RadiusY=8`,可用设置改)。
- [ ] 背景不透明度 0.5(`Opacity=0.5`);默认深色背景(`BackgroundColor` 默认黑,未启用自定义背景色时使用主题背景);背景模糊/毛玻璃在支持的平台上启用,不支持时降级为半透明纯色且视觉不突变。
- [ ] 行垂直间距 5px(`MainWindowLineVerticalMargin=5`);组件水平外边距 6px(`Margin="6 0"`)。
- [ ] 字号:次要 14、主体 16、强调 18、超大 20(可由设置覆盖);默认字重 Medium(`MainWindowFontWeight2=500`);字体为系统字体回退链。
- [ ] 进度条:位于当前课程行底部,宽度=行宽,高度见 4.4(建议 3px),颜色=强调色;进度精度默认降低(`ReduceProgressAccuracy=true`,即按整秒/整分更新,不逐帧抖动)。
- [ ] 隐藏/显示、编辑模式进入退出、上下文菜单弹出均无白屏、无黑边、无窗口阴影残影。
- [ ] 深色/浅色主题各截一张、Fluent/Classic 主题各截一张、125%/150%/200% 缩放各一张,全部与第 3、4、9 部分的规格一致。
### 16.3 功能行为验收(按模块)
**主界面与窗口**
- [ ] 6 个停靠位置、X/Y 偏移、多显示器索引生效;切换显示器后岛跟随。
- [ ] 不抢焦点:显示/切换页面时,当前前台应用(记事本/浏览器)不失去焦点,Alt+Tab 不出现岛。
- [ ] 任务栏:默认不显示;仅当「窗口模式」或「录屏模式」开启时显示。
- [ ] 鼠标穿透:开启后点击岛不触发任何事件;右键菜单仍可通过托盘打开;触摸设备长按可打开菜单。
- [ ] 隐藏规则:`HideOnClass` 上课隐藏、`HideOnMaxWindow` 前台最大窗口隐藏、`HideOnFullscreen` 全屏隐藏(排除进程名单生效)、高级规则集命中隐藏;任何隐藏后进入编辑模式必须强制显示。
- [ ] 临时显示:通过托盘/右键/自动化「显示主界面」在隐藏期间强制显示,并保持到超时或手动隐藏(行为与第 4.5 节一致)。
- [ ] 关闭再启动:位置/尺寸/缩放/透明度/主题/静默状态全部保持。
- [ ] 单实例:重复启动时旧实例收到信号并显示主界面(或按设置行为),新进程 1s 内退出。
- [ ] 开机自启开关生效(注册表/LaunchAgent/autostart 三平台),关闭后删除启动项。
**组件**
- [ ] 全部内置组件可添加、设置、删除、拖拽排序、嵌套;配置重新加载后视觉与行为一致。
- [ ] 每个组件的默认值、空数据兜底、超长文本截断/滚动、隐藏规则、显示优先级符合第 5 部分。
- [ ] 组件动画(时钟、倒计时、滚动、滑动、天气图标)在 60fps 下无抖动;浏览器 DevTools 无 layout thrash 警告。
- [ ] 组件在编辑模式下可被选中并显示虚线框(1.5px、虚线 1,1.5、圆角描边、Opacity 0.75);退出编辑后完全消失。
**课表/档案**
- [ ] 时间点类型:上课/课间/分割线/行动;时间点可拖拽改时间、可快速录入、可跨天;校验不允许重叠/倒序。
- [ ] 周次轮换:单周、双周、1–9 周自定义轮换、偏移、起始周日期;跨学期/跨年正确。
- [ ] 临时课表:单日、跨天、按日期区间;预定课表到点自动启用、结束自动恢复;临时课表与正式课表优先级符合第 6 部分。
- [ ] 换课:同日/跨日、按节次交换、恢复;影响提醒与当前课程。
- [ ] Excel 导入:支持 xlsx/xls/csv;模板列映射;错误单元格定位到行列;导入前预览;取消不落库。
- [ ] Excel 导出:导出当前档案课表,格式可再导入(往返一致)。
- [ ] CSES JSON 导入:字段映射与失败提示。
- [ ] 多档案:新建/复制/重命名/删除/切换;删除有二次确认与备份;默认档案在无课时兜底。
- [ ] 档案损坏/版本过旧:提示可恢复,不允许静默清空。
**提醒**
- [ ] 队列:高优先级插队、同级先进先出、同 id 去重、最长显示时长、可手动清除。
- [ ] 上课提醒:根据时间表在上课时间点触发;课前 N 秒(默认 60s)触发准备提醒;下课前倒计时(默认 60s)显示;下课提醒;放学提醒。
- [ ] 提醒内容:学科名、教师名、课间名、下一节课程、天气预警、自定义文本;布局与字号符合第 7 部分。
- [ ] 强提醒(Topmost):全屏/大区域遮罩、超时自动关闭、点击/按键关闭、在岛所在的显示器上显示;不抢焦点。
- [ ] 音效:内置音效播放、音量、单次/循环;无音频设备时降级不报错;`-q/--quiet` 静默启动后 10 秒内丢弃提醒(ClassIsland 2.1 无独立「静音时段」,不得虚构该功能;如实现 P2 免打扰需默认关闭并标【推断】)。
- [ ] TTS:系统语音/Edge TTS 可切换、语速/音量/音色、失败回退;不阻塞 UI。
- [ ] 提醒设置:按提供者启用/排序/参数;修改后立即生效。
- [ ] 提醒历史:最近 N 条可查、可清空。
**自动化**
- [ ] 触发器全部可用(时间点/CRON/信号/URI/托盘菜单/窗口状态/当前课程/天气/日出日落)。
- [ ] 行动全部可用(提醒/运行程序/打开文件/打开网页/关机/睡眠/重启/修改设置/广播信号/切换课表/发通知);危险行动有二次确认与超时保护;测试模式禁止真实关机。
- [ ] 规则集:AND/OR/NOT、嵌套、比较运算、空值处理;求值结果与第 8 部分一致。
- [ ] 执行日志:时间、触发源、行动、结果、耗时;失败重试有上限;不会无限循环。
- [ ] 自动化关闭时不执行任何行动。
**设置与窗口**
- [ ] 每个设置页与控件、默认值、枚举项与第 10 部分清单一致;搜索可命中设置标题/关键词。
- [ ] 设置即时生效(防抖 300ms 内);需要重启的项明确提示并提供重启按钮。
- [ ] 设置导入/导出、恢复默认、覆盖层(SettingsOverlay)按预期工作。
- [ ] OOBE 可完整走通、可跳过、可从设置再次打开;OOBE 未完成时主窗口不出现或被引导遮罩。
- [ ] 所有次级窗口的尺寸/标题/按钮/关闭行为符合第 11 部分;Esc 关闭;关闭设置不退出应用。
- [ ] 崩溃窗口、恢复模式、更新进度、数据迁移、教程、授权、日志窗口可正常打开/关闭,无内存泄漏(关闭后窗口对象被释放)。
**主题/资源/本地化**
- [ ] 内置主题切换全覆盖、无残留样式、无闪烁;非法主题包被拒绝且不崩溃。
- [ ] 若实现 P2 壁纸取色(ClassIsland 2.1 已停用、默认关闭),取色结果稳定且浅色/深色下前景对比度 ≥ 4.5:1(WCAG AA);色盲安全抽查;未实现时不得在设置页露出可用的取色入口(按源码禁用隐藏)。
- [ ] 图标/字体/音效资源齐全,缺失时使用兜底资源;无 404。
- [ ] 中文文案 100% 覆盖;文案集中在常量文件;ClassIsland 2.1 只有 3 种语言且仅覆盖设置页与托盘菜单(其余硬编码中文),因此本实现交付「完整 zh-CN + 文案集中」,不得宣称全量多语言;若要加 en-US 只能作为 P2 增量且不得遗漏中文兜底。
### 16.4 动画验收
- [ ] 第 14 部分动画总表中的每一项都实现,时长误差 ≤ 16ms,曲线一致(用 DevTools Animations 面板或离屏逐帧截图验证关键帧)。
- [ ] 岛出现/隐藏、行展开/折叠、提醒进出、强提醒、OOBE 转场、主题切换、设置页切换全部流畅;长任务不阻塞动画(用 Performance 面板确认帧率 ≥ 55fps)。
- [ ] `prefers-reduced-motion` / 设置中的「减少动画」(`AnimationLevel=0`)生效:仅保留必要的透明度变化,位移与缩放归零。
- [ ] 动画结束后无残留 `will-change`、无未清理的 `requestAnimationFrame`/定时器。
- [ ] 弹簧动画参数与 `spring.ts` 一致(stiffness 0.10、damping 0.68、snap 0.05px、换向保留 35%),不得用 CSS 过渡近似替代(行高/尺寸变化场景)。
### 16.5 数据完整性与容错
- [ ] 所有写入原子化(临时文件 + rename);断电/强杀后配置仍可读。
- [ ] 自动备份:默认开启、每 7 天一次、最多保留 16 份(`IsAutoBackupEnabled=true`、`AutoBackupIntervalDays=7`、`AutoBackupLimit=16`),可从备份恢复。
- [ ] 配置版本迁移:旧版本字段兼容,迁移前备份,迁移失败自动回滚并提示。
- [ ] 配置校验:非法 JSON、未知字段、越界数值(如半径 -1、透明度 >1)被修正或拒绝,不崩溃。
- [ ] 崩溃恢复:渲染进程崩溃后自动重建(限流:1 分钟内最多 3 次,超过则进入安全模式);GPU 崩溃降级软件渲染;安全模式下禁用插件/主题并提示。
- [ ] 数据目录可配置;卸载/重置时不误删用户手动备份。
### 16.6 性能与资源
- [ ] 冷启动到岛可见 ≤ 3s(SSD,不联网)。
- [ ] 空闲 CPU 平均 ≤ 1%(岛可见、时钟每秒刷新);内存(主进程+两个渲染进程)≤ 300MB。
- [ ] 连续运行 24h 无内存增长(增长 ≤ 10%)、无句柄泄漏、无崩溃。
- [ ] 提醒密集场景(1 分钟 20 条)不卡顿、不丢提醒、不重复。
- [ ] 打包体积:Windows 安装包 ≤ 150MB(不含可选模型/音色资源)。
- [ ] 首屏构建资源 ≤ 2MB(gzip);岛渲染进程启动 ≤ 500ms。
### 16.7 平台与兼容性
- [ ] Windows 10 21H2+、Windows 11:透明/置顶/点击穿透/多显示器/DPI 125%/150%/200% 正常。
- [ ] macOS 12+:透明窗口、置顶、全屏隐藏、通知权限(如使用系统通知)。
- [ ] Linux X11:透明窗口、置顶、点击穿透;Wayland 明确提示不支持并给出降级说明。
- [ ] 触摸设备:单击/长按/滑动不冲突;编辑模式可拖拽。
- [ ] 屏幕阅读器可读出设置控件;键盘 Tab 顺序合理;所有按钮有 `aria-label` 或可见文本。
### 16.8 安全验收
- [ ] `nodeIntegration: false`、`contextIsolation: true`、`sandbox: true`(或提供等效隔离);preload 仅暴露白名单 API。
- [ ] 所有 IPC 通道校验参数类型与来源窗口;渲染层不能任意读写文件/执行命令。
- [ ] CSP 严格(`default-src 'self'`;`connect-src` 仅允许天气/更新/插件市场域名);无 `unsafe-eval`(Vue 生产构建)。
- [ ] 更新包必须校验哈希/签名;拒绝非 HTTPS 下载(本地镜像可通过设置允许但需明确警告)。
- [ ] 自动化行动执行命令有白名单/确认;日志脱敏(不含完整路径中的用户名/令牌)。
- [ ] 插件默认无 Node 权限;插件权限声明在清单中,安装时展示。
- [ ] 本地数据不上传;崩溃上报默认关闭/需同意(`IsReportingEnabled` 默认 true 但仅上报匿名崩溃,需在隐私页说明)。
### 16.9 交付物清单
- [ ] 源码 + `pnpm-lock.yaml` + 构建脚本。
- [ ] `README.md`、`docs/architecture.md`、`docs/data-schema.md`、`docs/plugin-api.md`、`docs/theme-api.md`。
- [ ] 示例档案(含 3 周轮换)、示例主题、示例插件(P1)。
- [ ] 无头测试:单元测试(时间/课表/规则/配置校验)+ 组件测试(岛渲染快照)+ 端到端测试(启动、开设置、切主题、导入课表)。
- [ ] 验收报告:逐条勾选 16.1–16.9,附截图路径与测试输出摘要;未通过项必须列出原因与修复计划。
---
## 17. 开发里程碑与实现顺序
> 必须按 M0→M8 顺序推进;每个里程碑结束都要能「无头启动 + 通过该阶段验收」。禁止先堆功能再补架构。
### M0 工程脚手架与进程骨架(0.5–1 天)
- 交付:
- pnpm monorepo:`pnpm-workspace.yaml`(`packages/*`)、根 `package.json`(`packageManager: pnpm@11.8.0`)、`tsconfig.base.json`、Prettier/ESLint。
- `packages/desktop-client`:Electron 主进程入口、`island.html` 与 `index.html` 两个 Vite 入口、preload 白名单、单实例锁、托盘、日志、`config.json` 原子读写、默认 `settings.json`。
- `packages/shared`:类型定义、常量、schema、时间工具;`packages/ui`:`tokens.css`、基础控件。
- 验证(无头):`pnpm typecheck`、`pnpm lint`、`pnpm build` 通过;主进程以 `--headless` 启动后**不创建任何可见窗口**,5 秒内输出 `app-ready` 并自动退出;配置文件按 schema 生成且二次启动可读回。
- 完成标准:`config.json` 字段完整、原子写、损坏时自动回退默认值并记录日志;窗口/托盘代码有开关(headless 模式全部跳过)。
### M1 岛窗口 + 时钟引擎 + 基础显示(1–2 天)
- 交付:
- 独立岛 BrowserWindow:透明、无边框、置顶、不抢焦点、默认不显示在任务栏、按 `WindowDockingLocation` 6 个位置停靠、多显示器选择、缩放、DPI 处理、关闭/隐藏动画。
- 岛结构:多行容器(`Lines`)、行背景、连续圆角、背景不透明度、毛玻璃/纯色、进度条、遮罩层宿主。
- 时间引擎:NTP 对时(可关闭)、手动偏移、倍速调试、休眠唤醒后重算。
- 默认组件:日期 + 时钟 + 欢迎文本;右键原生菜单(完整项,见 4.7)。
- 验证:1920×1080@100% 下离屏截图与第 4 部分基准逐项比对;切换 6 个停靠位置、125%/150% 缩放截图无模糊/偏移;岛空闲 CPU < 1%。
- 完成标准:无边框、透明、点击穿透可切换、圆角与阴影正确、菜单可用、时钟跨分钟无跳变。
### M2 组件系统(2–3 天)
- 交付:
- 组件注册表、Component 数据模型(`Id/Name/Type/Settings/Children/IsVisible/HidingRules`)、组件设置面板、组件的添加/删除/拖拽排序/嵌套。
- 内置组件按第 5 部分顺序实现:分隔线 → 文本 → 日期 → 时钟 → 倒计时 → 课程表 → 天气 → 滚动 → 滑动 → 堆叠 → 分组 → 容器。
- 编辑模式:选中框、拖动把手、行设置抽屉、添加组件面板、组件设置弹层。
- 验证:每个组件在编辑模式可增删改、保存后重载不丢失;空数据/超长文本/多语言/深色主题截图验收;拖拽排序有动画且不闪烁。
- 完成标准:组件配置 JSON 与第 13 部分 schema 一致;关闭编辑模式无选中框残留。
### M3 课程表与档案系统(3–4 天)
- 交付:
- 时间表编辑器(时间点增删、上课/课间/分割线/行动、分钟级拖拽、快速录入、跨天)。
- 课表编辑器(周模板、单日覆盖、多周轮换 1–9 周、群组、预定/临时课表、单日/跨天换课)。
- 档案管理(新建/复制/删除/导入/导出/切换、默认档案、档案校验)。
- Excel 导入(模板下载、表头映射、预览、错误定位)、Excel 导出、CSES JSON 导入。
- 课程状态机:当前/下一节、课间、放学、跨天、提前上课/拖堂、时间点行动。
- 验证:构造 3 周轮换课表 + 临时换课 + 跨天场景,无头单元测试断言当前课程/剩余时间/下一节;Excel 往返导入导出数据一致;损坏档案提示可恢复。
- 完成标准:课程计算函数覆盖率 ≥ 90%;所有档案操作有备份。
### M4 提醒系统(2–3 天)
- 交付:
- 提醒队列与优先级、去重、排队/插队、提醒历史、清除全部提醒。
- 内置提醒提供者:上课、下课、课前准备(提前 N 秒)、放学、天气预警、天气预报、管理通知、自定义(行动触发)。
- 提醒 UI:气泡(行内展开/胶囊)、强提醒全屏特效;音效、TTS(系统 TTS/Edge TTS)、音量;-q/--quiet 静默启动后 10 秒内丢弃提醒(无独立静音时段,不得虚构)。
- 提醒设置页与各提供者设置。
- 验证:无头注入假时间,断言 6:00–23:00 全时间点提醒触发一次且仅一次;队列优先级符合第 7 部分;气泡/强提醒动画时长与曲线与第 14 部分一致;关闭音效后不播放。
- 完成标准:重复提醒按 id 去重;上课期间普通提醒按规则暂存/延后;不阻塞主线程。
### M5 设置中心 + OOBE(2–3 天)
- 交付:
- 设置窗口:左侧导航(分组 + 图标 + 搜索)、右侧页面、面包屑/返回、即时保存、重启提示、恢复默认。
- 全部设置页与控件、默认值、禁用条件(见第 10 部分)。
- OOBE(8 页):欢迎 → 许可证 → 翻新/迎新 → 基本 → 颜色 → 外观 → 系统 → 完成;支持跳过与再次打开。
- 验证:设置项与第 10 部分清单逐项对照;改变关键设置(圆角/透明度/字号/停靠/主题)后岛即时生效;重启后保持;搜索命中正确。
- 完成标准:设置写入防抖 ≤ 300ms;连续修改不产生大量磁盘写(合并写入)。
### M6 主题系统(+ 可选壁纸取色 P2)(1–2 天)
- 交付:
- 内置 Fluent/Classic 主题的完整 CSS 变量与组件样式差异。
- 主题包格式(`theme.json` + `theme.css` + `preview.png`)、安装/卸载/启用、冲突处理。
- 自定义背景色/前景色;系统深浅色跟随。
- P2(可选,默认关闭):壁纸取色——屏幕/壁纸采样、主色提取、深浅色适配、调色板候选(ClassIsland 2.1 已停用该功能,只给算法与数据结构,不纳入 P1 验收)。
- 验证:切换主题后主界面/设置/弹窗全部生效且无闪烁;非法主题包不崩溃;若实现 P2 取色,则抽查取色结果与壁纸截图的视觉一致性。
- 完成标准:主题变量覆盖第 3 部分全部 token;主题预览图自动生成或提供占位。
### M7 自动化 + 天气 + 高级窗口(2–3 天)
- 交付:
- 自动化引擎:触发器轮询/事件、行动执行、规则集求值、执行日志、失败重试/限流、危险行动二次确认。
- 内置触发器与行动完整清单(见第 8 部分);规则类型与嵌套。
- 天气服务:定位/城市、当前/逐小时/3 天、预警、降水提醒、缓存与失败回退、图标主题。
- 次级窗口:换课、ClassPlanDetails、Excel 导入/导出向导、崩溃/恢复、更新进度、时间调整、数据迁移、教程、关于、日志、调试。
- 验证:构造自动化场景(时间点触发提醒、窗口全屏时隐藏岛、下雨提醒)无头断言;断网时天气显示缓存并提示;危险行动(关机/睡眠)在测试模式下不得真正执行。
- 完成标准:自动化配置可导出/导入;执行记录可查;异常不会导致应用退出。
### M8 插件、更新、恢复与打磨(2–4 天)
- 交付:
- 插件宿主(P1):清单、权限、启用/禁用、生命周期、扩展点(组件/提醒提供者/触发器/行动/设置页)、示例插件。
- 更新:检查/通道/镜像/下载/哈希校验/静默安装/失败回滚;`UpdateService` 对应 `update.ts`。
- 崩溃守卫与恢复模式:渲染进程崩溃自动重建(限流)、GPU 崩溃降级软件渲染、配置损坏恢复、安全模式。
- 教程系统、文案集中(zh-CN;en-US 为 P2 可选)、无障碍、性能优化、打包脚本、文档、示例主题。
- 验证:打包产物在干净系统安装运行;断网/拔电源模拟崩溃后数据不丢;更新流程灰度与回滚;插件启用后无卡顿。
- 完成标准:全部 P0/P1 验收项通过;安装包体积与内存占用达标(见 16.6)。
### 里程碑总览表
| 里程碑 | 核心风险 | 完成标志 |
|---|---|---|
| M0 | 进程模型/透明窗口/配置原子写 | headless 启动 < 5s,无可见窗口 |
| M1 | 透明置顶窗口跨平台差异、DPI | 6 停靠位置像素级正确 |
| M2 | 组件递归模型与编辑模式 | 组件配置闭环 |
| M3 | 课表状态机/周次/导入 | 单元测试覆盖核心算法 |
| M4 | 提醒去重与音视频调度 | 全时间点场景通过 |
| M5 | 设置项数量庞大 | 设置清单 100% 对照 |
| M6 | 主题变量覆盖与取色 | 切换无残留 |
| M7 | 自动化安全/天气容错 | 危险行动有保护 |
| M8 | 插件沙箱/更新安全 | 打包安装可用 |
---
## 18. 禁止事项与实现边界
> 以下为硬性红线。违反任一项即视为交付不合格。
### 18.1 关于「不打扰」的硬性要求
1. **禁止任何会显示可见窗口或发出声音的自动验证**:自动化测试、冒烟脚本、构建检查必须在 `--headless` / 离屏 / 隐藏窗口模式下运行;不得创建可见的岛、托盘气泡、系统通知、声音。验证脚本必须默认静默,需要人工观察时由显式参数开启(例如 `--visual`),且不得作为 CI 默认。
2. **禁止在开发/构建过程中自动创建桌面快捷方式、开机自启项、全局快捷键**;这些只能由用户在设置中主动开启。
3. **禁止把主窗口做成会抢占焦点或进入任务栏的普通窗口**:默认 `focusable=false`、`skipTaskbar=true`、`show=false` 直到首帧准备好;不得使用 `setAlwaysOnTop` 之外的 API 强制置顶到系统级(如 Windows 的 `SetWindowPos HWND_TOPMOST` 需与 Electron 的置顶设置一致,避免与全屏应用冲突)。
4. **禁止用 `setBackgroundMaterial()`(Electron 的 Windows 材质 API)破坏逐像素透明**;透明背景必须由无边框透明窗口 + CSS 自绘实现,以保证圆角与阴影的正确性。
5. **禁止自动播放声音**:提醒音效仅在提醒触发时播放;调试/测试静音;首次启动不播放。
### 18.2 技术红线
6. **禁止让 Element Plus 的全局默认样式进入岛渲染进程**:`island.html` 不得引入 Element Plus 的全局 CSS;岛使用自研 CSS + 设计 tokens。Element Plus 仅用于设置中心等管理型界面。
7. **禁止在渲染进程开启 Node 集成**;preload 不得暴露 `ipcRenderer`、`require`、`process`、文件系统能力;跨桥只传纯数据(不得传 Vue Proxy/函数/类实例)。
8. **禁止把动画交给 `setInterval`/`setTimeout` 逐帧驱动**(弹簧动画用 rAF + 物理积分;其他用 CSS transition/keyframes/Web Animations)。
9. **禁止用 JS 直接改 `width/height/top/left` 触发同步布局做动画**;优先 `transform/opacity`,尺寸变化用 FLIP 或弹簧 + `ResizeObserver` 批量更新。
10. **禁止使用 `unsafe-eval`、`new Function`、远程加载并执行 JS**;CSP 必须严格;不得通过 `file://` 加载用户可写目录中的可执行脚本。
11. **禁止把核心功能依赖云端**:断网时课表、提醒、自动化、组件、设置、主题必须全部正常;天气/更新/插件市场允许降级。
12. **禁止把敏感信息(令牌、密码、完整用户路径)写入日志或打包进安装包**;`check-bundle-secrets` 式构建门禁必须保留(若实现管理端可选模块)。
13. **禁止硬编码绝对路径**;所有资源使用相对/`app://` 协议;品牌图片通过打包资源 URL 引用。
14. **禁止引入与原技术栈冲突的框架**(如再引入 React/Angular、再引入第二套 UI 组件库做岛);确需引入库时必须说明不可替代性并锁定版本。
### 18.3 数据与安全红线
15. **禁止未备份就迁移/覆盖用户数据**:任何 schema 升级、导入、恢复操作先写备份,失败可回滚;删除档案/主题/插件必须二次确认。
16. **禁止静默删除或重置用户配置**;配置损坏时保留原文件为 `.bak` 并提示。
17. **禁止在测试中使用真实班级数据**;测试必须使用临时目录与夹具数据;测试结束清理临时数据,不得触碰用户真实配置目录。
18. **禁止自动化静默执行危险行动**:关机/重启/注销/锁屏/删除文件等行动必须二次确认(或明确的免确认设置),测试模式必须假执行;执行前做权限与参数校验,执行后写审计日志。
19. **禁止插件/主题获得默认 Node 权限**;插件代码在受限环境运行(独立进程或 Worker + 白名单 API),安装前展示权限。
20. **禁止执行任意 URL/命令**:仅允许显式协议(http/https)与用户指定程序路径;路径参数做规范化与存在性校验,防止命令注入。
### 18.4 质量与流程红线
21. **禁止用占位符/`TODO` 交付核心功能**:所有 P0/P1 功能必须真实可用;未完成项必须在验收报告中显式列出,不得隐藏。
22. **禁止伪造验收结果**:截图、日志、测试输出必须来自实际运行;未验证的项标注「未验证」。
23. **禁止为了通过验收修改验收标准或删除测试**;标准以本提示词为准。
24. **禁止跳过像素级检查**:主界面、设置窗口、提醒、OOBE 至少各保留一份基准截图与 diff 报告。
25. **禁止把「默认值」写成 0/空导致功能不可见**:所有默认值必须与第 10、13 部分的默认值表一致。
26. **禁止破坏兼容性**:数据 schema、插件 API、主题变量一旦发布,只允许增量变更;破坏性变更必须提供迁移与版本字段。
### 18.5 本提示词未明确时的裁决顺序
当实现细节与本提示词冲突或缺失时,按以下优先级裁决:
1. 本提示词明确写出的数值与行为(最高优先级)。
2. ClassIsland 的公开文档与截图中可见的行为(可参考 `docs.classisland.tech`,但不得引入其代码)。
3. ClassHelper 已实现的桌面岛约定(`repos/ClassHelper-master/packages/desktop-client/src/island` 与 `src/main/island.ts`)。
4. Fluent Design / iOS 灵动岛的一般设计规范。
5. 你的工程判断(必须在代码注释与交付说明中标注「推断」)。
---
## 19. 结语(给实现者的最后要求)
- 先读完本提示词全部章节,列出你认为有歧义的地方并一次性向用户确认;不要边猜边写核心架构。
- 先搭出可无头启动、可截图对比的骨架,再逐模块填充;每完成一个里程碑就运行第 16 部分对应验收,不要最后一次性补测试。
- 代码组织保持「主进程薄、引擎纯函数、渲染层无副作用」:所有业务计算(时间、课表、规则)放在 `packages/shared` 并可单元测试;主进程只做编排与系统集成;渲染层只做展示与交互。
- 交付时提供一份「与 ClassIsland 的差异说明」:列出本实现因技术栈不同而做的等价替换(例如 Avalonia 控件 → Vue 组件、XAML 资源字典 → CSS 变量、插件 DLL → npm 插件包),以及未实现的 P2 能力。
- 最后再对照第 16 部分做一次完整自检,并对照第 18 部分确认没有触碰任何红线;确认无误后再声称完成。
+92
View File
@@ -0,0 +1,92 @@
# 投喂指南(重构版):把提示词交给 ChatGPT,重构 ClassHelper 灵动岛
> 本指南配合主提示词 `ClassHelper灵动岛重构-给ChatGPT的完整提示词.md` 使用。该文件是**在 ClassHelper 现有仓库内做重构**的任务书,不是「从零新建 ClassIsland」。全文约 **26.8 万字符(去空白)、约 12.3 万 tokens**。
## 一、投喂前必须做的准备
1. **把 ClassHelper 仓库交给模型**:至少提供 `packages/desktop-client`、`packages/shared`、`packages/ui`、`docs/`(在 ChatGPT 里上传仓库压缩包/快照,或使用支持仓库上下文的 IDE Agent)。提示词中的 `packages/...:行号` 需要模型能读到源码才能落实。
2. **不需要提供 ClassIsland 仓库**:提示词已把 ClassIsland 的行为、像素与动画基准写全;`repos/ClassIsland/...` 只是证据来源说明。
3. **先约定工作方式**:只读分析 → 文件级计划 → 迁移先行 → 分阶段实现 → 每阶段无头验证;避免模型直接大改 UI。
4. **明确禁止**:不要运行 Electron / `pnpm dev` / `verify:desktop:visual` / 截图 / 冒烟等会弹窗的操作;默认 `verify:desktop` 是后台无头检查;用户要求静默(见提示词第 0.2、11、12 部分)。
## 二、交付物
| 文件 | 内容 | 用途 |
|---|---|---|
| `../ClassHelper灵动岛重构-给ChatGPT的完整提示词.md` | 主提示词(14 个 part:00–13,含目录) | 权威版本;长上下文一次投喂 |
| `vol1` ~ `vol5` | 5 个分卷 | 适合 32k–128k 上下文模型分批投喂 |
| `refactor-sections/` | 14 个独立 part | 逐章投喂、或只补某一章 |
| `../analysis/`、`../parts-r/` | 调研记录与源 part | 人工核对证据,不投喂 |
## 三、各 part 体量
| # | 内容 | 文件 | 字符(去空白) | 估算 tokens |
|---|---|---|---|---|
| 00 | 使用说明 + 给 ChatGPT 的任务声明(总入口) | `00-preamble.md` | 4349 | 2002 |
| 01 | 重构目标与范围(Keep/Change/Add/Remove、7 条裁决、成功标准) | `01-objective.md` | 5548 | 3076 |
| 02 | ClassHelper 灵动岛现状基线(文件级现状地图) | `02-current-state.md` | 20803 | 9312 |
| 03 | ClassIsland 灵动岛行为基准(行/组件/提醒/课程/隐藏/主题) | `03-classisland-baseline.md` | 16807 | 8918 |
| 04 | 目标架构与文件级改造清单(38 项 + IPC v2 + 顺序) | `04-file-plan.md` | 23457 | 10684 |
| 05 | 数据契约、schema v2 与配置迁移(islandLayout.version=2) | `05-data-migration.md` | 23721 | 10904 |
| 06 | 设计 tokens 与视觉基线(双风格:classisland 默认 / winisland 兼容) | `06-tokens.md` | 29545 | 11499 |
| 07 | 岛结构与组件规格(窗口/行/气泡/11+ 组件像素级) | `07-island-components.md` | 43882 | 18197 |
| 08 | 消息、提醒、课程提醒、强提醒、天气与课件覆盖 | `08-messages-lessons.md` | 22437 | 10853 |
| 09 | 编辑器与设置页重构(IslandSettingsView / IslandEditorView) | `09-editor-settings.md` | 37715 | 17118 |
| 10 | 动画与动效映射(现状基线 20 条 + 映射 84 条) | `10-animations.md` | 23809 | 11186 |
| 11 | 兼容性、性能、平台与测试基线 | `11-compat-tests.md` | 5252 | 2897 |
| 12 | 验收清单(逐项可勾选) | `12-acceptance.md` | 4132 | 2447 |
| 13 | 重构里程碑(M0–M7) | `13-milestones.md` | 2994 | 1696 |
> token 按「汉字 0.9、其他 0.3」粗算;实际以模型 tokenizer 为准。
## 四、三套投喂方案
### 方案 A:一次性投喂(上下文 ≥ 128k)
- 适用:GPT-5 级长上下文 / Gemini 2.5 Pro / Claude 长上下文等。
- 做法:粘贴主提示词全文,末尾追加:
> 请只在 ClassHelper 现有仓库内重构灵动岛,不要新建项目、不要改服务端。先通读第 0–2 部分并复述现状、目标与 7 条裁决;然后输出文件级重构计划(Keep/Change/Add/Remove、迁移与回滚、M0–M7 拆分)。确认后再写代码。禁止运行 Electron/冒烟/截图等会弹出窗口的操作;所有验证必须无头。
- 注意:长上下文也会「中间遗忘」,每个里程碑要求模型复述关键像素/动画值。
### 方案 B:五卷投喂(上下文 32k–128k)
1. `vol1`(任务声明 + 目标 + 现状)→ 要求模型输出:现状理解、现状问题清单、不可破坏契约。
2. `vol2`(ClassIsland 基准 + 文件级改造 + 数据迁移)→ 要求输出:文件级 diff 计划、schema v2 迁移算法、IPC 契约变更。
3. `vol3`(tokens + 岛/组件像素规格)→ 要求输出:DOM/CSS 结构、组件逐个实现清单、像素验收表。
4. `vol4`(消息/课程/强提醒 + 编辑器/设置)→ 要求输出:状态机、动画时间线、编辑器交互清单。
5. `vol5`(动画 + 兼容/测试 + 验收 + 里程碑)→ 要求输出:最终实施顺序、每阶段无头验证脚本、验收勾选表,然后从 M0 开始实现。
- 每批结束要求模型把「已确认参数」写进 `SPEC-LOCK.md`,下一批先复述。
### 方案 C:逐章投喂(上下文 32k–64k)
- 顺序:00+01+02 → 03 → 04+05 → 06 → 07 → 08 → 09 → 10 → 11+12+13。
- 每次投喂加一句:「只改本章涉及的文件;先复述本章关键数值与验收点,再动手;不得运行会弹出窗口的命令;不要重写其他模块。」
## 五、推荐的开场与追问
- 开场:「你在 ClassHelper 仓库中做**重构**,不是新建项目。请先阅读提示词第 0–2 部分与仓库中 `src/main/island.ts`、`src/island/*`、`packages/shared/src/island-layout.ts`,输出:①现状理解;②不可破坏契约;③文件级重构计划(含迁移/回滚);④M0 无头验证方案。先不要写代码。」
- 计划评审:「请给出 `packages/desktop-client/src/island/` 目标文件树,每个文件一句话职责,并标注它替换/新增/保留哪些现有文件。」
- 迁移评审:「请给出 `islandLayout` v1→v2 的迁移函数伪代码、幂等性论证、失败回滚路径和 fixtures 清单。」
- 像素评审:「请按第 6/7 部分输出 fixed 信息行(classisland 风格)与 custom 行的 DOM/CSS 变量清单,标注每个数值来源。」
- 动画评审:「请把第 10 部分的 84 条映射按 M0–M7 归类,给出每条的无头验证方法(不打开窗口)。」
- 无头验证:「请给出 `pnpm typecheck`、`pnpm verify:desktop` 的预期输出与失败排查;禁止 `--visual` 作为默认验证。」
- 交付前:「请对照第 12 部分逐项勾选,列出未通过项与原因;对照第 11 部分确认迁移/回滚/无头纪律。」
## 六、必须向模型强调的五件事
1. **这是重构**:保留 ClassHelper 技术栈、包边界、服务端数据源、既有业务规则与配置;不新建工程、不引入新框架。
2. **默认 ClassIsland 规格,老用户可回退**:`appearance.family='classisland'` 为默认;升级用户迁移为 `winisland`(fixed 保留旧胶囊);`custom` 始终 ClassIsland 规格(与 family 无关)。
3. **fixed 重定义**:classisland 下 fixed = 40px 信息行(默认日期 + 课程表,行内气泡,3px 进度条);胶囊 + 详情卡只在 winisland 保留。
4. **迁移先行**:先版本化 `islandLayout.version=2` + 备份/回滚 + fixtures,再改渲染;任何阶段都必须能切回旧渲染。
5. **不打扰纪律**:默认验证无头;不弹窗、不发声、不创建可见岛/托盘;不碰真实班级数据。
## 七、已知的「与 ClassIsland 原版差异」(模型需在交付说明中列出)
- Avalonia 窗口 → Electron 透明 BrowserWindow;窗口包围盒/命中/触摸沿用 ClassHelper 既有方案。
- 数据源:ClassHelper 服务端解析结果(`ScheduleDto`/`IslandTimePointDto`/通知/作业/叫人/课件),不引入 ClassIsland 本地档案。
- 插件/自动化/主题市场/管理端/Excel 导入/更新器不在本轮范围。
- 壁纸取色不实现;天气继续 Open-Meteo;强提醒为独立透明窗口实现。
- 视 ClassIsland 源码为行为基准,不复制其 C#/XAML 代码。
## 八、与上一份「ClassIsland 从零实现提示词」的关系
- 上一轮产物 `ClassIsland-给ChatGPT的完整提示词.md` 是**ClassIsland 全功能规格**(约 200k tokens),详细覆盖 ClassIsland 原版的组件、窗口、动画、设置等。
- 本重构提示词已把这些规格中**灵动岛相关**的部分改写为「ClassHelper 落地版」;只有当你需要补充 ClassIsland 原版细节(例如某个组件的完整设置项、ClassIsland 的窗口列表)时,才把上一份作为附录/参考资料一并投喂。
- 两份提示词的冲突以本重构提示词为准(因为它包含 ClassHelper 的现状、数据契约与迁移)。
+86
View File
@@ -0,0 +1,86 @@
# 投喂指南:把这份 ClassIsland 提示词交给 ChatGPT
> 本目录是最终交付物。主提示词共 20 个 part(00–19),合计约 **446503** 字符(去空白)、**约 200672 tokens**。请不要一次性把全部内容塞进小上下文模型;按下面三套方案挑一套。
## 一、交付物
| 文件 | 内容 | 用途 |
|---|---|---|
| `../ClassIsland-给ChatGPT的完整提示词.md` | 全量主提示词(20 个 part,含目录) | 唯一权威版本;模型上下文足够时一次投喂 |
| `vol1-总纲与技术栈(00-02).md` ~ `vol5-动画资源验收与结语(14-19).md` | 5 个分卷 | 适合 128k 上下文模型分批投喂 |
| `sections/` | 20 个独立 part 文件 | 适合 32k–64k 上下文模型逐章投喂;也方便只补某一章 |
| `../analysis/`、`../parts/` | 调研记录与原始 part 源文件 | 供人工核对证据(不投喂给模型) |
## 二、各 part 体量与 token 估算
| # | 内容 | 文件 | 字符(去空白) | 估算 tokens |
|---|---|---|---|---|
| 00 | 使用说明 + 任务声明(总入口) | `00-preamble.md` | 3303 | 1830 |
| 01 | 项目总纲(定位 / 体验原则 / 架构 / 功能全景) | `01-overview.md` | 7166 | 4241 |
| 02 | 技术栈与工程结构(pnpm/Electron/Vue/Vite、进程模型、构建) | `02-tech-stack.md` | 29905 | 13144 |
| 03 | 设计系统 Token(颜色/字体/圆角/间距/阴影/层级/DPI) | `03-design-tokens.md` | 23754 | 9863 |
| 04 | 主界面(岛)像素级规格(窗口/行/背景/进度/遮罩/编辑/隐藏) | `04-main-window.md` | 43613 | 18129 |
| 05 | 组件系统(11 个内置组件逐个规格) | `05-components.md` | 33233 | 14274 |
| 06 | 课程表/时间表/档案/科目/周次/调课/导入导出 | `06-schedule-profile.md` | 32546 | 14720 |
| 07 | 提醒系统(队列/提供者/气泡/强提醒/音效/TTS/天气服务) | `07-notifications.md` | 38391 | 16170 |
| 08 | 自动化(触发器/行动/规则集) | `08-automation.md` | 26650 | 11310 |
| 09 | 主题系统(Fluent/Classic、主题包、壁纸取色 P2) | `09-themes.md` | 18809 | 8342 |
| 10 | 设置中心(19 个设置页逐项) | `10-settings.md` | 37254 | 18074 |
| 11 | 其它窗口与向导(OOBE/闪屏/崩溃/恢复/更新/换课/Excel/教程…) | `11-other-windows.md` | 36458 | 18532 |
| 12 | 插件系统、IPC、单实例与自动更新 | `12-plugins-ipc.md` | 18897 | 9154 |
| 13 | 数据模型、存储、备份与迁移(JSON schema) | `13-data-storage.md` | 41873 | 16452 |
| 14 | 动画与动效总表(99 条) | `14-animations.md` | 26795 | 11712 |
| 15 | 资源清单(图标/字体/音效/天气图标/教程/本地化) | `15-assets.md` | 15959 | 7247 |
| 16 | 质量门禁与验收清单 | `16-acceptance.md` | 5623 | 3464 |
| 17 | 开发里程碑(M0–M8) | `17-milestones.md` | 3478 | 2238 |
| 18 | 禁止事项与实现边界(红线) | `18-forbidden.md` | 2426 | 1507 |
| 19 | 结语 | `19-conclusion.md` | 370 | 269 |
> token 估算按「汉字 0.9、其他字符 0.3」粗算,仅用于选择投喂方式;实际以所用模型 tokenizer 为准。
## 三、三套投喂方案
### 方案 A:一次性投喂(上下文 ≥ 200k tokens)
- 适用:GPT-5 级别 / Claude 长上下文 / Gemini 2.5 Pro 等。
- 做法:粘贴 `ClassIsland-给ChatGPT的完整提示词.md` 全文,末尾追加:
> 请严格按此提示词实现「classisland-ts」。先通读全文,输出:①实施计划与完整文件树;②你识别出的歧义点(一次性列出,等我确认);③M0 的验收脚本设计。确认后再开始写代码。
- 注意:长上下文也会「中间遗忘」,务必要求模型在每个里程碑复述本阶段关键数值。
### 方案 B:五卷投喂(上下文 ≥ 128k tokens)
1. 第 1 批:`vol1` + 追加话术:「这是项目的总纲、技术栈与工程结构;先只确认理解,不要写代码。请复述 10 条体验原则、技术栈版本、进程模型。」
2. 第 2 批:`vol2` +「这是设计 tokens、主界面与组件规格;请输出你将创建的 Vue 组件树与 CSS token 文件草案。」
3. 第 3 批:`vol3` +「这是课表、提醒、自动化;请输出数据流与状态机设计。」
4. 第 4 批:`vol4` +「这是主题、设置、窗口、插件、数据;请输出 IPC 通道表与数据 schema 清单。」
5. 第 5 批:`vol5` +「这是动画、资源、验收、里程碑、红线;请输出最终实施顺序与验收计划,然后从 M0 开始实现。」
- 每批结束时要求模型把「已确认的关键参数」写进一份 `SPEC-LOCK.md`,下一批先复述再继续。
### 方案 C:逐章投喂(上下文 32k–64k)
- 适用:GPT-4o 等 32k/128k 模型或需要精细控制时。
- 做法:按 00 → 19 顺序,每次投喂 `sections/` 下 1–3 个 part(建议:00+01+02;03+04;05;06;07;08;09+10;11;12+13;14+15;16+17+18+19)。
- 每次投喂加一句:「只实现/修改与本章相关的文件;先复述本章关键数值与验收点,再动手;不要重写其他模块。」
## 四、建议的追问与验收话术
- 开工前:「请先输出 20 个你认为最可能歧义的地方,并给出你的默认决策。」
- 架构评审:「请给出 packages/desktop-client/src 的完整文件树,每个文件一句话职责;标注哪些文件对应提示词的哪个章节。」
- 像素校验:「请给出岛在 1920×1080、缩放 100%、深色 Fluent 主题下的基准截图校验清单(元素/坐标/颜色/字号)。」
- 动画校验:「请列出第 14 部分要求的 99 条动画中,你已实现/未实现的清单,并给出每条的验证方法。」
- 无头验证:「所有自动验证必须无头运行,不得显示可见窗口或播放声音;请提供 headless 测试命令与输出。」
- 数据安全:「请演示一次配置迁移:备份 → 迁移 → 校验 → 失败回滚。」
- 完成前:「请对照第 16 部分逐条勾选,并列出未通过项与原因;对照第 18 部分确认红线。」
## 五、必须向模型强调的三件事
1. **证据路径不是依赖**:文中 `repos/ClassIsland/...`、`repos/ClassHelper-master/...` 是规格来源,实现时应转写为 TypeScript/CSS,不得在产物中引用这两个仓库。
2. **技术栈不可替换**:pnpm 11 + Electron 44 + Vue 3.5 + Element Plus 2.14 + Pinia 4 + Vite 8 + TS 5.9;更新器自研(禁 electron-updater);岛不得引入 Element Plus 全局样式。
3. **不打扰纪律**:任何自动验证必须无头/离屏,不得弹窗、发声、占用真实桌面(第 18 部分)。
## 六、已知的「与 ClassIsland 原版差异」(模型需在交付说明中列出)
- Avalonia 窗口 → Electron 透明 BrowserWindow(固定包围盒 + 窗内形变,见 04 §4.3)。
- XAML 资源字典/主题 → CSS 变量 + 受控主题包(09)。
- 插件 DLL/.cipx → npm 包 + utilityProcess 宿主(12)。
- 命名管道 IPC → Electron IPC + 可选本地只读 HTTP(12)。
- Excel 导入导出:原版 2.1 已停用,本实现用 xlsx 重建为 P1(06/11)。
- 壁纸取色:原版 2.1 已停用,本实现仅 P2 实验性、默认关闭(09)。
- 置顶轮询:原版 1ms 轮询 → 事件驱动 + ≥1000ms 兜底(04/18)。