fix: 修复所有页面 loading 动画未正确移除的问题

- chat-debug.js: loadDebugInfo 完成后正确调用 renderDebugInfo 移除 loading
- agents.js: loadAgents 失败时显示错误信息替代 loading
- dashboard.js: renderLogs 无日志时显示提示信息
- memory.js: loadFiles 失败时显示错误信息
- services.js: loadServices/loadRegistry/loadBackups 添加 loading 状态并在完成/失败时移除
- extensions.js: loadCftunnel/loadClawapp 添加 loading 状态并在完成/失败时移除
- models.js: loadConfig 添加 loading 状态并在失败时显示错误
- gateway.js: loadConfig 添加 loading 状态并在失败时显示错误
- logs.js: loadLog/searchLog 使用 loading-text 样式并在失败时显示错误

确保所有异步加载函数都:
1. 开始时显示 loading 状态
2. 成功时渲染数据(自动移除 loading)
3. 失败时显示错误信息(替代 loading)
This commit is contained in:
晴天
2026-03-03 01:46:19 +08:00
parent 53f46d8ef2
commit 05771ffa63
23 changed files with 1014 additions and 124 deletions

View File

@@ -39,6 +39,7 @@ const NAV_ITEMS_FULL = [
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
]
}
]
@@ -60,6 +61,7 @@ const NAV_ITEMS_SETUP = [
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
]
}
]
@@ -76,6 +78,7 @@ const ICONS = {
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
}
let _delegated = false

View File

@@ -65,10 +65,10 @@ export async function refreshGatewayStatus() {
}
let _pollTimer = null
/** 启动 Gateway 状态轮询(每 5 秒) */
/** 启动 Gateway 状态轮询(每 15 秒,避免过于频繁 */
export function startGatewayPoll() {
if (_pollTimer) return
_pollTimer = setInterval(() => refreshGatewayStatus(), 5000)
_pollTimer = setInterval(() => refreshGatewayStatus(), 15000)
}
export function stopGatewayPoll() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }

View File

