mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 21:00:30 +08:00
refactor: 全局重构原生弹窗为自定义 Modal 并同步更新项目文档
- 替换所有不可用的 `alert`, `confirm`, `prompt` 调用为异步的自定义 `Modal` 组件以适配 Tauri WebView 的 API 限制。 - 优化与重构核心服务组件接口,增加模型有效性测试 (`test_model`) 以及依赖更新支持。 - 同步补齐 `README.md` 与 `CHANGELOG.md` 新增的系统特性说明(含仪表盘、日记、存储、重构页面调整)。
This commit is contained in:
@@ -1,21 +1,90 @@
|
||||
/**
|
||||
* Modal 弹窗组件
|
||||
*/
|
||||
|
||||
// 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构
|
||||
function escapeAttr(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义确认弹窗,替代原生 confirm()
|
||||
* Tauri WebView 不支持原生 confirm/alert,必须用自定义弹窗
|
||||
* @param {string} message 确认消息
|
||||
* @returns {Promise<boolean>} 用户选择确认返回 true,取消返回 false
|
||||
*/
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:400px">
|
||||
<div class="modal-title">确认操作</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(message)}</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="confirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const close = (result) => {
|
||||
overlay.remove()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close(false)
|
||||
})
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = () => close(false)
|
||||
overlay.querySelector('[data-action="confirm"]').onclick = () => close(true)
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); close(true) }
|
||||
else if (e.key === 'Escape') close(false)
|
||||
})
|
||||
// 聚焦确认按钮以接收键盘事件
|
||||
overlay.querySelector('[data-action="confirm"]').focus()
|
||||
})
|
||||
}
|
||||
|
||||
export function showModal({ title, fields, onConfirm }) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
|
||||
const fieldHtml = fields.map(f => `
|
||||
<div class="form-group">
|
||||
<label class="form-label">${f.label}</label>
|
||||
${f.type === 'select'
|
||||
? `<select class="form-input" data-name="${f.name}">
|
||||
const fieldHtml = fields.map(f => {
|
||||
if (f.type === 'checkbox') {
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" data-name="${f.name}" ${f.value ? 'checked' : ''}>
|
||||
<span class="form-label" style="margin:0">${f.label}</span>
|
||||
</label>
|
||||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||||
</div>`
|
||||
}
|
||||
if (f.type === 'select') {
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="form-label">${f.label}</label>
|
||||
<select class="form-input" data-name="${f.name}">
|
||||
${f.options.map(o => `<option value="${o.value}" ${o.value === f.value ? 'selected' : ''}>${o.label}</option>`).join('')}
|
||||
</select>`
|
||||
: `<input class="form-input" data-name="${f.name}" value="${f.value || ''}" placeholder="${f.placeholder || ''}">`
|
||||
}
|
||||
</div>
|
||||
`).join('')
|
||||
</select>
|
||||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||||
</div>`
|
||||
}
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="form-label">${f.label}</label>
|
||||
<input class="form-input" data-name="${f.name}" value="${escapeAttr(f.value)}" placeholder="${escapeAttr(f.placeholder)}">
|
||||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
@@ -40,7 +109,11 @@ export function showModal({ title, fields, onConfirm }) {
|
||||
overlay.querySelector('[data-action="confirm"]').onclick = () => {
|
||||
const result = {}
|
||||
overlay.querySelectorAll('[data-name]').forEach(el => {
|
||||
result[el.dataset.name] = el.value
|
||||
if (el.type === 'checkbox') {
|
||||
result[el.dataset.name] = el.checked
|
||||
} else {
|
||||
result[el.dataset.name] = el.value
|
||||
}
|
||||
})
|
||||
overlay.remove()
|
||||
onConfirm(result)
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
* 开发阶段用 mock 数据,Tauri 环境用 invoke
|
||||
*/
|
||||
|
||||
const isTauri = !!window.__TAURI__
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,12 +18,6 @@ function mockInvoke(cmd, args) {
|
||||
const mocks = {
|
||||
get_services_status: () => [
|
||||
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway' },
|
||||
{ label: 'com.cftunnel.cloudflared', pid: 35218, running: true, description: 'cftunnel 隧道服务' },
|
||||
{ label: 'com.openclaw.guardian.watch', pid: 55290, running: true, description: '健康监控 (60s)' },
|
||||
{ label: 'com.openclaw.guardian.backup', pid: null, running: false, description: '配置备份 (3600s)' },
|
||||
{ label: 'com.openclaw.watchdog', pid: null, running: false, description: '看门狗 (120s)' },
|
||||
{ label: 'com.openclaw.webhook-router', pid: 38983, running: true, description: 'Webhook 路由' },
|
||||
{ label: 'com.openclaw.webhook-tunnel', pid: null, running: false, description: 'Webhook SSH 隧道' },
|
||||
],
|
||||
get_version_info: () => ({
|
||||
current: '2026.2.23',
|
||||
@@ -38,7 +31,7 @@ function mockInvoke(cmd, args) {
|
||||
providers: {
|
||||
'newapi-claude': {
|
||||
baseUrl: 'http://192.168.1.14:30080/v1',
|
||||
apiType: 'openai',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{ id: 'claude-opus-4-6' },
|
||||
{ id: 'claude-sonnet-4-5' },
|
||||
@@ -103,7 +96,11 @@ function mockInvoke(cmd, args) {
|
||||
stop_service: () => true,
|
||||
restart_service: () => true,
|
||||
reload_gateway: () => 'Gateway 已重载',
|
||||
test_model: ({ base_url, model_id }) => `模型 ${model_id} 连通正常 (mock)`,
|
||||
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
|
||||
install_gateway: () => 'Gateway 服务已安装 (mock)',
|
||||
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
|
||||
test_model: ({ modelId }) => `模型 ${modelId} 连通正常 (mock)`,
|
||||
list_remote_models: () => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o3-mini', 'dall-e-3', 'text-embedding-3-small'],
|
||||
write_env_file: () => true,
|
||||
list_backups: () => [
|
||||
{ name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 },
|
||||
@@ -144,7 +141,11 @@ export const api = {
|
||||
readMcpConfig: () => invoke('read_mcp_config'),
|
||||
writeMcpConfig: (config) => invoke('write_mcp_config', { config }),
|
||||
reloadGateway: () => invoke('reload_gateway'),
|
||||
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { base_url: baseUrl, api_key: apiKey, model_id: modelId }),
|
||||
upgradeOpenclaw: () => invoke('upgrade_openclaw'),
|
||||
installGateway: () => invoke('install_gateway'),
|
||||
uninstallGateway: () => invoke('uninstall_gateway'),
|
||||
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }),
|
||||
listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }),
|
||||
|
||||
// 日志
|
||||
readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }),
|
||||
@@ -153,7 +154,7 @@ export const api = {
|
||||
// 记忆文件
|
||||
listMemoryFiles: (category) => invoke('list_memory_files', { category }),
|
||||
readMemoryFile: (path) => invoke('read_memory_file', { path }),
|
||||
writeMemoryFile: (path, content) => invoke('write_memory_file', { path, content }),
|
||||
writeMemoryFile: (path, content, category) => invoke('write_memory_file', { path, content, category: category || 'memory' }),
|
||||
deleteMemoryFile: (path) => invoke('delete_memory_file', { path }),
|
||||
exportMemoryZip: (category) => invoke('export_memory_zip', { category }),
|
||||
|
||||
|
||||
@@ -31,6 +31,3 @@ const content = document.getElementById('content')
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
|
||||
// 路由变化时刷新侧边栏高亮
|
||||
window.addEventListener('hashchange', () => renderSidebar(sidebar))
|
||||
|
||||
@@ -3,21 +3,29 @@
|
||||
* 版本信息、项目链接、相关项目、系统环境
|
||||
*/
|
||||
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">ClawPanel — OpenClaw 可视化管理面板</p>
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:16px">
|
||||
<img src="/images/logo.svg" alt="ClawPanel" style="width:48px;height:48px;border-radius:var(--radius-md)">
|
||||
<div>
|
||||
<h1 class="page-title" style="margin:0">ClawPanel</h1>
|
||||
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>
|
||||
</div>
|
||||
</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="community-section"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">相关项目</div>
|
||||
<div id="projects-list"></div>
|
||||
@@ -33,6 +41,7 @@ export async function render() {
|
||||
`
|
||||
|
||||
loadData(page)
|
||||
renderCommunity(page)
|
||||
renderProjects(page)
|
||||
renderLinks(page)
|
||||
return page
|
||||
@@ -45,28 +54,88 @@ async function loadData(page) {
|
||||
api.getVersionInfo(),
|
||||
api.checkInstallation(),
|
||||
])
|
||||
|
||||
// 尝试从 Tauri API 获取 ClawPanel 自身版本号,失败则 fallback
|
||||
let panelVersion = '0.1.0'
|
||||
try {
|
||||
const { getVersion } = await import('@tauri-apps/api/app')
|
||||
panelVersion = await getVersion()
|
||||
} catch {
|
||||
// 非 Tauri 环境或 API 不可用,使用 fallback
|
||||
}
|
||||
|
||||
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-value">${panelVersion}</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 class="stat-card-value">${version.current || '未安装'}</div>
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px">
|
||||
${version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span><button class="btn btn-primary btn-sm" id="btn-upgrade" style="padding:2px 8px;font-size:var(--font-size-xs)">升级</button>`
|
||||
: version.current ? '<span style="color:var(--success)">已是最新</span>' : '<span style="color:var(--error)">未检测到</span>'}
|
||||
</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 class="stat-card-meta">${install.installed ? '配置文件存在' : '未找到配置文件'}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 绑定升级按钮
|
||||
const upgradeBtn = cards.querySelector('#btn-upgrade')
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.onclick = async () => {
|
||||
upgradeBtn.disabled = true
|
||||
upgradeBtn.textContent = '升级中...'
|
||||
try {
|
||||
const msg = await api.upgradeOpenclaw()
|
||||
upgradeBtn.textContent = '完成'
|
||||
// 刷新版本信息
|
||||
loadData(page)
|
||||
} catch (e) {
|
||||
upgradeBtn.disabled = false
|
||||
upgradeBtn.textContent = '升级失败'
|
||||
toast('升级失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
cards.innerHTML = '<div class="stat-card"><div class="stat-card-label">加载失败</div></div>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommunity(page) {
|
||||
const el = page.querySelector('#community-section')
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
|
||||
<div style="text-align:center">
|
||||
<img src="/images/OpenClaw-QQ.png" alt="QQ 交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">QQ 交流群</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<img src="/images/OpenClawWx.png" alt="微信交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">微信交流群</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:8px;padding-top:4px">
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">扫码或点击链接加入交流群,反馈问题、获取帮助</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px">
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">加入 QQ 群</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">加入微信群</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://yb.tencent.com/gp/i/LsvIw7mdR7Lb" target="_blank" rel="noopener">元宝派社群</a>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:8px">
|
||||
2000 人大群,满员自动切换 · 碰到问题可直接在群内反馈
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const PROJECTS = [
|
||||
{
|
||||
name: 'OpenClaw',
|
||||
|
||||
@@ -35,26 +35,28 @@ export async function render() {
|
||||
}
|
||||
|
||||
async function loadDashboardData(page) {
|
||||
try {
|
||||
const [services, version, logs] = await Promise.all([
|
||||
api.getServicesStatus(),
|
||||
api.getVersionInfo(),
|
||||
api.readLogTail('gateway', 20),
|
||||
])
|
||||
const [servicesRes, versionRes, logsRes] = await Promise.allSettled([
|
||||
api.getServicesStatus(),
|
||||
api.getVersionInfo(),
|
||||
api.readLogTail('gateway', 20),
|
||||
])
|
||||
|
||||
renderStatCards(page, services, version)
|
||||
renderLogs(page, logs)
|
||||
bindActions(page)
|
||||
} catch (e) {
|
||||
toast('加载仪表盘数据失败: ' + e, 'error')
|
||||
}
|
||||
const services = servicesRes.status === 'fulfilled' ? servicesRes.value : []
|
||||
const version = versionRes.status === 'fulfilled' ? versionRes.value : {}
|
||||
const logs = logsRes.status === 'fulfilled' ? logsRes.value : ''
|
||||
|
||||
if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error')
|
||||
if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error')
|
||||
if (logsRes.status === 'rejected') toast('日志加载失败', 'error')
|
||||
|
||||
renderStatCards(page, services, version)
|
||||
renderLogs(page, logs)
|
||||
bindActions(page)
|
||||
}
|
||||
|
||||
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 gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const runningCount = services.filter(s => s.running).length
|
||||
|
||||
cardsEl.innerHTML = `
|
||||
@@ -64,30 +66,21 @@ function renderStatCards(page, services, version) {
|
||||
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
|
||||
<div class="stat-card-meta">${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 class="stat-card-meta">健康监控</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 class="stat-card-meta">看门狗</div>
|
||||
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : '未启动'}</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 class="stat-card-meta">服务 ${runningCount}/${services.length} 运行中</div>
|
||||
<div class="stat-card-meta">${version.update_available ? '有新版本: ' + version.latest : '已是最新'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">服务</span>
|
||||
</div>
|
||||
<div class="stat-card-value">${runningCount}/${services.length}</div>
|
||||
<div class="stat-card-meta">运行中</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
let _delegated = false
|
||||
// HTML 转义,防止 XSS
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -18,10 +26,12 @@ export async function render() {
|
||||
</div>
|
||||
<div id="cftunnel-card" class="config-section">
|
||||
<div class="config-section-title">cftunnel 内网穿透</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
|
||||
<div id="cftunnel-content">加载中...</div>
|
||||
</div>
|
||||
<div id="clawapp-card" class="config-section">
|
||||
<div class="config-section-title">ClawApp 移动客户端</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
|
||||
<div id="clawapp-content">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
@@ -150,9 +160,6 @@ function renderClawapp(el, s) {
|
||||
// ===== 事件绑定 =====
|
||||
|
||||
function bindEvents(page) {
|
||||
if (_delegated) return
|
||||
_delegated = true
|
||||
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
@@ -206,7 +213,7 @@ async function handleCftunnelLogs(page) {
|
||||
<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>
|
||||
<pre class="log-viewer">${escapeHtml(logs) || '暂无日志'}</pre>
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
|
||||
@@ -55,21 +55,24 @@ function renderConfig(page, state) {
|
||||
<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 class="form-hint">Gateway 监听的本地端口号,范围 1024-65535。修改后需重载服务生效。</div>
|
||||
</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>
|
||||
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>仅本机 (Loopback)</option>
|
||||
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>所有接口 (All)</option>
|
||||
</select>
|
||||
<div class="form-hint">仅本机:只允许本机访问(127.0.0.1)。所有接口:允许局域网内其他设备通过 IP 访问。</div>
|
||||
</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>
|
||||
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>本地模式</option>
|
||||
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>远程模式</option>
|
||||
</select>
|
||||
<div class="form-hint">本地模式:Gateway 直接调用本机模型服务。远程模式:Gateway 转发请求到远程 API 端点。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +84,7 @@ function renderConfig(page, state) {
|
||||
<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 class="form-hint">访问 Gateway 时需要携带的认证令牌。留空表示不启用认证,任何人都可以直接调用。建议在开放网络环境下设置。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +93,7 @@ function renderConfig(page, state) {
|
||||
<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 class="form-hint">通过 Tailscale 组网暴露 Gateway 的地址。填写后,远程设备可通过此地址访问。留空表示不使用 Tailscale。</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -117,7 +122,7 @@ async function saveConfig(page, state) {
|
||||
state.config.gateway = {
|
||||
...state.config.gateway,
|
||||
port, bind, mode, authToken,
|
||||
tailscale: tailscaleAddr ? { address: tailscaleAddr } : (state.config.gateway?.tailscale || undefined),
|
||||
tailscale: tailscaleAddr.trim() ? { address: tailscaleAddr.trim() } : undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,10 +5,10 @@ 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: 'gateway', label: 'Gateway 日志' },
|
||||
{ key: 'gateway-err', label: 'Gateway 错误' },
|
||||
{ key: 'guardian', label: '守护进程' },
|
||||
{ key: 'guardian-backup', label: '备份日志' },
|
||||
{ key: 'config-audit', label: '审计日志' },
|
||||
]
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import { toast } from '../components/toast.js'
|
||||
import { showModal } from '../components/modal.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'memory', label: '工作记忆' },
|
||||
{ key: 'archive', label: '记忆归档' },
|
||||
{ key: 'core', label: '核心文件' },
|
||||
{ key: 'memory', label: '工作记忆', desc: '当前活跃的工作上下文、决策记录和进度追踪' },
|
||||
{ key: 'archive', label: '记忆归档', desc: '已归档的历史记忆文件,按时间周期整理' },
|
||||
{ key: 'core', label: '核心文件', desc: 'Agent 核心配置文件,如 AGENTS.md、CLAUDE.md 等' },
|
||||
]
|
||||
|
||||
export async function render() {
|
||||
@@ -23,6 +23,7 @@ export async function render() {
|
||||
<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="form-hint" id="category-desc" style="margin-bottom:var(--space-md)">${CATEGORIES[0].desc}</div>
|
||||
<div class="memory-layout">
|
||||
<div class="memory-sidebar">
|
||||
<div style="padding:0 var(--space-sm) var(--space-sm);display:flex;gap:4px">
|
||||
@@ -57,6 +58,8 @@ export async function render() {
|
||||
tab.classList.add('active')
|
||||
state.category = tab.dataset.tab
|
||||
state.currentPath = null
|
||||
const cat = CATEGORIES.find(c => c.key === state.category)
|
||||
page.querySelector('#category-desc').textContent = cat?.desc || ''
|
||||
resetEditor(page)
|
||||
loadFiles(page, state)
|
||||
}
|
||||
@@ -72,11 +75,11 @@ export async function render() {
|
||||
page.querySelector('#btn-new-file').onclick = () => {
|
||||
showModal({
|
||||
title: '新建记忆文件',
|
||||
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md' }],
|
||||
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md', hint: '建议使用 .md 格式,文件将保存到当前分类目录下' }],
|
||||
onConfirm: async ({ filename }) => {
|
||||
if (!filename) return
|
||||
try {
|
||||
await api.writeMemoryFile(filename, `# ${filename}\n\n`)
|
||||
await api.writeMemoryFile(filename, `# ${filename}\n\n`, state.category)
|
||||
toast(`已创建 ${filename}`, 'success')
|
||||
loadFiles(page, state)
|
||||
} catch (e) {
|
||||
@@ -90,7 +93,9 @@ export async function render() {
|
||||
page.querySelector('#btn-del-file').onclick = async () => {
|
||||
if (!state.currentPath) return
|
||||
const name = state.currentPath.split('/').pop()
|
||||
if (!confirm(`确定删除 ${name}?`)) return
|
||||
const { showConfirm } = await import('../components/modal.js')
|
||||
const yes = await showConfirm(`确定删除 ${name}?`)
|
||||
if (!yes) return
|
||||
try {
|
||||
await api.deleteMemoryFile(state.currentPath)
|
||||
toast(`已删除 ${name}`, 'success')
|
||||
@@ -270,7 +275,16 @@ async function exportZip(state) {
|
||||
try {
|
||||
const zipPath = await api.exportMemoryZip(state.category)
|
||||
const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category
|
||||
toast(`已导出: ${label} → ${zipPath}`, 'success')
|
||||
// 尝试用 Tauri shell open 打开文件所在目录
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-shell')
|
||||
const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath
|
||||
await open(dir)
|
||||
toast(`已导出: ${label} → ${zipPath}`, 'success')
|
||||
} catch {
|
||||
// fallback:仅显示路径
|
||||
toast(`已导出: ${label} → ${zipPath}`, 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('打包下载失败: ' + e, 'error')
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,17 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm } from '../components/modal.js'
|
||||
|
||||
let _delegated = false
|
||||
// HTML 转义,防止 XSS
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -47,6 +56,7 @@ async function loadVersion(page) {
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
const ver = info.current || '未知'
|
||||
const hasUpdate = info.update_available
|
||||
bar.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
@@ -54,7 +64,8 @@ async function loadVersion(page) {
|
||||
<span class="stat-card-label">当前版本</span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${info.update_available ? '有新版本可用' : '已是最新版本'}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
|
||||
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade" style="margin-top:var(--space-sm)">升级到最新版</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -71,33 +82,52 @@ async function loadServices(page) {
|
||||
const services = await api.getServicesStatus()
|
||||
renderServices(container, services)
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${e}</div>`
|
||||
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderServices(container, services) {
|
||||
if (!services || !services.length) {
|
||||
container.innerHTML = '<div style="color:var(--text-tertiary)">暂无服务</div>'
|
||||
return
|
||||
}
|
||||
container.innerHTML = services.map(s => `
|
||||
<div class="service-card" data-label="${s.label}">
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
|
||||
// Gateway 专属卡片(带安装/卸载)
|
||||
let html = ''
|
||||
if (gw) {
|
||||
html += `
|
||||
<div class="service-card" data-label="${gw.label}">
|
||||
<div class="service-info">
|
||||
<span class="status-dot ${s.running ? 'running' : 'stopped'}"></span>
|
||||
<span class="status-dot ${gw.running ? 'running' : 'stopped'}"></span>
|
||||
<div>
|
||||
<div class="service-name">${s.label}</div>
|
||||
<div class="service-desc">${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
|
||||
<div class="service-name">${gw.label}</div>
|
||||
<div class="service-desc">${gw.description || ''}${gw.pid ? ' (PID: ' + gw.pid + ')' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
${s.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${s.label}">重启</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${s.label}">停止</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${s.label}">启动</button>`
|
||||
${gw.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
</div>`
|
||||
} else {
|
||||
html += `
|
||||
<div class="service-card">
|
||||
<div class="service-info">
|
||||
<span class="status-dot stopped"></span>
|
||||
<div>
|
||||
<div class="service-name">ai.openclaw.gateway</div>
|
||||
<div class="service-desc">Gateway 服务未安装</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
// ===== 备份管理 =====
|
||||
@@ -139,9 +169,6 @@ function renderBackups(container, backups) {
|
||||
// ===== 事件绑定(事件委托) =====
|
||||
|
||||
function bindEvents(page) {
|
||||
if (_delegated) return
|
||||
_delegated = true
|
||||
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
@@ -164,6 +191,15 @@ function bindEvents(page) {
|
||||
case 'delete-backup':
|
||||
await handleDeleteBackup(btn.dataset.name, page)
|
||||
break
|
||||
case 'upgrade':
|
||||
await handleUpgrade(btn, page)
|
||||
break
|
||||
case 'install-gateway':
|
||||
await handleInstallGateway(btn, page)
|
||||
break
|
||||
case 'uninstall-gateway':
|
||||
await handleUninstallGateway(btn, page)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.toString(), 'error')
|
||||
@@ -193,15 +229,46 @@ async function handleCreateBackup(page) {
|
||||
}
|
||||
|
||||
async function handleRestoreBackup(name, page) {
|
||||
if (!confirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)) return
|
||||
const yes = await showConfirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)
|
||||
if (!yes) return
|
||||
await api.restoreBackup(name)
|
||||
toast('配置已恢复', 'success')
|
||||
await loadBackups(page)
|
||||
}
|
||||
|
||||
async function handleDeleteBackup(name, page) {
|
||||
if (!confirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)) return
|
||||
const yes = await showConfirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)
|
||||
if (!yes) return
|
||||
await api.deleteBackup(name)
|
||||
toast('备份已删除', 'success')
|
||||
await loadBackups(page)
|
||||
}
|
||||
|
||||
// ===== 升级操作 =====
|
||||
|
||||
async function handleUpgrade(btn, page) {
|
||||
const yes = await showConfirm('确定要升级 OpenClaw 到最新版本吗?\n升级过程中 Gateway 会短暂中断。')
|
||||
if (!yes) return
|
||||
btn.textContent = '升级中...'
|
||||
const msg = await api.upgradeOpenclaw()
|
||||
toast(msg, 'success')
|
||||
await loadVersion(page)
|
||||
}
|
||||
|
||||
// ===== Gateway 安装/卸载 =====
|
||||
|
||||
async function handleInstallGateway(btn, page) {
|
||||
btn.textContent = '安装中...'
|
||||
await api.installGateway()
|
||||
toast('Gateway 服务已安装', 'success')
|
||||
await loadServices(page)
|
||||
}
|
||||
|
||||
async function handleUninstallGateway(btn, page) {
|
||||
const yes = await showConfirm('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。')
|
||||
if (!yes) return
|
||||
btn.textContent = '卸载中...'
|
||||
await api.uninstallGateway()
|
||||
toast('Gateway 服务已卸载', 'success')
|
||||
await loadServices(page)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
/* 配置项说明 */
|
||||
.form-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 配置编辑区 */
|
||||
.config-section {
|
||||
background: var(--bg-card);
|
||||
@@ -165,25 +173,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user