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

54 KiB
Raw Blame History

6. 课程表 / 时间表 / 档案 / 科目 / 周次 / 调课 / 导入导出

本节规定「档案(Profile)—时间表(TimeLayout)—课表(ClassPlan)—课程(ClassInfo)—科目(Subject)」这一整条业务链的完整实现规格。技术栈按 ClassHelper 的 Electron + Vue 3 + TypeScript 实现:主进程负责文件读写与计算,渲染进程负责编辑界面;数据结构必须以 JSON 落盘,字段命名沿用本节给出的名字,不得自行改名。

6.1 数据结构(必须逐字段实现)

档案是一个 JSON 文件,存放在 Profiles/ 目录下,默认文件名 Default.json(repos/ClassIsland/ClassIsland/Models/Settings.cs:66:_selectedProfile = "Default.json";repos/ClassIsland/ClassIsland/Services/ProfileService.cs:48:ProfilePath = 应用根目录/Profiles)。档案根对象必须包含以下字段(repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:17-32):

字段 类型 默认值 说明
Name string "" 档案名
TimeLayouts Record<Guid, TimeLayout> {} 时间表字典
ClassPlans Record<Guid, ClassPlan> {} 课表字典
Subjects Record<Guid, Subject> {} 科目字典
ClassPlanGroups Record<Guid, ClassPlanGroup> 2 个内置项,见 6.1.6 课表群字典
SelectedClassPlanGroupId Guid ACAF4EF0-E261-4262-B941-34EA93CB4369 当前启用的课表群
TempClassPlanGroupId Guid? null 临时课表群
TempClassPlanGroupExpireTime DateTime DateTime.Now 临时课表群失效日期
IsTempClassPlanGroupEnabled bool false 临时课表群开关
TempClassPlanGroupType 0/1 1(Inherit) 0=覆盖 Override,1=继承 Inherit
TempClassPlanId Guid? null 临时课表(当天)
TempClassPlanSetupTime DateTime DateTime.Now 临时课表设置时间
IsOverlayClassPlanEnabled bool false 临时层课表总开关
OverlayClassPlanId Guid? null 当前临时层课表
OrderedSchedules Record<"yyyy-MM-dd", { ClassPlanId: Guid }> {} 预定课表(按日期)
Id Guid Guid.NewGuid() 档案 ID,用于「信任档案」

TempClassPlanGroupType 的两值语义必须严格按源码实现(repos/ClassIsland/ClassIsland.Shared/Enums/TempClassPlanGroupType.cs:6-16):Override 表示「覆盖现在启用的课表群」,Inherit 表示「继承现在启用的课表群;临时课表群中没有满足规则的课表时,回退到当前启用的课表群中查找」。

6.1.1 时间表 TimeLayout

{
  "Name": "新时间表",                       // 默认值,Profile.cs 同级模型 TimeLayout.cs:17
  "Layouts": [ TimeLayoutItem, ... ],       // 有序数组,按 StartTime 升序维护
  "IsOverlay": false,                       // 是否临时层时间表
  "OverlaySourceId": null                   // 临时层来源时间表 ID
}

IsActivated、IsActivatedManually 是运行期状态,[JsonIgnore],不得写入 JSON(repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayout.cs:272-297)。

6.1.2 时间点 TimeLayoutItem

字段 类型 默认值 说明
StartTime string(HH:mm:ss) 00:00:00 时间点开始
EndTime string 00:00:00 时间点结束;TimeType 为 2 或 3 时强制等于 StartTime
TimeType int 0 0=上课,1=课间,2=分割线,3=行动
IsHideDefault bool false 默认隐藏
DefaultClassId Guid Guid.Empty 默认科目 ID
BreakName string "" 自定义课间名;为空时显示 BreakNameText("课间休息")
ActionSet ActionSet? null 仅当 TimeType=3 时使用,见第 8 节

证据:repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayoutItem.cs:13-21,62-93,119-138,143-191,196-205。Last(时长)= EndTime - StartTime,[JsonIgnore](同文件 113-117)。序列化时必须使用 HH:mm:ss 文本,禁止用「一天内秒数」数字(旧字段 StartSecond/EndSecond 已 [Obsolete(..., true)],见同文件 26-57,读取旧档案时需要迁移)。

6.1.3 课表 ClassPlan

字段 类型 默认值 说明
Name string "新课表" 课表名
TimeLayoutId Guid Guid.Empty 引用 Profile.TimeLayouts 的键
Classes ClassInfo[] [] 与时间表中 TimeType=0 的时间点一一对应、同序
TimeRule TimeRule 见 6.1.4 触发规则
IsEnabled bool true 是否自动启用
IsOverlay bool false 是否临时层课表
OverlaySourceId Guid? null 临时层源课表
OverlaySetupTime DateTime DateTime.Now 临时层日期
AssociatedGroup Guid ACAF4EF0-E261-4262-B941-34EA93CB4369 所属课表群

证据:repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:15-27,393-607。IsActivated、ValidTimeLayoutItems、LastTimeLayoutCount、ClassPlans、TimeLayouts、TimeLayout 均为运行期或引用属性,必须 [JsonIgnore](同文件 35-46,110-120,357-391,427-435,527-537)。

Classes 与时间点的同步规则必须实现(repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:451-491):

  1. 统计 TimeLayout.Layouts 中 TimeType==0 的个数 l。
  2. Classes.Count < l 时用空 ClassInfo 补齐;Classes.Count > l 时从尾部删除多余项。
  3. 逐项写入 Index 与 CurrentTimeLayout(运行期字段)。
  4. 若 Classes[i].SubjectId == Guid.Empty 且对应时间点 DefaultClassId != Guid.Empty,则把 DefaultClassId 写入 SubjectId。
  5. 更新 LastTimeLayoutCount = TimeLayout.Layouts.Count。

6.1.4 课表触发规则 TimeRule

字段 类型 默认值 语义
WeekDay int 0 (int)DayOfWeek,0=周日、1=周一……6=周六。注意默认是周日,因为 private int _weekDay = new(); 即 0
WeekCountDiv int 0 0=不轮换;n=第 n 周启用
WeekCountDivTotal int 2 轮换总周数(2=单双周)

证据:repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeRule.cs:13-16,20-69。

6.1.5 课程 ClassInfo

{ "SubjectId": Guid, "IsChangedClass": bool, "IsEnabled": true }。SubjectId 默认 Guid.Empty;Index、CurrentTimeLayout、CurrentTimeLayoutItem、IsEmpty 都是运行期字段,不得落盘(repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassInfo.cs:12-113)。IsChangedClass 表示「临时换课」标记,仅在临时换课模式下由界面写入。

6.1.6 课表群 ClassPlanGroup 与科目 Subject

课表群 { "Name": "新课表群", "IsGlobal": false },两个内置群的 GUID 必须硬编码(repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlanGroup.cs:10-11,44-49):

  • 默认课表群:ACAF4EF0-E261-4262-B941-34EA93CB4369,名称 "默认"。
  • 全局课表群:Guid.Empty(全 0),名称 "全局课表群",IsGlobal=true。

档案构造时必须保证这两个群存在(repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:44-59)。解散课表群时把其中的课表移回默认群;删除课表群时连同课表一起删除;对默认群与全局群执行这两种操作必须抛出异常(Profile.cs:104-137)。

