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

474 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 必须新增/扩展的字段(只加不改,保证迁移可回滚)
1. `IslandState` 新增:
- `family: 'classisland' | 'winisland'`:由外观下发给渲染进程,决定渲染形态;
- `pendingStaged: number`:上课期间被暂存的消息数(用于行内角标/铃铛计数);
- `bubblePhase: 'hidden' | 'mask' | 'overlay' | 'closing'`:气泡阶段(仅 `fixed` 使用,见 8.4);
- `bubbleDeadline: number`:当前气泡阶段结束时间戳(毫秒),供渲染进程做进度条与自动收起(避免渲染进程自己计时导致主/渲染漂移)。
2. `IslandNotification` 新增可选 `level?: 'emergency'|'high'|'normal'|'low'`:由 `priority` 映射而来(URGENT→emergency、HIGH→high、NORMAL→normal、LOW→low),用于 ClassIsland 三级排序键的中间键;**不改变服务端 DTO**,只在主进程派生。
3. `IslandLessonReminder` 新增 `progress: number`(0–1,课程进度)与 `leftSeconds: number`(整数秒,供徽标直接渲染),由主进程计算下发,渲染进程不得自行 `Date.now()` 推算(防止跨进程时钟漂移与系统时间跳变)。
4. `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 业务规则,必须保留)
1. **按 id 去重**:同一 id 只保留一条,`pendingNotifications()` 以 Map 去重(`packages/desktop-client/src/main/island.ts:738-749`;渲染侧同口径 `src/island/IslandApp.vue:86-97`)。
2. **通知/叫人双播合并**:`createCall` 同时发 `notification:new` 与 `call:new`,同 id 两条到达时必须**合并而非覆盖**:`kind` 取更特殊的(call 优先),`priority` 取更高(`PRIORITY_RANK` 比较)(`island.ts:713-724`)。这是硬性要求,否则会出现「叫人胶囊显示成新消息、上课被当普通通知隐藏」。
3. **排序口径**:`PRIORITY_RANK` 降序 → `createdAt` 降序(新的在前)(`island.ts:744-748`)。**不得**改用 ClassIsland 的三级键打乱业务优先级——ClassIsland 的「提供方优先级」在 ClassHelper 中没有对应概念,只有服务端单通道消息,因此**本重构的排序键固定为(业务优先级 → 时间)**;若要引入 ClassIsland 的「通知等级」作为第二键,仅在与时间同序时作为稳定排序的次级键,不得跨优先级反转。
4. **未读与已读**:未读数以服务端为准(`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`)。
5. **实时撤回**:收到 `notification:deleted` 后必须从岛队列与当前展示中移除(`island.ts:1210-1216`),缓存与通知列表同步清理(`docs/app/notifications.md:34`)。
6. **本机作业指纹**:本机刚录的作业通过指纹避免重复提醒自己——这是既有规则,必须保留(`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` 不得整体废弃,必须**拆成「气泡内容」组件**并同时服务两种形态:
1. **两阶段复用**:`fixed` + `classisland` 下,`mask` 阶段只渲染它的 `header` 部分(类型图标 + 类型徽标 + 标题 + 紧急/叫人提示),`overlay` 阶段再渲染 `title / body / footer`(标题、正文、元信息、按钮行);列表形态(多条)只在 `overlay` 阶段出现。原组件的 `size/listLayout/pendingNotifications` 计算逻辑保留,改为由父组件传入。
2. **`winisland` 兼容**:整卡(header + title + body + footer 一次性渲染)继续用于详情卡,**交互与尺寸不变**;只把文案/徽标/按钮语义与新气泡对齐(`叫人` 时按钮「收到」等)。
3. **不得复制两份实现**:图标、徽标、行布局、按钮行必须共用同一组件与同一份 `classic-island.css`,避免两种形态文案不一致。
### 8.5 强提醒全屏特效(Topmost)
#### 8.5.1 行为
1. **触发条件**(全部满足):消息 `priority==='URGENT'`(紧急通知或紧急叫人);`appearance.animations===true` 且 `appearance.effectEnabled`(新增外观开关,默认 `true`);岛当前可见(不是编辑模式、不是预览、不是隐藏);本节特效与气泡可同时出现。
2. **表现**:以岛中心为原点,强调色涟漪扩散到覆盖主界面所在屏幕;扩散 **600ms**、同步淡出 **600ms**,**750ms** 后移除元素并隐藏特效窗口。
3. **独立窗口**:新建 `topmost-effect` 渲染入口与独立 BrowserWindow(`transparent:true`、`frame:false`、`skipTaskbar:true`、`alwaysOnTop:true`、`showInactive`、`setIgnoreMouseEvents(true)`、逐屏一个窗口);**禁止** `setBackgroundMaterial()`;窗口内元素清空即隐藏,不销毁。
4. **缩放与多屏**:`effectScale`(默认 1.0,复用现有 `ISLAND_SHADOW_PAD` 之外的独立字段)缩放窗口与圆心;屏幕/DPI 变化重算;4K@150% 下涟漪必须覆盖整屏无接缝。
5. **降级**:`animations=false`、系统减少动态、编辑模式、窗口重建中、系统休眠恢复后的 1s 内 → 不播特效(只播气泡),不得阻塞消息展示。
6. **非目标**:本轮**不做**音效与 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`。
1. 当天课表 = `dayOfWeek === (now.getDay() || 7)` 的条目,按开始时间升序(周日=7)。
2. `current` = 满足 `start ≤ now < end` 的条目;`next` = 第一个 `start > now`;`ended` = `end ≤ now` 且距 `end` < **12 000ms** 的最近一条。
3. **kind 判定顺序(自上而下,命中即止)**:
1. `current && now - current.start < 5 000 && onClassEnabled` → `on-class`;
2. `breakEnabled && !current && ended && now - ended.end < (next ? 12 000 : 5 000) && (now - ended.end < 5 000 || this.current?.id === keyOf(ended,'break'))` → `break`;
3. `current && endingEnabled && current.end - now ≤ endingSeconds*1000` → `ending`;
4. `!current && next && prepareEnabled && next.start - now ≤ prepareSeconds*1000` → `prepare`;
5. `current && classProgressEnabled` → `in-class`。
4. `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` |
5. `id = "${dayKey}:${scheduleId}:${start}:${end}:${kind}"`;`entered` 为同一 id 的连续保持时刻(避免同一提醒重复计时)。
6. 课程名 `course?.name ?? '课程'`;时间字符串截到 `HH:mm`(导入数据可能带秒,`lesson-reminder.ts:78-79`)。
7. **渲染进程不得自行推导 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`) |
**必须遵守的三条硬规则**:
1. **没有「即将下课」遮罩**:`ending` 绝不显示 ClassIsland 式全行遮罩,只显示课程名右侧的蓝色秒数徽标(`docs/app/island.md:68`)。
2. **不替换自定义岛常态组件**:`custom` 模式下课程提醒(除 prepare/on-class/break 的临时接管)不得替换常态组件行。
3. **关闭开关即不显示**:`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 行为
1. **显示条件**(全部满足):`kind==='ending'`(即 `current && endingEnabled && end - now ≤ endingSeconds*1000`);`leftSeconds ≥ 0`;课表数据存在 `endTime`。
2. **数值**:`leftSeconds = ceil((lessonEndsAt - now)/1000)`;显示为 `-${leftSeconds}`。
3. **剩余一秒显示 `-1`**:当真实剩余时间在 `(0, 1000]ms` 时 `ceil` 结果为 1,徽标显示 `-1`;**不得**显示 `-0`,也不得在剩余 0 时继续显示(到点后 `ending` 条件不再成立,徽标消失)。这是用户明确要求的行为,必须有单测用例(`now = end - 1ms` → `-1`)。
4. **阈值与开关**:`endingSeconds` 默认 60(5–600);`endingEnabled=false` 时完全不出现徽标;`animations=false` 也不影响徽标(徽标不是动画)。
5. **出现/消失动画**:徽标可淡入 100ms(`opacity 0→1`)并随课程提醒条一起退出;**不得**用缩放弹跳(避免课堂投影上的视觉干扰)。
6. **两种岛都要用**:`fixed`(两种 family)与 `custom` 都必须显示该徽标;`custom` 下徽标挂在 `schedule` 组件的当前课程名右侧,由组件内部渲染同一 `lesson-ending.css`,不得复制第二套样式。
7. **边界**:
- 无时间点数据、按 `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 服务行为(必须原样保留)
1. 固定服务与参数,**不接受渲染层传 URL**:`https://api.open-meteo.com/v1/forecast` + `current=temperature_2m,weather_code,wind_speed_10m` + `forecast_days=1`(`island-weather.ts:35-41`)。
2. 坐标校验:`latitude/longitude` 必须是有限数字,纬度绝对值 ≤90、经度 ≤180,否则抛「请设置有效的经纬度」(`:14-22`)。
3. 缓存键 = 坐标定点 4 位(`:23`);成功缓存 **15 分钟**(`:60`);失败缓存 **60 秒**退避并保留旧数据(`:63`);同一坐标并发请求复用同一 Promise(`:29-30`);缓存上限 **32** 条(`:68`)。
4. 请求安全:`AbortSignal.timeout(8000)`、`redirect:'error'`、响应体 > **64 000** 字节拒收(`:42-45`);自动验收模式下不访问外网(`:33-34`)。
5. 数据校验:三个数值字段必须都是有限数字,否则「天气数据格式不正确」(`:47-52`)。
6. `stale`:成功返回时按 `fetchedAt` 距今 ≥ **15 分钟**标记;失败返回旧数据时强制 `stale:true`(`:26,64`)。
7. 渲染侧:每 **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`)
1. 课件传输(`coursewareTransfer`)**覆盖整岛内容**:`fixed` 的两种 family 与 `custom` 都必须显示;`getState()` 时若「当前有正在展示的紧急消息或叫人(`mode!=='hidden' && active && (priority==='URGENT' || kind==='call')`)」则**课件让位**,状态里返回 `coursewareTransfer:null`。
2. 其余消息不因课件覆盖而丢弃:`active/queued` 保持原状,课件结束后按队列规则继续展示。
3. 课件传输期间不触发强提醒特效(课件不是紧急消息)。
4. 课件覆盖时窗口几何按 `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 教师端 / 班级端差异
1. **岛只在班级端运行**:教师/管理员使用 Web 管理端,不启动岛;客户端以 `CLASS_DEVICE` 登录时才有「本班」消息。
2. 班级端:接收本班通知/作业/叫人/课件;课程提醒基于服务端解析后的本班课表(`ScheduleDto`/`IslandTimePointDto`)。
3. 教师端(Web):无岛;发布 URGENT 时的二次确认是服务端规则(`docs/app/notifications.md:25-29`),与岛无关。
4. **未登录/离线**:不展示在线消息;已缓存消息可只读展示(不得伪造未读状态);课程提醒与天气可离线工作(课表来自本地缓存,天气有缓存)。
5. **权限**:已读/撤回等操作失败(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 边界与失败降级
1. **无课表/课表为空**:`lessonReminder=null`;信息行显示日期 + 课程表占位(`今天没有课程。`);课程提醒全部不触发;不得因此隐藏整行。
2. **上课状态未知**(`inClass` 未下发/服务端断线):按「非上课」处理(消息正常展示),不得猜测;恢复后以下发值为准。
3. **时间跳变/时区变化**:课程提醒 id 含 `dayKey`,跨天自动重建;时间回退不得重复触发同一 id 的提醒(`entered` 逻辑),时间前跳不得补发多条。
4. **消息风暴**(一次 > 20 条):列表最多 `maxRows=20`,其余提示「更多请前往应用内操作」;不得逐条弹气泡;同一 id 重复到达按合并规则处理。
5. **重复广播 / 断线重连重放**:以 id 去重;重连后以服务端列表为准做一次全量对账(新增/删除/已读状态),不得凭队列恢复弹窗。
6. **已读请求失败**:`markRead/markAllRead` 失败时恢复本地未读状态并提示;不得把消息从队列删除。
7. **课件传输中断/断网**:显示 `failed + error`;恢复网络后由心跳补拉(`docs/app/courseware.md:26-28`);岛不得自行重试下载。
8. **渲染进程崩溃/窗口重建**:主进程保留 `state`,重建后一次性下发;课件/课程提醒/消息不丢;重建限流沿用现有(3 次/10 分钟)。
9. **系统睡眠/唤醒**:唤醒后立即重算课程提醒与上课状态;跨过的时间点不补弹(与 8.13 第 3 条一致);课件若在睡眠中完成,按最新状态展示。
10. **多屏切换/DPI 变化**:重算命中矩形、气泡与特效窗口;切换瞬间不得显示残影(先隐藏特效窗口再重算)。
11. **无网络启动**:消息/课表用离线缓存(按服务器与班级隔离),天气显示缓存或「暂不可用」;课程提醒只要课表缓存存在就必须工作。
12. **编辑模式/设置预览**:编辑模式不弹新气泡、不播特效、课程提醒条不接管编辑画布;预览模式停留 30s 自动结束(`PREVIEW_DURATION_MS`,`island.ts:2299-2300`),不写已读。
### 8.14 验收标准(可勾选,全部通过才算完成)
**消息与队列**
1. 同一 id 的通知与叫人双播到达:只出现一条,`kind='call'`、`priority` 取高者;不会降级成普通通知。
2. 课间收到普通通知:`fixed`+`classisland` 行内自动展开气泡(先遮罩后正文);`winisland` 为胶囊;`custom` 铃铛计数 +1。
3. 上课收到普通通知:不弹气泡,行内显示暂存角标;下课自动补弹最新一条,其余保留「还有 N 条」。
4. 上课收到普通叫人:显示可点 chip,不自动展开;点击后展开气泡并可「收到」。
5. 紧急通知/紧急叫人:无论上课与否立即展开气泡,并在 45s/90s 后按规则收起(有未处理时保持 chip/角标)。
6. 「知道了」与「标为已读」语义区分正确;整批已读会过滤 homework 本地 id,不报「通知不存在」。
7. 实时撤回后,岛内该消息从 active 与 queued 同时消失。
8. 消息列表默认 3 行、超出显示「展开更多(还有 N 条)」;展开后受屏幕高度限制改提示「更多请前往应用内操作」。
**气泡视觉**
9. 气泡遮罩 5 区段斜切扫入,区段 360ms、错峰 120ms;文字延迟 260ms 淡入 250ms + 缩放 750ms;退出 200ms 且缩放回 1.1;`speed` 改变后所有时长同比缩放。
10. 行高 40、圆角 8、背景不透明度 0.5、底部进度条 3px、值过渡 50ms 线性;文字始终不透明。
11. `animations=false` 或系统减少动态时,气泡直接终态,最终几何与有动画时一致。
**强提醒特效**
12. 紧急消息触发全屏涟漪:600ms 扩散 + 600ms 淡出,750ms 后移除;多屏各一窗口;`effectScale` 生效。
13. 编辑模式、隐藏状态、窗口重建中不播特效,但气泡照常;特效窗口不接收鼠标、不改变岛命中。
**课程提醒**
14. 五类提醒按 8.6.2 算法在构造的时间轴下逐一出现,且同一提醒不重复;关闭对应开关后不出现。
15. `custom` 下 `in-class` 与 `ending` 不接管岛;`fixed` 下 `ending` 显示徽标而非遮罩;任何形态都**没有「即将下课」遮罩**。
16. 蓝色秒数徽标:正圆 30×30、`#0a84ff`、12px/600、显示 `-N`;`now = end - 1ms` 时显示 `-1`;`endingEnabled=false` 不显示。
17. 课程进度:提醒条 2px、组件内 3px,两条同时存在且均贴底、均用 `scaleX`。
**天气与课件**
18. 天气 15 分钟缓存、失败 60s 退避并保留旧数据、断网显示「· 缓存」、坐标非法显示「请设置天气地点」;同一坐标并发请求只发一次网络请求。
19. 课件传输覆盖整岛(424×132),显示文件名/类型/教师/已收-总量/进度;紧急或叫人在播时课件让位且不丢消息;完成后 SHA256 校验通过才显示「完成」。
**边界**
20. 跨天、时间跳变、睡眠唤醒、渲染进程崩溃重建后,消息不丢、不重复弹、课程提醒状态正确。
21. 无课表/无网络/未登录时,岛显示合理占位且不崩溃;已读失败会回滚并提示。
22. 全程未出现音效/TTS、自动化、插件、主题市场等非目标功能;也未出现第二个岛窗口(课件与特效窗口除外,且均为独立用途)。