mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
feat: ClawPanel v0.1.0 项目骨架
- Tauri v2 + Vanilla JS + Vite 技术栈 - 9 个页面: 仪表盘/服务管理/日志/模型配置/Agent配置/Gateway/MCP工具/记忆文件/部署 - Rust 后端: 配置读写/服务管理(launchd)/日志读取/记忆文件管理 - 暗色主题 + 玻璃拟态 UI - Mock 数据支持纯浏览器开发调试
This commit is contained in:
118
src/pages/agents.js
Normal file
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
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
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
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
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
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
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
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
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' ? '停止' : '重启'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user