- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
60 KiB
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):
- 数组为空直接返回;全局提醒关闭则把每个 request 的
CompletedTokenSource取消后返回。 - 用
CancellationTokenSource.CreateLinkedTokenSource生成RootCancellationTokenSource(取消令牌)与RootCompletedTokenSource(完成令牌),任一子请求取消即取消全部。 - 为每个 request 建立
ChainedHeadRequest = requests[0]、ChainedNextRequest = 下一个、RootCancellationTokenSource、RootCompletedTokenSource,逐个SetupNotificationRequest并注册完成回调。 - 优先尝试推送给消费者(见 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):
NotificationProvidersPriority中不存在该 GUID → 追加到末尾(即新的提供方优先级最低)。NotificationProvidersSettings[guid] = null(提供方私有设置对象,按需反序列化)。NotificationProvidersEnableStates[guid] = true。NotificationProvidersNotifySettings[guid] = new NotificationSettings()。- 建立注册信息对象
{ Name, Description, ProviderGuid, ProviderInstance, ProviderSettings, NotificationChannels[] }(repos/ClassIsland/ClassIsland.Shared/Models/Notification/NotificationProviderRegisterInfo.cs:14-56)。 - 遍历该提供者声明的所有
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):
- 仅当
CurrentState ∈ {Breaking, None}时继续。 - 触发条件:
0 < OnClassLeftTime <= Δ且IsClassOnPreparingNotificationEnabled;Δ取附加设置 → 否则按「下节课是否户外」取 600 或 60 秒。 - 已提醒过(
IsClassPreparingNotified)→ 不再提醒。 - 计算剩余秒
deltaTime:deltaTime = Δ - OnClassLeftTime,若> 10 秒则用OnClassLeftTime,否则用Δ(CalculateDeltaTime,125-129)。遮罩朗读文本固定为"距上课还剩{格式化时长}。",遮罩持续 3 秒。 - 若
IsClassOnNotificationEnabled,同时构造「上课」请求并入链。 - 正文内容:
ClassPrepareNotifyOverlay,包含左列"距上课还剩 N 秒"(N 为OnClassLeftTime总秒数,加粗)与右列"下节课是:" + 科目名(MainWindowLargeFontSize加粗)+ 可选教师名 + 起止时间(hh:mm-hh:mm);左右两列各占1*,单元格内Margin=12 0(repos/ClassIsland/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml:19-46)。 - 正文
EndTime= 今天 +NextClassTimeLayoutItem.StartTime(即一直显示到上课时刻),朗读文本 ="{正文文本} 下节课是:{科目名}[,由{姓氏}老师任教]。"。 - 条件不再满足时(例如时间被追溯改晚):取消已发出的准备上课请求,并把
IsClassPreparingNotified复位。
行为 B:上课(响应 OnClass 事件,240-286):
- 若
IsClassOnNotificationEnabled=false、IsClassOnNotified=true、当前时间点为空、或now - CurrentTimeLayoutItem.StartTime > 5 秒(迟到超过 5 秒不提醒)→ 全部跳过。 - 只发遮罩:
CreateTwoIconsMask(ClassOnMaskText, rightIcon="lucide(\ue54f)"),默认持续 5 秒;SpeechContent = ClassOnMaskText。 - 事件回调在「取消」时复位
IsClassOnNotified(注意源码注册在CancellationToken上,283 行)。 OnClass事件处理末尾必须把IsClassPreparingNotified=false、IsClassOnNotified=false(源码注释说明:避免「提醒过程中时间提前导致播完还没上课」造成重复提醒,244-247)。
行为 C:下课(响应 OnBreakingTime,199-238):
- 校验:
IsClassOffNotificationEnabled且CurrentTimeLayoutItem != Empty且now - CurrentTimeLayoutItem.StartTime <= 5 秒,否则跳过。 - 正文文本
overlayText = 附加设置的 ClassOffOverlayText ?? 全局设置;showOverlayText = 非空白。 是否还有下节课 = (NextClassSubject != Subject.Fallback)。- 遮罩:
ClassOffNotification布局——左图标\ue0ff(20×20,居中)、中间文字 =CurrentTimeLayoutItem.BreakNameText(MainWindowEmphasizedFontSize、加粗、左右Margin=8 0)、右图标\ue250(20×20);文字颜色TextOnAccentFillColorPrimaryBrush。遮罩时长:无下节课 5 秒;有下节课 2 秒。朗读内容 =BreakNameText。 - 正文:无下节课 →
null;有下节课 →ClassOffOverlay:左列"本节{课间名}长 {人性化时长}"(加粗时长,格式见下)、右列"下节课是:" + 科目名 + 可选教师 + 起止时间;正文时长:有自定义文本 20 秒,否则 10 秒。朗读文本 ="本节{课间名}常{时长},下节课是:{科目名}[,由{姓氏}老师任教]。{overlayText}"(源码该句为「常」字,属笔误,实现时建议修正为「长」并记录)。 - 人性化时长格式(
ClassNotificationProviderControl.axaml.cs:134-152):>0才有输出;小时用"N 小时";分钟在有秒时用"N 分"、无秒时用"N 分钟";秒用"N 秒";以空格连接;<=0返回"0 分钟"。 - 内容轮播:该控件的
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):
- 天气未刷新(
IsWeatherRefreshed=false)→ 跳过。 - 取附加设置(按当前时间点)→ 无则用提供方设置。附加设置 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)。 - 关闭条件:
!Settings.IsAlertEnabled,或附加设置AlertShowMode == Disabled,或(附加设置AlertShowMode == Default且全局AlertShowMode == Disabled)。 - 对
LastWeatherInfo.Alerts中未在本次会话展示过(ShownAlerts以Detail文本去重)的每条预警:计算单次显示秒数t = Detail.Length / WeatherAlertSpeed,钳制到 [10, 90];遮罩 = 预警标题(图片图标 22×22 + 标题MainWindowEmphasizedFontSize加粗,Margin=8 0),SpeechContent = 标题,时长 5 秒;正文 = 预警详情(滚动文本),时长 2t,SpeechContent = Detail。展示后把Detail记入ShownAlerts(会话内不再重复)。 - 正文布局:左列 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):
repeatCounts = max(1, payload.RepeatCounts);durationSeconds = payload.DurationSeconds <= 0 ? 5 : payload.DurationSeconds。maskText = payload.MessageMask 为空白 ? "集控通知" : MessageMask。- 遮罩:
CreateTwoIconsMask(maskText, rightIcon: "\uE7E7")(默认 5 秒)。 - 正文:
CreateRollingTextContent(messageContent, 时长 = durationSeconds * repeatCounts, repeatCounts)(滚动 N 次)。 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,必须逐步实现):
- 若正在播(
_isOverlayOpen)→ 直接返回;否则上锁。 - 首次播放记录
_firstProcessNotificationsTime。若全局提醒关闭,或「静默启动」且距首次播放 ≤ 10 秒(App.ApplicationCommand.Quiet)→ 清空请求队列(698-704)。-q即静默启动参数(repos/ClassIsland/ClassIsland/Models/ApplicationCommand.cs的Quiet)。 - 队列循环:取出 ticket(含 request 与生效设置),置
CurrentNotificationRequest。 - 若
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))。
- 清除
- 遮罩结束后:
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置
:overlay-anim、:mask-out、:overlay-out,结束本次。 - 否则绑定正文、置
:mask-out、:overlay-in,await ticket.ProcessOverlay()。
- 正文为 null / 已取消 / 正文时长 ≤ 0 → 置
- 队列与宿主队列都空且本次确实播过 → 置
:overlay-anim、:mask-out;清空MaskContent/OverlayContent;解锁;释放置顶锁。 - 捕获
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):
SlantedMaskControl置IsOpened = true(斜切遮罩展开)。OverlayMaskContent:Opacity 0→1,Delay=260ms、Duration=250ms、Easing = "0.25, 1, 0.5, 1"(自定义贝塞尔,近似 QuartOut)。OverlayMaskContent缩放:ScaleX/ScaleY 1.1→1.0,Delay=260ms、Duration=750ms、同一条缓动曲线。- 遮罩背景
BackgroundBorderOverlayMask:Opacity 0→1,Delay=600ms、Duration=100ms、FillMode=Forward。 - 原内容
Grid#GridContentRoot:Opacity = 0。
遮罩退出动画(:mask-out,372-417):
SlantedMaskControl.IsOpened = false(斜切遮罩向中间收拢)。OverlayMaskContent:Opacity 1→0,Duration=200ms,FillMode=Forward;同时Scale 1.0→1.1,Duration=200ms,Easing="0.5, 0, 0.75, 0"(约 QuartIn)。- 遮罩背景:
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、3delay = 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;CanvasClipToBounds=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):
- 顶部
SettingsExpander:Header="启用提醒",Description="启用提醒后,将会在对重要事件发出醒目提醒。拖动提醒提供方以调整对应类型提醒的优先级。",右侧ToggleSwitch绑定Settings.IsNotificationEnabled(66-70)。 TabItem "提醒提供方"(87):左侧为提供方列表(图标 24×24ProviderInstance.IconElement+ 名称 + 描述,支持拖动排序 → 写回NotificationProvidersPriority),右侧详情形。- 提供方详情:
- 每个渠道一行(
Header="提醒渠道"),显示渠道图标/名称/描述(148-186)。 启用提醒语音(AllowNotificationSpeech)+默认启用(IsSpeechEnabled)+语音引擎+语音音量+ 测试朗读/测试语音(224-283)。启用提醒强调特效(AllowNotificationEffect)+默认启用(IsNotificationEffectEnabled)(286-299)。启用提醒音效(AllowNotificationSound)+默认启用(IsNotificationSoundEnabled)+音效音量+自定义音效(301-347)。置顶提醒(AllowNotificationTopmost)+默认启用(IsNotificationTopmostEnabled)(353-368)。
- 每个渠道一行(
- 提供方自身设置页
TabItem "基本设置":承载ProviderInstance.SettingsElement;无设置时显示"该提醒提供方没有设置。"(374-390)。 - 每个提供方/渠道的高级覆盖区:
Header="对此提醒来源启用特殊高级设置",Description="启用后,此处的设置将覆盖全局设置和提醒请求中要求的设置。",勾选后写ProviderSettings.IsSettingsEnabled = true,其下再出现语音/特效/音效/音效路径/置顶五项(409-484)。 - 拖动排序必须即时持久化,并影响后续队列顺序(不必重排已在队列中的项)。
7.12 边界与异常(必须处理)
- 提醒关闭:
IsNotificationEnabled=false时新请求立刻取消完成令牌,不得入队、不得响铃。 - 无消费者:主界面被隐藏 / 所有行都禁用提醒 / 正在播 / 正在编辑 → 请求留在队列,等消费者恢复后再推。
- 遮罩时长 0:
MaskContent.Duration <= 0时不进入遮罩阶段,直接按「无正文」收尾。 - 正文时长为 0:视为无正文。
EndTime早于当前时间:Duration钳为 0,立即结束,不得出现负数时长导致Task.Delay抛异常。- 语音/音效同时开启:语音先入队播放,音效在遮罩阶段播放;两者混音不得互相打断;提醒结束时清空语音队列。
- 音效文件不存在 / 损坏:捕获异常、记日志、继续播放提醒,不得中断整条链路。
- 集控消息超长:滚动文本时长已达 90 秒上限(预警)或由
DurationSeconds × RepeatCounts决定;必须防止构造出超长(建议单条提醒时长上限 300 秒)。 - 特效窗口在编辑模式:丢弃特效但继续播气泡(不得因特效缺失阻塞)。
- 多显示器 / DPI 变更:特效窗口每个显示器一个;屏幕变化时必须重算
UpdateWindowPos,否则会出现「特效只在主屏」或缩放错位。 - 提醒风暴:同一提供者在极短时间内连续发多条(如天气多条预警)必须靠「会话内已展示集合」抑制重复;队列本身不限制长度,但连续入队要在 UI 上表现为顺序播放而不是同时播放。
- 应用退出:正在播放的提醒必须允许被
ApplicationShutdown打断(特效窗口关闭拦截需放行该 reason)。
7.13 验收标准
- 触发上课事件:默认配置下先出现「即将上课」遮罩(斜切遮罩展开 + 文字缩放淡入),随后显示正文「距上课还剩 N 秒 / 下节课是:语文 08:00-08:40」,正文一直显示到上课时刻;随后自动接「上课」遮罩。
- 遮罩进入动画的四个关键时间点可核对:内容
delay 260ms / 250ms、缩放delay 260ms / 750ms、背景delay 600ms / 100ms、阴影过渡 100ms。 - 遮罩退出的斜切收拢方向为「中间→两边」,正文淡入淡出 200ms + SineEaseIn。
- 强提醒涟漪:从行中心扩散到全屏,600ms 完成,同时 600ms 淡出,750ms 后移除元素;特效窗口覆盖主界面所在屏幕,
scale改变后窗口尺寸按比例变化。 - 置顶锁:连续两条提醒时主界面保持置顶,直到最后一条结束才还原层级。
- 关闭「启用提醒」后,任何提供者都发不出提醒;重新开启后历史队列不补播。
- 优先级排序:把「提供方 A(优先级下标 0)+ Normal」「提供方 B(下标 1)+ Emergency」「提供方 A + Emergency」三条同时入队,播放顺序必须是 A+Emergency → B+Emergency → A+Normal(先比提供方下标,再比等级,最后 FIFO);集控紧急消息必须最先播放且不再有「反而排到队尾」的现象。
- 语音:打开
AllowNotificationSpeech后,遮罩文本被朗读一次;正文阶段不重复朗读同一段;Edge TTS 音色默认zh-CN-XiaoxiaoNeural,第二次相同文本命中缓存不重新合成。 - 音效:默认音效在遮罩阶段播放一次,正文阶段不再播放;自定义音效超长部分被截断;音量滑块 0~1 生效。
- 天气预警:同一预警在一次会话中只提醒一次;显示时长被钳制在 10~90 秒;正文为详情滚动文本。
- 主界面右键「清除全部提醒」只在有提醒正在播放时出现,点击后队列清空、当前提醒立即消失、置顶锁释放。
- 全流程不出现「二次弹窗框架」或与系统通知重复弹出的现象;所有异常均有日志且不打断后续提醒。
- 附加设置覆盖:给某课表挂「上课提醒设置」附加设置并启用、把
ClassOnMaskText改为"上课了",该课表上下课提醒的遮罩文案必须变为"上课了";关闭附加设置后立即回退为提供方设置的"上课"。 - 附加设置优先级:同一课程在 Subject / TimePoint / ClassPlan / TimeLayout 四层都挂了同一 GUID 并启用,实际生效的必须是 Subject 层(顺序 Subject → TimePoint → ClassPlan → TimeLayout)。
- 天气:断网时提醒链路不得异常中断,界面继续显示
LastWeatherInfo与「更新于 HH:mm」;恢复网络后最迟一个刷新周期(5 分钟)内更新;坐标模式精度降级到 2 位时必须返回并提示IsPrecisionDegraded。 - 天气图标:把
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,必须实现):- 用
Settings.ExcludedWeatherAlerts对Title与Detail做包含匹配过滤,空字符串忽略。 - 按
Type分组,每组只保留PubTime最大的一条;被丢弃的旧预警记日志("已丢弃旧预警:…")。 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 验收标准(天气服务)
- 首次启动且无网络:
IsWeatherRefreshed=false,界面显示占位;有缓存时显示缓存与「更新于」,提醒与规则均按IsWeatherRefreshed跳过。 - 恢复网络后,最迟 5 分钟(或退避重试命中)内刷新成功,
LastWeatherInfo更新且组件/提醒同步变化。 - 城市模式:修改
CityId后刷新,城市名与新城市一致;非法CityId时返回失败且不清空旧数据。 - 坐标模式:把坐标改到精度较低的位置,请求必须按 4→3→2→1→0 降级并回报
IsPrecisionDegraded。 - 预警:同一
type出现两条,只保留PubTime最新;被ExcludedWeatherAlerts命中的预警不进入Alerts、不提醒。 - 图标:4 个模板可切换并立即生效;非法 ID 保持上一次并记 Warning。
- 降水:构造
value数组「前 N 个 >0 后为 0」时RainRemainingMinutes = -N;「前 N 个为 0 后 >0」时返回N;全 0 返回 0。 - AQI:41/51/101/151/201/301 分别落到 1/2/3/4/5/6 级。