feat: 全面完善功能和修复 CSS/API 问题

- 修复暗色主题缺少 --accent 变量导致按钮颜色异常
- 消除所有 CSS 硬编码颜色(btn-primary, btn-danger:hover, sidebar-logo)
- 添加 toast.warning 样式支持
- Modal 支持 Enter 确认和 Escape 关闭
- Dashboard 快速操作按钮添加 loading 状态
- Services 操作后延迟刷新确保状态同步
- Memory 页面添加预览/新建/删除文件功能
- Deploy 页面 .env 路径添加默认值
- Rust 后端补充 delete_memory_file/check_installation/write_env_file 命令
- Mock 数据补全所有 API 端点
This commit is contained in:
晴天
2026-02-26 23:19:00 +08:00
parent 8bf2caf788
commit ed353cb3b5
12 changed files with 187 additions and 10 deletions

View File

@@ -70,3 +70,29 @@ pub fn get_version_info() -> Result<VersionInfo, String> {
update_available: false,
})
}
#[tauri::command]
pub fn check_installation() -> Result<Value, String> {
let openclaw_dir = openclaw_dir();
let installed = openclaw_dir.join("openclaw.json").exists();
let mut result = serde_json::Map::new();
result.insert("installed".into(), Value::Bool(installed));
result.insert("path".into(), Value::String(openclaw_dir.to_string_lossy().to_string()));
Ok(Value::Object(result))
}
#[tauri::command]
pub fn write_env_file(path: String, config: String) -> Result<(), String> {
let expanded = if path.starts_with("~/") {
dirs::home_dir()
.unwrap_or_default()
.join(&path[2..])
} else {
PathBuf::from(&path)
};
if let Some(parent) = expanded.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&expanded, &config)
.map_err(|e| format!("写入 .env 失败: {e}"))
}

View File

@@ -110,3 +110,25 @@ pub fn write_memory_file(path: String, content: String) -> Result<(), String> {
fs::write(&target, &content)
.map_err(|e| format!("写入失败: {e}"))
}
#[tauri::command]
pub fn delete_memory_file(path: String) -> Result<(), String> {
if path.contains("..") {
return Err("非法路径".to_string());
}
let candidates = [
memory_dir("memory").join(&path),
memory_dir("archive").join(&path),
memory_dir("core").join(&path),
];
for candidate in &candidates {
if candidate.exists() {
return fs::remove_file(candidate)
.map_err(|e| format!("删除失败: {e}"));
}
}
Err(format!("文件不存在: {path}"))
}

View File

@@ -13,6 +13,8 @@ pub fn run() {
config::read_mcp_config,
config::write_mcp_config,
config::get_version_info,
config::check_installation,
config::write_env_file,
// 服务
service::get_services_status,
service::start_service,
@@ -25,6 +27,7 @@ pub fn run() {
memory::list_memory_files,
memory::read_memory_file,
memory::write_memory_file,
memory::delete_memory_file,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");

View File

@@ -46,6 +46,17 @@ export function showModal({ title, fields, onConfirm }) {
onConfirm(result)
}
// 键盘事件Enter 确认Escape 关闭
const handleKey = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
overlay.querySelector('[data-action="confirm"]')?.click()
} else if (e.key === 'Escape') {
overlay.remove()
}
}
overlay.addEventListener('keydown', handleKey)
// 自动聚焦第一个输入框
const firstInput = overlay.querySelector('input, select')
if (firstInput) firstInput.focus()

View File