@@ -10,12 +10,74 @@ const _invokeReady = isTauri
? import('@tauri-apps/api/core').then(m => m.invoke)
: null
// 简单缓存:避免页面切换时重复请求后端
const _cache = new Map()
const CACHE_TTL = 15000 // 15秒
// 网络请求日志(用于调试)
const _requestLogs = []
const MAX_LOGS = 100
function logRequest(cmd, args, duration, cached = false) {
const log = {
timestamp: Date.now(),
time: new Date().toLocaleTimeString('zh-CN', { hour12: false, fractionalSecondDigits: 3 }),
cmd,
args: JSON.stringify(args),
duration: duration ? `${duration}ms` : '-',
cached
}
_requestLogs.push(log)
if (_requestLogs.length > MAX_LOGS) {
_requestLogs.shift()
}
}
// 导出日志供调试页面使用
export function getRequestLogs() {
return _requestLogs.slice()
}
export function clearRequestLogs() {
_requestLogs.length = 0
}
function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
const key = cmd + JSON.stringify(args)
const cached = _cache.get(key)
if (cached && Date.now() - cached.ts < ttl) {
logRequest(cmd, args, 0, true)
return Promise.resolve(cached.val)
}
const start = Date.now()
return invoke(cmd, args).then(val => {
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
_cache.set(key, { val, ts: Date.now() })
return val
})
}
// 清除指定命令的缓存(写操作后调用)
function invalidate(...cmds) {
for (const [k] of _cache) {
if (cmds.some(c => k.startsWith(c))) _cache.delete(k)
}
}
async function invoke(cmd, args = {}) {
const start = Date.now()
if (_invokeReady) {
const tauriInvoke = await _invokeReady
return tauriInvoke(cmd, args)
const result = await tauriInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
}
return mockInvoke(cmd, args)
const result = mockInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
}
// Mock 数据,方便纯浏览器开发调试
@@ -136,64 +198,68 @@ function mockInvoke(cmd, args) {
// 导出 API
export const api = {
// 服务管理
getServicesStatus: () => invoke('get_services_status'),
startService: (label) => invoke('start_service', { label }),
stopService: (label) => invoke('stop_service', { label }),
restartService: (label) => invoke('restart_service', { label }),
// 服务管理(状态用短缓存,操作不缓存)
getServicesStatus: () => cachedInvoke('get_services_status', {}, 3000),
startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) },
stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) },
restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) },
// 配置
getVersionInfo: () => invoke('get_version_info'),
readOpenclawConfig: () => invoke('read_openclaw_config'),
writeOpenclawConfig: (config) => invoke('write_openclaw_config', { config }),
readMcpConfig: () => invoke('read_mcp_config'),
writeMcpConfig: (config) => invoke('write_mcp_config', { config }),
// 配置(读缓存,写清缓存)
getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000),
readOpenclawConfig: () => cachedInvoke('read_openclaw_config'),
writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) },
readMcpConfig: () => cachedInvoke('read_mcp_config'),
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
reloadGateway: () => invoke('reload_gateway'),
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
installGateway: () => invoke('install_gateway'),
uninstallGateway: () => invoke('uninstall_gateway'),
getNpmRegistry: () => invoke('get_npm_registry'),
setNpmRegistry: (registry) => invoke('set_npm_registry', { registry }),
getNpmRegistry: () => cachedInvoke('get_npm_registry', {}, 30000),
setNpmRegistry: (registry) => { invalidate('get_npm_registry'); return invoke('set_npm_registry', { registry }) },
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }),
listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }),
// Agent 管理
listAgents: () => invoke('list_agents'),
addAgent: (name, model, workspace) => invoke('add_agent', { name, model, workspace: workspace || null }),
deleteAgent: (id) => invoke('delete_agent', { id }),
updateAgentIdentity: (id, name, emoji) => invoke('update_agent_identity', { id, name, emoji }),
listAgents: () => cachedInvoke('list_agents'),
addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) },
deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) },
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) },
backupAgent: (id) => invoke('backup_agent', { id }),
// 日志
readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }),
// 日志(短缓存)
readLogTail: (logName, lines = 100) => cachedInvoke('read_log_tail', { logName, lines }, 5000),
searchLog: (logName, query, maxResults = 50) => invoke('search_log', { logName, query, maxResults }),
// 记忆文件
listMemoryFiles: (category, agentId) => invoke('list_memory_files', { category, agent_id: agentId || null }),
readMemoryFile: (path, agentId) => invoke('read_memory_file', { path, agent_id: agentId || null }),
writeMemoryFile: (path, content, category, agentId) => invoke('write_memory_file', { path, content, category: category || 'memory', agent_id: agentId || null }),
deleteMemoryFile: (path, agentId) => invoke('delete_memory_file', { path, agent_id: agentId || null }),
listMemoryFiles: (category, agentId) => cachedInvoke('list_memory_files', { category, agent_id: agentId || null }),
readMemoryFile: (path, agentId) => cachedInvoke('read_memory_file', { path, agent_id: agentId || null }, 5000),
writeMemoryFile: (path, content, category, agentId) => { invalidate('list_memory_files', 'read_memory_file'); return invoke('write_memory_file', { path, content, category: category || 'memory', agent_id: agentId || null }) },
deleteMemoryFile: (path, agentId) => { invalidate('list_memory_files'); return invoke('delete_memory_file', { path, agent_id: agentId || null }) },
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agent_id: agentId || null }),
// 安装/部署
checkInstallation: () => invoke('check_installation'),
checkNode: () => invoke('check_node'),
getDeployConfig: () => invoke('get_deploy_config'),
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
checkNode: () => cachedInvoke('check_node', {}, 60000),
getDeployConfig: () => cachedInvoke('get_deploy_config'),
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),
// 备份管理
listBackups: () => invoke('list_backups'),
createBackup: () => invoke('create_backup'),
listBackups: () => cachedInvoke('list_backups'),
createBackup: () => { invalidate('list_backups'); return invoke('create_backup') },
restoreBackup: (name) => invoke('restore_backup', { name }),
deleteBackup: (name) => invoke('delete_backup', { name }),
deleteBackup: (name) => { invalidate('list_backups'); return invoke('delete_backup', { name }) },
// 扩展工具
getCftunnelStatus: () => invoke('get_cftunnel_status'),
cftunnelAction: (action) => invoke('cftunnel_action', { action }),
getCftunnelLogs: (lines = 20) => invoke('get_cftunnel_logs', { lines }),
getClawappStatus: () => invoke('get_clawapp_status'),
getCftunnelStatus: () => cachedInvoke('get_cftunnel_status', {}, 10000),
cftunnelAction: (action) => { invalidate('get_cftunnel_status'); return invoke('cftunnel_action', { action }) },
getCftunnelLogs: (lines = 20) => cachedInvoke('get_cftunnel_logs', { lines }, 5000),
getClawappStatus: () => cachedInvoke('get_clawapp_status', {}, 5000),
installCftunnel: () => invoke('install_cftunnel'),
// 设备密钥 + Gateway 握手
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),
// 设备配对
autoPairDevice: () => invoke('auto_pair_device'),
checkPairingStatus: () => invoke('check_pairing_status'),
}

View File

