feat: 精简页面结构并增强核心功能

- 删除 MCP 配置、Agent 配置、部署 3 个页面,保留 6 个核心页面
- 重写模型配置页:Provider/模型 CRUD + 一键应用默认模型(自动生成 fallback)
- 增强服务管理页:版本检测 + 配置备份管理(创建/恢复/删除)
- 增强记忆文件页:单个文件下载 + 分类打包 zip 下载
- Rust 后端新增 5 个命令(4 个备份 + export_memory_zip)
- 更新路由和侧边栏,同步清理
This commit is contained in:
晴天
2026-02-27 00:16:45 +08:00
parent c2e3f738b5
commit 1b9a195d32
14 changed files with 693 additions and 555 deletions

53
src-tauri/Cargo.lock generated
View File

@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -292,8 +301,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -301,12 +312,14 @@ dependencies = [
name = "clawpanel"
version = "0.1.0"
dependencies = [
"chrono",
"dirs",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-shell",
"zip",
]
[[package]]
@@ -500,6 +513,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -4927,8 +4951,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@@ -16,3 +16,5 @@ tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
chrono = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }

View File

@@ -11,6 +11,10 @@ fn openclaw_dir() -> PathBuf {
.join(".openclaw")
}
fn backups_dir() -> PathBuf {
openclaw_dir().join("backups")
}
#[tauri::command]
pub fn read_openclaw_config() -> Result<Value, String> {
let path = openclaw_dir().join("openclaw.json");
@@ -96,3 +100,103 @@ pub fn write_env_file(path: String, config: String) -> Result<(), String> {
fs::write(&expanded, &config)
.map_err(|e| format!("写入 .env 失败: {e}"))
}
// ===== 备份管理 =====
#[tauri::command]
pub fn list_backups() -> Result<Value, String> {
let dir = backups_dir();
if !dir.exists() {
return Ok(Value::Array(vec![]));
}
let mut backups: Vec<Value> = vec![];
let entries = fs::read_dir(&dir)
.map_err(|e| format!("读取备份目录失败: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let meta = fs::metadata(&path).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let created = meta
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let mut obj = serde_json::Map::new();
obj.insert("name".into(), Value::String(name));
obj.insert("size".into(), Value::Number(size.into()));
obj.insert("created_at".into(), Value::Number(created.into()));
backups.push(Value::Object(obj));
}
// 按时间倒序
backups.sort_by(|a, b| {
let ta = a.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
let tb = b.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
tb.cmp(&ta)
});
Ok(Value::Array(backups))
}
#[tauri::command]
pub fn create_backup() -> Result<Value, String> {
let dir = backups_dir();
fs::create_dir_all(&dir)
.map_err(|e| format!("创建备份目录失败: {e}"))?;
let src = openclaw_dir().join("openclaw.json");
if !src.exists() {
return Err("openclaw.json 不存在".into());
}
let now = chrono::Local::now();
let name = format!("openclaw-{}.json", now.format("%Y%m%d-%H%M%S"));
let dest = dir.join(&name);
fs::copy(&src, &dest)
.map_err(|e| format!("备份失败: {e}"))?;
let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
let mut obj = serde_json::Map::new();
obj.insert("name".into(), Value::String(name));
obj.insert("size".into(), Value::Number(size.into()));
Ok(Value::Object(obj))
}
#[tauri::command]
pub fn restore_backup(name: String) -> Result<(), String> {
// 安全检查
if name.contains("..") || name.contains('/') {
return Err("非法文件名".into());
}
let backup_path = backups_dir().join(&name);
if !backup_path.exists() {
return Err(format!("备份文件不存在: {name}"));
}
let target = openclaw_dir().join("openclaw.json");
// 恢复前先自动备份当前配置
if target.exists() {
let _ = create_backup();
}
fs::copy(&backup_path, &target)
.map_err(|e| format!("恢复失败: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn delete_backup(name: String) -> Result<(), String> {
if name.contains("..") || name.contains('/') {
return Err("非法文件名".into());
}
let path = backups_dir().join(&name);
if !path.exists() {
return Err(format!("备份文件不存在: {name}"));
}
fs::remove_file(&path)
.map_err(|e| format!("删除失败: {e}"))
}

View File

@@ -1,5 +1,6 @@
/// 记忆文件管理命令
use std::fs;
use std::io::Write;
use std::path::PathBuf;
fn openclaw_dir() -> PathBuf {
@@ -132,3 +133,44 @@ pub fn delete_memory_file(path: String) -> Result<(), String> {
Err(format!("文件不存在: {path}"))
}
#[tauri::command]
pub fn export_memory_zip(category: String) -> Result<String, String> {
let dir = memory_dir(&category);
if !dir.exists() {
return Err("目录不存在".to_string());
}
let mut files = Vec::new();
collect_files(&dir, &dir, &mut files, &category)?;
if files.is_empty() {
return Err("没有可导出的文件".to_string());
}
let tmp_dir = std::env::temp_dir();
let zip_name = format!(
"openclaw-{}-{}.zip",
category,
chrono::Local::now().format("%Y%m%d-%H%M%S")
);
let zip_path = tmp_dir.join(&zip_name);
let file = fs::File::create(&zip_path)
.map_err(|e| format!("创建 zip 失败: {e}"))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for rel_path in &files {
let full_path = dir.join(rel_path);
let content = fs::read_to_string(&full_path)
.map_err(|e| format!("读取 {rel_path} 失败: {e}"))?;
zip.start_file(rel_path, options)
.map_err(|e| format!("写入 zip 失败: {e}"))?;
zip.write_all(content.as_bytes())
.map_err(|e| format!("写入内容失败: {e}"))?;
}
zip.finish().map_err(|e| format!("完成 zip 失败: {e}"))?;
Ok(zip_path.to_string_lossy().to_string())
}

View File

@@ -15,6 +15,10 @@ pub fn run() {
config::get_version_info,
config::check_installation,
config::write_env_file,
config::list_backups,
config::create_backup,
config::restore_backup,
config::delete_backup,
// 服务
service::get_services_status,
service::start_service,
@@ -28,6 +32,7 @@ pub fn run() {
memory::read_memory_file,
memory::write_memory_file,
memory::delete_memory_file,
memory::export_memory_zip,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");

View File

@@ -17,16 +17,13 @@ const NAV_ITEMS = [
section: '配置',
items: [
{ route: '/models', label: '模型配置', icon: 'models' },
{ route: '/agents', label: 'Agent 配置', icon: 'agents' },
{ route: '/gateway', label: 'Gateway', icon: 'gateway' },
{ route: '/mcp', label: 'MCP 工具', icon: 'mcp' },
]
},
{
section: '数据',
items: [
{ route: '/memory', label: '记忆文件', icon: 'memory' },
{ route: '/deploy', label: 'ClawApp 部署', icon: 'deploy' },
]
}
]
@@ -36,11 +33,8 @@ const ICONS = {
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>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
models: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>',
agents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>',
gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
mcp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>',
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>',
deploy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
}
let _delegated = false

View File

@@ -85,6 +85,7 @@ function mockInvoke(cmd, args) {
read_memory_file: ({ path }) => `# ${path}\n\n这是 ${path} 的内容示例。\n\n## 概述\n\n在此记录工作记忆...`,
write_memory_file: () => true,
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' }),
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
read_mcp_config: () => ({
@@ -99,6 +100,13 @@ function mockInvoke(cmd, args) {
stop_service: () => true,
restart_service: () => true,
write_env_file: () => true,
list_backups: () => [
{ name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 },
{ name: 'openclaw-20260225-100000.json', size: 8210, created_at: 1740474000 },
],
create_backup: () => ({ name: 'openclaw-20260226-160000.json', size: 8542 }),
restore_backup: () => true,
delete_backup: () => true,
}
const fn = mocks[cmd]
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
@@ -128,9 +136,16 @@ export const api = {
readMemoryFile: (path) => invoke('read_memory_file', { path }),
writeMemoryFile: (path, content) => invoke('write_memory_file', { path, content }),
deleteMemoryFile: (path) => invoke('delete_memory_file', { path }),
exportMemoryZip: (category) => invoke('export_memory_zip', { category }),
// 安装/部署
checkInstallation: () => invoke('check_installation'),
getDeployConfig: () => invoke('get_deploy_config'),
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),
// 备份管理
listBackups: () => invoke('list_backups'),
createBackup: () => invoke('create_backup'),
restoreBackup: (name) => invoke('restore_backup', { name }),
deleteBackup: (name) => invoke('delete_backup', { name }),
}

View File

@@ -17,11 +17,8 @@ registerRoute('/dashboard', () => import('./pages/dashboard.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('/mcp', () => import('./pages/mcp.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/deploy', () => import('./pages/deploy.js'))
// 初始化主题
initTheme()

View File

@@ -1,135 +0,0 @@
/**
* Agent 配置页面
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">Agent 配置</h1>
<p class="page-desc">配置默认模型、Fallback 链和记忆搜索</p>
</div>
<div id="agent-config">加载中...</div>
<div style="margin-top:16px">
<button class="btn btn-primary" id="btn-save-agent">保存配置</button>
</div>
`
const state = { config: null }
await loadConfig(page, state)
page.querySelector('#btn-save-agent').onclick = async () => {
const btn = page.querySelector('#btn-save-agent')
btn.disabled = true
btn.textContent = '保存中...'
try {
await saveConfig(page, state)
} finally {
btn.disabled = false
btn.textContent = '保存配置'
}
}
return page
}
async function loadConfig(page, state) {
try {
state.config = await api.readOpenclawConfig()
renderConfig(page, state)
} catch (e) {
toast('加载配置失败: ' + e, 'error')
}
}
function renderConfig(page, state) {
const el = page.querySelector('#agent-config')
const agents = state.config?.agents || {}
const defaults = agents.defaults || {}
const model = defaults.model || {}
el.innerHTML = `
<div class="config-section">
<div class="config-section-title">主模型</div>
<div class="form-group">
<label class="form-label">Primary Model</label>
<input class="form-input" id="primary-model" value="${model.primary || ''}" placeholder="如 newapi-claude/claude-opus-4-6">
</div>
</div>
<div class="config-section">
<div class="config-section-title">Fallback 链</div>
<div id="fallback-list">
${(model.fallbacks || []).map((f, i) => `
<div class="fallback-item" style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<span style="color:var(--text-tertiary);font-size:var(--font-size-sm);min-width:20px">${i + 1}.</span>
<input class="form-input fallback-input" value="${f}" style="flex:1">
<button class="btn btn-sm btn-danger" data-action="remove-fallback" data-index="${i}">删除</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-secondary" id="btn-add-fallback">+ 添加 Fallback</button>
</div>
<div class="config-section">
<div class="config-section-title">并发控制</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label class="form-label">最大并发</label>
<input class="form-input" id="max-concurrent" type="number" value="${defaults.maxConcurrent || 4}" min="1" max="20">
</div>
<div class="form-group">
<label class="form-label">子 Agent 数</label>
<input class="form-input" id="max-subagents" type="number" value="${defaults.subagents || 2}" min="0" max="10">
</div>
</div>
</div>
`
// 删除 fallback
el.querySelectorAll('[data-action="remove-fallback"]').forEach(btn => {
btn.onclick = () => {
const idx = parseInt(btn.dataset.index)
if (model.fallbacks) model.fallbacks.splice(idx, 1)
renderConfig(page, state)
}
})
// fallback 输入框实时同步到 state
el.querySelectorAll('.fallback-input').forEach((input, i) => {
input.oninput = () => {
if (model.fallbacks) model.fallbacks[i] = input.value
}
})
// 添加 fallback
el.querySelector('#btn-add-fallback').onclick = () => {
if (!model.fallbacks) model.fallbacks = []
model.fallbacks.push('')
renderConfig(page, state)
}
}
async function saveConfig(page, state) {
// 从 DOM 收集值
const primary = page.querySelector('#primary-model')?.value || ''
const fallbacks = [...page.querySelectorAll('.fallback-input')].map(i => i.value).filter(Boolean)
const maxConcurrent = parseInt(page.querySelector('#max-concurrent')?.value) || 4
const subagents = parseInt(page.querySelector('#max-subagents')?.value) || 2
if (!state.config.agents) state.config.agents = {}
if (!state.config.agents.defaults) state.config.agents.defaults = {}
state.config.agents.defaults.model = { primary, fallbacks }
state.config.agents.defaults.maxConcurrent = maxConcurrent
state.config.agents.defaults.subagents = subagents
try {
await api.writeOpenclawConfig(state.config)
toast('Agent 配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}

View File

@@ -1,127 +0,0 @@
/**
* ClawApp 部署页面
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">ClawApp 部署</h1>
<p class="page-desc">一键生成 ClawApp 客户端配置</p>
</div>
<div id="deploy-content">加载中...</div>
`
await loadDeployConfig(page)
return page
}
async function loadDeployConfig(page) {
const el = page.querySelector('#deploy-content')
try {
const [config, version] = await Promise.all([
api.readOpenclawConfig(),
api.getVersionInfo(),
])
const gw = config?.gateway || {}
const port = gw.port || 18789
const bind = gw.bind || 'loopback'
const token = gw.authToken || ''
// 推断 Gateway URL
let gwUrl = `http://127.0.0.1:${port}`
if (gw.tailscale?.address) {
gwUrl = `http://${gw.tailscale.address}`
}
const envContent = [
`# ClawApp 环境配置`,
`# 由 ClawPanel 自动生成`,
`VITE_GATEWAY_URL=${gwUrl}`,
token ? `VITE_AUTH_TOKEN=${token}` : `# VITE_AUTH_TOKEN=`,
`VITE_APP_VERSION=${version?.current || 'unknown'}`,
].join('\n')
renderDeployUI(page, el, envContent, gwUrl, token)
} catch (e) {
toast('加载部署配置失败: ' + e, 'error')
el.innerHTML = '<div style="color:var(--text-tertiary)">加载失败</div>'
}
}
function renderDeployUI(page, el, envContent, gwUrl, token) {
el.innerHTML = `
<div class="config-section">
<div class="config-section-title">连接信息</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">Gateway URL</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm);font-family:var(--font-mono)">${gwUrl}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">认证状态</span></div>
<div class="stat-card-value">${token ? '已配置 Token' : '无认证'}</div>
</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">.env 文件预览</div>
<div class="log-viewer" style="max-height:200px;margin-bottom:12px">
${envContent.split('\n').map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')}
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btn-copy-env">复制到剪贴板</button>
<button class="btn btn-secondary btn-sm" id="btn-write-env">写入 .env 文件</button>
</div>
</div>
<div class="config-section">
<div class="config-section-title">写入路径</div>
<div class="form-group">
<input class="form-input" id="env-path" value="~/Desktop/clawapp/.env" placeholder="输入 ClawApp 项目 .env 文件路径">
</div>
</div>
`
// 复制到剪贴板
el.querySelector('#btn-copy-env').onclick = async () => {
try {
await navigator.clipboard.writeText(envContent)
toast('已复制到剪贴板', 'success')
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = envContent
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
toast('已复制到剪贴板', 'success')
}
}
// 写入文件
el.querySelector('#btn-write-env').onclick = async () => {
const path = el.querySelector('#env-path')?.value
if (!path) {
toast('请输入 .env 文件路径', 'error')
return
}
try {
await api.writeEnvFile(path, envContent)
toast('.env 文件已写入', 'success')
} catch (e) {
toast('写入失败: ' + e, 'error')
}
}
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -1,164 +0,0 @@
/**
* MCP 工具配置页面
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showModal } from '../components/modal.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">MCP 工具</h1>
<p class="page-desc">管理 MCP Server 配置</p>
</div>
<div class="config-actions">
<button class="btn btn-primary btn-sm" id="btn-add-mcp">+ 添加 MCP Server</button>
<button class="btn btn-secondary btn-sm" id="btn-save-mcp">保存配置</button>
</div>
<div id="mcp-list">加载中...</div>
`
const state = { config: null }
await loadConfig(page, state)
page.querySelector('#btn-save-mcp').onclick = async () => {
const btn = page.querySelector('#btn-save-mcp')
btn.disabled = true
btn.textContent = '保存中...'
try {
await saveConfig(state)
} finally {
btn.disabled = false
btn.textContent = '保存配置'
}
}
page.querySelector('#btn-add-mcp').onclick = () => addServer(page, state)
return page
}
async function loadConfig(page, state) {
try {
state.config = await api.readMcpConfig()
renderServers(page, state)
} catch (e) {
toast('加载 MCP 配置失败: ' + e, 'error')
}
}
function renderServers(page, state) {
const listEl = page.querySelector('#mcp-list')
const servers = state.config?.mcpServers || state.config || {}
const keys = Object.keys(servers)
if (!keys.length) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 MCP Server 配置</div>'
return
}
listEl.innerHTML = keys.map(key => {
const s = servers[key]
const type = s.url ? 'http' : 'stdio'
return `
<div class="service-card" data-server="${key}">
<div class="service-info">
<span class="status-dot running"></span>
<div>
<div class="service-name">${key}</div>
<div class="service-desc">${type} · ${type === 'stdio' ? (s.command || '') : (s.url || '')}</div>
</div>
</div>
<div class="service-actions">
<button class="btn btn-sm btn-secondary" data-action="edit">编辑</button>
<button class="btn btn-sm btn-danger" data-action="delete">删除</button>
</div>
</div>
`
}).join('')
// 绑定事件
listEl.querySelectorAll('[data-action]').forEach(btn => {
btn.onclick = () => {
const card = btn.closest('[data-server]')
const key = card.dataset.server
const action = btn.dataset.action
if (action === 'delete') {
if (!confirm(`确定删除 MCP Server "${key}"`)) return
if (state.config.mcpServers) delete state.config.mcpServers[key]
else delete state.config[key]
renderServers(page, state)
toast(`已删除 ${key}`, 'info')
} else if (action === 'edit') {
editServer(page, state, key)
}
}
})
}
function editServer(page, state, key) {
const servers = state.config?.mcpServers || state.config || {}
const s = servers[key] || {}
const json = JSON.stringify(s, null, 2)
const listEl = page.querySelector('#mcp-list')
listEl.innerHTML = `
<div class="config-section">
<div class="config-section-title">编辑: ${key}</div>
<div class="form-group">
<label class="form-label">JSON 配置</label>
<textarea class="form-input" id="mcp-json" rows="12" style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${escapeHtml(json)}</textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btn-apply-edit">应用</button>
<button class="btn btn-secondary btn-sm" id="btn-cancel-edit">取消</button>
</div>
</div>
`
listEl.querySelector('#btn-apply-edit').onclick = () => {
try {
const parsed = JSON.parse(listEl.querySelector('#mcp-json').value)
if (state.config.mcpServers) state.config.mcpServers[key] = parsed
else state.config[key] = parsed
renderServers(page, state)
toast('已应用修改', 'success')
} catch (e) {
toast('JSON 格式错误: ' + e.message, 'error')
}
}
listEl.querySelector('#btn-cancel-edit').onclick = () => renderServers(page, state)
}
function addServer(page, state) {
showModal({
title: '添加 MCP Server',
fields: [
{ name: 'name', label: 'Server 名称', placeholder: '如 exa, web-reader' },
{ name: 'command', label: '启动命令', placeholder: '如 npx, node' },
],
onConfirm: ({ name, command }) => {
if (!name) return
const target = state.config?.mcpServers || state.config
target[name] = { command: command || '', args: [], env: {} }
renderServers(page, state)
toast(`已添加 ${name}`, 'success')
},
})
}
async function saveConfig(state) {
try {
await api.writeMcpConfig(state.config)
toast('MCP 配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -29,12 +29,16 @@ export async function render() {
<button class="btn btn-sm btn-secondary" id="btn-new-file" style="flex:1">+ 新建</button>
<button class="btn btn-sm btn-danger" id="btn-del-file" disabled style="flex:1">删除</button>
</div>
<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>
<div class="memory-editor">
<div class="editor-toolbar">
<span id="current-file" style="font-size:var(--font-size-sm);color:var(--text-tertiary)">选择文件查看</span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" id="btn-download" disabled>下载</button>
<button class="btn btn-sm btn-secondary" id="btn-preview" disabled>预览</button>
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>保存</button>
</div>
@@ -98,6 +102,12 @@ export async function render() {
}
}
// 单个下载
page.querySelector('#btn-download').onclick = () => downloadCurrentFile(page, state)
// 打包下载
page.querySelector('#btn-export-zip').onclick = () => exportZip(state)
loadFiles(page, state)
return page
}
@@ -142,6 +152,7 @@ async function loadFileContent(page, state) {
const btnSave = page.querySelector('#btn-save-file')
const btnPreview = page.querySelector('#btn-preview')
const btnDel = page.querySelector('#btn-del-file')
const btnDl = page.querySelector('#btn-download')
editor.disabled = true
editor.value = '加载中...'
@@ -160,6 +171,7 @@ async function loadFileContent(page, state) {
btnSave.disabled = false
btnPreview.disabled = false
btnDel.disabled = false
btnDl.disabled = false
} catch (e) {
editor.value = '读取失败: ' + e
toast('读取文件失败: ' + e, 'error')
@@ -178,6 +190,7 @@ function resetEditor(page) {
page.querySelector('#btn-preview').disabled = true
page.querySelector('#btn-preview').textContent = '预览'
page.querySelector('#btn-del-file').disabled = true
page.querySelector('#btn-download').disabled = true
}
async function saveFile(page, state) {
@@ -228,3 +241,37 @@ function renderMarkdown(md) {
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>')
}
// ===== 下载功能 =====
function triggerDownload(filename, content) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
async function downloadCurrentFile(page, state) {
if (!state.currentPath) return
try {
const content = page.querySelector('#file-editor').value
const filename = state.currentPath.split('/').pop()
triggerDownload(filename, content)
toast(`已下载 ${filename}`, 'success')
} catch (e) {
toast('下载失败: ' + e, 'error')
}
}
async function exportZip(state) {
try {
const zipPath = await api.exportMemoryZip(state.category)
const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category
toast(`已导出: ${label}${zipPath}`, 'success')
} catch (e) {
toast('打包下载失败: ' + e, 'error')
}
}

View File

@@ -1,5 +1,6 @@
/**
* 模型配置页面
* 模型增删改查 + 选择默认主模型应用(未选中自动成为 fallback
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
@@ -12,50 +13,86 @@ export async function render() {
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">模型配置</h1>
<p class="page-desc">管理 AI 模型 Provider 和模型列表</p>
<p class="page-desc">管理模型列表,选择默认主模型并一键应用</p>
</div>
<div class="config-actions">
<button class="btn btn-primary btn-sm" id="btn-add-provider">+ 添加 Provider</button>
<button class="btn btn-secondary btn-sm" id="btn-save-models">保存配置</button>
<button class="btn btn-secondary btn-sm" id="btn-save-models">保存模型配置</button>
<button class="btn btn-primary btn-sm" id="btn-apply-default">应用默认模型</button>
</div>
<div id="default-model-bar"></div>
<div id="providers-list">加载中...</div>
`
const state = { config: null }
await loadConfig(page, state)
page.querySelector('#btn-save-models').onclick = async () => {
const btn = page.querySelector('#btn-save-models')
btn.disabled = true
btn.textContent = '保存中...'
try {
await saveConfig(state)
} finally {
btn.disabled = false
btn.textContent = '保存配置'
}
}
page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state)
// 事件委托绑定
bindTopActions(page, state)
return page
}
async function loadConfig(page, state) {
try {
state.config = await api.readOpenclawConfig()
renderDefaultBar(page, state)
renderProviders(page, state)
} catch (e) {
toast('加载配置失败: ' + e, 'error')
}
}
// 获取当前默认主模型
function getCurrentPrimary(config) {
return config?.agents?.defaults?.model?.primary || ''
}
// 收集所有 provider/model-id 组合
function collectAllModels(config) {
const result = []
const providers = config?.models?.providers || {}
for (const [pk, pv] of Object.entries(providers)) {
for (const m of (pv.models || [])) {
const id = typeof m === 'string' ? m : m.id
if (id) result.push({ provider: pk, modelId: id, full: `${pk}/${id}` })
}
}
return result
}
// 渲染默认模型状态栏
function renderDefaultBar(page, state) {
const bar = page.querySelector('#default-model-bar')
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full)
bar.innerHTML = `
<div class="config-section" style="margin-bottom:var(--space-lg)">
<div class="config-section-title">当前应用配置</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<div>
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">主模型:</span>
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm);color:${primary ? 'var(--success)' : 'var(--error)'}">${primary || '未配置'}</span>
</div>
<div>
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Fallback</span>
<span style="font-size:var(--font-size-sm);color:var(--text-secondary)">${fallbacks.length ? fallbacks.join(', ') : '无'}</span>
</div>
</div>
</div>
`
}
// 渲染 Provider 列表
function renderProviders(page, state) {
const listEl = page.querySelector('#providers-list')
const providers = state.config?.models?.providers || {}
const keys = Object.keys(providers)
const primary = getCurrentPrimary(state.config)
if (!keys.length) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider 配置,点击上方按钮添加</div>'
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider点击上方按钮添加</div>'
return
}
@@ -65,116 +102,251 @@ function renderProviders(page, state) {
return `
<div class="config-section" data-provider="${key}">
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>${key}</span>
<span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${p.api || p.apiType || ''} · ${models.length} 个模型</span></span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" data-action="toggle">展开/收起</button>
<button class="btn btn-sm btn-secondary" data-action="edit-provider">编辑</button>
<button class="btn btn-sm btn-secondary" data-action="add-model">+ 模型</button>
<button class="btn btn-sm btn-danger" data-action="delete-provider">删除</button>
</div>
</div>
<div class="provider-meta" style="margin-bottom:12px">
<div class="form-group" style="margin-bottom:8px">
<label class="form-label">Base URL</label>
<input class="form-input" data-field="baseUrl" value="${p.baseUrl || ''}">
</div>
<div class="form-group" style="margin-bottom:8px">
<label class="form-label">API 类型</label>
<select class="form-input" data-field="apiType">
<option value="openai" ${p.apiType === 'openai' ? 'selected' : ''}>OpenAI</option>
<option value="anthropic" ${p.apiType === 'anthropic' ? 'selected' : ''}>Anthropic</option>
<option value="google" ${p.apiType === 'google' ? 'selected' : ''}>Google</option>
</select>
</div>
</div>
<div class="provider-models" style="display:none">
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-bottom:8px">
模型列表 (${models.length})
</div>
${models.map((m, i) => `
<div class="model-item" style="background:var(--bg-tertiary);padding:8px 12px;border-radius:var(--radius-sm);margin-bottom:6px;display:flex;justify-content:space-between;align-items:center">
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${m.id || m}</span>
<button class="btn btn-sm btn-danger" data-action="delete-model" data-index="${i}">删除</button>
</div>
`).join('')}
<button class="btn btn-sm btn-secondary" data-action="add-model" style="margin-top:4px">+ 添加模型</button>
<div class="provider-models">
${renderModelCards(key, models, primary)}
</div>
</div>
`
}).join('')
// 绑定事件
bindProviderEvents(page, state)
}
// 渲染单个 Provider 下的模型卡片
function renderModelCards(providerKey, models, primary) {
if (!models.length) {
return '<div style="color:var(--text-tertiary);font-size:var(--font-size-sm);padding:8px 0">暂无模型</div>'
}
return models.map((m, i) => {
const id = typeof m === 'string' ? m : m.id
const name = m.name || id
const full = `${providerKey}/${id}`
const isPrimary = full === primary
const borderColor = isPrimary ? 'var(--success)' : 'var(--border-primary)'
const bgColor = isPrimary ? 'var(--success-muted)' : 'var(--bg-tertiary)'
return `
<div class="model-card" data-index="${i}" data-full="${full}"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${id}</span>
${isPrimary ? '<span style="font-size:var(--font-size-xs);background:var(--success);color:var(--text-inverse);padding:1px 6px;border-radius:var(--radius-sm)">主模型</span>' : ''}
${m.reasoning ? '<span style="font-size:var(--font-size-xs);background:var(--accent-muted);color:var(--accent);padding:1px 6px;border-radius:var(--radius-sm)">Reasoning</span>' : ''}
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
${name !== id ? name + ' · ' : ''}${m.contextWindow ? (m.contextWindow / 1000) + 'K ctx' : ''}${m.cost?.input ? ' · $' + m.cost.input + '/$' + m.cost.output : ''}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
${!isPrimary ? `<button class="btn btn-sm btn-secondary" data-action="set-primary">设为主模型</button>` : ''}
<button class="btn btn-sm btn-secondary" data-action="edit-model">编辑</button>
<button class="btn btn-sm btn-danger" data-action="delete-model">删除</button>
</div>
</div>
`
}).join('')
}
// 绑定 Provider 列表内的事件
function bindProviderEvents(page, state) {
const listEl = page.querySelector('#providers-list')
listEl.querySelectorAll('[data-action]').forEach(btn => {
btn.onclick = () => {
const section = btn.closest('[data-provider]')
const providerKey = section.dataset.provider
const action = btn.dataset.action
if (action === 'toggle') {
const models = section.querySelector('.provider-models')
models.style.display = models.style.display === 'none' ? 'block' : 'none'
} else if (action === 'delete-provider') {
if (!confirm(`确定删除 Provider "${providerKey}"`)) return
if (action === 'delete-provider') {
if (!confirm(`确定删除 Provider "${providerKey}" 及其所有模型?`)) return
delete state.config.models.providers[providerKey]
renderProviders(page, state)
renderDefaultBar(page, state)
toast(`已删除 ${providerKey}`, 'info')
} else if (action === 'delete-model') {
const idx = parseInt(btn.dataset.index)
state.config.models.providers[providerKey].models.splice(idx, 1)
renderProviders(page, state)
} else if (action === 'add-model') {
showModal({
title: '添加模型',
fields: [{ name: 'id', label: '模型 ID', placeholder: '如 claude-opus-4-6' }],
onConfirm: ({ id }) => {
if (id) {
state.config.models.providers[providerKey].models.push({ id })
renderProviders(page, state)
}
},
})
addModel(page, state, providerKey)
} else if (action === 'edit-provider') {
editProvider(page, state, providerKey)
} else if (action === 'delete-model') {
const card = btn.closest('.model-card')
const idx = parseInt(card.dataset.index)
const models = state.config.models.providers[providerKey].models
models.splice(idx, 1)
renderProviders(page, state)
renderDefaultBar(page, state)
} else if (action === 'edit-model') {
const card = btn.closest('.model-card')
const idx = parseInt(card.dataset.index)
editModel(page, state, providerKey, idx)
} else if (action === 'set-primary') {
const card = btn.closest('.model-card')
const full = card.dataset.full
setPrimary(state, full)
renderProviders(page, state)
renderDefaultBar(page, state)
toast(`已设为主模型: ${full}`, 'success')
}
}
})
// 输入框变更实时同步到 state
listEl.querySelectorAll('[data-field]').forEach(input => {
input.oninput = () => {
const providerKey = input.closest('[data-provider]').dataset.provider
state.config.models.providers[providerKey][input.dataset.field] = input.value
}
})
}
// 设置主模型(仅修改 state不写入文件
function setPrimary(state, full) {
if (!state.config.agents) state.config.agents = {}
if (!state.config.agents.defaults) state.config.agents.defaults = {}
if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {}
state.config.agents.defaults.model.primary = full
}
// 顶部按钮事件绑定
function bindTopActions(page, state) {
page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state)
page.querySelector('#btn-save-models').onclick = async () => {
const btn = page.querySelector('#btn-save-models')
btn.disabled = true
btn.textContent = '保存中...'
try {
await api.writeOpenclawConfig(state.config)
toast('模型配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = '保存模型配置'
}
}
page.querySelector('#btn-apply-default').onclick = async () => {
const btn = page.querySelector('#btn-apply-default')
const primary = getCurrentPrimary(state.config)
if (!primary) {
toast('请先选择一个主模型', 'warning')
return
}
btn.disabled = true
btn.textContent = '应用中...'
try {
applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
renderDefaultBar(page, state)
toast('默认模型已应用', 'success')
} catch (e) {
toast('应用失败: ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = '应用默认模型'
}
}
}
// 应用默认模型primary + 其余自动成为 fallback
function applyDefaultModel(state) {
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full)
const defaults = state.config.agents.defaults
defaults.model.primary = primary
defaults.model.fallbacks = fallbacks
// 生成 models 映射(所有模型的空配置对象)
const modelsMap = {}
modelsMap[primary] = {}
for (const fb of fallbacks) modelsMap[fb] = {}
defaults.models = modelsMap
}
// 添加 Provider
function addProvider(page, state) {
showModal({
title: '添加 Provider',
fields: [
{ name: 'key', label: 'Provider 名称', placeholder: '如 openai, newapi' },
{ name: 'baseUrl', label: 'Base URL', placeholder: 'https://api.openai.com/v1' },
{
name: 'apiType', label: 'API 类型', type: 'select',
options: [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'google', label: 'Google' },
],
},
{ name: 'apiKey', label: 'API Key', placeholder: 'sk-...' },
],
onConfirm: ({ key, baseUrl, apiType }) => {
onConfirm: ({ key, baseUrl, apiKey }) => {
if (!key) return
if (!state.config.models) state.config.models = { mode: 'replace', providers: {} }
if (!state.config.models.providers) state.config.models.providers = {}
state.config.models.providers[key] = { baseUrl, apiType, models: [] }
state.config.models.providers[key] = {
baseUrl: baseUrl || '',
apiKey: apiKey || '',
api: 'openai-completions',
models: [],
}
renderProviders(page, state)
toast(`已添加 ${key}`, 'success')
toast(`已添加 Provider: ${key}`, 'success')
},
})
}
async function saveConfig(state) {
try {
await api.writeOpenclawConfig(state.config)
toast('配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
// 编辑 Provider 属性
function editProvider(page, state, providerKey) {
const p = state.config.models.providers[providerKey]
showModal({
title: `编辑 Provider: ${providerKey}`,
fields: [
{ name: 'baseUrl', label: 'Base URL', value: p.baseUrl || '' },
{ name: 'apiKey', label: 'API Key', value: p.apiKey || '' },
{ name: 'api', label: 'API 类型', value: p.api || 'openai-completions' },
],
onConfirm: ({ baseUrl, apiKey, api: apiType }) => {
p.baseUrl = baseUrl
p.apiKey = apiKey
p.api = apiType
renderProviders(page, state)
toast('Provider 已更新', 'success')
},
})
}
// 添加模型
function addModel(page, state, providerKey) {
showModal({
title: `添加模型到 ${providerKey}`,
fields: [
{ name: 'id', label: '模型 ID', placeholder: '如 claude-opus-4-6' },
{ name: 'name', label: '显示名称', placeholder: '如 Claude Opus 4.6' },
{ name: 'contextWindow', label: 'Context Window', placeholder: '如 200000' },
],
onConfirm: ({ id, name, contextWindow }) => {
if (!id) return
const model = { id, name: name || id, reasoning: false, input: ['text', 'image'] }
if (contextWindow) model.contextWindow = parseInt(contextWindow) || 0
state.config.models.providers[providerKey].models.push(model)
renderProviders(page, state)
renderDefaultBar(page, state)
toast(`已添加模型: ${id}`, 'success')
},
})
}
// 编辑模型属性
function editModel(page, state, providerKey, idx) {
const m = state.config.models.providers[providerKey].models[idx]
showModal({
title: `编辑模型: ${m.id}`,
fields: [
{ name: 'id', label: '模型 ID', value: m.id || '' },
{ name: 'name', label: '显示名称', value: m.name || '' },
{ name: 'contextWindow', label: 'Context Window', value: String(m.contextWindow || '') },
],
onConfirm: (vals) => {
if (!vals.id) return
m.id = vals.id
m.name = vals.name || vals.id
if (vals.contextWindow) m.contextWindow = parseInt(vals.contextWindow) || 0
renderProviders(page, state)
renderDefaultBar(page, state)
toast('模型已更新', 'success')
},
})
}

View File

@@ -1,9 +1,12 @@
/**
* 服务管理页面
* 服务启停 + 更新检测 + 配置备份管理
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
let _delegated = false
export async function render() {
const page = document.createElement('div')
page.className = 'page'
@@ -11,64 +14,194 @@ export async function render() {
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">服务管理</h1>
<p class="page-desc">管理 OpenClaw 相关的 launchd 服务</p>
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
</div>
<div id="version-bar"></div>
<div id="services-list">加载中...</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
<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>
`
loadServices(page)
bindEvents(page)
loadAll(page)
return page
}
async function loadServices(page) {
async function loadAll(page) {
await Promise.all([
loadVersion(page),
loadServices(page),
loadBackups(page),
])
}
// ===== 版本检测 =====
async function loadVersion(page) {
const bar = page.querySelector('#version-bar')
try {
const services = await api.getServicesStatus()
renderServices(page, services)
const info = await api.getVersionInfo()
const ver = info.current || '未知'
bar.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">当前版本</span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${info.update_available ? '有新版本可用' : '已是最新版本'}</div>
</div>
</div>
`
} catch (e) {
toast('加载服务状态失败: ' + e, 'error')
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">版本信息加载失败</div></div>`
}
}
function renderServices(page, services) {
const listEl = page.querySelector('#services-list')
listEl.innerHTML = services.map(s => `
// ===== 服务列表 =====
async function loadServices(page) {
const container = page.querySelector('#services-list')
try {
const services = await api.getServicesStatus()
renderServices(container, services)
} catch (e) {
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${e}</div>`
}
}
function renderServices(container, services) {
if (!services || !services.length) {
container.innerHTML = '<div style="color:var(--text-tertiary)">暂无服务</div>'
return
}
container.innerHTML = services.map(s => `
<div class="service-card" data-label="${s.label}">
<div class="service-info">
<span class="status-dot ${s.running ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${s.label}</div>
<div class="service-desc">${s.description}${s.pid ? ' · PID: ' + s.pid : ''}</div>
<div class="service-desc">${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
</div>
</div>
<div class="service-actions">
${s.running
? `<button class="btn btn-sm btn-secondary" data-action="stop">停止</button>
<button class="btn btn-sm btn-primary" data-action="restart">重启</button>`
: `<button class="btn btn-sm btn-primary" data-action="start">启动</button>`
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${s.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${s.label}">停止</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${s.label}">启动</button>`
}
</div>
</div>
`).join('')
}
// 绑定操作按钮
listEl.querySelectorAll('[data-action]').forEach(btn => {
btn.onclick = async () => {
const card = btn.closest('.service-card')
const label = card.dataset.label
const action = btn.dataset.action
btn.disabled = true
btn.textContent = '执行中...'
try {
if (action === 'start') await api.startService(label)
else if (action === 'stop') await api.stopService(label)
else if (action === 'restart') await api.restartService(label)
toast(`${label} ${action} 成功`, 'success')
setTimeout(() => loadServices(page), 300)
} catch (e) {
toast(`操作失败: ${e}`, 'error')
btn.disabled = false
btn.textContent = action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'
// ===== 备份管理 =====
async function loadBackups(page) {
const list = page.querySelector('#backup-list')
try {
const backups = await api.listBackups()
renderBackups(list, backups)
} catch (e) {
list.innerHTML = `<div style="color:var(--error)">加载备份列表失败: ${e}</div>`
}
}
function renderBackups(container, backups) {
if (!backups || !backups.length) {
container.innerHTML = '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无备份</div>'
return
}
container.innerHTML = backups.map(b => {
const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString('zh-CN') : '未知'
const size = b.size ? (b.size / 1024).toFixed(1) + ' KB' : ''
return `
<div class="service-card" data-backup="${b.name}">
<div class="service-info">
<div>
<div class="service-name">${b.name}</div>
<div class="service-desc">${date}${size ? ' · ' + size : ''}</div>
</div>
</div>
<div class="service-actions">
<button class="btn btn-primary btn-sm" data-action="restore-backup" data-name="${b.name}">恢复</button>
<button class="btn btn-danger btn-sm" data-action="delete-backup" data-name="${b.name}">删除</button>
</div>
</div>`
}).join('')
}
// ===== 事件绑定(事件委托) =====
function bindEvents(page) {
if (_delegated) return
_delegated = true
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
btn.disabled = true
try {
switch (action) {
case 'start':
case 'stop':
case 'restart':
await handleServiceAction(action, btn.dataset.label, page)
break
case 'create-backup':
await handleCreateBackup(page)
break
case 'restore-backup':
await handleRestoreBackup(btn.dataset.name, page)
break
case 'delete-backup':
await handleDeleteBackup(btn.dataset.name, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
} finally {
btn.disabled = false
}
})
}
// ===== 服务操作 =====
const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
async function handleServiceAction(action, label, page) {
const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action]
await fn(label)
toast(`${ACTION_LABELS[action]} ${label} 成功`, 'success')
await loadServices(page)
}
// ===== 备份操作 =====
async function handleCreateBackup(page) {
const result = await api.createBackup()
toast(`备份已创建: ${result.name}`, 'success')
await loadBackups(page)
}
async function handleRestoreBackup(name, page) {
if (!confirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)) return
await api.restoreBackup(name)
toast('配置已恢复', 'success')
await loadBackups(page)
}
async function handleDeleteBackup(name, page) {
if (!confirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)) return
await api.deleteBackup(name)
toast('备份已删除', 'success')
await loadBackups(page)
}