feat: Windows 兼容性全面改进

- Windows Gateway 启动改为前台 spawn 模式(绕过 schtasks 管理员权限)
- 添加全局 Gateway 未启动引导横幅(黄色提示条 + 一键启动按钮)
- 所有页面加载动画改为脉冲效果
- 统一 Windows cmd /c 调用加 CREATE_NO_WINDOW 标志
- 托盘菜单复用 service.rs 逻辑
- 新增 utils.rs 封装 openclaw_command
- 修复 config 文件 UI 字段污染问题
- 添加 dev.ps1 启动脚本
This commit is contained in:
晴天
2026-03-02 13:00:16 +08:00
parent 5352337eaa
commit 53f46d8ef2
64 changed files with 4260 additions and 451 deletions

View File

@@ -3,8 +3,9 @@
*/
import { navigate, getCurrentRoute } from '../router.js'
import { toggleTheme, getTheme } from '../lib/theme.js'
import { isOpenclawReady } from '../lib/app-state.js'
const NAV_ITEMS = [
const NAV_ITEMS_FULL = [
{
section: '概览',
items: [
@@ -42,7 +43,29 @@ const NAV_ITEMS = [
}
]
const NAV_ITEMS_SETUP = [
{
section: '',
items: [
{ route: '/setup', label: '初始设置', icon: 'setup' },
]
},
{
section: '扩展',
items: [
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
]
},
{
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
]
}
]
const ICONS = {
setup: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>',
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
services: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>',
@@ -63,21 +86,16 @@ export function renderSidebar(el) {
let html = `
<div class="sidebar-header">
<div class="sidebar-logo">
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor">
<circle cx="256" cy="280" r="55"/>
<ellipse cx="140" cy="200" rx="32" ry="70" transform="rotate(-30 140 200)"/>
<ellipse cx="372" cy="200" rx="32" ry="70" transform="rotate(30 372 200)"/>
<ellipse cx="256" cy="380" rx="32" ry="70"/>
</g>
</svg>
<img src="/images/logo.png" alt="ClawPanel">
</div>
<span class="sidebar-title">ClawPanel</span>
</div>
<nav class="sidebar-nav">
`
for (const section of NAV_ITEMS) {
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL : NAV_ITEMS_SETUP
for (const section of navItems) {
html += `<div class="nav-section">
<div class="nav-section-title">${section.section}</div>`

81
src/lib/app-state.js Normal file
View File

@@ -0,0 +1,81 @@
/**
* 全局应用状态
* 管理 openclaw 安装状态,供各组件查询
*/
import { api } from './tauri-api.js'
let _openclawReady = false
let _gatewayRunning = false
let _listeners = []
let _gwListeners = []
/** openclaw 是否就绪CLI 已安装 + 配置文件存在) */
export function isOpenclawReady() {
return _openclawReady
}
/** Gateway 是否正在运行 */
export function isGatewayRunning() {
return _gatewayRunning
}
/** 监听 Gateway 状态变化 */
export function onGatewayChange(fn) {
_gwListeners.push(fn)
return () => { _gwListeners = _gwListeners.filter(cb => cb !== fn) }
}
/** 检测 openclaw 安装状态 */
export async function detectOpenclawStatus() {
try {
const [installation, services] = await Promise.allSettled([
api.checkInstallation(),
api.getServicesStatus(),
])
const configExists = installation.status === 'fulfilled' && installation.value?.installed
const cliInstalled = services.status === 'fulfilled'
&& services.value?.length > 0
&& services.value[0]?.cli_installed !== false
_openclawReady = configExists && cliInstalled
// 顺便检测 Gateway 运行状态
if (services.status === 'fulfilled' && services.value?.length > 0) {
_setGatewayRunning(services.value[0]?.running === true)
}
} catch {
_openclawReady = false
}
_listeners.forEach(fn => { try { fn(_openclawReady) } catch {} })
return _openclawReady
}
function _setGatewayRunning(val) {
const changed = _gatewayRunning !== val
_gatewayRunning = val
if (changed) _gwListeners.forEach(fn => { try { fn(val) } catch {} })
}
/** 刷新 Gateway 运行状态(轻量,仅查服务状态) */
export async function refreshGatewayStatus() {
try {
const services = await api.getServicesStatus()
if (services?.length > 0) _setGatewayRunning(services[0]?.running === true)
} catch {}
return _gatewayRunning
}
let _pollTimer = null
/** 启动 Gateway 状态轮询(每 5 秒) */
export function startGatewayPoll() {
if (_pollTimer) return
_pollTimer = setInterval(() => refreshGatewayStatus(), 5000)
}
export function stopGatewayPoll() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
}
/** 监听状态变化 */
export function onReadyChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
}

View File

@@ -5,9 +5,14 @@
const isTauri = !!window.__TAURI_INTERNALS__
// 预加载 Tauri invoke避免每次 API 调用都做动态 import
const _invokeReady = isTauri
? import('@tauri-apps/api/core').then(m => m.invoke)
: null
async function invoke(cmd, args = {}) {
if (isTauri) {
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
if (_invokeReady) {
const tauriInvoke = await _invokeReady
return tauriInvoke(cmd, args)
}
return mockInvoke(cmd, args)
@@ -17,7 +22,7 @@ async function invoke(cmd, args = {}) {
function mockInvoke(cmd, args) {
const mocks = {
get_services_status: () => [
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway' },
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway', cli_installed: true },
],
get_version_info: () => ({
current: '2026.2.23',
@@ -83,6 +88,7 @@ function mockInvoke(cmd, args) {
delete_memory_file: () => true,
export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`,
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }),
check_node: () => ({ installed: true, version: 'v20.11.0' }),
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
read_mcp_config: () => ({
mcpServers: {
@@ -171,6 +177,7 @@ export const api = {
// 安装/部署
checkInstallation: () => invoke('check_installation'),
checkNode: () => invoke('check_node'),
getDeployConfig: () => invoke('get_deploy_config'),
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),

View File

@@ -1,9 +1,11 @@
/**
* ClawPanel 入口
*/
import { registerRoute, initRouter } from './router.js'
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll } from './lib/app-state.js'
import { api } from './lib/tauri-api.js'
// 样式
import './style/variables.css'
@@ -14,24 +16,81 @@ import './style/pages.css'
import './style/chat.css'
import './style/agents.css'
// 注册页面路由(懒加载)
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'))
// 初始化主题
initTheme()
// 初始化
const sidebar = document.getElementById('sidebar')
const content = document.getElementById('content')
renderSidebar(sidebar)
initRouter(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'))
}
renderSidebar(sidebar)
initRouter(content)
// 未安装时强制跳转到 setup
if (!isOpenclawReady()) {
navigate('/setup')
return
}
// Gateway 未启动引导横幅
setupGatewayBanner()
startGatewayPoll()
}
function setupGatewayBanner() {
const banner = document.getElementById('gw-banner')
if (!banner) return
function update(running) {
if (running) {
banner.classList.add('gw-banner-hidden')
} else {
banner.classList.remove('gw-banner-hidden')
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">⚠</span>
<span>Gateway 未启动,部分功能不可用</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start">启动 Gateway</button>
</div>
`
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
const btn = e.target
btn.disabled = true
btn.textContent = '启动中...'
try {
await api.startService('ai.openclaw.gateway')
} catch (err) {
btn.textContent = '启动失败,重试'
btn.disabled = false
}
})
}
}
update(isGatewayRunning())
onGatewayChange(update)
}
boot()

View File

@@ -12,7 +12,7 @@ export async function render() {
page.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:16px">
<img src="/images/logo.svg" alt="ClawPanel" style="width:48px;height:48px;border-radius:var(--radius-md)">
<img src="/images/logo-brand.png" alt="ClawPanel" style="height:48px;width:auto">
<div>
<h1 class="page-title" style="margin:0">ClawPanel</h1>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>

View File

@@ -21,12 +21,13 @@ export async function render() {
</div>
</div>
<div class="page-content">
<div id="agents-list"></div>
<div id="agents-list" class="loading-text">加载中...</div>
</div>
`
const state = { agents: [] }
await loadAgents(page, state)
// 非阻塞:先返回 DOM后台加载数据
loadAgents(page, state)
page.querySelector('#btn-add-agent').addEventListener('click', () => showAddAgentDialog(page, state))

View File

@@ -109,7 +109,8 @@ export async function render() {
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
bindEvents(page)
await connectGateway()
// 非阻塞:先返回 DOM后台连接 Gateway
connectGateway()
return page
}

View File

@@ -29,7 +29,7 @@ export async function render() {
</div>
<div class="config-section">
<div class="config-section-title">最近日志</div>
<div class="log-viewer" id="recent-logs" style="max-height:300px">加载中...</div>
<div class="log-viewer" id="recent-logs" style="max-height:300px"><div class="loading-text">加载中...</div></div>
</div>
`

View File

@@ -27,12 +27,12 @@ export async function render() {
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
<div id="cftunnel-content">加载中...</div>
<div id="cftunnel-content" class="loading-text">加载中...</div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
<div id="clawapp-content">加载中...</div>
<div id="clawapp-content" class="loading-text">加载中...</div>
</div>
`

View File

@@ -13,7 +13,7 @@ export async function render() {
<h1 class="page-title">Gateway 配置</h1>
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
</div>
<div id="gateway-config">加载中...</div>
<div id="gateway-config" class="loading-text">加载中...</div>
<div class="gw-save-bar">
<button class="btn btn-primary" id="btn-save-gw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
@@ -24,7 +24,8 @@ export async function render() {
`
const state = { config: null }
await loadConfig(page, state)
// 非阻塞:先返回 DOM后台加载数据
loadConfig(page, state)
page.querySelector('#btn-save-gw').onclick = async () => {
const btn = page.querySelector('#btn-save-gw')
btn.disabled = true

View File

@@ -33,7 +33,7 @@ export async function render() {
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
</label>
</div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)">加载中...</div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="loading-text">加载中...</div></div>
`
let currentTab = 'gateway'

View File

@@ -15,23 +15,12 @@ export async function render() {
const page = document.createElement('div')
page.className = 'page'
// 先获取 agent 列表
let agents = []
try {
agents = await api.listAgents()
} catch { agents = [{ id: 'main', identityName: '默认' }] }
const agentOptions = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">记忆文件</h1>
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
<label style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Agent:</label>
<select class="form-input" id="agent-select" style="width:auto;min-width:140px">${agentOptions}</select>
<select class="form-input" id="agent-select" style="width:auto;min-width:140px"><option value="main">main</option></select>
</div>
</div>
<div class="tab-bar">
@@ -47,7 +36,7 @@ export async function render() {
<div style="padding:0 var(--space-sm) var(--space-sm)">
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
</div>
<div id="file-tree">加载中...</div>
<div id="file-tree" class="loading-text">加载中...</div>
</div>
<div class="memory-editor">
<div class="editor-toolbar">
@@ -65,6 +54,17 @@ export async function render() {
const state = { category: 'memory', currentPath: null, agentId: 'main' }
// 非阻塞加载 agent 列表,然后填充下拉框
api.listAgents().then(agents => {
const select = page.querySelector('#agent-select')
if (!select) return
const options = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
select.innerHTML = options
}).catch(() => {})
// Agent 切换
page.querySelector('#agent-select').onchange = (e) => {
state.agentId = e.target.value

View File

@@ -64,11 +64,12 @@ export async function render() {
<div style="margin-bottom:var(--space-md)">
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
</div>
<div id="providers-list">加载中...</div>
<div id="providers-list" class="loading-text">加载中...</div>
`
const state = { config: null, search: '', undoStack: [] }
await loadConfig(page, state)
// 非阻塞:先返回 DOM后台加载数据
loadConfig(page, state)
bindTopActions(page, state)
// 搜索框实时过滤

View File

@@ -26,10 +26,10 @@ export async function render() {
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
</div>
<div id="version-bar"></div>
<div id="services-list">加载中...</div>
<div id="services-list" class="loading-text">加载中...</div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar">加载中...</div>
<div id="registry-bar" class="loading-text">加载中...</div>
</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
@@ -37,7 +37,7 @@ export async function render() {
<div id="backup-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
</div>
<div id="backup-list">加载中...</div>
<div id="backup-list" class="loading-text">加载中...</div>
</div>
`
@@ -141,25 +141,37 @@ async function loadServices(page) {
function renderServices(container, services) {
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
// Gateway 专属卡片(带安装/卸载)
let html = ''
if (gw) {
// 检测 CLI 是否安装
const cliMissing = gw.cli_installed === false
html += `
<div class="service-card" data-label="${gw.label}">
<div class="service-info">
<span class="status-dot ${gw.running ? 'running' : 'stopped'}"></span>
<span class="status-dot ${cliMissing ? 'stopped' : gw.running ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${gw.label}</div>
<div class="service-desc">${gw.description || ''}${gw.pid ? ' (PID: ' + gw.pid + ')' : ''}</div>
<div class="service-desc">${cliMissing
? 'OpenClaw CLI 未安装'
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
}</div>
</div>
</div>
<div class="service-actions">
${gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
${cliMissing
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs)">请先安装 OpenClaw CLI:</div>
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">刷新状态</button>
</div>`
: gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
}
</div>
</div>`
@@ -255,6 +267,9 @@ function bindEvents(page) {
case 'uninstall-gateway':
await handleUninstallGateway(btn, page)
break
case 'refresh-services':
await loadServices(page)
break
case 'save-registry':
await handleSaveRegistry(btn, page)
break

215
src/pages/setup.js Normal file
View File

@@ -0,0 +1,215 @@
/**
* 初始设置页面 — openclaw 未安装时的引导
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
*/
import { api } from '../lib/tauri-api.js'
import { showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div style="max-width:560px;margin:48px auto;text-align:center">
<div style="margin-bottom:var(--space-lg)">
<img src="/images/logo-brand.png" alt="ClawPanel" style="max-width:160px;width:100%;height:auto">
</div>
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">欢迎使用 ClawPanel</h1>
<p style="color:var(--text-secondary);margin-bottom:var(--space-xl);line-height:1.6">
OpenClaw AI Agent 框架的桌面管理面板
</p>
<div id="setup-steps"></div>
<div style="margin-top:var(--space-lg)">
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
重新检测
</button>
</div>
</div>
`
page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page))
runDetect(page)
return page
}
async function runDetect(page) {
const stepsEl = page.querySelector('#setup-steps')
stepsEl.innerHTML = `
<div class="stat-card loading-placeholder" style="height:48px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
`
// 并行检测 Node.js、OpenClaw CLI、配置文件
const [nodeRes, clawRes, configRes] = await Promise.allSettled([
api.checkNode(),
api.getServicesStatus(),
api.checkInstallation(),
])
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
const cliOk = clawRes.status === 'fulfilled'
&& clawRes.value?.length > 0
&& clawRes.value[0]?.cli_installed !== false
const config = configRes.status === 'fulfilled' ? configRes.value : { installed: false }
renderSteps(page, { node, cliOk, config })
}
function stepIcon(ok) {
const color = ok ? 'var(--success)' : 'var(--text-tertiary)'
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
}
function renderSteps(page, { node, cliOk, config }) {
const stepsEl = page.querySelector('#setup-steps')
const nodeOk = node.installed
const allOk = nodeOk && cliOk && config.installed
let html = ''
// 第一步Node.js
html += `
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(nodeOk)} Node.js 环境
</div>
${nodeOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${node.version || ''}</p>`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
OpenClaw 基于 Node.js 运行,请先安装。
</p>
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">下载 Node.js</a>
<span class="form-hint" style="margin-left:8px">安装后点击「重新检测」</span>`
}
</div>
`
// 第二步OpenClaw CLI
html += `
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(cliOk)} OpenClaw CLI
</div>
${cliOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>`
: renderInstallSection()
}
</div>
`
// 第三步:配置文件
html += `
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(config.installed)} 配置文件
</div>
${config.installed
? `<p style="color:var(--success);font-size:var(--font-size-sm)">配置文件位于 ${config.path || ''}</p>`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
安装 CLI 后会自动生成配置,也可手动执行 <code>openclaw configure</code>
</p>`
}
</div>
`
// 全部就绪 → 进入面板
if (allOk) {
html += `
<div style="margin-top:var(--space-lg)">
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">进入面板</button>
</div>
`
}
stepsEl.innerHTML = html
bindEvents(page, nodeOk)
}
function renderInstallSection() {
return `
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
选择版本后点击安装,将自动执行 npm 全局安装。
</p>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">汉化优化版(推荐)</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
</div>
</label>
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="official" style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">官方原版</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
</div>
</label>
</div>
<div style="margin-bottom:var(--space-sm)">
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">npm 镜像源</label>
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
<option value="https://registry.npmmirror.com">淘宝镜像(推荐国内用户)</option>
<option value="https://registry.npmjs.org">npm 官方源</option>
<option value="https://repo.huaweicloud.com/repository/npm/">华为云镜像</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="btn-install">一键安装</button>
`
}
function bindEvents(page, nodeOk) {
// 进入面板
page.querySelector('#btn-enter')?.addEventListener('click', () => {
window.location.reload()
})
// 一键安装
const installBtn = page.querySelector('#btn-install')
if (!installBtn || !nodeOk) return
installBtn.addEventListener('click', async () => {
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
const registry = page.querySelector('#registry-select')?.value
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// 先设置镜像源
if (registry) {
modal.appendLog(`设置 npm 镜像源: ${registry}`)
try { await api.setNpmRegistry(registry) } catch {}
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(msg)
// 安装成功后自动安装 Gateway
modal.appendLog('正在安装 Gateway 服务...')
try {
await api.installGateway()
modal.appendLog('✅ Gateway 服务已安装')
} catch (e) {
modal.appendLog('⚠️ Gateway 安装失败: ' + e)
}
toast('OpenClaw 安装成功', 'success')
setTimeout(() => window.location.reload(), 1500)
} catch (e) {
modal.appendLog(String(e))
modal.setError('安装失败')
} finally {
unlistenLog?.()
unlistenProgress?.()
}
})
}

View File

@@ -2,15 +2,22 @@
* 极简 hash 路由
*/
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) {
window.location.hash = path
}
@@ -25,7 +32,7 @@ export function initRouter(contentEl) {
}
async function loadRoute() {
const hash = window.location.hash.slice(1) || '/dashboard'
const hash = window.location.hash.slice(1) || _defaultRoute
const loader = routes[hash]
if (!loader || !_contentEl) return
@@ -38,18 +45,32 @@ async function loadRoute() {
_currentCleanup = null
}
_contentEl.innerHTML = ''
// 退出动画:如果有旧页面,播放退出动画后再替换
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
}
// 显示加载动画
const loader_el = document.createElement('div')
loader_el.className = 'page-loader'
loader_el.innerHTML = `
<div class="page-loader-spinner"></div>
<div class="page-loader-text">加载中...</div>
`
_contentEl.appendChild(loader_el)
// 已缓存的模块:跳过 spinner直接渲染
let mod = _moduleCache[hash]
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">加载中...</div>
`
_contentEl.appendChild(spinnerEl)
const mod = await loader()
mod = await loader()
_moduleCache[hash] = mod
} else {
_contentEl.innerHTML = ''
}
// 如果加载期间路由又变了,丢弃本次结果
if (thisLoad !== _loadId) return
@@ -57,7 +78,7 @@ async function loadRoute() {
const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod
if (thisLoad !== _loadId) return
// 移除加载动画,插入页面内容
// 插入页面内容
_contentEl.innerHTML = ''
if (typeof page === 'string') {
_contentEl.innerHTML = page
@@ -75,5 +96,5 @@ async function loadRoute() {
}
export function getCurrentRoute() {
return window.location.hash.slice(1) || '/dashboard'
return window.location.hash.slice(1) || _defaultRoute
}

View File

@@ -205,6 +205,16 @@ mark {
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.loading-text {
color: var(--text-tertiary);
padding: 24px 0;
text-align: center;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes shimmer {
0% { background-position: 200% 0; }

View File

@@ -24,15 +24,17 @@
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--font-size-sm);
color: var(--text-inverse);
flex-shrink: 0;
padding: 4px;
overflow: hidden;
}
.sidebar-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sidebar-logo svg {
@@ -106,10 +108,18 @@
opacity: 1;
}
/* 主内容列(横幅 + 内容区) */
#main-col {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* 主内容区 */
#content {
flex: 1;
height: 100vh;
overflow-y: auto;
background: var(--bg-primary);
}
@@ -117,7 +127,7 @@
.page {
padding: var(--space-xl) var(--space-2xl);
max-width: 1200px;
animation: fadeIn 200ms ease;
animation: pageIn 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.page-header {
@@ -143,7 +153,70 @@
color: var(--text-tertiary);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
@keyframes pageIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pageOut {
from { opacity: 1; }
to { opacity: 0; }
}
.page-exit {
animation: pageOut 100ms ease forwards;
pointer-events: none;
}
/* Setup 页面版本选择卡片 */
.setup-source-option {
display: flex;
align-items: flex-start;
gap: 6px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.setup-source-option:hover {
border-color: var(--accent);
background: var(--bg-glass-hover);
}
.setup-source-option:has(input:checked) {
border-color: var(--accent);
background: var(--accent-muted);
}
/* Gateway 未启动引导横幅 */
.gw-banner {
background: var(--warning, #f59e0b);
color: #000;
padding: 8px 16px;
font-size: var(--font-size-sm);
z-index: 100;
transition: all 300ms ease;
overflow: hidden;
max-height: 50px;
}
.gw-banner-hidden {
max-height: 0;
padding: 0 16px;
opacity: 0;
}
.gw-banner-content {
display: flex;
align-items: center;
gap: 8px;
}
.gw-banner-icon {
font-size: 16px;
}
.gw-banner .btn {
margin-left: auto;
background: rgba(0,0,0,0.15);
border: none;
color: #000;
font-weight: 600;
}