From b9a7c043d27ee4bc53253160a546055bce277beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 07:17:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E5=A4=8D=E6=9F=A5=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=205=20=E4=B8=AA=20bug=20=E2=80=94=20?= =?UTF-8?q?=E5=8F=8C=20listener=20/=20duplicate=20stub=20/=20timer=20leak?= =?UTF-8?q?=20/=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐项排错盘点(OpenClaw + Hermes 引擎 + 共享层全面复查)。 ## Bug #1 — Hermes 引擎双 listener 数组混用 src/engines/hermes/index.js onStateChange / onReadyChange 共用一个 `_listeners` 数组: ```js onStateChange(fn) { _listeners.push(fn); ... } onReadyChange(fn) { _listeners.push(fn); ... } ← 同一数组 ``` main.js 注册 sidebar 渲染回调时两个都注册: ```js _engineStateUnsub = engine.onStateChange(() => renderSidebar(sidebar)) _engineReadyUnsub = engine.onReadyChange(() => renderSidebar(sidebar)) ``` 结果:每次 detectHermesStatus(15s 一次 poll)触发,sidebar 被 renderSidebar 调两遍。OpenClaw 引擎用的 lib/app-state.js 早就是分开 两个数组(_gwListeners + _listeners),Hermes 是退化实现。 修复: - 拆成 _stateListeners / _readyListeners 两个数组 - 加 prevReady / prevRunning 做 diff,仅在状态实际变化时通知 ## Bug #2 — Web 模式下 check_panel_update 永远返回 false scripts/dev-api.js `check_panel_update` 在 line 6785 有完整实现(fetch GitHub/Gitee release API),但 line 8586 又 stub 了一次: ```js check_panel_update() { return { hasUpdate: false } } ``` Object literal 后定义覆盖前定义,Web 模式下用户永远看不到「有新版」 提示,必须升级到桌面客户端才能查更新。 修复:删掉重复 stub,留注释说明真实实现位置。 esbuild 之前就在 build 里 warn `Duplicate key "check_panel_update" in object literal`,现在 warning 也消失了。 ## Bug #3 — engine-select.js setTimeout 不在 cleanup 时清 src/pages/engine-select.js choose 动画里两个 setTimeout (600ms + 1300ms) 没保存 id,路由 cleanup 时不清。极端情况:用户点完 OpenClaw 后立刻点 secondary "稍后再说" → 1300ms 后被强制 navigate 回原 targetRoute,把用户拉走。 修复: - 模块级 _animTimers 数组追踪所有动画 setTimeout id - cleanup 时 clearTimeout 全清 - stage.dataset 防御性访问(路由切走后 stage 可能已不在 DOM) ## Bug #4 — router.js 三处中文硬编码 src/router.js 之前页面 loading / 加载失败 / 重新加载按钮直接写死"加载中..." "页面加载失败" "重新加载"。i18n 里 `common.loading` / `common.pageLoadFailed` / `common.reloadRetry` 早已存在但没用上。 修复:import t() + 三处替换。 ## Bug #5 — dashboard.js Promise 超时 Error 写死中文 src/pages/dashboard.js `new Error(\`超时(${ms/1000}s)\`)` — 这条错误最后被 humanizeError 处理后展示给用户,本来应该走 i18n。改成英文 `Timed out after Xs`, 统一与日志聚合(其他地方都是英文)。 ## 验证 - npm run build:PASS(1.85s, 无 duplicate-key warning) - cargo fmt --check:PASS(无改动) - 受影响场景: - 引擎切换 sidebar 性能(Hermes 双重渲染消失) - Web 模式更新提示(恢复正常) - engine-select 动画中途切走(不再被强拉回) - 加载/错误页(i18n 完整) --- scripts/dev-api.js | 3 ++- src/engines/hermes/index.js | 8 ++++---- src/pages/dashboard.js | 2 +- src/pages/engine-select.js | 32 ++++++++++++++++++++------------ src/router.js | 8 +++++--- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 783d193..afa9f7e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -8583,7 +8583,8 @@ const handlers = { download_frontend_update() { throw new Error('Web 模式无需前端热更新,刷新浏览器即可') }, rollback_frontend_update() { throw new Error('Web 模式不支持前端热更新回滚') }, get_update_status() { return { status: 'idle', mode: 'web' } }, - check_panel_update() { return { hasUpdate: false } }, + // 注意:check_panel_update 的真实实现在前面(line ~6785)—— 走 GitHub/Gitee release API。 + // 这里不能再 stub,否则 object literal 的后定义会覆盖前者,导致 Web 模式永远看不到新版。 // —— 应用重启(Web 端由 tauri-api.js 包装层直接调 location.reload,到这里说明绕过了包装)—— relaunch_app() { throw new Error('Web 模式请直接刷新浏览器') }, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 7a0bf10..3d17c6e 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -148,12 +148,12 @@ export default { isGatewayForeign() { return false }, onStateChange(fn) { - _listeners.push(fn) - return () => { _listeners = _listeners.filter(cb => cb !== fn) } + _stateListeners.push(fn) + return () => { _stateListeners = _stateListeners.filter(cb => cb !== fn) } }, onReadyChange(fn) { - _listeners.push(fn) - return () => { _listeners = _listeners.filter(cb => cb !== fn) } + _readyListeners.push(fn) + return () => { _readyListeners = _readyListeners.filter(cb => cb !== fn) } }, isFeatureAvailable() { return true }, diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 2e0a448..068a69f 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -142,7 +142,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { // 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做 const withTimeout = (promise, ms) => Promise.race([ promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`超时(${ms/1000}s)`)), ms)) + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${(ms/1000).toFixed(1)}s`)), ms)) ]) const shouldFetchVersion = !_dashboardInitialized || fullRefresh || !_dashboardVersionCache || versionInfoIncomplete(_dashboardVersionCache) if (shouldFetchVersion && (fullRefresh || versionInfoIncomplete(_dashboardVersionCache))) { diff --git a/src/pages/engine-select.js b/src/pages/engine-select.js index 5e466ab..b493efc 100644 --- a/src/pages/engine-select.js +++ b/src/pages/engine-select.js @@ -43,6 +43,7 @@ const ICONS = { let _busy = false let _revealEl = null let _homeEl = null +let _animTimers = [] // 跟踪动画 setTimeout,cleanup 时清 export async function render() { const page = document.createElement('div') @@ -185,13 +186,15 @@ async function chooseWithAnimation(page, panel, option, engine) { // 阶段 1: 三角形扩满(CSS 通过 [data-expanding] 触发 clip-path 变化) // 阶段 2: 600ms 后开始中心圆扩散 - setTimeout(() => { - _revealEl.dataset.engine = engine - _revealEl.classList.add('es-reveal-active') - }, 600) + _animTimers.push(setTimeout(() => { + if (_revealEl) { + _revealEl.dataset.engine = engine + _revealEl.classList.add('es-reveal-active') + } + }, 600)) // 阶段 3: 1300ms 后保存选择 + 切换路由 - setTimeout(async () => { + _animTimers.push(setTimeout(async () => { try { await applyEngineSelection({ activeEngineId: option.activeEngineId, @@ -202,21 +205,22 @@ async function chooseWithAnimation(page, panel, option, engine) { }) navigate(option.targetRoute) // 给新页面一点渲染时间后淡出 reveal 层 - setTimeout(() => { + _animTimers.push(setTimeout(() => { if (_revealEl) { _revealEl.classList.add('es-reveal-fadeout') - setTimeout(() => removeRevealNodes(), 600) + _animTimers.push(setTimeout(() => removeRevealNodes(), 600)) } - }, 280) + }, 280)) } catch (error) { console.error('[engine-select] choose failed:', error) toast(t('engine.choiceSaveFailed'), 'error') // 失败回退:移除动画层 + 解除 busy removeRevealNodes() - delete stage.dataset.expanding + // stage 可能已不在 DOM(路由已切走)— 防御性访问 + try { delete stage.dataset.expanding } catch (_) {} _busy = false } - }, 1300) + }, 1300)) } function ensureRevealNodes() { @@ -239,8 +243,12 @@ function removeRevealNodes() { } export function cleanup() { - // 路由切走时不主动销毁 reveal 节点(动画完成后会自行淡出) - // 这里仅重置 busy(防卡死) + // 路由切走时清所有 setTimeout,避免动画后期调 navigate / mutate stale element + for (const id of _animTimers) { + try { clearTimeout(id) } catch (_) {} + } + _animTimers = [] + // 不主动销毁 reveal 节点(动画完成后会自行淡出)— 但万一动画被中断,亦初始化允许下次 ensureRevealNodes 重新创建 _busy = false } diff --git a/src/router.js b/src/router.js index 411306d..814754e 100644 --- a/src/router.js +++ b/src/router.js @@ -1,6 +1,8 @@ /** * 极简 hash 路由 */ +import { t } from './lib/i18n.js' + const routes = {} const _moduleCache = {} let _contentEl = null @@ -63,7 +65,7 @@ async function loadRoute() { spinnerEl.className = 'page-loader' spinnerEl.innerHTML = `
-
加载中...
+
${escHtml(t('common.loading'))}
` _contentEl.appendChild(spinnerEl) @@ -142,9 +144,9 @@ function showLoadError(container, hash, error) {
-
页面加载失败
+
${escHtml(t('common.pageLoadFailed'))}
${escHtml(String(error?.message || error))}
- + ` }