科目 { "Name": "", "Initial": "", "TeacherName": "", "IsOutDoor": false }(repos/ClassIsland/ClassIsland.Shared/Models/Profile/Subject.cs:10-14)。必须实现三条内置常量科目:

  • Fallback:{ Initial: "?", Name: "???" }(Subject.cs:79-83)——课程服务找不到科目时的兜底,界面上必须先判断是否等于它(例如「下节课是 ???」代表没有下一节课)。
  • Empty:{ Initial: "", Name: "" }(Subject.cs:86-92)。
  • Breaking:{ Initial: "休", Name: "课间休息" }(Subject.cs:95-101)——课间状态时 CurrentSubject 指向它,并把 Name 覆写为当前课间名。

Subject.Name 的 setter 必须实现「Initial 为空且新 Name 非空白时,自动取 Name 首字符写入 Initial」(Subject.cs:19-30)。GetFirstName() 必须实现教师姓氏提取(Subject.cs:107-140):含中文时优先匹配复姓表(两字),否则取首字;不含中文时取按空格切分后的最后一个单词;未填教师名返回空串。

6.2 当天应使用哪张课表:选择算法(必须逐分支实现)

每次主计时器 tick(默认 50ms,见 6.3)都要重新计算「当前课表」。优先级严格按以下顺序,命中即返回(repos/ClassIsland/ClassIsland/Services/LessonsService.cs:143-201):

  1. 预定课表:Profile.OrderedSchedules[今天] 存在,且对应 ClassPlan 存在,且(该课表不是临时层 或 IsOverlayClassPlanEnabled)→ 返回它。
  2. 临时课表:Profile.TempClassPlanId != null 且存在该课表,且 TempClassPlanSetupTime.Date >= 今天 → 返回它。
  3. 常规课表:按下列过滤 + 排序后取第一个:
    • 分组匹配:AssociatedGroup 等于 ClassPlanGroup.GlobalGroupGuid 或 SelectedClassPlanGroupId;若启用临时课表群且未过期,再按类型处理——Inherit:默认群 ∪ 临时群 ∪ 全局群;Override:临时群 ∪ 全局群。
    • 组优先级排序(必须用 OrderByDescending):临时课表群=3 > 当前选中课表群=2 > 全局课表群=1 > 其他=0。
    • 对每张候选课表执行 CheckClassPlan。

CheckClassPlan(plan, date) 的判定顺序(LessonsService.cs:582-607):

  1. plan.IsOverlay == true → 不匹配。
  2. plan.IsEnabled == false → 不匹配。
  3. plan.TimeRule.WeekDay != (int)date.DayOfWeek → 不匹配。
  4. AssociatedGroup 既不是全局群、也不是当前选中群、也不是临时群 → 不匹配。
  5. WeekCountDivTotal > Settings.MultiWeekRotationMaxCycle → 不匹配(防止越过最大周期)。
  6. WeekCountDiv == 0 → 匹配。
  7. 否则计算 GetCyclePositionsByDate(date)[WeekCountDivTotal],等于 WeekCountDiv 才算匹配。

实现时注意:若一张课表都没匹配上,CurrentClassPlan 必须为 null,且所有运行期状态回到「无课表」语义(CurrentState=None、CurrentSubject=Fallback、CurrentTimeLayoutItem=Empty),不得抛异常。

6.3 时间状态机与课程计算(主循环)

必须起一个周期 50ms、DispatcherPriority.Render 优先级的计时器(repos/ClassIsland/ClassIsland/Services/LessonsService.cs:39-45),每个 tick 依次触发 PreMainTimerTicked → 计算 → PostMainTimerTicked(同文件 373-388)。在 Electron 实现中映射为:主进程用 50ms setInterval 或(建议)requestAnimationFrame+定时器混合,通过 IPC 广播 tick 事件;渲染进程不得自己做第二套计时。

每次 tick 的计算步骤(LessonsService.cs:390-527):

  1. 刷新档案引用、清理过期数据:TempClassPlanSetupTime.Date < 今天 → TempClassPlanId = null;清理过期预定/临时层;清理过期临时课表群(见 6.7)。
  2. 若 IsClassPlanEnabled == false → CurrentClassPlan = null 并结束。
  3. 取当前课表;若当天预定课表本身是临时层 → OverlayClassPlanId = 该课表,否则置 null。
  4. 取 now = 当前本地时间(以校时服务为准).TimeOfDay。
  5. 当前时间点 = 有效时间点列表中第一个满足 TimeType ∈ {0,1} 且 StartTime <= now <= EndTime 的项。
  6. 若命中且 TimeType==0:CurrentState=OnClass;按「上课时间点在教学点中的序号」映射到 Classes[i],取 SubjectId 对应科目填入 CurrentSubject(映射算法:把 TimeLayout.Layouts 中 TimeType==0 的项按序建数组,取当前项的下标,见 LessonsService.cs:531-541)。
  7. 若命中且 TimeType==1:CurrentSubject = Subject.Breaking,并把其 Name 设为 CurrentTimeLayoutItem.BreakNameText;CurrentState=Breaking;IsLessonConfirmed=true。
  8. 下节课 = 有效时间点中第一个 TimeType==0 && EndTime >= now 的项;有则按步骤 6 的方法取科目写入 NextClassSubject。
  9. 下个课间 = 有效时间点中第一个 TimeType==1 && EndTime >= now 的项。
  10. 剩余时间:OnClass 状态下算 OnBreakingTimeLeftTime = 下个课间.StartTime - now;否则算 OnClassLeftTime = 下节课.StartTime - now;两种时间都必须下限为 0(LessonsService.cs:529)。
  11. 若「下节课」和「下个课间」都为 null → CurrentState = AfterSchool。
  12. 把结果统一写回所有属性(没算出的用默认值:-1、None、Fallback、Empty、Zero、false)。
  13. 状态变化事件:与上一次状态比较,不同才发事件;顺序固定为「先 CurrentTimeStateChanged,再按新状态发 OnClass / OnBreakingTime / OnAfterSchool 之一」。

必须同时实现的两个派生属性:

  • CurrentStatusIsBreaking(课间)与 IsLessonConfirmed:仅当命中当前时间点时置 true,否则 false。提醒系统用它避免重复提醒(见第 7 节)。
  • ValidTimeLayoutItems(有效时间点):仅保留 TimeType ∈ {0,1,2} 的时间点,然后按「课程是否启用」做正向 + 反向两遍裁剪(repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:54-98):正序时一旦遇到 ClassInfo.IsEnabled=false,其后所有时间点都移除;逆序时一旦遇到 IsEnabled=false,其前所有时间点都移除。效果是「禁用一节早课,则它前面所有时间点都不显示;禁用一节晚课,则其后所有时间点都不显示」。必须实现该双向裁剪,否则「课程表」组件与倒计时会与真实课表不一致。

6.4 周次轮换(单双周 / 多周)

必须实现「基准周 + 周期 + 每周期偏移」三件套:

  • Settings.SingleWeekStartTime:单周起始日,默认取今天所在周的周日 DateTime.Today.AddDays(-(int)Today.DayOfWeek)(repos/ClassIsland/ClassIsland/Models/Settings.cs:37-42)。
  • Settings.MultiWeekRotationMaxCycle:最大轮换周期,默认 4(Settings.cs:61)。
  • Settings.MultiWeekRotationOffset:整型数组,默认 [-1, -1, 0, 0, 0](Settings.cs:60);下标 = 周期长度,值 = 偏移周数;下标 0、1 固定 -1 占位。

