feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能

## 灵动岛修复与动画优化(用户反馈)

- **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**,
  窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块,
  胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。
  冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。
- **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊;
  收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。
- 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、
  尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。

## 灵动岛"标为已读"真正同步通知中心

- 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 →
  `notificationApi.markRead` + 刷新 store(未读红点即时减少)。
- 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。

## 作业发布也上岛

- `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标),
  展开显示作业说明与"截止时间:…"。

## 新增"叫人"功能

- 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班,
  落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间,
  并定向 `call:new` 给被叫学生。
- 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、
  `ServerToClientEvents['call:new']`、`CreateCallRequest`。
- Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。
- 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生),
  琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。

## 客户端界面改为 ClassIsland 同款 Fluent 风格

- 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、
  强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。
- Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。
- 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条;
  品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。

## 验证

- `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理)
- `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路)
- `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中)
- `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
This commit is contained in:
LoveChengke committed 2026-09-12 13:57:46 +08:00
1 parent f759aac0a2
commit 219f6ae83a
28 files changed
+1555 -156

No files matched your search

@@ -97,6 +97,11 @@ if (!gotLock) {
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
+85 -20
View File
@@ -24,6 +24,9 @@ const SIZES: Record<IslandMode, { width: number; height: number }> = {
const URGENT_SIZE = { width: 424, height: 344 };
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
const CALL_SIZE = { width: 440, height: 360 };
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = {
pill: 15_000,
@@ -31,6 +34,8 @@ const TIMEOUTS = {
afterClassPill: 25_000,
expanded: 20_000,
urgent: 45_000,
/** 叫人消息常驻更久,避免学生走开一会儿回来就看不到 */
call: 90_000,
};
export interface IslandPushContext {
@@ -76,10 +81,17 @@ class IslandController {
/** 灵动岛展开时点击"打开应用"交给外部处理 */
private openAppHandler: (() => void) | null = null;
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
private markReadHandler: ((id: string) => void) | null = null;
setOpenAppHandler(handler: () => void): void {
this.openAppHandler = handler;
}
setMarkReadHandler(handler: (id: string) => void): void {
this.markReadHandler = handler;
}
async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl;
if (this.win && !this.win.isDestroyed()) return;
@@ -117,6 +129,17 @@ class IslandController {
this.ready = false;
});
/**
* 点击屏幕任意位置收起:展开态临时允许窗口获得焦点,
* 用户点到别处(桌面、浏览器、其他应用)时窗口失焦 → 回缩为胶囊。
*/
win.on('blur', () => {
if (this.state.mode === 'expanded' && !this.state.inClass) {
logger.info('灵动岛:点击屏幕其他位置,回缩为胶囊');
this.handleAction({ action: 'collapse' });
}
});
this.win = win;
// 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题
@@ -161,10 +184,18 @@ class IslandController {
/* ------------------------------------------------------------ 对外行为 */
/**
* 收到一条通知。
* - 紧急:立刻展开(无视上课时段)
* 是否需要"立即展开、无视上课时段"。
* 紧急通知与"叫人"(老师点名)都属于:学生必须马上看到。
*/
private isImmediate(notification: IslandNotification): boolean {
return notification.priority === 'URGENT' || notification.kind === 'call';
}
/**
* 收到一条消息。
* - 紧急 / 叫人:立刻展开(无视上课时段)
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
* - 其它:显示"新消息"胶囊,等待点击展开
* - 其它:显示"新消息/新作业"胶囊,等待点击展开
*/
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
const inClass = context.inClass ?? this.state.inClass;
@@ -172,14 +203,19 @@ class IslandController {
if (context.currentPeriodEnd !== undefined)
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
if (notification.priority === 'URGENT') {
// 紧急插播:如果正在展示别的通知,把它放回队列
if (this.isImmediate(notification)) {
// 插播:如果正在展示别的消息,把它放回队列
if (this.state.active && this.state.active.id !== notification.id) {
this.state.queued.push(this.state.active);
}
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
this.activate(notification, 'urgent', 'expanded');
logger.info(`灵动岛紧急插播:${notification.title}`);
const reason = notification.kind === 'call' ? 'call' : 'urgent';
this.activate(notification, reason, 'expanded');
logger.info(
notification.kind === 'call'
? `灵动岛叫人消息:${notification.title}`
: `灵动岛紧急插播:${notification.title}`,
);
return;
}
@@ -245,18 +281,24 @@ class IslandController {
handleAction(payload: IslandActionPayload): void {
switch (payload.action) {
case 'expand':
// 上课时段一律不显示灵动岛(只有紧急通知能自动展开)
case 'expand': {
// 上课时段一律不显示灵动岛(只有紧急通知 / 叫人才会自动展开)
if (this.state.inClass) break;
if (this.state.active) {
const active = this.state.active;
if (active) {
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
this.state.active.priority === 'URGENT' ? TIMEOUTS.urgent : TIMEOUTS.expanded,
this.isImmediate(active)
? active.kind === 'call'
? TIMEOUTS.call
: TIMEOUTS.urgent
: TIMEOUTS.expanded,
);
}
break;
}
case 'collapse':
// 点击卡片空白处 / 右上角收起按钮:回缩到灵动岛(胶囊),不直接消失
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
if (this.state.inClass) {
this.hideImmediately();
break;
@@ -272,12 +314,18 @@ class IslandController {
this.dismissActive();
break;
case 'mark-read': {
const queue = this.state.queued.filter((item) => item.id !== payload.id);
const id = payload.id;
const queue = this.state.queued.filter((item) => item.id !== id);
this.state.queued = queue;
if (this.state.active?.id === payload.id) this.state.active = null;
if (this.state.active?.id === id) this.state.active = null;
this.state.updatedAt = Date.now();
if (this.state.mode === 'hidden') this.emit();
else this.dismissActive();
// 通知中心同步:转交主窗口渲染进程调用"标记已读"接口并刷新列表
if (id) {
logger.info(`灵动岛标记已读:${id}`);
this.markReadHandler?.(id);
}
break;
}
case 'open-app':
@@ -308,6 +356,7 @@ class IslandController {
this.emit();
}
if (!this.win || this.win.isDestroyed()) return;
this.win.setFocusable(false);
if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1);
}
@@ -419,13 +468,17 @@ class IslandController {
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知"允许出现在屏幕上)
const urgentShowing = this.state.mode === 'expanded' && this.state.active?.priority === 'URGENT';
if (this.state.inClass && !urgentShowing) {
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知/叫人"允许出现在屏幕上)
const immediateShowing =
this.state.mode === 'expanded' && this.state.active !== null && this.isImmediate(this.state.active);
if (this.state.inClass && !immediateShowing) {
this.hideImmediately();
return;
}
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
this.applyFocusable();
if (this.state.mode === 'hidden') {
this.fadeOut();
return;
@@ -433,8 +486,12 @@ class IslandController {
const size =
this.state.mode === 'expanded'
? this.state.active?.priority === 'URGENT'
? URGENT_SIZE
? this.state.active
? this.isImmediate(this.state.active)
? this.state.active.kind === 'call'
? CALL_SIZE
: URGENT_SIZE
: SIZES.expanded
: SIZES.expanded
: SIZES.pill;
@@ -465,6 +522,14 @@ class IslandController {
this.fadeToken += 1;
}
/** 展开态允许聚焦(用于"点击屏幕任意处收起"),其余时间不抢焦点 */
private applyFocusable(): void {
if (!this.win || this.win.isDestroyed()) return;
const shouldFocus = this.state.mode === 'expanded';
if (this.win.isFocusable() !== shouldFocus) this.win.setFocusable(shouldFocus);
if (shouldFocus) this.win.focus();
}
private fadeIn(): void {
if (!this.win || this.win.isDestroyed()) return;
const token = ++this.fadeToken;
@@ -561,4 +626,4 @@ export function registerIslandIpc(): void {
});
}
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE };
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE };
+227 -7
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs';
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandNotification } from '@classhelper/shared';
import { getDiagnostics } from './ipc.js';
import { island, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
interface SmokeResult {
name: string;
@@ -87,9 +87,10 @@ async function captureIsland(
name: string,
expected: { width: number; height: number },
): Promise<IslandShotStats | null> {
const dir = process.env.ISLAND_SHOTS_DIR;
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
const win = island.getWindow();
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null;
if (!win || win.isDestroyed() || !win.isVisible()) return null;
try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript(
@@ -100,9 +101,11 @@ async function captureIsland(
await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${name}.png`);
fs.writeFileSync(filePath, image.toPNG());
const filePath = dir ? path.join(dir, `${name}.png`) : '';
if (filePath) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, image.toPNG());
}
const size = image.getSize();
const stats: IslandShotStats = {
@@ -348,6 +351,131 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
);
// 4.4) 收回过程中卡片不能出现"被拉扁的方框":
// 采样卡片尺寸,必须始终是固定的胶囊/详情尺寸之一,绝不出现中间值。
island.handleAction({ action: 'expand' });
await sleep(700);
const shrinkSamples: string[] = [];
const shrinkSampler = setInterval(() => {
void islandWindow?.webContents
.executeJavaScript(
`(() => {
const node = document.querySelector('.island-card');
if (!node) return '';
const rect = node.getBoundingClientRect();
return Math.round(rect.width) + 'x' + Math.round(rect.height);
})()`,
)
.then((value: string) => {
if (value) shrinkSamples.push(value);
})
.catch(() => undefined);
}, 40);
island.handleAction({ action: 'collapse' });
await sleep(700);
clearInterval(shrinkSampler);
await sleep(120);
const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']);
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
record(
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
shrinkSamples.length >= 3 && stretched.length === 0,
`采样=${shrinkSamples.length} 尺寸集合=[${[...new Set(shrinkSamples)].join(',')}] 异常尺寸=[${stretched.join(',')}]`,
);
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
island.handleAction({ action: 'expand' });
await sleep(700);
const focusable = islandWindow?.isFocusable() ?? false;
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
islandWindow?.emit('blur');
await sleep(700);
const afterBlur = island.getState();
record(
'点击屏幕任意位置回缩为胶囊(展开时可聚焦 + 失焦收起)',
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
);
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
island.handleAction({ action: 'dismiss' });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
kind: 'homework',
subtitle: '截止时间:2026-09-15 22:00',
teacherName: '张老师',
},
{ inClass: false },
);
await sleep(700);
const homeworkState = island.getState();
const homeworkDom = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.pill.homework');
return { cls: card?.className ?? '', title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '' };
})()`,
);
record(
'新作业发布也上岛(胶囊带"新作业"样式)',
homeworkState.mode === 'pill' &&
homeworkState.active?.kind === 'homework' &&
(homeworkDom?.title ?? '').includes('新作业'),
`mode=${homeworkState.mode} kind=${homeworkState.active?.kind} 胶囊标题="${homeworkDom?.title ?? '-'}" 类名=${homeworkDom?.cls ?? '-'}`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
);
record(
'作业详情显示截止时间',
(homeworkHint ?? '').includes('截止时间'),
`附加说明="${homeworkHint ?? '-'}"`,
);
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
// 4.7) "叫人":上课时段也立即展开,展示"请 XXX 同学找 XXX 老师"
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-call', 'HIGH', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
teacherName: '张老师',
},
{ inClass: true },
);
await sleep(900);
const callState = island.getState();
const callDom = await islandWindow?.webContents.executeJavaScript(
`(() => ({
badge: document.querySelector('.island-card.expanded .badge')?.textContent?.trim() ?? '',
title: document.querySelector('.island-card.expanded .title')?.textContent?.trim() ?? '',
solid: document.querySelector('.island-card.expanded .solid-btn')?.textContent?.trim() ?? '',
hint: document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? '',
cls: document.querySelector('.island-card.expanded')?.className ?? '',
}))()`,
);
record(
'叫人消息上岛(上课时段也立即展开,无需点击)',
callState.mode === 'expanded' &&
callState.active?.kind === 'call' &&
callState.reason === 'call' &&
callState.inClass === true &&
(callDom?.title ?? '').includes('请') &&
(callDom?.title ?? '').includes('找'),
`mode=${callState.mode} kind=${callState.active?.kind} reason=${callState.reason} 上课中=${callState.inClass} ` +
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
);
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(400);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => {
@@ -403,6 +531,93 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
}
/**
* 真实通知链路自检(含"标为已读"同步)。
*
* 与 runIslandChecks 的区别:那里直接调 IPC 验证状态机;这里走完
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/
async function runIslandRealtimeCheck(
win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
const scenario = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeScenario) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.islandRealtimeScenario();
})()`,
);
if (!scenario?.ok) {
return {
delivered: false,
deliveryDetail: `投递失败:${scenario?.detail ?? '未知原因'}`,
markRead: false,
markReadDetail: '未投递成功,跳过',
};
}
// 等通知经实时通道到达并弹出胶囊
const deadline = Date.now() + 10_000;
let state = island.getState();
while (Date.now() < deadline) {
state = island.getState();
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
await sleep(120);
}
const visible = island.getWindow()?.isVisible() ?? false;
const delivered = state.mode === 'pill' && state.active?.id === scenario.notificationId && visible;
const deliveryDetail =
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
`灵动岛 mode=${state.mode} 可见=${visible}`;
// 扩展灵动岛 → 点"标为已读" → 通知中心应同步为已读
let markRead = false;
let markReadDetail = '未执行';
if (delivered) {
const before = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const clicked = await island.getWindow()?.webContents.executeJavaScript(
`(() => {
const button = Array.from(document.querySelectorAll('.island-card.expanded .ghost-btn')).find((node) =>
(node.textContent ?? '').includes('标为已读'),
);
if (!button) return false;
button.click();
return true;
})()`,
);
await sleep(900);
const after = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
markRead = clicked === true && after?.found === true && after?.read === true;
markReadDetail =
`点击标为已读=${clicked} 点击前 read=${before?.read}(未读 ${before?.unreadCount})` +
` 点击后 read=${after?.read}(未读 ${after?.unreadCount})标题="${after?.title ?? '-'}"`;
}
// 清理:删除本次自检产生的通知,避免污染演示数据
const cleanup = await win.webContents
.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
return await window.__classhelperSmoke__.islandRealtimeCleanup(
${JSON.stringify(scenario.notificationId)},
${JSON.stringify(scenario.teacherToken)},
);
})()`,
)
.catch(() => null);
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
island.handleAction({ action: 'dismiss' });
await sleep(300);
return { delivered, deliveryDetail, markRead, markReadDetail };
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -545,6 +760,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(online?.detail ?? ''),
);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win);
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript(
`(async () => {