feat: 新增「关于」和「扩展工具」页面

- 关于页面:版本信息、相关项目链接、快捷链接、开源协议
- 扩展页面:cftunnel 隧道状态/路由/启停/日志 + ClawApp 状态/快捷访问
- Rust 后端:新增 extensions.rs(4 个命令:状态/操作/日志/ClawApp 检测)
- 侧边栏新增「扩展」和「关于」导航项,总计 8 个页面
This commit is contained in:
晴天
2026-02-27 00:53:01 +08:00
parent 1b9a195d32
commit fedd2f66fc
8 changed files with 538 additions and 1 deletions

View File

@@ -0,0 +1,160 @@
/// 扩展工具命令cftunnel + ClawApp
use serde_json::Value;
use std::process::Command;
/// 解析 cftunnel status 输出
fn parse_cftunnel_status(output: &str) -> serde_json::Map<String, Value> {
let mut map = serde_json::Map::new();
for line in output.lines() {
let line = line.trim();
if line.starts_with("隧道:") || line.starts_with("隧道:") {
let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim();
// "mac-home (uuid)" → 取名称
let name = rest.split('(').next().unwrap_or(rest).trim();
map.insert("tunnel_name".into(), Value::String(name.to_string()));
} else if line.starts_with("状态:") || line.starts_with("状态:") {
let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let running = rest.contains("运行中");
map.insert("running".into(), Value::Bool(running));
// 提取 PID
if let Some(pid_str) = rest.split("PID:").nth(1) {
let pid = pid_str.trim().trim_end_matches(')').trim();
if let Ok(p) = pid.parse::<u64>() {
map.insert("pid".into(), Value::Number(p.into()));
}
}
}
}
map
}
/// 解析 cftunnel list 输出为路由数组
fn parse_cftunnel_routes(output: &str) -> Vec<Value> {
let mut routes = Vec::new();
for line in output.lines() {
let line = line.trim();
// 跳过表头行
if line.is_empty() || line.starts_with("名称") || line.starts_with("---") {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), Value::String(parts[0].to_string()));
obj.insert("domain".into(), Value::String(parts[1].to_string()));
obj.insert("service".into(), Value::String(parts[2].to_string()));
routes.push(Value::Object(obj));
}
}
routes
}
fn cftunnel_bin() -> String {
// 优先查找用户 bin 目录
let home = dirs::home_dir().unwrap_or_default();
let user_bin = home.join("bin").join("cftunnel");
if user_bin.exists() {
return user_bin.to_string_lossy().to_string();
}
"cftunnel".to_string()
}
#[tauri::command]
pub fn get_cftunnel_status() -> Result<Value, String> {
let bin = cftunnel_bin();
let mut result = serde_json::Map::new();
// 检查是否安装
let version_out = Command::new(&bin).arg("version").output();
match version_out {
Ok(out) => {
let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
}
Err(_) => {
result.insert("installed".into(), Value::Bool(false));
return Ok(Value::Object(result));
}
}
// 获取状态
if let Ok(out) = Command::new(&bin).arg("status").output() {
let text = String::from_utf8_lossy(&out.stdout);
let status = parse_cftunnel_status(&text);
for (k, v) in status {
result.insert(k, v);
}
}
// 获取路由列表
if let Ok(out) = Command::new(&bin).arg("list").output() {
let text = String::from_utf8_lossy(&out.stdout);
let routes = parse_cftunnel_routes(&text);
result.insert("routes".into(), Value::Array(routes));
}
Ok(Value::Object(result))
}
#[tauri::command]
pub fn cftunnel_action(action: String) -> Result<(), String> {
let bin = cftunnel_bin();
match action.as_str() {
"up" | "down" => {}
_ => return Err(format!("不支持的操作: {action}")),
}
let output = Command::new(&bin)
.arg(&action)
.output()
.map_err(|e| format!("执行 cftunnel {action} 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("cftunnel {action} 失败: {stderr}"));
}
Ok(())
}
#[tauri::command]
pub fn get_cftunnel_logs(lines: Option<u32>) -> Result<String, String> {
let bin = cftunnel_bin();
let n = lines.unwrap_or(20).to_string();
let output = Command::new(&bin)
.args(["logs", "--tail", &n])
.output()
.map_err(|e| format!("读取 cftunnel 日志失败: {e}"))?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[tauri::command]
pub fn get_clawapp_status() -> Result<Value, String> {
let mut result = serde_json::Map::new();
// 用 lsof 检测 :3210 端口
let output = Command::new("lsof")
.args(["-i", ":3210", "-P", "-t"])
.output();
match output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if text.is_empty() {
result.insert("running".into(), Value::Bool(false));
} else {
result.insert("running".into(), Value::Bool(true));
if let Ok(pid) = text.lines().next().unwrap_or("").parse::<u64>() {
result.insert("pid".into(), Value::Number(pid.into()));
}
}
}
Err(_) => {
result.insert("running".into(), Value::Bool(false));
}
}
result.insert("port".into(), Value::Number(3210.into()));
result.insert("url".into(), Value::String("http://localhost:3210".into()));
Ok(Value::Object(result))
}

