## 13. 数据模型、存储、备份与迁移 ### 13.1 存储总则(必须遵守) 1. 本应用是**纯本地单机应用**,所有设置、档案、课表、组件、自动化、提醒历史、主题、插件配置必须存在本机,**不得**依赖任何服务器;断网、无账号、首次启动都必须完整可用。 2. 存储根目录必须是 `app.getPath('userData')`(Windows 为 `%APPDATA%\ClassIsland`,macOS 为 `~/Library/Application Support/ClassIsland`,Linux 为 `~/.config/ClassIsland`)。**不得**把用户数据写进安装目录(安装目录可能只读、且升级会被整体替换)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/config.ts:165-167`(`path.join(app.getPath('userData'), 'config.json')`)与 `repos/ClassIsland/ClassIsland.Core/CommonDirectories.cs:16-28`(ClassIsland 的 `AppRootFolderPath`/`Config`/`Logs` 划分)。 3. 所有持久化文件必须是 **UTF-8 无 BOM**、LF 换行、两空格缩进的 JSON;字段名大小写必须按第 13.3/13.4 节的约定(ClassIsland 兼容文件用 PascalCase,本应用运行期配置用 camelCase)。读入时必须**同时容忍**紧凑格式(无缩进、无换行)与带注释之外的一切合法 JSON;**不得**写注释(ClassIsland 的 `examples/example.json` 带注释只是示例,真实加载器不支持注释,见 `repos/ClassIsland/examples/example.json:1-2`)。 4. 所有写入必须**原子化**:写 `.tmp` → `fsync` → `rename` 覆盖目标;禁止直接 `writeFile` 覆盖正在被读的文件。依据:`repos/ClassIsland/ClassIsland.Shared/Helpers/ConfigureFileHelper.cs:182-188`(`FileStream.Flush(true)` 后关闭)。 5. 每次成功读取 JSON 后必须把原文件复制为 `.bak`;读取失败时自动回退加载 `.bak`,成功后把 `.bak` 复制回目标(对齐 `ConfigureFileHelper.LoadConfigUnWrapped` 的语义,见 `ConfigureFileHelper.cs:63-96`)。若 `.bak` 也失败,必须使用**全默认值**并把错误加入"配置错误列表"(`ConfigError{ Path, Code, IsFallback, Exception }`,见 `repos/ClassIsland/ClassIsland.Shared/Models/ConfigError.cs`),在设置页"诊断/存储"中展示。 6. 所有配置对象必须"**缺失字段用默认值补齐、未知字段忽略、越界值夹紧**"。禁止因为新增字段就让老配置文件加载失败。依据:`repos/ClassIsland/ClassIsland/Services/SettingsService.cs:63-118`(加载失败只记日志并继续)与 `config.ts:198-233`(逐字段归一化)。 7. 所有写入必须经过**单一写入入口**(`storage.ts` 的 `saveXxx()`),禁止业务代码直接 `fs.writeFile`;该入口负责合并写、备份、事件通知。依据:`config.ts:235-346`。 ### 13.2 目录与文件清单(必须逐项命名一致) ```text / # app.getPath('userData') ├─ config.json # 运行期设置(camelCase;对应 ClassIsland Settings.json) ├─ Settings.json # ClassIsland 格式兼容文件(PascalCase;仅只读导入/导出,运行期不读写) ├─ data/ │ ├─ profiles/ │ │ ├─ Default.json # Profile 档案(PascalCase,与 ClassIsland 同构) │ │ └─ <档案名>.json │ ├─ component-layouts/ │ │ └─ Default.json # ComponentProfile(PascalCase) │ ├─ automations/ │ │ └─ Default.json # Workflow[] 数组(PascalCase) │ ├─ themes/ │ │ ├─ <主题Id>/ # 主题包解压目录(manifest、样式、资源) │ │ └─ EnabledThemes.json # string[],启用中的主题 Id │ ├─ plugins/ # 插件解压目录:<插件Id>/ │ ├─ plugin-data/ # 插件私有存储:<插件Id>/storage.json │ ├─ notification-history.json # 通知历史(用于提醒历史视图) │ └─ generated/ │ └─ default-subjects.json # 首次启动写入的默认科目表 ├─ backups/ # 自动/手动备份 zip ├─ logs/ │ └─ main.log # 超过 1MB 轮转为 main.log.1 ├─ cache/ │ ├─ plugin-index/ # 插件市场索引缓存 │ ├─ theme-packages/ # 主题包下载缓存 .zip │ └─ update/ # 更新清单/元数据缓存 └─ updates/ # 待安装更新包、policy.json、install-result.json ``` 依据:`repos/ClassIsland/ClassIsland/Services/PluginService.cs:35-44`(插件三目录)、`XamlThemeService.cs:47-49`(主题两目录 + `EnabledThemes.json`)、`AutomationService.cs:27-32`(自动化目录 + `.json`)、`ComponentsService.cs:28-58`(`ComponentLayouts/.json`)、`ProfileService.cs:48-58`(`Profiles/` 与 ``)、`FileFolderService.cs:23-49`(需要预创建的目录清单)、`auto-update.ts:131-133`(`updates/`)、`logger.ts:32-39`(`logs/main.log`)、`config.ts:165-167`(`config.json`)。 ### 13.3 `config.json` 完整 schema(字段级,含默认值) `config.json` 是 ClassIsland `Settings.json` 的超集;下表的"ClassIsland 对应字段"必须保留语义,默认值必须与源码一致。**任何字段缺失都必须落到默认值,任何越界值都必须夹紧到合法区间。** **`Settings.json` 兼容层(强制)**:运行期只读写 `config.json`;`Settings.json` 只作为「ClassIsland 格式导入/导出兼容层」存在——导入时**只读**解析(`PascalCase` → 本实现字段映射),导出时生成一份 ClassIsland 可识别的 `Settings.json`(`PascalCase`)。**禁止双向双写**(不得同时写 `config.json` 与 `Settings.json`,否则两份文件必然会分叉)。导入映射必须保留历史拼写,尤其是 `IsIslandSeperated`(ClassIsland 源码就是少一个 `a`,`repos/ClassIsland/ClassIsland/Models/Settings.cs:1852`)——本实现内部字段可写作 `isIslandSeparated`,但导入/导出映射表必须双向接受原拼写。**【推断】** #### 13.3.1 外观与主界面 | 字段 | 类型 | 默认值 | 合法区间/枚举 | ClassIsland 依据 | |---|---|---|---|---| | `theme` | number | `2` | `0` 亮 / `1` 暗 / `2` 跟随系统 | `Settings.cs:32` | | `primaryColor` | string | `#00BFFF` | `#RRGGBBAA` | `Settings.cs:35`(DeepSkyBlue) | | `secondaryColor` | string | `#7FFFD4` | 同上 | `Settings.cs:36`(Aquamarine) | | `isCustomBackgroundColorEnabled` | boolean | `false` | — | `Settings.cs:33` | | `backgroundColor` | string | `#000000FF` | 同上 | `Settings.cs:34`(Colors.Black) | | `isCustomForegroundColorEnabled` | boolean | `false` | — | `Settings.cs:202` | | `customForegroundColor` | string | `#1E90FF` | 同上 | `Settings.cs:203`(DodgerBlue) | | `opacity` | number | `0.5` | `0.1`–`1` | `Settings.cs:64` | | `scale` | number | `1.0` | `0.75`–`2.5` | `Settings.cs:86` | | `radiusX` / `radiusY` | number | `8` / `8` | `0`–`20` | `Settings.cs:183-184` | | `mainWindowFont` | string | 跟随系统默认字体 | 合法字体族名 | `Settings.cs:95` | | `mainWindowFontWeight` | number | `500`(Medium) | `100`–`900` | `Settings.cs:101` | | `mainWindowSecondaryFontSize` | number | `14` | `8`–`96` | `Settings.cs:197` | | `mainWindowBodyFontSize` | number | `16` | 同上 | `Settings.cs:198` | | `mainWindowEmphasizedFontSize` | number | `18` | 同上 | `Settings.cs:199` | | `mainWindowLargeFontSize` | number | `20` | 同上 | `Settings.cs:200` | | `mainWindowLineVerticalMargin` | number | `5` | `0`–`150` | `Settings.cs:219` | | `isIslandSeperated` | boolean | `false` | — | `Settings.cs:1852`(拼写保留) | | `reduceProgressAccuracy` | boolean | `true` | — | `Settings.cs:1853` | | `animationLevel` | number | `1` | `0` 关闭 / `1` 标准 / `2` 全部 | `Settings.cs:1851` | | `isScreenRecordingModeEnabled` | boolean | `false` | — | `Settings.cs:1861` | | `isWindowCaptureBlockingEnabled` | boolean | `false` | — | `Settings.cs:1862` | #### 13.3.2 窗口、停靠与隐藏 | 字段 | 类型 | 默认值 | 说明 | 依据 | |---|---|---|---|---| | `windowDockingLocation` | number | `1` | `0`–`5`:左上/中上/右上/左下/中下/右下 | `Settings.cs:48` | | `windowDockingOffsetX/Y` | number | `0`/`0` | 停靠偏移(px) | `Settings.cs:49-50` | | `windowDockingMonitorIndex` | number | `0` | 多显示器索引 | `Settings.cs:51` | | `windowLayer` | number | `1` | 窗口层级(普通/置顶/桌面) | `Settings.cs:52` | | `isMouseClickingEnabled` | boolean | `false` | 鼠标穿透(true = 点击穿透) | `Settings.cs:53` | | `hideOnClass` | boolean | `false` | 上课自动隐藏 | `Settings.cs:45` | | `hideOnMaxWindow` | boolean | `false` | 有最大化窗口时隐藏 | `Settings.cs:63` | | `hideOnFullscreen` | boolean | `false` | 全屏时隐藏 | `Settings.cs:54` | | `excludedFullscreenWindow` | string[] | `[]` | 全屏检测排除的进程名 | `Settings.cs:56-59` | | `hideMode` | number | `0` | `0` 基本(上课/最大化/全屏);`1` 高级(规则集) | `Settings.cs:185` | | `hideRules` | Ruleset | 空规则集 | 见 13.5.3 | `Settings.cs:186` | | `useRawInput` | boolean | `false` | 原始输入(兼容某些输入法) | `Settings.cs:192` | | `isMouseInFadingEnabled` | boolean | `true` | 鼠标进入时淡化 | `Settings.cs:193` | | `isMouseInFadingReversed` | boolean | `false` | 反向淡化 | `Settings.cs:194` | | `touchInFadingDurationMs` | number | `0` | 触摸淡化时长 | `Settings.cs:195` | | `isCompatibleWindowTransparentEnabled` | boolean | `false` | 兼容透明(老显卡) | `Settings.cs:196` | | `windowTopmostRecheckMode` | number | `0` | 置顶复查策略 | `Settings.cs:1860` | | `showCurrentLessonOnlyOnClass` | boolean | `false` | — | `Settings.cs:209` | | `isSwapMode` | boolean | `true` | 换课模式 | `Settings.cs:210` | **坐标口径(强制)**:`windowDockingOffsetX/Y`、`scale`、`radiusX/Y`、所有字号与组件尺寸/间距字段一律以 **DIP(CSS px)** 存储与解释;窗口 bounds 也按 DIP 交给 Electron。**禁止**把 `devicePixelRatio` 乘进这些字段;只有截图/像素级视觉验收时才用 `devicePixelRatio` 把 DIP 换算成物理像素(与 04 部分同一口径)。 #### 13.3.3 课程与时间 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `selectedProfile` | string | `"Default.json"` | `Settings.cs:66` | | `singleWeekStartTime` | string(ISO) | 首次启动时写入当天 00:00 | `Settings.cs:60`(与其相关的周次计算见 ProfileService) | | `multiWeekRotationOffset` | number[5] | `[-1,-1,0,0,0]` | `Settings.cs:60` | | `multiWeekRotationMaxCycle` | number | `4` | `Settings.cs:61` | | `classPrepareNotifySeconds` | number | `60` | `Settings.cs:43` | | `isClassPrepareNotificationEnabled` | boolean | `true` | `Settings.cs:47` | | `isClassChangingNotificationEnabled` | boolean | `true` | `Settings.cs:46` | | `isClassOffNotificationEnabled` | boolean | `true` | `Settings.cs:55` | | `showDate` | boolean | `true` | `Settings.cs:44` | | `showExtraInfoOnTimePoint` | boolean | `true` | `Settings.cs:103` | | `extraInfoType` | number | `0` | `Settings.cs:104`(0 持续时间/1 已过/2 剩余/3 已过百分比/4 剩余时间/5 倒计时) | | `isCountdownEnabled` | boolean | `true` | `Settings.cs:105` | | `countdownSeconds` | number | `60` | `Settings.cs:106` | | `extraInfo4ShowSecondsSeconds` | number | `300` | `Settings.cs:107` | | `defaultOnClassTimePointMinutes` | number | `40` | `Settings.cs:108` | | `defaultBreakingTimePointMinutes` | number | `10` | `Settings.cs:109` | | `isNonExactCountdownEnabled` | boolean | `false` | `Settings.cs:221` | #### 13.3.4 提醒、语音与时间同步 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `isNotificationEnabled` | boolean | `true` | `Settings.cs:91` | | `notificationProvidersEnableStates` | map | `{}` | `Settings.cs:88` | | `notificationProvidersPriority` | string[] | `[]` | `Settings.cs:89` | | `notificationProvidersSettings` | map | `{}` | `Settings.cs:90` | | `notificationProvidersNotifySettings` | map | `{}` | `Settings.cs:143` | | `notificationChannelsNotifySettings` | map | `{}` | `Settings.cs:1846` | | `isNotificationEffectEnabled` | boolean | `true` | `Settings.cs:142` | | `isNotificationTopmostEnabled` | boolean | `true` | `Settings.cs:149` | | `notificationEffectRenderingScale` | number | `1.0` | `Settings.cs:150` | | `isNotificationEffectRenderingScaleAutoSet` | boolean | `false` | `Settings.cs:151` | | `isNotificationSoundEnabled` | boolean | `true` | `Settings.cs:144` | | `notificationSoundPath` | string | `""` | `Settings.cs:145` | | `notificationSoundVolume` | number | `1.0` | `Settings.cs:182` | | `allowNotificationSpeech/Effect/Sound/Topmost` | boolean | `false`/`true`/`false`/`true` | `Settings.cs:153-156` | | `notificationUseStandaloneEffectUiThread` | boolean | `true` | `Settings.cs:1837` | | `isSpeechEnabled` | boolean | `true` | `Settings.cs:135` | | `speechVolume` | number | `1.0` | `Settings.cs:136` | | `speechSource` | number | `0` | `Settings.cs:137` | | `selectedSpeechProvider` | string | `"classisland.speech.edgeTts"` | `Settings.cs:1847` | | `edgeTtsVoiceName` | string | `"zh-CN-XiaoxiaoNeural"` | `Settings.cs:138` | | `isSystemSpeechSystemExist` | boolean | `false`(运行时探测) | `Settings.cs:133` | | `exactTimeServer` | string | `"ntp.aliyun.com"` | `Settings.cs:139` | | `isExactTimeEnabled` | boolean | `true` | `Settings.cs:140` | | `timeOffsetSeconds` | number | `0` | `Settings.cs:141` | | `isTimeAutoAdjustEnabled` | boolean | `false` | `Settings.cs:146` | | `timeAutoAdjustSeconds` | number | `0` | `Settings.cs:147` | | `lastTimeAdjustDateTime` | string(ISO) | 当前时间 | `Settings.cs:148` | #### 13.3.5 组件、自动化、主题 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `currentComponentConfig` | string | `"Default"` | `Settings.cs:159` | | `isAutomationEnabled` | boolean | `false` | `Settings.cs:160` | | `currentAutomationConfig` | string | `"Default"` | `Settings.cs:162` | | `isAutomationWarningVisible` | boolean | `true` | `Settings.cs:161` | | `selectedPlatte` | string | `#1E90FFFF` | `Settings.cs:80`(DodgerBlue) | | `selectedPlatteIndex` | number | `0` | `Settings.cs:81` | | `wallpaperColorPlatte` | string[] | 5 个 DodgerBlue | `Settings.cs:82` | | `colorSource` | number | `2` | `Settings.cs:83`(0 无/1 壁纸/2 手动) | | `wallpaperClassName` | string | `""` | `Settings.cs:84` | | `targetLightValue` | number | `0.6` | `Settings.cs:85` | | `isWallpaperAutoUpdateEnabled` | boolean | `false` | `Settings.cs:92` | | `wallpaperAutoUpdateIntervalSeconds` | number | `60` | `Settings.cs:93` | | `weatherIconId` | string | `"classisland.weatherIcons.lucide"` | `Settings.cs:1849` | | `isRollingComponentWarningVisible` | boolean | `true` | `Settings.cs:1850` | | `isThemeWarningVisible` | boolean | `true` | `Settings.cs:1848` | | `isThemeSeparateInfoVisible` | boolean | `true` | `Settings.cs:1875` | #### 13.3.6 天气 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `cityId` | string | `"weathercn:101010100"` | `Settings.cs:99` | | `cityName` | string | `"北京 (北京, 中国)"` | `Settings.cs:100` | | `lastWeatherInfo` | object | `{}` | `Settings.cs:98` | | `weatherLatitude` / `weatherLongitude` | number | `0`/`0` | `Settings.cs:1838-1839` | | `weatherLocationSource` | number | `0` | `Settings.cs:1840` | | `autoRefreshWeatherLocation` | boolean | `false` | `Settings.cs:1841` | | `noTLSWeatherRequests` | boolean | `false` | `Settings.cs:1842` | | `excludedWeatherAlerts` | string[] | `[]` | `Settings.cs:158` | | `selectedMiniInfoProvider` | string? | 内置天气提供者 Guid | `Settings.cs:97` | | `miniInfoProviderSettings` | map | `{}` | `Settings.cs:96` | #### 13.3.7 更新、插件、备份、诊断 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `updateMode` | number | `3` | `Settings.cs:77` | | `autoInstallUpdateNextStartup` | boolean | `true` | `Settings.cs:78` | | `selectedUpdateChannelV2` | string | `"stable"` | `Settings.cs:217` | | `selectedUpdateMirrorV2` | string | `"main"` | `Settings.cs:216` | | `selectedUpdateChannelV3` | string(Guid) | `Guid.Empty` | `Settings.cs:1854` | | `lastCheckUpdateTime` | string(ISO) | `DateTime.MinValue` | `Settings.cs:75` | | `lastUpdateStatus` | number | `0`(UpToDate) | `Settings.cs:76` | | `updateArtifactHash` | string | `""` | `Settings.cs:157` | | `ignoredUpdateVersion` | string | `""` | `config.ts:76`(本应用字段,兼容旧行为) | | `userPluginIndexes` | string[] | `[]` | `Settings.cs:167` | | `additionalPluginIndexes` | map | `{}` | `Settings.cs:168` | | `pluginIndexes` | object[] | `[]` | `Settings.cs:169` | | `pluginIndexSelectedMirrors` | map | `{}` | `Settings.cs:166` | | `officialSelectedMirror` | string | `"github"` | `Settings.cs:170` | | `officialIndexMirrors` | map | `{github: https://github.com, ghproxy: …, moeyy: …}` | `Settings.cs:171-176` | | `ignoreSslForPluginMirrors` | boolean | `false` | `Settings.cs:177` | | `isPluginMarketWarningVisible` | boolean | `true` | `Settings.cs:204` | | `isPluginsAutoUpdateEnabled` | boolean | `true` | `Settings.cs:1858` | | `isPluginsUpdateNotificationEnabled` | boolean | `true` | `Settings.cs:1859` | | `autoDisableCorruptPlugins` | boolean | `true` | `Settings.cs:1844` | | `corruptPluginsDisabledLastSession` | boolean | `false` | `Settings.cs:1845` | | `lastRefreshPluginSourceTime` | string(ISO) | `MinValue` | `Settings.cs:180` | | `isAutoBackupEnabled` | boolean | `true` | `Settings.cs:187` | | `lastAutoBackupTime` | string(ISO) | 当前时间 | `Settings.cs:188` | | `autoBackupLimit` | number | `16` | `Settings.cs:190` | | `autoBackupIntervalDays` | number | `7` | `Settings.cs:191` | | `backupFilesSize` | string | `"计算中..."` | `Settings.cs:189` | | `firstLaunchTime` | string(ISO) | 当前时间 | `Settings.cs:127` | | `diagnosticStartupCount` | number | `0` | `Settings.cs:128` | | `diagnosticCrashCount` | number | `0` | `Settings.cs:129` | | `diagnosticLastCrashTime` | string(ISO) | `MinValue` | `Settings.cs:130` | | `diagnosticMemoryKillCount` | number | `0` | `Settings.cs:131` | | `diagnosticLastMemoryKillTime` | string(ISO) | 当前时间 | `Settings.cs:132` | | `developerMode` | boolean | `false` | `config.ts:79`(本应用字段,决定是否显示开发者页) | | `careMode` | boolean | `false` | `config.ts:80`(关怀模式) | | `isDebugEnabled` | boolean | `false` | `Settings.cs:65` | | `isDebugOptionsEnabled` | boolean | `false` | `Settings.cs:79` | | `isDebugConsoleEnabled` | boolean | `false` | `Settings.cs:118` | | `debugAnimationScale` | number | `1.0` | `Settings.cs:110` | | `debugTimeSpeed` | number | `1.0` | `Settings.cs:111` | | `debugTimeOffsetSeconds` | number | `0` | `Settings.cs:112` | | `isSplashEnabled` | boolean | `false` | `Settings.cs:115` | | `splashCustomText` | string | `""` | `Settings.cs:116` | | `showDetailedStatusOnSplash` | boolean | `false` | `Settings.cs:222` | | `isWelcomeWindowShowed` | boolean | `false` | `Settings.cs:68` | | `onboardingVersion` | number | `0` | `config.ts:41` | | `isReportingEnabled` | boolean | `true` | `Settings.cs:69` | | `isFallbackModeEnabled` | boolean | `true` | `Settings.cs:94` | | `isCriticalSafeMode` | boolean | `false` | `Settings.cs:207` | | `criticalSafeModeMethod` | number | `0` | `Settings.cs:1836` | | `trustedProfileIds` | string(Guid)[] | `[]` | `Settings.cs:220` | | `taskBarIconClickBehavior` | number | Windows `0`,其它 `4` | `Settings.cs:102` | | `settingsPagesCachePolicy` | number | `0` | `Settings.cs:213` | | `isRefreshingToastEnabled` | boolean | `true` | `Settings.cs:1865` | | `refreshingToastThresholdDays` | number | `20` | `Settings.cs:1866` | | `maxRefreshingToastCounts` | number | `5` | `Settings.cs:1873` | **字段级兼容规则(必须实现)**: 1. 新增字段一律有默认值,缺失即用默认值(`SettingsService.LoadSettingsAsync` 缺失文件时直接返回 `new Settings()`,见 `SettingsService.cs:73-77`)。 2. 字段类型不匹配时用默认值覆盖并记 warn,不得整体抛错(对齐 `config.ts:198-233` 每个字段逐个 `typeof` 判断)。 3. 枚举越界值回落到默认值(如 `theme` 非 `0/1/2` → `2`)。 4. 语义变更或废弃的字段必须保留读取(忽略)一个版本,禁止删除后导致老配置迁移失败。**不得**删除 `isIslandSeperated` 这类历史拼写字段(`Settings.cs:1852`)。 5. `deviceKey` 类"机器身份"字段一旦生成必须永不变;如由外部传入不同值必须拒绝并记 warn(对齐 `config.ts:501-505`)。 ### 13.4 档案(Profile)schema — 与 ClassIsland 完全同构 档案文件:`data/profiles/`,默认 `Default.json`。**字段名必须保留 PascalCase**,以便直接导入/导出 ClassIsland 档案。 #### 13.4.1 Profile 根对象 | 字段 | 类型 | 默认值 | 说明 | 依据 | |---|---|---|---|---| | `Name` | string | `""` | 档案名称 | `Profile.cs:227-236` | | `Id` | string(Guid) | 新建 Guid | 档案 ID(用于"信任档案"判断) | `Profile.cs:452-463` | | `TimeLayouts` | map | `{}` | 时间表字典,键为 Guid | `Profile.cs:241-250` | | `ClassPlans` | map | `{}` | 课表字典 | `Profile.cs:255-270` | | `Subjects` | map | 首次启动写入默认科目表 | 科目字典 | `Profile.cs:284-293` | | `EditingSubjects` | Subject[] | `[JsonIgnore]` 不落盘 | 编辑期临时集合 | `Profile.cs:298-308` | | `IsOverlayClassPlanEnabled` | boolean | `false` | 是否启用临时层课表 | `Profile.cs:313-322` | | `OverlayClassPlanId` | string(Guid)? | `null` | 当前临时层课表 ID | `Profile.cs:327-337` | | `TempClassPlanId` | string(Guid)? | `null` | 临时课表 ID | `Profile.cs:342-351` | | `TempClassPlanSetupTime` | string(ISO) | 当前时间 | 临时课表设置时间 | `Profile.cs:356-365` | | `ClassPlanGroups` | map | 必含默认群 + 全局群 | 课表群 | `Profile.cs:370-379` | | `SelectedClassPlanGroupId` | string(Guid) | 默认群 Guid `ACAF4EF0-E261-4262-B941-34EA93CB4369` | 当前选中群 | `Profile.cs:384-393`、`ClassPlanGroup.cs:47-52` | | `TempClassPlanGroupId` | string(Guid)? | `null` | 临时课表群 | `Profile.cs:398-407` | | `TempClassPlanGroupExpireTime` | string(ISO) | 当前时间 | 临时群失效时间 | `Profile.cs:412-421` | | `IsTempClassPlanGroupEnabled` | boolean | `false` | — | `Profile.cs:426-435` | | `TempClassPlanGroupType` | number | `Inherit` | `Inherit`/`Override` 等(枚举) | `Profile.cs:440-449` | | `OrderedSchedules` | map | `{}` | 预定课表(日期→课表 ID) | `Profile.cs:468-477` | **初始化规则**:新档案必须自动加入两个群:默认群(`ACAF4EF0-E261-4262-B941-34EA93CB4369`,名称"默认")与全局群(`Guid.Empty`,名称"全局课表群",`IsGlobal=true`)。依据:`Profile.cs:44-59`。 #### 13.4.2 TimeLayout(时间表) | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `Name` | string | `"新时间表"` | `TimeLayout.cs:17` | | `Layouts` | TimeLayoutItem[] | `[]` | `TimeLayout.cs:254-267` | | `IsOverlay` | boolean | `false` | `TimeLayout.cs:26-35` | | `OverlaySourceId` | string(Guid)? | `null` | `TimeLayout.cs:40-49` | | `IsActivated` / `IsActivatedManually` | boolean | `[JsonIgnore]` 不落盘 | `TimeLayout.cs:272-297` | #### 13.4.3 TimeLayoutItem(时间点) | 字段 | 类型 | 默认值 | 说明 | 依据 | |---|---|---|---|---| | `StartTime` | TimeSpan 字符串 | `"00:00:00"` | 开始时间(当天内的偏移) | `TimeLayoutItem.cs:62-77` | | `EndTime` | TimeSpan 字符串 | `"00:00:00"` | 结束时间;`TimeType` 为 2/3 时强制等于 `StartTime` | `TimeLayoutItem.cs:82-93`、`:70-73` | | `TimeType` | number | `0` | `0` 上课 / `1` 课间 / `2` 分割线 / `3` 行动 | `TimeLayoutItem.cs:128-138` | | `IsHideDefault` | boolean | `false` | 默认隐藏(仅上课类型有效) | `TimeLayoutItem.cs:143-153` | | `DefaultClassId` | string(Guid) | `Guid.Empty`(空字符串也解析为 Empty) | 默认科目 | `TimeLayoutItem.cs:158-169`、`GuidEmptyFallbackConverter` | | `BreakName` | string | `""` | 自定义课间名;展示用 `BreakNameText`(空则"课间休息") | `TimeLayoutItem.cs:180-191,174-175` | | `ActionSet` | ActionSet? | `null` | 仅 `TimeType=3` 时有意义 | `TimeLayoutItem.cs:196-205` | | `StartSecond` / `EndSecond` | string | `[Obsolete]` | 旧版字段,读取时忽略、写出时不得生成 | `TimeLayoutItem.cs:26-57` | | `attachedObjects` | map | `{}`(导入时接受 `AttachedObjects` 原拼写) | **一等公民的附加设置字典**(课级提醒/天气/课程显示等),见 13.4.8 | `repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:10-15` | **校验规则**:`StartTime > EndTime` 必须拒绝(对齐 `TimeLayoutItem.EnsureTime`,`TimeLayoutItem.cs:95-101`);同一时间表内时间点必须按 `StartTime` 升序,重叠允许但必须保留排序;分割线/行动点不占课程位(见 13.4.4 的"有效时间点")。 #### 13.4.4 ClassPlan(课表) | 字段 | 类型 | 默认值 | 说明 | 依据 | |---|---|---|---|---| | `Name` | string | `"新课表"` | — | `ClassPlan.cs:440-449` | | `TimeLayoutId` | string(Guid) | `Guid.Empty` | 引用时间表 | `ClassPlan.cs:396-407` | | `TimeRule` | TimeRule | `{WeekDay:0, WeekCountDiv:0, WeekCountDivTotal:2}` | 周次规则 | `ClassPlan.cs:412-421`、`TimeRule.cs:13-15` | | `Classes` | ClassInfo[] | `[]` | 课程列表,长度 = 时间表中上课类型点数 | `ClassPlan.cs:426-435,451-491` | | `IsActivated` | boolean | `[JsonIgnore]` 不落盘 | 当前激活 | `ClassPlan.cs:527-537` | | `IsOverlay` | boolean | `false` | 临时层课表 | `ClassPlan.cs:542-551` | | `OverlaySourceId` | string(Guid)? | `null` | 临时层源课表 | `ClassPlan.cs:556-565` | | `OverlaySetupTime` | string(ISO) | 当前时间 | — | `ClassPlan.cs:570-579` | | `IsEnabled` | boolean | `true` | 是否默认启用 | `ClassPlan.cs:584-593` | | `AssociatedGroup` | string(Guid) | 默认群 Guid | 所属课表群 | `ClassPlan.cs:598-607` | **有效时间点规则**(必须实现 `ValidTimeLayoutItems`):只取 `TimeType ∈ {0,1,2}`;从前后两个方向扫描 `ClassInfo.IsEnabled`,把被禁用课程"夹住"的时间点全部隐藏;`TimeType=3` 的行动点永不显示。依据:`ClassPlan.cs:54-98`。 #### 13.4.5 ClassInfo(一节课) | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `SubjectId` | string(Guid) | `Guid.Empty`(空串也解析为 Empty) | `ClassInfo.cs:70-80` | | `IsChangedClass` | boolean | `false` | `ClassInfo.cs:85-94` | | `IsEnabled` | boolean | `true` | `ClassInfo.cs:99-108` | | `Index` / `CurrentTimeLayout` / `IsEmpty` | — | `[JsonIgnore]` 不落盘 | `ClassInfo.cs:21-64,110-121` | #### 13.4.6 Subject(科目) | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `Name` | string | `""` | `Subject.cs:19-30` | | `Initial` | string | `""`(`Name` 变化且 `Initial` 为空时自动取首字) | `Subject.cs:35-44` | | `TeacherName` | string | `""` | `Subject.cs:49-58` | | `IsOutDoor` | boolean | `false` | `Subject.cs:63-72` | 内置常量必须实现:`Fallback = {Name:"???", Initial:"?"}`、`Empty = {Name:"", Initial:""}`、`Breaking = {Name:"课间休息", Initial:"休"}`。依据:`Subject.cs:79-101`。教师姓氏提取必须支持复姓表(万俟/司马/上官/欧阳……)与英文姓名取最后一个单词。依据:`Subject.cs:14,107-140`。 #### 13.4.7 课表群与预定 - `ClassPlanGroup { Name: "新课表群", IsGlobal: false }`;默认群 Guid `ACAF4EF0-E261-4262-B941-34EA93CB4369`;全局群 Guid `Guid.Empty`;解散群时课表移到默认群,删除群时群内课表一并删除。依据:`ClassPlanGroup.cs`、`Profile.cs:104-137`。 - `OrderedSchedule { ClassPlanId: Guid }`;`OrderedSchedules` 的键是**日期**(本地日期,序列化为 `"2026-11-12T00:00:00"`)。 - 临时层规则:每天启动/跨天时必须 `CleanExpiredTempClassPlan()`:删除今天以前的 `OrderedSchedules`;删除未被任何预定引用的临时课表;删除未被引用的临时时间表。依据:`ProfileService.cs:295-328`。 #### 13.4.8 附加设置(`attachedObjects`)——一等公民 **定位**:附加设置**不是** 1.7 遗留字段,而是 ClassIsland 2.x 仍在使用的核心机制:可附着对象(`AttachableSettingsObject`)自带 `Dictionary AttachedObjects`,键是"附加设置提供者的 Guid",值是设置对象;界面通过 `AttachedSettingsControlPresenter` 读写它(`repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:10-15`、`repos/ClassIsland/ClassIsland.Core/Controls/AttachedSettingsControlPresenter.axaml.cs:241-253`)。本实现必须把它升级为**一等公民字段**,供 06/07 部分的"课级提醒/课级附加行动/天气提醒"直接引用。 **承载对象**(均必须有 `attachedObjects` 字段): | 对象 | 目标枚举 | 说明 | |---|---|---| | `Profile`(档案级) | — | 本实现扩展的档案级附加设置;ClassIsland 2.x 的可附着基类未覆盖档案,档案级为本实现新增【推断】 | | `ClassPlan`(课表级) | `ClassPlan` | 整张课表的附加设置 | | `ClassInfo`(课级) | `Lesson` | 单节课的附加设置(最常用,例如这节课用自定义提醒文案) | | `TimeLayout`(时间表级) | `TimeLayout` | 整张时间表的附加设置 | | `TimeLayoutItem`(时间点级) | `TimePoint` | 单个时间点的附加设置 | | `Subject`(科目级) | `Subject` | 科目的附加设置 | 字段定义:`attachedObjects: map`,默认 `{}`;序列化字段名 **`attachedObjects`(camelCase)**;导入 ClassIsland 档案时**接受原拼写 `AttachedObjects`**(PascalCase),导出/写出统一 camelCase。值必须是普通 JSON 对象(结构化克隆可序列化),不得存函数或类实例。依据:`AttachableSettingsObject.cs:12-15`(字典键为 Guid);`AttachedSettingsControlPresenter.axaml.cs:245-253`(目标对象 → `AttachedSettingsTargets` 的映射)。 **已知附加设置 Guid 与设置对象类型(必须内置映射)**: | Guid | 名称 | 设置对象类型 | 允许附着目标 | 依据 | |---|---|---|---|---| | `58e5b69a-764a-472b-bcf7-003b6a8c7fdf` | 课程显示设置 | `LessonControlAttachedSettings` | ClassPlan / TimeLayout / Lesson / Subject / TimePoint | `LessonControlAttachedSettingsControl.axaml.cs:10-14`、`AttachedSettingsTargets.cs` | | `08f0d9c3-c770-4093-a3d0-02f3d90c24bc` | 上课提醒设置 | `ClassNotificationAttachedSettings` | ClassPlan / TimeLayout / Lesson / Subject / TimePoint | `ClassNotificationAttachedSettingsControl.axaml.cs:11-15` | | `8fbc3a26-6d20-44dd-b895-b9411e3ddc51` | 放学提醒设置 | `AfterSchoolNotificationAttachedSettings` | ClassPlan / TimeLayout | `AfterSchoolNotificationAttachedSettingsControl.axaml.cs:10-12` | | `7625de96-38aa-4b71-b478-3f156dd9458d` | 天气提醒设置 | `WeatherNotificationAttachedSettings` | TimePoint | `WeatherNotificationAttachedSettingsControl.axaml.cs:10-12` | **设置对象字段(默认值必须逐项一致)**: - `LessonControlAttachedSettings`:`IsAttachSettingsEnabled=false`、`ShowExtraInfoOnTimePoint=true`、`ExtraInfoType=0`、`IsCountdownEnabled=true`、`CountdownSeconds=60`、`ExtraInfo4ShowSecondsSeconds=0`、`ScheduleSpacing=1`、`ShowCurrentLessonOnlyOnClass=false`、`IsNonExactCountdownEnabled=false`。依据:`repos/ClassIsland/ClassIsland.Core/Models/AttachedSettings/LessonControlAttachedSettings.cs:7-17`。 - `ClassNotificationAttachedSettings`:`IsClassOnNotificationEnabled=true`、`IsClassOnPreparingNotificationEnabled=true`、`IsClassOffNotificationEnabled=true`、`ClassPreparingDeltaTime=60`、`ClassOnPreparingText="准备上课,请回到座位并保持安静,做好上课准备。"`、`OutdoorClassOnPreparingText="下节课程为户外课程,请合理规划时间,做好上课准备。"`、`ClassOnPreparingMaskText="即将上课"`、`OutdoorClassOnPreparingMaskText="即将上课"`、`ClassOnMaskText="上课"`、`ClassOffMaskText="课间休息"`、`ClassOffOverlayText=""`、`IsAttachSettingsEnabled=false`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/ClassNotificationAttachedSettings.cs:7-24`。 - `AfterSchoolNotificationAttachedSettings`:`IsEnabled=true`、`NotificationMsg="今天的课程已结束,请同学们有序离开。"`、`IsAttachSettingsEnabled=false`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:7-14`。 - `WeatherNotificationAttachedSettings`:`AlertShowMode=Default`、`ForecastShowMode=Default`、`IsAttachSettingsEnabled=true`。依据:`repos/ClassIsland/ClassIsland/Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:7-17`。 - 所有设置对象都带 `IsAttachSettingsEnabled`:**只有它为 true 时附加设置才生效**;为 false 时按空对象处理并允许回落到全局设置。 **读写与兼容规则(强制)**: 1. 读取时必须"按目标对象类型过滤 Guid":若某 Guid 允许的目标不包含当前对象类型,保留数据但**不得生效**(不能因为档案来自别处就崩溃)。 2. 未知 Guid 必须原样保留(不删除、不报错),以便插件卸载后重装仍能恢复设置;UI 显示为"未知附加设置(可删除)"。 3. 值里出现 `null` 时按"未设置"处理;导出的 JSON 必须省略值为 `null` 的键(对齐 `object?` 语义)。 4. 导入 ClassIsland 档案的 `AttachedObjects` 时:键 Guid 统一小写、值按上表类型反序列化;未知 Guid 的 `JsonElement` 原样保留;**不得**丢弃。 5. 导出为 ClassIsland 档案时字段名写 `AttachedObjects`(PascalCase),以保持双向可导入。 6. 附加设置必须参与 `ProfileService.SaveProfile` 的保存触发(对象属性变化 → 档案保存),并在 UI 中提供"清除该对象全部附加设置"的入口。 ### 13.5 组件布局(ComponentProfile)schema 文件:`data/component-layouts/.json`,默认 `Default.json`。依据:`ComponentsService.cs:28-58,122-166`。 #### 13.5.1 顶层 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `Lines` | MainWindowLineSettings[] | 1 行 + 2 个默认组件 | 主界面行集合 | 默认组件配置的固定内容:一行,含两个组件,`Id` 分别为 `df3f8295-21f6-482e-bada-fa0e5f14bb66`(日期)与 `1db2017d-e374-4bc6-9d57-0b4adf03a6b8`(时间)。依据:`ComponentsService.cs:33-52`。**这两个 GUID 必须保留**(导入 ClassIsland 组件布局时按 GUID 匹配)。 #### 13.5.2 MainWindowLineSettings(主界面行) | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `Children` | ComponentSettings[] | `[]` | `MainWindowLineSettings.cs` | | `IsMainLine` | boolean | `false` | 同上 | | `IsNotificationEnabled` | boolean | `true` | 同上 | | `IsResourceOverridingEnabled` | boolean | `false` | 同上 | | `MainWindowSecondaryFontSize` / `Body` / `Emphasized` / `Large` | number | `14/16/18/20` | 同上 | | `IsCustomForegroundColorEnabled` / `ForegroundColor` | boolean / string | `false` / `#1E90FFFF` | 同上 | | `BackgroundOpacity` / `IsCustomBackgroundOpacityEnabled` | number / boolean | `0.5` / `false` | 同上 | | `BackgroundColor` / `IsCustomBackgroundColorEnabled` | string / boolean | `#000000FF` / `false` | 同上 | | `CustomCornerRadius` / `IsCustomCornerRadiusEnabled` | number / boolean | `0` / `false` | 同上 | | `Opacity` | number | `1.0` | 同上 | | `IsVisible` | boolean | `true` | 同上 | | `HideOnRule` | boolean | `false` | 同上 | | `HidingRules` | Ruleset | 空规则集 | 同上 | | `IslandSeparationMode` | number | `0` | 同上(`0` 默认 / `1` 禁用 / `2` 启用) | #### 13.5.3 ComponentSettings(单个组件) | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---| | `Id` | string | 组件 GUID(小写) | `ComponentSettings.cs:56-66` | | `NameCache` | string | `""`(组件未加载时显示) | `:71-80` | | `Settings` | object? | 组件专属设置对象 | `:85-95` | | `HideOnRule` | boolean | `false` | `:100-109` | | `HidingRules` | Ruleset | 空规则集 | `:114-123` | | `IsResourceOverridingEnabled` | boolean | `false` | `:149-158` | | `MainWindowSecondaryFontSize` / `Body` / `Emphasized` / `Large` | number | `14/16/18/20` | `:161-180` 起 | | `ForegroundColor` / `IsCustomForegroundColorEnabled` | string / boolean | `#1E90FFFF` / `false` | `:28-29` | | `IsMinWidthEnabled` / `MinWidth` | boolean / number | `false` / `100` | `:30-31` | | `IsMaxWidthEnabled` / `MaxWidth` | boolean / number | `false` / `300` | `:32-33` | | `IsFixedWidthEnabled` / `FixedWidth` | boolean / number | `false` / `200` | `:34-35` | | `HorizontalAlignment` | enum | `Stretch` | `:36`(`Left/Center/Right/Stretch`) | | `RelativeLineNumber` | number | `0` | `:37` | | `IsVisible` | boolean | `true` | `:38` | | `BackgroundOpacity` / `IsCustomBackgroundOpacityEnabled` | number / boolean | `0.5` / `false` | `:40-41` | | `BackgroundColor` / `IsCustomBackgroundColorEnabled` | string / boolean | `#000000FF` / `false` | `:42-43` | | `CustomCornerRadius` / `IsCustomCornerRadiusEnabled` | number / boolean | `8` / `false` | `:44-45` | | `Opacity` | number | `1.0` | `:46` | | `IsCustomMarginEnabled` + `MarginLeft/Top/Right/Bottom` | boolean + number×4 | `false` + `0` | `:47-51` | 组件专属设置对象必须在 `data/component-layouts/` 同一文件内序列化,其字段名按组件设置模型:`ClockComponentSettings`、`CountDownComponentSettings`、`LessonControlSettings`、`RollingComponentSettings`、`SlideComponentSettings`、`StackComponentSettings`、`GroupComponentSettings`、`TextComponentSettings`、`WeatherComponentSettings`。依据:`repos/ClassIsland/ClassIsland/Models/ComponentSettings/*.cs`。**组件设置的字段名与默认值必须逐项对齐这 9 个模型**(具体见第 5 部分组件规格)。 **组件迁移规则**:加载组件布局时,若组件 `Id` 命中 `ComponentRegistryService.MigrationPairs`,必须把 `Id` 改成新 GUID、`IsMigrated=true`、`MigrationSource=旧 GUID`(两者均 `[JsonIgnore]` 不落盘),迁移后立即保存。依据:`ComponentsService.cs:140-161`、`ComponentSettings.cs:125-128`。 ### 13.6 自动化配置 schema 文件:`data/automations/.json`,默认 `Default.json`;顶层类型是 **Workflow 数组**。依据:`AutomationService.cs:27-32,173-208,441`。 | 层级 | 字段 | 类型 | 默认值 | 依据 | |---|---|---|---|---| | Workflow | `Triggers` | TriggerSettings[] | `[]` | `Workflow.cs:12-14` | | Workflow | `IsConditionEnabled` | boolean | `false` | `Workflow.cs:17-18` | | Workflow | `Ruleset` | Ruleset | 空规则集 | `Workflow.cs:21-22` | | Workflow | `ActionSet` | ActionSet | 新行动组 | `Workflow.cs:25-26` | | TriggerSettings | `Id` | string | `""` | `TriggerSettings.cs:14-30` | | TriggerSettings | `Settings` | object? | `null` | `TriggerSettings.cs:33-45` | | ActionSet | `Name` | string | `"新行动组"` | `ActionSet.cs:74-77` | | ActionSet | `Actions` | ActionItem[] | `[]` | `ActionSet.cs:79-83`(序列化名必须为 `Actions`) | | ActionSet | `IsEnabled` | boolean | `true` | `ActionSet.cs:88` | | ActionSet | `IsRevertEnabled` | boolean | `false` | `ActionSet.cs:93` | | ActionSet | `Status` | number | `Normal` | `ActionSet.cs:104-105` | | ActionSet | `Guid` | string(Guid) | 新建 Guid | `ActionSet.cs:115` | | ActionItem | `Id` | string | `""` | `ActionItem.cs:43` | | ActionItem | `Settings` | object? | `null`(null 时不写该字段) | `ActionItem.cs:50-51` | **规则集(Ruleset)嵌套结构**(必须精确实现): ```json { "Mode": 0, "IsReversed": false, "Groups": [ { "Rules": [{ "IsReversed": false, "Id": "classisland.lessons.currentTimeStateChanged", "Settings": {} }], "Mode": 1, "IsReversed": false, "IsEnabled": true } ] } ``` - `Ruleset.Mode` 默认 `Or`(`Ruleset.cs:14`)、`RuleGroup.Mode` 默认 `And`(`RuleGroup.cs:14`);两者均支持 `IsReversed`;`RuleGroup.IsEnabled` 默认 `true`;`Ruleset.Groups` 默认含一个"一个空规则"的组(`Ruleset.cs:16`)。`State` 是运行期字段(`[JsonIgnore]`),不得落盘。 - 触发器 ID 必须使用以下注册表 ID(导入 ClassIsland 自动化时按此匹配): | 触发器 | ID | 依据 | |---|---|---| | 上课时 | `classisland.lessons.onClass` | `OnClassTrigger.cs:8` | | 课间休息时 | `classisland.lessons.onBreakingTime` | `OnBreakingTimeTrigger.cs:8` | | 放学时 | `classisland.lessons.onAfterSchool` | `OnAfterSchoolTrigger.cs:8` | | 当前时间状态变化 | `classisland.lessons.currentTimeStateChanged` | `CurrentTimeStateChangedTrigger.cs:8` | | 特定时间点前 | `classisland.lessons.preTimePoint` | `PreTimePointTrigger.cs:13` | | 应用启动时 | `classisland.lifetime.startup` | `AppStartupTrigger.cs:10` | | 应用退出时 | `classisland.lifetime.stopping` | `AppStoppingTrigger.cs:8` | | cron | `classisland.cron` | `CronTrigger.cs:15` | | 收到信号时 | `classisland.signal` | `SignalTrigger.cs:8` | | 调用 Uri 时 | `classisland.uri` | `UriTrigger.cs:8` | | 从托盘菜单运行 | `classisland.trayMenu` | `TrayMenuTrigger.cs:11` | | 规则集更新时 | `classisland.ruleSet.rulesetChanged` | `RulesetChangedTrigger.cs:8` | 行动 ID:`classisland.showNotification`(显示提醒)、`classisland.notification.weather`(天气提醒)、`classisland.os.run`(运行)、`classisland.action.sleep`(等待)、`classisland.app.quit`(退出)、`classisland.app.restart`(重启)、`classisland.settings`(应用设置)。依据:`repos/ClassIsland/ClassIsland/Services/Automation/Actions/*.cs` 的 `ActionInfo` 特性。 ### 13.7 提醒数据 1. **运行期队列不落盘**(重启丢失可接受);**通知历史必须落盘**到 `data/notification-history.json`,结构为: ```json { "schemaVersion": 1, "items": [ { "id": "uuid", "providerId": "classisland.notification.class", "title": "上课提醒", "content": "语文课 08:00-08:45", "priority": 3, "channelId": "classisland.channel.desktop", "createdAt": "2026-11-12T07:59:00", "read": false, "speech": "准备上课", "overlayDurationMs": 5000, "maskDurationMs": 5000 } ] } ``` 2. 历史保留条数由设置项 `notificationHistoryLimit` 控制:默认 `500`,合法区间 `100–5000`;超限按最旧优先滚动删除(不得一次性清空)。"清空通知"必须二次确认并写审计日志。落盘必须**批量节流**:累积 ≥ 5 条或距上次写入 ≥ 5 秒才落盘一次,退出时强制 flush;不得每来一条通知就写一次盘。**【推断】**(数量上限为工程决策,ClassIsland 不落盘历史。) 3. 提醒设置对象 `NotificationSettings` 的字段与默认值必须为:`IsNotificationEnabled=true`、`IsSpeechEnabled=false`、`IsNotificationEffectEnabled=true`、`IsNotificationSoundEnabled=false`、`NotificationSoundPath=""`、`IsSettingsEnabled=false`、`IsNotificationTopmostEnabled=false`。依据:`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationSettings.cs:11-17`。 4. 提醒播放会话字段:`OverlayContent`、`MaskContent`、`OverlayDuration`(默认 5s)、`MaskDuration`(默认 5s)、`TargetOverlayEndTime`、`TargetMaskEndTime`、`OverlaySpeechContent`、`MaskSpeechContent`、`IsSpeechEnabled`(默认 true)、`RequestNotificationSettings`、`ChannelId`、`State`、`LeftProgress`(默认 1.0)。依据:`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:14-160`、`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationRequest.cs:16-20`。 5. 优先级排序必须与 ClassIsland 一致:`IsPriorityOverride` 最高(插队);否则先比 `Priority`(数值小优先),再比入队 `Index`。依据:`NotificationPriority.CompareTo`(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationPriority.cs`)。 ### 13.8 主题数据 1. 主题目录:`data/themes/<主题Id>/`(解压后),启用列表 `data/themes/EnabledThemes.json`(`string[]`),主题包缓存 `cache/theme-packages/<主题Id>.zip`。依据:`XamlThemeService.cs:47-49,392-440`。 2. 主题清单 `ThemeManifest` 字段与默认值:`Id=""`、`Name=""`、`Author=""`、`Url=""`、`Description=""`、`Version="0.0.0.0"`、`Banner="banner.png"`、`VerticalSafeAreaPx=0`。依据:`repos/ClassIsland/ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs`。 3. 启用/禁用主题后必须重建样式表并持久化;**禁用全部主题时必须回落到内置主题**,不得白屏。 4. 主题包安装必须:校验 zip 路径安全 → 校验清单必填字段 → 解压到 `data/themes//` → 若已存在则保留用户自定义值(按字段合并,不整目录覆盖)。 5. **默认离线(强制)**:主题市场与插件市场同一策略——安装包**不内置在线官方索引**,只内置离线示例索引;用户必须手动添加索引 URL 并二次确认后才联网;网络失败不得影响本机已安装主题与 `EnabledThemes.json`。依据:与 12.3 第 7 条同一产品决策【推断】。 ### 13.9 其他存储 1. **机器级全局存储**:`Global.json`(键值对字符串),ClassIsland 放在 `%LOCALAPPDATA%\ClassIsland\Global\Global.json`;本实现放 `/global.json`,用于"上次运行版本、是否首次启动、Sentry 开关、MLE 标记"等不适合放进用户配置的信息。依据:`repos/ClassIsland/ClassIsland.Core/Services/GlobalStorageService.cs`。 2. **设置叠层**:自动化/插件临时覆盖设置时必须记录"原值 + 覆盖者 Guid",移除覆盖者后按栈顶恢复;不得直接改配置后忘记恢复。依据:`repos/ClassIsland/ClassIsland/Services/SettingsService.cs:191-259`。 3. **信任档案列表**:`trustedProfileIds` 默认 `[]`;首次加载未信任档案时必须提示"该档案来自外部,是否信任";不允许自动化在未信任档案下运行(对齐 `AutomationService` 对 `IsCurrentProfileTrusted` 的检查)。依据:`ProfileService.cs:166-176,398-422`。 4. **缓存目录可安全清空**:`cache/` 下任何内容删除后不得影响功能(下次自动重建);设置页"清理缓存"不得删除 `data/`、`backups/`、`config.json`。 ### 13.10 备份、恢复与迁移(必须实现) **加载时备份**: 1. 每次读取 JSON 成功 → 复制为 `.bak`;失败 → 用 `.bak` 恢复并记入错误列表。依据:`ConfigureFileHelper.cs:63-96`。 2. `.bak` 只保留一份(覆盖式),不做多版本滚动;多版本由 `backups/` 承担。 **整包备份**: 1. 备份内容固定为:`config.json`、`Settings.json`(若存在)、`data/` 全目录、`data/themes/EnabledThemes.json`;**不得**包含 `cache/`、`logs/`、`updates/`、插件包缓存。依据:`repos/ClassIsland/ClassIsland/Services/FileFolderService.cs:115-168`(备份 `Config/` + `Profiles/` + `Settings.json`,排除缓存/日志)。 2. 备份文件名:自动备份 `Auto_Backup_yy-MMM-dd_HH-mm-ss.zip`,手动备份 `Backup_yy-MMM-dd_HH-mm-ss.zip`。依据:`FileFolderService.cs:128-132`。 3. 自动备份策略:`isAutoBackupEnabled=true` 时,距上次备份 ≥ `autoBackupIntervalDays`(默认 7 天)才执行;只保留最近 `autoBackupLimit`(默认 16)份 `Auto_*`,按文件修改时间倒序删除多余。依据:`FileFolderService.cs:83-113`。 4. 备份写入必须"先写 `.tmp` 再 rename";备份失败不得影响启动与当前数据(只记日志并在设置页提示)。 5. 恢复流程:选择备份 → **先自动创建一份"恢复前快照"** → 校验 zip 结构(必须含 `config.json` 或 `Settings.json` + `data/profiles/Default.json`)→ 解压到临时目录 → 原子替换 → 提示"重启后生效" → 重启应用。恢复失败必须回滚到快照。 6. 备份必须可在资源管理器里直接解压查看(不得加密、不得用私有格式)。 **版本迁移**: 1. 必须在 `config.json` 写入 `schemaVersion`(当前 `1`),并在 `data/profiles/*.json` 内写 `SchemaVersion`(当前 `1`,允许缺省视为 1)。**这是本实现的工程必需字段**(ClassIsland 没有,但迁移流水线需要显式版本号)。**【推断】** 2. 迁移执行顺序:读取文件 → 记录 `fromVersion` → 逐级执行 `migrations[fromVersion → fromVersion+1]` → 写回 → 备份迁移前文件到 `backups/migrate--to--<时间>.zip`。任一步失败必须中止并保留原文件,提示"迁移失败,已保留原配置"。 3. ClassIsland 形迁移规则必须实现(依据 `repos/ClassIsland/ClassIsland/Services/SettingsService.cs:134-168`): - 旧版本 `LastAppVersion < 2.0.0.2`:重新登记 URL 协议(`classisland://`)。 - 旧版本 `LastAppVersion < 2.0.3.0`:重置教程初始化标记(`WillMigrateInitTutorial`)。 - 旧版本 `LastAppVersion < 2.0.3.3`:重写开机自启快捷方式(先禁用再启用)。 - 迁移完成后必须把 `LastAppVersion` 更新为当前版本(`SettingsService.cs:112`)。 4. 从 ClassIsland 导入必须支持两种入口:`--importV1 `(1.x)、`--importV2 `(2.x);导入前必须自动备份当前数据;导入后必须展示"导入结果清单"(成功项/跳过项/失败项及原因),不得静默覆盖。依据:`repos/ClassIsland/ClassIsland/Program.cs:60-65`。 5. 组件迁移:`ComponentRegistryService.MigrationPairs` 必须内置一份"旧 GUID → 新 GUID"映射;导入旧组件布局时按映射改写并保存。依据:`ComponentsService.cs:140-161`。 6. 空 GUID 兼容:所有 `Guid` 字段读到 `""` 必须解析为 `Guid.Empty`;写出时 `Guid.Empty` 写成 `""`(对齐 `GuidEmptyFallbackConverter` 的双向规则)。依据:`repos/ClassIsland/ClassIsland.Shared/JsonConverters/GuidEmptyFallbackConverter.cs`。 7. 颜色兼容:读入支持 `#RRGGBB`、`#RRGGBBAA`、`#AARRGGBB` 三种写法(ClassIsland 写出为 `#RRGGBBAA`,Alpha 在尾部);写出统一 `#RRGGBBAA` 大写。依据:`repos/ClassIsland/ClassIsland.Core/Converters/ColorHexJsonConverter.cs`(`AlphaComponentPosition.Trailing`)。 8. 时间兼容:`DateTime` 写出为 ISO `yyyy-MM-ddTHH:mm:ss`(无时区,本地时间);`TimeSpan` 写出为 `hh:mm:ss`(超过 24h 用 `d.hh:mm:ss`);读入必须同时接受带时区与不带时区的 ISO 字符串,并按本地时区归一。依据:`repos/ClassIsland/examples/example.json:10-11` 与 System.Text.Json 默认 `TimeSpan` 格式。 9. 枚举兼容:所有枚举按**数字**落盘(如 `TimeType`、`UpdateStatus`、`RulesetLogicalMode`),不得写出名字;读到未知数字必须夹紧到合法范围并记 warn。依据:ClassIsland 使用默认 `System.Text.Json` 序列化(`ConfigureFileHelper.SerializerOptions = new()`,`ConfigureFileHelper.cs:30`),枚举默认输出数字。 10. 迁移必须**幂等**:同一版本重复执行不得产生二次修改(用 `schemaVersion` 与字段存在性双重判断)。 **Excel 导入导出(P1 必须实现,xlsx 重建)**: 11. 必须用 `xlsx`(SheetJS)在**主进程/utilityProcess** 中重建 ClassIsland 的 Excel 导入导出能力;ClassIsland 的 `unvell.ReoGrid` + WPF 窗口实现(`repos/ClassIsland/ClassIsland/Views/ExcelImportWindow.xaml.cs:29-44`、`ExcelExportWindow.xaml.cs:23-43`)**只作字段与流程参考**,不得照搬 UI。 12. 导入最低能力:读取 `.xlsx` → 选择工作表 → 识别"行 = 节次 / 列 = 星期"的网格 → 生成 `ClassPlan` + 对应 `TimeLayout`;必须支持合并单元格、空节次、科目名去重、教师名写入 `Subject.TeacherName`;解析失败必须给出行列级错误提示,且**不得**覆盖现有档案(先写入临时档案,用户确认后替换)。 13. 导出最低能力:把当前档案/课表写成 `.xlsx`(一个工作表一张课表,首行星期、首列节次与起止时间),首行冻结;导出文件默认落 `下载/ClassIsland-课表-<日期>.xlsx`,由用户选择路径。 14. 导入导出必须遵守 P1 数据兼容:导出的 xlsx 再导入必须得到等价档案(科目/时间表/课表/周次规则不丢);单文件上限 8MB(对齐 ClassHelper 服务端表格上限,见 `repos/ClassHelper-master/AGENTS.md` §2);导入前自动备份当前档案。 ### 13.11 读写一致性与错误处理 1. 所有加载函数必须返回"数据 + 警告列表",不得只返回数据;设置页与诊断页必须能展示"哪些文件回退了备份、哪些字段被重置"。 2. 阻止"写入正在读取的文件":每个文件维护内存版本号,写入前比较版本;发现外部修改(用户在资源管理器里改了 JSON)必须提示"配置已被外部修改,是否重新加载",禁止静默覆盖用户改动。**【推断】** 3. 任何写入失败必须:记 error(含路径、错误码、可用磁盘空间)→ 提示用户 → 保留旧文件不变(不得留下半截文件)。 4. 磁盘空间不足(< 100MB)时禁止自动备份并提示"清理磁盘";备份与导出前必须检查可用空间。 5. 读取时必须限制单文件大小(配置文件 ≤ 8MB、档案 ≤ 32MB、备份 zip ≤ 512MB),超限拒绝并提示,防止恶意文件把内存打爆。**【推断】** ### 13.12 天气数据 schema(与 07 天气服务对齐) 1. **缓存位置与字段**:最后一份天气必须持久化在 `config.json` 的 `lastWeatherInfo`(与 ClassIsland 把 `Settings.LastWeatherInfo` 当缓存一致,见 `repos/ClassIsland/ClassIsland/Services/WeatherService.cs:48-51,387`),并附加 `lastWeatherInfoUpdatedAt`(ISO 本地时间)与 `lastWeatherInfoStale`(boolean)。缓存**不得**因查询失败被清空:失败时保留旧值并标记 `stale`,由 UI 展示"天气数据可能已过期"。 2. **刷新节奏**:天气服务必须每 5 分钟检查一次(`WeatherService.cs:41-44`)、启动后立即查询一次(`:71`);查询失败按 30s/60s/120s 退避重试 3 次后停到下个周期。请求默认使用 `https://`;仅当用户显式开启 `noTLSWeatherRequests` 时才允许 `http://`(对齐 `WeatherService.cs:34`)。 3. **来源抽象**:不得把第三方接口写死在渲染层。必须定义 `WeatherProvider` 接口(`id`、`name`、`requiresNetwork`、`fetch()`),内置"小米天气"参考实现并允许替换:城市信息 `GET {base}/location/city/info?locationKey=&locale=zh_cn`、全量天气 `GET {base}/weather/all?latitude=&longitude=&locationKey=&days=15&appKey=&sign=&isGlobal=false&locale=zh_cn`(依据 `WeatherService.cs:327,358-360`)。`base/appKey/sign` 必须可配置、不得出现在渲染进程产物中;默认天气提示"需联网"。 4. **预警清洗**:查询结果必须按 `excludedWeatherAlerts`(默认 `[]`)过滤——`Title` 或 `Detail` 包含排除词即剔除(`WeatherService.cs:363-374`);同一 `Type` 只保留 `PubTime` 最新一条(`:376-385`);`alerts` 按 `pubTime` 倒序展示。 5. **图标与码表**:`weatherIconId` 默认 `classisland.weatherIcons.lucide`(`Settings.cs:1849`);天气码到文案的映射必须来自内置资源 `Assets/XiaomiWeather/xiaomi_weather_status.json`(`WeatherService.cs:228-233`),并提供 `getWeatherTextByCode(code)` 等价函数。 6. **数据结构(字段名必须与 07 一致,JSON 键为 camelCase;ClassIsland 原始模型用 `[JsonPropertyName]` 指定小写键,可直接对齐)**: | 类型 | 字段 | 类型 | 默认值 | |---|---|---|---| | `WeatherInfo` | `current` | `CurrentWeather` | 空对象 | | | `alerts` | `WeatherAlert[]` | `[]` | | | `updateTime` | number(Unix 毫秒) | `0` | | | `forecastDaily` | `ForecastDaily` | 空对象 | | | `forecastHourly` | `ForecastHourly` | 空对象 | | | `minutely` | `Minutely` | 空对象 | | | `aqi` | `AqiInfo` | 空对象 | | `CurrentWeather` | `feelsLike` / `humidity` / `pressure` / `temperature` / `visibility` | `ValueUnitPair {value:string, unit:string}` | `{"",""}` | | | `weather` | string(天气码) | `"99"` | | | `pubTime` | string(ISO) | 查询时间 | | | `wind` | `WindInfo {direction:ValueUnitPair, speed:ValueUnitPair}` | 空对象 | | `WeatherAlert` | `locationKey` / `alertId` / `title` / `type` / `level` / `detail` | string | `""` | | | `pubTime` | string(ISO) | 当前时间 | | | `images` | `map` | `{}`(至少含 `icon`) | | `ForecastDaily` | `precipitationProbability` | `StatusValueBase` | `{value:[]}` | | | `temperature` / `weather` / `sunRiseSet` | `StatusValueBase`(`RangedValue {from,to}`) | `{value:[]}` | | `ForecastHourly` | `temperature` / `weather` | `StatusValueBase` | `{value:[]}` | | `Minutely` | `precipitation` | `MinutelyPrecipitation {value:number[]}` | `{value:[]}` | | `AqiInfo` | `aqi` | string | `"0.0"` | | `WeatherQueryResult` | `isSuccess` / `isPrecisionDegraded` | boolean | `false` | | | `errorMessage` | string? | `null` | | | `degradedPrecision` | number | `0` | 7. **派生字段(不得落盘,读取时计算)**:`WeatherInfo.updateTime` → 本地时间;`MinutelyPrecipitation.rainRemainingMinutes`(首值为 0 时返回"接下来多少分钟开始下雨",首值 >0 时返回负的"还有多少分钟停雨",算法见 `repos/ClassIsland/ClassIsland.Core/Models/Weather/MinutelyPrecipitation.cs`);`AqiInfo.aqiLevel`(1–6,按 50/100/150/200/300 分档,见 `AqiInfo.cs`)。 8. **与 07 的契约**:07 天气服务必须复用本节字段名与派生字段;规则集 handler ID 必须为 `classisland.weather.currentWeather`、`classisland.weather.tomorrowWeather`、`classisland.weather.hasWeatherAlert`、`classisland.weather.rainTime`、`classisland.weather.sunRiseSet`(`WeatherService.cs:64-68`);提醒文案与图标渲染由 07 定义,但**读取的数据结构只能是本节 schema**。 ### 13.13 验收标准(第 13 部分) 1. 首次启动能在 `/` 下生成完整的目录结构与默认 `config.json`、`Default.json` 档案、`Default.json` 组件布局、默认科目表;目录清单与 13.2 完全一致。 2. 删除 `config.json` 后应用能正常启动并重建默认配置;删除 `data/` 后能重建默认档案与组件布局,不白屏、不崩溃。 3. 故意把 `config.json` 改成非法 JSON 后启动:应用回退到 `.bak`;若 `.bak` 也非法则使用默认值并进入"配置错误"提示,不得启动失败。 4. `Settings.json`/档案的字段名、类型、默认值与 13.3/13.4 表一致;用 ClassIsland 的真实档案 JSON 导入后,时间表、课表、科目、周次规则全部正确解析(含 `TimeType=3` 与 `IsHideDefault`、`AttachedObjects`)。**并且**:运行期只写 `config.json`,`Settings.json` 只读导入/单独导出、不双向双写;`IsIslandSeperated` 原拼写能被导入与导出映射。 5. 写入过程中断电(模拟 `rename` 前杀进程)不会损坏原文件;`.tmp` 残留有清理机制。 6. 自动备份:把 `lastAutoBackupTime` 改为 8 天前,启动后生成 `Auto_Backup_*.zip`;备份超过 16 份时最旧的 `Auto_*` 被删除,手动 `Backup_*` 不受影响。 7. 备份 zip 内必须含 `config.json`、`data/profiles/Default.json`、`data/component-layouts/Default.json`、`data/automations/Default.json`,且不含 `cache/`、`logs/`、`updates/`。 8. 恢复备份:选择备份 → 自动生成恢复前快照 → 恢复 → 重启后数据与备份一致;中途失败能回滚。 9. 迁移流水线:把 `schemaVersion` 改为 0 的旧结构文件,启动后能迁移到 1 并保留原字段;迁移失败时原文件不被破坏。 10. 组件布局导入旧 GUID 后按 `MigrationPairs` 改写,且迁移后立即保存、不重复迁移。 11. 空 GUID、`#RRGGBB`、带时区 ISO、数字枚举四类兼容用例全部通过(读写往返不丢信息)。 12. 档案切换/删除后 `config.selectedProfile` 指向有效文件;指向不存在文件时回落到 `Default.json` 并提示。 13. 自动化配置按 `Workflow[]` 落盘;触发器/行动 ID 与 13.6 表一致;规则集 `Mode`/`IsReversed`/`IsEnabled` 往返不丢。 14. 通知历史落盘、默认上限 500(可配 100–5000)、超限滚动删除、写入节流(≥5 条或 ≥5s)、清空需二次确认;历史文件损坏时回退为空列表并记警告,不影响提醒播放。 15. 所有写入路径都能在世界可读的 JSON 中直接看到内容(无私有封装、无加密),且 UTF-8 无 BOM、LF 换行。 16. 附加设置:Profile/ClassPlan/ClassInfo/TimeLayout/TimeLayoutItem/Subject 都能读写 `attachedObjects`;4 个已知 Guid 的设置对象默认值正确;导入 `AttachedObjects`(PascalCase)后可按目标生效;未知 Guid 不丢失;导出写回 `AttachedObjects`。 17. 天气:`lastWeatherInfo` 缓存可持久化、失败时保留旧值并标 `stale`;`WeatherInfo`/`WeatherAlert`/`ForecastDaily`/`ForecastHourly`/`Minutely`/`AqiInfo`/`WeatherQueryResult` 字段与 13.12 完全一致;预警按排除词过滤并按 Type 保留最新;5 个天气规则 ID 可被 07 使用。 18. Excel:能从 `.xlsx` 导入生成课表(先临时档案、确认后替换、导入前备份),能导出 `.xlsx` 再导入得到等价档案;8MB 上限;所有解析在 utilityProcess 或 worker 中执行,不阻塞主进程。 19. 坐标:`config.json` 中所有几何字段(`windowDockingOffsetX/Y`、`scale`、`radiusX/Y`、字号、间距)均为 DIP 数值;把 `scale` 由 1 改为 1.5 后窗口与岛按 CSS px 等比放大,不出现 devicePixelRatio 二次换算。 20. 主题市场默认离线:无内置在线官方索引;手动添加 URL 需二次确认;断网时本机已安装主题与 `EnabledThemes.json` 正常工作。