计算函数必须逐字实现(repos/ClassIsland/ClassIsland/Services/LessonsService.cs:619-635):

totalElapsedWeeks = floor((date.Date - SingleWeekStartTime.Date).TotalDays / 7)
positions = [-1, -1]
for cycleLength in 2..MultiWeekRotationMaxCycle:
    offset = MultiWeekRotationOffset[cycleLength] ?? 0
    pos = (totalElapsedWeeks + offset) % cycleLength
    if pos < 0: pos += cycleLength
    positions[cycleLength] = pos + 1      // 1-based

注意返回值是 2-first、1-based 的数组(注释原文:2-first, 1-based,LessonsService.cs:614),CheckClassPlan 直接用 positions[WeekCountDivTotal] 与 WeekCountDiv 比较。

UI 侧「调整多周轮换偏移」必须反向求解偏移(对称逻辑,repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:99-113):

totalElapsedWeeks = floor((today.Date - SingleWeekStartTime).TotalDays / 7)
for cycleLength in 2..max:
    cycleOffset = (用户选择的 0-based 位置 - totalElapsedWeeks) % cycleLength
    if cycleOffset < 0: cycleOffset += cycleLength
    MultiWeekRotationOffset[cycleLength] = cycleOffset

「清除」按钮把 MultiWeekRotationOffset 清空(数组为空),随后重新读取时按 GetValueOrDefault → 0 处理(同文件 115-119)。

轮换 UI 必须按此渲染(repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:41-90):从 2 到 MultiWeekRotationMaxCycle 每个周期生成一行;2 周时标题为 "单双周:",其余为 "{N}周轮换:"(N 用中文数字);2 周时选项为 ["单周","双周"],否则为 ["1/N周","2/N周",...];控件 MaxWidth=400,行间距 Margin=0,8,0,0、Spacing=4。

课表编辑界面上的触发规则必须表现为两行(repos/ClassIsland/ClassIsland/Controls/TimeRuleEditControl.axaml:15-52):

  1. 第一行文案 "每 [周期] 的 [第几周]"——只有 WeekCountDiv>0 时两个下拉框才可交互,否则整体淡化到 Opacity=0.60。
  2. 第二行文案 "当今天是:" + 7 个 Chip 按钮(UniformGrid Columns=7),项为 周日 周一 周二 周三 周四 周五 周六 的顺序(与 time.DayOfWeek 的 0..6 对齐)。

6.5 时间表编辑(档案编辑窗口 · 时间表页)

窗口基础参数必须照抄:标题 "档案编辑"、MinWidth=750、Width=1100、Height=600、EnableMicaWindow=True(repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml:23-30)。左侧为主标签页,索引与内容固定为:0=课程表、1=时间表、2=科目、3=禁止事项(forbidden)、4=调课/日程调整、5=导入导出(ProfileSettingsWindow.axaml.cs:246-261)。右侧详情面板宽度 332(ProfileSettingsWindow.axaml:53,202)。

新增时间点的完整规则必须实现(repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml.cs:983-1099):

  1. 基准时间 baseSec:若已选中时间点,上课/课间取「选中项 EndTime」,分割线/行动取「选中项 StartTime」;若未选中,默认 08:00:00(源码注释:按规定中学最早上课不得早于 8:00,见 1006-1009)。
  2. 默认时长:上课 = Settings.DefaultOnClassTimePointMinutes(默认 40 分钟);课间 = Settings.DefaultBreakingTimePointMinutes(默认 10 分钟);分割线与行动 = 0(Settings.cs:108-109,651,662)。
  3. 插入位置:遍历现有时间点,插入到第一个 StartTime > 新项 StartTime 的位置之前;没有则追加到末尾(989-998)。
  4. 空间不足的处理(1023-1078,必须逐条实现):
    • 若下一个非分割线时间点的 StartTime <= baseSec:当选中项不是第 0 项 → 提示 "没有合适的位置来插入新的时间点。" 并放弃;当选中项是第 0 项 → 允许向前插:baseSec = 选中项.StartTime - 默认时长,并提示 "已向前插入了新的时间点。"。
    • 若下一个时间点起点早于 baseSec + 默认时长:把时长截断为 next.StartTime - baseSec,提示 "没有足够的空间完全插入该时间点,已缩短时间点长度。"。
    • 分割线:baseSec = 选中项.EndTime;若该时刻已存在分割线 → 警告 "这里已经存在一条分割线。" 并放弃。
    • 行动:baseSec = 选中项.EndTime;若该时刻已存在行动 → 警告 "这里已经存在一个行动。" 并放弃。
  5. 新项 ActionSet:仅 TimeType==3 时创建 new ActionSet(),其余为 null。
  6. 插入后选中新项、滚动居中,并压入撤销栈。

必须实现撤销/重做栈(ProfileSettingsWindow.axaml.cs:54-56,874-981):UndoEntry(IsAdd, Item, Layout, Index, Description) 记录「新增/删除 + 项 + 所属时间表 + 索引 + 描述」,撤销/重做在 UI 上以历史列表呈现,支持「撤销到第 N 步」(UndoToIndex / RedoToIndex)。撤销新增 = 删除该项;撤销删除 = 在原索引插回。

删除时间点必须同步删除对应课程:ClassPlan.RemoveTimePointSafe 会先移除所有 CurrentTimeLayoutItem == 该时间点 的课程,再 RefreshClassesList()(repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:515-522)。

「应用到所有课表」功能:Profile.OverwriteAllClassPlanSubject(timeLayoutId, timePoint, subjectId)——遍历所有 TimeLayoutId 相同课表,RefreshClassesList() 后把命中该时间点的课程 SubjectId 全部改写(Profile.cs:85-95)。

时间表刻度尺与缩放:时间线列表必须支持缩放(源码有 ButtonZoomIn/ButtonZoomOut,ProfileSettingsWindow.axaml.cs:1290-1311),缩放范围与步长属实现细节【推断:源码未给出常量,建议 0.5x–3x、步长 0.1】。

6.6 课表编辑页与「换课 / 调课 / 临时层」

课表编辑页右侧面板(ProfileSettingsWindow.axaml:53-140)必须包含:

  • 名称 输入框。
  • 时间表 下拉框:ItemsSource = TimeLayouts,SelectedValue = ClassPlan.TimeLayoutId,项文本 = Value.Name,ToolTip = 时间表 GUID。
  • 课表群 下拉框:SelectedValue = ClassPlan.AssociatedGroup。
  • 自动启用 复选框(IsChecked = IsEnabled);当 IsOverlay=true 时该复选框禁用,并显示提示条 "这是一个临时层课表,将在使用过后自动删除。"。
  • 三态说明文本(必须按 IsEnabled/IsOverlay 组合显示):IsEnabled&&!IsOverlay → "将会根据触发规则自动启用该课表。";!IsEnabled&&!IsOverlay → "该课表不会自动启用,只能通过临时课表或临时层手动启用该课表。";IsOverlay → "临时层课表将忽略触发规则强制启用。"(ProfileSettingsWindow.axaml:88-111)。
  • TimeRuleEditControl(触发规则),仅当 !IsOverlay && IsEnabled 时可用(113-116)。
  • 更多选项:附加设置列表(IAttachedSettingsHostService.ClassPlanSettingsAttachedSettingsControls)。

