Files
clawpanel/src/pages/engine-select.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

262 lines
8.9 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.
import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
import { applyEngineSelection } from '../lib/engine-manager.js'
import { toast } from '../components/toast.js'
const PRIMARY_OPTIONS = [
{
id: 'openclaw',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw'],
targetRoute: '/setup',
},
{
id: 'hermes',
activeEngineId: 'hermes',
enabledEngineIds: ['hermes'],
targetRoute: '/h/setup',
},
]
const SECONDARY_OPTIONS = [
{
id: 'both',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw', 'hermes'],
engineMode: 'both',
targetRoute: '/setup',
},
{
id: 'later',
activeEngineId: 'openclaw',
enabledEngineIds: [],
deferred: true,
targetRoute: '/engine-select',
},
]
const ICONS = {
openclaw: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
hermes: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
}
let _busy = false
let _revealEl = null
let _homeEl = null
let _animTimers = [] // 跟踪动画 setTimeoutcleanup 时清
export async function render() {
const page = document.createElement('div')
page.className = 'page engine-select-page es-monolith'
page.innerHTML = `
<div class="es-stage">
<div class="es-panel es-panel-openclaw" data-engine="openclaw">
<div class="es-glow es-glow-openclaw"></div>
</div>
<div class="es-panel es-panel-hermes" data-engine="hermes">
<div class="es-glow es-glow-hermes"></div>
</div>
<div class="es-divider"></div>
<div class="es-top-banner">${esc(t('engine.choiceTopBanner'))}</div>
<div class="es-corner-mark es-corner-tl">CLAWPANEL</div>
<div class="es-corner-mark es-corner-br" data-version-tag>v—</div>
${renderContent('openclaw')}
${renderContent('hermes')}
<div class="es-secondary">
<button type="button" class="es-secondary-link" data-secondary="both">${esc(t('engine.choiceSecondaryBoth'))}</button>
<span class="es-secondary-sep" aria-hidden="true">·</span>
<button type="button" class="es-secondary-link" data-secondary="later">${esc(t('engine.choiceSecondaryLater'))}</button>
</div>
</div>
`
// 注入版本号package.json 同步Vite define 注入)
try {
const v = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : ''
const tag = page.querySelector('[data-version-tag]')
if (tag && v) tag.textContent = `v${v}`
} catch (_) {}
bindHover(page)
bindClick(page)
return page
}
function renderContent(id) {
const num = id === 'openclaw' ? '01' : '02'
const cap = id === 'openclaw' ? 'OpenClaw' : 'Hermes'
const cat = id === 'openclaw' ? t('engine.choiceOpenclawCategory') : t('engine.choiceHermesCategory')
const tagline = id === 'openclaw' ? t('engine.choiceOpenclawTagline') : t('engine.choiceHermesTagline')
const feats = id === 'openclaw'
? [t('engine.choiceOpenclawFeat1'), t('engine.choiceOpenclawFeat2'), t('engine.choiceOpenclawFeat3')]
: [t('engine.choiceHermesFeat1'), t('engine.choiceHermesFeat2'), t('engine.choiceHermesFeat3')]
const cta = `${t('engine.choiceCtaEnter')} ${cap}`
// OpenClaw左上序号在前 / Hermes右下序号在后
const productRow = id === 'openclaw'
? `<span class="es-product-icon">${ICONS[id]}</span><span class="es-product-tag">${esc(num)} · ${esc(cat)}</span>`
: `<span class="es-product-tag">${esc(cat)} · ${esc(num)}</span><span class="es-product-icon">${ICONS[id]}</span>`
return `
<div class="es-content es-content-${id}" data-engine-content="${id}">
<div class="es-product-row">${productRow}</div>
<div class="es-title">${esc(cap)}</div>
<div class="es-tagline">${esc(tagline)}</div>
<ul class="es-feature-list">
${feats.map(f => `<li>${esc(f)}</li>`).join('')}
</ul>
<button type="button" class="es-cta" data-engine-cta="${id}" tabindex="-1">
${id === 'openclaw' ? `<span>${esc(cta)}</span><span class="es-cta-arrow">→</span>` : `<span class="es-cta-arrow">→</span><span>${esc(cta)}</span>`}
</button>
</div>
`
}
function bindHover(page) {
// 用 attribute 替代 :has() — 兼容性更好(旧 WebKit / Linux WebKitGTK
const stage = page.querySelector('.es-stage')
page.querySelectorAll('.es-panel').forEach(panel => {
const engine = panel.dataset.engine
panel.addEventListener('mouseenter', () => {
if (_busy) return
stage.dataset.hover = engine
})
panel.addEventListener('mouseleave', () => {
if (_busy) return
delete stage.dataset.hover
})
})
}
function bindClick(page) {
const stage = page.querySelector('.es-stage')
// 主区:点击三角形选引擎
stage.addEventListener('click', (event) => {
if (_busy) return
const panel = event.target.closest('.es-panel')
if (!panel) return
const engine = panel.dataset.engine
const option = PRIMARY_OPTIONS.find(o => o.id === engine)
if (option) chooseWithAnimation(page, panel, option, engine)
})
// 次级链接:两个都要 / 稍后再说(无对角线动画,直接走选择)
page.querySelectorAll('[data-secondary]').forEach(btn => {
btn.addEventListener('click', async (event) => {
event.stopPropagation()
if (_busy) return
const id = btn.dataset.secondary
const option = SECONDARY_OPTIONS.find(o => o.id === id)
if (!option) return
_busy = true
btn.classList.add('loading')
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
})
toast(t('engine.choiceSaved'), 'success')
navigate(option.targetRoute)
} catch (error) {
console.error('[engine-select] secondary choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
_busy = false
btn.classList.remove('loading')
}
})
})
}
async function chooseWithAnimation(page, panel, option, engine) {
_busy = true
const stage = page.querySelector('.es-stage')
delete stage.dataset.hover
stage.dataset.expanding = engine
// 先把 reveal / home mock 节点 attach 到 body — 路由切换时它们不会被销毁
ensureRevealNodes()
// 阶段 1: 三角形扩满CSS 通过 [data-expanding] 触发 clip-path 变化)
// 阶段 2: 600ms 后开始中心圆扩散
_animTimers.push(setTimeout(() => {
if (_revealEl) {
_revealEl.dataset.engine = engine
_revealEl.classList.add('es-reveal-active')
}
}, 600))
// 阶段 3: 1300ms 后保存选择 + 切换路由
_animTimers.push(setTimeout(async () => {
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
})
navigate(option.targetRoute)
// 给新页面一点渲染时间后淡出 reveal 层
_animTimers.push(setTimeout(() => {
if (_revealEl) {
_revealEl.classList.add('es-reveal-fadeout')
_animTimers.push(setTimeout(() => removeRevealNodes(), 600))
}
}, 280))
} catch (error) {
console.error('[engine-select] choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
// 失败回退:移除动画层 + 解除 busy
removeRevealNodes()
// stage 可能已不在 DOM路由已切走— 防御性访问
try { delete stage.dataset.expanding } catch (_) {}
_busy = false
}
}, 1300))
}
function ensureRevealNodes() {
if (!_revealEl) {
_revealEl = document.createElement('div')
_revealEl.className = 'es-reveal'
document.body.appendChild(_revealEl)
}
if (!_homeEl) {
_homeEl = document.createElement('div')
_homeEl.className = 'es-reveal-home'
document.body.appendChild(_homeEl)
}
}
function removeRevealNodes() {
if (_revealEl) { _revealEl.remove(); _revealEl = null }
if (_homeEl) { _homeEl.remove(); _homeEl = null }
_busy = false
}
export function cleanup() {
// 路由切走时清所有 setTimeout避免动画后期调 navigate / mutate stale element
for (const id of _animTimers) {
try { clearTimeout(id) } catch (_) {}
}
_animTimers = []
// 不主动销毁 reveal 节点(动画完成后会自行淡出)— 但万一动画被中断,亦初始化允许下次 ensureRevealNodes 重新创建
_busy = false
}
function esc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}