feat: ClawPanel v0.1.0 项目骨架
- Tauri v2 + Vanilla JS + Vite 技术栈 - 9 个页面: 仪表盘/服务管理/日志/模型配置/Agent配置/Gateway/MCP工具/记忆文件/部署 - Rust 后端: 配置读写/服务管理(launchd)/日志读取/记忆文件管理 - 暗色主题 + 玻璃拟态 UI - Mock 数据支持纯浏览器开发调试
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
*.log
|
||||
.DS_Store
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ClawPanel</title>
|
||||
<!-- 样式由 main.js 通过 Vite 统一加载 -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar"></aside>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1308
package-lock.json
generated
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
4934
src-tauri/Cargo.lock
generated
Normal file
18
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "clawpanel_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
2564
src-tauri/gen/schemas/desktop-schema.json
Normal file
2564
src-tauri/gen/schemas/macOS-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 448 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/icon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/icon.iconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
src-tauri/icons/icon.iconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 806 B |
BIN
src-tauri/icons/icon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.iconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/icon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 806 B |
BIN
src-tauri/icons/icon.iconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
72
src-tauri/src/commands/config.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
/// 配置读写命令
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::models::types::VersionInfo;
|
||||
|
||||
fn openclaw_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".openclaw")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_openclaw_config() -> Result<Value, String> {
|
||||
let path = openclaw_dir().join("openclaw.json");
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_openclaw_config(config: Value) -> Result<(), String> {
|
||||
let path = openclaw_dir().join("openclaw.json");
|
||||
// 备份
|
||||
let bak = openclaw_dir().join("openclaw.json.bak");
|
||||
let _ = fs::copy(&path, &bak);
|
||||
// 写入
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_mcp_config() -> Result<Value, String> {
|
||||
let path = openclaw_dir().join("mcp.json");
|
||||
if !path.exists() {
|
||||
return Ok(Value::Object(Default::default()));
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取 MCP 配置失败: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_mcp_config(config: Value) -> Result<(), String> {
|
||||
let path = openclaw_dir().join("mcp.json");
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_version_info() -> Result<VersionInfo, String> {
|
||||
// 从 openclaw.json 的 meta.lastTouchedVersion 读取
|
||||
let config = read_openclaw_config()?;
|
||||
let current = config
|
||||
.get("meta")
|
||||
.and_then(|m| m.get("lastTouchedVersion"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
Ok(VersionInfo {
|
||||
current,
|
||||
latest: None,
|
||||
update_available: false,
|
||||
})
|
||||
}
|
||||
69
src-tauri/src/commands/logs.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
/// 日志读取命令
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn log_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".openclaw")
|
||||
.join("logs")
|
||||
}
|
||||
|
||||
fn log_path(log_name: &str) -> PathBuf {
|
||||
let filename = match log_name {
|
||||
"gateway" => "gateway.log",
|
||||
"gateway-err" => "gateway.err.log",
|
||||
"guardian" => "guardian.log",
|
||||
"guardian-backup" => "guardian-backup.log",
|
||||
"config-audit" => "config-audit.jsonl",
|
||||
_ => "gateway.log",
|
||||
};
|
||||
log_dir().join(filename)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_log_tail(log_name: String, lines: usize) -> Result<String, String> {
|
||||
let path = log_path(&log_name);
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取日志失败: {e}"))?;
|
||||
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let start = if all_lines.len() > lines {
|
||||
all_lines.len() - lines
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(all_lines[start..].join("\n"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_log(
|
||||
log_name: String,
|
||||
query: String,
|
||||
max_results: usize,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let path = log_path(&log_name);
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let file = fs::File::open(&path)
|
||||
.map_err(|e| format!("打开日志失败: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let results: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|l| l.ok())
|
||||
.filter(|l| l.to_lowercase().contains(&query_lower))
|
||||
.take(max_results)
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
112
src-tauri/src/commands/memory.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
/// 记忆文件管理命令
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn openclaw_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".openclaw")
|
||||
}
|
||||
|
||||
fn memory_dir(category: &str) -> PathBuf {
|
||||
match category {
|
||||
"memory" => openclaw_dir().join("workspace").join("memory"),
|
||||
"archive" => openclaw_dir().join("workspace-memory"),
|
||||
"core" => openclaw_dir().join("workspace"),
|
||||
_ => openclaw_dir().join("workspace").join("memory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_memory_files(category: String) -> Result<Vec<String>, String> {
|
||||
let dir = memory_dir(&category);
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_files(&dir, &dir, &mut files, &category)?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_files(
|
||||
base: &PathBuf,
|
||||
dir: &PathBuf,
|
||||
files: &mut Vec<String>,
|
||||
category: &str,
|
||||
) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// core 类别只读根目录的 .md 文件
|
||||
if category != "core" {
|
||||
collect_files(base, &path, files, category)?;
|
||||
}
|
||||
} else {
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if matches!(ext, "md" | "txt" | "json" | "jsonl") {
|
||||
let rel = path.strip_prefix(base)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_memory_file(path: String) -> Result<String, String> {
|
||||
// 安全检查:路径不能包含 ..
|
||||
if path.contains("..") {
|
||||
return Err("非法路径".to_string());
|
||||
}
|
||||
|
||||
// 尝试在各个记忆目录下查找
|
||||
let candidates = [
|
||||
memory_dir("memory").join(&path),
|
||||
memory_dir("archive").join(&path),
|
||||
memory_dir("core").join(&path),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return fs::read_to_string(candidate)
|
||||
.map_err(|e| format!("读取失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("文件不存在: {path}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_memory_file(path: String, content: String) -> Result<(), String> {
|
||||
if path.contains("..") {
|
||||
return Err("非法路径".to_string());
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
memory_dir("memory").join(&path),
|
||||
memory_dir("archive").join(&path),
|
||||
memory_dir("core").join(&path),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return fs::write(candidate, &content)
|
||||
.map_err(|e| format!("写入失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 默认写入 memory 目录
|
||||
let target = memory_dir("memory").join(&path);
|
||||
if let Some(parent) = target.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::write(&target, &content)
|
||||
.map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod logs;
|
||||
pub mod memory;
|
||||
pub mod service;
|
||||
91
src-tauri/src/commands/service.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
/// 服务管理命令 (macOS launchd)
|
||||
use std::process::Command;
|
||||
|
||||
use crate::models::types::ServiceStatus;
|
||||
|
||||
const SERVICES: &[(&str, &str)] = &[
|
||||
("ai.openclaw.gateway", "OpenClaw Gateway"),
|
||||
("com.openclaw.guardian.watch", "健康监控 (60s)"),
|
||||
("com.openclaw.guardian.backup", "配置备份 (3600s)"),
|
||||
("com.openclaw.watchdog", "看门狗 (120s)"),
|
||||
];
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||||
let output = Command::new("launchctl")
|
||||
.arg("list")
|
||||
.output()
|
||||
.map_err(|e| format!("执行 launchctl 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (label, desc) in SERVICES {
|
||||
let mut status = ServiceStatus {
|
||||
label: label.to_string(),
|
||||
pid: None,
|
||||
running: false,
|
||||
description: desc.to_string(),
|
||||
};
|
||||
|
||||
// 解析 launchctl list 输出: PID\tStatus\tLabel
|
||||
for line in stdout.lines() {
|
||||
if line.contains(label) {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 3 {
|
||||
if let Ok(pid) = parts[0].trim().parse::<u32>() {
|
||||
status.pid = Some(pid);
|
||||
status.running = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
results.push(status);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn plist_path(label: &str) -> String {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
format!(
|
||||
"{}/Library/LaunchAgents/{}.plist",
|
||||
home.display(),
|
||||
label
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_service(label: String) -> Result<(), String> {
|
||||
let path = plist_path(&label);
|
||||
Command::new("launchctl")
|
||||
.args(["load", &path])
|
||||
.output()
|
||||
.map_err(|e| format!("启动失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_service(label: String) -> Result<(), String> {
|
||||
let path = plist_path(&label);
|
||||
Command::new("launchctl")
|
||||
.args(["unload", &path])
|
||||
.output()
|
||||
.map_err(|e| format!("停止失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_service(label: String) -> Result<(), String> {
|
||||
let path = plist_path(&label);
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["unload", &path])
|
||||
.output();
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
Command::new("launchctl")
|
||||
.args(["load", &path])
|
||||
.output()
|
||||
.map_err(|e| format!("重启失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
31
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
mod commands;
|
||||
mod models;
|
||||
|
||||
use commands::{config, logs, memory, service};
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 配置
|
||||
config::read_openclaw_config,
|
||||
config::write_openclaw_config,
|
||||
config::read_mcp_config,
|
||||
config::write_mcp_config,
|
||||
config::get_version_info,
|
||||
// 服务
|
||||
service::get_services_status,
|
||||
service::start_service,
|
||||
service::stop_service,
|
||||
service::restart_service,
|
||||
// 日志
|
||||
logs::read_log_tail,
|
||||
logs::search_log,
|
||||
// 记忆文件
|
||||
memory::list_memory_files,
|
||||
memory::read_memory_file,
|
||||
memory::write_memory_file,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("启动 ClawPanel 失败");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// ClawPanel 入口
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
clawpanel_lib::run()
|
||||
}
|
||||
1
src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod types;
|
||||
16
src-tauri/src/models/types.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServiceStatus {
|
||||
pub label: String,
|
||||
pub pid: Option<u32>,
|
||||
pub running: bool,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VersionInfo {
|
||||
pub current: Option<String>,
|
||||
pub latest: Option<String>,
|
||||
pub update_available: bool,
|
||||
}
|
||||
39
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ClawPanel",
|
||||
"width": 1100,
|
||||
"height": 700,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"decorations": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
77
src/components/sidebar.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 侧边导航栏
|
||||
*/
|
||||
import { navigate, getCurrentRoute } from '../router.js'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
section: '概览',
|
||||
items: [
|
||||
{ route: '/dashboard', label: '仪表盘', icon: 'dashboard' },
|
||||
{ route: '/services', label: '服务管理', icon: 'services' },
|
||||
{ route: '/logs', label: '日志查看', icon: 'logs' },
|
||||
]
|
||||
},
|
||||
{
|
||||
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' },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ICONS = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
|
||||
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>',
|
||||
}
|
||||
|
||||
export function renderSidebar(el) {
|
||||
const current = getCurrentRoute()
|
||||
|
||||
let html = `
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">CP</div>
|
||||
<span class="sidebar-title">ClawPanel</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
`
|
||||
|
||||
for (const section of NAV_ITEMS) {
|
||||
html += `<div class="nav-section">
|
||||
<div class="nav-section-title">${section.section}</div>`
|
||||
|
||||
for (const item of section.items) {
|
||||
const active = current === item.route ? ' active' : ''
|
||||
html += `<div class="nav-item${active}" data-route="${item.route}">
|
||||
${ICONS[item.icon] || ''}
|
||||
<span>${item.label}</span>
|
||||
</div>`
|
||||
}
|
||||
html += '</div>'
|
||||
}
|
||||
|
||||
html += '</nav>'
|
||||
el.innerHTML = html
|
||||
|
||||
// 绑定点击事件
|
||||
el.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.onclick = () => navigate(item.dataset.route)
|
||||
})
|
||||
}
|
||||
28
src/components/toast.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Toast 通知组件
|
||||
*/
|
||||
let _container = null
|
||||
|
||||
function ensureContainer() {
|
||||
if (!_container) {
|
||||
_container = document.createElement('div')
|
||||
_container.className = 'toast-container'
|
||||
document.body.appendChild(_container)
|
||||
}
|
||||
return _container
|
||||
}
|
||||
|
||||
export function toast(message, type = 'info', duration = 3000) {
|
||||
const container = ensureContainer()
|
||||
const el = document.createElement('div')
|
||||
el.className = `toast ${type}`
|
||||
el.textContent = message
|
||||
container.appendChild(el)
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0'
|
||||
el.style.transform = 'translateX(20px)'
|
||||
el.style.transition = 'all 250ms ease'
|
||||
setTimeout(() => el.remove(), 250)
|
||||
}, duration)
|
||||
}
|
||||
73
src/lib/tauri-api.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Tauri API 封装层
|
||||
* 开发阶段用 mock 数据,Tauri 环境用 invoke
|
||||
*/
|
||||
|
||||
const isTauri = !!window.__TAURI__
|
||||
|
||||
async function invoke(cmd, args = {}) {
|
||||
if (isTauri) {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
|
||||
return tauriInvoke(cmd, args)
|
||||
}
|
||||
// 开发模式 mock
|
||||
return mockInvoke(cmd, args)
|
||||
}
|
||||
|
||||
// Mock 数据,方便纯浏览器开发调试
|
||||
function mockInvoke(cmd, args) {
|
||||
const mocks = {
|
||||
get_services_status: () => [
|
||||
{ label: 'ai.openclaw.gateway', pid: 54284, running: true, description: 'OpenClaw Gateway' },
|
||||
{ label: 'com.openclaw.guardian.watch', pid: 54301, running: true, description: '健康监控 (60s)' },
|
||||
{ label: 'com.openclaw.guardian.backup', pid: null, running: false, description: '配置备份 (3600s)' },
|
||||
{ label: 'com.openclaw.watchdog', pid: 54320, running: true, description: '看门狗 (120s)' },
|
||||
],
|
||||
get_version_info: () => ({
|
||||
current: '2026.2.23',
|
||||
latest: null,
|
||||
update_available: false,
|
||||
}),
|
||||
read_openclaw_config: () => ({
|
||||
meta: { lastTouchedVersion: '2026.2.23' },
|
||||
models: { mode: 'replace', providers: {} },
|
||||
agents: { defaults: { model: { primary: 'newapi-claude/claude-opus-4-6', fallbacks: [] } } },
|
||||
gateway: { port: 18789, mode: 'local', bind: 'loopback' },
|
||||
}),
|
||||
read_log_tail: () => '2026-02-26 13:29:01 [INFO] Gateway started on :18789\n2026-02-26 13:29:02 [INFO] Agent connected\n',
|
||||
list_memory_files: () => [],
|
||||
read_mcp_config: () => ({}),
|
||||
}
|
||||
const fn = mocks[cmd]
|
||||
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
|
||||
}
|
||||
|
||||
// 导出 API
|
||||
export const api = {
|
||||
// 服务管理
|
||||
getServicesStatus: () => invoke('get_services_status'),
|
||||
startService: (label) => invoke('start_service', { label }),
|
||||
stopService: (label) => invoke('stop_service', { label }),
|
||||
restartService: (label) => invoke('restart_service', { label }),
|
||||
|
||||
// 配置
|
||||
getVersionInfo: () => invoke('get_version_info'),
|
||||
readOpenclawConfig: () => invoke('read_openclaw_config'),
|
||||
writeOpenclawConfig: (config) => invoke('write_openclaw_config', { config }),
|
||||
readMcpConfig: () => invoke('read_mcp_config'),
|
||||
writeMcpConfig: (config) => invoke('write_mcp_config', { config }),
|
||||
|
||||
// 日志
|
||||
readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }),
|
||||
searchLog: (logName, query, maxResults = 50) => invoke('search_log', { logName, query, maxResults }),
|
||||
|
||||
// 记忆文件
|
||||
listMemoryFiles: (category) => invoke('list_memory_files', { category }),
|
||||
readMemoryFile: (path) => invoke('read_memory_file', { path }),
|
||||
writeMemoryFile: (path, content) => invoke('write_memory_file', { path, content }),
|
||||
|
||||
// 安装/部署
|
||||
checkInstallation: () => invoke('check_installation'),
|
||||
getDeployConfig: () => invoke('get_deploy_config'),
|
||||
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),
|
||||
}
|
||||
33
src/main.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* ClawPanel 入口
|
||||
*/
|
||||
import { registerRoute, initRouter } from './router.js'
|
||||
import { renderSidebar } from './components/sidebar.js'
|
||||
|
||||
// 样式
|
||||
import './style/variables.css'
|
||||
import './style/reset.css'
|
||||
import './style/layout.css'
|
||||
import './style/components.css'
|
||||
import './style/pages.css'
|
||||
|
||||
// 注册页面路由(懒加载)
|
||||
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'))
|
||||
|
||||
// 初始化
|
||||
const sidebar = document.getElementById('sidebar')
|
||||
const content = document.getElementById('content')
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
|
||||
// 路由变化时刷新侧边栏高亮
|
||||
window.addEventListener('hashchange', () => renderSidebar(sidebar))
|
||||
118
src/pages/agents.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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 = () => saveConfig(page, state)
|
||||
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)
|
||||
model.fallbacks.splice(idx, 1)
|
||||
renderConfig(page, state)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加 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')
|
||||
}
|
||||
}
|
||||
133
src/pages/dashboard.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 仪表盘页面
|
||||
*/
|
||||
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">仪表盘</h1>
|
||||
<p class="page-desc">OpenClaw 运行状态概览</p>
|
||||
</div>
|
||||
<div class="stat-cards" id="stat-cards">
|
||||
<div class="stat-card loading-placeholder"></div>
|
||||
<div class="stat-card loading-placeholder"></div>
|
||||
<div class="stat-card loading-placeholder"></div>
|
||||
<div class="stat-card loading-placeholder"></div>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="btn btn-secondary" id="btn-restart-gw">重启 Gateway</button>
|
||||
<button class="btn btn-secondary" id="btn-check-update">检查更新</button>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">最近日志</div>
|
||||
<div class="log-viewer" id="recent-logs" style="max-height:300px">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 异步加载数据
|
||||
loadDashboardData(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadDashboardData(page) {
|
||||
try {
|
||||
const [services, version, logs] = await Promise.all([
|
||||
api.getServicesStatus(),
|
||||
api.getVersionInfo(),
|
||||
api.readLogTail('gateway', 20),
|
||||
])
|
||||
|
||||
renderStatCards(page, services, version)
|
||||
renderLogs(page, logs)
|
||||
bindActions(page)
|
||||
} catch (e) {
|
||||
toast('加载仪表盘数据失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatCards(page, services, version) {
|
||||
const cardsEl = page.querySelector('#stat-cards')
|
||||
const gw = services.find(s => s.label.includes('gateway'))
|
||||
const guardian = services.find(s => s.label.includes('guardian.watch'))
|
||||
const watchdog = services.find(s => s.label.includes('watchdog'))
|
||||
const runningCount = services.filter(s => s.running).length
|
||||
|
||||
cardsEl.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Gateway</span>
|
||||
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">
|
||||
${gw?.pid ? 'PID: ' + gw.pid : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Guardian</span>
|
||||
<span class="status-dot ${guardian?.running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${guardian?.running ? '运行中' : '已停止'}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">健康监控</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Watchdog</span>
|
||||
<span class="status-dot ${watchdog?.running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${watchdog?.running ? '运行中' : '已停止'}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">看门狗</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">版本</span>
|
||||
</div>
|
||||
<div class="stat-card-value">${version.current || '未知'}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">
|
||||
服务 ${runningCount}/${services.length} 运行中
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderLogs(page, logs) {
|
||||
const logsEl = page.querySelector('#recent-logs')
|
||||
if (!logs) { logsEl.textContent = '暂无日志'; return }
|
||||
const lines = logs.trim().split('\n')
|
||||
logsEl.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
|
||||
logsEl.scrollTop = logsEl.scrollHeight
|
||||
}
|
||||
|
||||
function bindActions(page) {
|
||||
page.querySelector('#btn-restart-gw')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.restartService('ai.openclaw.gateway')
|
||||
toast('Gateway 已重启', 'success')
|
||||
} catch (e) {
|
||||
toast('重启失败: ' + e, 'error')
|
||||
}
|
||||
})
|
||||
|
||||
page.querySelector('#btn-check-update')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
if (info.update_available) {
|
||||
toast(`发现新版本: ${info.latest}`, 'info')
|
||||
} else {
|
||||
toast('已是最新版本', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('检查更新失败: ' + e, 'error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
127
src/pages/deploy.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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="" 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, '>')
|
||||
}
|
||||
119
src/pages/gateway.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Gateway 配置页面
|
||||
*/
|
||||
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">Gateway 配置</h1>
|
||||
<p class="page-desc">配置 OpenClaw Gateway 端口、绑定和认证</p>
|
||||
</div>
|
||||
<div id="gateway-config">加载中...</div>
|
||||
<div style="margin-top:16px">
|
||||
<button class="btn btn-primary" id="btn-save-gw">保存配置</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { config: null }
|
||||
await loadConfig(page, state)
|
||||
page.querySelector('#btn-save-gw').onclick = () => saveConfig(page, state)
|
||||
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('#gateway-config')
|
||||
const gw = state.config?.gateway || {}
|
||||
|
||||
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="form-group">
|
||||
<label class="form-label">端口</label>
|
||||
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">绑定模式</label>
|
||||
<select class="form-input" id="gw-bind">
|
||||
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>Loopback (仅本机)</option>
|
||||
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>All (所有接口)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">运行模式</label>
|
||||
<select class="form-input" id="gw-mode">
|
||||
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>Local</option>
|
||||
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>Remote</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">认证</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Auth Token</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="form-input" id="gw-token" type="password" value="${gw.authToken || ''}" placeholder="留空则无认证" style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">Tailscale</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tailscale 地址</label>
|
||||
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="如 100.x.x.x:18789">
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 切换密码可见
|
||||
el.querySelector('#btn-toggle-token').onclick = () => {
|
||||
const input = el.querySelector('#gw-token')
|
||||
const btn = el.querySelector('#btn-toggle-token')
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text'
|
||||
btn.textContent = '隐藏'
|
||||
} else {
|
||||
input.type = 'password'
|
||||
btn.textContent = '显示'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(page, state) {
|
||||
const port = parseInt(page.querySelector('#gw-port')?.value) || 18789
|
||||
const bind = page.querySelector('#gw-bind')?.value || 'loopback'
|
||||
const mode = page.querySelector('#gw-mode')?.value || 'local'
|
||||
const authToken = page.querySelector('#gw-token')?.value || ''
|
||||
const tailscaleAddr = page.querySelector('#gw-tailscale')?.value || ''
|
||||
|
||||
state.config.gateway = {
|
||||
...state.config.gateway,
|
||||
port, bind, mode, authToken,
|
||||
tailscale: tailscaleAddr ? { address: tailscaleAddr } : undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
toast('Gateway 配置已保存', 'success')
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
110
src/pages/logs.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 日志查看页面
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const LOG_TABS = [
|
||||
{ key: 'gateway', label: 'Gateway' },
|
||||
{ key: 'gateway-err', label: 'Gateway Err' },
|
||||
{ key: 'guardian', label: 'Guardian' },
|
||||
{ key: 'guardian-backup', label: 'Backup' },
|
||||
{ key: 'config-audit', label: '审计日志' },
|
||||
]
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">日志查看</h1>
|
||||
<p class="page-desc">查看 OpenClaw 各服务日志</p>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
${LOG_TABS.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${t.key}">${t.label}</div>`).join('')}
|
||||
</div>
|
||||
<div class="log-toolbar">
|
||||
<input type="text" class="form-input" id="log-search" placeholder="搜索日志..." style="max-width:300px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh">刷新</button>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);color:var(--text-secondary)">
|
||||
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)">加载中...</div>
|
||||
`
|
||||
|
||||
let currentTab = 'gateway'
|
||||
|
||||
// Tab 切换
|
||||
page.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.onclick = () => {
|
||||
page.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
currentTab = tab.dataset.tab
|
||||
loadLog(page, currentTab)
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索
|
||||
let searchTimer = null
|
||||
page.querySelector('#log-search').addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
if (e.target.value.trim()) {
|
||||
searchLog(page, currentTab, e.target.value.trim())
|
||||
} else {
|
||||
loadLog(page, currentTab)
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 刷新
|
||||
page.querySelector('#btn-refresh').onclick = () => loadLog(page, currentTab)
|
||||
|
||||
loadLog(page, currentTab)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadLog(page, logName) {
|
||||
const el = page.querySelector('#log-content')
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">加载中...</div>'
|
||||
try {
|
||||
const content = await api.readLogTail(logName, 200)
|
||||
if (!content || !content.trim()) {
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">暂无日志</div>'
|
||||
return
|
||||
}
|
||||
const lines = content.trim().split('\n')
|
||||
el.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
|
||||
if (page.querySelector('#log-autoscroll')?.checked) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
} catch (e) {
|
||||
toast('加载日志失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLog(page, logName, query) {
|
||||
const el = page.querySelector('#log-content')
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">搜索中...</div>'
|
||||
try {
|
||||
const results = await api.searchLog(logName, query)
|
||||
if (!results || !results.length) {
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">未找到匹配结果</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML = results.map(l => `<div class="log-line">${highlightMatch(escapeHtml(l), query)}</div>`).join('')
|
||||
} catch (e) {
|
||||
toast('搜索失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function highlightMatch(html, query) {
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return html.replace(new RegExp(escaped, 'gi'), m => `<mark>${m}</mark>`)
|
||||
}
|
||||
144
src/pages/mcp.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* MCP 工具配置页面
|
||||
*/
|
||||
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">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 = () => saveConfig(state)
|
||||
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 (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) {
|
||||
const name = prompt('输入 MCP Server 名称:')
|
||||
if (!name) return
|
||||
const target = state.config?.mcpServers || state.config
|
||||
target[name] = { 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, '>')
|
||||
}
|
||||
135
src/pages/memory.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 记忆文件管理页面
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'memory', label: '工作记忆' },
|
||||
{ key: 'archive', label: '记忆归档' },
|
||||
{ key: 'core', label: '核心文件' },
|
||||
]
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">记忆文件</h1>
|
||||
<p class="page-desc">管理 OpenClaw 工作记忆和归档文件</p>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
${CATEGORIES.map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
|
||||
</div>
|
||||
<div class="memory-layout">
|
||||
<div class="memory-sidebar" id="file-tree">加载中...</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-preview" disabled>预览</button>
|
||||
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="editor-area" id="file-editor" placeholder="选择左侧文件进行编辑..." disabled></textarea>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { category: 'memory', currentPath: null }
|
||||
|
||||
// Tab 切换
|
||||
page.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.onclick = () => {
|
||||
page.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
state.category = tab.dataset.tab
|
||||
state.currentPath = null
|
||||
resetEditor(page)
|
||||
loadFiles(page, state)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存
|
||||
page.querySelector('#btn-save-file').onclick = () => saveFile(page, state)
|
||||
|
||||
loadFiles(page, state)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadFiles(page, state) {
|
||||
const tree = page.querySelector('#file-tree')
|
||||
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">加载中...</div>'
|
||||
|
||||
try {
|
||||
const files = await api.listMemoryFiles(state.category)
|
||||
if (!files || !files.length) {
|
||||
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无文件</div>'
|
||||
return
|
||||
}
|
||||
renderFileTree(page, state, files)
|
||||
} catch (e) {
|
||||
toast('加载文件列表失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileTree(page, state, files) {
|
||||
const tree = page.querySelector('#file-tree')
|
||||
tree.innerHTML = files.map(f => {
|
||||
const name = f.split('/').pop()
|
||||
const active = state.currentPath === f ? ' active' : ''
|
||||
return `<div class="file-item${active}" data-path="${f}">${name}</div>`
|
||||
}).join('')
|
||||
|
||||
tree.querySelectorAll('.file-item').forEach(item => {
|
||||
item.onclick = () => {
|
||||
state.currentPath = item.dataset.path
|
||||
tree.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'))
|
||||
item.classList.add('active')
|
||||
loadFileContent(page, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadFileContent(page, state) {
|
||||
const editor = page.querySelector('#file-editor')
|
||||
const label = page.querySelector('#current-file')
|
||||
const btnSave = page.querySelector('#btn-save-file')
|
||||
const btnPreview = page.querySelector('#btn-preview')
|
||||
|
||||
editor.disabled = true
|
||||
editor.value = '加载中...'
|
||||
label.textContent = state.currentPath
|
||||
|
||||
try {
|
||||
const content = await api.readMemoryFile(state.currentPath)
|
||||
editor.value = content || ''
|
||||
editor.disabled = false
|
||||
btnSave.disabled = false
|
||||
btnPreview.disabled = false
|
||||
} catch (e) {
|
||||
editor.value = '读取失败: ' + e
|
||||
toast('读取文件失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditor(page) {
|
||||
const editor = page.querySelector('#file-editor')
|
||||
editor.value = ''
|
||||
editor.disabled = true
|
||||
page.querySelector('#current-file').textContent = '选择文件查看'
|
||||
page.querySelector('#btn-save-file').disabled = true
|
||||
page.querySelector('#btn-preview').disabled = true
|
||||
}
|
||||
|
||||
async function saveFile(page, state) {
|
||||
if (!state.currentPath) return
|
||||
const content = page.querySelector('#file-editor').value
|
||||
try {
|
||||
await api.writeMemoryFile(state.currentPath, content)
|
||||
toast('文件已保存', 'success')
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
147
src/pages/models.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 模型配置页面
|
||||
*/
|
||||
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">模型配置</h1>
|
||||
<p class="page-desc">管理 AI 模型 Provider 和模型列表</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>
|
||||
</div>
|
||||
<div id="providers-list">加载中...</div>
|
||||
`
|
||||
|
||||
const state = { config: null }
|
||||
await loadConfig(page, state)
|
||||
|
||||
page.querySelector('#btn-save-models').onclick = () => saveConfig(state)
|
||||
page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadConfig(page, state) {
|
||||
try {
|
||||
state.config = await api.readOpenclawConfig()
|
||||
renderProviders(page, state)
|
||||
} catch (e) {
|
||||
toast('加载配置失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderProviders(page, state) {
|
||||
const listEl = page.querySelector('#providers-list')
|
||||
const providers = state.config?.models?.providers || {}
|
||||
const keys = Object.keys(providers)
|
||||
|
||||
if (!keys.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider 配置,点击上方按钮添加</div>'
|
||||
return
|
||||
}
|
||||
|
||||
listEl.innerHTML = keys.map(key => {
|
||||
const p = providers[key]
|
||||
const models = p.models || []
|
||||
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>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">展开/收起</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>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
// 绑定事件
|
||||
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') {
|
||||
delete state.config.models.providers[providerKey]
|
||||
renderProviders(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') {
|
||||
const id = prompt('输入模型 ID:')
|
||||
if (id) {
|
||||
state.config.models.providers[providerKey].models.push({ id })
|
||||
renderProviders(page, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 输入框变更同步到 state
|
||||
listEl.querySelectorAll('[data-field]').forEach(input => {
|
||||
input.onchange = () => {
|
||||
const providerKey = input.closest('[data-provider]').dataset.provider
|
||||
state.config.models.providers[providerKey][input.dataset.field] = input.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addProvider(page, state) {
|
||||
const key = prompt('输入 Provider 名称 (如 openai, newapi):')
|
||||
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: 'openai', models: [] }
|
||||
renderProviders(page, state)
|
||||
toast(`已添加 ${key}`, 'success')
|
||||
}
|
||||
|
||||
async function saveConfig(state) {
|
||||
try {
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
toast('配置已保存', 'success')
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
74
src/pages/services.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 服务管理页面
|
||||
*/
|
||||
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">服务管理</h1>
|
||||
<p class="page-desc">管理 OpenClaw 相关的 launchd 服务</p>
|
||||
</div>
|
||||
<div id="services-list">加载中...</div>
|
||||
`
|
||||
|
||||
loadServices(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadServices(page) {
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
renderServices(page, services)
|
||||
} catch (e) {
|
||||
toast('加载服务状态失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderServices(page, services) {
|
||||
const listEl = page.querySelector('#services-list')
|
||||
listEl.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>
|
||||
</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>`
|
||||
}
|
||||
</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')
|
||||
loadServices(page)
|
||||
} catch (e) {
|
||||
toast(`操作失败: ${e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
44
src/router.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 极简 hash 路由
|
||||
*/
|
||||
const routes = {}
|
||||
let _contentEl = null
|
||||
|
||||
export function registerRoute(path, loader) {
|
||||
routes[path] = loader
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
export function initRouter(contentEl) {
|
||||
_contentEl = contentEl
|
||||
window.addEventListener('hashchange', () => loadRoute())
|
||||
loadRoute()
|
||||
}
|
||||
|
||||
async function loadRoute() {
|
||||
const hash = window.location.hash.slice(1) || '/dashboard'
|
||||
const loader = routes[hash]
|
||||
if (!loader || !_contentEl) return
|
||||
|
||||
_contentEl.innerHTML = ''
|
||||
const mod = await loader()
|
||||
// 动态 import 返回模块对象,调用 render() 获取页面元素
|
||||
const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod
|
||||
if (typeof page === 'string') {
|
||||
_contentEl.innerHTML = page
|
||||
} else if (page instanceof HTMLElement) {
|
||||
_contentEl.appendChild(page)
|
||||
}
|
||||
|
||||
// 更新侧边栏激活状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.route === hash)
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentRoute() {
|
||||
return window.location.hash.slice(1) || '/dashboard'
|
||||
}
|
||||
170
src/style/components.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
/* 状态卡片 */
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.stat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 状态点 */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.running { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.status-dot.stopped { background: var(--error); }
|
||||
.status-dot.warning { background: var(--warning); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error-muted);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, 0.25); }
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Toast 通知 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: var(--space-lg);
|
||||
right: var(--space-lg);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
backdrop-filter: blur(12px);
|
||||
animation: slideIn 250ms ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast.success { background: var(--success-muted); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
|
||||
.toast.error { background: var(--error-muted); border: 1px solid rgba(239,68,68,0.3); color: var(--error); }
|
||||
.toast.info { background: var(--info-muted); border: 1px solid rgba(59,130,246,0.3); color: var(--info); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Tab 栏 */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-item:hover { color: var(--text-secondary); }
|
||||
.tab-item.active {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
130
src/style/layout.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* 侧边栏 */
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: width var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-lg) var(--space-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-sm) var(--space-sm);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--space-sm) var(--space-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
#content {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--space-xl) var(--space-2xl);
|
||||
max-width: 1200px;
|
||||
animation: fadeIn 200ms ease;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
152
src/style/pages.css
Normal file
@@ -0,0 +1,152 @@
|
||||
/* 仪表盘 */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* 服务卡片 */
|
||||
.service-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.service-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.service-desc {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.service-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 日志查看器 */
|
||||
.log-viewer {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 240px);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.log-line:hover {
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
/* 配置编辑区 */
|
||||
.config-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.config-section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
/* 记忆文件管理 */
|
||||
.memory-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: var(--space-lg);
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.file-item:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
|
||||
.file-item.active { background: var(--accent-muted); color: var(--accent-hover); }
|
||||
|
||||
.editor-area {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.editor-content textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
59
src/style/reset.css
Normal file
@@ -0,0 +1,59 @@
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
75
src/style/variables.css
Normal file
@@ -0,0 +1,75 @@
|
||||
:root {
|
||||
/* 颜色系统 - 暗色主题 */
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-tertiary: #1a1a26;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-glass: rgba(255, 255, 255, 0.05);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--border-primary: rgba(255, 255, 255, 0.08);
|
||||
--border-secondary: rgba(255, 255, 255, 0.04);
|
||||
--border-focus: rgba(99, 102, 241, 0.5);
|
||||
|
||||
--text-primary: #e4e4e7;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-tertiary: #71717a;
|
||||
--text-inverse: #0a0a0f;
|
||||
|
||||
/* 强调色 */
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--accent-muted: rgba(99, 102, 241, 0.15);
|
||||
|
||||
/* 状态色 */
|
||||
--success: #22c55e;
|
||||
--success-muted: rgba(34, 197, 94, 0.15);
|
||||
--warning: #f59e0b;
|
||||
--warning-muted: rgba(245, 158, 11, 0.15);
|
||||
--error: #ef4444;
|
||||
--error-muted: rgba(239, 68, 68, 0.15);
|
||||
--info: #3b82f6;
|
||||
--info-muted: rgba(59, 130, 246, 0.15);
|
||||
|
||||
/* 间距 */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--space-2xl: 32px;
|
||||
--space-3xl: 48px;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
/* 字体 */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-md: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 24px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15);
|
||||
|
||||
/* 动效 */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
/* 布局 */
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed: 60px;
|
||||
--header-height: 52px;
|
||||
}
|
||||
15
vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: ['es2021', 'chrome100', 'safari13'],
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
})
|
||||