View File

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

View File

@@ -1,7 +1,7 @@
mod commands;
mod models;
use commands::{config, logs, memory, service};
use commands::{config, extensions, logs, memory, service};
pub fn run() {
tauri::Builder::default()
@@ -33,6 +33,11 @@ pub fn run() {
memory::write_memory_file,
memory::delete_memory_file,
memory::export_memory_zip,
// 扩展工具
extensions::get_cftunnel_status,
extensions::cftunnel_action,
extensions::get_cftunnel_logs,
extensions::get_clawapp_status,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");

View File

@@ -25,6 +25,18 @@ const NAV_ITEMS = [
items: [
{ route: '/memory', label: '记忆文件', icon: 'memory' },
]
},
{
section: '扩展',
items: [
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
]
},
{
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
]
}
]
@@ -35,6 +47,8 @@ const ICONS = {
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>',
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>',
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>',
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
}
let _delegated = false

View File

@@ -107,6 +107,18 @@ function mockInvoke(cmd, args) {
create_backup: () => ({ name: 'openclaw-20260226-160000.json', size: 8542 }),
restore_backup: () => true,
delete_backup: () => true,
get_cftunnel_status: () => ({
installed: true, version: 'cftunnel 0.7.0', running: true,
tunnel_name: 'mac-home', pid: 73325,
routes: [
{ name: 'clawapp', domain: 'chat.qrj.ai', service: 'http://localhost:3210' },
{ name: 'newapi', domain: 'newapi.qrj.ai', service: 'http://localhost:30080' },
{ name: 'webhook', domain: 'webhook.qrj.ai', service: 'http://localhost:9801' },
],
}),
cftunnel_action: () => true,
get_cftunnel_logs: () => '2026-02-26 13:29:01 [INFO] Tunnel started\n2026-02-26 13:30:00 [INFO] Connection healthy',
get_clawapp_status: () => ({ running: true, pid: 7752, port: 3210, url: 'http://localhost:3210' }),
}
const fn = mocks[cmd]
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
@@ -148,4 +160,10 @@ export const api = {
createBackup: () => invoke('create_backup'),
restoreBackup: (name) => invoke('restore_backup', { name }),
deleteBackup: (name) => invoke('delete_backup', { name }),
// 扩展工具
getCftunnelStatus: () => invoke('get_cftunnel_status'),
cftunnelAction: (action) => invoke('cftunnel_action', { action }),
getCftunnelLogs: (lines = 20) => invoke('get_cftunnel_logs', { lines }),
getClawappStatus: () => invoke('get_clawapp_status'),
}

View File

@@ -19,6 +19,8 @@ registerRoute('/logs', () => import('./pages/logs.js'))
registerRoute('/models', () => import('./pages/models.js'))
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/about', () => import('./pages/about.js'))
// 初始化主题
initTheme()

