Files
classhelper-island-refactor…/parts/07-notifications.md
T
ClassIsland Prompt Kit 22c1784bab feat: ClassIsland / ClassHelper 灵动岛提示词套件
- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
2026-11-12 21:59:58 +08:00

528 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 级。