mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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 失败");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = '检查更新'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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>')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user