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

41 KiB
Raw Blame History

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
  1. id = "${dayKey}:${scheduleId}:${start}:${end}:${kind}";entered 为同一 id 的连续保持时刻(避免同一提醒重复计时)。
  2. 课程名 course?.name ?? '课程';时间字符串截到 HH:mm(导入数据可能带秒,lesson-reminder.ts:78-79)。
  3. 渲染进程不得自行推导 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 条)」;展开后受屏幕高度限制改提示「更多请前往应用内操作」。

气泡视觉

  1. 气泡遮罩 5 区段斜切扫入,区段 360ms、错峰 120ms;文字延迟 260ms 淡入 250ms + 缩放 750ms;退出 200ms 且缩放回 1.1;speed 改变后所有时长同比缩放。
  2. 行高 40、圆角 8、背景不透明度 0.5、底部进度条 3px、值过渡 50ms 线性;文字始终不透明。
  3. animations=false 或系统减少动态时,气泡直接终态,最终几何与有动画时一致。

强提醒特效

  1. 紧急消息触发全屏涟漪:600ms 扩散 + 600ms 淡出,750ms 后移除;多屏各一窗口;effectScale 生效。
  2. 编辑模式、隐藏状态、窗口重建中不播特效,但气泡照常;特效窗口不接收鼠标、不改变岛命中。

课程提醒

  1. 五类提醒按 8.6.2 算法在构造的时间轴下逐一出现,且同一提醒不重复;关闭对应开关后不出现。
  2. custom 下 in-class 与 ending 不接管岛;fixed 下 ending 显示徽标而非遮罩;任何形态都没有「即将下课」遮罩。
  3. 蓝色秒数徽标:正圆 30×30、#0a84ff、12px/600、显示 -N;now = end - 1ms 时显示 -1;endingEnabled=false 不显示。
  4. 课程进度:提醒条 2px、组件内 3px,两条同时存在且均贴底、均用 scaleX。

天气与课件

  1. 天气 15 分钟缓存、失败 60s 退避并保留旧数据、断网显示「· 缓存」、坐标非法显示「请设置天气地点」;同一坐标并发请求只发一次网络请求。
  2. 课件传输覆盖整岛(424×132),显示文件名/类型/教师/已收-总量/进度;紧急或叫人在播时课件让位且不丢消息;完成后 SHA256 校验通过才显示「完成」。

边界

  1. 跨天、时间跳变、睡眠唤醒、渲染进程崩溃重建后,消息不丢、不重复弹、课程提醒状态正确。
  2. 无课表/无网络/未登录时,岛显示合理占位且不崩溃;已读失败会回滚并提示。
  3. 全程未出现音效/TTS、自动化、插件、主题市场等非目标功能;也未出现第二个岛窗口(课件与特效窗口除外,且均为独立用途)。