feat: ClawPanel v0.1.0 项目骨架

- Tauri v2 + Vanilla JS + Vite 技术栈
- 9 个页面: 仪表盘/服务管理/日志/模型配置/Agent配置/Gateway/MCP工具/记忆文件/部署
- Rust 后端: 配置读写/服务管理(launchd)/日志读取/记忆文件管理
- 暗色主题 + 玻璃拟态 UI
- Mock 数据支持纯浏览器开发调试
This commit is contained in:
晴天
2026-02-26 22:34:55 +08:00
commit e26c4d9307
54 changed files with 13839 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
src-tauri/target/
*.log
.DS_Store

16
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

18
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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,
})
}

View 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)
}

View 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}"))
}

View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod logs;
pub mod memory;
pub mod service;

View 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
View 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
View File

@@ -0,0 +1,6 @@
// ClawPanel 入口
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
clawpanel_lib::run()
}

View File

@@ -0,0 +1 @@
pub mod types;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

127
src/pages/deploy.js Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

119
src/pages/gateway.js Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

135
src/pages/memory.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
})