换课(修改单节课的科目):点击课程单元格右侧按钮弹出 Flyout(ProfileSettingsWindow.axaml:415-446),结构固定:

  1. 标题 "修改课程"(Subtitle 样式,下边距 8)。
  2. 换课成… 下拉框:ItemsSource = Subjects,SelectedValue = TargetSubjectIndex(Guid)。
  3. 复选框 "永久换课":IsChecked = !IsTempSwapMode(注意取反绑定),受集控策略 DisableProfileClassPlanEditing 约束。
  4. 确定 按钮(Accent 样式):当 TargetSubjectIndex == Guid.Empty 时禁用。
  5. 确认逻辑(ProfileSettingsWindow.axaml.cs:1531-1550):先 GetTargetClassPlan(date, IsTempSwapMode, out guid) 取得目标课表;写入 Classes[index].SubjectId = TargetSubjectIndex;若 IsTempSwapMode 为真,同时置 Classes[index].IsChangedClass = true;最后刷新日程。

调课(两节课互换) 必须实现两段式交互(ProfileSettingsWindow.axaml.cs:1435-1477):

  1. 点「开始换课」记录起点 ClassSwapStartPosition = (date, index),进入换课模式;若选中课程为空(IsEmpty)→ 提示 "选择课程区域无效。" 并结束。
  2. 点「完成换课」记录终点,再用 GetTargetClassPlan 分别取起点/终点课表;任一为空或索引越界 → 直接返回(不报错、不改数据)。
  3. 交换两个 SubjectId(元组解构赋值一次完成)。
  4. 临时换课模式下,两侧都置 IsChangedClass = true。
  5. 刷新日程视图。可随时「取消换课」退出模式。

临时换课的目标课表解析 GetTargetClassPlan(date, overlay, out guid)(ProfileSettingsWindow.axaml.cs:1479-1506)必须按此实现:

  1. LessonsService.GetClassPlanByDate(date, out baseGuid);为空 → 返回 null。
  2. 若 !overlay 或 baseClassPlan.IsOverlay → 直接返回当前课表。
  3. 若该日期已有 OrderedSchedules[date] 且其课表是临时层 → 返回当前课表。
  4. 否则调用 CreateTempClassPlan(baseGuid, enableDateTime: date) 创建「当天临时层」,返回新课表(失败返回 null)。

创建临时层 CreateTempClassPlan(id, timeLayoutId?, enableDateTime?)(repos/ClassIsland/ClassIsland/Services/ProfileService.cs:235-280):

  1. 若该日期已有临时层预定 → 返回 null(不允许重复创建)。
  2. 用 JSON 深拷贝源课表(必须经序列化/反序列化,保证引用隔离)。
  3. 可选:同时深拷贝时间表,置 IsOverlay=true、OverlaySourceId=源时间表ID、Name += "(临时层)",注册进 TimeLayouts,并把新课表的 TimeLayoutId 指向它。
  4. 新课表:IsOverlay=true、OverlaySourceId=源课表ID、Name += "(临时层)"、OverlaySetupTime=日期。
  5. Profile.IsOverlayClassPlanEnabled = true、Profile.OverlayClassPlanId = 新ID、写入 ClassPlans、OrderedSchedules[date] = { ClassPlanId: 新ID }。

临时层与普通课表互转:ConvertToStdClassPlan(id) 把 IsOverlay=false,若其时间表也是临时层则一并转正并去掉名称中的 "(临时层)" 后缀(ProfileService.cs:344-360)。

过期清理 CleanExpiredTempClassPlan() 必须每 tick 调用(ProfileService.cs:295-328):

  1. 删除所有 OrderedSchedules 中日期 < 今天的项。
  2. 收集现存的 OrderedSchedules[].ClassPlanId,删除所有 IsOverlay 但未被引用的课表。
  3. 汇总「未过期课表」引用的 TimeLayoutId 集合,删除所有 IsOverlay 但未被引用的时间表。

「加载临时课表…」右键菜单:主界面右键菜单项 "加载临时课表…"(repos/ClassIsland/ClassIsland/MainWindow.axaml:101-103)打开选择器,写入 TempClassPlanId 与 TempClassPlanSetupTime;次日自动失效(见 6.3 步骤 1)。

临时课表群 SetupTempClassPlanGroup(key, expireTime?)(ProfileService.cs:362-390)必须实现到期日推算:

today = now; dow = today.DayOfWeek
dw = floor((today.Date - SingleWeekStartTime.Date).TotalDays / 7) + 1
dayOffset = 0
for classPlan in 该群所有课表:
    w = dw % WeekCountDivTotal
    baseOffset = WeekDay - dow
    divOffset = (WeekCountDiv + WeekCountDivTotal - w) % WeekCountDivTotal
    finalOffset = baseOffset + divOffset*7
    if finalOffset < 0: finalOffset += 7
    dayOffset = max(dayOffset, finalOffset)
TempClassPlanGroupExpireTime = expireTime ?? today.Date + dayOffset 天
TempClassPlanGroupId = key; IsTempClassPlanGroupEnabled = true

课表群管理面板必须提供:临时课表群名称与「清除」按钮、启用 ToggleSwitch、失效日期 CalendarDatePicker(绑定 TempClassPlanGroupExpireTime)、覆盖模式 下拉(选项顺序 覆盖、继承,对应 0=Override、1=Inherit)、新建课表群 按钮(受集控策略禁用)、课表群列表(ProfileSettingsWindow.axaml:342-413)。

6.7 档案管理(多档案)

Profiles/ 下每个 *.json 即一个档案。档案列表必须只显示 .json 文件(ProfileSettingsWindow.axaml.cs:297-303)。管理操作与约束:

  • 切换档案:写入 Settings.SelectedProfile = 文件名,必须重启应用生效(界面文案 "切换档案需要重新启动应用",ProfileSettingsWindow.axaml:143)。
  • 新建档案:输入名称 → 目标路径 Profiles/{名称}.json;已存在则中止;创建后写入默认档案(含 Assets/default-subjects.json 内置默认科目)。
  • 重命名 / 复制 / 删除:删除前必须二次确认;删除当前档案要阻止或提示【推断:源码使用通用确认对话框,未特判当前档案】。
  • 保存:档案属性变更即触发保存(Profile.PropertyChanged += SaveProfile,ProfileService.cs:164);关闭档案窗口时再保存一次(ProfileSettingsWindow.axaml.cs:264-274,e.Cancel = true 后 Hide(),即窗口只隐藏不销毁)。
  • 信任档案:档案 ID 写入 Settings.TrustedProfileIds 后才允许自动化「时间点行动」等敏感行为(ProfileService.cs:166-176,417-422;LessonsService 之外,AutomationService.cs:91 首先检查 IsCurrentProfileTrusted)。首次加载未信任档案必须显示「信任」入口。
  • 集控模式:档案文件名固定为 _management-profile.json;依次拉取课表、时间表、科目三份档案并按版本号合并进本地档案,合并后深拷贝并 RefreshTimeLayouts()(ProfileService.cs:78-137,160-163)。

