/** * 仪表盘页面 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { onGatewayChange } from '../lib/app-state.js' import { navigate } from '../router.js' let _unsubGw = null export async function render() { const page = document.createElement('div') page.className = 'page' page.innerHTML = `
最近日志
` // 绑定事件(只绑一次) bindActions(page) // 异步加载数据 loadDashboardData(page) // 监听 Gateway 状态变化,自动刷新仪表盘 if (_unsubGw) _unsubGw() _unsubGw = onGatewayChange(() => { loadDashboardData(page) }) return page } export function cleanup() { if (_unsubGw) { _unsubGw(); _unsubGw = null } } async function loadDashboardData(page) { // 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待 const coreP = Promise.allSettled([ api.getServicesStatus(), api.getVersionInfo(), api.readOpenclawConfig(), ]) const secondaryP = Promise.allSettled([ api.listAgents(), api.readMcpConfig(), api.listBackups(), ]) const logsP = api.readLogTail('gateway', 20).catch(() => '') // 第一波:服务状态 + 版本 + 配置 → 立即渲染统计卡片 const [servicesRes, versionRes, configRes] = await coreP const services = servicesRes.status === 'fulfilled' ? servicesRes.value : [] const version = versionRes.status === 'fulfilled' ? versionRes.value : {} const config = configRes.status === 'fulfilled' ? configRes.value : null if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error') if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error') // 自愈:补全关键默认值 if (config) { let patched = false if (!config.gateway) config.gateway = {} if (!config.gateway.mode) { config.gateway.mode = 'local'; patched = true } // 修复旧版错误:mode 不应在顶层(OpenClaw 不认识) if (config.mode) { delete config.mode; patched = true } if (!config.tools || config.tools.profile !== 'full') { config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) } config.tools.profile = 'full' if (!config.tools.sessions) config.tools.sessions = {} config.tools.sessions.visibility = 'all' patched = true } if (patched) api.writeOpenclawConfig(config).catch(() => {}) } renderStatCards(page, services, version, [], config) // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览 const [agentsRes, mcpRes, backupsRes] = await secondaryP const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] renderStatCards(page, services, version, agents, config) renderOverview(page, services, mcpConfig, backups, config, agents) // 第三波:日志(最低优先级) const logs = await logsP renderLogs(page, logs) } function renderStatCards(page, services, version, agents, config) { const cardsEl = page.querySelector('#stat-cards') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const runningCount = services.filter(s => s.running).length const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main' const modelCount = config?.models?.providers ? Object.values(config.models.providers).reduce((acc, p) => acc + (p.models?.length || 0), 0) : 0 const providerCount = config?.models?.providers ? Object.keys(config.models.providers).length : 0 cardsEl.innerHTML = `
Gateway
${gw?.running ? '运行中' : '已停止'}
${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? '端口检测' : '未启动')}
版本 · ${version.source === 'official' ? '官方' : '汉化'}
${version.current || '未知'}
${version.update_available ? '有新版本: ' + version.latest : '已是最新'}
Agent 舰队
${agents.length} 个
默认: ${defaultAgent}
模型池
${modelCount} 个
基于 ${providerCount} 个渠道商
基础服务
${runningCount}/${services.length}
存活率 ${services.length ? Math.round(runningCount / services.length * 100) : 0}%
Control UI
OpenClaw 原生面板
${gw?.running ? '点击打开浏览器' : 'Gateway 未运行'}
` } function renderOverview(page, services, mcpConfig, backups, config, agents) { const containerEl = page.querySelector('#dashboard-overview-container') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0 const formatDate = (timestamp) => { if (!timestamp) return '——' const d = new Date(timestamp * 1000) const mon = d.getMonth() + 1 const day = d.getDate() const hr = d.getHours().toString().padStart(2, '0') const min = d.getMinutes().toString().padStart(2, '0') return mon + '-' + day + ' ' + hr + ':' + min } const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null const lastUpdate = config?.meta?.lastTouchedVersion || '未知' const gwPort = config?.gateway?.port || 18789 const primaryModel = config?.agents?.defaults?.model?.primary || '未设置' containerEl.innerHTML = `
Gateway
${gw?.running ? '运行中' : '已停止'}
端口 ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}
${gw?.running ? '' : '' }
主模型
${primaryModel}
并发上限 ${config?.agents?.defaults?.maxConcurrent || 4}
MCP 工具
${mcpCount} 个
已挂载扩展
最近备份
${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}
${backups.length} 个备份文件
Agent 舰队
${agents.length} 个
${agents.filter(a => a.workspace).length} 个独立工作区
配置版本
${lastUpdate}
openclaw.json
` // 概览卡片点击导航 containerEl.querySelectorAll('[data-nav]').forEach(card => { card.style.cursor = 'pointer' card.addEventListener('click', (e) => { if (e.target.closest('button')) return navigate(card.dataset.nav) }) }) } function renderLogs(page, logs) { const logsEl = page.querySelector('#recent-logs') if (!logs) { logsEl.innerHTML = '
暂无日志
' return } const lines = logs.trim().split('\n') logsEl.innerHTML = lines.map(l => `
${escapeHtml(l)}
`).join('') logsEl.scrollTop = logsEl.scrollHeight } function bindActions(page) { const btnRestart = page.querySelector('#btn-restart-gw') const btnUpdate = page.querySelector('#btn-check-update') const btnCreateBackup = page.querySelector('#btn-create-backup') // Control UI 卡片点击 → 打开 OpenClaw 原生面板(用事件委托,因为卡片是动态渲染的) page.addEventListener('click', async (e) => { const card = e.target.closest('#card-control-ui') if (!card) return if (e.target.closest('button')) return try { const config = await api.readOpenclawConfig() const port = config?.gateway?.port || 18789 // 远程部署时使用当前浏览器域名/IP,桌面版用 127.0.0.1 const host = window.__TAURI_INTERNALS__ ? '127.0.0.1' : (location.hostname || '127.0.0.1') const proto = location.protocol === 'https:' ? 'https' : 'http' let url = `${proto}://${host}:${port}` // 如果 Gateway 配置了 token 鉴权,附加到 URL 方便直接访问 const authToken = config?.gateway?.auth?.token if (authToken) url += `?token=${encodeURIComponent(authToken)}` // 尝试多种方式打开浏览器 if (window.__TAURI_INTERNALS__) { try { const { open } = await import('@tauri-apps/plugin-shell') await open(url) } catch { window.open(url, '_blank') } } else { window.open(url, '_blank') } } catch (e2) { toast('打开 Control UI 失败: ' + (e2.message || e2), 'error') } }) // 概览区域的 Gateway 启动/停止/重启 + ClawApp 导航 page.addEventListener('click', async (e) => { const actionBtn = e.target.closest('[data-action]') if (!actionBtn) return const action = actionBtn.dataset.action if (action === 'start-gw') { actionBtn.disabled = true; actionBtn.textContent = '启动中...' try { await api.startService('ai.openclaw.gateway') toast('Gateway 启动指令已发送', 'success') setTimeout(() => loadDashboardData(page), 2000) } catch (err) { toast('启动失败: ' + err, 'error') } finally { actionBtn.disabled = false; actionBtn.textContent = '启动' } } if (action === 'stop-gw') { actionBtn.disabled = true; actionBtn.textContent = '停止中...' try { await api.stopService('ai.openclaw.gateway') toast('Gateway 已停止', 'success') setTimeout(() => loadDashboardData(page), 1500) } catch (err) { toast('停止失败: ' + err, 'error') } finally { actionBtn.disabled = false; actionBtn.textContent = '停止' } } if (action === 'restart-gw') { actionBtn.disabled = true; actionBtn.textContent = '重启中...' try { await api.restartService('ai.openclaw.gateway') toast('Gateway 重启指令已发送', 'success') setTimeout(() => loadDashboardData(page), 3000) } catch (err) { toast('重启失败: ' + err, 'error') } finally { actionBtn.disabled = false; actionBtn.textContent = '重启' } } }) btnRestart?.addEventListener('click', async () => { btnRestart.disabled = true btnRestart.classList.add('btn-loading') btnRestart.textContent = '重启中...' try { await api.restartService('ai.openclaw.gateway') } catch (e) { toast('重启失败: ' + e, 'error') btnRestart.disabled = false btnRestart.classList.remove('btn-loading') btnRestart.textContent = '重启 Gateway' return } // 轮询等待实际重启完成 const t0 = Date.now() while (Date.now() - t0 < 30000) { try { const s = await api.getServicesStatus() const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0] if (gw?.running) { toast(`Gateway 已重启 (PID: ${gw.pid})`, 'success') btnRestart.disabled = false btnRestart.classList.remove('btn-loading') btnRestart.textContent = '重启 Gateway' loadDashboardData(page) return } } catch {} const sec = Math.floor((Date.now() - t0) / 1000) btnRestart.textContent = `重启中... ${sec}s` await new Promise(r => setTimeout(r, 1500)) } toast('重启超时,Gateway 可能仍在启动中', 'warning') btnRestart.disabled = false btnRestart.classList.remove('btn-loading') btnRestart.textContent = '重启 Gateway' loadDashboardData(page) }) btnUpdate?.addEventListener('click', async () => { btnUpdate.disabled = true btnUpdate.textContent = '检查中...' try { const info = await api.getVersionInfo() if (info.update_available) { toast(`发现新版本: ${info.latest}`, 'info') } else { toast('已是最新版本', 'success') } } catch (e) { toast('检查更新失败: ' + e, 'error') } finally { btnUpdate.disabled = false btnUpdate.textContent = '检查更新' } }) btnCreateBackup?.addEventListener('click', async () => { btnCreateBackup.disabled = true btnCreateBackup.innerHTML = '备份中...' try { const res = await api.createBackup() toast(`已备份: ${res.name}`, 'success') setTimeout(() => loadDashboardData(page), 500) } catch (e) { toast('备份失败: ' + e, 'error') } finally { btnCreateBackup.disabled = false btnCreateBackup.textContent = '创建备份' } }) } function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>') }