- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
1563 lines
157 KiB
Markdown
1563 lines
157 KiB
Markdown
## 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. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 `"触发{行动项}时出现错误。"`。
|