Files
classhelper-island-refactor…/out/sections/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

531 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 6. 课程表 / 时间表 / 档案 / 科目 / 周次 / 调课 / 导入导出
本节规定「档案(Profile)—时间表(TimeLayout)—课表(ClassPlan)—课程(ClassInfo)—科目(Subject)」这一整条业务链的完整实现规格。技术栈按 ClassHelper 的 Electron + Vue 3 + TypeScript 实现:主进程负责文件读写与计算,渲染进程负责编辑界面;数据结构必须以 JSON 落盘,字段命名沿用本节给出的名字,不得自行改名。
### 6.1 数据结构(必须逐字段实现)
档案是**一个 JSON 文件**,存放在 `Profiles/` 目录下,默认文件名 `Default.json`(`repos/ClassIsland/ClassIsland/Models/Settings.cs:66`:`_selectedProfile = "Default.json"`;`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:48`:`ProfilePath = 应用根目录/Profiles`)。档案根对象必须包含以下字段(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:17-32`):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Name` | string | `""` | 档案名 |
| `TimeLayouts` | `Record<Guid, TimeLayout>` | `{}` | 时间表字典 |
| `ClassPlans` | `Record<Guid, ClassPlan>` | `{}` | 课表字典 |
| `Subjects` | `Record<Guid, Subject>` | `{}` | 科目字典 |
| `ClassPlanGroups` | `Record<Guid, ClassPlanGroup>` | 2 个内置项,见 6.1.6 | 课表群字典 |
| `SelectedClassPlanGroupId` | Guid | `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 当前启用的课表群 |
| `TempClassPlanGroupId` | Guid? | `null` | 临时课表群 |
| `TempClassPlanGroupExpireTime` | DateTime | `DateTime.Now` | 临时课表群失效日期 |
| `IsTempClassPlanGroupEnabled` | bool | `false` | 临时课表群开关 |
| `TempClassPlanGroupType` | 0/1 | `1`(Inherit) | 0=覆盖 Override,1=继承 Inherit |
| `TempClassPlanId` | Guid? | `null` | 临时课表(当天) |
| `TempClassPlanSetupTime` | DateTime | `DateTime.Now` | 临时课表设置时间 |
| `IsOverlayClassPlanEnabled` | bool | `false` | 临时层课表总开关 |
| `OverlayClassPlanId` | Guid? | `null` | 当前临时层课表 |
| `OrderedSchedules` | `Record<"yyyy-MM-dd", { ClassPlanId: Guid }>` | `{}` | 预定课表(按日期) |
| `Id` | Guid | `Guid.NewGuid()` | 档案 ID,用于「信任档案」 |
`TempClassPlanGroupType` 的两值语义必须严格按源码实现(`repos/ClassIsland/ClassIsland.Shared/Enums/TempClassPlanGroupType.cs:6-16`):`Override` 表示「覆盖现在启用的课表群」,`Inherit` 表示「继承现在启用的课表群;临时课表群中没有满足规则的课表时,回退到当前启用的课表群中查找」。
#### 6.1.1 时间表 TimeLayout
```
{
"Name": "新时间表", // 默认值,Profile.cs 同级模型 TimeLayout.cs:17
"Layouts": [ TimeLayoutItem, ... ], // 有序数组,按 StartTime 升序维护
"IsOverlay": false, // 是否临时层时间表
"OverlaySourceId": null // 临时层来源时间表 ID
}
```
`IsActivated`、`IsActivatedManually` 是运行期状态,`[JsonIgnore]`,**不得写入 JSON**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayout.cs:272-297`)。
#### 6.1.2 时间点 TimeLayoutItem
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `StartTime` | string(`HH:mm:ss`) | `00:00:00` | 时间点开始 |
| `EndTime` | string | `00:00:00` | 时间点结束;`TimeType` 为 2 或 3 时**强制等于 StartTime** |
| `TimeType` | int | `0` | 0=上课,1=课间,2=分割线,3=行动 |
| `IsHideDefault` | bool | `false` | 默认隐藏 |
| `DefaultClassId` | Guid | `Guid.Empty` | 默认科目 ID |
| `BreakName` | string | `""` | 自定义课间名;为空时显示 `BreakNameText`(`"课间休息"`) |
| `ActionSet` | ActionSet? | `null` | 仅当 `TimeType=3` 时使用,见第 8 节 |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeLayoutItem.cs:13-21,62-93,119-138,143-191,196-205`。`Last`(时长)= `EndTime - StartTime`,`[JsonIgnore]`(同文件 113-117)。序列化时必须使用 `HH:mm:ss` 文本,禁止用「一天内秒数」数字(旧字段 `StartSecond`/`EndSecond` 已 `[Obsolete(..., true)]`,见同文件 26-57,读取旧档案时需要迁移)。
#### 6.1.3 课表 ClassPlan
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `Name` | string | `"新课表"` | 课表名 |
| `TimeLayoutId` | Guid | `Guid.Empty` | 引用 `Profile.TimeLayouts` 的键 |
| `Classes` | ClassInfo[] | `[]` | 与时间表中 **TimeType=0 的时间点一一对应、同序** |
| `TimeRule` | TimeRule | 见 6.1.4 | 触发规则 |
| `IsEnabled` | bool | `true` | 是否自动启用 |
| `IsOverlay` | bool | `false` | 是否临时层课表 |
| `OverlaySourceId` | Guid? | `null` | 临时层源课表 |
| `OverlaySetupTime` | DateTime | `DateTime.Now` | 临时层日期 |
| `AssociatedGroup` | Guid | `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 所属课表群 |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:15-27,393-607`。`IsActivated`、`ValidTimeLayoutItems`、`LastTimeLayoutCount`、`ClassPlans`、`TimeLayouts`、`TimeLayout` 均为运行期或引用属性,必须 `[JsonIgnore]`(同文件 35-46,110-120,357-391,427-435,527-537)。
`Classes` 与时间点的同步规则必须实现(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:451-491`):
1. 统计 `TimeLayout.Layouts` 中 `TimeType==0` 的个数 `l`。
2. `Classes.Count < l` 时用空 `ClassInfo` 补齐;`Classes.Count > l` 时从尾部删除多余项。
3. 逐项写入 `Index` 与 `CurrentTimeLayout`(运行期字段)。
4. **若 `Classes[i].SubjectId == Guid.Empty` 且对应时间点 `DefaultClassId != Guid.Empty`,则把 `DefaultClassId` 写入 `SubjectId`**。
5. 更新 `LastTimeLayoutCount = TimeLayout.Layouts.Count`。
#### 6.1.4 课表触发规则 TimeRule
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
| `WeekDay` | int | `0` | `(int)DayOfWeek`,0=周日、1=周一……6=周六。注意默认是**周日**,因为 `private int _weekDay = new();` 即 0 |
| `WeekCountDiv` | int | `0` | 0=不轮换;n=第 n 周启用 |
| `WeekCountDivTotal` | int | `2` | 轮换总周数(2=单双周) |
证据:`repos/ClassIsland/ClassIsland.Shared/Models/Profile/TimeRule.cs:13-16,20-69`。
#### 6.1.5 课程 ClassInfo
`{ "SubjectId": Guid, "IsChangedClass": bool, "IsEnabled": true }`。`SubjectId` 默认 `Guid.Empty`;`Index`、`CurrentTimeLayout`、`CurrentTimeLayoutItem`、`IsEmpty` 都是运行期字段,不得落盘(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassInfo.cs:12-113`)。`IsChangedClass` 表示「临时换课」标记,仅在临时换课模式下由界面写入。
#### 6.1.6 课表群 ClassPlanGroup 与科目 Subject
课表群 `{ "Name": "新课表群", "IsGlobal": false }`,两个内置群的 GUID 必须**硬编码**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlanGroup.cs:10-11,44-49`):
- 默认课表群:`ACAF4EF0-E261-4262-B941-34EA93CB4369`,名称 `"默认"`。
- 全局课表群:`Guid.Empty`(全 0),名称 `"全局课表群"`,`IsGlobal=true`。
档案构造时必须保证这两个群存在(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Profile.cs:44-59`)。解散课表群时把其中的课表移回默认群;删除课表群时连同课表一起删除;对默认群与全局群执行这两种操作必须抛出异常(`Profile.cs:104-137`)。
科目 `{ "Name": "", "Initial": "", "TeacherName": "", "IsOutDoor": false }`(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/Subject.cs:10-14`)。必须实现三条内置常量科目:
- `Fallback`:`{ Initial: "?", Name: "???" }`(Subject.cs:79-83)——课程服务找不到科目时的兜底,界面上必须先判断是否等于它(例如「下节课是 ???」代表没有下一节课)。
- `Empty`:`{ Initial: "", Name: "" }`(Subject.cs:86-92)。
- `Breaking`:`{ Initial: "休", Name: "课间休息" }`(Subject.cs:95-101)——课间状态时 `CurrentSubject` 指向它,并把 `Name` 覆写为当前课间名。
`Subject.Name` 的 setter 必须实现「`Initial` 为空且新 Name 非空白时,自动取 Name 首字符写入 Initial」(Subject.cs:19-30)。`GetFirstName()` 必须实现教师姓氏提取(Subject.cs:107-140):含中文时优先匹配复姓表(两字),否则取首字;不含中文时取按空格切分后的最后一个单词;未填教师名返回空串。
### 6.2 当天应使用哪张课表:选择算法(必须逐分支实现)
每次主计时器 tick(默认 50ms,见 6.3)都要重新计算「当前课表」。优先级**严格按以下顺序**,命中即返回(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:143-201`):
1. **预定课表**:`Profile.OrderedSchedules[今天]` 存在,且对应 `ClassPlan` 存在,且(该课表不是临时层 或 `IsOverlayClassPlanEnabled`)→ 返回它。
2. **临时课表**:`Profile.TempClassPlanId != null` 且存在该课表,且 `TempClassPlanSetupTime.Date >= 今天` → 返回它。
3. **常规课表**:按下列过滤 + 排序后取第一个:
- 分组匹配:`AssociatedGroup` 等于 `ClassPlanGroup.GlobalGroupGuid` 或 `SelectedClassPlanGroupId`;若启用临时课表群且未过期,再按类型处理——`Inherit`:默认群 ∪ 临时群 ∪ 全局群;`Override`:临时群 ∪ 全局群。
- 组优先级排序(必须用 `OrderByDescending`):临时课表群=3 > 当前选中课表群=2 > 全局课表群=1 > 其他=0。
- 对每张候选课表执行 `CheckClassPlan`。
`CheckClassPlan(plan, date)` 的判定顺序(`LessonsService.cs:582-607`):
1. `plan.IsOverlay == true` → 不匹配。
2. `plan.IsEnabled == false` → 不匹配。
3. `plan.TimeRule.WeekDay != (int)date.DayOfWeek` → 不匹配。
4. `AssociatedGroup` 既不是全局群、也不是当前选中群、也不是临时群 → 不匹配。
5. `WeekCountDivTotal > Settings.MultiWeekRotationMaxCycle` → 不匹配(防止越过最大周期)。
6. `WeekCountDiv == 0` → **匹配**。
7. 否则计算 `GetCyclePositionsByDate(date)[WeekCountDivTotal]`,等于 `WeekCountDiv` 才算匹配。
实现时注意:若一张课表都没匹配上,`CurrentClassPlan` 必须为 `null`,且所有运行期状态回到「无课表」语义(`CurrentState=None`、`CurrentSubject=Fallback`、`CurrentTimeLayoutItem=Empty`),不得抛异常。
### 6.3 时间状态机与课程计算(主循环)
必须起一个**周期 50ms、`DispatcherPriority.Render` 优先级**的计时器(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:39-45`),每个 tick 依次触发 `PreMainTimerTicked` → 计算 → `PostMainTimerTicked`(同文件 373-388)。在 Electron 实现中映射为:主进程用 50ms `setInterval` 或(建议)`requestAnimationFrame`+定时器混合,通过 IPC 广播 tick 事件;渲染进程不得自己做第二套计时。
每次 tick 的计算步骤(`LessonsService.cs:390-527`):
1. 刷新档案引用、清理过期数据:`TempClassPlanSetupTime.Date < 今天` → `TempClassPlanId = null`;清理过期预定/临时层;清理过期临时课表群(见 6.7)。
2. 若 `IsClassPlanEnabled == false` → `CurrentClassPlan = null` 并结束。
3. 取当前课表;若当天预定课表本身是临时层 → `OverlayClassPlanId = 该课表`,否则置 `null`。
4. 取 `now = 当前本地时间(以校时服务为准).TimeOfDay`。
5. **当前时间点** = 有效时间点列表中第一个满足 `TimeType ∈ {0,1}` 且 `StartTime <= now <= EndTime` 的项。
6. 若命中且 `TimeType==0`:`CurrentState=OnClass`;按「上课时间点在教学点中的序号」映射到 `Classes[i]`,取 `SubjectId` 对应科目填入 `CurrentSubject`(映射算法:把 `TimeLayout.Layouts` 中 `TimeType==0` 的项按序建数组,取当前项的下标,见 `LessonsService.cs:531-541`)。
7. 若命中且 `TimeType==1`:`CurrentSubject = Subject.Breaking`,并把其 `Name` 设为 `CurrentTimeLayoutItem.BreakNameText`;`CurrentState=Breaking`;`IsLessonConfirmed=true`。
8. **下节课** = 有效时间点中第一个 `TimeType==0 && EndTime >= now` 的项;有则按步骤 6 的方法取科目写入 `NextClassSubject`。
9. **下个课间** = 有效时间点中第一个 `TimeType==1 && EndTime >= now` 的项。
10. 剩余时间:`OnClass` 状态下算 `OnBreakingTimeLeftTime = 下个课间.StartTime - now`;否则算 `OnClassLeftTime = 下节课.StartTime - now`;两种时间都必须**下限为 0**(`LessonsService.cs:529`)。
11. 若「下节课」和「下个课间」都为 `null` → `CurrentState = AfterSchool`。
12. 把结果统一写回所有属性(没算出的用默认值:`-1`、`None`、`Fallback`、`Empty`、`Zero`、`false`)。
13. **状态变化事件**:与上一次状态比较,不同才发事件;顺序固定为「先 `CurrentTimeStateChanged`,再按新状态发 `OnClass` / `OnBreakingTime` / `OnAfterSchool` 之一」。
必须同时实现的两个派生属性:
- `CurrentStatusIsBreaking`(课间)与 `IsLessonConfirmed`:仅当命中当前时间点时置 true,否则 false。提醒系统用它避免重复提醒(见第 7 节)。
- `ValidTimeLayoutItems`(有效时间点):仅保留 `TimeType ∈ {0,1,2}` 的时间点,然后按「课程是否启用」做**正向 + 反向两遍裁剪**(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:54-98`):正序时一旦遇到 `ClassInfo.IsEnabled=false`,其后所有时间点都移除;逆序时一旦遇到 `IsEnabled=false`,其前所有时间点都移除。效果是「禁用一节早课,则它前面所有时间点都不显示;禁用一节晚课,则其后所有时间点都不显示」。必须实现该双向裁剪,否则「课程表」组件与倒计时会与真实课表不一致。
### 6.4 周次轮换(单双周 / 多周)
必须实现「基准周 + 周期 + 每周期偏移」三件套:
- `Settings.SingleWeekStartTime`:单周起始日,默认取**今天所在周的周日** `DateTime.Today.AddDays(-(int)Today.DayOfWeek)`(`repos/ClassIsland/ClassIsland/Models/Settings.cs:37-42`)。
- `Settings.MultiWeekRotationMaxCycle`:最大轮换周期,默认 `4`(Settings.cs:61)。
- `Settings.MultiWeekRotationOffset`:整型数组,默认 `[-1, -1, 0, 0, 0]`(Settings.cs:60);下标 = 周期长度,值 = 偏移周数;下标 0、1 固定 `-1` 占位。
计算函数必须逐字实现(`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:619-635`):
```
totalElapsedWeeks = floor((date.Date - SingleWeekStartTime.Date).TotalDays / 7)
positions = [-1, -1]
for cycleLength in 2..MultiWeekRotationMaxCycle:
offset = MultiWeekRotationOffset[cycleLength] ?? 0
pos = (totalElapsedWeeks + offset) % cycleLength
if pos < 0: pos += cycleLength
positions[cycleLength] = pos + 1 // 1-based
```
注意返回值是 **2-first、1-based** 的数组(注释原文:`2-first, 1-based`,LessonsService.cs:614),`CheckClassPlan` 直接用 `positions[WeekCountDivTotal]` 与 `WeekCountDiv` 比较。
UI 侧「调整多周轮换偏移」必须反向求解偏移(对称逻辑,`repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:99-113`):
```
totalElapsedWeeks = floor((today.Date - SingleWeekStartTime).TotalDays / 7)
for cycleLength in 2..max:
cycleOffset = (用户选择的 0-based 位置 - totalElapsedWeeks) % cycleLength
if cycleOffset < 0: cycleOffset += cycleLength
MultiWeekRotationOffset[cycleLength] = cycleOffset
```
「清除」按钮把 `MultiWeekRotationOffset` 清空(数组为空),随后重新读取时按 `GetValueOrDefault → 0` 处理(同文件 115-119)。
轮换 UI 必须按此渲染(`repos/ClassIsland/ClassIsland/Controls/WeekOffsetSettingsControl.axaml.cs:41-90`):从 2 到 `MultiWeekRotationMaxCycle` 每个周期生成一行;2 周时标题为 `"单双周:"`,其余为 `"{N}周轮换:"`(N 用中文数字);2 周时选项为 `["单周","双周"]`,否则为 `["1/N周","2/N周",...]`;控件 `MaxWidth=400`,行间距 `Margin=0,8,0,0`、`Spacing=4`。
课表编辑界面上的触发规则必须表现为两行(`repos/ClassIsland/ClassIsland/Controls/TimeRuleEditControl.axaml:15-52`):
1. 第一行文案 `"每 [周期] 的 [第几周]"`——只有 `WeekCountDiv>0` 时两个下拉框才可交互,否则整体淡化到 `Opacity=0.60`。
2. 第二行文案 `"当今天是:"` + 7 个 Chip 按钮(`UniformGrid Columns=7`),项为 `周日 周一 周二 周三 周四 周五 周六` 的顺序(与 `time.DayOfWeek` 的 0..6 对齐)。
### 6.5 时间表编辑(档案编辑窗口 · 时间表页)
窗口基础参数必须照抄:标题 `"档案编辑"`、`MinWidth=750`、`Width=1100`、`Height=600`、`EnableMicaWindow=True`(`repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml:23-30`)。左侧为主标签页,索引与内容固定为:0=课程表、1=时间表、2=科目、3=禁止事项(forbidden)、4=调课/日程调整、5=导入导出(`ProfileSettingsWindow.axaml.cs:246-261`)。右侧详情面板宽度 `332`(`ProfileSettingsWindow.axaml:53,202`)。
新增时间点的完整规则必须实现(`repos/ClassIsland/ClassIsland/Views/ProfileSettingsWindow.axaml.cs:983-1099`):
1. **基准时间** `baseSec`:若已选中时间点,上课/课间取「选中项 `EndTime`」,分割线/行动取「选中项 `StartTime`」;若未选中,默认 `08:00:00`(源码注释:按规定中学最早上课不得早于 8:00,见 1006-1009)。
2. **默认时长**:上课 = `Settings.DefaultOnClassTimePointMinutes`(默认 40 分钟);课间 = `Settings.DefaultBreakingTimePointMinutes`(默认 10 分钟);分割线与行动 = 0(Settings.cs:108-109,651,662)。
3. **插入位置**:遍历现有时间点,插入到第一个 `StartTime > 新项 StartTime` 的位置之前;没有则追加到末尾(989-998)。
4. **空间不足的处理**(1023-1078,必须逐条实现):
- 若下一个非分割线时间点的 `StartTime <= baseSec`:当选中项不是第 0 项 → 提示 `"没有合适的位置来插入新的时间点。"` 并放弃;当选中项是第 0 项 → 允许向前插:`baseSec = 选中项.StartTime - 默认时长`,并提示 `"已向前插入了新的时间点。"`。
- 若下一个时间点起点早于 `baseSec + 默认时长`:把时长截断为 `next.StartTime - baseSec`,提示 `"没有足够的空间完全插入该时间点,已缩短时间点长度。"`。
- 分割线:`baseSec = 选中项.EndTime`;若该时刻已存在分割线 → 警告 `"这里已经存在一条分割线。"` 并放弃。
- 行动:`baseSec = 选中项.EndTime`;若该时刻已存在行动 → 警告 `"这里已经存在一个行动。"` 并放弃。
5. 新项 `ActionSet`:仅 `TimeType==3` 时创建 `new ActionSet()`,其余为 `null`。
6. 插入后选中新项、滚动居中,并压入撤销栈。
必须实现撤销/重做栈(`ProfileSettingsWindow.axaml.cs:54-56,874-981`):`UndoEntry(IsAdd, Item, Layout, Index, Description)` 记录「新增/删除 + 项 + 所属时间表 + 索引 + 描述」,撤销/重做在 UI 上以历史列表呈现,支持「撤销到第 N 步」(`UndoToIndex` / `RedoToIndex`)。撤销新增 = 删除该项;撤销删除 = 在原索引插回。
删除时间点必须同步删除对应课程:`ClassPlan.RemoveTimePointSafe` 会先移除所有 `CurrentTimeLayoutItem == 该时间点` 的课程,再 `RefreshClassesList()`(`repos/ClassIsland/ClassIsland.Shared/Models/Profile/ClassPlan.cs:515-522`)。
「应用到所有课表」功能:`Profile.OverwriteAllClassPlanSubject(timeLayoutId, timePoint, subjectId)`——遍历所有 `TimeLayoutId` 相同课表,`RefreshClassesList()` 后把命中该时间点的课程 `SubjectId` 全部改写(`Profile.cs:85-95`)。
时间表刻度尺与缩放:时间线列表必须支持缩放(源码有 `ButtonZoomIn/ButtonZoomOut`,`ProfileSettingsWindow.axaml.cs:1290-1311`),缩放范围与步长属实现细节【推断:源码未给出常量,建议 0.5x–3x、步长 0.1】。
### 6.6 课表编辑页与「换课 / 调课 / 临时层」
课表编辑页右侧面板(`ProfileSettingsWindow.axaml:53-140`)必须包含:
- `名称` 输入框。
- `时间表` 下拉框:`ItemsSource = TimeLayouts`,`SelectedValue = ClassPlan.TimeLayoutId`,项文本 = `Value.Name`,`ToolTip` = 时间表 GUID。
- `课表群` 下拉框:`SelectedValue = ClassPlan.AssociatedGroup`。
- `自动启用` 复选框(`IsChecked = IsEnabled`);当 `IsOverlay=true` 时该复选框禁用,并显示提示条 `"这是一个临时层课表,将在使用过后自动删除。"`。
- 三态说明文本(必须按 `IsEnabled`/`IsOverlay` 组合显示):`IsEnabled&&!IsOverlay` → `"将会根据触发规则自动启用该课表。"`;`!IsEnabled&&!IsOverlay` → `"该课表不会自动启用,只能通过临时课表或临时层手动启用该课表。"`;`IsOverlay` → `"临时层课表将忽略触发规则强制启用。"`(ProfileSettingsWindow.axaml:88-111)。
- `TimeRuleEditControl`(触发规则),仅当 `!IsOverlay && IsEnabled` 时可用(113-116)。
- `更多选项`:附加设置列表(`IAttachedSettingsHostService.ClassPlanSettingsAttachedSettingsControls`)。
**换课(修改单节课的科目)**:点击课程单元格右侧按钮弹出 Flyout(`ProfileSettingsWindow.axaml:415-446`),结构固定:
1. 标题 `"修改课程"`(Subtitle 样式,下边距 8)。
2. `换课成…` 下拉框:`ItemsSource = Subjects`,`SelectedValue = TargetSubjectIndex`(Guid)。
3. 复选框 `"永久换课"`:`IsChecked = !IsTempSwapMode`(注意取反绑定),受集控策略 `DisableProfileClassPlanEditing` 约束。
4. `确定` 按钮(Accent 样式):当 `TargetSubjectIndex == Guid.Empty` 时禁用。
5. 确认逻辑(`ProfileSettingsWindow.axaml.cs:1531-1550`):先 `GetTargetClassPlan(date, IsTempSwapMode, out guid)` 取得目标课表;写入 `Classes[index].SubjectId = TargetSubjectIndex`;若 `IsTempSwapMode` 为真,同时置 `Classes[index].IsChangedClass = true`;最后刷新日程。
**调课(两节课互换)** 必须实现两段式交互(`ProfileSettingsWindow.axaml.cs:1435-1477`):
1. 点「开始换课」记录起点 `ClassSwapStartPosition = (date, index)`,进入换课模式;若选中课程为空(`IsEmpty`)→ 提示 `"选择课程区域无效。"` 并结束。
2. 点「完成换课」记录终点,再用 `GetTargetClassPlan` 分别取起点/终点课表;任一为空或索引越界 → 直接返回(不报错、不改数据)。
3. 交换两个 `SubjectId`(元组解构赋值一次完成)。
4. 临时换课模式下,两侧都置 `IsChangedClass = true`。
5. 刷新日程视图。可随时「取消换课」退出模式。
**临时换课的目标课表解析** `GetTargetClassPlan(date, overlay, out guid)`(`ProfileSettingsWindow.axaml.cs:1479-1506`)必须按此实现:
1. `LessonsService.GetClassPlanByDate(date, out baseGuid)`;为空 → 返回 null。
2. 若 `!overlay` 或 `baseClassPlan.IsOverlay` → 直接返回当前课表。
3. 若该日期已有 `OrderedSchedules[date]` 且其课表是临时层 → 返回当前课表。
4. 否则调用 `CreateTempClassPlan(baseGuid, enableDateTime: date)` 创建「当天临时层」,返回新课表(失败返回 null)。
**创建临时层** `CreateTempClassPlan(id, timeLayoutId?, enableDateTime?)`(`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:235-280`):
1. 若该日期已有临时层预定 → 返回 `null`(不允许重复创建)。
2. 用 JSON 深拷贝源课表(必须经序列化/反序列化,保证引用隔离)。
3. 可选:同时深拷贝时间表,置 `IsOverlay=true`、`OverlaySourceId=源时间表ID`、`Name += "(临时层)"`,注册进 `TimeLayouts`,并把新课表的 `TimeLayoutId` 指向它。
4. 新课表:`IsOverlay=true`、`OverlaySourceId=源课表ID`、`Name += "(临时层)"`、`OverlaySetupTime=日期`。
5. `Profile.IsOverlayClassPlanEnabled = true`、`Profile.OverlayClassPlanId = 新ID`、写入 `ClassPlans`、`OrderedSchedules[date] = { ClassPlanId: 新ID }`。
**临时层与普通课表互转**:`ConvertToStdClassPlan(id)` 把 `IsOverlay=false`,若其时间表也是临时层则一并转正并去掉名称中的 `"(临时层)"` 后缀(`ProfileService.cs:344-360`)。
**过期清理** `CleanExpiredTempClassPlan()` 必须每 tick 调用(`ProfileService.cs:295-328`):
1. 删除所有 `OrderedSchedules` 中日期 < 今天的项。
2. 收集现存的 `OrderedSchedules[].ClassPlanId`,删除所有 `IsOverlay` 但未被引用的课表。
3. 汇总「未过期课表」引用的 `TimeLayoutId` 集合,删除所有 `IsOverlay` 但未被引用的时间表。
**「加载临时课表…」右键菜单**:主界面右键菜单项 `"加载临时课表…"`(`repos/ClassIsland/ClassIsland/MainWindow.axaml:101-103`)打开选择器,写入 `TempClassPlanId` 与 `TempClassPlanSetupTime`;次日自动失效(见 6.3 步骤 1)。
**临时课表群** `SetupTempClassPlanGroup(key, expireTime?)`(`ProfileService.cs:362-390`)必须实现到期日推算:
```
today = now; dow = today.DayOfWeek
dw = floor((today.Date - SingleWeekStartTime.Date).TotalDays / 7) + 1
dayOffset = 0
for classPlan in 该群所有课表:
w = dw % WeekCountDivTotal
baseOffset = WeekDay - dow
divOffset = (WeekCountDiv + WeekCountDivTotal - w) % WeekCountDivTotal
finalOffset = baseOffset + divOffset*7
if finalOffset < 0: finalOffset += 7
dayOffset = max(dayOffset, finalOffset)
TempClassPlanGroupExpireTime = expireTime ?? today.Date + dayOffset 天
TempClassPlanGroupId = key; IsTempClassPlanGroupEnabled = true
```
课表群管理面板必须提供:临时课表群名称与「清除」按钮、启用 ToggleSwitch、`失效日期` CalendarDatePicker(绑定 `TempClassPlanGroupExpireTime`)、`覆盖模式` 下拉(选项顺序 `覆盖`、`继承`,对应 0=Override、1=Inherit)、`新建课表群` 按钮(受集控策略禁用)、课表群列表(ProfileSettingsWindow.axaml:342-413)。
### 6.7 档案管理(多档案)
`Profiles/` 下每个 `*.json` 即一个档案。档案列表必须只显示 `.json` 文件(`ProfileSettingsWindow.axaml.cs:297-303`)。管理操作与约束:
- **切换档案**:写入 `Settings.SelectedProfile = 文件名`,**必须重启应用生效**(界面文案 `"切换档案需要重新启动应用"`,ProfileSettingsWindow.axaml:143)。
- **新建档案**:输入名称 → 目标路径 `Profiles/{名称}.json`;已存在则中止;创建后写入默认档案(含 `Assets/default-subjects.json` 内置默认科目)。
- **重命名 / 复制 / 删除**:删除前必须二次确认;删除当前档案要阻止或提示【推断:源码使用通用确认对话框,未特判当前档案】。
- **保存**:档案属性变更即触发保存(`Profile.PropertyChanged += SaveProfile`,ProfileService.cs:164);关闭档案窗口时再保存一次(`ProfileSettingsWindow.axaml.cs:264-274`,`e.Cancel = true` 后 `Hide()`,即窗口只隐藏不销毁)。
- **信任档案**:档案 ID 写入 `Settings.TrustedProfileIds` 后才允许自动化「时间点行动」等敏感行为(`ProfileService.cs:166-176,417-422`;`LessonsService` 之外,`AutomationService.cs:91` 首先检查 `IsCurrentProfileTrusted`)。首次加载未信任档案必须显示「信任」入口。
- **集控模式**:档案文件名固定为 `_management-profile.json`;依次拉取课表、时间表、科目三份档案并按版本号合并进本地档案,合并后深拷贝并 `RefreshTimeLayouts()`(`ProfileService.cs:78-137,160-163`)。
`Profile.RefreshTimeLayouts()` 必须每次把档案级字典注入到每张课表(`TimeLayouts`、`ClassPlans` 引用),否则课表拿不到时间表(`Profile.cs:272-279`,`LessonsService.cs:551` 每 tick 调用一次)。Electron 实现中把它做成「档案 store 的 hydrate 步骤」,在加载与任何课表增删后执行。
### 6.8 科目管理
科目页(主标签 2)必须提供:新增、复制、删除科目;每项字段 `名称`、`简称`(Initial)、`教师名`(TeacherName)、`是否户外课程`(IsOutDoor)。
必须实现的行为:
- 新增科目时默认名 `""`;用户输入名称后自动补 `Initial`(见 6.1.6)。
- `开启「编辑后自动跳到下一节」`:`Settings.IsProfileEditorClassInfoSubjectAutoMoveNextEnabled`,默认 `true`(`Settings.cs:181`)。
- 删除科目时必须把引用它的课程 `SubjectId` 置空或提示,不得留下悬空 GUID【推断:源码删除逻辑直接移除科目条目,课程引用会退化为 `Guid.Empty`,显示为「???」;实现时建议删除前提示引用数量】。
- 科目选择器在课表表格中以「科目简称 + 科目名」呈现【推断:`ScheduleDataGrid` 内部实现,未在本次读取范围内给出确切模板】。
### 6.9 导入 / 导出
当前版本**实际可用**的导入导出入口在档案窗口的「导入导出」页(主标签 5),按类型分组渲染:导入组、导出组,两组之间加分隔线,每个提供方一个 `NavigationViewItem`(`ProfileSettingsWindow.axaml.cs:1565-1627`)。注册表(`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:292-296`)为:
| ID | 名称 | 类型 |
|---|---|---|
| `classisland.profileTransfer.import.cses` | 从 CSES 导入 | Import |
| `classisland.profileTransfer.import.legacyV1` | 从 ClassIsland 1.x 导入 | Import |
| `classisland.profileTransfer.import.classWidgets` | 从 Class Widgets 1.2 导入 | Import |
| `classisland.profileTransfer.export.cses` | 导出到 CSES | Export |
**本实现必须在该页额外提供「Excel 导入」「Excel 导出」两项(P1,见 6.9.1)**:原版这两项受实验开关 `Settings.ExpIsExcelImportEnabled`(默认 `false`)控制且实现已废弃,本实现必须默认可见(或以独立向导入口提供),同时保留该设置字段做配置兼容;不得删除 CSES / 1.x / Class Widgets 三项 P0 入口。
导入提供方统一交互:选择源文件 → 选择「合并到当前档案」或「导入为新档案」→ 执行;导入为新档案时若目标 `Profiles/{名称}.json` 已存在必须报错中止(`CsesImportProvider.cs:51-59`);成功 `Toast("导入成功。")`,失败记录日志并 `Toast("无法导入 CSES 课表", exception)`。CSES 文件类型过滤为 `*.yml`、`*.yaml`,头部文案 `"CESE 文件路径"`(`CsesImportProvider.cs:30-40`,注意源码拼写为 `CESE`,实现时保持可配置以便修正)。
**CSES → ClassIsland 转换算法(P0,必须实现)**(`repos/ClassIsland/ClassIsland.Shared/Extensions/CsesExtensions.cs:23-119`,必须逐条实现):
1. 目标档案 = 合并档案或新建档案;建立「科目名 → 科目 Guid」缓存,先用目标档案已有科目名填充。
2. 遍历 CSES `Subjects`:名字已存在则复用 Guid,否则新建 `Subject`;写入 `Name`;`SimplifiedName` 非空才覆盖 `Initial`;`Teacher` 非空才覆盖 `TeacherName`。
3. 遍历 CSES `Schedules`:找一张「上课时间点数量与 `i.Classes.Count` 相等,且每个上课时间点的 `StartTime/EndTime` 全部与 CSES 一致」的时间表;找不到则新建时间表。
4. 为每节课建立 `ClassInfo { SubjectId }`;若是新时间表,则同时生成时间点:每节课一个 `TimeType=0`(起止取 CSES 的起止),相邻两节之间补一个 `TimeType=1`(前一节结束 → 后一节开始)。
5. 课表:`TimeLayoutId` 指向该时间表;`TimeRule.WeekCountDiv = i.Weeks`;`TimeRule.WeekDay = (int)i.EnableDay`;`Name = i.Name`;新 Guid 注册进 `ClassPlans`。
**ClassIsland → CSES 导出算法(P0,必须实现)**(CsesExtensions.cs:127-166):
1. 把目标档案中不存在(按名称比对)的科目追加进 CSES 科目,写 `Name/SimplifiedName/Teacher`。
2. `Profile.RefreshTimeLayouts()` 后,**只导出** `TimeLayout != null && IsEnabled && WeekCountDivTotal <= 2 && WeekCountDiv <= 2` 的课表(即单双周以内且已启用的课表)。
3. 每张课表导出为一个 `Schedule { Name, Weeks = (WeekType)WeekCountDiv, EnableDay = (DayOfWeek)WeekDay }`,并按 `Classes` 顺序填 `ClassInfo { StartTime, EndTime, Subject }`,`Subject` 取科目名,找不到时写空串。
#### 6.9.1 Excel 导入 / 导出(**P1:必须实现**,用 `xlsx` 重建)
**定位与来源边界**:ClassIsland 2.1 源码中两个 Excel 窗口整体被 `#if false` 屏蔽(`repos/ClassIsland/ClassIsland/Views/ExcelImportWindow.xaml.cs:1`、`repos/ClassIsland/ClassIsland/Views/ExcelExportWindow.xaml.cs:1,539`),DI 注册被注释(`App.Services.xaml.cs:145,204`),且仓库内已不存在 `ExcelImportViewModel` / `ExcelExportViewModel` 类型(全仓检索无命中,属不可编译的遗留骨架)。因此:
- **本实现必须独立重建该功能(P1,不是可选项)**:在主进程使用 **`xlsx`(SheetJS)** 读写 `.xlsx`,用 `fs` 存盘、`shell.openPath` 打开文件;**不得**引入 `unvell.ReoGrid`、`OfficeOpenXml`、WPF 控件或依赖 ClassIsland 的窗口实现。
- 源码里的 `ExcelImportWindow.xaml.cs` / `ExcelExportWindow.xaml.cs` **只作为布局与交互参考**(步骤机、拖放、单元格选择、导出参数),不得整段移植。
- **导入/导出向导的 UI(步骤、表单、预览面板)归 Part 11**(windows 负责);本节只规定**数据契约、列格式、映射算法、往返一致性与错误校验**,Part 11 的向导必须调用本节定义的契约层(建议单独 `excel-io.ts` 模块,纯函数、可单测)。
**文件与工作表约定**:
1. 文件扩展名 `.xlsx`;默认工作表 `课表`(第 1 个 sheet)。
2. 可选第 2 个 sheet `时间表`,用于承载时间点(保证往返可还原时间表,见 6.9.1.5)。
3. 兼容「单 sheet 模式」:只有 `课表` 时,导入必须要求用户在向导里另行选择/导入时间表(对应源码 `ImportTimeLayoutOnly` 流程,`ExcelImportWindow.xaml.cs:388-397`)。
**课表矩阵列格式(必须按此实现与校验)**:
| 位置 | 内容 |
|---|---|
| `A1` | 空或标题(导入忽略) |
| `B1..H1` | 星期名,**顺序固定 星期日、星期一 … 星期六**(对应 `(int)DayOfWeek` 0..6);**只写存在课表的星期**(源码 `GetWeekDayName` 与 `cellData[7]` 按 0..6 输出,`ExcelExportWindow.xaml.cs:340-350,369-435`) |
| `A2..A(n+1)` | 节次序号 `1..n`,**文本格式**(源码 `Cells[pos].Data = i.ToString(); DataFormat = Text`,437-442) |
| `B2..` 数据格 | 科目名;若启用教师名则在同格内用 `\n` 追加教师名(403-408) |
| 同一格多课表 | 用 `/` 连接科目名(403-408);教师名同样 `/` 连接 |
样式:`A` 列宽 32,数据列宽 = 导出参数 `ColWidth`;行高 = `RowHeight`;`TextWrap = BreakAll`;对齐 `HorAlign/VerAlign`;前景/背景色;边框 0=无、1=细实线、2=粗实线(`ExcelExportWindow.xaml.cs:446-472,456-458`)。
**`时间表` sheet 列格式**:A 列从 `A1` 起每行一个时间范围文本,支持 `HH:mm-HH:mm` 与 `HH:mm:ss-HH:mm:ss`;导入解析规则必须照抄:**提取文本中连续数字,个数必须为 4 或 6,否则该行跳过并记录警告**(`ExcelImportWindow.xaml.cs:428-450`);解析成功生成 `TimeType=0` 的上课时间点,随后在相邻两节之间自动补 `TimeType=1` 的课间(仅当 `下一节.StartTime - 上一节.EndTime > 0`,505-522)。
**导入映射规则**:
1. **科目匹配**:数据格文本 `text.Contains(subject.Name)` 即视为命中(源码 627-632);多个科目名同时命中取档案中第一个;`/` 分隔的多科目必须按段分别匹配。
2. **星期 → 课表**:每个星期列生成一张 `ClassPlan`,`TimeRule.WeekDay` = 该列对应 0..6,`TimeLayoutId` = 本次导入的时间表。
3. **节次 ↔ 时间点对齐**:课表行数必须 ≤ 时间表「上课时间点」数量;超出 → 报错并中止(不允许静默截断)。
4. **单双周**:若列中存在 `/` 合并(多课表同列),必须拆成多张课表并写入各自的 `WeekCountDiv/WeekCountDivTotal`;列首若带周次标记(见 6.9.1.5 新增约定),按标记写入。
5. **完成导入**:每张课表分配新 Guid;导入过程中被临时激活的时间表必须复位 `IsActivated=false`、`IsActivatedManually=false`(源码 `CompleteImport`,695-706)。
6. **只导时间表模式**:`ImportTimeLayoutOnly=true` 时只写入时间表,不创建课表(388-397)。
**往返一致性契约(必须显式实现并写进向导文案)**:
- 可往返:单张单周课表 + 科目名 + 教师名开关一致时,科目矩阵逐格可还原。
- **不可往返、必须补齐的项**:
1. 原版导出**完全不写时间点**(只写节次序号,437-442),因此原版导出文件无法还原时间表。本实现必须写 `时间表` sheet(或把 A 列改为 `HH:mm-HH:mm` 文本),否则导入必须强制让用户另选时间表。
2. 原版导出把同一星期的多张课表用 `/` 合并到同一格(403-408),**丢失单双周/多周信息**。本实现必须增加「周次」行(如 `单周`/`双周`/`2/4周`)或拆分为多行块,确保 `WeekCountDiv` 与 `WeekCountDivTotal` 可往返。
3. `Initial` 不导出 → 往返后由 `Name` 首字自动重算(见 6.1.6);`IsOutDoor` 不导出 → 往返后丢失,向导必须提示。
- 验收等价性:导出 → 导入 → 再导出,两次导出文件的**单元格文本矩阵必须逐格相等**(颜色/列宽/边框不要求)。
**导入前全量预检(任一硬错误不得写入档案)**:
1. 非 `.xlsx` / 缺少 `课表` sheet / 表头缺失 → 结构化错误并中止。
2. 时间范围数字个数 ≠ 4/6、时 >23、分/秒 >59、`Start >= End` → 逐行错误。
3. 节次序号不连续或重复 → 警告,允许继续但必须二次确认。
4. 列首不是合法星期名 → 该列跳过并在报告中列出。
5. 科目名未在档案中 → 逐格报告,提供「新建科目 / 置空 / 忽略该格」三选一。
6. 解析结果为空(无任何课表或时间点)→ 拒绝并提示。
7. 文件被占用 / 损坏 / 密码保护 → 捕获并以 dialog/toast 展示,不得崩溃。
**导出参数与默认值**:导出起点单元格由向导选择(对应源码 `ClassPlanStartPos`);参数含 `RowHeight`、`ColWidth`、`IsTeacherNameEnabled`、`HorAlign`、`VerAlign`、`ForegroundColor`、`BackgroundColor`、`BorderMode`;导出后支持「保存文件」与「保存并用 Excel 打开」(源码 `SaveFile`/`ButtonSaveAndOpenInExcel`,486-516)。由于 ViewModel 已缺失,以下默认值标【推断】:`RowHeight=24`、`ColWidth=80`、`IsTeacherNameEnabled=false`、`HorAlign=Center`、`VerAlign=Center`、前景 `#FF000000`、背景 `#FFFFFFFF`、`BorderMode=1`。以上默认值必须在向导中可见、可改,并持久化到 `Settings`(新增 `ExcelExportSettings` 分组,默认值即上表)。
### 6.10 附加设置(AttachedObjects)模型
附加设置**不是 1.7 遗留**,而是 2.x 仍在使用的机制:基类字段定义在 `repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:15`,2.x 的 `AttachedSettingsControlPresenter` 仍在读写它(`repos/ClassIsland/ClassIsland.Core/Controls/AttachedSettingsControlPresenter.axaml.cs:241` 读取、`:361` 回写)。实现时必须完整支持。
#### 6.10.1 定义与承载对象
`AttachableSettingsObject` 提供:
```json
"AttachedObjects": {
"<设置 GUID>": { /* 该附加设置的设置对象 */ }
}
```
- 属性名固定为 **`AttachedObjects`**(C# 属性名,System.Text.Json 默认序列化;键为 GUID 字符串,值是多态对象)(`AttachableSettingsObject.cs:15`)。
- 三个 API 必须等价实现(`AttachableSettingsObject.cs:23-61`):
- `GetAttachedObject<T>(id)`:取不到返回 `null`;若存的是 `JsonElement` 则反序列化为 `T`。
- `GetAttachedObject<T>(id, defaultValue)`:取到则**回写**并返回;取不到则**写入 default 并返回 default**(注意这会产生副作用,规范要求「读」不应改变档案,实现时请把该重载改成纯读,或在调用处显式接受副作用——以纯读为推荐,并在代码注释标注与源码差异)。
- `WriteAttachedObject<T>(id, o)`:直接写入。
- 必须继承 `AttachableSettingsObject` 的模型:`ClassPlan`(`ClassPlan.cs:13`)、`ClassInfo`(`ClassInfo.cs:10`)、`TimeLayout`(`TimeLayout.cs:13`)、`TimeLayoutItem`(`TimeLayoutItem.cs:11`)、`Subject`(`Subject.cs:8`)。
- **`Profile` 本身不继承该基类**(`Profile.cs:14` 直接继承 `ObservableRecipient`),因此**不得**把附加设置挂在档案根上。
#### 6.10.2 注册表与 GUID(必须硬编码,与提供方/渠道共用 GUID 空间)
附加设置控件用两个特性声明:`[AttachedSettingsUsage(AttachedSettingsTargets targets)]` 与 `[AttachedSettingsControlInfo(guid, name, iconGlyph, hasEnabledState=true)]`(`ClassIsland.Core/Attributes/AttachedSettingsUsage.cs:9-15`、`AttachedSettingsControlInfo.cs:10-44`)。`AttachedSettingsTargets` 是 `[Flags]`(`ClassIsland.Core/Enums/AttachedSettingsTargets.cs:6-33`):`None=0`、`Lesson=1`、`Subject=2`、`TimePoint=4`、`ClassPlan=8`、`TimeLayout=16`。
内置 4 个附加设置(注册于 `App.Services.xaml.cs:234-238`):
| GUID | 名称 | 图标 | 可附加目标 | 有启用开关 |
|---|---|---|---|---|
| `08F0D9C3-C770-4093-A3D0-02F3D90C24BC` | 上课提醒设置 | (空/默认) | ClassPlan \| TimeLayout \| Lesson \| Subject \| TimePoint | 是 |
| `58e5b69a-764a-472b-bcf7-003b6a8c7fdf` | 课程显示设置 | `\ue479` | ClassPlan \| TimeLayout \| Lesson \| Subject \| TimePoint | 是 |
| `8FBC3A26-6D20-44DD-B895-B9411E3DDC51` | 放学提醒设置 | `\ued34` | ClassPlan \| TimeLayout | 是 |
| `7625DE96-38AA-4B71-B478-3F156DD9458D` | 天气提醒设置 | `\ue4dc` | 仅 TimePoint | **否**(`hasEnabledState=false`) |
证据:`Controls/AttachedSettingsControls/ClassNotificationAttachedSettingsControl.axaml.cs:12-16`、`LessonControlAttachedSettingsControl.axaml.cs:11-15`、`AfterSchoolNotificationAttachedSettingsControl.axaml.cs:11-13`、`WeatherNotificationAttachedSettingsControl.axaml.cs:11-13`。
设置模型与默认值(`Models/AttachedSettings/*.cs`):
- `ClassNotificationAttachedSettings`:`IsAttachSettingsEnabled=false`,`IsClassOnNotificationEnabled=true`、`IsClassOnPreparingNotificationEnabled=true`、`IsClassOffNotificationEnabled=true`、`ClassPreparingDeltaTime=60`、`ClassOnPreparingText="准备上课,请回到座位并保持安静,做好上课准备。"`、`OutdoorClassOnPreparingText="下节课程为户外课程,请合理规划时间,做好上课准备。"`、`ClassOnPreparingMaskText="即将上课"`、`ClassOnMaskText="上课"`、`ClassOffMaskText="课间休息"`、`ClassOffOverlayText=""`(153 行文件,见 7.4.1)。
- `LessonControlAttachedSettings`:`IsAttachSettingsEnabled=false`、`ShowExtraInfoOnTimePoint=true`、`ExtraInfoType=0`、`IsCountdownEnabled=true`、`CountdownSeconds=60`、`ExtraInfo4ShowSecondsSeconds=0`、`ScheduleSpacing=1`、`ShowCurrentLessonOnlyOnClass=false`、`IsNonExactCountdownEnabled=false`(`ClassIsland.Core/Models/AttachedSettings/LessonControlAttachedSettings.cs:9-17`)。
- `AfterSchoolNotificationAttachedSettings`:`IsAttachSettingsEnabled=false`、`IsEnabled=true`、`NotificationMsg="今天的课程已结束,请同学们有序离开。"`(`Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:11-13`)。
- `WeatherNotificationAttachedSettings`:`IsAttachSettingsEnabled=**true**`、`AlertShowMode=Default`、`ForecastShowMode=Default`(`Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:11-13`)。
#### 6.10.3 读取优先级(必须逐层实现)
`GetAttachedSettingsByPriority<T>(id, subject, timeLayoutItem, classPlan, timeLayout)` 固定按 **Subject → TimeLayoutItem → ClassPlan → TimeLayout** 顺序逐层查找,返回**第一个 `IsAttachSettingsEnabled == true`** 的对象;全部未命中返回 `null`(`repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IAttachedSettingsHostService.cs:24-43`;官方文档 `repos/ClassIsland/doc/AttachedSettings.md:50-57` 的 mermaid 顺序同为 Subject → TimeLayoutItem → ClassPlan → TimeLayout)。
- 调用方传参顺序必须与上表一致:`GetAttachedSettingsByPriority(ProviderGuid, CurrentSubject, CurrentTimeLayoutItem, CurrentClassPlan, CurrentClassPlan?.TimeLayout)`(上课/下课/准备上课,见 7.4.1);天气按「当前时间点」查找(`WeatherService` 侧见 7.4.3)。
- `IsAttachSettingsEnabled` 的判定要兼容「JSON 里存的是 `JsonElement`」:`IAttachedSettings.GetIsEnabled(obj)` 在对象为 `JsonElement` 时读 `IsAttachSettingsEnabled` 属性是否为 `true`,为 `JsonElement` 但缺失该属性 → false(`ClassIsland.Shared/Interfaces/IAttachedSettings.cs:20-38`)。
- 不得实现「就近优先」以外的隐式规则(如按课程时长/科目类型自行加权)。
#### 6.10.4 写入与序列化纪律
- 编辑控件回写规则(必须照做,`AttachedSettingsControlPresenter.axaml.cs:354-362`):**仅当** `IsAttachSettingsEnabled == true` **或** 控件 `HasEnabledState == false` 时,才把设置对象写回 `TargetObject.AttachedObjects[ControlInfo.Guid]`;否则**不回写**(源码注释:以降低档案文件体积)。因此「关闭附加设置」在档案里表现为该 GUID 不存在,而不是存一个 `IsAttachSettingsEnabled=false` 的对象。
- 读取失败(类型不匹配/JSON 损坏)→ 丢弃该 GUID 的项、记日志、继续加载其余项,不得让整个档案加载失败【推断:源码 `GetAttachedObject` 直接 `Deserialize`,未捕获异常,实现必须补 try/catch】。
- 反序列化时必须按 GUID 在注册表中找到设置类型再反序列化;找不到类型时保留原始 JSON(插件可后续注册),不得丢弃。
- 附加设置 JSON 必须与课表/时间表/科目同文件存储(`Profiles/*.json`),**不得**放进 `Settings.json`,也不得用组件设置(`ComponentSettings`)替代。
#### 6.10.5 与 1.7 导入的兼容
`ClassIsland1ImportProvider` 导入 1.x 档案时必须迁移附加设置(`Controls/ProfileTransferProviders/ClassIsland1ImportProvider.cs:32-57` 调用 `ClassIslandV1ProfileTransferHelper`):
1. 1.x 的「附加设置」按对象分别存放;迁移时按**控件 GUID** 一一映射到 2.x 的 `AttachedObjects`(4 个内置 GUID 见 6.10.2)。
2. 未知 GUID 的附加设置必须原样保留(键与值都不丢),以便插件后续注册后仍可读取。
3. 迁移失败(JSON 结构不同)→ 记录警告并跳过该项,不得阻断整个导入【1.x JSON 的确切结构未在本次读取范围内确认,具体字段映射标【推断】,需补读 `ClassIslandV1ProfileTransferHelper` 后细化】。
#### 6.10.6 与档案分析 / 课表详细信息的关系
- 「课表详细信息」窗口的「附加设置结果」页签依赖 `ProfileAnalyzeService` 对象图 + 本节优先级链,展示某节课实际会用到哪些附加设置(见 6.11)。
- 节点状态用 `AttachedSettingsControlState`:`Disabled=0`、`Enabled=1`、`Override=2`(`ClassIsland.Core/Enums/AttachedSettingsControlState.cs:3-8`),由「在当前对象上是否存在该 GUID 且启用」推导:不存在 → Disabled;存在且启用但被更近层覆盖 → Override;最终生效 → Enabled【推导逻辑为【推断】,源码在 `ClassPlanDetailsWindow.axaml.cs` 中计算,未细读】。
- `ClassPlanDetailsWindow` 的节点状态条颜色:`Enabled` = `AccentFillColorDefaultBrush`、`Override` = `SystemFillColorCautionBrush`,列表项高度 36、圆角 4(`Views/ClassPlanDetailsWindow.axaml:108-166`)。
#### 6.10.7 边界
1. 同一 GUID 在多层存在但都未启用 → 等价于不存在。
2. `TimePoint` 级别的附加设置只有在其时间点被课程实际引用时才可能生效(未引用节点在档案分析里会提示「当前节点未被任何一个课表使用」,`AttachedSettingsControlPresenter.axaml.cs:209-213`)。
3. 删除科目/时间点/课表时,其 `AttachedObjects` 随之删除,不得残留在档案里(对象被整体移除即满足)。
4. 天气提醒附加设置 `hasEnabledState=false`:UI 不得显示启用开关;写回不受 `IsAttachSettingsEnabled` 限制(默认 true 仅为兼容)。
### 6.11 档案分析(可视化辅助)
必须实现档案对象图分析,用于「课表详细信息」窗口判定附加设置的生效顺序(`repos/ClassIsland/ClassIsland/Services/ProfileAnalyzeService.cs`):
- 节点类型:`ClassPlan`、`TimeLayout`(Target=TimeLayout)、`TimePoint`、`Lesson`(ClassInfo)、`Subject`(28-133)。
- 地址:`{ Guid, Index }`;时间点 Index 为其在 `Layouts` 中的下标,课程 Index 为其在 `Classes` 中的下标(`repos/ClassIsland/ClassIsland.Core/Models/ProfileAnalyzing/AttachableObjectAddress.cs`)。
- 边:课表 ↔ 时间表、时间表 ↔ 时间点、课表 ↔ 时间点、课表 ↔ 课程、时间点 ↔ 课程、课程 ↔ 科目;同时维护 `PreviousNodes` 与 `NextNodes`(双向),并为每类对象维护 `RelatedLessons`。
- 查询:`FindNextObjects(address, id)` 向下遍历、结果按 `Target` **降序**排序;`FindPreviousObjects` 向上遍历、按 `Target` **升序**排序;两者都可要求「附加设置已启用」。遍历时遇到 `Subject` 节点停止继续向上/向下扩散(`Walk` 中 `if (node.Target == Subject && !isInit) return;`,158-177)。
- 调试:支持导出 Mermaid `graph LR` 文本(139-156)。
「课表详细信息」窗口(`repos/ClassIsland/ClassIsland/Views/ClassPlanDetailsWindow.axaml:14-22`)参数:标题 `"课表详细信息"`、`Height=500`、`Width=800`;左列 `1*` 且 `MinWidth=300 MaxWidth=500`,中间分隔条宽 5,右列 `2*`。左侧表格列固定为「时间」(`StartTime-EndTime`)与「科目」;右侧为「附加设置结果」页签,先给出说明 `"以下的附加设置将按从上到下的顺序使用。"` 再列出节点,节点左侧 4px 状态条:`Enabled` 用 `AccentFillColorDefaultBrush`,`Override` 用 `SystemFillColorCautionBrush`,列表项高度 36、圆角 4。
### 6.12 边界与异常(必须处理)
1. **时间点重叠 / 逆序**:`StartTime > EndTime` 必须拒绝(源码 `EnsureTime` 抛 `ArgumentException("时间点起始时间无效。")`,`TimeLayoutItem.cs:95-101`),UI 层必须先校验再写模型。
2. **跨天**:时间点只在同一天内比较,不支持跨 0 点;若用户排出 `23:50-00:10` 必须提示非法。
3. **空课表**:`CurrentClassPlan == null`、`TimeLayout == null` 时所有派生状态必须安全降级,不得空引用。
4. **课表引用的时间表被删**:必须回退为 `TimeLayoutId = Guid.Empty` 并显示「未选择时间表」占位;不得崩溃。
5. **本周无匹配课表**:`CurrentClassPlan=null`,组件显示空态,`OnClass`/`OnBreakingTime`/`OnAfterSchool` 均不触发。
6. **时间表被多个课表共享**:修改时间表必须联动所有共享课表(`ClassPlan.TimeLayoutOnLayoutObjectChanged` 会 `RefreshClassesList`),且「应用到所有课表」的科目覆写只影响同一 `TimeLayoutId` 的课表。
7. **临时课表/临时层与预定课表冲突**:优先级固定为 预定课表 > 临时课表 > 常规课表;临时层覆盖临时课表的提示文案必须出现(`"临时课表被临时层课表覆盖。要使用临时课表,请暂时禁用或清除临时层课表。"`,ProfileSettingsWindow.axaml:283-292)。
8. **档案 JSON 损坏**:加载失败必须回退为空档案并保留原文件为 `.bak`,同时在设置页提示【推断:`ConfigureFileHelper.LoadConfig` 的容错细节未在本次读取范围内给出确切行为,实现时必须显式兜底】。
9. **并发写入**:同一档案的自动保存必须串行化(建议 300ms 防抖 + 单写队列),避免 Electron 主进程多窗口同时写导致截断。
### 6.13 验收标准
1. 新建档案后 `Profiles/*.json` 中同时存在默认课表群与全局课表群,GUID 与本节一致;删除/解散这两个群会被拒绝。
2. 构造「周一/周三/周五 + 单周 + 不轮换」「周二 + 2 周轮换第 2 周」两组课表,把系统时间改到对应日期与周次,`当前课表` 必须正好切换;把 `MultiWeekRotationMaxCycle` 改成 2 后,`WeekCountDivTotal=4` 的课表必须**永不启用**。
3. 时间表新增一节 40 分钟上课 + 10 分钟课间,`Classes` 自动补齐数量且顺序与上课时间点一致;删除时间点后对应课程同步消失,撤销后恢复原位。
4. 在 08:00 之前插入上课时间点会触发「向前插入」提示;插入分割线/行动到已存在时刻会被拒绝并给出对应警告文案。
5. 课表中「永久换课」只在当前课表写入 `SubjectId`;「临时换课」必须新建当天临时层课表(`IsOverlay=true`,名称带 `(临时层)`)并置 `IsChangedClass=true`,次日自动被清理。
6. 「换课」两段式交换后两张课表的两个 `SubjectId` 互换;起点/终点任一为空时不发生任何数据变更。
7. 关闭档案窗口再打开,所有编辑都已落盘;重启应用后 `SelectedProfile` 生效。
8. 从 CSES 导入后:科目不重复(同名复用)、时间表按上课起止自动匹配或新建、单双周与星期正确写入;导出到 CSES 时只包含已启用且单双周内的课表。
9. 课表详细信息窗口列出的附加设置顺序与生效顺序一致,状态条颜色符合 `Enabled`/`Override` 规则。
10. 全程不得出现「点击后无提示、数据半更新」的情况;所有校验失败都必须在 UI 上给出明确中文提示。
11. Excel 往返:导出 → 导入 → 再导出后,两次导出文件的**单元格文本矩阵逐格相等**;时间表可通过 `时间表` sheet(或 A 列 `HH:mm-HH:mm` 文本)完整还原;同一星期的单双周课表往返后 `WeekCountDiv/WeekCountDivTotal` 不丢。
12. Excel 预检:时间格式错误、起止逆序、列首非法星期、科目名缺失、解析结果为空这五类情况必须被拦截并给出可定位(行/列)的报告,且**档案文件未被写入**。
13. 附加设置优先级:在 Subject、TimePoint、ClassPlan、TimeLayout 四层分别写入同一 GUID 并启用,读取结果必须严格按 `Subject → TimeLayoutItem → ClassPlan → TimeLayout` 返回第一个启用项;未启用的层不得在档案里留下该 GUID 键。
14. 附加设置序列化:JSON 中键名为 `AttachedObjects`、键为 GUID 字符串;加载时未知 GUID 原样保留、损坏项被跳过且不影响整个档案加载;`Profile` 根上不得出现该字段。