- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
41 KiB
8. 消息、提醒、课程提醒、强提醒特效、天气与课件覆盖(ClassHelper 落地规格)
本节是「消息与课程」这一部分在 ClassHelper 现有仓库内 的落地规格。前提口径(与 parts-r/01、parts-r/02 保持一致):
- 默认风格
appearance.family = 'classisland'(对应配置键islandAppearance.style,落盘字段以 parts-r/05 为准):fixed不再是胶囊+详情卡,而是 ClassIsland 风格信息行——默认 1 行 = 日期 + 课程表,行高 40、圆角 8、背景不透明度 0.5、底部 3px 进度条;消息到来时在同一行内展开 ClassIsland 风格气泡。原「胶囊 + 详情卡」只在family='winisland'兼容形态保留(老用户升级默认winisland,新安装默认classisland)。 custom常驻:保留现有schemes/rows/nodes/expandedRows数据模型与编辑器,渲染与交互按 ClassIsland 规格重构;消息入口仍是铃铛 + 共用消息面板。- 数据不迁移:通知/作业/叫人/课表/课件/天气全部沿用 ClassHelper 现有服务端与本地链路(
IslandNotification、ScheduleDto、IslandTimePointDto、CoursewareTransfer、IslandWeather)。 - 裁决优先级:ClassHelper 业务语义优先,ClassIsland 视觉表现优先;逐项裁决见 8.12。
8.1 数据契约(复用为主,最小扩展)
8.1.1 现有类型必须原样复用
| 类型 | 位置 | 用途 |
|---|---|---|
NotificationPriority = 'LOW'|'NORMAL'|'HIGH'|'URGENT' |
packages/shared/src/types.ts:30 |
业务优先级(四档) |
PRIORITY_RANK |
packages/shared/src/constants.ts:203-208 |
排序与合并共用的权重(LOW 0 / NORMAL 1 / HIGH 2 / URGENT 3) |
IslandNotification |
packages/shared/src/types.ts:1131-1143 |
岛消息载荷:id/title/content/priority/createdAt/courseName/teacherName/kind/subtitle |
IslandNotificationKind = 'notification'|'homework'|'call' |
packages/shared/src/types.ts:1111 |
消息类型 |
IslandState |
packages/shared/src/types.ts:1198-1230 |
coursewareTransfer/mode/lessonReminder/active/queued/inClass/currentPeriodEnd/reason/listExpanded/maxCardHeight/updatedAt |
IslandLessonReminder |
packages/shared/src/types.ts:1114-1128 |
kind: prepare/on-class/break/ending/in-class 等字段 |
LessonReminderSettings |
packages/shared/src/island-layout.ts:141-158 |
五类提醒开关 + prepareSeconds/endingSeconds |
ScheduleDto |
packages/shared/src/types.ts:545-564 |
dayOfWeek(1=周一…7=周日)/startTime/endTime/weekParity/course |
IslandTimePointDto |
packages/shared/src/class-plan.ts:76-87 |
timeType/name/courseName/startTime/endTime/isHideDefault |
CoursewareTransfer |
packages/shared/src/courseware.ts:82-94 |
stage/receivedBytes/totalBytes/progress/error |
IslandWeather |
packages/shared/src/island-layout.ts:38-44 |
temperature/code/windSpeed/fetchedAt/stale |
8.1.2 必须新增/扩展的字段(只加不改,保证迁移可回滚)
IslandState新增:family: 'classisland' | 'winisland':由外观下发给渲染进程,决定渲染形态;pendingStaged: number:上课期间被暂存的消息数(用于行内角标/铃铛计数);bubblePhase: 'hidden' | 'mask' | 'overlay' | 'closing':气泡阶段(仅fixed使用,见 8.4);bubbleDeadline: number:当前气泡阶段结束时间戳(毫秒),供渲染进程做进度条与自动收起(避免渲染进程自己计时导致主/渲染漂移)。
IslandNotification新增可选level?: 'emergency'|'high'|'normal'|'low':由priority映射而来(URGENT→emergency、HIGH→high、NORMAL→normal、LOW→low),用于 ClassIsland 三级排序键的中间键;不改变服务端 DTO,只在主进程派生。IslandLessonReminder新增progress: number(0–1,课程进度)与leftSeconds: number(整数秒,供徽标直接渲染),由主进程计算下发,渲染进程不得自行Date.now()推算(防止跨进程时钟漂移与系统时间跳变)。islandLayout新增version与family(见 parts-r/05);旧residentEnabled保留为兼容字段。
8.1.3 通知 DTO → 岛载荷的映射(单一入口)
必须在主进程的推送链路(现有 pushNotification 的调用方)集中实现,禁止各处自行拼装:
toIslandNotification(dto: NotificationDto, kindHint?): IslandNotification {
id: dto.id,
title: dto.title,
content: dto.content,
priority: dto.priority,
level: MAP[dto.priority],
createdAt: dto.createdAt,
kind: kindHint ?? 'notification', // 作业=homework;叫人=call(保留现有双播合并)
courseName: dto.course?.name ?? null,
teacherName: dto.teacher?.name ?? null,
subtitle: dto.subtitle ?? null
}
- 作业消息
kind='homework',id为本地合成的homework-<id>(packages/desktop-client/src/main/island.ts:1061-1065),不得拿它去调「标为已读」接口。 - 叫人消息
kind='call',标题由服务端buildCallTitle生成(packages/shared/src/constants.ts:571-575:请 XXX 同学找 XXX 老师)。
8.2 去重、合并与排序(ClassHelper 业务规则,必须保留)
- 按 id 去重:同一 id 只保留一条,
pendingNotifications()以 Map 去重(packages/desktop-client/src/main/island.ts:738-749;渲染侧同口径src/island/IslandApp.vue:86-97)。 - 通知/叫人双播合并:
createCall同时发notification:new与call:new,同 id 两条到达时必须合并而非覆盖:kind取更特殊的(call 优先),priority取更高(PRIORITY_RANK比较)(island.ts:713-724)。这是硬性要求,否则会出现「叫人胶囊显示成新消息、上课被当普通通知隐藏」。 - 排序口径:
PRIORITY_RANK降序 →createdAt降序(新的在前)(island.ts:744-748)。不得改用 ClassIsland 的三级键打乱业务优先级——ClassIsland 的「提供方优先级」在 ClassHelper 中没有对应概念,只有服务端单通道消息,因此本重构的排序键固定为(业务优先级 → 时间);若要引入 ClassIsland 的「通知等级」作为第二键,仅在与时间同序时作为稳定排序的次级键,不得跨优先级反转。 - 未读与已读:未读数以服务端为准(
GET /notifications/unread-count,docs/app/notifications.md:45-47);岛内「标为已读 / 全部已读」必须调用既有markRead/markAllRead处理器,成功后通知中心列表同步(island.ts:1044-1070;packages/desktop-client/src/renderer/stores/notifications.ts:102-122)。 - 实时撤回:收到
notification:deleted后必须从岛队列与当前展示中移除(island.ts:1210-1216),缓存与通知列表同步清理(docs/app/notifications.md:34)。 - 本机作业指纹:本机刚录的作业通过指纹避免重复提醒自己——这是既有规则,必须保留(
docs/app/island.md:25);实现位置在推送入口,不在岛渲染层。
8.3 队列与展示状态机(三形态对照 + 伪代码)
IslandState 的状态字段固定为:mode(hidden|pill|expanded)、active、queued、inClass、currentPeriodEnd、reason(new|after-class|urgent|call|closing)、listExpanded、maxCardHeight + 新增 bubblePhase/pendingStaged。
8.3.1 三种形态的展示规则对照
| 场景 | fixed + classisland(默认) |
fixed + winisland(兼容) |
custom |
|---|---|---|---|
| 空闲 | 信息行常驻(日期+课程表),无消息时不显示气泡 | 隐藏或细缝(现有规则) | 常驻组件行 |
| 课间普通通知/作业 | 行内气泡自动展开(遮罩→正文),可点开应用 | 胶囊,点击详情 | 铃铛 + 未处理数量,点击面板 |
| 课堂普通通知/作业 | 暂存:不展开气泡,行内显示「N 条暂存」角标(muted),下课补弹 | 暂存,下课补弹(现有) | 铃铛提示,允许主动查看 |
| 普通叫人(课间) | 行内气泡自动展开(kind=call 样式) | 胶囊(可点) | 铃铛 |
| 普通叫人(课堂) | 保持可见的「叫人」chip,不自动展开;点击后展开气泡 | 保留可点胶囊,不自动展开 | 铃铛,主动查看 |
| 紧急通知/紧急叫人 | 立即展开气泡(无视上课),并触发强提醒特效 | 立即展开 | 铃铛 + 提示,用户主动展开(可自动提示) |
| 处理完成 | 气泡收回,信息行恢复 | 队列清空后隐藏/细缝 | 回常态行 |
| 课件传输 | 覆盖整岛内容(信息行也覆盖),见 8.10 | 同 | 同 |
8.3.2 pushNotification 分支(必须逐条实现,以现有实现为基底)
现实现(packages/desktop-client/src/main/island.ts:794-867)分支顺序不得打乱,只在「展示方式」处按形态分流:
push(raw, ctx):
state.listExpanded = false
n = mergeNotification(findKnown(raw.id), raw)
inClass = ctx.inClass ?? state.inClass; state.inClass = inClass
if (ctx.currentPeriodEnd !== undefined) state.currentPeriodEnd = ...
if (ctx.preview) → 预览分支(30s 自收,失焦不收起)
if (isImmediate(n)): // priority === 'URGENT'(含紧急叫人)
正在展示的其它消息回队列
activate(n, n.kind==='call' ? 'call' : 'urgent', 'expanded')
→ classisland: bubblePhase='mask',并触发出特效
else if (inClass && n.kind==='call'):
activate(n, 'call', 'pill') // classisland: 只显示 chip,不展开气泡
else if (inClass):
queued = [..., n]; mode='hidden'(classisland: 保持行可见,pendingStaged++)
else:
上一条 active 回队列;queued 追加 n;activate(n, 'new', 'pill')
→ classisland: bubblePhase='mask',行内自动展开气泡
8.3.3 消息动作表(渲染 → 主进程,island.ts:929-1077)
| 动作 | 语义 | 备注 |
|---|---|---|
expand |
展开当前/最新消息 | classisland:进入气泡;winisland:展开详情卡;custom:打开消息面板 |
collapse |
收起 | 上课时普通通知直接隐藏;紧急/叫人回缩为 chip/胶囊 |
expand-list |
多条时「展开更多」 | 由 islandListLayout 决定行数与提示(见 8.6.3) |
dismiss |
单条「知道了」 | 不改已读 |
dismiss-all |
整批「知道了」 | 不改已读 |
mark-read |
标为已读 | 调后端,失败要回滚 UI 状态 |
mark-all-read |
整批已读 | 过滤掉 kind==='homework' 的本地 id |
open-app |
打开应用内通知页 | 列表行点击也走这里 |
8.3.4 自动收起超时(必须原值保留,island.ts:124-132)
| 形态 | 超时 |
|---|---|
pill(胶囊/chip) |
15 000ms |
| 课后补发展开 | 14 000ms |
| 课后补发胶囊 | 25 000ms |
| 展开(普通) | 20 000ms |
| 紧急 | 45 000ms |
| 叫人 | 90 000ms |
收回动画 CLOSE_ANIMATION_MS |
360ms(随 speed 缩放,island.ts:121) |
收起后的关键规则(island.ts:1266-1296):只要还有未处理消息(active 或 queued 非空),收起后必须保持 chip/胶囊/行内角标,不得直接消失;彻底隐藏只在「知道了 / 已读 / 队列清空 / 进入上课」时发生。
8.4 ClassIsland 风格气泡(fixed 默认形态,两阶段)
8.4.1 阶段机(必须由主进程状态驱动,渲染进程只渲染)
bubblePhase: hidden → mask →(有正文)overlay → closing → hidden
└(无正文/正文时长为 0)→ closing → hidden
- 进入
mask:行仍为 40 高,气泡在同一行内展开;遮罩背景为强调色,斜切遮罩扫入,遮罩文字(标题/徽标)延迟入场。 mask时长:默认 3 000ms;若该消息带正文且正文有明确EndTime/时长,则按正文时长决定(不得把正文时长叠加到遮罩上导致总时长翻倍)。- 进入
overlay:遮罩退出、正文淡入;底部显示剩余时间进度条(见 8.4.4)。 closing:正文/遮罩退出(200ms),随后窗口/行恢复;reason='closing'时行内不得显示任何「新消息」文案(防闪烁,沿用现有isClosing语义src/island/IslandApp.vue:73-80)。- 任一阶段都可被新消息打断:紧急消息直接覆盖当前气泡(当前消息回队列),普通消息按队列顺序等待。
8.4.2 行内气泡几何(像素)
- 行高固定 40;行圆角 8(
--ci-radius);行背景不透明度 0.5(只作用背景层)。 - 气泡内容内边距:水平 12(ClassIsland 行内容盒
Margin="12 0");遮罩文字层内边距 12 0;遮罩文字图标 20×20、间距 8。 - 气泡最大高度 = 行高 40(正文单行/双行截断,超出省略);多条列表形态时高度由
islandListLayout计算(见 8.6.3)。 - 气泡底部进度条:高 3px、贴底、圆角 1.5、
transform-origin:left、scaleX(progress)、值过渡 50ms linear(ClassIsland 3px 规格;现有课程提醒条是 2px,两者不得混用,见 8.8)。 - 遮罩背景:强调色实底(
--island-accent,默认#0a84ff);文字色#fff,标题 16px/600,副文本 12px/0.72 透明度。
8.4.3 气泡动画(ClassIsland 参数 + ClassHelper 弹簧的裁决)
裁决:岛的整体尺寸/圆角继续用 ClassHelper 的 SpringValue(stiffness=0.10、damping=0.68、换向保留 35% 动量、单帧位移 ≤ 剩余 20%、snap 0.05px,packages/desktop-client/src/island/spring.ts:26-114);气泡内部的遮罩扫入/文字/进度用 ClassIsland 的 CSS/WAAPI 参数。两条动画路径不得互相接管。
| 动画 | 参数 | 实现 |
|---|---|---|
| 斜切遮罩扫入 | 5 区段,权重 [0,0.1,0.2,0.4,0.2,0.1],每段 360ms,错峰 120ms(区 0/4 同时、1/3 延迟 90ms、2 延迟 120ms),缓动 QuarticEaseOut |
SVG polygon 5 片,或 clip-path 5 段;--ci-speed 缩放 |
| 斜切遮罩退出 | 反向:中间先收,1/3 延迟 90ms,0/4 延迟 120ms,缓动 QuadraticEaseIn,每段 360ms | 同上,反向 |
| 遮罩文字淡入 | delay 260ms、250ms、opacity 0→1、cubic-bezier(0.25,1,0.5,1) |
CSS animation both |
| 遮罩文字缩放 | delay 260ms、750ms、scale 1.1→1.0、同曲线 |
CSS transform |
| 遮罩退出文字 | 200ms、opacity 1→0 + scale 1.0→1.1、cubic-bezier(0.5,0,0.75,0) |
CSS |
| 正文淡入淡出 | 200ms、SineEaseIn |
CSS transition |
| 进度条值 | 50ms linear |
transform: scaleX |
| 行宽/行高变化 | 由弹簧决定(约 300–500ms 自然落定) | SpringValue.step() |
速度与降级:appearance.speed(0.5–2)必须同比缩放上述所有 ms:duration = base / speed,延迟同理;animations=false 时全部时长置 0(直接终态),但不得改变最终几何。系统「减少动态效果」时按 speed=2 且时长压到 1ms 处理。
8.4.4 气泡内容(信息层次)
- 遮罩阶段:类型图标(通知/作业/叫人 三套 SVG,沿用
src/island/ClassicNotifications.vue:115-132)+ 类型徽标(紧急/重要/普通/低或叫人/新作业)+ 一行标题;紧急加「需立即查看」、叫人加「老师正在等你」、课后补发加「下课后补发」(同一套live-dot语义)。 - 正文阶段:标题(单行省略)+ 正文(最多 2 行省略)+ 元信息(
教师名 · 时间)+ 可选subtitle(叫人提示语,如「请到办公室找我」)+ 按钮行(打开应用/标为已读/知道了;叫人时为收到)。 - 多条列表(
listMode):标题"N 条待处理通知",每行「类型图标 + 标题 +教师名 · 时间+ 类型徽标」,默认显示前 3 行,超出提示「展开更多(还有 N 条)」或「更多请前往应用内操作」(packages/shared/src/constants.ts:379-402,445-474)。
8.4.5 ClassicNotifications.vue 的改造与 winisland 兼容形态
packages/desktop-client/src/island/ClassicNotifications.vue 不得整体废弃,必须拆成「气泡内容」组件并同时服务两种形态:
- 两阶段复用:
fixed+classisland下,mask阶段只渲染它的header部分(类型图标 + 类型徽标 + 标题 + 紧急/叫人提示),overlay阶段再渲染title / body / footer(标题、正文、元信息、按钮行);列表形态(多条)只在overlay阶段出现。原组件的size/listLayout/pendingNotifications计算逻辑保留,改为由父组件传入。 winisland兼容:整卡(header + title + body + footer 一次性渲染)继续用于详情卡,交互与尺寸不变;只把文案/徽标/按钮语义与新气泡对齐(叫人时按钮「收到」等)。- 不得复制两份实现:图标、徽标、行布局、按钮行必须共用同一组件与同一份
classic-island.css,避免两种形态文案不一致。
8.5 强提醒全屏特效(Topmost)
8.5.1 行为
- 触发条件(全部满足):消息
priority==='URGENT'(紧急通知或紧急叫人);appearance.animations===true且appearance.effectEnabled(新增外观开关,默认true);岛当前可见(不是编辑模式、不是预览、不是隐藏);本节特效与气泡可同时出现。 - 表现:以岛中心为原点,强调色涟漪扩散到覆盖主界面所在屏幕;扩散 600ms、同步淡出 600ms,750ms 后移除元素并隐藏特效窗口。
- 独立窗口:新建
topmost-effect渲染入口与独立 BrowserWindow(transparent:true、frame:false、skipTaskbar:true、alwaysOnTop:true、showInactive、setIgnoreMouseEvents(true)、逐屏一个窗口);禁止setBackgroundMaterial();窗口内元素清空即隐藏,不销毁。 - 缩放与多屏:
effectScale(默认 1.0,复用现有ISLAND_SHADOW_PAD之外的独立字段)缩放窗口与圆心;屏幕/DPI 变化重算;4K@150% 下涟漪必须覆盖整屏无接缝。 - 降级:
animations=false、系统减少动态、编辑模式、窗口重建中、系统休眠恢复后的 1s 内 → 不播特效(只播气泡),不得阻塞消息展示。 - 非目标:本轮不做音效与 TTS(ClassHelper 无音频通道);
docs/app/notifications.md:23的「约 45 秒收起」仍由队列超时实现,与特效无关。
8.5.2 像素/动画参数
| 项 | 值 |
|---|---|
| 涟漪元素 | 全屏 SVG/CSS 圆(border-radius:50% 或 SVG circle),pointer-events:none |
| 扩散 | scale 0→1(半径 = 圆心到屏幕最远角),600ms,QuadraticEaseIn(≈cubic-bezier(0.11,0,0.5,0)) |
| 淡出 | opacity 1→0,600ms,SineEaseIn(≈cubic-bezier(0.12,0,0.39,0)) |
| 颜色 | --island-accent,默认 #0a84ff,透明度按 1→0 |
| 生命周期 | setTimeout(750 / speed) 后移除 |
| 窗口 | 覆盖 display.workArea;scale 后宽高乘系数 |
8.6 课程提醒五类(prepare / on-class / break / ending / progress)
命名对照:任务/文档口中的
progress(课中进度)在代码里对应IslandLessonReminder.kind === 'in-class',开关为classProgressEnabled;其余四类同名。本节一律用代码枚举值,并在需要时注明「progress」。
8.6.1 设置与默认值
islandLayout.lessonReminders(packages/shared/src/island-layout.ts:141-158,390-398):
| 字段 | 默认 | 范围 |
|---|---|---|
prepareEnabled |
true |
— |
onClassEnabled |
true |
— |
breakEnabled |
true |
— |
endingEnabled |
true |
— |
classProgressEnabled |
true |
— |
prepareSeconds |
60 |
5–1800 |
endingSeconds |
60 |
5–600 |
8.6.2 判定算法(LessonReminderTracker.update,必须逐行实现)
输入:schedules(当天过滤后按 startTime 升序)、now、settings。输出 IslandLessonReminder | null。证据:packages/desktop-client/src/main/lesson-reminder.ts:17-109。
- 当天课表 =
dayOfWeek === (now.getDay() || 7)的条目,按开始时间升序(周日=7)。 current= 满足start ≤ now < end的条目;next= 第一个start > now;ended=end ≤ now且距end< 12 000ms 的最近一条。- kind 判定顺序(自上而下,命中即止):
current && now - current.start < 5 000 && onClassEnabled→on-class;breakEnabled && !current && ended && now - ended.end < (next ? 12 000 : 5 000) && (now - ended.end < 5 000 || this.current?.id === keyOf(ended,'break'))→break;current && endingEnabled && current.end - now ≤ endingSeconds*1000→ending;!current && next && prepareEnabled && next.start - now ≤ prepareSeconds*1000→prepare;current && classProgressEnabled→in-class。
maskUntil / endsAt / lessonStartsAt / lessonEndsAt必须按源码公式:
| kind | maskUntil | endsAt |
|---|---|---|
| ending / in-class | entered(无遮罩) |
end |
| prepare | min(start, entered + 3000) |
start |
| break 且有下一节 | entered + 2000 |
end + 12000 |
| break 无下一节 | entered + 5000 |
end + 5000 |
| on-class | entered + 5000 |
start + 5000 |
id = "${dayKey}:${scheduleId}:${start}:${end}:${kind}";entered为同一 id 的连续保持时刻(避免同一提醒重复计时)。- 课程名
course?.name ?? '课程';时间字符串截到HH:mm(导入数据可能带秒,lesson-reminder.ts:78-79)。 - 渲染进程不得自行推导 kind,只消费主进程下发的
lessonReminder(以及新增的progress/leftSeconds)。
8.6.3 课程提醒条 UI(像素)
| 项 | 值 | 依据 |
|---|---|---|
| 容器 | 高 40、宽 max-content、min-width 300、max-width 800、圆角 8、背景 #20202080 |
LessonReminderContent.vue:196-208 |
| 信息层 | 高 40、居中、gap 12、padding 0 14、white-space:nowrap |
:209-216 |
| 主字号 | 16(课程名 20、时间 14、遮罩标题 18) | :204,235-245,262 |
| 进度条 | 高 2px、贴底、强调色、scaleX(progress)、transition 50ms linear |
:307-316 |
| 遮罩 | 5 片斜切 SVG、tan(30°)*40 偏移、区域边界 [0,0.1,0.3,0.7,0.9,1]、每片 360ms |
:62-72 |
| 遮罩时序 | 开:delays [0,90,120,90,0];关:[120,90,0,90,120];eased = p^4(开)/ p²(关) |
:91-99 |
| 兜底 | rAF 停摆时 600/speed ms 后强制到位 |
:83-89 |
| 文字动画 | 淡入 250ms/延迟 260ms、缩放 750ms/延迟 260ms,曲线 (0.25,1,0.5,1);退出 200ms、(0.5,0,0.75,0) |
:126-138,273-306 |
文案规则(LessonReminderContent.vue:14-23,143-169):
| kind | 左侧 | 课程侧 |
|---|---|---|
| prepare | 距上课还剩 N 秒 |
下节课是:课程名 + 时间 |
| on-class | 开始上课 |
本节课:课程名 + 时间 |
| break | 课间休息 |
下节课是:下一节课程名 + 下一节时间 |
| ending | (无左侧倒计时文字,只显示课程侧) | 本节课:课程名 + 时间 + 右侧蓝色秒数徽标(见 8.7) |
| in-class | 距下课还剩 N 秒 |
本节课:课程名 + 时间(无徽标) |
- 无课程时课程名显示
"今日课程已结束"。 leftSeconds = ceil((目标时间 - now)/1000),下限 0;目标时间:ending/in-class 用lessonEndsAt,其余用lessonStartsAt。progress:ending/in-class 用「已上课时间 / 总课时」,其余用「剩余时长 / 本提醒总时长」,钳制 0–1。
8.6.4 三形态差异(必须实现)
| 形态 | 行为 |
|---|---|
fixed + classisland |
课程提醒以行内条接管岛内容:信息行临时替换为提醒条(宽 = 行宽,高 40);ending 用徽标而非遮罩;in-class(进度)默认不使用提醒条(由课程表组件常驻显示进度),仅当用户开启 classProgressEnabled 且组件不可用时回退为提醒条【推断】 |
fixed + winisland |
保留现有实现:customLayout=false 时 ending 不接管(island.ts:173-185 的 lessonShowing 已排除 custom 的 ending),fixed 的 ending 仍以提醒条显示并使用徽标(docs/app/island.md:70) |
custom |
customLayout=true 时:kind==='in-class' 不接管岛(由 schedule 组件常驻显示进度);kind==='ending' 不接管岛内容,只在当前课程名右侧显示徽标(island.ts:198-199;docs/app/island.md:65-68) |
必须遵守的三条硬规则:
- 没有「即将下课」遮罩:
ending绝不显示 ClassIsland 式全行遮罩,只显示课程名右侧的蓝色秒数徽标(docs/app/island.md:68)。 - 不替换自定义岛常态组件:
custom模式下课程提醒(除 prepare/on-class/break 的临时接管)不得替换常态组件行。 - 关闭开关即不显示:
endingEnabled=false时不显示徽标;endingSeconds只影响徽标出现时机,不影响其他提醒。
8.7 下课前蓝色秒数徽标(重点规格)
8.7.1 视觉
| 项 | 值 |
|---|---|
| 位置 | 课程名 <strong> 右侧,与课程名同一行(inline-flex,flex:0 0 auto) |
| 形态 | 正圆 30×30,border-radius:50% |
| 背景 | 蓝色 #0a84ff(与默认强调色同值;可随 --island-accent 同步【推断:现实现写死 #0a84ff】) |
| 文字 | -N,白色 #fff,12px / 600,font-variant-numeric: tabular-nums,line-height:1,水平垂直居中 |
| 无障碍 | aria-label="距下课N秒" |
| 证据 | packages/desktop-client/src/island/lesson-ending.css:1-16;LessonReminderContent.vue:160-167 |
8.7.2 行为
- 显示条件(全部满足):
kind==='ending'(即current && endingEnabled && end - now ≤ endingSeconds*1000);leftSeconds ≥ 0;课表数据存在endTime。 - 数值:
leftSeconds = ceil((lessonEndsAt - now)/1000);显示为-${leftSeconds}。 - 剩余一秒显示
-1:当真实剩余时间在(0, 1000]ms时ceil结果为 1,徽标显示-1;不得显示-0,也不得在剩余 0 时继续显示(到点后ending条件不再成立,徽标消失)。这是用户明确要求的行为,必须有单测用例(now = end - 1ms→-1)。 - 阈值与开关:
endingSeconds默认 60(5–600);endingEnabled=false时完全不出现徽标;animations=false也不影响徽标(徽标不是动画)。 - 出现/消失动画:徽标可淡入 100ms(
opacity 0→1)并随课程提醒条一起退出;不得用缩放弹跳(避免课堂投影上的视觉干扰)。 - 两种岛都要用:
fixed(两种 family)与custom都必须显示该徽标;custom下徽标挂在schedule组件的当前课程名右侧,由组件内部渲染同一lesson-ending.css,不得复制第二套样式。 - 边界:
- 无时间点数据、按
ScheduleDto回退显示课程时,leftSeconds同样由endTime计算,徽标照常显示; - 跨天(课程结束时间 ≤ 开始时间)视为数据异常,不显示徽标并记一次告警;
- 系统时间跳变:以主进程下发的
leftSeconds为准,渲染进程不得自行用Date.now()覆盖; - 连续多节连堂:每节课结束时徽标各自独立计算,切换瞬间不得出现「-60 → -1」跳变(切换时应先隐藏上一节徽标再显示新一节)。
- 无时间点数据、按
8.8 课程进度条(两处,不得混用)
| 位置 | 高度 | 颜色 | 过渡 | 取值 |
|---|---|---|---|---|
| 课程提醒条底部(ClassHelper 既有) | 2px | --island-accent |
transform 50ms linear |
见 8.6.3 的 progress 公式 |
| 课程表组件内「当前课程」进度(ClassIsland 规格) | 3px | 强调色 | transform 50ms linear |
已上/总课时,钳 0–1 |
裁决:两者同时出现时(fixed + classisland 信息行同时含课程表与提醒条)保留两条——提醒条 2px 贴岛底,组件内进度 3px 贴课程项底;不得合并成一条,也不得把提醒条改成 3px(会与 ClassIsland 「行内进度条 3px」混淆)。渲染必须用 scaleX + transform-origin:left,不得对宽度做过渡。
8.9 天气简报(Open-Meteo,保留 ClassHelper 语义)
8.9.1 数据链路(不得改)
渲染组件 WeatherBrief.vue → window.island.getIslandWeather({latitude, longitude})(preload 白名单)→ IPC island:weather(packages/desktop-client/src/main/ipc.ts:23)→ 主进程 getIslandWeather(packages/desktop-client/src/main/island-weather.ts)→ Open-Meteo。
8.9.2 服务行为(必须原样保留)
- 固定服务与参数,不接受渲染层传 URL:
https://api.open-meteo.com/v1/forecast+current=temperature_2m,weather_code,wind_speed_10m+forecast_days=1(island-weather.ts:35-41)。 - 坐标校验:
latitude/longitude必须是有限数字,纬度绝对值 ≤90、经度 ≤180,否则抛「请设置有效的经纬度」(:14-22)。 - 缓存键 = 坐标定点 4 位(
:23);成功缓存 15 分钟(:60);失败缓存 60 秒退避并保留旧数据(:63);同一坐标并发请求复用同一 Promise(:29-30);缓存上限 32 条(:68)。 - 请求安全:
AbortSignal.timeout(8000)、redirect:'error'、响应体 > 64 000 字节拒收(:42-45);自动验收模式下不访问外网(:33-34)。 - 数据校验:三个数值字段必须都是有限数字,否则「天气数据格式不正确」(
:47-52)。 stale:成功返回时按fetchedAt距今 ≥ 15 分钟标记;失败返回旧数据时强制stale:true(:26,64)。- 渲染侧:每 60 秒刷新一次、坐标变化立即刷新、失败显示「天气暂不可用」、未设置坐标显示「请设置天气地点」、
stale || unavailable时追加· 缓存(WeatherBrief.vue:36-49,68-81)。
8.9.3 天气文案与建议(保留)
WMO 天气码映射(WeatherBrief.vue:50-60):0 晴、1/2/3 少云/多云/阴、45/48 雾、51-57 毛毛雨、61-67/80-82 雨、71-77/85/86 雪、95-99 雷雨、其他 天气。建议文案:code ≥ 95 → 雷雨天气,注意避雷;windSpeed ≥ 40 → 风力较大,注意出行(:61-66)。
8.9.4 ClassIsland 视觉映射与范围
- ClassIsland 天气组件的「6 种主信息模式」本轮只取默认一种:
地名 + 天气文字 + 温度(取整)+ 可选建议,以 ClassIsland 的次要/正文字号(14/16)呈现;不实现多模式切换。 - 不实现 ClassIsland 的气象预警、三天天气预报、逐小时预报(数据源无对应字段;
docs/app/island.md:45明确「不提供官方气象预警」)。 - 组件内刷新失败不得阻塞岛其他内容;天气与服务端无关,不参与消息队列。
8.10 课件传输覆盖(最高优先级内容)
8.10.1 优先级规则(必须实现,island.ts:649-676)
- 课件传输(
coursewareTransfer)覆盖整岛内容:fixed的两种 family 与custom都必须显示;getState()时若「当前有正在展示的紧急消息或叫人(mode!=='hidden' && active && (priority==='URGENT' || kind==='call'))」则课件让位,状态里返回coursewareTransfer:null。 - 其余消息不因课件覆盖而丢弃:
active/queued保持原状,课件结束后按队列规则继续展示。 - 课件传输期间不触发强提醒特效(课件不是紧急消息)。
- 课件覆盖时窗口几何按
COURSEWARE_ISLAND_SIZE(424×132,packages/shared/src/courseware.ts:4)+ISLAND_SHADOW_PAD定位;custom常驻也临时改为该尺寸,结束后恢复原方案尺寸。
8.10.2 UI 与像素(保留现有值)
| 项 | 值 |
|---|---|
| 卡片 | 424×132,padding 17 24,border-radius 24,背景 rgba(20,20,24,0.97),阴影 0 2px 8px #0004 |
| 字体 | 13px/1.4 'Segoe UI','Microsoft YaHei',sans-serif |
| 状态行 | 12px,#ffffffb8;百分比/「失败」用强调色、600 |
| 文件名 | 16px、600、单行省略、margin 5px 0 |
| 元信息 | 类型 · 教师 + 已收/总量 MB(保留 1 位小数) |
| 进度条 | 高 5px、圆角 8、accent-color,进度槽 #ffffff30 |
| 错误 | 11px、#ffb4ab、单行省略 |
| 证据 | CoursewareTransfer.vue:67-131 |
8.10.3 阶段与失败
- 阶段文案(
CoursewareTransfer.vue:26-34):downloading 正在接收课件、verifying 正在校验文件、completed 课件接收完成、failed 课件接收失败。 - 完成后由主进程/服务端链路校验 SHA256 并回执(
docs/app/courseware.md:28);岛只负责展示,不执行文件。 - 失败必须显示
error文案并允许用户点击卡片打开课件页(pointer-events在课件卡上为none,点击交给整岛open-app);失败卡片停留至用户处理或下一个传输覆盖,建议停留 30s 后自动恢复常态【推断】。 - 传输中窗口重建/渲染进程崩溃:主进程重新下发
coursewareTransfer状态,恢复展示。
8.11 教师端 / 班级端差异
- 岛只在班级端运行:教师/管理员使用 Web 管理端,不启动岛;客户端以
CLASS_DEVICE登录时才有「本班」消息。 - 班级端:接收本班通知/作业/叫人/课件;课程提醒基于服务端解析后的本班课表(
ScheduleDto/IslandTimePointDto)。 - 教师端(Web):无岛;发布 URGENT 时的二次确认是服务端规则(
docs/app/notifications.md:25-29),与岛无关。 - 未登录/离线:不展示在线消息;已缓存消息可只读展示(不得伪造未读状态);课程提醒与天气可离线工作(课表来自本地缓存,天气有缓存)。
- 权限:已读/撤回等操作失败(403/401)时,岛必须回滚本地状态并提示「无权限或登录已失效」,不得假装成功。
8.12 冲突裁决表(ClassHelper 业务优先,ClassIsland 视觉优先)
| 主题 | ClassHelper 既有语义(胜出) | ClassIsland 视觉(采用) | 落地规则 |
|---|---|---|---|
| 消息排序 | PRIORITY_RANK → 时间 |
提供方优先级/通知等级 | 按 8.2 第 3 条:业务优先级优先,等级仅作稳定次级键 |
| 上课暂存 | 上课期间普通通知暂存,下课补弹 | ClassIsland 上课照常播提醒 | 暂存规则保留;信息行保持可见但不展开气泡 |
| 紧急消息 | 立即展开、45s 收起 | ClassIsland 气泡两阶段 | 立即展开气泡 + 撒特效;45s 超时保留 |
| 普通叫人 | 上课也保留可点入口,不自动展开 | ClassIsland 无「叫人」概念 | 上课显示可点 chip;点击展开气泡 |
| 去重 | 按 id + 双播合并 | 同一 request 只入队一次 | 两者都实现(8.2) |
| 已读 | 服务端已读 + 本地回执 | ClassIsland 无 | 保留现有,失败回滚 |
| 收起动画 | 收回动画 360ms/窗口淡出 | 遮罩退出 200ms | 遮罩退出用 ClassIsland 参数;岛形变用弹簧;窗口淡出保留 |
| 多消息列表 | 前 3 条 + 展开更多 | ClassIsland 无列表形态 | 用 ClassIsland 行高/字号重排列表(共享 islandListLayout) |
| 课程提醒 kind | prepare/on-class/break/ending/in-class | ClassIsland 只有上下课/课前/放学 | 保留五类;ending 用徽标,不做「即将下课」遮罩 |
| 课程提醒条高度 | 40 | 行高 40 | 一致,沿用 40 |
| 进度条 | 2px(提醒条) | 3px(行/组件) | 两处各自保留,见 8.8 |
| 天气 | Open-Meteo 15 分钟 + 缓存标记 | 小米天气 + 预警 | 保留 Open-Meteo;不实现预警/预报 |
| 课件覆盖 | 覆盖岛内容 | 无对应 | 保留 ClassHelper 规则;紧急/叫人时不覆盖 |
| 音效/TTS | 无 | 有 | 不实现,不进入验收 |
| 自动化/插件/管理端 | 无 | 有 | 不实现 |
8.13 边界与失败降级
- 无课表/课表为空:
lessonReminder=null;信息行显示日期 + 课程表占位(今天没有课程。);课程提醒全部不触发;不得因此隐藏整行。 - 上课状态未知(
inClass未下发/服务端断线):按「非上课」处理(消息正常展示),不得猜测;恢复后以下发值为准。 - 时间跳变/时区变化:课程提醒 id 含
dayKey,跨天自动重建;时间回退不得重复触发同一 id 的提醒(entered逻辑),时间前跳不得补发多条。 - 消息风暴(一次 > 20 条):列表最多
maxRows=20,其余提示「更多请前往应用内操作」;不得逐条弹气泡;同一 id 重复到达按合并规则处理。 - 重复广播 / 断线重连重放:以 id 去重;重连后以服务端列表为准做一次全量对账(新增/删除/已读状态),不得凭队列恢复弹窗。
- 已读请求失败:
markRead/markAllRead失败时恢复本地未读状态并提示;不得把消息从队列删除。 - 课件传输中断/断网:显示
failed + error;恢复网络后由心跳补拉(docs/app/courseware.md:26-28);岛不得自行重试下载。 - 渲染进程崩溃/窗口重建:主进程保留
state,重建后一次性下发;课件/课程提醒/消息不丢;重建限流沿用现有(3 次/10 分钟)。 - 系统睡眠/唤醒:唤醒后立即重算课程提醒与上课状态;跨过的时间点不补弹(与 8.13 第 3 条一致);课件若在睡眠中完成,按最新状态展示。
- 多屏切换/DPI 变化:重算命中矩形、气泡与特效窗口;切换瞬间不得显示残影(先隐藏特效窗口再重算)。
- 无网络启动:消息/课表用离线缓存(按服务器与班级隔离),天气显示缓存或「暂不可用」;课程提醒只要课表缓存存在就必须工作。
- 编辑模式/设置预览:编辑模式不弹新气泡、不播特效、课程提醒条不接管编辑画布;预览模式停留 30s 自动结束(
PREVIEW_DURATION_MS,island.ts:2299-2300),不写已读。
8.14 验收标准(可勾选,全部通过才算完成)
消息与队列
- 同一 id 的通知与叫人双播到达:只出现一条,
kind='call'、priority取高者;不会降级成普通通知。 - 课间收到普通通知:
fixed+classisland行内自动展开气泡(先遮罩后正文);winisland为胶囊;custom铃铛计数 +1。 - 上课收到普通通知:不弹气泡,行内显示暂存角标;下课自动补弹最新一条,其余保留「还有 N 条」。
- 上课收到普通叫人:显示可点 chip,不自动展开;点击后展开气泡并可「收到」。
- 紧急通知/紧急叫人:无论上课与否立即展开气泡,并在 45s/90s 后按规则收起(有未处理时保持 chip/角标)。
- 「知道了」与「标为已读」语义区分正确;整批已读会过滤 homework 本地 id,不报「通知不存在」。
- 实时撤回后,岛内该消息从 active 与 queued 同时消失。
- 消息列表默认 3 行、超出显示「展开更多(还有 N 条)」;展开后受屏幕高度限制改提示「更多请前往应用内操作」。
气泡视觉
- 气泡遮罩 5 区段斜切扫入,区段 360ms、错峰 120ms;文字延迟 260ms 淡入 250ms + 缩放 750ms;退出 200ms 且缩放回 1.1;
speed改变后所有时长同比缩放。 - 行高 40、圆角 8、背景不透明度 0.5、底部进度条 3px、值过渡 50ms 线性;文字始终不透明。
animations=false或系统减少动态时,气泡直接终态,最终几何与有动画时一致。
强提醒特效
- 紧急消息触发全屏涟漪:600ms 扩散 + 600ms 淡出,750ms 后移除;多屏各一窗口;
effectScale生效。 - 编辑模式、隐藏状态、窗口重建中不播特效,但气泡照常;特效窗口不接收鼠标、不改变岛命中。
课程提醒
- 五类提醒按 8.6.2 算法在构造的时间轴下逐一出现,且同一提醒不重复;关闭对应开关后不出现。
custom下in-class与ending不接管岛;fixed下ending显示徽标而非遮罩;任何形态都没有「即将下课」遮罩。- 蓝色秒数徽标:正圆 30×30、
#0a84ff、12px/600、显示-N;now = end - 1ms时显示-1;endingEnabled=false不显示。 - 课程进度:提醒条 2px、组件内 3px,两条同时存在且均贴底、均用
scaleX。
天气与课件
- 天气 15 分钟缓存、失败 60s 退避并保留旧数据、断网显示「· 缓存」、坐标非法显示「请设置天气地点」;同一坐标并发请求只发一次网络请求。
- 课件传输覆盖整岛(424×132),显示文件名/类型/教师/已收-总量/进度;紧急或叫人在播时课件让位且不丢消息;完成后 SHA256 校验通过才显示「完成」。
边界
- 跨天、时间跳变、睡眠唤醒、渲染进程崩溃重建后,消息不丢、不重复弹、课程提醒状态正确。
- 无课表/无网络/未登录时,岛显示合理占位且不崩溃;已读失败会回滚并提示。
- 全程未出现音效/TTS、自动化、插件、主题市场等非目标功能;也未出现第二个岛窗口(课件与特效窗口除外,且均为独立用途)。