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

60 KiB
Raw Blame History

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),并在设置页显式标注「扩展功能」;推荐模型:

"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):

{
  "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 级。