fix(desktop,island): 设置页灵动岛外观改动即生效——IPC 只接受纯数据 + 真实 UI 冒烟回归
用户反馈"灵动岛设置无法生效并且无法实时预览"。根因是设置页把 ref 的响应式 Proxy 直接交给 contextBridge,结构化克隆在进入 preload 函数体之前就抛 `An object could not be cloned.`,界面数字照常变化却半静默失败。
- SettingsView:应用/保存外观时展开成纯对象({ ...island.value })
- desktop.d.ts / preload:显式写明"必须传纯数据",记录该失败模式
- smoke.ts:新增「拖拽设置页真实滑块」用例,同时断言页面数值与主进程外观都变化
This commit is contained in:
1 parent
d900280760
commit
14ac16ff29
4 files changed
+113
-4
No files matched your search
@@ -2455,6 +2455,87 @@ async function runIslandRealtimeCheck(
|
|||||||
|
|
||||||
return { delivered, deliveryDetail, markRead, markReadDetail };
|
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 时触发,跑完自动退出)。
|
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||||
@@ -2645,6 +2726,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
String(online?.detail ?? ''),
|
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);
|
const realtime = await runIslandRealtimeCheck(win);
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { IslandNotification } from '@classhelper/shared';
|
|||||||
/**
|
/**
|
||||||
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
||||||
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
|
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
|
||||||
|
*
|
||||||
|
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
|
||||||
|
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
|
||||||
|
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
|
||||||
*/
|
*/
|
||||||
const bridge: DesktopBridge = {
|
const bridge: DesktopBridge = {
|
||||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||||
|
|||||||
@@ -137,17 +137,21 @@ async function loadIslandAppearance(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
|
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
|
||||||
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
|
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
|
||||||
|
*
|
||||||
|
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||||
|
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||||
|
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||||
*/
|
*/
|
||||||
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||||
island.value = { ...island.value, ...patch };
|
island.value = { ...island.value, ...patch };
|
||||||
window.desktop?.islandSetAppearance(island.value);
|
window.desktop?.islandSetAppearance({ ...island.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveIslandAppearance(): Promise<void> {
|
async function saveIslandAppearance(): Promise<void> {
|
||||||
if (!window.desktop?.saveConfig) return;
|
if (!window.desktop?.saveConfig) return;
|
||||||
savingIsland.value = true;
|
savingIsland.value = true;
|
||||||
try {
|
try {
|
||||||
await window.desktop.saveConfig({ island: island.value });
|
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||||
ElMessage.success('个性化设置已保存');
|
ElMessage.success('个性化设置已保存');
|
||||||
} finally {
|
} finally {
|
||||||
savingIsland.value = false;
|
savingIsland.value = false;
|
||||||
|
|||||||
+16
-2
@@ -61,19 +61,33 @@ export interface DesktopBridge {
|
|||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
getConfig(): Promise<DesktopStoredConfig>;
|
getConfig(): Promise<DesktopStoredConfig>;
|
||||||
|
/**
|
||||||
|
* 局部更新配置。
|
||||||
|
*
|
||||||
|
* **必须传纯数据(普通对象 / 基本类型)**:传 Vue 的 `ref.value` / `reactive()` 对象
|
||||||
|
* 会在 contextBridge 跨隔离世界时被结构化克隆拒绝,抛
|
||||||
|
* `Error: An object could not be cloned.`,而且调用点看起来"只是没生效"。
|
||||||
|
* 页面里请写成 `saveConfig({ island: { ...island.value } })` 这种展开后的字面量。
|
||||||
|
*/
|
||||||
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
|
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
|
||||||
clearConfig(): Promise<DesktopStoredConfig>;
|
clearConfig(): Promise<DesktopStoredConfig>;
|
||||||
getAppInfo(): Promise<DesktopAppInfo>;
|
getAppInfo(): Promise<DesktopAppInfo>;
|
||||||
openExternal(url: string): Promise<boolean>;
|
openExternal(url: string): Promise<boolean>;
|
||||||
|
|
||||||
/* 灵动岛 */
|
/* 灵动岛 */
|
||||||
/** 把一条通知投递到灵动岛 */
|
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
|
||||||
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
||||||
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
|
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
|
||||||
islandSetClassState(payload: IslandClassStatePayload): void;
|
islandSetClassState(payload: IslandClassStatePayload): void;
|
||||||
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
|
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
|
||||||
islandGetState(): Promise<IslandState>;
|
islandGetState(): Promise<IslandState>;
|
||||||
/** 应用灵动岛外观设置(实时生效) */
|
/**
|
||||||
|
* 应用灵动岛外观设置(实时生效)。
|
||||||
|
*
|
||||||
|
* 同 saveConfig:**必须是纯数据**。设置页曾把 `island`(`ref`)的响应式 Proxy 直接传进来,
|
||||||
|
* 结果 contextBridge 抛 `An object could not be cloned.`,主进程收不到任何东西 ——
|
||||||
|
* 现象就是"拖了滑块、数字在变,灵动岛毫无反应,保存也不生效"。
|
||||||
|
*/
|
||||||
islandSetAppearance(appearance: Partial<IslandAppearance>): void;
|
islandSetAppearance(appearance: Partial<IslandAppearance>): void;
|
||||||
/** 读取当前生效的外观设置 */
|
/** 读取当前生效的外观设置 */
|
||||||
islandGetAppearance(): Promise<IslandAppearance>;
|
islandGetAppearance(): Promise<IslandAppearance>;
|
||||||
|
|||||||
Reference in new issue
Block a user