Profile.RefreshTimeLayouts() 必须每次把档案级字典注入到每张课表(TimeLayouts、ClassPlans 引用),否则课表拿不到时间表(Profile.cs:272-279,LessonsService.cs:551 每 tick 调用一次)。Electron 实现中把它做成「档案 store 的 hydrate 步骤」,在加载与任何课表增删后执行。

6.8 科目管理

科目页(主标签 2)必须提供:新增、复制、删除科目;每项字段 名称、简称(Initial)、教师名(TeacherName)、是否户外课程(IsOutDoor)。

必须实现的行为:

  • 新增科目时默认名 "";用户输入名称后自动补 Initial(见 6.1.6)。
  • 开启「编辑后自动跳到下一节」:Settings.IsProfileEditorClassInfoSubjectAutoMoveNextEnabled,默认 true(Settings.cs:181)。
  • 删除科目时必须把引用它的课程 SubjectId 置空或提示,不得留下悬空 GUID【推断:源码删除逻辑直接移除科目条目,课程引用会退化为 Guid.Empty,显示为「???」;实现时建议删除前提示引用数量】。
  • 科目选择器在课表表格中以「科目简称 + 科目名」呈现【推断:ScheduleDataGrid 内部实现,未在本次读取范围内给出确切模板】。

6.9 导入 / 导出

当前版本实际可用的导入导出入口在档案窗口的「导入导出」页(主标签 5),按类型分组渲染:导入组、导出组,两组之间加分隔线,每个提供方一个 NavigationViewItem(ProfileSettingsWindow.axaml.cs:1565-1627)。注册表(repos/ClassIsland/ClassIsland/App.Services.xaml.cs:292-296)为:

ID 名称 类型
classisland.profileTransfer.import.cses 从 CSES 导入 Import
classisland.profileTransfer.import.legacyV1 从 ClassIsland 1.x 导入 Import
classisland.profileTransfer.import.classWidgets 从 Class Widgets 1.2 导入 Import
classisland.profileTransfer.export.cses 导出到 CSES Export

本实现必须在该页额外提供「Excel 导入」「Excel 导出」两项(P1,见 6.9.1):原版这两项受实验开关 Settings.ExpIsExcelImportEnabled(默认 false)控制且实现已废弃,本实现必须默认可见(或以独立向导入口提供),同时保留该设置字段做配置兼容;不得删除 CSES / 1.x / Class Widgets 三项 P0 入口。

导入提供方统一交互:选择源文件 → 选择「合并到当前档案」或「导入为新档案」→ 执行;导入为新档案时若目标 Profiles/{名称}.json 已存在必须报错中止(CsesImportProvider.cs:51-59);成功 Toast("导入成功。"),失败记录日志并 Toast("无法导入 CSES 课表", exception)。CSES 文件类型过滤为 *.yml、*.yaml,头部文案 "CESE 文件路径"(CsesImportProvider.cs:30-40,注意源码拼写为 CESE,实现时保持可配置以便修正)。

CSES → ClassIsland 转换算法(P0,必须实现)(repos/ClassIsland/ClassIsland.Shared/Extensions/CsesExtensions.cs:23-119,必须逐条实现):

  1. 目标档案 = 合并档案或新建档案;建立「科目名 → 科目 Guid」缓存,先用目标档案已有科目名填充。
  2. 遍历 CSES Subjects:名字已存在则复用 Guid,否则新建 Subject;写入 Name;SimplifiedName 非空才覆盖 Initial;Teacher 非空才覆盖 TeacherName。
  3. 遍历 CSES Schedules:找一张「上课时间点数量与 i.Classes.Count 相等,且每个上课时间点的 StartTime/EndTime 全部与 CSES 一致」的时间表;找不到则新建时间表。
  4. 为每节课建立 ClassInfo { SubjectId };若是新时间表,则同时生成时间点:每节课一个 TimeType=0(起止取 CSES 的起止),相邻两节之间补一个 TimeType=1(前一节结束 → 后一节开始)。
  5. 课表:TimeLayoutId 指向该时间表;TimeRule.WeekCountDiv = i.Weeks;TimeRule.WeekDay = (int)i.EnableDay;Name = i.Name;新 Guid 注册进 ClassPlans。

ClassIsland → CSES 导出算法(P0,必须实现)(CsesExtensions.cs:127-166):

  1. 把目标档案中不存在(按名称比对)的科目追加进 CSES 科目,写 Name/SimplifiedName/Teacher。
  2. Profile.RefreshTimeLayouts() 后,只导出 TimeLayout != null && IsEnabled && WeekCountDivTotal <= 2 && WeekCountDiv <= 2 的课表(即单双周以内且已启用的课表)。
  3. 每张课表导出为一个 Schedule { Name, Weeks = (WeekType)WeekCountDiv, EnableDay = (DayOfWeek)WeekDay },并按 Classes 顺序填 ClassInfo { StartTime, EndTime, Subject },Subject 取科目名,找不到时写空串。

6.9.1 Excel 导入 / 导出(P1:必须实现,用 xlsx 重建)

定位与来源边界:ClassIsland 2.1 源码中两个 Excel 窗口整体被 #if false 屏蔽(repos/ClassIsland/ClassIsland/Views/ExcelImportWindow.xaml.cs:1、repos/ClassIsland/ClassIsland/Views/ExcelExportWindow.xaml.cs:1,539),DI 注册被注释(App.Services.xaml.cs:145,204),且仓库内已不存在 ExcelImportViewModel / ExcelExportViewModel 类型(全仓检索无命中,属不可编译的遗留骨架)。因此:

  • 本实现必须独立重建该功能(P1,不是可选项):在主进程使用 xlsx(SheetJS) 读写 .xlsx,用 fs 存盘、shell.openPath 打开文件;不得引入 unvell.ReoGrid、OfficeOpenXml、WPF 控件或依赖 ClassIsland 的窗口实现。
  • 源码里的 ExcelImportWindow.xaml.cs / ExcelExportWindow.xaml.cs 只作为布局与交互参考(步骤机、拖放、单元格选择、导出参数),不得整段移植。
  • 导入/导出向导的 UI(步骤、表单、预览面板)归 Part 11(windows 负责);本节只规定数据契约、列格式、映射算法、往返一致性与错误校验,Part 11 的向导必须调用本节定义的契约层(建议单独 excel-io.ts 模块,纯函数、可单测)。

文件与工作表约定:

  1. 文件扩展名 .xlsx;默认工作表 课表(第 1 个 sheet)。
  2. 可选第 2 个 sheet 时间表,用于承载时间点(保证往返可还原时间表,见 6.9.1.5)。
  3. 兼容「单 sheet 模式」:只有 课表 时,导入必须要求用户在向导里另行选择/导入时间表(对应源码 ImportTimeLayoutOnly 流程,ExcelImportWindow.xaml.cs:388-397)。

课表矩阵列格式(必须按此实现与校验):

