fix(audit): 复查发现的 5 个 bug — 双 listener / duplicate stub / timer leak / i18n

逐项排错盘点(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 完整)
This commit is contained in:
晴天
2026-05-14 07:17:28 +08:00
parent c264224e7c
commit b9a7c043d2
5 changed files with 32 additions and 21 deletions

View File

@@ -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 模式请直接刷新浏览器') },

View File

@@ -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 },

View File

@@ -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))) {

View File

@@ -43,6 +43,7 @@ const ICONS = {
let _busy = false
let _revealEl = null
let _homeEl = null
let _animTimers = [] // 跟踪动画 setTimeoutcleanup 时清
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
}

View File

@@ -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 = `
<div class="page-loader-spinner"></div>
<div class="page-loader-text">加载中...</div>
<div class="page-loader-text">${escHtml(t('common.loading'))}</div>
`
_contentEl.appendChild(spinnerEl)
@@ -142,9 +144,9 @@ function showLoadError(container, hash, error) {
<div style="color:var(--error,#ef4444);margin-bottom:12px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="page-loader-text" style="color:var(--text-primary)">页面加载失败</div>
<div class="page-loader-text" style="color:var(--text-primary)">${escHtml(t('common.pageLoadFailed'))}</div>
<div style="color:var(--text-tertiary);font-size:12px;margin:8px 0 16px;max-width:400px;word-break:break-all">${escHtml(String(error?.message || error))}</div>
<button onclick="location.hash='${hash}';location.reload()" style="padding:6px 20px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px">重新加载</button>
<button onclick="location.hash='${hash}';location.reload()" style="padding:6px 20px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px">${escHtml(t('common.reloadRetry'))}</button>
</div>
`
}