122
src/pages/about.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* 关于页面
* 版本信息、项目链接、相关项目、系统环境
*/
import { api } from '../lib/tauri-api.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">ClawPanel — OpenClaw 可视化管理面板</p>
</div>
<div class="stat-cards" id="version-cards">
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
</div>
<div class="config-section">
<div class="config-section-title">相关项目</div>
<div id="projects-list"></div>
</div>
<div class="config-section">
<div class="config-section-title">快捷链接</div>
<div id="links-list"></div>
</div>
<div class="config-section" style="color:var(--text-tertiary);font-size:var(--font-size-xs)">
<p>ClawPanel 基于 Tauri v2 构建,前端 Vanilla JS + Vite后端 Rust。</p>
<p style="margin-top:8px">MIT License &copy; 2026 qingchencloud</p>
</div>
`
loadData(page)
renderProjects(page)
renderLinks(page)
return page
}
async function loadData(page) {
const cards = page.querySelector('#version-cards')
try {
const [version, install] = await Promise.all([
api.getVersionInfo(),
api.checkInstallation(),
])
cards.innerHTML = `
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">ClawPanel</span></div>
<div class="stat-card-value">0.1.0</div>
<div class="stat-card-meta">Tauri v2 桌面应用</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">OpenClaw</span></div>
<div class="stat-card-value">${version.current || '未知'}</div>
<div class="stat-card-meta">${version.update_available ? '有新版本可用' : '已是最新'}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">安装路径</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm);word-break:break-all">${install.path || '未知'}</div>
<div class="stat-card-meta">${install.installed ? '已安装' : '未安装'}</div>
</div>
`
} catch {
cards.innerHTML = '<div class="stat-card"><div class="stat-card-label">加载失败</div></div>'
}
}
const PROJECTS = [
{
name: 'OpenClaw',
desc: 'AI Agent 框架,支持多模型协作、工具调用、记忆管理',
url: 'https://github.com/openclaw-labs/openclaw',
},
{
name: 'ClawApp',
desc: '跨平台移动聊天客户端H5 + 代理服务器架构,支持离线和流式传输',
url: 'https://github.com/qingchencloud/clawapp',
},
{
name: 'cftunnel',
desc: '全协议内网穿透工具Cloud 模式免费 HTTP/WS + Relay 模式自建中继',
url: 'https://github.com/qingchencloud/cftunnel',
},
{
name: 'ClawPanel',
desc: 'OpenClaw 可视化管理面板Tauri v2 桌面应用',
url: 'https://github.com/qingchencloud/clawpanel',
},
]
function renderProjects(page) {
const el = page.querySelector('#projects-list')
el.innerHTML = PROJECTS.map(p => `
<div class="service-card">
<div class="service-info">
<div>
<div class="service-name">${p.name}</div>
<div class="service-desc">${p.desc}</div>
</div>
</div>
<div class="service-actions">
<a class="btn btn-secondary btn-sm" href="${p.url}" target="_blank" rel="noopener">GitHub</a>
</div>
</div>
`).join('')
}
const LINKS = [
{ label: 'cftunnel 官网', url: 'https://cftunnel.qt.cool' },
{ label: 'cftunnel 桌面客户端', url: 'https://github.com/qingchencloud/cftunnel-app/releases' },
{ label: 'OpenClaw 中文翻译', url: 'https://github.com/1186258278/OpenClawChineseTranslation' },
{ label: 'ClawApp 文档', url: 'https://github.com/qingchencloud/clawapp#readme' },
]
function renderLinks(page) {
const el = page.querySelector('#links-list')
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-sm)">
${LINKS.map(l => `<a class="btn btn-secondary btn-sm" href="${l.url}" target="_blank" rel="noopener">${l.label}</a>`).join('')}
</div>`
}

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

