- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
157 KiB
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):
- 统计
TimeLayout.Layouts中TimeType==0的个数l。 Classes.Count < l时用空ClassInfo补齐;Classes.Count > l时从尾部删除多余项。- 逐项写入
Index与CurrentTimeLayout(运行期字段)。 - 若
Classes[i].SubjectId == Guid.Empty且对应时间点DefaultClassId != Guid.Empty,则把DefaultClassId写入SubjectId。 - 更新
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):
- 预定课表:
Profile.OrderedSchedules[今天]存在,且对应ClassPlan存在,且(该课表不是临时层 或IsOverlayClassPlanEnabled)→ 返回它。 - 临时课表:
Profile.TempClassPlanId != null且存在该课表,且TempClassPlanSetupTime.Date >= 今天→ 返回它。 - 常规课表:按下列过滤 + 排序后取第一个:
- 分组匹配:
AssociatedGroup等于ClassPlanGroup.GlobalGroupGuid或SelectedClassPlanGroupId;若启用临时课表群且未过期,再按类型处理——Inherit:默认群 ∪ 临时群 ∪ 全局群;Override:临时群 ∪ 全局群。 - 组优先级排序(必须用
OrderByDescending):临时课表群=3 > 当前选中课表群=2 > 全局课表群=1 > 其他=0。 - 对每张候选课表执行
CheckClassPlan。
- 分组匹配:
CheckClassPlan(plan, date) 的判定顺序(LessonsService.cs:582-607):
plan.IsOverlay == true→ 不匹配。plan.IsEnabled == false→ 不匹配。plan.TimeRule.WeekDay != (int)date.DayOfWeek→ 不匹配。AssociatedGroup既不是全局群、也不是当前选中群、也不是临时群 → 不匹配。WeekCountDivTotal > Settings.MultiWeekRotationMaxCycle→ 不匹配(防止越过最大周期)。WeekCountDiv == 0→ 匹配。- 否则计算
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):
- 刷新档案引用、清理过期数据:
TempClassPlanSetupTime.Date < 今天→TempClassPlanId = null;清理过期预定/临时层;清理过期临时课表群(见 6.7)。 - 若
IsClassPlanEnabled == false→CurrentClassPlan = null并结束。 - 取当前课表;若当天预定课表本身是临时层 →
OverlayClassPlanId = 该课表,否则置null。 - 取
now = 当前本地时间(以校时服务为准).TimeOfDay。 - 当前时间点 = 有效时间点列表中第一个满足
TimeType ∈ {0,1}且StartTime <= now <= EndTime的项。 - 若命中且
TimeType==0:CurrentState=OnClass;按「上课时间点在教学点中的序号」映射到Classes[i],取SubjectId对应科目填入CurrentSubject(映射算法:把TimeLayout.Layouts中TimeType==0的项按序建数组,取当前项的下标,见LessonsService.cs:531-541)。 - 若命中且
TimeType==1:CurrentSubject = Subject.Breaking,并把其Name设为CurrentTimeLayoutItem.BreakNameText;CurrentState=Breaking;IsLessonConfirmed=true。 - 下节课 = 有效时间点中第一个
TimeType==0 && EndTime >= now的项;有则按步骤 6 的方法取科目写入NextClassSubject。 - 下个课间 = 有效时间点中第一个
TimeType==1 && EndTime >= now的项。 - 剩余时间:
OnClass状态下算OnBreakingTimeLeftTime = 下个课间.StartTime - now;否则算OnClassLeftTime = 下节课.StartTime - now;两种时间都必须下限为 0(LessonsService.cs:529)。 - 若「下节课」和「下个课间」都为
null→CurrentState = AfterSchool。 - 把结果统一写回所有属性(没算出的用默认值:
-1、None、Fallback、Empty、Zero、false)。 - 状态变化事件:与上一次状态比较,不同才发事件;顺序固定为「先
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):
- 第一行文案
"每 [周期] 的 [第几周]"——只有WeekCountDiv>0时两个下拉框才可交互,否则整体淡化到Opacity=0.60。 - 第二行文案
"当今天是:"+ 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):
- 基准时间
baseSec:若已选中时间点,上课/课间取「选中项EndTime」,分割线/行动取「选中项StartTime」;若未选中,默认08:00:00(源码注释:按规定中学最早上课不得早于 8:00,见 1006-1009)。 - 默认时长:上课 =
Settings.DefaultOnClassTimePointMinutes(默认 40 分钟);课间 =Settings.DefaultBreakingTimePointMinutes(默认 10 分钟);分割线与行动 = 0(Settings.cs:108-109,651,662)。 - 插入位置:遍历现有时间点,插入到第一个
StartTime > 新项 StartTime的位置之前;没有则追加到末尾(989-998)。 - 空间不足的处理(1023-1078,必须逐条实现):
- 若下一个非分割线时间点的
StartTime <= baseSec:当选中项不是第 0 项 → 提示"没有合适的位置来插入新的时间点。"并放弃;当选中项是第 0 项 → 允许向前插:baseSec = 选中项.StartTime - 默认时长,并提示"已向前插入了新的时间点。"。 - 若下一个时间点起点早于
baseSec + 默认时长:把时长截断为next.StartTime - baseSec,提示"没有足够的空间完全插入该时间点,已缩短时间点长度。"。 - 分割线:
baseSec = 选中项.EndTime;若该时刻已存在分割线 → 警告"这里已经存在一条分割线。"并放弃。 - 行动:
baseSec = 选中项.EndTime;若该时刻已存在行动 → 警告"这里已经存在一个行动。"并放弃。
- 若下一个非分割线时间点的
- 新项
ActionSet:仅TimeType==3时创建new ActionSet(),其余为null。 - 插入后选中新项、滚动居中,并压入撤销栈。
必须实现撤销/重做栈(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),结构固定:
- 标题
"修改课程"(Subtitle 样式,下边距 8)。 换课成…下拉框:ItemsSource = Subjects,SelectedValue = TargetSubjectIndex(Guid)。- 复选框
"永久换课":IsChecked = !IsTempSwapMode(注意取反绑定),受集控策略DisableProfileClassPlanEditing约束。 确定按钮(Accent 样式):当TargetSubjectIndex == Guid.Empty时禁用。- 确认逻辑(
ProfileSettingsWindow.axaml.cs:1531-1550):先GetTargetClassPlan(date, IsTempSwapMode, out guid)取得目标课表;写入Classes[index].SubjectId = TargetSubjectIndex;若IsTempSwapMode为真,同时置Classes[index].IsChangedClass = true;最后刷新日程。
调课(两节课互换) 必须实现两段式交互(ProfileSettingsWindow.axaml.cs:1435-1477):
- 点「开始换课」记录起点
ClassSwapStartPosition = (date, index),进入换课模式;若选中课程为空(IsEmpty)→ 提示"选择课程区域无效。"并结束。 - 点「完成换课」记录终点,再用
GetTargetClassPlan分别取起点/终点课表;任一为空或索引越界 → 直接返回(不报错、不改数据)。 - 交换两个
SubjectId(元组解构赋值一次完成)。 - 临时换课模式下,两侧都置
IsChangedClass = true。 - 刷新日程视图。可随时「取消换课」退出模式。
临时换课的目标课表解析 GetTargetClassPlan(date, overlay, out guid)(ProfileSettingsWindow.axaml.cs:1479-1506)必须按此实现:
LessonsService.GetClassPlanByDate(date, out baseGuid);为空 → 返回 null。- 若
!overlay或baseClassPlan.IsOverlay→ 直接返回当前课表。 - 若该日期已有
OrderedSchedules[date]且其课表是临时层 → 返回当前课表。 - 否则调用
CreateTempClassPlan(baseGuid, enableDateTime: date)创建「当天临时层」,返回新课表(失败返回 null)。
创建临时层 CreateTempClassPlan(id, timeLayoutId?, enableDateTime?)(repos/ClassIsland/ClassIsland/Services/ProfileService.cs:235-280):
- 若该日期已有临时层预定 → 返回
null(不允许重复创建)。 - 用 JSON 深拷贝源课表(必须经序列化/反序列化,保证引用隔离)。
- 可选:同时深拷贝时间表,置
IsOverlay=true、OverlaySourceId=源时间表ID、Name += "(临时层)",注册进TimeLayouts,并把新课表的TimeLayoutId指向它。 - 新课表:
IsOverlay=true、OverlaySourceId=源课表ID、Name += "(临时层)"、OverlaySetupTime=日期。 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):
- 删除所有
OrderedSchedules中日期 < 今天的项。 - 收集现存的
OrderedSchedules[].ClassPlanId,删除所有IsOverlay但未被引用的课表。 - 汇总「未过期课表」引用的
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,必须逐条实现):
- 目标档案 = 合并档案或新建档案;建立「科目名 → 科目 Guid」缓存,先用目标档案已有科目名填充。
- 遍历 CSES
Subjects:名字已存在则复用 Guid,否则新建Subject;写入Name;SimplifiedName非空才覆盖Initial;Teacher非空才覆盖TeacherName。 - 遍历 CSES
Schedules:找一张「上课时间点数量与i.Classes.Count相等,且每个上课时间点的StartTime/EndTime全部与 CSES 一致」的时间表;找不到则新建时间表。 - 为每节课建立
ClassInfo { SubjectId };若是新时间表,则同时生成时间点:每节课一个TimeType=0(起止取 CSES 的起止),相邻两节之间补一个TimeType=1(前一节结束 → 后一节开始)。 - 课表:
TimeLayoutId指向该时间表;TimeRule.WeekCountDiv = i.Weeks;TimeRule.WeekDay = (int)i.EnableDay;Name = i.Name;新 Guid 注册进ClassPlans。
ClassIsland → CSES 导出算法(P0,必须实现)(CsesExtensions.cs:127-166):
- 把目标档案中不存在(按名称比对)的科目追加进 CSES 科目,写
Name/SimplifiedName/Teacher。 Profile.RefreshTimeLayouts()后,只导出TimeLayout != null && IsEnabled && WeekCountDivTotal <= 2 && WeekCountDiv <= 2的课表(即单双周以内且已启用的课表)。- 每张课表导出为一个
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模块,纯函数、可单测)。
文件与工作表约定:
- 文件扩展名
.xlsx;默认工作表课表(第 1 个 sheet)。 - 可选第 2 个 sheet
时间表,用于承载时间点(保证往返可还原时间表,见 6.9.1.5)。 - 兼容「单 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)。
导入映射规则:
- 科目匹配:数据格文本
text.Contains(subject.Name)即视为命中(源码 627-632);多个科目名同时命中取档案中第一个;/分隔的多科目必须按段分别匹配。 - 星期 → 课表:每个星期列生成一张
ClassPlan,TimeRule.WeekDay= 该列对应 0..6,TimeLayoutId= 本次导入的时间表。 - 节次 ↔ 时间点对齐:课表行数必须 ≤ 时间表「上课时间点」数量;超出 → 报错并中止(不允许静默截断)。
- 单双周:若列中存在
/合并(多课表同列),必须拆成多张课表并写入各自的WeekCountDiv/WeekCountDivTotal;列首若带周次标记(见 6.9.1.5 新增约定),按标记写入。 - 完成导入:每张课表分配新 Guid;导入过程中被临时激活的时间表必须复位
IsActivated=false、IsActivatedManually=false(源码CompleteImport,695-706)。 - 只导时间表模式:
ImportTimeLayoutOnly=true时只写入时间表,不创建课表(388-397)。
往返一致性契约(必须显式实现并写进向导文案):
- 可往返:单张单周课表 + 科目名 + 教师名开关一致时,科目矩阵逐格可还原。
- 不可往返、必须补齐的项:
- 原版导出完全不写时间点(只写节次序号,437-442),因此原版导出文件无法还原时间表。本实现必须写
时间表sheet(或把 A 列改为HH:mm-HH:mm文本),否则导入必须强制让用户另选时间表。 - 原版导出把同一星期的多张课表用
/合并到同一格(403-408),丢失单双周/多周信息。本实现必须增加「周次」行(如单周/双周/2/4周)或拆分为多行块,确保WeekCountDiv与WeekCountDivTotal可往返。 Initial不导出 → 往返后由Name首字自动重算(见 6.1.6);IsOutDoor不导出 → 往返后丢失,向导必须提示。
- 原版导出完全不写时间点(只写节次序号,437-442),因此原版导出文件无法还原时间表。本实现必须写
- 验收等价性:导出 → 导入 → 再导出,两次导出文件的单元格文本矩阵必须逐格相等(颜色/列宽/边框不要求)。
导入前全量预检(任一硬错误不得写入档案):
- 非
.xlsx/ 缺少课表sheet / 表头缺失 → 结构化错误并中止。 - 时间范围数字个数 ≠ 4/6、时 >23、分/秒 >59、
Start >= End→ 逐行错误。 - 节次序号不连续或重复 → 警告,允许继续但必须二次确认。
- 列首不是合法星期名 → 该列跳过并在报告中列出。
- 科目名未在档案中 → 逐格报告,提供「新建科目 / 置空 / 忽略该格」三选一。
- 解析结果为空(无任何课表或时间点)→ 拒绝并提示。
- 文件被占用 / 损坏 / 密码保护 → 捕获并以 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.x 的「附加设置」按对象分别存放;迁移时按控件 GUID 一一映射到 2.x 的
AttachedObjects(4 个内置 GUID 见 6.10.2)。 - 未知 GUID 的附加设置必须原样保留(键与值都不丢),以便插件后续注册后仍可读取。
- 迁移失败(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 边界
- 同一 GUID 在多层存在但都未启用 → 等价于不存在。
TimePoint级别的附加设置只有在其时间点被课程实际引用时才可能生效(未引用节点在档案分析里会提示「当前节点未被任何一个课表使用」,AttachedSettingsControlPresenter.axaml.cs:209-213)。- 删除科目/时间点/课表时,其
AttachedObjects随之删除,不得残留在档案里(对象被整体移除即满足)。 - 天气提醒附加设置
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 边界与异常(必须处理)
- 时间点重叠 / 逆序:
StartTime > EndTime必须拒绝(源码EnsureTime抛ArgumentException("时间点起始时间无效。"),TimeLayoutItem.cs:95-101),UI 层必须先校验再写模型。 - 跨天:时间点只在同一天内比较,不支持跨 0 点;若用户排出
23:50-00:10必须提示非法。 - 空课表:
CurrentClassPlan == null、TimeLayout == null时所有派生状态必须安全降级,不得空引用。 - 课表引用的时间表被删:必须回退为
TimeLayoutId = Guid.Empty并显示「未选择时间表」占位;不得崩溃。 - 本周无匹配课表:
CurrentClassPlan=null,组件显示空态,OnClass/OnBreakingTime/OnAfterSchool均不触发。 - 时间表被多个课表共享:修改时间表必须联动所有共享课表(
ClassPlan.TimeLayoutOnLayoutObjectChanged会RefreshClassesList),且「应用到所有课表」的科目覆写只影响同一TimeLayoutId的课表。 - 临时课表/临时层与预定课表冲突:优先级固定为 预定课表 > 临时课表 > 常规课表;临时层覆盖临时课表的提示文案必须出现(
"临时课表被临时层课表覆盖。要使用临时课表,请暂时禁用或清除临时层课表。",ProfileSettingsWindow.axaml:283-292)。 - 档案 JSON 损坏:加载失败必须回退为空档案并保留原文件为
.bak,同时在设置页提示【推断:ConfigureFileHelper.LoadConfig的容错细节未在本次读取范围内给出确切行为,实现时必须显式兜底】。 - 并发写入:同一档案的自动保存必须串行化(建议 300ms 防抖 + 单写队列),避免 Electron 主进程多窗口同时写导致截断。
6.13 验收标准
- 新建档案后
Profiles/*.json中同时存在默认课表群与全局课表群,GUID 与本节一致;删除/解散这两个群会被拒绝。 - 构造「周一/周三/周五 + 单周 + 不轮换」「周二 + 2 周轮换第 2 周」两组课表,把系统时间改到对应日期与周次,
当前课表必须正好切换;把MultiWeekRotationMaxCycle改成 2 后,WeekCountDivTotal=4的课表必须永不启用。 - 时间表新增一节 40 分钟上课 + 10 分钟课间,
Classes自动补齐数量且顺序与上课时间点一致;删除时间点后对应课程同步消失,撤销后恢复原位。 - 在 08:00 之前插入上课时间点会触发「向前插入」提示;插入分割线/行动到已存在时刻会被拒绝并给出对应警告文案。
- 课表中「永久换课」只在当前课表写入
SubjectId;「临时换课」必须新建当天临时层课表(IsOverlay=true,名称带(临时层))并置IsChangedClass=true,次日自动被清理。 - 「换课」两段式交换后两张课表的两个
SubjectId互换;起点/终点任一为空时不发生任何数据变更。 - 关闭档案窗口再打开,所有编辑都已落盘;重启应用后
SelectedProfile生效。 - 从 CSES 导入后:科目不重复(同名复用)、时间表按上课起止自动匹配或新建、单双周与星期正确写入;导出到 CSES 时只包含已启用且单双周内的课表。
- 课表详细信息窗口列出的附加设置顺序与生效顺序一致,状态条颜色符合
Enabled/Override规则。 - 全程不得出现「点击后无提示、数据半更新」的情况;所有校验失败都必须在 UI 上给出明确中文提示。
- Excel 往返:导出 → 导入 → 再导出后,两次导出文件的单元格文本矩阵逐格相等;时间表可通过
时间表sheet(或 A 列HH:mm-HH:mm文本)完整还原;同一星期的单双周课表往返后WeekCountDiv/WeekCountDivTotal不丢。 - Excel 预检:时间格式错误、起止逆序、列首非法星期、科目名缺失、解析结果为空这五类情况必须被拦截并给出可定位(行/列)的报告,且档案文件未被写入。
- 附加设置优先级:在 Subject、TimePoint、ClassPlan、TimeLayout 四层分别写入同一 GUID 并启用,读取结果必须严格按
Subject → TimeLayoutItem → ClassPlan → TimeLayout返回第一个启用项;未启用的层不得在档案里留下该 GUID 键。 - 附加设置序列化: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):
- 数组为空直接返回;全局提醒关闭则把每个 request 的
CompletedTokenSource取消后返回。 - 用
CancellationTokenSource.CreateLinkedTokenSource生成RootCancellationTokenSource(取消令牌)与RootCompletedTokenSource(完成令牌),任一子请求取消即取消全部。 - 为每个 request 建立
ChainedHeadRequest = requests[0]、ChainedNextRequest = 下一个、RootCancellationTokenSource、RootCompletedTokenSource,逐个SetupNotificationRequest并注册完成回调。 - 优先尝试推送给消费者(见 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):
NotificationProvidersPriority中不存在该 GUID → 追加到末尾(即新的提供方优先级最低)。NotificationProvidersSettings[guid] = null(提供方私有设置对象,按需反序列化)。NotificationProvidersEnableStates[guid] = true。NotificationProvidersNotifySettings[guid] = new NotificationSettings()。- 建立注册信息对象
{ Name, Description, ProviderGuid, ProviderInstance, ProviderSettings, NotificationChannels[] }(repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationProviderRegisterInfo.cs:14-56)。 - 遍历该提供者声明的所有
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):
- 仅当
CurrentState ∈ {Breaking, None}时继续。 - 触发条件:
0 < OnClassLeftTime <= Δ且IsClassOnPreparingNotificationEnabled;Δ取附加设置 → 否则按「下节课是否户外」取 600 或 60 秒。 - 已提醒过(
IsClassPreparingNotified)→ 不再提醒。 - 计算剩余秒
deltaTime:deltaTime = Δ - OnClassLeftTime,若> 10 秒则用OnClassLeftTime,否则用Δ(CalculateDeltaTime,125-129)。遮罩朗读文本固定为"距上课还剩{格式化时长}。",遮罩持续 3 秒。 - 若
IsClassOnNotificationEnabled,同时构造「上课」请求并入链。 - 正文内容:
ClassPrepareNotifyOverlay,包含左列"距上课还剩 N 秒"(N 为OnClassLeftTime总秒数,加粗)与右列"下节课是:" + 科目名(MainWindowLargeFontSize加粗)+ 可选教师名 + 起止时间(hh:mm-hh:mm);左右两列各占1*,单元格内Margin=12 0(repos/ClassIsland/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml:19-46)。 - 正文
EndTime= 今天 +NextClassTimeLayoutItem.StartTime(即一直显示到上课时刻),朗读文本 ="{正文文本} 下节课是:{科目名}[,由{姓氏}老师任教]。"。 - 条件不再满足时(例如时间被追溯改晚):取消已发出的准备上课请求,并把
IsClassPreparingNotified复位。
行为 B:上课(响应 OnClass 事件,240-286):
- 若
IsClassOnNotificationEnabled=false、IsClassOnNotified=true、当前时间点为空、或now - CurrentTimeLayoutItem.StartTime > 5 秒(迟到超过 5 秒不提醒)→ 全部跳过。 - 只发遮罩:
CreateTwoIconsMask(ClassOnMaskText, rightIcon="lucide(\ue54f)"),默认持续 5 秒;SpeechContent = ClassOnMaskText。 - 事件回调在「取消」时复位
IsClassOnNotified(注意源码注册在CancellationToken上,283 行)。 OnClass事件处理末尾必须把IsClassPreparingNotified=false、IsClassOnNotified=false(源码注释说明:避免「提醒过程中时间提前导致播完还没上课」造成重复提醒,244-247)。
行为 C:下课(响应 OnBreakingTime,199-238):
- 校验:
IsClassOffNotificationEnabled且CurrentTimeLayoutItem != Empty且now - CurrentTimeLayoutItem.StartTime <= 5 秒,否则跳过。 - 正文文本
overlayText = 附加设置的 ClassOffOverlayText ?? 全局设置;showOverlayText = 非空白。 是否还有下节课 = (NextClassSubject != Subject.Fallback)。- 遮罩:
ClassOffNotification布局——左图标\ue0ff(20×20,居中)、中间文字 =CurrentTimeLayoutItem.BreakNameText(MainWindowEmphasizedFontSize、加粗、左右Margin=8 0)、右图标\ue250(20×20);文字颜色TextOnAccentFillColorPrimaryBrush。遮罩时长:无下节课 5 秒;有下节课 2 秒。朗读内容 =BreakNameText。 - 正文:无下节课 →
null;有下节课 →ClassOffOverlay:左列"本节{课间名}长 {人性化时长}"(加粗时长,格式见下)、右列"下节课是:" + 科目名 + 可选教师 + 起止时间;正文时长:有自定义文本 20 秒,否则 10 秒。朗读文本 ="本节{课间名}常{时长},下节课是:{科目名}[,由{姓氏}老师任教]。{overlayText}"(源码该句为「常」字,属笔误,实现时建议修正为「长」并记录)。 - 人性化时长格式(
ClassNotificationProviderControl.axaml.cs:134-152):>0才有输出;小时用"N 小时";分钟在有秒时用"N 分"、无秒时用"N 分钟";秒用"N 秒";以空格连接;<=0返回"0 分钟"。 - 内容轮播:该控件的
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):
- 天气未刷新(
IsWeatherRefreshed=false)→ 跳过。 - 取附加设置(按当前时间点)→ 无则用提供方设置。附加设置 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)。 - 关闭条件:
!Settings.IsAlertEnabled,或附加设置AlertShowMode == Disabled,或(附加设置AlertShowMode == Default且全局AlertShowMode == Disabled)。 - 对
LastWeatherInfo.Alerts中未在本次会话展示过(ShownAlerts以Detail文本去重)的每条预警:计算单次显示秒数t = Detail.Length / WeatherAlertSpeed,钳制到 [10, 90];遮罩 = 预警标题(图片图标 22×22 + 标题MainWindowEmphasizedFontSize加粗,Margin=8 0),SpeechContent = 标题,时长 5 秒;正文 = 预警详情(滚动文本),时长 2t,SpeechContent = Detail。展示后把Detail记入ShownAlerts(会话内不再重复)。 - 正文布局:左列 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):
repeatCounts = max(1, payload.RepeatCounts);durationSeconds = payload.DurationSeconds <= 0 ? 5 : payload.DurationSeconds。maskText = payload.MessageMask 为空白 ? "集控通知" : MessageMask。- 遮罩:
CreateTwoIconsMask(maskText, rightIcon: "\uE7E7")(默认 5 秒)。 - 正文:
CreateRollingTextContent(messageContent, 时长 = durationSeconds * repeatCounts, repeatCounts)(滚动 N 次)。 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,必须逐步实现):
- 若正在播(
_isOverlayOpen)→ 直接返回;否则上锁。 - 首次播放记录
_firstProcessNotificationsTime。若全局提醒关闭,或「静默启动」且距首次播放 ≤ 10 秒(App.ApplicationCommand.Quiet)→ 清空请求队列(698-704)。-q即静默启动参数(repos/ClassIsland/ClassIsland/Models/ApplicationCommand.cs的Quiet)。 - 队列循环:取出 ticket(含 request 与生效设置),置
CurrentNotificationRequest。 - 若
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))。
- 清除
- 遮罩结束后:
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置
:overlay-anim、:mask-out、:overlay-out,结束本次。 - 否则绑定正文、置
:mask-out、:overlay-in,await ticket.ProcessOverlay()。
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置
- 队列与宿主队列都空且本次确实播过 → 置
:overlay-anim、:mask-out;清空MaskContent/OverlayContent;解锁;释放置顶锁。 - 捕获
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):
SlantedMaskControl置IsOpened = true(斜切遮罩展开)。OverlayMaskContent:Opacity 0→1,Delay=260ms、Duration=250ms、Easing = "0.25, 1, 0.5, 1"(自定义贝塞尔,近似 QuartOut)。OverlayMaskContent缩放:ScaleX/ScaleY 1.1→1.0,Delay=260ms、Duration=750ms、同一条缓动曲线。- 遮罩背景
BackgroundBorderOverlayMask:Opacity 0→1,Delay=600ms、Duration=100ms、FillMode=Forward。 - 原内容
Grid#GridContentRoot:Opacity = 0。
遮罩退出动画(:mask-out,372-417):
SlantedMaskControl.IsOpened = false(斜切遮罩向中间收拢)。OverlayMaskContent:Opacity 1→0,Duration=200ms,FillMode=Forward;同时Scale 1.0→1.1,Duration=200ms,Easing="0.5, 0, 0.75, 0"(约 QuartIn)。- 遮罩背景:
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、3delay = 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;CanvasClipToBounds=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):
- 顶部
SettingsExpander:Header="启用提醒",Description="启用提醒后,将会在对重要事件发出醒目提醒。拖动提醒提供方以调整对应类型提醒的优先级。",右侧ToggleSwitch绑定Settings.IsNotificationEnabled(66-70)。 TabItem "提醒提供方"(87):左侧为提供方列表(图标 24×24ProviderInstance.IconElement+ 名称 + 描述,支持拖动排序 → 写回NotificationProvidersPriority),右侧详情形。- 提供方详情:
- 每个渠道一行(
Header="提醒渠道"),显示渠道图标/名称/描述(148-186)。 启用提醒语音(AllowNotificationSpeech)+默认启用(IsSpeechEnabled)+语音引擎+语音音量+ 测试朗读/测试语音(224-283)。启用提醒强调特效(AllowNotificationEffect)+默认启用(IsNotificationEffectEnabled)(286-299)。启用提醒音效(AllowNotificationSound)+默认启用(IsNotificationSoundEnabled)+音效音量+自定义音效(301-347)。置顶提醒(AllowNotificationTopmost)+默认启用(IsNotificationTopmostEnabled)(353-368)。
- 每个渠道一行(
- 提供方自身设置页
TabItem "基本设置":承载ProviderInstance.SettingsElement;无设置时显示"该提醒提供方没有设置。"(374-390)。 - 每个提供方/渠道的高级覆盖区:
Header="对此提醒来源启用特殊高级设置",Description="启用后,此处的设置将覆盖全局设置和提醒请求中要求的设置。",勾选后写ProviderSettings.IsSettingsEnabled = true,其下再出现语音/特效/音效/音效路径/置顶五项(409-484)。 - 拖动排序必须即时持久化,并影响后续队列顺序(不必重排已在队列中的项)。
7.12 边界与异常(必须处理)
- 提醒关闭:
IsNotificationEnabled=false时新请求立刻取消完成令牌,不得入队、不得响铃。 - 无消费者:主界面被隐藏 / 所有行都禁用提醒 / 正在播 / 正在编辑 → 请求留在队列,等消费者恢复后再推。
- 遮罩时长 0:
MaskContent.Duration <= 0时不进入遮罩阶段,直接按「无正文」收尾。 - 正文时长为 0:视为无正文。
EndTime早于当前时间:Duration钳为 0,立即结束,不得出现负数时长导致Task.Delay抛异常。- 语音/音效同时开启:语音先入队播放,音效在遮罩阶段播放;两者混音不得互相打断;提醒结束时清空语音队列。
- 音效文件不存在 / 损坏:捕获异常、记日志、继续播放提醒,不得中断整条链路。
- 集控消息超长:滚动文本时长已达 90 秒上限(预警)或由
DurationSeconds × RepeatCounts决定;必须防止构造出超长(建议单条提醒时长上限 300 秒)。 - 特效窗口在编辑模式:丢弃特效但继续播气泡(不得因特效缺失阻塞)。
- 多显示器 / DPI 变更:特效窗口每个显示器一个;屏幕变化时必须重算
UpdateWindowPos,否则会出现「特效只在主屏」或缩放错位。 - 提醒风暴:同一提供者在极短时间内连续发多条(如天气多条预警)必须靠「会话内已展示集合」抑制重复;队列本身不限制长度,但连续入队要在 UI 上表现为顺序播放而不是同时播放。
- 应用退出:正在播放的提醒必须允许被
ApplicationShutdown打断(特效窗口关闭拦截需放行该 reason)。
7.13 验收标准
- 触发上课事件:默认配置下先出现「即将上课」遮罩(斜切遮罩展开 + 文字缩放淡入),随后显示正文「距上课还剩 N 秒 / 下节课是:语文 08:00-08:40」,正文一直显示到上课时刻;随后自动接「上课」遮罩。
- 遮罩进入动画的四个关键时间点可核对:内容
delay 260ms / 250ms、缩放delay 260ms / 750ms、背景delay 600ms / 100ms、阴影过渡 100ms。 - 遮罩退出的斜切收拢方向为「中间→两边」,正文淡入淡出 200ms + SineEaseIn。
- 强提醒涟漪:从行中心扩散到全屏,600ms 完成,同时 600ms 淡出,750ms 后移除元素;特效窗口覆盖主界面所在屏幕,
scale改变后窗口尺寸按比例变化。 - 置顶锁:连续两条提醒时主界面保持置顶,直到最后一条结束才还原层级。
- 关闭「启用提醒」后,任何提供者都发不出提醒;重新开启后历史队列不补播。
- 优先级排序:把「提供方 A(优先级下标 0)+ Normal」「提供方 B(下标 1)+ Emergency」「提供方 A + Emergency」三条同时入队,播放顺序必须是 A+Emergency → B+Emergency → A+Normal(先比提供方下标,再比等级,最后 FIFO);集控紧急消息必须最先播放且不再有「反而排到队尾」的现象。
- 语音:打开
AllowNotificationSpeech后,遮罩文本被朗读一次;正文阶段不重复朗读同一段;Edge TTS 音色默认zh-CN-XiaoxiaoNeural,第二次相同文本命中缓存不重新合成。 - 音效:默认音效在遮罩阶段播放一次,正文阶段不再播放;自定义音效超长部分被截断;音量滑块 0~1 生效。
- 天气预警:同一预警在一次会话中只提醒一次;显示时长被钳制在 10~90 秒;正文为详情滚动文本。
- 主界面右键「清除全部提醒」只在有提醒正在播放时出现,点击后队列清空、当前提醒立即消失、置顶锁释放。
- 全流程不出现「二次弹窗框架」或与系统通知重复弹出的现象;所有异常均有日志且不打断后续提醒。
- 附加设置覆盖:给某课表挂「上课提醒设置」附加设置并启用、把
ClassOnMaskText改为"上课了",该课表上下课提醒的遮罩文案必须变为"上课了";关闭附加设置后立即回退为提供方设置的"上课"。 - 附加设置优先级:同一课程在 Subject / TimePoint / ClassPlan / TimeLayout 四层都挂了同一 GUID 并启用,实际生效的必须是 Subject 层(顺序 Subject → TimePoint → ClassPlan → TimeLayout)。
- 天气:断网时提醒链路不得异常中断,界面继续显示
LastWeatherInfo与「更新于 HH:mm」;恢复网络后最迟一个刷新周期(5 分钟)内更新;坐标模式精度降级到 2 位时必须返回并提示IsPrecisionDegraded。 - 天气图标:把
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,必须实现):- 用
Settings.ExcludedWeatherAlerts对Title与Detail做包含匹配过滤,空字符串忽略。 - 按
Type分组,每组只保留PubTime最大的一条;被丢弃的旧预警记日志("已丢弃旧预警:…")。 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 验收标准(天气服务)
- 首次启动且无网络:
IsWeatherRefreshed=false,界面显示占位;有缓存时显示缓存与「更新于」,提醒与规则均按IsWeatherRefreshed跳过。 - 恢复网络后,最迟 5 分钟(或退避重试命中)内刷新成功,
LastWeatherInfo更新且组件/提醒同步变化。 - 城市模式:修改
CityId后刷新,城市名与新城市一致;非法CityId时返回失败且不清空旧数据。 - 坐标模式:把坐标改到精度较低的位置,请求必须按 4→3→2→1→0 降级并回报
IsPrecisionDegraded。 - 预警:同一
type出现两条,只保留PubTime最新;被ExcludedWeatherAlerts命中的预警不进入Alerts、不提醒。 - 图标:4 个模板可切换并立即生效;非法 ID 保持上一次并记 Warning。
- 降水:构造
value数组「前 N 个 >0 后为 0」时RainRemainingMinutes = -N;「前 N 个为 0 后 >0」时返回N;全 0 返回 0。 - 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):
- 读配置:配置不存在 → 用空工作流数组新建并立即保存(
note="新建自动化配置")。 - 安全模式:直接返回,不装载触发器。
- 逐个
LoadWorkflow:订阅Triggers.CollectionChanged、ActionSet.PropertyChanged、Unloading;对每个触发器LoadTrigger。 - 卸载(
UnloadWorkflow/UnloadTrigger):必须调用触发器的UnLoaded()、退订全部事件、把TriggerInstance置 null。源码注释明确「否则可能造成内存泄漏」(repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/TriggerBase.cs:35-39)。 - 触发器
Id改变 → 卸载再加载;Workflows集合增删 → 增量加载/卸载。 - 切换配置方案:
Settings.CurrentAutomationConfig变化时先InterruptAllWorkflows()(对每个工作流InterruptActionSetAsync)再保存旧配置,然后加载新配置(AutomationService.cs:59-74)。 - 关闭自动化总开关:
IsAutomationEnabled变 false 时InterruptAllWorkflows()(71-73)。 - 行动组状态变化:
ActionSet.Status变化且该组不在运行中时保存配置(ActionSetOnPropertyChanged,218-223),保证IsOn状态跨重启可恢复。 - 触发器实例化:按
Id从 DI 的键控服务取TriggerBase;若触发器带设置控件且控件基类的泛型参数为设置类型,则把 JSONJsonElement反序列化为该类型实例后注入(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):
- 若状态是
Reverting→ 先InterruptActionSetAsync。 - 若
IsWorking→ 直接返回(不排队)。 MigrateActionSet(迁移旧格式,见 8.5.6)。SetStartRunning(true)。- 逐项执行
ActionItems.Where(x => !x.IsRevertActionItem),每项InvokeActionItemAsync(x, actionSet, isRevertable && actionSet.IsRevertEnabled)。 finally { SetEndRunning(true) }。
RevertActionSetAsync(actionSet):
- 若状态是
Invoking→ 先中断。 - 若
IsWorking→ 返回。 SetStartRunning(false)。- 逐项执行
ActionItems.Where(x => x.IsRevertActionItem || x.IsRevertEnabled):IsRevertActionItem的项执行其 Invoke(它是「恢复专用行动项」),IsRevertEnabled的项执行RevertActionItemAsync。 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):
Name为空 → 直接返回(不报错)。- 通过
SettingsService.GetPropertyInfoByName(Name)反射取属性;找不到 → 抛KeyNotFoundException("应用设置中找不到属性“{Name}”。")。 Value == null→ 抛ArgumentNullException。- 值转换
ConvertToAssignableToSettingsType:支持string/int/double/bool/Color与枚举直接转换;其余类型走 JSON 反序列化(容忍注释、尾逗号、大小写、数字字符串、枚举字符串)。若转换结果为 null → 抛异常。 - 在 UI 线程执行:若
IsRevertable为真(行动组启用了恢复)→SettingsService.AddSettingsOverlay(ActionSet.Guid, Name, value)(叠层,不覆盖原值);否则直接propertyInfo.SetValue。 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):
Id以classisland.settings.开头才迁移。- 取后缀,首字母大写 → 新
ModifyAppSettingsActionSettings.Name。 Id = "classisland.settings"。- 若旧
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,必须逐条实现):
Loaded()时记录LastCheckTime = now,订阅PostMainTimerTicked。- 每 tick:若
CurrentState == TargetState→TriggerRevert()并结束(状态已到,恢复)。 TimeSeconds < 0→ 返回。- 求目标时刻
targetStartTime:TargetState == AfterSchool:取有效时间点中最后一个TimeType∈{0,1}的EndTime;无 → 返回。OnClass:NextClassTimeLayoutItem.StartTime;Breaking:NextBreakingTimeLayoutItem.StartTime;其他 →TimeLayoutItem.Empty;为空 → 返回。
targetTime = targetStartTime - TimeSeconds秒,拼成今天的时刻。- 跨点判定:
LastCheckTime < targetTime && targetTime <= now才Trigger()(保证只触发一次,且应用在跨点后才启动时不会误触发)。 finally { LastCheckTime = now }。
8.6.2 cron classisland.cron
设置:{ "CronExpression": "* * * * *" }(CronTriggerSettings.cs:7)。行为(CronTrigger.cs:25-84):
Loaded()时订阅设置变化并LoadCron()。LoadCron():取消旧 worker → 解析表达式 → 记日志「cron 表达式解析成功,下次触发时间:…」→ 起一个 LongRunning 后台任务。- Worker 循环:
sleep = crontab.GetSleepTimeSpan(now);await Task.Delay(sleep, token);到点在 UI 线程Trigger();直到被取消。 - 解析失败(
TimeCrontabException)→ 记 Warning 并停止该触发器,不崩溃。 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):
- 每个触发器实例创建一个
NativeMenuItem,文本 =Settings.Header。 - 挂到托盘图标的「更多选项」子菜单
"运行工作流"(图标\uedbf)下;子菜单为空时显示禁用项"(空)"(TrayMenuTriggerHandlerService.cs:15-62)。 Header变化必须实时刷新菜单文本。- 点击:
IsRevert为真 →TriggerRevert(),否则Trigger()。 - 卸载时移除菜单项。
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):
isSatisfied = (ruleset.Mode == And)(And 初始 true,Or 初始 false)。Groups.Count <= 0→State=0并返回 false。- 先复位所有 Group/Rule 的
State = 0。 - 只遍历
IsEnabled的 Group:res = IsRulesetGroupSatisfied(group);group.State = BoolToRuleObjectState(res);res == null→ 跳过。 - And 模式下遇到 false 立即 break;Or 模式下遇到 true 立即 break。
isSatisfied ^= ruleset.IsReversed;ruleset.State = 状态;返回。
IsRulesetGroupSatisfied(74-107):
groupSatisfied = (group.Mode == And)。- 组内
Id != ""的规则数 ≤ 0 → 返回null(三值:未判定)。 - 逐条
res = IsRuleSatisfied(rule);null→ 记 State 0 并 continue。 result = (bool)res ^ rule.IsReversed;rule.State = result ? 2 : 1。- And 遇 false / Or 遇 true 立即 break。
groupSatisfied ^= group.IsReversed;返回。
IsRuleSatisfied(36-72):
Id == ""→null。- 规则 ID 未注册 → 记 Warning
"找不到规则 {id} 的注册信息,已默认其结果为 false.",返回 false(不是 null)。 - 若该规则有设置类型:
settings = rule.Settings ?? Activator.CreateInstance(settingsType);若是JsonElement则反序列化。 - 无处理程序 → 记 Warning 并返回 false。
- 处理程序抛异常 → 记 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):
- 仅在
ProfileService.IsCurrentProfileTrusted == true时执行(未信任档案不执行时间点行动)。 - 每个
PostMainTimerTicked:从当前课表时间表中筛出TimeType == 3且StartTime > LastActionRunTime.TimeOfDay && StartTime <= now.TimeOfDay的时间点(即「上次检查之后、本次检查之前」被跨过的行动时间点)。 - 更新
LastActionRunTime = now(无论是否命中,避免重复)。 - 对每个命中的时间点,若
ActionSet != null→ActionService.InvokeActionSetAsync(actionSet, isRevertable: false)(时间点行动不可恢复)。 LastActionRunTime初始化为自动化服务启动时刻(79 行),因此启动前的时间点不会被补触发。- 每个时间点的
ActionSet在时间表编辑器中编辑(见第 6 节,新增「行动」时间点时自动创建空 ActionSet)。 - 时间点行动与「附加设置」互不影响:
TimeLayoutItem.AttachedObjects(附加设置模型见 06 §6.10)不参与时间点行动的执行判定;反之行动也不会修改附加设置。两者只共享同一个TimeLayoutItem对象。 - 时间点行动不受
Settings.IsAutomationEnabled控制(该开关只控制「自动化工作流」通道),但受档案信任状态控制;安全模式下不执行。
8.10 设置页 UI(自动化)
页面 AutomationSettingsPage:[SettingsPageInfo("automation", "自动化", "\ueEF1", "\ueEF0", Internal)]、[FullWidthPage](Views/SettingPages/AutomationSettingsPage.axaml.cs:27-29)。布局为「列表 + 详情」双栏(ListDetailView),必须实现:
- 首次使用须知(
Settings.IsAutomationWarningVisible == true时整页替换为该内容,AutomationSettingsPage.axaml:333-400):- 顶部图标行:
Grid ColumnDefinitions="75,Auto,75",左/Assets/AppLogo.png,中间FluentIcon \uE00C25×25(Margin=16 0),右FluentIcon \uEEF175×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)。
- 顶部图标行:
- 主界面:
- 左上
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)。
- 左上
- 右侧详情(
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)。
- 标题行:名称
- 删除必须可撤销:删除工作流后弹
ToastMessage,文案"已删除自动化“{名称}”。",Duration=10 秒,带「撤销」按钮;撤销时把工作流插回原索引并重新选中(AutomationSettingsPage.axaml.cs:39-67)。 - 复制工作流:JSON 深拷贝后
ActionSet.Guid = Guid.NewGuid(),插入到原项之后并选中(69-78)。 - 新建工作流:
ActionSet = { Name: "新自动化", IsRevertEnabled: true }(注意:新建默认开启恢复),插入到当前选中项之后并展开详情(117-133)。 - 新建配置方案:
ContentDialog标题"新建自动化配置方案",输入框标签"配置方案名称",后缀.json;路径已存在则中止;创建后刷新列表并把CurrentAutomationConfig设为新名称(87-115)。
8.11 边界与异常(必须处理)
- 总开关关闭:所有触发路径第一行判断,直接返回;不装载触发器(安全模式同理)。
- 行动组未启用:触发时跳过。
- 恢复开关与重复触发:
IsRevertEnabled && Status != Normal时不再执行,防止重复叠加(例如反复「修改设置」)。 - 规则集未启用:
IsConditionEnabled=false→ 忽略规则集,永远执行。 - 规则集为空(
Groups.Count == 0)→ 视为 false;组内无有效规则 → 三值null(不参与逻辑)。 - 未注册规则 / 处理程序异常 → false + Warning 日志,绝不抛出。
- 未知触发器 ID:实例化为 null,触发器被静默跳过(
LoadTrigger347-350 返回),配置保留不丢。 - 未知行动 ID:先迁移,仍未知则跳过(
InvokeActionItemAsync96-98)。 - 单个行动异常:记日志并继续后续行动;异常文本写进
ActionItem.Exception供界面展示。 - 长时行动的取消:
Wait/Sleep/命令执行必须响应InterruptCancellationToken;命令被中断要杀进程树。 - 用户在执行过程中编辑行动列表:按 8.4.4 的可变列表迭代算法处理。
- 重启后状态恢复:
ActionSet.Status == IsOn必须落盘(8.3 第 8 条),重启后仍可被规则集变化触发「恢复」。 - 时间点行动重复执行:靠
LastActionRunTime单调推进避免;应用时间被大幅回拨时不得重复执行历史时间点(StartTime > LastActionRunTime.TimeOfDay保证)。 - 持久化失败:配置写盘失败必须保留内存状态并在 UI 提示,不得因保存失败导致触发器被卸载。
8.12 验收标准
- 首次进入自动化设置页看到「使用须知」,点「同意并开始使用自动化」后
IsAutomationEnabled=true、IsAutomationWarningVisible=false,刷新页面后不再出现。 - 新建自动化默认名称
"新自动化"且「启用恢复」为勾选状态;复制出来的自动化ActionSet.Guid与原项不同。 - 建一条「上课时 → 显示提醒」的工作流:到上课时刻必须弹出提醒;把「启用恢复」打开并加规则集(例如「当前时间状态是 课间」),当状态变为课间时行动必须被恢复(若行动可恢复)。
- 12 个触发器按本节表格的 ID/名称/图标出现在「添加触发器」列表中,且参数默认值一致(
preTimePoint.TimeSeconds=60、cron="* * * * *"、trayMenu.Header=""等)。 - 8 个行动按本节表格出现在「添加行动」菜单中;「添加行动」菜单必须含分组「应用设置 / 运行 / 提醒 / ClassIsland」,且「应用设置」组只有 4 个快捷项(选择应用设置、组件配置方案、应用主题、窗口停靠位置)。
- 运行「cmd 命令」失败时,
ActionItem.Exception包含退出码与标准输出/错误输出(各截断到 2000 字符)。 - 「等待时长」行动的进度条在 0~100 之间单调变化,改设置立即按新目标重算。
- 规则集:And/Or 与三层反转(规则、组、规则集)组合 8 种情况结果正确;空规则集返回 false;未注册规则返回 false 且只记一条 Warning。
- 天气模糊匹配:
21与7判定为匹配(交集),21与301判定为匹配(301集合含21),21与302不匹配。 - 时间点行动:把某时间点类型设为「行动」并挂行动组后,到点执行一次;重启应用后不补执行到期时间点。
- 删除工作流后可点 Toast 上的「撤销」恢复到原位置,且触发器重新生效。
- 任何单条行动抛异常时,后续行动仍被执行,日志中可见
"触发{行动项}时出现错误。"。