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