@@ -0,0 +1,215 @@
/**
* 扩展工具页面
* cftunnel 隧道管理 + ClawApp 状态
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
let _delegated = false
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">扩展工具</h1>
<p class="page-desc">管理 cftunnel 内网穿透和 ClawApp 移动客户端</p>
</div>
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div id="cftunnel-content">加载中...</div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div id="clawapp-content">加载中...</div>
</div>
`
bindEvents(page)
loadAll(page)
return page
}
async function loadAll(page) {
await Promise.all([
loadCftunnel(page),
loadClawapp(page),
])
}
// ===== cftunnel =====
async function loadCftunnel(page) {
const el = page.querySelector('#cftunnel-content')
try {
const status = await api.getCftunnelStatus()
renderCftunnel(el, status)
} catch (e) {
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
}
}
function renderCftunnel(el, s) {
if (!s.installed) {
el.innerHTML = `
<div style="color:var(--text-tertiary)">cftunnel 未安装</div>
<a class="btn btn-primary btn-sm" href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener" style="margin-top:var(--space-md)">前往安装</a>
`
return
}
const running = s.running
const routes = s.routes || []
el.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-md)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">状态</span>
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${s.tunnel_name || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">版本</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-md)">${s.version || '未知'}</div>
<div class="stat-card-meta">${routes.length} 条路由</div>
</div>
</div>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-md)">
${running
? '<button class="btn btn-danger btn-sm" data-action="cftunnel-down">停止隧道</button>'
: '<button class="btn btn-primary btn-sm" data-action="cftunnel-up">启动隧道</button>'
}
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">查看日志</button>
<button class="btn btn-secondary btn-sm" data-action="cftunnel-refresh">刷新</button>
</div>
${renderRoutes(routes)}
<div id="cftunnel-logs-area"></div>
`
}
function renderRoutes(routes) {
if (!routes.length) return '<div style="color:var(--text-tertiary)">暂无路由</div>'
return `
<table class="data-table" style="margin-bottom:0">
<thead><tr><th>名称</th><th>域名</th><th>本地服务</th></tr></thead>
<tbody>
${routes.map(r => `
<tr>
<td>${r.name}</td>
<td><a href="https://${r.domain}" target="_blank" rel="noopener">${r.domain}</a></td>
<td><code>${r.service}</code></td>
</tr>
`).join('')}
</tbody>
</table>
`
}
// ===== ClawApp =====
async function loadClawapp(page) {
const el = page.querySelector('#clawapp-content')
try {
const status = await api.getClawappStatus()
renderClawapp(el, status)
} catch (e) {
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
}
}
function renderClawapp(el, s) {
const running = s.running
el.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-md)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">状态</span>
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${s.pid ? 'PID: ' + s.pid : ''}${s.port ? ' 端口: ' + s.port : ''}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">访问地址</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${s.url || 'http://localhost:3210'}</div>
<div class="stat-card-meta">外网: chat.qrj.ai</div>
</div>
</div>
<div style="display:flex;gap:var(--space-sm)">
<a class="btn btn-primary btn-sm" href="${s.url || 'http://localhost:3210'}" target="_blank" rel="noopener">打开 ClawApp</a>
<a class="btn btn-secondary btn-sm" href="https://chat.qrj.ai" target="_blank" rel="noopener">打开外网地址</a>
<button class="btn btn-secondary btn-sm" data-action="clawapp-refresh">刷新</button>
</div>
`
}
// ===== 事件绑定 =====
function bindEvents(page) {
if (_delegated) return
_delegated = true
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
switch (action) {
case 'cftunnel-up':
await handleCftunnelAction(page, 'up')
break
case 'cftunnel-down':
await handleCftunnelAction(page, 'down')
break
case 'cftunnel-logs':
await handleCftunnelLogs(page)
break
case 'cftunnel-refresh':
await loadCftunnel(page)
break
case 'clawapp-refresh':
await loadClawapp(page)
break
}
})
}
async function handleCftunnelAction(page, action) {
const label = action === 'up' ? '启动' : '停止'
try {
toast(`正在${label}隧道...`, 'info')
await api.cftunnelAction(action)
toast(`隧道已${label}`, 'success')
await loadCftunnel(page)
} catch (e) {
toast(`${label}失败: ${e}`, 'error')
}
}
async function handleCftunnelLogs(page) {
const area = page.querySelector('#cftunnel-logs-area')
if (!area) return
// 切换显示
if (area.innerHTML) {
area.innerHTML = ''
return
}
try {
const logs = await api.getCftunnelLogs(30)
area.innerHTML = `
<div style="margin-top:var(--space-md)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-sm)">
<span style="font-weight:600;font-size:var(--font-size-sm)">最近日志</span>
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">收起</button>
</div>
<pre class="log-viewer">${logs || '暂无日志'}</pre>
</div>
`
} catch (e) {
area.innerHTML = `<div style="color:var(--error);margin-top:var(--space-sm)">读取日志失败: ${e}</div>`
}
}