mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
逐项排错盘点(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 完整)
262 lines
8.9 KiB
JavaScript
262 lines
8.9 KiB
JavaScript
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 = [] // 跟踪动画 setTimeout,cleanup 时清
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|