位置 内容
A1 空或标题(导入忽略)
B1..H1 星期名,顺序固定 星期日、星期一 … 星期六(对应 (int)DayOfWeek 0..6);只写存在课表的星期(源码 GetWeekDayName 与 cellData[7] 按 0..6 输出,ExcelExportWindow.xaml.cs:340-350,369-435)
A2..A(n+1) 节次序号 1..n,文本格式(源码 Cells[pos].Data = i.ToString(); DataFormat = Text,437-442)
B2.. 数据格 科目名;若启用教师名则在同格内用 \n 追加教师名(403-408)
同一格多课表 用 / 连接科目名(403-408);教师名同样 / 连接

样式:A 列宽 32,数据列宽 = 导出参数 ColWidth;行高 = RowHeight;TextWrap = BreakAll;对齐 HorAlign/VerAlign;前景/背景色;边框 0=无、1=细实线、2=粗实线(ExcelExportWindow.xaml.cs:446-472,456-458)。

时间表 sheet 列格式:A 列从 A1 起每行一个时间范围文本,支持 HH:mm-HH:mm 与 HH:mm:ss-HH:mm:ss;导入解析规则必须照抄:提取文本中连续数字,个数必须为 4 或 6,否则该行跳过并记录警告(ExcelImportWindow.xaml.cs:428-450);解析成功生成 TimeType=0 的上课时间点,随后在相邻两节之间自动补 TimeType=1 的课间(仅当 下一节.StartTime - 上一节.EndTime > 0,505-522)。

导入映射规则:

  1. 科目匹配:数据格文本 text.Contains(subject.Name) 即视为命中(源码 627-632);多个科目名同时命中取档案中第一个;/ 分隔的多科目必须按段分别匹配。
  2. 星期 → 课表:每个星期列生成一张 ClassPlan,TimeRule.WeekDay = 该列对应 0..6,TimeLayoutId = 本次导入的时间表。
  3. 节次 ↔ 时间点对齐:课表行数必须 ≤ 时间表「上课时间点」数量;超出 → 报错并中止(不允许静默截断)。
  4. 单双周:若列中存在 / 合并(多课表同列),必须拆成多张课表并写入各自的 WeekCountDiv/WeekCountDivTotal;列首若带周次标记(见 6.9.1.5 新增约定),按标记写入。
  5. 完成导入:每张课表分配新 Guid;导入过程中被临时激活的时间表必须复位 IsActivated=false、IsActivatedManually=false(源码 CompleteImport,695-706)。
  6. 只导时间表模式:ImportTimeLayoutOnly=true 时只写入时间表,不创建课表(388-397)。

往返一致性契约(必须显式实现并写进向导文案):

  • 可往返:单张单周课表 + 科目名 + 教师名开关一致时,科目矩阵逐格可还原。
  • 不可往返、必须补齐的项:
    1. 原版导出完全不写时间点(只写节次序号,437-442),因此原版导出文件无法还原时间表。本实现必须写 时间表 sheet(或把 A 列改为 HH:mm-HH:mm 文本),否则导入必须强制让用户另选时间表。
    2. 原版导出把同一星期的多张课表用 / 合并到同一格(403-408),丢失单双周/多周信息。本实现必须增加「周次」行(如 单周/双周/2/4周)或拆分为多行块,确保 WeekCountDiv 与 WeekCountDivTotal 可往返。
    3. Initial 不导出 → 往返后由 Name 首字自动重算(见 6.1.6);IsOutDoor 不导出 → 往返后丢失,向导必须提示。
  • 验收等价性:导出 → 导入 → 再导出,两次导出文件的单元格文本矩阵必须逐格相等(颜色/列宽/边框不要求)。

导入前全量预检(任一硬错误不得写入档案):

  1. 非 .xlsx / 缺少 课表 sheet / 表头缺失 → 结构化错误并中止。
  2. 时间范围数字个数 ≠ 4/6、时 >23、分/秒 >59、Start >= End → 逐行错误。
  3. 节次序号不连续或重复 → 警告,允许继续但必须二次确认。
  4. 列首不是合法星期名 → 该列跳过并在报告中列出。
  5. 科目名未在档案中 → 逐格报告,提供「新建科目 / 置空 / 忽略该格」三选一。
  6. 解析结果为空(无任何课表或时间点)→ 拒绝并提示。
  7. 文件被占用 / 损坏 / 密码保护 → 捕获并以 dialog/toast 展示,不得崩溃。

导出参数与默认值:导出起点单元格由向导选择(对应源码 ClassPlanStartPos);参数含 RowHeight、ColWidth、IsTeacherNameEnabled、HorAlign、VerAlign、ForegroundColor、BackgroundColor、BorderMode;导出后支持「保存文件」与「保存并用 Excel 打开」(源码 SaveFile/ButtonSaveAndOpenInExcel,486-516)。由于 ViewModel 已缺失,以下默认值标【推断】:RowHeight=24、ColWidth=80、IsTeacherNameEnabled=false、HorAlign=Center、VerAlign=Center、前景 #FF000000、背景 #FFFFFFFF、BorderMode=1。以上默认值必须在向导中可见、可改,并持久化到 Settings(新增 ExcelExportSettings 分组,默认值即上表)。

6.10 附加设置(AttachedObjects)模型

附加设置不是 1.7 遗留,而是 2.x 仍在使用的机制:基类字段定义在 repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:15,2.x 的 AttachedSettingsControlPresenter 仍在读写它(repos/ClassIsland/ClassIsland.Core/Controls/AttachedSettingsControlPresenter.axaml.cs:241 读取、:361 回写)。实现时必须完整支持。

6.10.1 定义与承载对象

AttachableSettingsObject 提供:

