feat: ClassIsland / ClassHelper 灵动岛提示词套件

- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
This commit is contained in:
ClassIsland Prompt Kit committed 2026-11-12 21:59:58 +08:00
commit 22c1784bab
117 files changed
+47759

No files matched your search

+527
View File
@@ -0,0 +1,527 @@
## 7. 提醒系统(队列、提供者、气泡、强提醒、音效与 TTS)
本节规定「提醒(Notification)」全链路:请求模型 → 提供者注册 → 入队与优先级 → 主界面气泡播放(遮罩 + 正文)→ 全屏强调特效 → 音效与语音 → 取消与清理。实现必须让「提醒」成为一个**可被自动化行动、天气服务、上下课事件、集控消息共同复用**的统一管线,不得各写一套弹窗。
术语固定:**遮罩**(mask,气泡第一阶段,短文案)、**正文**(overlay,气泡第二阶段,详细内容)、**强调特效**(effect,全屏涟漪)、**置顶锁**(topmost lock,临时强制主界面置顶)。
### 7.1 核心数据结构
#### 7.1.1 提醒请求 NotificationRequest
必须实现以下字段(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:14-200`):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `MaskContent` | NotificationContent | 必填 | 遮罩内容(`[Required]`);无正文时只播它 |
| `OverlayContent` | NotificationContent? | `null` | 正文内容;为 null 表示不显示正文 |
| `RequestNotificationSettings` | NotificationSettings | `new()` | 本次提醒的专用覆盖设置 |
| `ChannelId` | Guid | `Guid.Empty` | 发送渠道 ID |
| `State` | `None/Queued/Playing/Paused/Cancelled/Completed` | `None` | 播放状态机,见 7.6 |
| `LeftProgress` | double | `1.0` | 剩余进度 0~1,驱动气泡底部进度条 |
| `MaskSession` / `OverlaySession` | NotificationPlayingSessionInfo | — | 分段计时会话(支持暂停/续播) |
| `CancellationToken` / `CompletedToken` | CancellationToken | — | 取消 / 完成令牌 |
| `IsPriorityOverride` | bool | `false` | **旧字段**,仅用于兼容:读写时映射到 `Level`(见 7.2) |
| `Level` | `NotificationLevel`(Emergency/High/Normal/Low) | `Normal` | 通知优先级等级,排序第二关键字(本实现字段,见 7.2) |
| `NotificationSourceGuid` / `ProviderSettings` / `ChannelSettings` | — | — | 由主机在 Setup 阶段回填,运行期字段 |
`NotificationContent`(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationContent.cs:16-109`)必须包含:`Content`(自定义内容组件)、`ContentTemplate` / `ContentTemplateResourceKey`(模板,可用资源键在运行时反射解析,见 `MainWindowLine.cs:658-665`)、`IsSpeechEnabled`(默认 `true`)、`SpeechContent`(默认 `""`)、`Color`(涟漪颜色,默认 null → 取主题强调色)、`Duration`(默认 **5 秒**)、`EndTime`(DateTime?,非空时**取代** Duration 计算剩余时间)。`IsTimingInit` 为内部字段。
三个必须实现的模板工厂(NotificationContent.cs:147-214):
- `CreateTwoIconsMask(text, leftIcon="lucide(\ue0ff)", rightIcon="lucide(\ue224)", hasRightIcon=true, factory?)`:生成 `TwoIconsMaskTemplateData`,`SpeechContent = text`,模板键 = `TwoIconsMaskTemplateData.TemplateResourceKey`。
- `CreateSimpleTextContent(text, factory?)`:`SimpleTextTemplateData`,`SpeechContent = text`。
- `CreateRollingTextContent(text, duration=20s, repeatCount=2, factory?)`:`RollingTextTemplate`(滚动字幕),`SpeechContent = text`,`Duration = duration`。
#### 7.1.2 提醒设置 NotificationSettings
默认值必须与源码一致(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationSettings.cs:11-17`):
| 字段 | 默认 |
|---|---|
| `IsNotificationEnabled` | `true` |
| `IsSpeechEnabled` | `false` |
| `IsNotificationEffectEnabled` | `true` |
| `IsNotificationSoundEnabled` | `false` |
| `NotificationSoundPath` | `""` |
| `IsNotificationTopmostEnabled` | `false` |
| `IsSettingsEnabled` | `false`(**总开关**:为 false 时这一层设置整段不生效) |
全局设置(`repos/ClassIsland/ClassIsland/Models/Settings.cs`)必须提供:`IsNotificationEnabled=true`、`NotificationProvidersEnableStates` / `NotificationProvidersPriority`(string 数组)/ `NotificationProvidersSettings` / `NotificationProvidersNotifySettings` / `NotificationChannelsNotifySettings`(四张以 GUID 字符串为键的字典)、`AllowNotificationSpeech=false`、`AllowNotificationEffect=true`、`AllowNotificationSound=false`、`AllowNotificationTopmost=true`、`IsSpeechEnabled=true`、`SpeechVolume=1.0`、`SpeechSource=0`、`SelectedSpeechProvider="classisland.speech.edgeTts"`、`EdgeTtsVoiceName="zh-CN-XiaoxiaoNeural"`、`IsNotificationSoundEnabled=true`、`NotificationSoundPath=""`、`NotificationSoundVolume=1.0`、`IsNotificationEffectEnabled=true`、`IsNotificationTopmostEnabled=true`、`NotificationEffectRenderingScale=1.0`、`IsNotificationEffectRenderingScaleAutoSet=false`、`NotificationUseStandaloneEffectUiThread=true`、`AnimationLevel=1`、`ExcludedWeatherAlerts=[]`、`ClassPrepareNotifySeconds=60`。
证据:`Settings.cs:43,88-94,135-156,158,182,149-151,142-145,1617,784,363`。
### 7.2 队列、优先级与去重(最终确定语义)
提醒主机必须维护一个按**三级排序键**确定顺序的队列(有序数组或最小堆均可,但比较结果必须唯一且稳定):
```
排序键 = (
providerPriorityIndex, // 1) 提供方在 NotificationProvidersPriority 中的下标,升序;下标小 = 先播
levelRank, // 2) 通知优先级等级,升序:Emergency=0 < High=1 < Normal=2 < Low=3
enqueueSeq // 3) 入队序号,升序(FIFO,先入先播)
)
```
- **第一关键字**:提供方优先级,由设置页拖动排序写入 `NotificationProvidersPriority`(数组下标)。
- **第二关键字**:通知优先级 `Level`,类型 `NotificationLevel`,默认 `Normal`;等级映射(本实现约定):集控消息 `payload.IsEmergency=true` → `Emergency`;气象预警 → `High`;上下课/放学/行动提醒 → `Normal`;插件可自行指定。
- **第三关键字**:同提供方、同等级时严格 FIFO。
**与 ClassIsland 2.1 的差异(必须写进实现说明与变更记录)**:源码只有 `IsPriorityOverride`(bool) 与从未参与比较的 `PriorityOverride`(int)(`repos/ClassIsland/ClassIsland.Core/Models/Notification/NotificationRequest.cs:153-155`),且 `NotificationPriority.CompareTo` 在 `IsPriorityOverride=true` 时返回 1(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationPriority.cs:47-50`),在 .NET 最小堆语义下反而**更晚**出队,与「紧急插队」直觉相反。**本实现按上面的排序键修正语义(Emergency 必须最先播放)**,并做字段兼容:读取旧数据时 `IsPriorityOverride==true` → `Level=Emergency`;写出时 `Level ∈ {Emergency, High}` → `IsPriorityOverride=true`。不得再保留「与原版逐位一致」的备选实现。
入队去重(必须实现):
- 每次入队前检查 `EnqueuedRequests` 集合,**同一个 request 对象只允许入队一次**;重复调用直接返回(NotificationHostService.cs:178-183)。
- 出队时用 `PoppedRequests` 记录已弹出的请求,避免「链式提醒的头节点」被弹出两次(369-399)。
- 正文/遮罩播放完成、取消都会把 request 从 `PlayingNotifications`、`PoppedRequests`、`EnqueuedRequests` 中剔除(116-126, 488-522)。
链接提醒(必须实现):`ShowChainedNotifications(requests[])` 把多个请求串成一条链(NotificationHostService.cs:205-254):
1. 数组为空直接返回;全局提醒关闭则把每个 request 的 `CompletedTokenSource` 取消后返回。
2. 用 `CancellationTokenSource.CreateLinkedTokenSource` 生成 `RootCancellationTokenSource`(取消令牌)与 `RootCompletedTokenSource`(完成令牌),任一子请求取消即取消全部。
3. 为每个 request 建立 `ChainedHeadRequest = requests[0]`、`ChainedNextRequest = 下一个`、`RootCancellationTokenSource`、`RootCompletedTokenSource`,逐个 `SetupNotificationRequest` 并注册完成回调。
4. 优先尝试推送给消费者(见 7.5);无消费者时才逐个入队。
「准备上课 + 上课」就是一条链:准备上课提醒的正文以 `EndTime = 上课时间点.StartTime` 结束,随后立刻进入上课提醒(ClassNotificationProvider,见 7.4.1)。
### 7.3 提供者与渠道注册
提供者必须有 GUID、名称、图标、描述;渠道是提供者下的子分组(可独立配置),渠道也有 GUID。内置清单必须照抄:
| 提供者 | GUID | 图标 | 渠道 |
|---|---|---|---|
| 上下课提醒 | `08F0D9C3-C770-4093-A3D0-02F3D90C24BC` | `\uE958` | 准备上课 `CDDFE7FF-B904-4C73-B458-82793B2F66E9`(图标 `\ue21a`)、上课 `AFF5B9A4-037C-4A71-8563-C9EA87DDA75C`(`\uE479`)、下课 `77C9F3FB-0A2A-4B22-BDDF-3C333462B2F9`(`\ue4c3`) |
| 放学提醒 | `8FBC3A26-6D20-44DD-B895-B9411E3DDC51` | `\ued34` | 无 |
| 天气预警 | `7625DE96-38AA-4B71-B478-3F156DD9458D` | `\ue4db` | 无 |
| 集控提醒 | `0117fb4f-5374-434a-97fb-4f5374634a07` | `\uE821` | 无 |
| 行动提醒 | `4B12F124-8585-43C7-AFC5-7BBB7CBE60D6` | `\ue01e` | 无 |
证据:`repos/ClassIsland/ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs:22-30`、`AfterSchoolNotificationProvider.cs:16`、`WeatherNotificationProvider.cs:26`、`ManagementNotificationProvider.cs:15`、`ActionNotificationProvider.cs:7`。注册入口:`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:198-202`。
注册时必须自动补全四份持久化配置(`NotificationHostService.cs:79-114`):
1. `NotificationProvidersPriority` 中不存在该 GUID → **追加到末尾**(即新的提供方优先级最低)。
2. `NotificationProvidersSettings[guid] = null`(提供方私有设置对象,按需反序列化)。
3. `NotificationProvidersEnableStates[guid] = true`。
4. `NotificationProvidersNotifySettings[guid] = new NotificationSettings()`。
5. 建立注册信息对象 `{ Name, Description, ProviderGuid, ProviderInstance, ProviderSettings, NotificationChannels[] }`(`repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationProviderRegisterInfo.cs:14-56`)。
6. 遍历该提供者声明的所有 `NotificationChannelInfo`,为每个渠道创建 `NotificationChannel` 并注册:`NotificationChannelsNotifySettings[channelGuid] = new()`,挂到对应提供者的 `NotificationChannels` 列表(`NotificationHostService.cs:273-284,104-114`)。
设置三层覆盖(**极其重要,必须按此顺序取第一个 `IsSettingsEnabled==true` 的层**,`repos/ClassIsland/ClassIsland/Services/NotificationWorkerService.cs:109-124`):
```
candidates = [request.ChannelSettings, request.ProviderSettings, request.RequestNotificationSettings]
effective = GlobalSettings
for c in candidates: if (c != null && c.IsSettingsEnabled) { effective = c; break }
```
`action` 字段含义:`ChannelSettings`(渠道级,优先级最高)→ `ProviderSettings`(提供者级)→ `RequestNotificationSettings`(单次请求)。全局设置永远是兜底。
### 7.4 内置提供者行为(逐个实现)
#### 7.4.1 上下课提醒 ClassNotificationProvider
设置默认值(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/ClassNotificationSettings.cs:7-22`):
| 字段 | 默认 |
|---|---|
| `IsClassOnNotificationEnabled` | `true` |
| `IsClassOnPreparingNotificationEnabled` | `true` |
| `IsClassOffNotificationEnabled` | `true` |
| `InDoorClassPreparingDeltaTime` | `60`(秒) |
| `OutDoorClassPreparingDeltaTime` | `600`(秒) |
| `ClassOnPreparingText` | `"准备上课,请回到座位并保持安静,做好上课准备。"` |
| `OutdoorClassOnPreparingText` | `"下节课程为户外课程,请合理规划时间,做好上课准备。"` |
| `IsSpeechEnabledOnClassPreparing` / `OnClassOn` / `OnClassOff` | 均为 `true` |
| `ShowTeacherName` | `false` |
| `ClassOnPreparingMaskText` | `"即将上课"`(室内/户外两个字段同默认) |
| `ClassOnMaskText` | `"上课"` |
| `ClassOffMaskText` | `"课间休息"` |
| `ClassOffOverlayText` | `""` |
行为 A:**准备上课**(每个 `PostMainTimerTicked` 检测,ClassNotificationProvider.cs:63-100):
1. 仅当 `CurrentState ∈ {Breaking, None}` 时继续。
2. 触发条件:`0 < OnClassLeftTime <= Δ` 且 `IsClassOnPreparingNotificationEnabled`;`Δ` 取附加设置 → 否则按「下节课是否户外」取 600 或 60 秒。
3. 已提醒过(`IsClassPreparingNotified`)→ 不再提醒。
4. 计算剩余秒 `deltaTime`:`deltaTime = Δ - OnClassLeftTime`,若 `> 10 秒` 则用 `OnClassLeftTime`,否则用 `Δ`(`CalculateDeltaTime`,125-129)。遮罩朗读文本固定为 `"距上课还剩{格式化时长}。"`,遮罩持续 **3 秒**。
5. 若 `IsClassOnNotificationEnabled`,同时构造「上课」请求并入链。
6. 正文内容:`ClassPrepareNotifyOverlay`,包含左列 `"距上课还剩 N 秒"`(N 为 `OnClassLeftTime` 总秒数,加粗)与右列 `"下节课是:" + 科目名(`MainWindowLargeFontSize` 加粗)+ 可选教师名 + 起止时间(`hh:mm-hh:mm`)`;左右两列各占 `1*`,单元格内 `Margin=12 0`(`repos/ClassIsland/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml:19-46`)。
7. 正文 `EndTime` = 今天 + `NextClassTimeLayoutItem.StartTime`(即一直显示到上课时刻),朗读文本 = `"{正文文本} 下节课是:{科目名}[,由{姓氏}老师任教]。"`。
8. 条件不再满足时(例如时间被追溯改晚):取消已发出的准备上课请求,并把 `IsClassPreparingNotified` 复位。
行为 B:**上课**(响应 `OnClass` 事件,240-286):
1. 若 `IsClassOnNotificationEnabled=false`、`IsClassOnNotified=true`、当前时间点为空、或 `now - CurrentTimeLayoutItem.StartTime > 5 秒`(迟到超过 5 秒不提醒)→ 全部跳过。
2. 只发遮罩:`CreateTwoIconsMask(ClassOnMaskText, rightIcon="lucide(\ue54f)")`,默认持续 5 秒;`SpeechContent = ClassOnMaskText`。
3. 事件回调在「取消」时复位 `IsClassOnNotified`(**注意源码注册在 `CancellationToken` 上**,283 行)。
4. `OnClass` 事件处理末尾必须把 `IsClassPreparingNotified=false`、`IsClassOnNotified=false`(源码注释说明:避免「提醒过程中时间提前导致播完还没上课」造成重复提醒,244-247)。
行为 C:**下课**(响应 `OnBreakingTime`,199-238):
1. 校验:`IsClassOffNotificationEnabled` 且 `CurrentTimeLayoutItem != Empty` 且 `now - CurrentTimeLayoutItem.StartTime <= 5 秒`,否则跳过。
2. 正文文本 `overlayText = 附加设置的 ClassOffOverlayText ?? 全局设置`;`showOverlayText = 非空白`。
3. `是否还有下节课 = (NextClassSubject != Subject.Fallback)`。
4. 遮罩:`ClassOffNotification` 布局——左图标 `\ue0ff`(20×20,居中)、中间文字 = `CurrentTimeLayoutItem.BreakNameText`(`MainWindowEmphasizedFontSize`、加粗、左右 `Margin=8 0`)、右图标 `\ue250`(20×20);文字颜色 `TextOnAccentFillColorPrimaryBrush`。**遮罩时长:无下节课 5 秒;有下节课 2 秒**。朗读内容 = `BreakNameText`。
5. 正文:无下节课 → `null`;有下节课 → `ClassOffOverlay`:左列 `"本节{课间名}长 {人性化时长}"`(加粗时长,格式见下)、右列 `"下节课是:" + 科目名 + 可选教师 + 起止时间`;**正文时长:有自定义文本 20 秒,否则 10 秒**。朗读文本 = `"本节{课间名}常{时长},下节课是:{科目名}[,由{姓氏}老师任教]。{overlayText}"`(源码该句为「常」字,属笔误,实现时建议修正为「长」并记录)。
6. 人性化时长格式(`ClassNotificationProviderControl.axaml.cs:134-152`):`>0` 才有输出;小时用 `"N 小时"`;分钟在有秒时用 `"N 分"`、无秒时用 `"N 分钟"`;秒用 `"N 秒"`;以空格连接;`<=0` 返回 `"0 分钟"`。
7. 内容轮播:该控件的 `ListBox` 每 **10 秒**在「自定义 Element」与「Message 文本」之间切换(`Timer Interval = 10 秒`,仅当 `_key` 为 `ClassPrepareNotifyOverlay` 或 `ClassOffOverlay` 时启动,112-117);`Message` 为空时轮播仍然发生但显示空白,因此实现时必须保证这两类正文的 `Message` 非空或禁用轮播。
**附加设置(数据来源与字段;完整模型见 06 §6.10)**:
- 存储位置:附加设置挂在分别继承 `AttachableSettingsObject` 的 `ClassPlan` / `ClassInfo`(课程)/ `TimeLayout` / `TimeLayoutItem` / `Subject` 上,字段为 `AttachedObjects: Record<Guid, 设置对象>`(`repos/ClassIsland/ClassIsland.Shared/AttachableSettingsObject.cs:15`)。`Profile` 根不承载附加设置。
- 读取 API 与优先级:`GetAttachedSettingsByPriority(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`)。
- 本提供者使用的附加设置 GUID:**上课提醒设置 `08F0D9C3-C770-4093-A3D0-02F3D90C24BC`**(与提供方 GUID 相同),可附加目标 `ClassPlan | TimeLayout | Lesson | Subject | TimePoint`(`Controls/AttachedSettingsControls/ClassNotificationAttachedSettingsControl.axaml.cs:12-15`)。
- 生效字段:`IsClassOnNotificationEnabled`、`IsClassOnPreparingNotificationEnabled`、`IsClassOffNotificationEnabled`、`ClassPreparingDeltaTime`、`ClassOnPreparingText`、`OutdoorClassOnPreparingText`、`ClassOnPreparingMaskText`、`OutdoorClassOnPreparingMaskText`、`ClassOnMaskText`、`ClassOffMaskText`、`ClassOffOverlayText`(`Models/AttachedSettings/ClassNotificationAttachedSettings.cs:9-20`)。
- 覆盖规则:仅当附加设置 `IsAttachSettingsEnabled == true` 时整组覆盖提供方设置,否则回退 `Settings`(`ClassNotificationProvider.cs:103-123,199-265`)。
- 时间点来源:下课/上课用 `CurrentSubject` / `CurrentTimeLayoutItem`;准备上课用 `NextClassSubject` / `NextClassTimeLayoutItem`(`ClassNotificationProvider.cs:288-314`)。
#### 7.4.2 放学提醒 AfterSchoolNotificationProvider
- 响应 `OnAfterSchool`(`repos/ClassIsland/ClassIsland/Services/NotificationProviders/AfterSchoolNotificationProvider.cs:34-48`)。
- 条件:设置启用(附加设置优先)且 `now - 最后一个 TimeType∈{0,1} 时间点.EndTime <= 10 秒`。
- 附加设置:GUID `8FBC3A26-6D20-44DD-B895-B9411E3DDC51`(与提供方 GUID 相同),**只能附加到 ClassPlan | TimeLayout**;字段 `IsEnabled`(默认 `true`)、`NotificationMsg`(默认 `"今天的课程已结束,请同学们有序离开。"`);`IsAttachSettingsEnabled` 默认 **`false`**,因此默认走提供方设置(`Controls/AttachedSettingsControls/AfterSchoolNotificationAttachedSettingsControl.axaml.cs:11-13`、`Models/AttachedSettings/AfterSchoolNotificationAttachedSettings.cs:11-13`)。
- 遮罩:`CreateTwoIconsMask("放学", rightIcon: "\ued34")`,默认 5 秒,朗读「放学」。
- 正文:`CreateSimpleTextContent(NotificationMsg)`,**时长 30 秒**。
- 默认文案:`NotificationMsg = "今天的课程已结束,请同学们有序离开。"`,`IsEnabled = true`(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/AfterSchoolNotificationProviderSettings.cs:9-10`)。
- 正文布局:单行文本居中,`Margin=24 0`,`TextTrimming=CharacterEllipsis`(`Controls/NotificationProviders/AfterSchoolNotificationProvider.axaml:15-23`)。
#### 7.4.3 天气提醒 WeatherNotificationProvider
设置默认值(`repos/ClassIsland/ClassIsland/Models/NotificationProviderSettings/WeatherNotificationProviderSettings.cs:10-14`):`AlertShowMode=Disabled(0)`、`ForecastShowMode=Disabled(0)`、`IsAlertEnabled=true`、`IsForecastEnabled=true`、`WeatherAlertSpeed=7.0`(字/秒)。`NotificationModes` 枚举为 `Disabled=0, EnabledForce=1, Default=2`(`repos/ClassIsland/ClassIsland.Shared/Enums/NotificationModes.cs:6-19`)。
触发时机:仅 `OnClass` 与 `OnBreakingTime` 两个事件(WeatherNotificationProvider.cs:57-65,116-120)。每次事件都先 `ShowAlertsNotification()` 再 `ShowForecastNotification()`。
**气象预警**(122-164):
1. 天气未刷新(`IsWeatherRefreshed=false`)→ 跳过。
2. 取附加设置(按当前时间点)→ 无则用提供方设置。附加设置 GUID 为 `7625DE96-38AA-4B71-B478-3F156DD9458D`,**只能附加到 TimePoint**,且 `hasEnabledState=false`(没有启用开关,写回不受 `IsAttachSettingsEnabled` 限制);字段仅 `AlertShowMode`、`ForecastShowMode`(`NotificationModes`,默认均为 `Default`),`IsAttachSettingsEnabled` 默认 `true`(`Controls/AttachedSettingsControls/WeatherNotificationAttachedSettingsControl.axaml.cs:11-13`、`Models/AttachedSettings/WeatherNotificationAttachedSettings.cs:11-13`)。读取入口仍是 `GetAttachedSettingsByPriority<WeatherNotificationAttachedSettings>(ProviderGuid, timeLayoutItem: CurrentTimeLayoutItem)`(`WeatherNotificationProvider.cs:71-73,126-128`)。
3. 关闭条件:`!Settings.IsAlertEnabled`,或附加设置 `AlertShowMode == Disabled`,或(附加设置 `AlertShowMode == Default` 且全局 `AlertShowMode == Disabled`)。
4. 对 `LastWeatherInfo.Alerts` 中**未在本次会话展示过**(`ShownAlerts` 以 `Detail` 文本去重)的每条预警:计算单次显示秒数 `t = Detail.Length / WeatherAlertSpeed`,**钳制到 [10, 90]**;遮罩 = 预警标题(图片图标 22×22 + 标题 `MainWindowEmphasizedFontSize` 加粗,`Margin=8 0`),`SpeechContent = 标题`,时长 **5 秒**;正文 = 预警详情(滚动文本),时长 **2t**,`SpeechContent = Detail`。展示后把 `Detail` 记入 `ShownAlerts`(会话内不再重复)。
5. 正文布局:左列 22×22 图标(`Margin=10 0 16 0`,`ZIndex=1`),右侧为裁剪容器内的滚动面板(`Margin=27 0 0 0`)(`Controls/NotificationProviders/WeatherNotificationProviderControl.axaml:18-46`)。
**三天天气预报**(67-93):`IsForecastEnabled` 与 `ForecastShowMode` 同上判定;遮罩与正文都用 `WeatherForecastNotificationProvider` 控件,正文时长 **15 秒**。
**逐小时预报**(102-114):基准时间 = `LastWeatherInfo.UpdateTime` **向上取整到整点**(`RoundUpToHour`,95-100),正文时长 15 秒。
天气数据侧的去重与过滤(必须同时实现,`repos/ClassIsland/ClassIsland/Services/WeatherService.cs:363-388`):先按 `Settings.ExcludedWeatherAlerts` 对 `Title`/`Detail` 做包含匹配过滤(空串忽略),再按 `Type` 分组只保留 `PubTime` 最新的一条。
#### 7.4.4 集控提醒 ManagementNotificationProvider
仅在集控模式下注册自身(构造函数 `base(false)` 后手工调用注册,`repos/ClassIsland/ClassIsland/Services/NotificationProviders/ManagementNotificationProvider.cs:24-39`)。收到 `CommandTypes.SendNotification` 指令时(41-80):
1. `repeatCounts = max(1, payload.RepeatCounts)`;`durationSeconds = payload.DurationSeconds <= 0 ? 5 : payload.DurationSeconds`。
2. `maskText = payload.MessageMask 为空白 ? "集控通知" : MessageMask`。
3. 遮罩:`CreateTwoIconsMask(maskText, rightIcon: "\uE7E7")`(默认 5 秒)。
4. 正文:`CreateRollingTextContent(messageContent, 时长 = durationSeconds * repeatCounts, repeatCounts)`(滚动 N 次)。
5. `Level = payload.IsEmergency ? Emergency : Normal`(同时为兼容置 `IsPriorityOverride = payload.IsEmergency`,见 7.2);`RequestNotificationSettings` 用 payload 的 `IsSpeechEnabled / IsEffectEnabled / IsSoundEnabled / IsTopmost` 全部覆盖,`IsSettingsEnabled = true`。
#### 7.4.5 行动提醒 ActionNotificationProvider
本身不主动发提醒,只作为「显示提醒」自动化行动的发送源暴露静态实例(`NotificationAction.cs:20-21,106`)。因此它必须注册、必须出现在设置页,但不需要自己的触发逻辑。
### 7.5 消费者(谁负责显示)
提醒主机自己不画气泡,而是推给「消费者」(`INotificationConsumer`,`repos/ClassIsland/ClassIsland.Core/Abstractions/INotificationConsumer.cs:9-31`):
- 消费者按 `priority` 插入有序列表,**priority 小在前**(`NotificationHostService.cs:436-459`)。
- 推送条件:`AcceptsNotificationRequests == true && QueuedNotificationCount <= 0`(337-367)。
- 主界面每个「行」(MainWindowLine)都是消费者:`AcceptsNotificationRequests = IsNotificationEnabled && !IsAllComponentsHid && !_isOverlayOpen && !_isUnloading`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:814`)。行级 `IsNotificationEnabled` 由编辑模式里「此行可以显示提醒」开关控制(`repos/ClassIsland/ClassIsland/MainWindow.axaml:425-433`)。
- 无消费者时退回队列;消费者播放完毕要主动 `PullNotificationRequests()` 拉后续(784-799)。
- 宿主在推送前如检测到有「未完成移交」的提醒(`_pendingRescheduleCount > 0`),必须暂缓推送(60, 344-348)。
### 7.6 播放流程(气泡两阶段 + 状态机)
单个 request 的播放(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs:682-811`,必须逐步实现):
1. 若正在播(`_isOverlayOpen`)→ 直接返回;否则上锁。
2. 首次播放记录 `_firstProcessNotificationsTime`。若全局提醒关闭,或「静默启动」且距首次播放 ≤ 10 秒(`App.ApplicationCommand.Quiet`)→ 清空请求队列(698-704)。`-q` 即静默启动参数(`repos/ClassIsland/ClassIsland/Models/ApplicationCommand.cs` 的 `Quiet`)。
3. 队列循环:取出 ticket(含 request 与生效设置),置 `CurrentNotificationRequest`。
4. 若 `MaskContent.Duration > 0` 且未取消:
- 清除 `:mask-anim`、`:overlay-out`、`:mask-in`、`:mask-out` 伪类。
- 绑定遮罩内容(`MaskContent = request.MaskContent`,先解析 `ContentTemplateResourceKey` → `ContentTemplate`)。
- 若生效设置 `IsNotificationTopmostEnabled && 全局 AllowNotificationTopmost` → 主界面 `AcquireTopmostLock(ticket)`;否则 `ReleaseTopmostLock(ticket)`。
- 置 `:mask-anim`、`:mask-in`、清除 `:overlay-anim`。
- **播放强调特效**:仅当 `IsNotificationEffectEnabled && 全局 AllowNotificationEffect && !IsAllComponentsHid && IsMainWindowVisible && !request.MaskSession.HasSoundsPlayed` → 取当前行中心点,`PlayEffect(new RippleEffect(center, MaskContent.Color))`。
- `await ticket.ProcessMask()`(含语音、音效、`Task.Delay(duration)`)。
5. 遮罩结束后:
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置 `:overlay-anim`、`:mask-out`、`:overlay-out`,结束本次。
- 否则绑定正文、置 `:mask-out`、`:overlay-in`,`await ticket.ProcessOverlay()`。
6. 队列与宿主队列都空且本次确实播过 → 置 `:overlay-anim`、`:mask-out`;清空 `MaskContent/OverlayContent`;解锁;释放置顶锁。
7. 捕获 `TaskCanceledException` 只记录 trace,不得弹错误。
播放会话与计时(`repos/ClassIsland/ClassIsland/Services/NotificationWorkerService.cs:161-248`):
- 进入会话先 `SetupNotificationSessionTiming`:若 `EndTime != null` 且尚未初始化 → `Duration = EndTime - now`(负值钳 0);`SessionStartTime` 首次写入;重启计时 Stopwatch;会话持续时长 = `EndTime != null ? EndTime - now : Duration - SessionPlayedTime`(82-107)。
- 置 `request.State = Playing`。
- 语音:`isSpeechEnabled = 生效设置.IsSpeechEnabled && content.IsSpeechEnabled && 全局 AllowNotificationSpeech`,且该会话尚未播放过声音 → `SpeechService.EnqueueSpeechQueue(content.SpeechContent)`(178-182)。
- 音效:仅**遮罩**阶段(`isMask`)、生效设置 `IsNotificationSoundEnabled`、全局 `AllowNotificationSound`、且未播过声音时播放;`NotificationSoundPath` 为空 → 用内置默认音效 `avares://ClassIsland/Assets/Media/Notification/1.wav`(`INotificationProvider.DefaultNotificationSoundUri`,`repos/ClassIsland/ClassIsland.Shared/Interfaces/INotificationProvider.cs:37`),音量 = `Settings.NotificationSoundVolume`;播放失败只记日志(184-208)。
- 计时结束:若「无正文」或「当前是正文阶段」→ `State=Completed`、清空语音队列、取消音效令牌、取消 `CompletedTokenSource`(212-218)。注意**遮罩阶段结束不会结束整个提醒**,音效令牌挂在整条提醒的取消令牌上(132-134 注释明确说明)。
- 异常:`TaskCanceledException` → `State = 请求被取消 ? Cancelled : Paused`;其他异常 → `Paused`(222-234)。
- 进度:每个 `PostMainTimerTicked` 用「显式结束时间」或「已播放时间 + 本次 Stopwatch」反算 `LeftProgress = 1 - elapsed/Duration`(65-80)。
### 7.7 气泡视觉与动画参数(像素级)
容器与排版(`repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:118-197,275-449`;ClassicTheme 对应 136-330 行结构相同):
- 行容器高度:`IslandContainerHeight = 40`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.axaml:16`)。
- 遮罩层 `Border#OverlayMask`:`Height = IslandContainerHeight`、`HorizontalAlignment/VerticalAlignment=Stretch`、`ClipToBounds=True`、`RenderTransformOrigin=Center`、文字前景 `TextOnAccentFillColorPrimaryBrush`、圆角 = 行圆角(由 `MainWindowStylesAssist.CornerRadius` 经 `SizeDoubleToCornerRadiusConverter` 计算,参数 1.0)。
- 遮罩内容外层 `Border Padding="12 0"`;内容 `ContentPresenter#OverlayMaskContent` 初始 `Opacity=0`,带 `ScaleTransform`。
- 正文层:外层 `Border ClipToBounds=True`,圆角同上;内部 `Panel` 依次叠「进度条 Canvas(贴底)」与 `ContentPresenter`(`MaxHeight = IslandContainerHeight`)。
- 正文进度的 `ProgressBar`:`Maximum=1`、`Value = CountdownProgressValue`(绑定 `CurrentNotificationRequest.LeftProgress`,`MainWindowLine.axaml:77`)、`MinWidth=0`、`Width` 绑定容器宽度,`Value` 用 `DoubleTransition Duration=0:0:0.05` + `LinearEasing`。
- 遮罩阴影:`:mask-in` 时 `BoxShadow = "0 8 32 0 #66000000"`(未分岛与分岛分别落在 `BackgroundBorderOverlayMask` / `OverlayMaskShadowRoot` 上),且该 Border 有 `BoxShadowsTransition Duration=0:0:0.1`。
遮罩进入动画(`:mask-in`,FluentTheme/Styles.axaml:313-371):
1. `SlantedMaskControl` 置 `IsOpened = true`(斜切遮罩展开)。
2. `OverlayMaskContent`:`Opacity 0→1`,`Delay=260ms`、`Duration=250ms`、`Easing = "0.25, 1, 0.5, 1"`(自定义贝塞尔,近似 QuartOut)。
3. `OverlayMaskContent` 缩放:`ScaleX/ScaleY 1.1→1.0`,`Delay=260ms`、`Duration=750ms`、同一条缓动曲线。
4. 遮罩背景 `BackgroundBorderOverlayMask`:`Opacity 0→1`,`Delay=600ms`、`Duration=100ms`、`FillMode=Forward`。
5. 原内容 `Grid#GridContentRoot`:`Opacity = 0`。
遮罩退出动画(`:mask-out`,372-417):
1. `SlantedMaskControl.IsOpened = false`(斜切遮罩向中间收拢)。
2. `OverlayMaskContent`:`Opacity 1→0`,`Duration=200ms`,`FillMode=Forward`;同时 `Scale 1.0→1.1`,`Duration=200ms`,`Easing="0.5, 0, 0.75, 0"`(约 QuartIn)。
3. 遮罩背景:`Opacity → 0`,`Duration=100ms`(并有静态 `Opacity=0` 兜底)。
正文淡入淡出(419-449):
- `:overlay-anim`:`Grid#GridOverlay` 用 `DoubleTransition Opacity 200ms + SineEaseIn`,置 `Opacity=0`。
- `:overlay-in`:`GridOverlay Opacity=1`,`GridContentRoot Opacity=0`。
- `:overlay-out`:`GridOverlay Opacity=0`,`GridContentRoot Opacity=1`。
斜切遮罩 `SlantedMaskControl`(`repos/ClassIsland/ClassIsland/Controls/SlantedMaskControl.cs`,必须完整实现):
- 5 个区域,水平权重 `[0, 0.1, 0.2, 0.4, 0.2, 0.1]`(前缀 0 占位,索引 0..5),每个区域占 `总宽 × weight`;区域宽 = `总宽 × weight[i+1] + 0.5`。
- 倾斜角固定 60°:`offset = tan(30°) × h`,`总宽 = 控件宽 + offset`;`SlantedHeight` 默认 `80`,超过控件高时取控件高,≤0 时取控件高。
- 每个区域绘制平行四边形:顶点为 `(c-w/2, 0)`、`(c+w/2, 0)`、`(c+w/2-offset, h)`、`(c-w/2-offset, h)`,其中 `c` 为区域中心、`w = 区域全宽 × 进度`;进度 < 0.0001 跳过。
- 展开(Open,两边→中间):区域 0、4 起始 `delay=0`;1、3 `delay = 0.75×StageStagger`;2(中间)`delay = StageStagger`。收起(Close,中间→两边)顺序相反。
- 动画总时长 = `StageStaggerMs + RegionDurationMs`;`RegionDurationMs` 默认 `300`,但界面里显式设为 **`360`**、`StageStaggerMs = 120`(`Styles.axaml:175-180`)。
- 缓动:展开 `QuarticEaseOut`,收起 `QuadraticEaseIn`;`FillMode=Forward`;结束后把所有区域进度钉到 1(展开)或 0(收起),避免插值残留。
- 动画必须可被新的 Open/Close 打断(内部 `CancellationTokenSource` 取消上一次)。
### 7.8 强提醒:全屏强调特效
特效元素:`RippleEffect`(`repos/ClassIsland/ClassIsland/Controls/NotificationEffects/RippleEffect.axaml/.cs`)。必须实现为一个**透明、不命中测试**的全屏控件,参数如下:
- 外观:`Ellipse StrokeThickness=200`,填充色 = `MaskContent.Color`,为 null 时取资源 `AccentFillColorDefaultBrush`;`IsHitTestVisible=False`;Canvas `ClipToBounds=True`(axaml:10-31)。
- 半径:计算控件中心到**四个顶点**距离取最大值,再 `Ceiling`,作为圆半径 `r`;`EllipseSize = Width = Height = r * 2`;`TranslateTransform` 用 `RippleEffectTranslationConverter` 把尺寸换算为位移,使圆心对齐点击中心。
- 展开动画:`Scale 0→1`,**Duration=600ms**,`QuadraticEaseIn`;同时 `Opacity 1→0`,**Duration=600ms**,`SineEaseIn`;均通过 Composition 层关键帧(`ElementComposition.GetElementVisual`)启动,保证 60fps 不占 UI 线程。
- 生命周期:动画开始后 `await Task.Delay(750)` 再抛 `EffectCompleted`(多出 150ms 让不透明度收尾)。
特效承载窗口 `TopmostEffectWindow`(`repos/ClassIsland/ClassIsland/Views/TopmostEffectWindow.axaml/.cs`):
- 基窗口:`Topmost=True`、`Height=1 Width=1`(真实尺寸由 `UpdateWindowPos` 覆盖)、`Background=Transparent`、`CanResize=False`、`SystemDecorations=None`、`TransparencyLevelHint=[Transparent]`;内容为 `ItemsControl` + `Grid` 面板(多个特效可叠加)(axaml:1-35)。
- 显示时机:`EffectControls.Count > 0` 时 `Show()`,为 0 时 `Hide()`;**不销毁窗口**(cs:50-67)。
- 每屏一个窗口:按「主界面所在屏幕」计算——`bounds = fullscreen ? screen.Bounds : screen.WorkingArea`;`Width = bounds.Width × scale - (fullscreen ? 1 : 0)`(减 1 像素规避全屏边界异常)、`Height = bounds.Height × scale`;`Position = (bounds.X, bounds.Y)`。`scale` 来自 `Settings.NotificationEffectRenderingScale`(默认 1.0;`IsNotificationEffectRenderingScaleAutoSet` 为 true 时按 DPI 自动推算,具体系数【推断:源码未给出公式】)。
- 窗口特性:`ShowActivated=false`、`ShowInTaskbar=false`、`Topmost=true`;平台层设 `Transparent | ToolWindow | Topmost | SkipManagement`(cs:120-128)。
- 关闭拦截:除 `OSShutdown` / `ApplicationShutdown` 外一律 `e.Cancel = true`(cs:111-118)。
- **编辑模式丢弃**:`ViewModel.IsEditMode == true` 时不播放新特效(cs:74-78)。
- 特效结束即从 `EffectControls` 移除(cs:90-98);若播放中进入编辑模式,允许已播放的结束。
- `Settings.NotificationUseStandaloneEffectUiThread`(默认 true)表示特效使用独立 UI 线程渲染;Electron 映射为**独立透明 BrowserWindow**(与灵动岛同款 `transparent:true, frame:false, focusable:false, skipTaskbar:true, alwaysOnTop:'screen-saver'`),并把它的 `setIgnoreMouseEvents(true)` 打开。该窗口不得使用 `setBackgroundMaterial()`,以免破坏逐像素透明。
**置顶锁**(必须实现,`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:1056-1092`):
- `AcquireTopmostLock(token)`:内部列表去重;当且仅当「从无到有」时置 `IsNotificationWindowExplicitShowed=true`;若 `Settings.WindowLayer == 0` 则立即 `UpdateWindowLayer()` 与 `ReCheckTopmostState()`。
- `ReleaseTopmostLock(token)`:移除 token;列表非空直接返回;列表为空且 `IsNotificationWindowExplicitShowed` 为真 → 复位标志、`SetBottom()`、`UpdateWindowLayer()`。
- 即:多个提醒同时存在时,只有最后一个释放才把主界面还原到原层级,避免闪烁。
### 7.9 声音、语音与音量
音效:
- 默认音效资源 `avares://ClassIsland/Assets/Media/Notification/1.wav`,仅在「遮罩阶段 + 未播过声音」时播一次;**音效与提醒同生命周期**:提醒被取消/正文结束才取消音效(关键行为,见 7.6)。
- 音量取全局 `NotificationSoundVolume`(0~1,滑块 `Minimum=0 Maximum=1 TickFrequency=0.01`,`Views/SettingPages/NotificationSettingsPage.axaml:313-318`)。
- 自定义音效:`NotificationSoundPath` 为空 → 用默认资源;非空 → 读文件;**若音效比提醒显示时长更长,多出来的部分被截断**(界面文案:`"设置自定义音效,留空以禁用。如果音效长于提醒显示时间,多出来的部分会被截断。"`,NotificationSettingsPage.axaml:329-337)。
- 播放实现:主进程持有唯一音频引擎(源码用 MiniAudio/SoundFlow,`repos/ClassIsland/ClassIsland/Services/AudioService.cs:20-136`),**共享默认播放设备并引用计数复用**(48-68),设备 `MasterMixer.Volume=1.0`,单次播放音量设在播放器上(106-108)。Electron 映射:用 `HTMLAudioElement` 或 `AudioContext` 在渲染进程播放,音量乘全局系数;多路并发必须共享一个 `AudioContext`。
语音(TTS):
- 语音层开关:全局 `AllowNotificationSpeech`(默认 **false**)必须为 true,且生效设置 `IsSpeechEnabled` 与 `content.IsSpeechEnabled` 三者为真才朗读(7.6 已述)。
- 语音源 `SpeechSource`:0 = 系统 TTS,1 = Edge TTS(源码在设置 `SpeechSource` 变化时会把 `SpeechProvider` 重置,Settings.cs:1443-1447)。默认提供方 `SelectedSpeechProvider = "classisland.speech.edgeTts"`。
- 系统 TTS(`repos/ClassIsland/ClassIsland/Services/SpeechService/SystemSpeechService.cs:27-34`):`SpeechSynthesizer.Volume = SpeechVolume * 100`(整数 0~100)。
- Edge TTS(`repos/ClassIsland/ClassIsland/Services/SpeechService/EdgeTtsService.cs`):默认音色 `"zh-CN-XiaoxiaoNeural"`(Settings.cs:138);缓存目录 `AppCache/EdgeTTS/{音色}/{文本 MD5}`(31,62);播放音量 `SpeechVolume`(142);找不到音色时记警告并跳过(168-172)。文本 → 音频结果必须缓存,重复提醒不得重复合成。
- 语音队列:`EnqueueSpeechQueue(text)` 入队、`ClearSpeechQueue()` 在提醒完成/取消时清空(NotificationWorkerService.cs:181,215)。
- 全局音量 `SpeechVolume` 默认 `1.0`,滑块 `Minimum=0 Maximum=1 TickFrequency=0.01`(NotificationSettingsPage.axaml:262-271);另有「测试朗读文本 / 测试语音」入口(273-283)。
- 语音引擎列表来自注册的语音提供方;设置页显示 `Name` 并用 `SettingsExpanderItem Content="语音引擎"` 包裹(237-259)。
**关于「静音时段」**:ClassIsland 2.1 **没有**独立的静音时段/免打扰时间表功能。可查到的相关行为只有两条:①全局 `IsNotificationEnabled` 总开关;②`-q/--quiet` 静默启动时,启动后 10 秒内的提醒会被丢弃(`MainWindowLine.cs:698-704`)。**不得**虚构一个「静音时段」配置项冒充原功能,也不得因此阻塞 P0/P1 验收。
若产品仍需要该能力,必须按**可选扩展处理:优先级 P2,默认关闭(`IsEnabled=false`)**,并在设置页显式标注「扩展功能」;推荐模型:
```json
"QuietHours": { "IsEnabled": false, "Ranges": [{ "Start": "22:00", "End": "06:30" }], "AllowTopmost": false, "AllowSound": false, "AllowSpeech": false }
```
以上为【推断/扩展】。P2 实现时还必须满足:命中静音时段时**提醒本身照常入队与显示**,只按开关屏蔽音效/语音/置顶/特效,不得静默丢弃提醒(与 `-q` 的「丢弃」语义区分开)。
### 7.10 取消与清理
- `CancelAllNotifications()`:清空队列并对每个出队请求取消 `CompletedTokenSource`;对所有正在播放的请求取消 `CancellationTokenSource` 与 `CompletedTokenSource`(`NotificationHostService.cs:322-335`)。
- 用户入口:主界面右键菜单 `"清除全部提醒"`(图标 `E029`),`IsVisible` 同时要求「欢迎窗口已关闭」与 `NotificationHostService.IsNotificationsPlaying`(`repos/ClassIsland/ClassIsland/MainWindow.axaml:86-94`;点击处理 `MainWindow.axaml.cs:1171-1174`)。
- 设置页入口:关闭「启用提醒」总开关后,新请求在入口即被取消(`NotificationHostService.cs:159-163`)。
- **`ClearNotificationButtonWindow`(清除提醒浮动按钮窗口)在当前源码中是 `#if false` 的 WPF 遗留实现**(`repos/ClassIsland/ClassIsland/Views/ClearNotificationButtonWindow.xaml:8-15` 仅有一个空 `Button`,`.xaml.cs:1-18` 整段被 `#if false` 包裹)。因此:**不得**实现这个独立窗口;清除提醒统一通过右键菜单 + `CancelAllNotifications` 完成。若产品坚持要有悬浮清除按钮,必须作为新功能设计(透明置顶小窗、`SizeToContent=WidthAndHeight`、点击即 `CancelAllNotifications`),并标注为扩展。
- 「重新入队」语义:ticket 被取消但 request 未被取消时,宿主必须把 request 重新入队(`isPlayed=true`)并在 `CancellationCompletedCompletionSource` 完成后再次尝试推送(`NotificationHostService.cs:488-522`)。这保证「切行/改布局」导致的中断不会丢提醒。
### 7.11 设置页(提醒)UI 规格
页面结构(`repos/ClassIsland/ClassIsland/Views/SettingPages/NotificationSettingsPage.axaml`):
1. 顶部 `SettingsExpander`:`Header="启用提醒"`,`Description="启用提醒后,将会在对重要事件发出醒目提醒。拖动提醒提供方以调整对应类型提醒的优先级。"`,右侧 `ToggleSwitch` 绑定 `Settings.IsNotificationEnabled`(66-70)。
2. `TabItem "提醒提供方"`(87):左侧为提供方列表(图标 24×24 `ProviderInstance.IconElement` + 名称 + 描述,支持拖动排序 → 写回 `NotificationProvidersPriority`),右侧详情形。
3. 提供方详情:
- 每个渠道一行(`Header="提醒渠道"`),显示渠道图标/名称/描述(148-186)。
- `启用提醒语音`(`AllowNotificationSpeech`)+ `默认启用`(`IsSpeechEnabled`)+ `语音引擎` + `语音音量` + 测试朗读/测试语音(224-283)。
- `启用提醒强调特效`(`AllowNotificationEffect`)+ `默认启用`(`IsNotificationEffectEnabled`)(286-299)。
- `启用提醒音效`(`AllowNotificationSound`)+ `默认启用`(`IsNotificationSoundEnabled`)+ `音效音量` + `自定义音效`(301-347)。
- `置顶提醒`(`AllowNotificationTopmost`)+ `默认启用`(`IsNotificationTopmostEnabled`)(353-368)。
4. 提供方自身设置页 `TabItem "基本设置"`:承载 `ProviderInstance.SettingsElement`;无设置时显示 `"该提醒提供方没有设置。"`(374-390)。
5. 每个提供方/渠道的高级覆盖区:`Header="对此提醒来源启用特殊高级设置"`,`Description="启用后,此处的设置将覆盖全局设置和提醒请求中要求的设置。"`,勾选后写 `ProviderSettings.IsSettingsEnabled = true`,其下再出现语音/特效/音效/音效路径/置顶五项(409-484)。
6. 拖动排序必须即时持久化,并影响后续队列顺序(不必重排已在队列中的项)。
### 7.12 边界与异常(必须处理)
1. **提醒关闭**:`IsNotificationEnabled=false` 时新请求立刻取消完成令牌,不得入队、不得响铃。
2. **无消费者**:主界面被隐藏 / 所有行都禁用提醒 / 正在播 / 正在编辑 → 请求留在队列,等消费者恢复后再推。
3. **遮罩时长 0**:`MaskContent.Duration <= 0` 时不进入遮罩阶段,直接按「无正文」收尾。
4. **正文时长为 0**:视为无正文。
5. **`EndTime` 早于当前时间**:`Duration` 钳为 0,立即结束,不得出现负数时长导致 `Task.Delay` 抛异常。
6. **语音/音效同时开启**:语音先入队播放,音效在遮罩阶段播放;两者混音不得互相打断;提醒结束时清空语音队列。
7. **音效文件不存在 / 损坏**:捕获异常、记日志、继续播放提醒,不得中断整条链路。
8. **集控消息超长**:滚动文本时长已达 90 秒上限(预警)或由 `DurationSeconds × RepeatCounts` 决定;必须防止构造出超长(建议单条提醒时长上限 300 秒)。
9. **特效窗口在编辑模式**:丢弃特效但继续播气泡(不得因特效缺失阻塞)。
10. **多显示器 / DPI 变更**:特效窗口每个显示器一个;屏幕变化时必须重算 `UpdateWindowPos`,否则会出现「特效只在主屏」或缩放错位。
11. **提醒风暴**:同一提供者在极短时间内连续发多条(如天气多条预警)必须靠「会话内已展示集合」抑制重复;队列本身不限制长度,但连续入队要在 UI 上表现为顺序播放而不是同时播放。
12. **应用退出**:正在播放的提醒必须允许被 `ApplicationShutdown` 打断(特效窗口关闭拦截需放行该 reason)。
### 7.13 验收标准
1. 触发上课事件:默认配置下先出现「即将上课」遮罩(斜切遮罩展开 + 文字缩放淡入),随后显示正文「距上课还剩 N 秒 / 下节课是:语文 08:00-08:40」,正文一直显示到上课时刻;随后自动接「上课」遮罩。
2. 遮罩进入动画的四个关键时间点可核对:内容 `delay 260ms / 250ms`、缩放 `delay 260ms / 750ms`、背景 `delay 600ms / 100ms`、阴影过渡 100ms。
3. 遮罩退出的斜切收拢方向为「中间→两边」,正文淡入淡出 200ms + SineEaseIn。
4. 强提醒涟漪:从行中心扩散到全屏,600ms 完成,同时 600ms 淡出,750ms 后移除元素;特效窗口覆盖主界面所在屏幕,`scale` 改变后窗口尺寸按比例变化。
5. 置顶锁:连续两条提醒时主界面保持置顶,直到最后一条结束才还原层级。
6. 关闭「启用提醒」后,任何提供者都发不出提醒;重新开启后历史队列不补播。
7. 优先级排序:把「提供方 A(优先级下标 0)+ Normal」「提供方 B(下标 1)+ Emergency」「提供方 A + Emergency」三条同时入队,播放顺序必须是 A+Emergency → B+Emergency → A+Normal(先比提供方下标,再比等级,最后 FIFO);集控紧急消息必须最先播放且不再有「反而排到队尾」的现象。
8. 语音:打开 `AllowNotificationSpeech` 后,遮罩文本被朗读一次;正文阶段不重复朗读同一段;Edge TTS 音色默认 `zh-CN-XiaoxiaoNeural`,第二次相同文本命中缓存不重新合成。
9. 音效:默认音效在遮罩阶段播放一次,正文阶段不再播放;自定义音效超长部分被截断;音量滑块 0~1 生效。
10. 天气预警:同一预警在一次会话中只提醒一次;显示时长被钳制在 10~90 秒;正文为详情滚动文本。
11. 主界面右键「清除全部提醒」只在有提醒正在播放时出现,点击后队列清空、当前提醒立即消失、置顶锁释放。
12. 全流程不出现「二次弹窗框架」或与系统通知重复弹出的现象;所有异常均有日志且不打断后续提醒。
13. 附加设置覆盖:给某课表挂「上课提醒设置」附加设置并启用、把 `ClassOnMaskText` 改为 `"上课了"`,该课表上下课提醒的遮罩文案必须变为 `"上课了"`;关闭附加设置后立即回退为提供方设置的 `"上课"`。
14. 附加设置优先级:同一课程在 Subject / TimePoint / ClassPlan / TimeLayout 四层都挂了同一 GUID 并启用,实际生效的必须是 Subject 层(顺序 Subject → TimePoint → ClassPlan → TimeLayout)。
15. 天气:断网时提醒链路不得异常中断,界面继续显示 `LastWeatherInfo` 与「更新于 HH:mm」;恢复网络后最迟一个刷新周期(5 分钟)内更新;坐标模式精度降级到 2 位时必须返回并提示 `IsPrecisionDegraded`。
16. 天气图标:把 `WeatherIconId` 依次切换为 4 个内置模板,天气组件与天气提醒的图标必须随之切换;填入不存在的 ID 时保留上一次模板并记 Warning,不得崩溃。
### 7.14 天气服务与数据模型
天气服务必须实现为一个**单例服务 + 单一数据缓存**,供「天气组件」「天气提醒」「天气规则(Part 08)」共用;禁止任何模块自行发请求。主进程负责 HTTP 请求与缓存落盘,渲染进程通过 IPC 订阅更新。
#### 7.14.1 刷新策略与缓存
- 定时刷新:`DispatcherTimer` 周期 **5 分钟**(`repos/ClassIsland/ClassIsland/Services/WeatherService.cs:41-44`),构造时 `UpdateTimer.Start()` 并**立即执行一次** `QueryWeatherAsync()`(69-71)。
- 刷新成功:写 `Settings.LastWeatherInfo = info`、`IsWeatherRefreshed = true`(386-389)。`IsWeatherRefreshed` 默认 `false`(46),所有依赖天气的提醒/规则必须先判它。
- 缓存:`LastWeatherInfo` 持久化在 `config.json`(ClassIsland 原生写入 `Settings.json`,字段 `_lastWeatherInfo = new()`,`Settings.cs:98`),因此**重启后先展示上次缓存**,直到首次刷新成功;界面必须展示 `UpdateTime`(`更新于 HH:mm`,`WeatherSettingsPage.axaml:87`)。
- 缓存丢失/为空:`LastWeatherInfo` 是新对象时所有数值为默认值(温度 `""`、天气码 `"99"`、预警空数组),UI 必须显示占位而不是 0。
- 自动位置:`AutoRefreshWeatherLocation=true` 时每次启动首次查询前调用一次 `LocationService.GetLocationAsync()`,经纬度四舍五入到 **4 位小数**写回设置(244-257);获取失败只记日志,继续用旧坐标。
- 刷新失败:捕获异常 → `WeatherQueryResult.IsSuccess=false` + `ErrorMessage`;`IsWeatherRefreshed` 保持原值;`finally` 中 `RulesetService.NotifyStatusChanged()`(391-400)。**源码无重试与退避**,唯一重试是下一个 5 分钟 tick;实现应补「失败后 30s/60s/120s 三次退避重试,之后回到 5 分钟周期」【推断/增强】。
- 并发:同一时刻只允许一个在途查询(源码靠单 Timer 隐式串行【推断】);实现必须加互斥锁,避免自动刷新与手动刷新同时请求导致 `LastWeatherInfo` 竞态。
#### 7.14.2 定位与城市来源
设置字段与默认值(`Settings.cs`):`CityId="weathercn:101010100"`(99)、`CityName="北京 (北京, 中国)"`(100)、`WeatherLongitude=0.0`(1838)、`WeatherLatitude=0.0`(1839)、`WeatherLocationSource=0`(1840,0=选择城市 / 1=坐标定位)、`AutoRefreshWeatherLocation=false`(1841)、`NoTLSWeatherRequests=false`(1842)、`WeatherIconId="classisland.weatherIcons.lucide"`(1849)、`ExcludedWeatherAlerts=[]`(158)。
- **坐标模式(source=1)**(`WeatherService.cs:265-323`):按精度 `[4,3,2,1,0]` 逐级降级请求 `{schema}://weatherapi.market.xiaomi.com/wtr-v3/location/city/geo?longitude=&latitude=&locale=zh_cn`;命中的判据是 `status == 0 && locationKey 非空`;命中即写回 `CityId/CityName`,`usedPrecision < 4` 时返回 `IsPrecisionDegraded=true` 与 `DegradedPrecision`;全部精度失败 → 抛 `"无法通过经纬度获取城市信息"`。
- **城市模式(source=0)**(`WeatherService.cs:324-344`):请求 `location/city/info?locationKey={CityId}&locale=zh_cn`,必须校验返回 `LocationKey == CityId`,取返回的 `latitude/longitude`;不匹配 → 记错误并返回失败。
- **城市搜索**(`GetCitiesByName`,414-445):名称为空 → `location/city/hots?locale=zh_cn`(热门城市);否则 `location/city/search?name={URL 编码}&locale=zh_cn`;结果映射为 `{ Name: "{name} ({affiliation})", CityId: locationKey }`,失败返回空数组(不抛)。
- 协议:`schema = NoTLSWeatherRequests ? "http" : "https"`(34)。设置页对「不使用 TLS」必须标注「不建议启用」(`WeatherSettingsPage.axaml:315-316`)。
- 位置来源 UI:`选择城市` / `坐标定位` 两项,后者仅在平台支持定位时显示(`WeatherSettingsPage.axaml:162-163`)。
#### 7.14.3 数据模型 JSON schema(字段名与类型必须一致)
`WeatherInfo`(`repos/ClassIsland/ClassIsland.Core/Models/Weather/WeatherInfo.cs:5-23`):
```json
{
"current": { CurrentWeather },
"alerts": [ WeatherAlert ],
"updateTime": 1720000000000,
"forecastDaily": { ForecastDaily },
"forecastHourly": { ForecastHourly },
"minutely": { Minutely },
"aqi": { AqiInfo }
}
```
派生字段:`UpdateTime = DateTimeOffset.FromUnixTimeMilliseconds(updateTime).LocalDateTime`(20)。
`CurrentWeather`(`CurrentWeather.cs:5-21`):`feelsLike`、`humidity`、`pressure`、`temperature`、`visibility` 均为 `ValueUnitPair { value: string, unit: string }`;`weather` 为字符串、默认 `"99"`;`pubTime`;`wind { direction: ValueUnitPair, speed: ValueUnitPair }`。`ValueUnitPair.ToString() = value + unit`(`ValueUnitPair.cs:14-17`)。
`ForecastDaily`(`ForecastDaily.cs:5-14`):`precipitationProbability { value: string[] }`、`temperature { value: RangedValue[] }`、`weather { value: RangedValue[] }`、`sunRiseSet { value: RangedValue[] }`;`RangedValue { from: string, to: string }`(`RangedValue.cs:5-9`)。
`ForecastHourly`(`ForecastHourly.cs:5-9`):`temperature { value: int[] }`、`weather { value: int[] }`。
`Minutely` / `MinutelyPrecipitation`(`Minutely.cs:5-8`、`MinutelyPrecipitation.cs:5-40`):`minutely.precipitation.value: number[]`;派生 `RainRemainingMinutes` 必须逐字实现:
```
空数组 → 0
若 value[0] > 0(正在下雨):从 i=0 起找第一个 value[i] <= 0 → 返回 -i(表示剩余 i 分钟)
否则:从 i=0 起找第一个 value[i] > 0 → 返回 i(表示 i 分钟后开始)
都没有 → 0
```
`WeatherAlert`(`WeatherAlert.cs:5-22`):`locationKey`、`alertId`、`pubTime`、`title`、`type`、`level`、`detail`、`images: Record<string,string>`;派生 `IsDefaultIcon`:`images["icon"]` 命中 4 个固定默认图标 URL(蓝/黄/橙/红预警)之一 → `1`,否则 `0`。
`AqiInfo`(`AqiInfo.cs:5-32`):`aqi: string` 默认 `"0.0"`;派生 `AqiLevel`:`≤50→1`、`≤100→2`、`≤150→3`、`≤200→4`、`≤300→5`、`>300→6`。
`CityInfo`(`CityInfo.cs:7-38`):`name`、`affiliation`、`locationKey`、`latitude`(字符串)、`longitude`(字符串)、`status`(默认 `-1`)。`City`:`{ Name, CityId }`(`City.cs`)。
`WeatherQueryResult`(`WeatherQueryResult.cs:6-29`):`IsSuccess`、`IsPrecisionDegraded`、`ErrorMessage`、`DegradedPrecision`。
天气文案:`GetWeatherTextByCode(code)` 查内嵌资源 `Assets/XiaomiWeather/xiaomi_weather_status.json`(`{ "weatherinfo": [ { "code": 0, "wea": "晴" }, … ] }`,含 0 晴、1 多云、2 阴、3 阵雨、4 雷阵雨、5 雷阵雨并伴有冰雹、6 雨夹雪、7 小雨、8 中雨、9 大雨、10 暴雨、11 大暴雨、12 特大暴雨、13 阵雪、14 小雪、15 中雪、16 大雪、17 暴雪、18 雾、19 冻雨、20 沙尘暴、21–28 组合降水、29 浮尘、30 扬沙、31 强沙尘暴、32 飑、33 龙卷风、34 弱高吹雪、35 轻雾、53 霾、301 雨、302 雪、99 未知),查不到返回 `"未知"`(`WeatherService.cs:405-412`、`XiaomiWeatherStatusCodeItem.cs:5-12`、`XiaomiWeatherStatusCodes.cs:5-10`)。
#### 7.14.4 预警与降水提醒的触发、去重
- 展示时机:仅在 `OnClass` 与 `OnBreakingTime` 两个事件(`WeatherNotificationProvider.cs:57-65,116-120`),见 7.4.3。
- 数据侧去重与过滤(`WeatherService.cs:363-388`,必须实现):
1. 用 `Settings.ExcludedWeatherAlerts` 对 `Title` 与 `Detail` 做**包含**匹配过滤,空字符串忽略。
2. 按 `Type` 分组,每组只保留 `PubTime` 最大的一条;被丢弃的旧预警记日志(`"已丢弃旧预警:…"`)。
3. `Alerts` 写回为去重后的集合。
- 提醒侧去重:`ShownAlerts` 以 `Detail` 文本记录「本次会话已展示」,同一 `Detail` 不重复提醒(`WeatherNotificationProvider.cs:141-163`)。
- 降水提醒:由规则 `classisland.weather.rainTime` 判定(语义见 Part 08 §8.8),数据来自 `minutely.precipitation.value` 与 `RainRemainingMinutes`;分钟数组长度由接口决定(默认 120 分钟)【推断:源码未断言长度】。
- 极端天气:源码**没有**独立的「极端天气等级」逻辑;`level` 只用于展示,`IsDefaultIcon` 仅判断是否默认图标。若产品需要「极端天气提醒」,必须用规则组合表达(例如 `hasWeatherAlert` + 标题关键字匹配),不得在天气服务里硬编码等级阈值【推断】。
#### 7.14.5 天气图标主题
- 注册 4 个内置模板(`App.Services.xaml.cs:287-290`):`classisland.weatherIcons.lucide` → `Lucide(默认)`、`classisland.weatherIcons.fluentDesign` → `Fluent Design`、`classisland.weatherIcons.sfSymbols` → `SF Symbols`、`classisland.weatherIcons.simpleText` → `纯文本`。
- 选择与缓存:`Settings.WeatherIconId`(默认 `classisland.weatherIcons.lucide`);服务启动时与 `WeatherIconId` 变化时重建 `SelectedWeatherIconTemplate`;找不到 ID → 记 Warning `"未找到 ID 为 {id} 的天气图标模板"` 并保持上一次模板(`WeatherService.cs:74-91`)。
- 模板注册集合为 `IWeatherService.RegisteredTemplates`(静态 `ObservableCollection<WeatherIconTemplateRegistryInfo>`);注册信息含 `Id/Name/Template`(`WeatherIconTemplateRegistryInfo.cs:10-33`)。
- 渲染:组件与提醒都必须通过 `SelectedWeatherIconTemplate` 渲染天气图标,禁止各自写死图标映射。
#### 7.14.6 断网降级与失败重试
- 断网:`Settings.IsNetworkConnect`(`Settings.cs:134`)存在但 `WeatherService` **不读取**;失败表现为请求异常 → 记日志 → 保留旧数据。UI 必须继续显示 `LastWeatherInfo` 与「更新于」时间,**不得清空**为占位。
- 重试:源码无显式重试,依赖 5 分钟周期;实现建议:失败后 30s/60s/120s 三次退避,之后回到 5 分钟周期;连续失败 ≥3 次在设置页显示「天气获取失败」提示【推断/增强】。
- TLS 降级:`NoTLSWeatherRequests=true` 时用 http;该开关必须默认关闭并在 UI 警示。
- 精度降级:坐标模式下必须把 `IsPrecisionDegraded/DegradedPrecision` 回传并提示用户「已降低定位精度到 N 位小数」。
- 数据损坏:`Settings.LastWeatherInfo` 反序列化失败时必须整体丢弃并重建空对象,不得让应用启动失败【推断】。
#### 7.14.7 服务与消费方分工(禁止重复实现)
| 消费方 | 职责 | 禁止 |
|---|---|---|
| 天气服务 | 请求、解析、缓存、去重、图标模板、定位 | 不得渲染 UI、不得直接发提醒 |
| 天气组件(Part 05) | 读 `LastWeatherInfo` + `SelectedWeatherIconTemplate` 渲染 | 不得发请求、不得改缓存 |
| 天气提醒(7.4.3) | 在 OnClass/OnBreakingTime 读缓存并推送 | 不得发请求 |
| 天气规则(Part 08) | 基于缓存判定布尔条件 | 不得发请求 |
#### 7.14.8 验收标准(天气服务)
1. 首次启动且无网络:`IsWeatherRefreshed=false`,界面显示占位;有缓存时显示缓存与「更新于」,提醒与规则均按 `IsWeatherRefreshed` 跳过。
2. 恢复网络后,最迟 5 分钟(或退避重试命中)内刷新成功,`LastWeatherInfo` 更新且组件/提醒同步变化。
3. 城市模式:修改 `CityId` 后刷新,城市名与新城市一致;非法 `CityId` 时返回失败且不清空旧数据。
4. 坐标模式:把坐标改到精度较低的位置,请求必须按 4→3→2→1→0 降级并回报 `IsPrecisionDegraded`。
5. 预警:同一 `type` 出现两条,只保留 `PubTime` 最新;被 `ExcludedWeatherAlerts` 命中的预警不进入 `Alerts`、不提醒。
6. 图标:4 个模板可切换并立即生效;非法 ID 保持上一次并记 Warning。
7. 降水:构造 `value` 数组「前 N 个 >0 后为 0」时 `RainRemainingMinutes = -N`;「前 N 个为 0 后 >0」时返回 `N`;全 0 返回 0。
8. AQI:41/51/101/151/201/301 分别落到 1/2/3/4/5/6 级。