- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
54 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根上不得出现该字段。