mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +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 完整)
165 lines
5.0 KiB
JavaScript
165 lines
5.0 KiB
JavaScript
/**
|
||
* 极简 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||
}
|
||
|
||
export function getCurrentRoute() {
|
||
return window.location.hash.slice(1) || _defaultRoute
|
||
}
|
||
|
||
export function reloadCurrentRoute() {
|
||
loadRoute()
|
||
}
|