"AttachedObjects": {
  "<设置 GUID>": { /* 该附加设置的设置对象 */ }
}
  • 属性名固定为 AttachedObjects(C# 属性名,System.Text.Json 默认序列化;键为 GUID 字符串,值是多态对象)(AttachableSettingsObject.cs:15)。
  • 三个 API 必须等价实现(AttachableSettingsObject.cs:23-61):
    • GetAttachedObject<T>(id):取不到返回 null;若存的是 JsonElement 则反序列化为 T。
    • GetAttachedObject<T>(id, defaultValue):取到则回写并返回;取不到则写入 default 并返回 default(注意这会产生副作用,规范要求「读」不应改变档案,实现时请把该重载改成纯读,或在调用处显式接受副作用——以纯读为推荐,并在代码注释标注与源码差异)。
    • WriteAttachedObject<T>(id, o):直接写入。
  • 必须继承 AttachableSettingsObject 的模型:ClassPlan(ClassPlan.cs:13)、ClassInfo(ClassInfo.cs:10)、TimeLayout(TimeLayout.cs:13)、TimeLayoutItem(TimeLayoutItem.cs:11)、Subject(Subject.cs:8)。
  • Profile 本身不继承该基类(Profile.cs:14 直接继承 ObservableRecipient),因此不得把附加设置挂在档案根上。

6.10.2 注册表与 GUID(必须硬编码,与提供方/渠道共用 GUID 空间)

附加设置控件用两个特性声明:[AttachedSettingsUsage(AttachedSettingsTargets targets)] 与 [AttachedSettingsControlInfo(guid, name, iconGlyph, hasEnabledState=true)](ClassIsland.Core/Attributes/AttachedSettingsUsage.cs:9-15、AttachedSettingsControlInfo.cs:10-44)。AttachedSettingsTargets 是 [Flags](ClassIsland.Core/Enums/AttachedSettingsTargets.cs:6-33):None=0、Lesson=1、Subject=2、TimePoint=4、ClassPlan=8、TimeLayout=16。

内置 4 个附加设置(注册于 App.Services.xaml.cs:234-238):

GUID 名称 图标 可附加目标 有启用开关
08F0D9C3-C770-4093-A3D0-02F3D90C24BC 上课提醒设置 (空/默认) ClassPlan | TimeLayout | Lesson | Subject | TimePoint 是
58e5b69a-764a-472b-bcf7-003b6a8c7fdf 课程显示设置 \ue479 ClassPlan | TimeLayout | Lesson | Subject | TimePoint 是
8FBC3A26-6D20-44DD-B895-B9411E3DDC51 放学提醒设置 \ued34 ClassPlan | TimeLayout 是
7625DE96-38AA-4B71-B478-3F156DD9458D 天气提醒设置 \ue4dc 仅 TimePoint 否(hasEnabledState=false)

证据:Controls/AttachedSettingsControls/ClassNotificationAttachedSettingsControl.axaml.cs:12-16、LessonControlAttachedSettingsControl.axaml.cs:11-15、AfterSchoolNotificationAttachedSettingsControl.axaml.cs:11-13、WeatherNotificationAttachedSettingsControl.axaml.cs:11-13。

设置模型与默认值(Models/AttachedSettings/*.cs):

  • ClassNotificationAttachedSettings:IsAttachSettingsEnabled=false,IsClassOnNotificationEnabled=true、IsClassOnPreparingNotificationEnabled=true、IsClassOffNotificationEnabled=true、ClassPreparingDeltaTime=60、ClassOnPreparingText="准备上课,请回到座位并保持安静,做好上课准备。"、OutdoorClassOnPreparingText="下节课程为户外课程,请合理规划时间,做好上课准备。"、ClassOnPreparingMaskText="即将上课"、ClassOnMaskText="上课"、ClassOffMaskText="课间休息"、ClassOffOverlayText=""(153 行文件,见 7.4.1)。
  • LessonControlAttachedSettings:IsAttachSettingsEnabled=false、ShowExtraInfoOnTimePoint=true、ExtraInfoType=0、IsCountdownEnabled=true、CountdownSeconds=60、ExtraInfo4ShowSecondsSeconds=0、ScheduleSpacing=1、ShowCurrentLessonOnlyOnClass=false、IsNonExactCountdownEnabled=false(ClassIsland.Core/Models/AttachedSettings/LessonControlAttachedSettings.cs:9-17)。
  • AfterSchoolNotificationAttachedSettings:IsAttachSettingsEnabled=false、IsEnabled=true、NotificationMsg="今天的课程已结束,请同学们有序离开。"(Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:11-13)。
  • WeatherNotificationAttachedSettings:IsAttachSettingsEnabled=**true**、AlertShowMode=Default、ForecastShowMode=Default(Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:11-13)。

6.10.3 读取优先级(必须逐层实现)

GetAttachedSettingsByPriority<T>(id, subject, timeLayoutItem, classPlan, timeLayout) 固定按 Subject → TimeLayoutItem → ClassPlan → TimeLayout 顺序逐层查找,返回第一个 IsAttachSettingsEnabled == true 的对象;全部未命中返回 null(repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IAttachedSettingsHostService.cs:24-43;官方文档 repos/ClassIsland/doc/AttachedSettings.md:50-57 的 mermaid 顺序同为 Subject → TimeLayoutItem → ClassPlan → TimeLayout)。

  • 调用方传参顺序必须与上表一致:GetAttachedSettingsByPriority(ProviderGuid, CurrentSubject, CurrentTimeLayoutItem, CurrentClassPlan, CurrentClassPlan?.TimeLayout)(上课/下课/准备上课,见 7.4.1);天气按「当前时间点」查找(WeatherService 侧见 7.4.3)。
  • IsAttachSettingsEnabled 的判定要兼容「JSON 里存的是 JsonElement」:IAttachedSettings.GetIsEnabled(obj) 在对象为 JsonElement 时读 IsAttachSettingsEnabled 属性是否为 true,为 JsonElement 但缺失该属性 → false(ClassIsland.Shared/Interfaces/IAttachedSettings.cs:20-38)。
  • 不得实现「就近优先」以外的隐式规则(如按课程时长/科目类型自行加权)。

6.10.4 写入与序列化纪律

  • 编辑控件回写规则(必须照做,AttachedSettingsControlPresenter.axaml.cs:354-362):仅当 IsAttachSettingsEnabled == true 或 控件 HasEnabledState == false 时,才把设置对象写回 TargetObject.AttachedObjects[ControlInfo.Guid];否则不回写(源码注释:以降低档案文件体积)。因此「关闭附加设置」在档案里表现为该 GUID 不存在,而不是存一个 IsAttachSettingsEnabled=false 的对象。
  • 读取失败(类型不匹配/JSON 损坏)→ 丢弃该 GUID 的项、记日志、继续加载其余项,不得让整个档案加载失败【推断:源码 GetAttachedObject 直接 Deserialize,未捕获异常,实现必须补 try/catch】。
  • 反序列化时必须按 GUID 在注册表中找到设置类型再反序列化;找不到类型时保留原始 JSON(插件可后续注册),不得丢弃。
  • 附加设置 JSON 必须与课表/时间表/科目同文件存储(Profiles/*.json),不得放进 Settings.json,也不得用组件设置(ComponentSettings)替代。

6.10.5 与 1.7 导入的兼容

ClassIsland1ImportProvider 导入 1.x 档案时必须迁移附加设置(Controls/ProfileTransferProviders/ClassIsland1ImportProvider.cs:32-57 调用 ClassIslandV1ProfileTransferHelper):

  1. 1.x 的「附加设置」按对象分别存放;迁移时按控件 GUID 一一映射到 2.x 的 AttachedObjects(4 个内置 GUID 见 6.10.2)。
  2. 未知 GUID 的附加设置必须原样保留(键与值都不丢),以便插件后续注册后仍可读取。
  3. 迁移失败(JSON 结构不同)→ 记录警告并跳过该项,不得阻断整个导入【1.x JSON 的确切结构未在本次读取范围内确认,具体字段映射标【推断】,需补读 ClassIslandV1ProfileTransferHelper 后细化】。

6.10.6 与档案分析 / 课表详细信息的关系

  • 「课表详细信息」窗口的「附加设置结果」页签依赖 ProfileAnalyzeService 对象图 + 本节优先级链,展示某节课实际会用到哪些附加设置(见 6.11)。
  • 节点状态用 AttachedSettingsControlState:Disabled=0、Enabled=1、Override=2(ClassIsland.Core/Enums/AttachedSettingsControlState.cs:3-8),由「在当前对象上是否存在该 GUID 且启用」推导:不存在 → Disabled;存在且启用但被更近层覆盖 → Override;最终生效 → Enabled【推导逻辑为【推断】,源码在 ClassPlanDetailsWindow.axaml.cs 中计算,未细读】。
  • ClassPlanDetailsWindow 的节点状态条颜色:Enabled = AccentFillColorDefaultBrush、Override = SystemFillColorCautionBrush,列表项高度 36、圆角 4(Views/ClassPlanDetailsWindow.axaml:108-166)。

6.10.7 边界

  1. 同一 GUID 在多层存在但都未启用 → 等价于不存在。
  2. TimePoint 级别的附加设置只有在其时间点被课程实际引用时才可能生效(未引用节点在档案分析里会提示「当前节点未被任何一个课表使用」,AttachedSettingsControlPresenter.axaml.cs:209-213)。
  3. 删除科目/时间点/课表时,其 AttachedObjects 随之删除,不得残留在档案里(对象被整体移除即满足)。
  4. 天气提醒附加设置 hasEnabledState=false:UI 不得显示启用开关;写回不受 IsAttachSettingsEnabled 限制(默认 true 仅为兼容)。

6.11 档案分析(可视化辅助)

必须实现档案对象图分析,用于「课表详细信息」窗口判定附加设置的生效顺序(repos/ClassIsland/ClassIsland/Services/ProfileAnalyzeService.cs):

  • 节点类型:ClassPlan、TimeLayout(Target=TimeLayout)、TimePoint、Lesson(ClassInfo)、Subject(28-133)。
  • 地址:{ Guid, Index };时间点 Index 为其在 Layouts 中的下标,课程 Index 为其在 Classes 中的下标(repos/ClassIsland/ClassIsland.Core/Models/ProfileAnalyzing/AttachableObjectAddress.cs)。
  • 边:课表 ↔ 时间表、时间表 ↔ 时间点、课表 ↔ 时间点、课表 ↔ 课程、时间点 ↔ 课程、课程 ↔ 科目;同时维护 PreviousNodes 与 NextNodes(双向),并为每类对象维护 RelatedLessons。
  • 查询:FindNextObjects(address, id) 向下遍历、结果按 Target 降序排序;FindPreviousObjects 向上遍历、按 Target 升序排序;两者都可要求「附加设置已启用」。遍历时遇到 Subject 节点停止继续向上/向下扩散(Walk 中 if (node.Target == Subject && !isInit) return;,158-177)。
  • 调试:支持导出 Mermaid graph LR 文本(139-156)。

「课表详细信息」窗口(repos/ClassIsland/ClassIsland/Views/ClassPlanDetailsWindow.axaml:14-22)参数:标题 "课表详细信息"、Height=500、Width=800;左列 1* 且 MinWidth=300 MaxWidth=500,中间分隔条宽 5,右列 2*。左侧表格列固定为「时间」(StartTime-EndTime)与「科目」;右侧为「附加设置结果」页签,先给出说明 "以下的附加设置将按从上到下的顺序使用。" 再列出节点,节点左侧 4px 状态条:Enabled 用 AccentFillColorDefaultBrush,Override 用 SystemFillColorCautionBrush,列表项高度 36、圆角 4。

6.12 边界与异常(必须处理)

  1. 时间点重叠 / 逆序:StartTime > EndTime 必须拒绝(源码 EnsureTime 抛 ArgumentException("时间点起始时间无效。"),TimeLayoutItem.cs:95-101),UI 层必须先校验再写模型。
  2. 跨天:时间点只在同一天内比较,不支持跨 0 点;若用户排出 23:50-00:10 必须提示非法。
  3. 空课表:CurrentClassPlan == null、TimeLayout == null 时所有派生状态必须安全降级,不得空引用。
  4. 课表引用的时间表被删:必须回退为 TimeLayoutId = Guid.Empty 并显示「未选择时间表」占位;不得崩溃。
  5. 本周无匹配课表:CurrentClassPlan=null,组件显示空态,OnClass/OnBreakingTime/OnAfterSchool 均不触发。
  6. 时间表被多个课表共享:修改时间表必须联动所有共享课表(ClassPlan.TimeLayoutOnLayoutObjectChanged 会 RefreshClassesList),且「应用到所有课表」的科目覆写只影响同一 TimeLayoutId 的课表。
  7. 临时课表/临时层与预定课表冲突:优先级固定为 预定课表 > 临时课表 > 常规课表;临时层覆盖临时课表的提示文案必须出现("临时课表被临时层课表覆盖。要使用临时课表,请暂时禁用或清除临时层课表。",ProfileSettingsWindow.axaml:283-292)。
  8. 档案 JSON 损坏:加载失败必须回退为空档案并保留原文件为 .bak,同时在设置页提示【推断:ConfigureFileHelper.LoadConfig 的容错细节未在本次读取范围内给出确切行为,实现时必须显式兜底】。
  9. 并发写入:同一档案的自动保存必须串行化(建议 300ms 防抖 + 单写队列),避免 Electron 主进程多窗口同时写导致截断。

6.13 验收标准

  1. 新建档案后 Profiles/*.json 中同时存在默认课表群与全局课表群,GUID 与本节一致;删除/解散这两个群会被拒绝。
  2. 构造「周一/周三/周五 + 单周 + 不轮换」「周二 + 2 周轮换第 2 周」两组课表,把系统时间改到对应日期与周次,当前课表 必须正好切换;把 MultiWeekRotationMaxCycle 改成 2 后,WeekCountDivTotal=4 的课表必须永不启用。
  3. 时间表新增一节 40 分钟上课 + 10 分钟课间,Classes 自动补齐数量且顺序与上课时间点一致;删除时间点后对应课程同步消失,撤销后恢复原位。
  4. 在 08:00 之前插入上课时间点会触发「向前插入」提示;插入分割线/行动到已存在时刻会被拒绝并给出对应警告文案。
  5. 课表中「永久换课」只在当前课表写入 SubjectId;「临时换课」必须新建当天临时层课表(IsOverlay=true,名称带 (临时层))并置 IsChangedClass=true,次日自动被清理。
  6. 「换课」两段式交换后两张课表的两个 SubjectId 互换;起点/终点任一为空时不发生任何数据变更。
  7. 关闭档案窗口再打开,所有编辑都已落盘;重启应用后 SelectedProfile 生效。
  8. 从 CSES 导入后:科目不重复(同名复用)、时间表按上课起止自动匹配或新建、单双周与星期正确写入;导出到 CSES 时只包含已启用且单双周内的课表。
  9. 课表详细信息窗口列出的附加设置顺序与生效顺序一致,状态条颜色符合 Enabled/Override 规则。
  10. 全程不得出现「点击后无提示、数据半更新」的情况;所有校验失败都必须在 UI 上给出明确中文提示。
  11. Excel 往返:导出 → 导入 → 再导出后,两次导出文件的单元格文本矩阵逐格相等;时间表可通过 时间表 sheet(或 A 列 HH:mm-HH:mm 文本)完整还原;同一星期的单双周课表往返后 WeekCountDiv/WeekCountDivTotal 不丢。
  12. Excel 预检:时间格式错误、起止逆序、列首非法星期、科目名缺失、解析结果为空这五类情况必须被拦截并给出可定位(行/列)的报告,且档案文件未被写入。
  13. 附加设置优先级:在 Subject、TimePoint、ClassPlan、TimeLayout 四层分别写入同一 GUID 并启用,读取结果必须严格按 Subject → TimeLayoutItem → ClassPlan → TimeLayout 返回第一个启用项;未启用的层不得在档案里留下该 GUID 键。
  14. 附加设置序列化:JSON 中键名为 AttachedObjects、键为 GUID 字符串;加载时未知 GUID 原样保留、损坏项被跳过且不影响整个档案加载;Profile 根上不得出现该字段。