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

157 KiB
Raw Blame History

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 提供:

"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),并在设置页显式标注「扩展功能」;推荐模型:

"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):

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