@@ -84,6 +84,9 @@ function mockInvoke(cmd, args) {
},
read_memory_file: ({ path }) => `# ${path}\n\n这是 ${path} 的内容示例。\n\n## 概述\n\n在此记录工作记忆...`,
write_memory_file: () => true,
delete_memory_file: () => true,
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }),
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
read_mcp_config: () => ({
mcpServers: {
'exa': { command: 'npx', args: ['-y', '@anthropic/exa-mcp-server'], env: { EXA_API_KEY: '***' } },
@@ -124,6 +127,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 }),
deleteMemoryFile: (path) => invoke('delete_memory_file', { path }),
// 安装/部署
checkInstallation: () => invoke('check_installation'),

View File

@@ -105,16 +105,27 @@ function renderLogs(page, logs) {
}
function bindActions(page) {
page.querySelector('#btn-restart-gw')?.addEventListener('click', async () => {
const btnRestart = page.querySelector('#btn-restart-gw')
const btnUpdate = page.querySelector('#btn-check-update')
btnRestart?.addEventListener('click', async () => {
btnRestart.disabled = true
btnRestart.textContent = '重启中...'
try {
await api.restartService('ai.openclaw.gateway')
toast('Gateway 已重启', 'success')
setTimeout(() => loadDashboardData(page), 500)
} catch (e) {
toast('重启失败: ' + e, 'error')
} finally {
btnRestart.disabled = false
btnRestart.textContent = '重启 Gateway'
}
})
page.querySelector('#btn-check-update')?.addEventListener('click', async () => {
btnUpdate?.addEventListener('click', async () => {
btnUpdate.disabled = true
btnUpdate.textContent = '检查中...'
try {
const info = await api.getVersionInfo()
if (info.update_available) {
@@ -124,6 +135,9 @@ function bindActions(page) {
}
} catch (e) {
toast('检查更新失败: ' + e, 'error')
} finally {
btnUpdate.disabled = false
btnUpdate.textContent = '检查更新'
}
})
}

View File

@@ -84,7 +84,7 @@ function renderDeployUI(page, el, envContent, gwUrl, token) {
<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 文件路径">
<input class="form-input" id="env-path" value="~/Desktop/clawapp/.env" placeholder="输入 ClawApp 项目 .env 文件路径">
</div>
</div>
`

View File

@@ -3,6 +3,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showModal } from '../components/modal.js'
const CATEGORIES = [
{ key: 'memory', label: '工作记忆' },
@@ -23,7 +24,13 @@ export async function render() {
${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-sidebar">
<div style="padding:0 var(--space-sm) var(--space-sm);display:flex;gap:4px">
<button class="btn btn-sm btn-secondary" id="btn-new-file" style="flex:1">+ 新建</button>
<button class="btn btn-sm btn-danger" id="btn-del-file" disabled style="flex:1">删除</button>
</div>
<div id="file-tree">加载中...</div>
</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>
@@ -54,6 +61,43 @@ export async function render() {
// 保存
page.querySelector('#btn-save-file').onclick = () => saveFile(page, state)
// 预览(简易 Markdown 渲染)
page.querySelector('#btn-preview').onclick = () => togglePreview(page, state)
// 新建文件
page.querySelector('#btn-new-file').onclick = () => {
showModal({
title: '新建记忆文件',
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md' }],
onConfirm: async ({ filename }) => {
if (!filename) return
try {
await api.writeMemoryFile(filename, `# ${filename}\n\n`)
toast(`已创建 ${filename}`, 'success')
loadFiles(page, state)
} catch (e) {
toast('创建失败: ' + e, 'error')
}
},
})
}
// 删除文件
page.querySelector('#btn-del-file').onclick = async () => {
if (!state.currentPath) return
const name = state.currentPath.split('/').pop()
if (!confirm(`确定删除 ${name}`)) return
try {
await api.deleteMemoryFile(state.currentPath)
toast(`已删除 ${name}`, 'success')
state.currentPath = null
resetEditor(page)
loadFiles(page, state)
} catch (e) {
toast('删除失败: ' + e, 'error')
}
}
loadFiles(page, state)
return page
}
@@ -97,17 +141,25 @@ async function loadFileContent(page, state) {
const label = page.querySelector('#current-file')
const btnSave = page.querySelector('#btn-save-file')
const btnPreview = page.querySelector('#btn-preview')
const btnDel = page.querySelector('#btn-del-file')
editor.disabled = true
editor.value = '加载中...'
label.textContent = state.currentPath
// 退出预览模式
editor.style.display = ''
const previewEl = page.querySelector('#md-preview')
if (previewEl) previewEl.remove()
btnPreview.textContent = '预览'
try {
const content = await api.readMemoryFile(state.currentPath)
editor.value = content || ''
editor.disabled = false
btnSave.disabled = false
btnPreview.disabled = false
btnDel.disabled = false
} catch (e) {
editor.value = '读取失败: ' + e
toast('读取文件失败: ' + e, 'error')
@@ -118,9 +170,14 @@ function resetEditor(page) {
const editor = page.querySelector('#file-editor')
editor.value = ''
editor.disabled = true
editor.style.display = ''
const previewEl = page.querySelector('#md-preview')
if (previewEl) previewEl.remove()
page.querySelector('#current-file').textContent = '选择文件查看'
page.querySelector('#btn-save-file').disabled = true
page.querySelector('#btn-preview').disabled = true
page.querySelector('#btn-preview').textContent = '预览'
page.querySelector('#btn-del-file').disabled = true
}
async function saveFile(page, state) {
@@ -133,3 +190,41 @@ async function saveFile(page, state) {
toast('保存失败: ' + e, 'error')
}
}
function togglePreview(page) {
const editor = page.querySelector('#file-editor')
const btn = page.querySelector('#btn-preview')
let previewEl = page.querySelector('#md-preview')
if (previewEl) {
// 退出预览
previewEl.remove()
editor.style.display = ''
btn.textContent = '预览'
} else {
// 进入预览
const md = editor.value
previewEl = document.createElement('div')
previewEl.id = 'md-preview'
previewEl.style.cssText = 'flex:1;padding:var(--space-lg);overflow-y:auto;line-height:1.8;color:var(--text-primary)'
previewEl.innerHTML = renderMarkdown(md)
editor.style.display = 'none'
editor.parentElement.appendChild(previewEl)
btn.textContent = '编辑'
}
}
// 简易 Markdown 渲染
function renderMarkdown(md) {
return md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3 style="font-size:var(--font-size-lg);font-weight:600;margin:16px 0 8px">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-size:var(--font-size-xl);font-weight:600;margin:20px 0 8px">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 style="font-size:var(--font-size-2xl);font-weight:700;margin:24px 0 12px">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-family:var(--font-mono);font-size:var(--font-size-xs)">$1</code>')
.replace(/^- (.+)$/gm, '<li style="margin-left:20px">$1</li>')
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>')
}

View File

@@ -63,7 +63,7 @@ function renderServices(page, services) {
else if (action === 'stop') await api.stopService(label)
else if (action === 'restart') await api.restartService(label)
toast(`${label} ${action} 成功`, 'success')
loadServices(page)
setTimeout(() => loadServices(page), 300)
} catch (e) {
toast(`操作失败: ${e}`, 'error')
btn.disabled = false

View File

@@ -72,7 +72,7 @@
.btn-primary {
background: var(--accent);
color: #fff;
color: var(--text-inverse);
}
.btn-primary:hover { background: var(--accent-hover); }
@@ -93,7 +93,7 @@
color: var(--error);
}
.btn-danger:hover { background: rgba(239, 68, 68, 0.25); }
.btn-danger:hover { background: var(--error-muted); opacity: 0.85; }
.btn-sm {
padding: var(--space-xs) var(--space-md);
@@ -140,6 +140,7 @@
.toast.success { background: var(--success-muted); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
.toast.error { background: var(--error-muted); border: 1px solid rgba(239,68,68,0.3); color: var(--error); }
.toast.info { background: var(--info-muted); border: 1px solid rgba(59,130,246,0.3); color: var(--info); }
.toast.warning { background: var(--warning-muted); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }

View File

@@ -24,13 +24,13 @@
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #a855f7);
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--font-size-sm);
color: #fff;
color: var(--text-inverse);
flex-shrink: 0;
}

View File

@@ -54,7 +54,8 @@
--text-tertiary: #71717a;
--text-inverse: #0a0a0f;
--accent-hover: #818cf8;
--accent: #818cf8;
--accent-hover: #a5b4fc;
--accent-muted: rgba(99, 102, 241, 0.15);
--success: #22c55e;