mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-07 04:22:42 +08:00
feat: 精简页面结构并增强核心功能
- 删除 MCP 配置、Agent 配置、部署 3 个页面,保留 6 个核心页面 - 重写模型配置页:Provider/模型 CRUD + 一键应用默认模型(自动生成 fallback) - 增强服务管理页:版本检测 + 配置备份管理(创建/恢复/删除) - 增强记忆文件页:单个文件下载 + 分类打包 zip 下载 - Rust 后端新增 5 个命令(4 个备份 + export_memory_zip) - 更新路由和侧边栏,同步清理
This commit is contained in:
53
src-tauri/Cargo.lock
generated
53
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 失败");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
164
src/pages/mcp.js
164
src/pages/mcp.js
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user