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

1563 lines
157 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.
## 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. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 `"触发{行动项}时出现错误。"`。