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

7322 lines
765 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 必须通过。
---
## 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` 这类混合命名。
---
## 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`。
---
## 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。
---
## 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` 根上不得出现该字段。
---
## 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 级。
---
## 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. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 `"触发{行动项}时出现错误。"`。
---
## 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)的主线程长任务;大规模图片解码必须异步。
---
## 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` 全文比对;每一处偏差都算失败)。
---
## 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,且后续动作照常执行。
---
## 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-<版本>` 目录。
---
## 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` 正常工作。
---
## 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 部分确认没有触碰任何红线;确认无误后再声称完成。