Files
clawpanel/src/router.js
晴天 b9a7c043d2 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 完整)
2026-05-14 07:17:28 +08:00

165 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 极简 hash 路由
*/
import { t } from './lib/i18n.js'
const routes = {}
const _moduleCache = {}
let _contentEl = null
let _loadId = 0
let _currentCleanup = null
let _initialized = false
let _defaultRoute = '/dashboard'
export function registerRoute(path, loader) {
routes[path] = loader
}
export function setDefaultRoute(path) {
_defaultRoute = path
}
export function navigate(path) {
const current = window.location.hash.slice(1)
window.location.hash = path
// 如果 hash 没有实际变化,手动触发加载(引擎切换等场景兜底)
if (current === path) {
reloadCurrentRoute()
}
}
export function initRouter(contentEl) {
_contentEl = contentEl
if (!_initialized) {
window.addEventListener('hashchange', () => loadRoute())
_initialized = true
}
loadRoute()
}
async function loadRoute() {
const hash = window.location.hash.slice(1) || _defaultRoute
const routePath = hash.split('?')[0]
const loader = routes[routePath]
if (!loader || !_contentEl) return
// 竞态防护:记录本次加载 ID
const thisLoad = ++_loadId
// 清理上一个页面
if (_currentCleanup) {
try { _currentCleanup() } catch (_) {}
_currentCleanup = null
}
// 立即移除旧页面(不等退出动画,消除切换卡顿)
_contentEl.innerHTML = ''
// 已缓存的模块:跳过 spinner直接渲染
let mod = _moduleCache[routePath]
if (!mod) {
_contentEl.innerHTML = ''
// 仅首次加载显示 spinner
const spinnerEl = document.createElement('div')
spinnerEl.className = 'page-loader'
spinnerEl.innerHTML = `
<div class="page-loader-spinner"></div>
<div class="page-loader-text">${escHtml(t('common.loading'))}</div>
`
_contentEl.appendChild(spinnerEl)
try {
mod = await retryLoad(loader, 3, 500)
} catch (e) {
console.error('[router] 模块加载失败:', routePath, e)
if (thisLoad === _loadId) showLoadError(_contentEl, routePath, e)
return
}
_moduleCache[routePath] = mod
} else {
_contentEl.innerHTML = ''
}
// 如果加载期间路由又变了,丢弃本次结果
if (thisLoad !== _loadId) return
let page
try {
const renderFn = mod.render || mod.default
page = renderFn ? await withTimeout(renderFn(), 15000, '页面渲染超时') : mod
} catch (e) {
console.error('[router] 页面渲染失败:', routePath, e)
// 渲染失败时清除缓存,下次重试时重新加载模块
delete _moduleCache[routePath]
if (thisLoad === _loadId) showLoadError(_contentEl, routePath, e)
return
}
if (thisLoad !== _loadId) return
// 插入页面内容
_contentEl.innerHTML = ''
if (typeof page === 'string') {
_contentEl.innerHTML = page
} else if (page instanceof HTMLElement) {
_contentEl.appendChild(page)
}
// 保存页面清理函数
_currentCleanup = mod.cleanup || null
// 更新侧边栏激活状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.route === routePath)
})
}
async function retryLoad(loader, maxRetries, delayMs) {
for (let i = 0; i <= maxRetries; i++) {
try {
return await withTimeout(loader(), 15000, '模块加载超时')
} catch (e) {
const isNetworkError = /fetch|network|connection|ERR_/i.test(String(e?.message || e))
if (i < maxRetries && isNetworkError) {
console.warn(`[router] 模块加载失败,${delayMs}ms 后重试 (${i + 1}/${maxRetries})...`)
await new Promise(r => setTimeout(r, delayMs))
continue
}
throw e
}
}
}
function withTimeout(promise, ms, msg) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms))
])
}
function showLoadError(container, hash, error) {
const name = hash.replace('/', '') || 'unknown'
container.innerHTML = `
<div class="page-loader">
<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)">${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">${escHtml(t('common.reloadRetry'))}</button>
</div>
`
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
}
export function getCurrentRoute() {
return window.location.hash.slice(1) || _defaultRoute
}
export function reloadCurrentRoute() {
loadRoute()
}