From 14ac16ff290c1e6a61adac613817945e34a7d356 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sun, 27 Sep 2026 10:43:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop,island):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E7=81=B5=E5=8A=A8=E5=B2=9B=E5=A4=96=E8=A7=82=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E5=8D=B3=E7=94=9F=E6=95=88=E2=80=94=E2=80=94IPC=20?= =?UTF-8?q?=E5=8F=AA=E6=8E=A5=E5=8F=97=E7=BA=AF=E6=95=B0=E6=8D=AE=20+=20?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20UI=20=E5=86=92=E7=83=9F=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈"灵动岛设置无法生效并且无法实时预览"。根因是设置页把 ref 的响应式 Proxy 直接交给 contextBridge,结构化克隆在进入 preload 函数体之前就抛 `An object could not be cloned.`,界面数字照常变化却半静默失败。 - SettingsView:应用/保存外观时展开成纯对象({ ...island.value }) - desktop.d.ts / preload:显式写明"必须传纯数据",记录该失败模式 - smoke.ts:新增「拖拽设置页真实滑块」用例,同时断言页面数值与主进程外观都变化 --- packages/desktop-client/src/main/smoke.ts | 87 +++++++++++++++++++ packages/desktop-client/src/preload/index.ts | 4 + .../src/renderer/views/SettingsView.vue | 8 +- .../desktop-client/src/types/desktop.d.ts | 18 +++- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 81e8e4f..4d94019 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -2455,6 +2455,87 @@ async function runIslandRealtimeCheck( return { delivered, deliveryDetail, markRead, markReadDetail }; } + +/** + * 设置页「真实 UI 链路」自检(用户反馈:**灵动岛设置无法生效并且无法实时预览**)。 + * + * 为什么必须单独来一条:上面的用例都是直接调 `window.desktop.islandSetAppearance(...)`, + * 绕过了设置页的控件,于是漏掉了真正的失败模式 —— 设置页把 Vue 的响应式 Proxy(`ref.value`) + * 直接交给 `ipcRenderer`,结构化克隆会失败并抛出 `An object could not be cloned.`。 + * 这种失败是"半静默"的:滑块数字照常变化、界面不报错,只有主进程里的外观一动不动, + * 用户看到的就是"拖了滑块没反应、预览也没变化"。 + * + * 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径), + * 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。 + */ +async function runSettingsPageAppearanceCheck( + win: BrowserWindow, +): Promise<{ ok: boolean; detail: string }> { + const result = (await win.webContents + .executeJavaScript( + `(async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const before = await window.desktop.islandGetAppearance(); + // 先固定基准高度:避免"当前高度恰好等于拖拽落点"导致假通过或假失败 + window.desktop.islandSetAppearance({ ...before, height: 44 }); + await wait(300); + + location.hash = '#/settings'; + let runway = null; + for (let i = 0; i < 40 && !runway; i += 1) { + await wait(100); + runway = document.querySelector('.el-slider__runway'); + } + if (!runway) return { ok: false, detail: '设置页未渲染出滑块(未登录或页面未挂载)' }; + const rect = runway.getBoundingClientRect(); + if (!rect.width) return { ok: false, detail: '滑块宽度为 0,无法拖拽' }; + + const x = rect.left + rect.width * 0.9; + const y = rect.top + rect.height / 2; + const fire = (target, type, extra) => + target.dispatchEvent( + new MouseEvent( + type, + Object.assign({ bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }, extra), + ), + ); + fire(runway, 'mousedown', { buttons: 1 }); + await wait(60); + fire(window, 'mousemove', { buttons: 1 }); + await wait(60); + fire(window, 'mouseup', {}); + await wait(600); + + const dragged = await window.desktop.islandGetAppearance(); + const label = + Array.from(document.querySelectorAll('.el-form-item__label')) + .map((node) => (node.textContent ?? '').trim()) + .find((text) => text.startsWith('高度')) ?? ''; + // 复原外观与路由,不影响后续用例 + window.desktop.islandSetAppearance({ ...before }); + location.hash = '#/schedule'; + return { ok: true, baseline: 44, dragged: dragged.height, label }; + })()`, + ) + .catch((error: unknown) => ({ + ok: false as const, + detail: `执行失败:${error instanceof Error ? error.message : String(error)}`, + }))) as + | { ok: true; baseline: number; dragged: number; label: string } + | { ok: false; detail: string } + | null; + + if (result?.ok) { + return { + ok: result.dragged !== result.baseline && result.label === `高度 ${result.dragged}px`, + detail: + `拖动高度滑块到 90%:页面标签="${result.label}",` + + `主进程外观高度 ${result.baseline} → ${result.dragged}`, + }; + } + return { ok: false, detail: result?.detail ?? '未执行' }; +} + /** * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, @@ -2645,6 +2726,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise { String(online?.detail ?? ''), ); + // 设置页真实 UI 链路(用户反馈:"灵动岛设置无法生效并且无法实时预览"): + // 必须真的去拖设置页里的滑块 —— 直接调 window.desktop.* 会绕过 + // 「Vue 响应式 Proxy 过不了 IPC 结构化克隆」这个失败模式。 + const settingsAppearance = await runSettingsPageAppearanceCheck(win); + record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail); + // 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊; // 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读") const realtime = await runIslandRealtimeCheck(win); diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index 17f0f08..a29ce62 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -12,6 +12,10 @@ import type { IslandNotification } from '@classhelper/shared'; /** * 预加载脚本:在开启了 contextIsolation + sandbox 的前提下, * 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。 + * + * 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆, + * 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体 + * 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。 */ const bridge: DesktopBridge = { smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', diff --git a/packages/desktop-client/src/renderer/views/SettingsView.vue b/packages/desktop-client/src/renderer/views/SettingsView.vue index 099be8d..0b62a77 100644 --- a/packages/desktop-client/src/renderer/views/SettingsView.vue +++ b/packages/desktop-client/src/renderer/views/SettingsView.vue @@ -137,17 +137,21 @@ async function loadIslandAppearance(): Promise { /** * 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。 * 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。 + * + * 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式 + * Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)—— + * 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。 */ function applyIslandAppearance(patch: Partial = {}): void { island.value = { ...island.value, ...patch }; - window.desktop?.islandSetAppearance(island.value); + window.desktop?.islandSetAppearance({ ...island.value }); } async function saveIslandAppearance(): Promise { if (!window.desktop?.saveConfig) return; savingIsland.value = true; try { - await window.desktop.saveConfig({ island: island.value }); + await window.desktop.saveConfig({ island: { ...island.value } }); ElMessage.success('个性化设置已保存'); } finally { savingIsland.value = false; diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index b255543..b631819 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -61,19 +61,33 @@ export interface DesktopBridge { /** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */ smokeTest: boolean; getConfig(): Promise; + /** + * 局部更新配置。 + * + * **必须传纯数据(普通对象 / 基本类型)**:传 Vue 的 `ref.value` / `reactive()` 对象 + * 会在 contextBridge 跨隔离世界时被结构化克隆拒绝,抛 + * `Error: An object could not be cloned.`,而且调用点看起来"只是没生效"。 + * 页面里请写成 `saveConfig({ island: { ...island.value } })` 这种展开后的字面量。 + */ saveConfig(patch: Partial): Promise; clearConfig(): Promise; getAppInfo(): Promise; openExternal(url: string): Promise; /* 灵动岛 */ - /** 把一条通知投递到灵动岛 */ + /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */ islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; /** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */ islandSetClassState(payload: IslandClassStatePayload): void; /** 读取灵动岛当前状态(设置页/冒烟验证用) */ islandGetState(): Promise; - /** 应用灵动岛外观设置(实时生效) */ + /** + * 应用灵动岛外观设置(实时生效)。 + * + * 同 saveConfig:**必须是纯数据**。设置页曾把 `island`(`ref`)的响应式 Proxy 直接传进来, + * 结果 contextBridge 抛 `An object could not be cloned.`,主进程收不到任何东西 —— + * 现象就是"拖了滑块、数字在变,灵动岛毫无反应,保存也不生效"。 + */ islandSetAppearance(appearance: Partial): void; /** 读取当前生效的外观设置 */ islandGetAppearance(): Promise;