diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index debd09c..fb14632 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -62,6 +62,15 @@ class IslandController { private boundsToken = 0; /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ private fadeToken = 0; + /** + * 自己维护的"窗口当前是否显示"标记。 + * + * 不直接用 `win.isVisible()`:Windows 上 hide/show 是异步生效的, + * 在 hide() 刚调用完的瞬间 isVisible() 仍可能返回 true。若此时来了一条新通知, + * 逻辑会误判成"窗口已经显示",于是只做尺寸动画、不调用 show(), + * 结果灵动岛再也不出现(真机上表现为"灵动岛消失了")。 + */ + private shown = false; private state: IslandState = { mode: 'hidden', @@ -115,6 +124,24 @@ class IslandController { win.on('closed', () => { this.win = null; this.ready = false; + this.shown = false; + }); + win.on('hide', () => { + this.shown = false; + }); + win.on('show', () => { + this.shown = true; + }); + + // 显示器变化(外接屏插拔/改分辨率)后重算顶部居中位置,避免灵动岛"跑"到屏幕外 + const reposition = (): void => this.reposition(); + screen.on('display-metrics-changed', reposition); + screen.on('display-added', reposition); + screen.on('display-removed', reposition); + win.on('closed', () => { + screen.off('display-metrics-changed', reposition); + screen.off('display-added', reposition); + screen.off('display-removed', reposition); }); this.win = win; @@ -308,6 +335,7 @@ class IslandController { this.emit(); } if (!this.win || this.win.isDestroyed()) return; + this.shown = false; if (this.win.isVisible()) this.win.hide(); this.win.setOpacity(1); } @@ -438,7 +466,7 @@ class IslandController { : SIZES.expanded : SIZES.pill; - if (!this.win.isVisible()) { + if (!this.shown) { const pillBounds = this.computeBounds(SIZES.pill); // 普通通知:直接出现在屏幕上,不做"上岛"入场动画 if (size === SIZES.pill) { @@ -446,6 +474,7 @@ class IslandController { this.win.setOpacity(1); this.win.setBounds(pillBounds); this.win.showInactive(); + this.shown = true; return; } // 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见 @@ -453,6 +482,7 @@ class IslandController { this.win.setOpacity(0); this.win.setBounds(pillBounds); this.win.showInactive(); + this.shown = true; this.animateBounds(size, { spring: true }); this.fadeIn(); return; @@ -460,6 +490,15 @@ class IslandController { this.animateBounds(size, { spring: size !== SIZES.pill }); } + /** 显示器参数变化后把窗口重新摆到当前主屏顶部居中 */ + private reposition(): void { + if (!this.win || this.win.isDestroyed()) return; + const size = this.win.getBounds(); + const target = this.computeBounds({ width: size.width, height: size.height }); + this.boundsToken += 1; + this.win.setBounds(target); + } + /** 作废进行中的淡入淡出动画 */ private cancelFade(): void { this.fadeToken += 1; @@ -479,7 +518,7 @@ class IslandController { } private fadeOut(): void { - if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; + if (!this.win || this.win.isDestroyed() || !this.shown) return; const token = ++this.fadeToken; let opacity = this.win.getOpacity(); const step = (): void => { @@ -490,6 +529,7 @@ class IslandController { setTimeout(step, 16); return; } + this.shown = false; this.win.hide(); this.win.setOpacity(1); }; diff --git a/packages/desktop-client/src/main/logger.ts b/packages/desktop-client/src/main/logger.ts index 8882604..6618c21 100644 --- a/packages/desktop-client/src/main/logger.ts +++ b/packages/desktop-client/src/main/logger.ts @@ -1,25 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { app } from 'electron'; + /** - * 主进程日志(控制台输出)。 - * 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过: - * - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件 - * - 主窗口右上角的连接状态 / 灵动岛状态 - * 对外可见,避免"出了问题却看不到日志"。 + * 主进程日志。 + * + * 打包后的 GUI 进程没有可见的控制台,所以除了 console 输出, + * 关键信息还会追加写入 `%APPDATA%//logs/client.log`(超过 1MB 自动轮转一次), + * 便于出问题时(例如"灵动岛去哪了")直接查看日志文件。 */ +const MAX_LOG_BYTES = 1024 * 1024; + +let logFile: string | null = null; +let initialized = false; + function timestamp(): string { return new Date().toISOString(); } +function ensureLogFile(): string | null { + if (initialized) return logFile; + initialized = true; + try { + const dir = path.join(app.getPath('userData'), 'logs'); + fs.mkdirSync(dir, { recursive: true }); + logFile = path.join(dir, 'client.log'); + if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) { + fs.renameSync(logFile, path.join(dir, 'client.log.1')); + } + } catch { + logFile = null; + } + return logFile; +} + +function write(level: string, message: string, args: unknown[]): void { + const extra = args.length > 0 ? ` ${args.map((item) => safeStringify(item)).join(' ')}` : ''; + const line = `[${timestamp()}] ${level} ${message}${extra}\n`; + const file = ensureLogFile(); + if (!file) return; + try { + fs.appendFileSync(file, line, 'utf8'); + } catch { + /* 日志写失败不能影响主流程 */ + } +} + +function safeStringify(value: unknown): string { + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + export const logger = { debug(message: string, ...args: unknown[]): void { console.log(`[${timestamp()}] DEBUG ${message}`, ...args); + write('DEBUG', message, args); }, info(message: string, ...args: unknown[]): void { console.log(`[${timestamp()}] INFO ${message}`, ...args); + write('INFO ', message, args); }, warn(message: string, ...args: unknown[]): void { console.warn(`[${timestamp()}] WARN ${message}`, ...args); + write('WARN ', message, args); }, error(message: string, ...args: unknown[]): void { console.error(`[${timestamp()}] ERROR ${message}`, ...args); + write('ERROR', message, args); + }, + /** 当前日志文件路径(设置页/排查时展示) */ + filePath(): string | null { + return ensureLogFile(); }, }; diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index bd7e6f5..aad3377 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -82,14 +82,19 @@ function analyzeBitmap(image: NativeImage): { return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish }; } -/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */ +/** + * 灵动岛状态截图留档 + 像素统计。 + * + * 像素统计**总是执行**(断言不依赖环境变量);写盘留档仅在设置了 + * `ISLAND_SHOTS_DIR` 时进行(用于 docs/screenshots 归档与人工复核)。 + */ async function captureIsland( name: string, expected: { width: number; height: number }, ): Promise { - const dir = process.env.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 +105,12 @@ 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 = { @@ -115,7 +123,7 @@ async function captureIsland( }; islandShots.push(stats); console.log( - `[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` + + `[SMOKE] 灵动岛截图:${filePath || '(仅内存统计)'}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` + `颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`, ); return stats; @@ -403,6 +411,67 @@ 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<{ ok: boolean; detail: string }> { + const scenario = await win.webContents.executeJavaScript( + `(async () => { + if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; + return await window.__classhelperSmoke__.islandRealtimeScenario(); + })()`, + ); + if (!scenario?.ok) { + return { ok: false, detail: `投递失败:${scenario?.detail ?? '未知原因'}` }; + } + + // 等通知经实时通道到达并弹出胶囊(实测 <100ms,这里留足余量) + 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 domText = await island + .getWindow() + ?.webContents.executeJavaScript( + `(document.querySelector('.island-card.pill')?.textContent ?? '').replace(/\\s+/g, ' ').trim()`, + ) + .catch(() => ''); + await captureIsland('island-6-realtime', ISLAND_SIZES.pill); + + const matched = state.mode === 'pill' && state.active?.id === scenario.notificationId; + const detail = + `通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` + + `灵动岛 mode=${state.mode} 可见=${visible} 文案="${String(domText).slice(0, 40)}"`; + + // 清理:删除本次自检产生的通知,避免污染演示数据 + 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 { ok: matched && visible, detail }; +} + /** * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, @@ -545,6 +614,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise { String(online?.detail ?? ''), ); + // 真实通知链路:教师发通知 → 学生客户端实时通道收到 → 灵动岛弹出胶囊 + // (前面的灵动岛用例只验证状态机,这一条验证"真机上通知真的会浮出来") + const realtime = await runIslandRealtimeCheck(win); + record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.ok, realtime.detail); + // 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应) const navigation = await win.webContents.executeJavaScript( `(async () => { diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 1ae8145..6670ea9 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -215,6 +215,88 @@ export async function onlineScenario(): Promise { } } +/** + * 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。 + * + * 前面的灵动岛用例是直接调 IPC 验证"状态机",这一条走**真机链路**: + * 教师账号给"当前登录学生所在班级"发一条普通通知 → 服务端 Socket.IO 广播 + * → 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊。 + * + * 返回临时信息(通知 id / 教师令牌 / 标题)供主进程断言与收尾清理。 + */ +export async function islandRealtimeScenario(): Promise<{ + ok: boolean; + detail: string; + notificationId?: string; + teacherToken?: string; + title?: string; + classId?: string; + inClass?: boolean; +}> { + const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([ + import('../stores/auth.js'), + import('../island/bridge.js'), + ]); + const auth = useAuthStore(); + const serverUrl = 'http://127.0.0.1:4000'; + const classId = auth.classId ?? null; + if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' }; + + const title = `灵动岛真机链路自检 ${Date.now()}`; + try { + const login = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), + }).then((response) => response.json()); + const teacherToken: string | undefined = login?.data?.token; + if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` }; + + const created = await fetch(`${serverUrl}/api/notifications`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` }, + body: JSON.stringify({ + classId, + title, + content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。', + priority: 'NORMAL', + }), + }).then((response) => response.json()); + const notificationId: string | undefined = created?.data?.id; + if (!notificationId) + return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` }; + + const context = getIslandClassContext(); + return { + ok: true, + detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`, + notificationId, + teacherToken, + title, + classId, + inClass: context.inClass, + }; + } catch (error) { + return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` }; + } +} + +/** 冒烟收尾:删除真实链路自检产生的通知 */ +export async function islandRealtimeCleanup( + notificationId: string, + teacherToken: string, +): Promise { + try { + const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${teacherToken}` }, + }); + return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` }; + } catch (error) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } +} + /** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */ export async function sessionCleanup(): Promise { const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ @@ -237,6 +319,8 @@ export function registerSmokeHooks(): void { offlineScenario, layoutNavigationSelfTest, onlineScenario, + islandRealtimeScenario, + islandRealtimeCleanup, sessionCleanup, }; } diff --git a/packages/desktop-client/src/renderer/views/SettingsView.vue b/packages/desktop-client/src/renderer/views/SettingsView.vue index d76468c..b9f848f 100644 --- a/packages/desktop-client/src/renderer/views/SettingsView.vue +++ b/packages/desktop-client/src/renderer/views/SettingsView.vue @@ -1,11 +1,12 @@ @@ -150,6 +217,36 @@ onMounted(async () => { + + + + + + {{ islandModeLabel }} + + + {{ islandClassContext.inClass ? '上课中(普通通知暂存)' : '非上课时段' }} + + + + {{ islandState ? (islandState.mode === 'hidden' ? '否(无通知时隐藏)' : '是') : '-' }} + + + {{ islandState?.queued.length ?? 0 }} 条 + + + {{ islandHint }} + +
+ 测试灵动岛 + 刷新状态 +
+
+ diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 42e3b4d..28ad506 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -76,6 +76,20 @@ declare global { offlineScenario(): Promise<{ ok: boolean; detail: string }>; layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>; onlineScenario(): Promise<{ ok: boolean; detail: string }>; + /** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */ + islandRealtimeScenario(): Promise<{ + ok: boolean; + detail: string; + notificationId?: string; + teacherToken?: string; + title?: string; + classId?: string; + inClass?: boolean; + }>; + islandRealtimeCleanup( + notificationId: string, + teacherToken: string, + ): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>; }; }