@@ -162,7 +162,16 @@ export class WsClient {
this._handshaking = false
if (!msg.ok || msg.error) {
const errMsg = msg.error?.message || 'Gateway 握手失败'
console.error('[ws] connect 失败:', errMsg)
const errCode = msg.error?.code
console.error('[ws] connect 失败:', errMsg, errCode)
// 如果是配对错误,尝试自动配对
if (errCode === 'NOT_PAIRED' || errCode === 'PAIRING_REQUIRED') {
console.log('[ws] 检测到未配对,尝试自动配对...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', errMsg)
this._readyCallbacks.forEach(fn => {
try { fn(null, null, { error: true, message: errMsg }) } catch {}
@@ -194,6 +203,25 @@ export class WsClient {
}
}
async _autoPairAndReconnect() {
try {
console.log('[ws] 检测到未配对,执行自动配对...')
const result = await api.autoPairDevice()
console.log('[ws] 配对结果:', result)
// 配对成功后直接重连,不需要重启 Gateway
console.log('[ws] 配对成功2秒后重新连接...')
setTimeout(() => {
if (!this._intentionalClose) {
this.reconnect()
}
}, 2000)
} catch (e) {
console.error('[ws] 自动配对失败:', e)
this._setConnected(false, 'error', `配对失败: ${e}`)
}
}
async _sendConnectFrame(nonce) {
this._handshaking = true
try {

View File

@@ -5,6 +5,7 @@ import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.j
import { renderSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll } from './lib/app-state.js'
import { wsClient } from './lib/ws-client.js'
import { api } from './lib/tauri-api.js'
// 样式
@@ -15,6 +16,7 @@ import './style/components.css'
import './style/pages.css'
import './style/chat.css'
import './style/agents.css'
import './style/debug.css'
// 初始化主题
initTheme()
@@ -23,40 +25,69 @@ const sidebar = document.getElementById('sidebar')
const content = document.getElementById('content')
async function boot() {
await detectOpenclawStatus()
if (isOpenclawReady()) {
// 正常模式:注册所有页面
registerRoute('/dashboard', () => import('./pages/dashboard.js'))
registerRoute('/chat', () => import('./pages/chat.js'))
registerRoute('/services', () => import('./pages/services.js'))
registerRoute('/logs', () => import('./pages/logs.js'))
registerRoute('/models', () => import('./pages/models.js'))
registerRoute('/agents', () => import('./pages/agents.js'))
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/about', () => import('./pages/about.js'))
} else {
// 未安装模式:只注册 setup、extensions、about
setDefaultRoute('/setup')
registerRoute('/setup', () => import('./pages/setup.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/about', () => import('./pages/about.js'))
}
// 先注册所有路由,立即渲染 UI不等后端检测
registerRoute('/dashboard', () => import('./pages/dashboard.js'))
registerRoute('/chat', () => import('./pages/chat.js'))
registerRoute('/chat-debug', () => import('./pages/chat-debug.js'))
registerRoute('/services', () => import('./pages/services.js'))
registerRoute('/logs', () => import('./pages/logs.js'))
registerRoute('/models', () => import('./pages/models.js'))
registerRoute('/agents', () => import('./pages/agents.js'))
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
renderSidebar(sidebar)
initRouter(content)
// 未安装时强制跳转 setup
if (!isOpenclawReady()) {
navigate('/setup')
return
}
// 后台检测状态,检测完再决定是否跳转 setup
detectOpenclawStatus().then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
renderSidebar(sidebar)
if (!isOpenclawReady()) {
setDefaultRoute('/setup')
navigate('/setup')
} else {
if (window.location.hash === '#/setup') navigate('/dashboard')
setupGatewayBanner()
startGatewayPoll()
// Gateway 未启动引导横幅
setupGatewayBanner()
startGatewayPoll()
// 自动连接 WebSocket如果 Gateway 正在运行)
if (isGatewayRunning()) {
autoConnectWebSocket()
}
// 监听 Gateway 状态变化,自动连接/断开 WebSocket
onGatewayChange((running) => {
if (running) {
autoConnectWebSocket()
} else {
wsClient.close()
}
})
}
})
}
async function autoConnectWebSocket() {
try {
console.log('[main] 自动连接 WebSocket...')
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
if (!token) {
console.warn('[main] Gateway token 未设置,跳过 WebSocket 连接')
return
}
wsClient.connect(`ws://127.0.0.1:${port}/ws`, token)
console.log('[main] WebSocket 连接已启动')
} catch (e) {
console.error('[main] 自动连接 WebSocket 失败:', e)
}
}
function setupGatewayBanner() {

View File

@@ -35,10 +35,12 @@ export async function render() {
}
async function loadAgents(page, state) {
const container = page.querySelector('#agents-list')
try {
state.agents = await api.listAgents()
renderAgents(page, state)
} catch (e) {
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + e + '</div>'
toast('加载 Agent 列表失败: ' + e, 'error')
}
}

559
src/pages/chat-debug.js Normal file
View File

@@ -0,0 +1,559 @@
/**
* 系统诊断页面
* 全面检测 ClawPanel 各项功能状态,快速定位问题
*/
import { api, getRequestLogs, clearRequestLogs } from '../lib/tauri-api.js'
import { wsClient } from '../lib/ws-client.js'
import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">系统诊断</h1>
<p class="page-desc">全面检测系统状态,快速定位问题</p>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btn-refresh">刷新状态</button>
<button class="btn btn-secondary btn-sm" id="btn-test-ws">测试 WebSocket</button>
<button class="btn btn-secondary btn-sm" id="btn-network-log">网络日志</button>
<button class="btn btn-warning btn-sm" id="btn-fix-pairing">一键修复配对</button>
</div>
</div>
<div id="debug-content"></div>
<div id="ws-test-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span>WebSocket 连接测试</span>
<button class="btn btn-sm" id="btn-clear-log" style="padding:4px 8px;font-size:11px">清空</button>
</div>
<pre id="ws-log-content" style="font-size:11px;line-height:1.5;max-height:400px;overflow:auto;margin:0;color:var(--text-primary)"></pre>
</div>
<div id="network-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span>网络请求日志(最近 100 条)</span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm" id="btn-refresh-network" style="padding:4px 8px;font-size:11px">刷新</button>
<button class="btn btn-sm" id="btn-clear-network" style="padding:4px 8px;font-size:11px">清空</button>
</div>
</div>
<div id="network-log-content" style="font-size:11px;line-height:1.5;max-height:400px;overflow:auto"></div>
</div>
`
page.querySelector('#btn-refresh').addEventListener('click', () => loadDebugInfo(page))
page.querySelector('#btn-test-ws').addEventListener('click', () => testWebSocket(page))
page.querySelector('#btn-network-log').addEventListener('click', () => toggleNetworkLog(page))
page.querySelector('#btn-fix-pairing').addEventListener('click', () => fixPairing(page))
loadDebugInfo(page)
return page
}
async function loadDebugInfo(page) {
const el = page.querySelector('#debug-content')
el.innerHTML = '<div class="loading-text">检测中...</div>'
const info = {
timestamp: new Date().toLocaleString('zh-CN'),
// 应用状态
appState: {
openclawReady: isOpenclawReady(),
gatewayRunning: isGatewayRunning(),
},
// WebSocket 状态
wsClient: {
connected: wsClient.connected,
gatewayReady: wsClient.gatewayReady,
sessionKey: wsClient.sessionKey,
},
// 配置文件
config: null,
configError: null,
// 服务状态
services: null,
servicesError: null,
// 版本信息
version: null,
versionError: null,
// Node.js 环境
node: null,
nodeError: null,
// 设备密钥
connectFrame: null,
connectFrameError: null,
}
// 并行检测所有项目
await Promise.allSettled([
// 配置文件
api.readOpenclawConfig().then(r => { info.config = r }).catch(e => { info.configError = String(e) }),
// 服务状态
api.getServicesStatus().then(r => { info.services = r }).catch(e => { info.servicesError = String(e) }),
// 版本信息
api.getVersionInfo().then(r => { info.version = r }).catch(e => { info.versionError = String(e) }),
// Node.js
api.checkNode().then(r => { info.node = r }).catch(e => { info.nodeError = String(e) }),
])
// 设备密钥检测(需要等配置加载完成)
try {
const token = info.config?.gateway?.auth?.token || ''
info.connectFrame = await api.createConnectFrame('test-nonce', token)
} catch (e) {
info.connectFrameError = String(e)
}
// 移除 loading 状态并渲染结果
renderDebugInfo(el, info)
}
function renderDebugInfo(el, info) {
let html = `<div style="font-family:monospace;font-size:12px;line-height:1.6">`
// 总体状态概览
const allOk = info.appState.openclawReady && info.appState.gatewayRunning && info.wsClient.gatewayReady
html += `<div class="config-section" style="background:${allOk ? 'var(--success-bg)' : 'var(--warning-bg)'};border-left:3px solid ${allOk ? 'var(--success)' : 'var(--warning)'}">
<div style="font-size:16px;font-weight:600;margin-bottom:8px">${allOk ? '✅ 系统正常' : '⚠️ 发现问题'}</div>
<div style="color:var(--text-secondary);font-size:13px">${allOk ? '所有核心功能运行正常' : '部分功能异常,请查看下方详情'}</div>
</div>`
// 应用状态
html += `<div class="config-section">
<div class="config-section-title">应用状态</div>
<table class="debug-table">
<tr><td>OpenClaw 就绪</td><td>${info.appState.openclawReady ? '✅' : '❌'}</td></tr>
<tr><td>Gateway 运行中</td><td>${info.appState.gatewayRunning ? '✅' : '❌'}</td></tr>
</table>
</div>`
// WebSocket 状态
html += `<div class="config-section">
<div class="config-section-title">WebSocket 连接</div>
<table class="debug-table">
<tr><td>连接状态</td><td>${info.wsClient.connected ? '✅ 已连接' : '❌ 未连接'}</td></tr>
<tr><td>握手状态</td><td>${info.wsClient.gatewayReady ? '✅ 已完成' : '❌ 未完成'}</td></tr>
<tr><td>会话密钥</td><td>${info.wsClient.sessionKey || '(空)'}</td></tr>
</table>
</div>`
// Node.js 环境
html += `<div class="config-section">
<div class="config-section-title">Node.js 环境</div>`
if (info.nodeError) {
html += `<div style="color:var(--error)">❌ ${escapeHtml(info.nodeError)}</div>`
} else if (info.node) {
html += `<table class="debug-table">
<tr><td>安装状态</td><td>${info.node.installed ? '✅ 已安装' : '❌ 未安装'}</td></tr>
<tr><td>版本</td><td>${info.node.version || '(未知)'}</td></tr>
</table>`
}
html += `</div>`
// 版本信息
html += `<div class="config-section">
<div class="config-section-title">版本信息</div>`
if (info.versionError) {
html += `<div style="color:var(--error)">❌ ${escapeHtml(info.versionError)}</div>`
} else if (info.version) {
html += `<table class="debug-table">
<tr><td>当前版本</td><td>${info.version.current || '(未知)'}</td></tr>
<tr><td>最新版本</td><td>${info.version.latest || '(未检测)'}</td></tr>
<tr><td>更新可用</td><td>${info.version.update_available ? '⚠️ 有新版本' : '✅ 已是最新'}</td></tr>
</table>`
}
html += `</div>`
// 配置文件
html += `<div class="config-section">
<div class="config-section-title">配置文件</div>`
if (info.configError) {
html += `<div style="color:var(--error)">❌ ${escapeHtml(info.configError)}</div>`
} else if (info.config) {
const gw = info.config.gateway || {}
html += `<table class="debug-table">
<tr><td>gateway.port</td><td>${gw.port || '(未设置)'}</td></tr>
<tr><td>gateway.auth.token</td><td>${gw.auth?.token ? '✅ 已设置' : '⚠️ 未设置'}</td></tr>
<tr><td>gateway.enabled</td><td>${gw.enabled !== false ? '✅' : '❌'}</td></tr>
<tr><td>gateway.mode</td><td>${gw.mode || 'local'}</td></tr>
</table>`
}
html += `</div>`
// 服务状态
html += `<div class="config-section">
<div class="config-section-title">服务状态</div>`
if (info.servicesError) {
html += `<div style="color:var(--error)">❌ ${escapeHtml(info.servicesError)}</div>`
} else if (info.services?.length > 0) {
const svc = info.services[0]
html += `<table class="debug-table">
<tr><td>CLI 安装</td><td>${svc.cli_installed !== false ? '✅ 已安装' : '❌ 未安装'}</td></tr>
<tr><td>运行状态</td><td>${svc.running ? '✅ 运行中' : '❌ 已停止'}</td></tr>
<tr><td>进程 PID</td><td>${svc.pid || '(无)'}</td></tr>
<tr><td>服务标签</td><td>${svc.label || '(未知)'}</td></tr>
</table>`
}
html += `</div>`
// 设备密钥
html += `<div class="config-section">
<div class="config-section-title">设备密钥 & 握手签名</div>`
if (info.connectFrameError) {
html += `<div style="color:var(--error)">❌ ${escapeHtml(info.connectFrameError)}</div>`
} else if (info.connectFrame) {
const device = info.connectFrame.params?.device
html += `<div style="color:var(--success);margin-bottom:8px">✅ 设备密钥生成成功</div>
<table class="debug-table">
<tr><td>设备 ID</td><td style="font-size:10px;word-break:break-all">${device?.id || '(无)'}</td></tr>
<tr><td>公钥</td><td style="font-size:10px;word-break:break-all">${device?.publicKey ? device.publicKey.substring(0, 32) + '...' : '(无)'}</td></tr>
<tr><td>签名时间</td><td>${device?.signedAt || '(无)'}</td></tr>
</table>
<details style="margin-top:8px">
<summary style="cursor:pointer;color:var(--text-secondary);font-size:12px">查看完整 Connect Frame</summary>
<pre style="background:var(--bg-secondary);padding:8px;border-radius:4px;overflow:auto;max-height:300px;font-size:11px">${escapeHtml(JSON.stringify(info.connectFrame, null, 2))}</pre>
</details>`
}
html += `</div>`
// 诊断建议
html += `<div class="config-section">
<div class="config-section-title">诊断建议</div>
<ul style="margin:0;padding-left:20px;color:var(--text-secondary);font-size:13px">`
if (!info.node?.installed) {
html += `<li style="color:var(--error);margin-bottom:6px">❌ Node.js 未安装,请先安装 Node.js<a href="https://nodejs.org/" target="_blank" rel="noopener">下载地址</a></li>`
}
if (info.configError) {
html += `<li style="color:var(--error);margin-bottom:6px">❌ 配置文件不存在或损坏,请前往"初始设置"页面完成配置</li>`
}
if (info.servicesError || !info.services?.length || info.services[0]?.cli_installed === false) {
html += `<li style="color:var(--error);margin-bottom:6px">❌ OpenClaw CLI 未安装,请前往"初始设置"页面安装</li>`
}
if (info.services?.length > 0 && !info.services[0]?.running) {
html += `<li style="color:var(--warning);margin-bottom:6px">⚠️ Gateway 未启动,请前往"服务管理"页面启动服务</li>`
}
if (info.config && !info.config.gateway?.auth?.token) {
html += `<li style="color:var(--warning);margin-bottom:6px">⚠️ Gateway token 未设置(本地开发可选,生产环境建议设置)</li>`
}
if (info.connectFrameError) {
html += `<li style="color:var(--error);margin-bottom:6px">❌ 设备密钥生成失败,请检查 Rust 后端日志</li>`
}
if (!info.wsClient.connected && info.services?.length > 0 && info.services[0]?.running) {
html += `<li style="color:var(--warning);margin-bottom:6px">⚠️ Gateway 已启动但 WebSocket 未连接,请检查端口 ${info.config?.gateway?.port || 18789} 是否被占用</li>`
}
if (info.wsClient.connected && !info.wsClient.gatewayReady) {
html += `<li style="color:var(--warning);margin-bottom:6px">⚠️ WebSocket 已连接但握手未完成,请检查 token 是否正确</li>`
}
if (allOk) {
html += `<li style="color:var(--success);margin-bottom:6px">✅ 所有检测项正常,系统运行良好</li>`
}
html += `</ul></div>`
html += `<div style="margin-top:16px;padding:8px;background:var(--bg-secondary);border-radius:4px;font-size:11px;color:var(--text-tertiary)">检测时间: ${info.timestamp}</div>`
html += `</div>`
el.innerHTML = html
}
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// WebSocket 连接测试
let testWs = null
let testLogs = []
function testWebSocket(page) {
const logEl = page.querySelector('#ws-test-log')
const contentEl = page.querySelector('#ws-log-content')
const clearBtn = page.querySelector('#btn-clear-log')
logEl.style.display = 'block'
testLogs = []
clearBtn.onclick = () => {
testLogs = []
contentEl.textContent = ''
}
addLog('🔍 开始 WebSocket 连接测试...')
// 关闭旧连接
if (testWs) {
testWs.close()
testWs = null
}
// 读取配置
api.readOpenclawConfig().then(config => {
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
addLog(`📡 连接地址: ${url}`)
addLog(`🔑 Token: ${token ? token.substring(0, 20) + '...' : '(空)'}`)
addLog(`⏳ 正在连接...`)
try {
testWs = new WebSocket(url)
testWs.onopen = () => {
addLog('✅ WebSocket 连接成功')
addLog('⏳ 等待 Gateway 发送 connect.challenge...')
}
testWs.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data)
addLog(`📥 收到消息: ${JSON.stringify(msg, null, 2)}`)
// 如果收到 challenge尝试发送 connect frame
if (msg.type === 'event' && msg.event === 'connect.challenge') {
const nonce = msg.payload?.nonce || ''
addLog(`🔐 收到 challenge, nonce: ${nonce}`)
addLog(`⏳ 生成 connect frame...`)
api.createConnectFrame(nonce, token).then(frame => {
addLog(`✅ Connect frame 生成成功`)
addLog(`📤 发送 connect frame: ${JSON.stringify(frame, null, 2)}`)
testWs.send(JSON.stringify(frame))
}).catch(e => {
addLog(`❌ 生成 connect frame 失败: ${e}`)
})
}
// 如果收到 connect 响应
if (msg.type === 'res' && msg.id?.startsWith('connect-')) {
if (msg.ok) {
addLog(`✅ 握手成功!`)
addLog(`📊 Snapshot: ${JSON.stringify(msg.payload, null, 2)}`)
const sessionKey = msg.payload?.snapshot?.sessionDefaults?.mainSessionKey
if (sessionKey) {
addLog(`🔑 Session Key: ${sessionKey}`)
}
} else {
addLog(`❌ 握手失败: ${msg.error?.message || msg.error?.code || '未知错误'}`)
}
}
} catch (e) {
addLog(`⚠️ 解析消息失败: ${e}`)
addLog(`📥 原始数据: ${evt.data}`)
}
}
testWs.onerror = (e) => {
addLog(`❌ WebSocket 错误: ${e.type}`)
}
testWs.onclose = (e) => {
addLog(`🔌 连接关闭 - Code: ${e.code}, Reason: ${e.reason || '(空)'}`)
if (e.code === 4001) {
addLog(`❌ 认证失败 (4001) - Token 可能不正确`)
} else if (e.code === 1006) {
addLog(`⚠️ 异常关闭 (1006) - 可能是网络问题或 Gateway 主动断开`)
}
testWs = null
}
} catch (e) {
addLog(`❌ 创建 WebSocket 失败: ${e}`)
}
}).catch(e => {
addLog(`❌ 读取配置失败: ${e}`)
})
function addLog(msg) {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
const line = `[${timestamp}] ${msg}`
testLogs.push(line)
contentEl.textContent = testLogs.join('\n')
contentEl.scrollTop = contentEl.scrollHeight
}
}
// 网络日志功能
function toggleNetworkLog(page) {
const logEl = page.querySelector('#network-log')
const contentEl = page.querySelector('#network-log-content')
const refreshBtn = page.querySelector('#btn-refresh-network')
const clearBtn = page.querySelector('#btn-clear-network')
if (logEl.style.display === 'none') {
logEl.style.display = 'block'
renderNetworkLog(contentEl)
} else {
logEl.style.display = 'none'
}
refreshBtn.onclick = () => renderNetworkLog(contentEl)
clearBtn.onclick = () => {
clearRequestLogs()
renderNetworkLog(contentEl)
}
}
function renderNetworkLog(contentEl) {
const logs = getRequestLogs()
if (logs.length === 0) {
contentEl.innerHTML = '<div style="color:var(--text-secondary);padding:8px">暂无请求记录</div>'
return
}
// 统计信息
const total = logs.length
const cached = logs.filter(l => l.cached).length
const avgDuration = logs.filter(l => !l.cached).reduce((sum, l) => {
const ms = parseInt(l.duration)
return sum + (isNaN(ms) ? 0 : ms)
}, 0) / (total - cached || 1)
let html = `
<div style="padding:8px;background:var(--bg-primary);border-radius:4px;margin-bottom:8px;font-size:12px">
<div style="display:flex;gap:16px">
<span>总请求: <strong>${total}</strong></span>
<span>缓存命中: <strong>${cached}</strong></span>
<span>平均耗时: <strong>${avgDuration.toFixed(0)}ms</strong></span>
</div>
</div>
<table class="debug-table" style="width:100%;font-size:11px">
<thead>
<tr style="background:var(--bg-primary)">
<th style="padding:6px;text-align:left;width:80px">时间</th>
<th style="padding:6px;text-align:left">命令</th>
<th style="padding:6px;text-align:left;max-width:200px">参数</th>
<th style="padding:6px;text-align:right;width:80px">耗时</th>
<th style="padding:6px;text-align:center;width:60px">缓存</th>
</tr>
</thead>
<tbody>
`
// 倒序显示(最新的在上面)
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i]
const cachedIcon = log.cached ? '✅' : '-'
const durationColor = log.cached ? 'var(--text-tertiary)' :
(parseInt(log.duration) > 1000 ? 'var(--error)' :
(parseInt(log.duration) > 500 ? 'var(--warning)' : 'var(--text-primary)'))
html += `
<tr>
<td style="padding:4px;color:var(--text-tertiary)">${log.time}</td>
<td style="padding:4px;font-family:monospace">${escapeHtml(log.cmd)}</td>
<td style="padding:4px;font-family:monospace;font-size:10px;color:var(--text-secondary);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(log.args)}">${escapeHtml(log.args)}</td>
<td style="padding:4px;text-align:right;color:${durationColor}">${log.duration}</td>
<td style="padding:4px;text-align:center">${cachedIcon}</td>
</tr>
`
}
html += `</tbody></table>`
contentEl.innerHTML = html
}
// 一键修复配对问题
async function fixPairing(page) {
const logEl = page.querySelector('#ws-test-log')
const contentEl = page.querySelector('#ws-log-content')
logEl.style.display = 'block'
testLogs = []
function addLog(msg) {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
const line = `[${timestamp}] ${msg}`
testLogs.push(line)
contentEl.textContent = testLogs.join('\n')
contentEl.scrollTop = contentEl.scrollHeight
}
try {
addLog('🔧 开始修复配对问题...')
// 1. 修改配置禁用配对
addLog('📝 修改配置文件,禁用配对要求...')
const result = await api.autoPairDevice()
addLog(`${result}`)
// 2. 重启 Gateway
addLog('🔄 重启 Gateway 服务...')
await api.restartService('ai.openclaw.gateway')
addLog('✅ Gateway 重启命令已发送')
// 3. 等待 Gateway 启动
addLog('⏳ 等待 Gateway 启动8秒...')
await new Promise(resolve => setTimeout(resolve, 8000))
// 4. 检查 Gateway 状态
addLog('🔍 检查 Gateway 状态...')
const services = await api.getServicesStatus()
const running = services?.[0]?.running
if (running) {
addLog('✅ Gateway 已启动')
} else {
addLog('⚠️ Gateway 可能还在启动中,请稍后手动测试')
}
// 5. 测试 WebSocket 连接
addLog('🔌 测试 WebSocket 连接...')
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)
ws.onopen = () => {
addLog('✅ WebSocket 连接成功')
}
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data)
if (msg.type === 'event' && msg.event === 'connect.challenge') {
addLog('✅ 收到 connect.challenge')
const nonce = msg.payload?.nonce || ''
api.createConnectFrame(nonce, token).then(frame => {
ws.send(JSON.stringify(frame))
addLog('📤 已发送 connect frame')
})
}
if (msg.type === 'res' && msg.id?.startsWith('connect-')) {
if (msg.ok) {
addLog('🎉 握手成功!配对问题已修复!')
addLog('💡 提示:现在可以正常使用 WebSocket 功能了')
ws.close()
} else {
addLog(`❌ 握手失败: ${msg.error?.message || '未知错误'}`)
addLog('💡 建议:请手动重启 Gateway 或联系技术支持')
}
}
} catch (e) {
addLog(`⚠️ 解析消息失败: ${e}`)
}
}
ws.onerror = () => {
addLog('❌ WebSocket 连接失败')
}
ws.onclose = (e) => {
if (e.code !== 1000) {
addLog(`⚠️ 连接关闭 - Code: ${e.code}`)
}
}
} catch (e) {
addLog(`❌ 修复失败: ${e}`)
addLog('💡 建议:请手动前往"服务管理"页面重启 Gateway')
}
}

View File

@@ -231,7 +231,10 @@ function renderOverview(page, services, clawapp, tunnel, mcpConfig, backups, con
function renderLogs(page, logs) {
const logsEl = page.querySelector('#recent-logs')
if (!logs) { logsEl.textContent = '暂无日志'; return }
if (!logs) {
logsEl.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无日志</div>'
return
}
const lines = logs.trim().split('\n')
logsEl.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
logsEl.scrollTop = logsEl.scrollHeight

View File

@@ -52,6 +52,7 @@ async function loadAll(page) {
async function loadCftunnel(page) {
const el = page.querySelector('#cftunnel-content')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const status = await api.getCftunnelStatus()
renderCftunnel(el, status)
@@ -145,6 +146,7 @@ function renderRoutes(routes) {
async function loadClawapp(page) {
const el = page.querySelector('#clawapp-content')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const status = await api.getClawappStatus()
renderClawapp(el, status)

View File

@@ -41,10 +41,13 @@ export async function render() {
}
async function loadConfig(page, state) {
const el = page.querySelector('#gateway-config')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
state.config = await api.readOpenclawConfig()
renderConfig(page, state)
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:20px">加载配置失败: ' + e + '</div>'
toast('加载配置失败: ' + e, 'error')
}
}

View File

@@ -75,7 +75,7 @@ export function cleanup() {
async function loadLog(page, logName) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div style="color:var(--text-tertiary)">加载中...</div>'
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const content = await api.readLogTail(logName, 200)
if (!content || !content.trim()) {
@@ -88,13 +88,14 @@ async function loadLog(page, logName) {
el.scrollTop = el.scrollHeight
}
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">加载日志失败: ' + e + '</div>'
toast('加载日志失败: ' + e, 'error')
}
}
async function searchLog(page, logName, query) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div style="color:var(--text-tertiary)">搜索中...</div>'
el.innerHTML = '<div class="loading-text">搜索中...</div>'
try {
const results = await api.searchLog(logName, query)
if (!results || !results.length) {
@@ -103,6 +104,7 @@ async function searchLog(page, logName, query) {
}
el.innerHTML = results.map(l => `<div class="log-line">${highlightMatch(escapeHtml(l), query)}</div>`).join('')
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">搜索失败: ' + e + '</div>'
toast('搜索失败: ' + e, 'error')
}
}

View File

@@ -151,6 +151,7 @@ async function loadFiles(page, state) {
}
renderFileTree(page, state, files)
} catch (e) {
tree.innerHTML = '<div style="color:var(--error);padding:12px">加载失败: ' + e + '</div>'
toast('加载文件列表失败: ' + e, 'error')
}
}

View File

@@ -82,11 +82,14 @@ export async function render() {
}
async function loadConfig(page, state) {
const listEl = page.querySelector('#providers-list')
listEl.innerHTML = '<div class="loading-text">加载中...</div>'
try {
state.config = await api.readOpenclawConfig()
renderDefaultBar(page, state)
renderProviders(page, state)
} catch (e) {
listEl.innerHTML = '<div style="color:var(--error);padding:20px">加载配置失败: ' + e + '</div>'
toast('加载配置失败: ' + e, 'error')
}
}

View File

@@ -101,6 +101,7 @@ const REGISTRIES = [
async function loadRegistry(page) {
const bar = page.querySelector('#registry-bar')
bar.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const current = await api.getNpmRegistry()
const isPreset = REGISTRIES.some(r => r.value === current)
@@ -130,6 +131,7 @@ async function loadRegistry(page) {
async function loadServices(page) {
const container = page.querySelector('#services-list')
container.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const services = await api.getServicesStatus()
renderServices(container, services)
@@ -198,6 +200,7 @@ function renderServices(container, services) {
async function loadBackups(page) {
const list = page.querySelector('#backup-list')
list.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const backups = await api.listBackups()
renderBackups(list, backups)

View File

@@ -164,7 +164,7 @@ function renderInstallSection() {
function bindEvents(page, nodeOk) {
// 进入面板
page.querySelector('#btn-enter')?.addEventListener('click', () => {
window.location.reload()
window.location.hash = '/dashboard'
})
// 一键安装

View File

@@ -45,13 +45,8 @@ async function loadRoute() {
_currentCleanup = null
}
// 退出动画:如果有旧页面,播放退出动画后再替换
const oldPage = _contentEl.querySelector('.page, .page-loader, .chat-page')
if (oldPage) {
oldPage.classList.add('page-exit')
await new Promise(r => setTimeout(r, 100))
if (thisLoad !== _loadId) return
}
// 立即移除旧页面(不等退出动画,消除切换卡顿)
_contentEl.innerHTML = ''
// 已缓存的模块:跳过 spinner直接渲染
let mod = _moduleCache[hash]

58
src/style/debug.css Normal file
View File

@@ -0,0 +1,58 @@
/**
* 调试页面样式
*/
.debug-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.debug-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.debug-table td:first-child {
color: var(--text-secondary);
width: 200px;
font-weight: 500;
}
.debug-table td:last-child {
color: var(--text-primary);
font-family: monospace;
}
.debug-table tr:last-child td {
border-bottom: none;
}
/* 状态概览卡片 */
.config-section {
background: var(--bg-secondary);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.config-section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
/* 成功/警告背景色 */
:root {
--success-bg: rgba(34, 197, 94, 0.1);
--warning-bg: rgba(251, 191, 36, 0.1);
--error-bg: rgba(239, 68, 68, 0.1);
}
[data-theme="dark"] {
--success-bg: rgba(34, 197, 94, 0.15);
--warning-bg: rgba(251, 191, 36, 0.15);
--error-bg: rgba(239, 68, 68, 0.15);
}