cc工作前备份
This commit is contained in:
120
desktop/src/components/ChannelList.tsx
Normal file
120
desktop/src/components/ChannelList.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Radio, RefreshCw, MessageCircle, Settings } from 'lucide-react';
|
||||
|
||||
const CHANNEL_ICONS: Record<string, string> = {
|
||||
feishu: '飞',
|
||||
qqbot: 'QQ',
|
||||
wechat: '微',
|
||||
};
|
||||
|
||||
interface ChannelListProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export function ChannelList({ onOpenSettings }: ChannelListProps) {
|
||||
const { channels, connectionState, loadChannels, loadPluginStatus } = useGatewayStore();
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadPluginStatus().then(() => loadChannels());
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadPluginStatus().then(() => loadChannels());
|
||||
};
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
|
||||
<Radio className="w-8 h-8 mb-2 opacity-30" />
|
||||
<p>IM 频道</p>
|
||||
<p className="mt-1">连接 Gateway 后可用</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-xs font-medium text-gray-500">频道列表</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1 text-gray-400 hover:text-orange-500 rounded"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{/* Configured channels */}
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 ${
|
||||
ch.status === 'active'
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
: 'bg-gray-300'
|
||||
}`}>
|
||||
{CHANNEL_ICONS[ch.type] || <MessageCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-900 truncate">{ch.label}</div>
|
||||
<div className={`text-[11px] ${
|
||||
ch.status === 'active' ? 'text-green-500' : ch.status === 'error' ? 'text-red-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{ch.status === 'active' ? '已连接' : ch.status === 'error' ? ch.error || '错误' : '未配置'}
|
||||
{ch.accounts !== undefined && ch.accounts > 0 && ` · ${ch.accounts} 个账号`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Always show available channels that aren't configured */}
|
||||
{!channels.find(c => c.type === 'feishu') && (
|
||||
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
|
||||
飞
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-600">飞书 (Feishu)</div>
|
||||
<div className="text-[11px] text-gray-400">未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!channels.find(c => c.type === 'qqbot') && (
|
||||
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
|
||||
QQ
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-600">QQ 机器人</div>
|
||||
<div className="text-[11px] text-gray-400">未安装插件</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<div className="px-3 py-4 text-center">
|
||||
<p className="text-[11px] text-gray-400 mb-2">在设置中配置 IM 频道</p>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="inline-flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
打开设置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { Send, Paperclip, ChevronDown } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Send, Paperclip, ChevronDown, Terminal, Loader2, SquarePen } from 'lucide-react';
|
||||
|
||||
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
|
||||
|
||||
export function ChatArea() {
|
||||
const { messages, currentAgent, addMessage } = useChatStore();
|
||||
const {
|
||||
messages, currentAgent, isStreaming, currentModel,
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation,
|
||||
} = useChatStore();
|
||||
const { connectionState } = useGatewayStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!input.trim()) return;
|
||||
// Auto-resize textarea
|
||||
const adjustTextarea = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user' as const,
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Init agent stream listener on mount
|
||||
useEffect(() => {
|
||||
const unsub = initStreamListener();
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
addMessage(userMessage);
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isStreaming) return;
|
||||
sendToGateway(input);
|
||||
setInput('');
|
||||
|
||||
// TODO: 调用后端 API
|
||||
setTimeout(() => {
|
||||
const aiMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant' as const,
|
||||
content: '收到你的消息了!正在处理中...',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
addMessage(aiMessage);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部标题栏 */}
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-gray-900">{currentAgent?.name || 'ZCLAW'}</h2>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full"></span>
|
||||
在线
|
||||
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`}></span>
|
||||
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={newConversation}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
title="新对话"
|
||||
>
|
||||
<SquarePen className="w-3.5 h-3.5" />
|
||||
新对话
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天内容区 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-20">
|
||||
<p className="text-lg mb-2">欢迎使用 ZCLAW 🦞</p>
|
||||
<p className="text-sm">发送消息开始对话</p>
|
||||
<p className="text-sm">{connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={lex gap-4 }
|
||||
>
|
||||
<div
|
||||
className={w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 }
|
||||
>
|
||||
{message.role === 'user' ? '用' : '🦞'}
|
||||
</div>
|
||||
<div className={message.role === 'user' ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={message.role === 'user' ? 'chat-bubble-user p-4 shadow-md' : 'chat-bubble-assistant p-4 shadow-sm'}>
|
||||
<p className="leading-relaxed text-gray-700">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs pl-12">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Agent 思考中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-100 p-4 bg-white">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="relative flex items-end gap-2 bg-gray-50 rounded-2xl border border-gray-200 p-2 focus-within:border-orange-300 focus-within:ring-2 focus-within:ring-orange-100 transition-all">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 py-2">
|
||||
<input
|
||||
type="text"
|
||||
<div className="flex-1 py-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="发送给 ZCLAW"
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400"
|
||||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isStreaming ? 'Agent 正在回复...' : '发送给 ZCLAW(Shift+Enter 换行)'}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400 disabled:opacity-50 resize-none leading-relaxed"
|
||||
style={{ minHeight: '24px', maxHeight: '160px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pr-2 pb-1">
|
||||
<button className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors">
|
||||
<span>glm5</span>
|
||||
<div className="flex items-center gap-2 pr-2 pb-1 relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
<span>{currentModel}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{showModelPicker && (
|
||||
<div className="absolute bottom-full right-8 mb-2 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[160px] z-10">
|
||||
{MODELS.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => { setCurrentModel(model); setShowModelPicker(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 ${model === currentModel ? 'text-orange-600 font-medium' : 'text-gray-700'}`}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -110,3 +159,132 @@ export function ChatArea() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
|
||||
function renderMarkdown(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block
|
||||
if (line.startsWith('```')) {
|
||||
const lang = line.slice(3).trim();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing ```
|
||||
nodes.push(
|
||||
<pre key={nodes.length} className="bg-gray-900 text-gray-100 rounded-lg p-3 my-2 overflow-x-auto text-xs font-mono leading-relaxed">
|
||||
{lang && <div className="text-gray-500 text-[10px] mb-1 uppercase">{lang}</div>}
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal line — parse inline markdown
|
||||
nodes.push(
|
||||
<span key={nodes.length}>
|
||||
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
|
||||
{renderInline(line)}
|
||||
</span>
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Pattern: **bold**, *italic*, `code`, [text](url)
|
||||
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Text before match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1]) {
|
||||
// **bold**
|
||||
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
|
||||
} else if (match[3]) {
|
||||
// *italic*
|
||||
parts.push(<em key={parts.length}>{match[4]}</em>);
|
||||
} else if (match[5]) {
|
||||
// `code`
|
||||
parts.push(
|
||||
<code key={parts.length} className="bg-gray-100 text-orange-700 px-1 py-0.5 rounded text-[0.85em] font-mono">
|
||||
{match[6]}
|
||||
</code>
|
||||
);
|
||||
} else if (match[7]) {
|
||||
// [text](url)
|
||||
parts.push(
|
||||
<a key={parts.length} href={match[9]} target="_blank" rel="noopener noreferrer"
|
||||
className="text-orange-600 underline hover:text-orange-700">{match[8]}</a>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
if (message.role === 'tool') {
|
||||
return (
|
||||
<div className="ml-12 bg-gray-50 border border-gray-200 rounded-lg p-3 text-xs font-mono">
|
||||
<div className="flex items-center gap-2 text-gray-500 mb-1">
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold">{message.toolName || 'tool'}</span>
|
||||
</div>
|
||||
{message.toolInput && (
|
||||
<pre className="text-gray-600 bg-white rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
|
||||
)}
|
||||
{message.content && (
|
||||
<pre className="text-green-700 bg-white rounded p-2 overflow-x-auto">{message.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${isUser ? 'bg-gray-200 text-gray-600 order-last' : 'agent-avatar text-white'}`}
|
||||
>
|
||||
{isUser ? '用' : '🦞'}
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: (message.streaming ? '' : '...')}
|
||||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
139
desktop/src/components/CloneManager.tsx
Normal file
139
desktop/src/components/CloneManager.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { Plus, Trash2, Bot, X } from 'lucide-react';
|
||||
|
||||
interface CloneFormData {
|
||||
name: string;
|
||||
role: string;
|
||||
scenarios: string;
|
||||
}
|
||||
|
||||
export function CloneManager() {
|
||||
const { clones, loadClones, createClone, deleteClone, connectionState } = useGatewayStore();
|
||||
const { agents } = useChatStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<CloneFormData>({ name: '', role: '', scenarios: '' });
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadClones();
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
await createClone({
|
||||
name: form.name,
|
||||
role: form.role || undefined,
|
||||
scenarios: form.scenarios ? form.scenarios.split(',').map(s => s.trim()) : undefined,
|
||||
});
|
||||
setForm({ name: '', role: '', scenarios: '' });
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定删除该分身?')) {
|
||||
await deleteClone(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Merge gateway clones with local agents for display
|
||||
const displayClones = clones.length > 0 ? clones : agents.map(a => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: '默认助手',
|
||||
createdAt: '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-xs font-medium text-gray-500">分身列表</span>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="p-1 text-gray-400 hover:text-orange-500 rounded"
|
||||
title="创建分身"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<div className="p-3 border-b border-gray-200 bg-orange-50 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-orange-700">新建分身</span>
|
||||
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="名称 (必填)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.role}
|
||||
onChange={e => setForm({ ...form, role: e.target.value })}
|
||||
placeholder="角色 (如: 代码助手)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.scenarios}
|
||||
onChange={e => setForm({ ...form, scenarios: e.target.value })}
|
||||
placeholder="场景标签 (逗号分隔)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!form.name.trim()}
|
||||
className="w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clone list */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{displayClones.map((clone) => (
|
||||
<div
|
||||
key={clone.id}
|
||||
className="group flex items-center gap-3 px-3 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-50"
|
||||
>
|
||||
<div className="w-9 h-9 bg-gradient-to-br from-orange-400 to-red-500 rounded-xl flex items-center justify-center text-white flex-shrink-0">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{clone.name}</div>
|
||||
<div className="text-xs text-gray-400 truncate">{clone.role || '默认助手'}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(clone.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{displayClones.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-gray-400">
|
||||
<Bot className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无分身</p>
|
||||
<p className="mt-1">点击 + 创建你的第一个分身</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
desktop/src/components/ConversationList.tsx
Normal file
113
desktop/src/components/ConversationList.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
|
||||
|
||||
export function ConversationList() {
|
||||
const {
|
||||
conversations, currentConversationId, messages,
|
||||
newConversation, switchConversation, deleteConversation,
|
||||
} = useChatStore();
|
||||
|
||||
const hasActiveChat = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-xs font-medium text-gray-500">对话历史</span>
|
||||
<button
|
||||
onClick={newConversation}
|
||||
className="p-1 text-gray-400 hover:text-orange-500 rounded"
|
||||
title="新对话"
|
||||
>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{/* Current active chat (unsaved) */}
|
||||
{hasActiveChat && !currentConversationId && (
|
||||
<div className="flex items-center gap-3 px-3 py-3 bg-orange-50 border-b border-orange-100 cursor-default">
|
||||
<div className="w-7 h-7 bg-orange-500 rounded-lg flex items-center justify-center text-white flex-shrink-0">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-orange-700 truncate">当前对话</div>
|
||||
<div className="text-[11px] text-orange-500 truncate">
|
||||
{messages.filter(m => m.role === 'user').length} 条消息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved conversations */}
|
||||
{conversations.map((conv) => {
|
||||
const isActive = conv.id === currentConversationId;
|
||||
const msgCount = conv.messages.filter(m => m.role === 'user').length;
|
||||
const timeStr = formatTime(conv.updatedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => switchConversation(conv.id)}
|
||||
className={`group flex items-center gap-3 px-3 py-3 cursor-pointer border-b border-gray-50 transition-colors ${
|
||||
isActive ? 'bg-orange-50' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isActive ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-medium truncate ${isActive ? 'text-orange-700' : 'text-gray-900'}`}>
|
||||
{conv.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 truncate">
|
||||
{msgCount} 条消息 · {timeStr}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('删除该对话?')) {
|
||||
deleteConversation(conv.id);
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{conversations.length === 0 && !hasActiveChat && (
|
||||
<div className="text-center py-8 text-xs text-gray-400">
|
||||
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无对话</p>
|
||||
<p className="mt-1">发送消息开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const d = new Date(date);
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`;
|
||||
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr} 小时前`;
|
||||
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay} 天前`;
|
||||
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
@@ -1,103 +1,214 @@
|
||||
import { FileText, User, Target, CheckSquare } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, Activity,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function RightPanel() {
|
||||
const {
|
||||
connectionState, gatewayVersion, error, clones, usageStats, pluginStatus,
|
||||
connect, loadClones, loadUsageStats, loadPluginStatus,
|
||||
} = useGatewayStore();
|
||||
const { messages, currentModel } = useChatStore();
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
// Load data when connected
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadClones();
|
||||
loadUsageStats();
|
||||
loadPluginStatus();
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
const handleReconnect = () => {
|
||||
connect().catch(() => {});
|
||||
};
|
||||
|
||||
const userMsgCount = messages.filter(m => m.role === 'user').length;
|
||||
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
|
||||
const toolCallCount = messages.filter(m => m.role === 'tool').length;
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
|
||||
{/* 顶部工具栏 */}
|
||||
<aside className="w-72 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
|
||||
{/* 顶部 */}
|
||||
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-gray-600">
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="font-medium">2268</span>
|
||||
</div>
|
||||
<button className="text-xs text-orange-600 hover:underline">去购买</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-medium text-gray-700 text-sm">系统状态</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>文件</span>
|
||||
{connected && (
|
||||
<button
|
||||
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
|
||||
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
|
||||
title="刷新数据"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
|
||||
<User className="w-4 h-4" />
|
||||
<span>Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||
{/* 任务进度 */}
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 mb-6 overflow-hidden">
|
||||
<div className="p-3">
|
||||
<div className="flex justify-between text-xs mb-2">
|
||||
<span className="text-gray-600">任务进度</span>
|
||||
<span className="font-medium text-gray-900">65%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-orange-500 h-2 rounded-full transition-all" style={{ width: '65%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 divide-y divide-gray-100 text-xs">
|
||||
<div className="py-2 px-3 flex justify-between">
|
||||
<span className="text-gray-600">远程执行</span>
|
||||
<span className="text-green-600">✅ 完成</span>
|
||||
</div>
|
||||
<div className="py-2 px-3 flex justify-between">
|
||||
<span className="text-gray-600">任务编排</span>
|
||||
<span className="text-orange-600">🔄 进行中</span>
|
||||
</div>
|
||||
<div className="py-2 px-3 flex justify-between">
|
||||
<span className="text-gray-600">多 Agent 协作</span>
|
||||
<span className="text-gray-400">⏳ 待开始</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 今日统计 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-gray-900 mb-3 text-sm">今日统计</h3>
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">任务数</span>
|
||||
<span className="font-medium text-gray-900">8 个</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">成功</span>
|
||||
<span className="font-medium text-green-600">6 个</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">进行中</span>
|
||||
<span className="font-medium text-orange-600">2 个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下一步行动 */}
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 text-sm flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center text-white text-xs">
|
||||
<Target className="w-3 h-3" />
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
|
||||
{/* Gateway 连接状态 */}
|
||||
<div className={`rounded-lg border p-3 ${connected ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{connected ? (
|
||||
<Wifi className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className={`text-xs font-semibold ${connected ? 'text-green-700' : 'text-gray-600'}`}>
|
||||
Gateway {connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
|
||||
</span>
|
||||
下一步行动
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">地址</span>
|
||||
<span className="text-gray-700 font-mono">127.0.0.1:18789</span>
|
||||
</div>
|
||||
{gatewayVersion && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">版本</span>
|
||||
<span className="text-gray-700">{gatewayVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">当前模型</span>
|
||||
<span className="text-orange-600 font-medium">{currentModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!connected && connectionState !== 'connecting' && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="mt-2 w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
连接 Gateway
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-500 truncate" title={error}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前会话 */}
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
当前会话
|
||||
</h3>
|
||||
<div className="ml-7 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-gray-900 mb-2">立即执行 (今天)</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
|
||||
<span>初始化项目结构</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
|
||||
<span>创建核心系统代码</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
|
||||
<span>集成 OpenClaw SDK</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">用户消息</span>
|
||||
<span className="font-medium text-gray-900">{userMsgCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">助手回复</span>
|
||||
<span className="font-medium text-gray-900">{assistantMsgCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">工具调用</span>
|
||||
<span className="font-medium text-gray-900">{toolCallCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">总消息数</span>
|
||||
<span className="font-medium text-orange-600">{messages.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分身 */}
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
分身
|
||||
</h3>
|
||||
{clones.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{clones.slice(0, 5).map(c => (
|
||||
<div key={c.id} className="flex items-center gap-2 text-xs">
|
||||
<div className="w-5 h-5 bg-gradient-to-br from-orange-400 to-red-500 rounded-md flex items-center justify-center text-white text-[10px]">
|
||||
<Bot className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-gray-700 truncate">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{clones.length > 5 && (
|
||||
<p className="text-xs text-gray-400">+{clones.length - 5} 个分身</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{connected ? '暂无分身,在左侧栏创建' : '连接后可用'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用量统计 */}
|
||||
{usageStats && (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
用量统计
|
||||
</h3>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">总会话数</span>
|
||||
<span className="font-medium text-gray-900">{usageStats.totalSessions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">总消息数</span>
|
||||
<span className="font-medium text-gray-900">{usageStats.totalMessages}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">总 Token</span>
|
||||
<span className="font-medium text-gray-900">{usageStats.totalTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件状态 */}
|
||||
{pluginStatus.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<Plug className="w-3.5 h-3.5" />
|
||||
插件 ({pluginStatus.length})
|
||||
</h3>
|
||||
<div className="space-y-1 text-xs">
|
||||
{pluginStatus.map((p: any, i: number) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<span className="text-gray-600 truncate">{p.name || p.id}</span>
|
||||
<span className={p.status === 'active' ? 'text-green-600' : 'text-gray-400'}>
|
||||
{p.status === 'active' ? '运行中' : '已停止'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 系统信息 */}
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<Cpu className="w-3.5 h-3.5" />
|
||||
系统信息
|
||||
</h3>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">ZCLAW 版本</span>
|
||||
<span className="text-gray-700">v0.2.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">协议版本</span>
|
||||
<span className="text-gray-700">Gateway v3</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">平台</span>
|
||||
<span className="text-gray-700">Tauri 2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
45
desktop/src/components/Settings/About.tsx
Normal file
45
desktop/src/components/Settings/About.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export function About() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-red-500 rounded-2xl flex items-center justify-center text-3xl shadow-lg">
|
||||
🦞
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">ZCLAW</h1>
|
||||
<p className="text-sm text-orange-500">版本 0.2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">检查更新</h2>
|
||||
</div>
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 flex items-center gap-1">
|
||||
🔄 检查更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">更新日志</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">查看当前版本的更新内容</p>
|
||||
</div>
|
||||
<button className="border border-gray-300 rounded-lg px-4 py-1.5 text-sm hover:bg-gray-100">更新日志</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs text-gray-400 space-y-1">
|
||||
<p>© 2026 ZCLAW | Powered by OpenClaw</p>
|
||||
<p>基于 OpenClaw 开源框架定制</p>
|
||||
<div className="flex justify-center gap-4 mt-3">
|
||||
<a href="#" className="text-orange-500 hover:text-orange-600">隐私政策</a>
|
||||
<a href="#" className="text-orange-500 hover:text-orange-600">用户协议</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
desktop/src/components/Settings/General.tsx
Normal file
118
desktop/src/components/Settings/General.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
|
||||
export function General() {
|
||||
const { connectionState, gatewayVersion, error, connect, disconnect } = useGatewayStore();
|
||||
const { currentModel } = useChatStore();
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
const [autoStart, setAutoStart] = useState(false);
|
||||
const [showToolCalls, setShowToolCalls] = useState(false);
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
|
||||
|
||||
const handleConnect = () => { connect().catch(() => {}); };
|
||||
const handleDisconnect = () => { disconnect(); };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">通用设置</h1>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Gateway 连接</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">状态</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : connecting ? 'bg-yellow-400 animate-pulse' : 'bg-gray-300'}`} />
|
||||
<span className={`text-sm font-medium ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
|
||||
{connected ? '已连接' : connecting ? '连接中...' : connectionState === 'handshaking' ? '握手中...' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">地址</span>
|
||||
<span className="text-sm text-gray-500 font-mono">ws://127.0.0.1:18789</span>
|
||||
</div>
|
||||
{gatewayVersion && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">版本</span>
|
||||
<span className="text-sm text-gray-500">{gatewayVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">当前模型</span>
|
||||
<span className="text-sm text-orange-600 font-medium">{currentModel}</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 bg-red-50 rounded-lg p-2">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
{connected ? (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="text-sm border border-gray-300 rounded-lg px-4 py-1.5 hover:bg-gray-100 text-gray-600"
|
||||
>
|
||||
断开连接
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{connecting ? '连接中...' : '连接 Gateway'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">外观与行为</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 space-y-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">主题模式</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">选择浅色或深色模式。</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`w-8 h-8 rounded-full border-2 ${theme === 'light' ? 'border-orange-500' : 'border-gray-300'} bg-white`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`w-8 h-8 rounded-full border-2 ${theme === 'dark' ? 'border-orange-500' : 'border-gray-300'} bg-gray-900`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">开机自启</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">登录时自动启动 ZCLAW。</div>
|
||||
</div>
|
||||
<Toggle checked={autoStart} onChange={setAutoStart} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">显示工具调用</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">在对话消息中显示模型的工具调用详情块。</div>
|
||||
</div>
|
||||
<Toggle checked={showToolCalls} onChange={setShowToolCalls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
34
desktop/src/components/Settings/IMChannels.tsx
Normal file
34
desktop/src/components/Settings/IMChannels.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function IMChannels() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">IM 频道</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" /> 添加频道
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center mt-6">
|
||||
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600 mb-3">
|
||||
添加频道
|
||||
</button>
|
||||
<p className="text-sm text-gray-500">尚未添加 IM 频道</p>
|
||||
<p className="text-xs text-gray-400 mt-1">点击「添加频道」连接你的第一个 IM 频道</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-3">快速添加</h2>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ 飞书</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
desktop/src/components/Settings/MCPServices.tsx
Normal file
64
desktop/src/components/Settings/MCPServices.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface MCPService {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function MCPServices() {
|
||||
const [services, setServices] = useState<MCPService[]>([
|
||||
{ id: 'filesystem', name: 'File System', enabled: true },
|
||||
{ id: 'webfetch', name: 'Web Fetch', enabled: true },
|
||||
]);
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setServices(prev => prev.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">MCP 服务</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" /> 添加服务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">MCP(模型上下文协议)服务为 Agent 扩展外部工具:文件系统、数据库、网页搜索等。</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200">
|
||||
{services.map((svc) => (
|
||||
<div key={svc.id} className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400">⇄</span>
|
||||
<span className="text-sm text-gray-900">{svc.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => toggleService(svc.id)} className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100">
|
||||
{svc.enabled ? '停用' : '启用'}
|
||||
</button>
|
||||
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100">设置</button>
|
||||
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-3">快速添加模版</h2>
|
||||
<p className="text-xs text-gray-400 mb-3">一键添加常用 MCP 服务</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ Brave Search</button>
|
||||
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ SQLite</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
desktop/src/components/Settings/ModelsAPI.tsx
Normal file
101
desktop/src/components/Settings/ModelsAPI.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
|
||||
interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const AVAILABLE_MODELS: ModelEntry[] = [
|
||||
{ id: 'glm-5', name: 'GLM-5', provider: '智谱 AI' },
|
||||
{ id: 'qwen3.5-plus', name: 'Qwen3.5+', provider: '通义千问' },
|
||||
{ id: 'kimi-k2.5', name: 'Kimi-K2.5', provider: '月之暗面' },
|
||||
{ id: 'minimax-m2.5', name: 'MiniMax-M2.5', provider: 'MiniMax' },
|
||||
];
|
||||
|
||||
export function ModelsAPI() {
|
||||
const { connectionState, connect, disconnect } = useGatewayStore();
|
||||
const { currentModel, setCurrentModel } = useChatStore();
|
||||
const [gatewayUrl, setGatewayUrl] = useState('ws://127.0.0.1:18789');
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
|
||||
|
||||
const handleReconnect = () => {
|
||||
disconnect();
|
||||
setTimeout(() => connect().catch(() => {}), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">模型与 API</h1>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={connecting}
|
||||
className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{connecting ? '连接中...' : '重新连接'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">中文模型 Provider</h2>
|
||||
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200 mb-6">
|
||||
{AVAILABLE_MODELS.map((model) => {
|
||||
const isActive = model.id === currentModel;
|
||||
return (
|
||||
<div key={model.id} className="flex items-center justify-between px-5 py-3.5">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{model.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">{model.provider}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive ? (
|
||||
<span className="text-xs text-green-600 bg-green-50 px-2.5 py-1 rounded-md font-medium">当前使用</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCurrentModel(model.id)}
|
||||
className="text-xs text-orange-500 hover:text-orange-600 hover:bg-orange-50 px-2.5 py-1 rounded-md transition-colors"
|
||||
>
|
||||
切换
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Gateway 连接</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-md font-medium ${connected ? 'bg-green-50 text-green-600' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
|
||||
</span>
|
||||
{!connected && !connecting && (
|
||||
<button
|
||||
onClick={() => connect().catch(() => {})}
|
||||
className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600"
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">Gateway WebSocket URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayUrl}
|
||||
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||
className="w-full bg-white border border-gray-200 rounded-lg text-sm text-gray-700 font-mono px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-200 focus:border-orange-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
默认地址: ws://127.0.0.1:18789。修改后需重新连接。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
desktop/src/components/Settings/Privacy.tsx
Normal file
55
desktop/src/components/Settings/Privacy.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Privacy() {
|
||||
const [optimization, setOptimization] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">数据与隐私</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">查看数据存储位置与 ZCLAW 的网络出站范围。</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-6">
|
||||
<h2 className="text-sm font-bold text-gray-900 mb-1">本地数据路径</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">所有工作区文件、对话记录和 Agent 输出均存储在此本地目录。</p>
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
||||
<span className="text-sm text-gray-700 font-mono">~/.openclaw/zclaw-workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 mr-4">
|
||||
<h2 className="text-sm font-bold text-gray-900">优化计划</h2>
|
||||
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
|
||||
我们诚挚邀您加入优化提升计划。您的加入会帮助我们更好地改进产品:在去标识化处理后,我们可能将您输入与生成的信息以及屏幕操作信息用于模型的训练与优化。我们尊重您的个人信息主体权益,您有权选择不允许我们将您的信息用于此目的。您也可以在后续使用中的任何时候通过"设置"中的开启或关闭按钮选择加入或退出优化计划。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOptimization(!optimization)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${optimization ? 'bg-orange-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${optimization ? 'left-[22px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5">
|
||||
<h2 className="text-sm font-bold text-gray-900 mb-3">备案信息</h2>
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="flex gap-8">
|
||||
<span className="text-gray-400 w-24">项目名称</span>
|
||||
<span>ZCLAW — OpenClaw 定制版</span>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<span className="text-gray-400 w-24">开源协议</span>
|
||||
<span>MIT License</span>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<span className="text-gray-400 w-24">数据存储</span>
|
||||
<span>全部本地存储,不上传云端</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
desktop/src/components/Settings/SettingsLayout.tsx
Normal file
134
desktop/src/components/Settings/SettingsLayout.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Puzzle,
|
||||
Blocks,
|
||||
MessageSquare,
|
||||
FolderOpen,
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Info,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { General } from './General';
|
||||
import { UsageStats } from './UsageStats';
|
||||
import { ModelsAPI } from './ModelsAPI';
|
||||
import { MCPServices } from './MCPServices';
|
||||
import { Skills } from './Skills';
|
||||
import { IMChannels } from './IMChannels';
|
||||
import { Workspace } from './Workspace';
|
||||
import { Privacy } from './Privacy';
|
||||
import { About } from './About';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type SettingsPage =
|
||||
| 'general'
|
||||
| 'usage'
|
||||
| 'models'
|
||||
| 'mcp'
|
||||
| 'skills'
|
||||
| 'im'
|
||||
| 'workspace'
|
||||
| 'privacy'
|
||||
| 'feedback'
|
||||
| 'about';
|
||||
|
||||
const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> },
|
||||
{ id: 'usage', label: '用量统计', icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: 'models', label: '模型与 API', icon: <Bot className="w-4 h-4" /> },
|
||||
{ id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
|
||||
{ id: 'skills', label: '技能', icon: <Blocks className="w-4 h-4" /> },
|
||||
{ id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> },
|
||||
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
|
||||
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'feedback', label: '提交反馈', icon: <MessageCircle className="w-4 h-4" /> },
|
||||
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
const [activePage, setActivePage] = useState<SettingsPage>('general');
|
||||
|
||||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case 'general': return <General />;
|
||||
case 'usage': return <UsageStats />;
|
||||
case 'models': return <ModelsAPI />;
|
||||
case 'mcp': return <MCPServices />;
|
||||
case 'skills': return <Skills />;
|
||||
case 'im': return <IMChannels />;
|
||||
case 'workspace': return <Workspace />;
|
||||
case 'privacy': return <Privacy />;
|
||||
case 'feedback': return <Feedback />;
|
||||
case 'about': return <About />;
|
||||
default: return <General />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-white">
|
||||
{/* Left navigation */}
|
||||
<aside className="w-56 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-3 text-sm text-gray-500 hover:text-gray-700 border-b border-gray-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回应用
|
||||
</button>
|
||||
|
||||
<nav className="flex-1 py-2">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActivePage(item.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
activePage === item.id
|
||||
? 'bg-orange-50 text-orange-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-8 py-8">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple feedback page (inline)
|
||||
function Feedback() {
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">提交反馈</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">请描述你遇到的问题或建议。默认会附带本地日志,便于快速定位问题。</p>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="请尽量详细描述复现步骤、期望结果和实际结果"
|
||||
className="w-full h-40 border border-gray-300 rounded-lg p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
disabled={!text.trim()}
|
||||
className="mt-4 px-6 py-2 bg-orange-500 text-white text-sm rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
desktop/src/components/Settings/Skills.tsx
Normal file
51
desktop/src/components/Settings/Skills.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Skills() {
|
||||
const [extraDir, setExtraDir] = useState('~/.opencode/skills');
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'available' | 'installed'>('all');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">技能</h1>
|
||||
<button className="text-sm text-gray-400">加载中...</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
技能为 Agent 扩展专业知识和工作流程。它们是从本地包、技能目录和你配置的额外目录中发现的 SKILL.md 文件。满足所有依赖条件的技能会自动处于可用状态。
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-6">
|
||||
<h2 className="text-sm font-bold text-gray-900 mb-1">额外技能目录</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">包含 SKILL.md 文件的额外目录。保存到 Gateway 配置的 skills.load.extraDirs 中。</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={extraDir}
|
||||
onChange={(e) => setExtraDir(e.target.value)}
|
||||
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['all', 'available', 'installed'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs px-3 py-1 rounded-full ${
|
||||
activeTab === tab ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'all' ? '全部 (0)' : tab === 'available' ? '可用 (0)' : '已安装 (0)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center">
|
||||
<p className="text-sm text-gray-400">暂无技能</p>
|
||||
<p className="text-xs text-gray-300 mt-1">连接 Gateway 后将自动加载技能列表</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
desktop/src/components/Settings/UsageStats.tsx
Normal file
76
desktop/src/components/Settings/UsageStats.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
|
||||
export function UsageStats() {
|
||||
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected') {
|
||||
loadUsageStats();
|
||||
}
|
||||
}, [connectionState]);
|
||||
|
||||
const stats = usageStats || { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} };
|
||||
const models = Object.entries(stats.byModel || {});
|
||||
|
||||
const formatTokens = (n: number) => {
|
||||
if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)} M`;
|
||||
if (n >= 1_000) return `~${(n / 1_000).toFixed(1)} k`;
|
||||
return `${n}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">用量统计</h1>
|
||||
<button onClick={() => loadUsageStats()} className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">本设备所有已保存对话的 Token 用量汇总。</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<StatCard label="会话数" value={stats.totalSessions} />
|
||||
<StatCard label="消息数" value={stats.totalMessages} />
|
||||
<StatCard label="总 Token" value={formatTokens(stats.totalTokens)} />
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-3">按模型</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
|
||||
{models.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">暂无数据</p>
|
||||
)}
|
||||
{models.map(([model, data]) => {
|
||||
const total = data.inputTokens + data.outputTokens;
|
||||
const maxTokens = Math.max(...models.map(([, d]) => d.inputTokens + d.outputTokens), 1);
|
||||
const pct = (total / maxTokens) * 100;
|
||||
return (
|
||||
<div key={model}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">{model}</span>
|
||||
<span className="text-xs text-gray-500">{data.messages} 条消息</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-1">
|
||||
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>输入: {formatTokens(data.inputTokens)}</span>
|
||||
<span>输出: {formatTokens(data.outputTokens)}</span>
|
||||
<span>总计: {formatTokens(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
desktop/src/components/Settings/Workspace.tsx
Normal file
82
desktop/src/components/Settings/Workspace.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Workspace() {
|
||||
const [projectDir, setProjectDir] = useState('~/.openclaw/zclaw-workspace');
|
||||
const [restrictFiles, setRestrictFiles] = useState(true);
|
||||
const [autoSaveContext, setAutoSaveContext] = useState(true);
|
||||
const [fileWatching, setFileWatching] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">工作区</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">配置本地项目目录与上下文持久化行为。</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-6">
|
||||
<h2 className="text-sm font-bold text-gray-900 mb-1">默认项目目录</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">ZCLAW 项目和上下文文件的保存位置。</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={projectDir}
|
||||
onChange={(e) => setProjectDir(e.target.value)}
|
||||
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleCard
|
||||
title="限制文件访问范围"
|
||||
description="开启后,Agent 的工作空间将限制在工作目录内。关闭后可访问更大范围,可能导致数据操作。无论开关状态,均建议提前备份重要文件。注意:受技术限制,我们无法保证完全阻止目录外执行或由此带来的外部影响;请自行评估风险并谨慎使用。"
|
||||
checked={restrictFiles}
|
||||
onChange={setRestrictFiles}
|
||||
highlight
|
||||
/>
|
||||
<ToggleCard
|
||||
title="自动保存上下文"
|
||||
description="自动将聊天记录和提取的产物保存到本地工作区文件夹。"
|
||||
checked={autoSaveContext}
|
||||
onChange={setAutoSaveContext}
|
||||
/>
|
||||
<ToggleCard
|
||||
title="文件监听"
|
||||
description="监听本地文件变更,实时更新 Agent 上下文。"
|
||||
checked={fileWatching}
|
||||
onChange={setFileWatching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-gray-50 rounded-xl p-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">从 OpenClaw 迁移</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">将 OpenClaw 的配置、对话记录、技能等数据迁移到 ZCLAW</p>
|
||||
</div>
|
||||
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100">开始迁移</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleCard({ title, description, checked, onChange, highlight }: {
|
||||
title: string; description: string; checked: boolean; onChange: (v: boolean) => void; highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl p-5 ${highlight ? 'bg-orange-50 border border-orange-200' : 'bg-gray-50'}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 mr-4">
|
||||
<h3 className={`text-sm font-bold ${highlight ? 'text-orange-700' : 'text-gray-900'}`}>{title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 mt-0.5 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,55 @@
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { Settings, Cat, Search, Globe, BarChart } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Settings, MessageSquare, Clock, Bot, Radio } from 'lucide-react';
|
||||
import { CloneManager } from './CloneManager';
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { TaskList } from './TaskList';
|
||||
|
||||
export function Sidebar() {
|
||||
const { agents, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = React.useState('agents');
|
||||
interface SidebarProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'chats' | 'clones' | 'channels' | 'tasks';
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: typeof MessageSquare }[] = [
|
||||
{ key: 'chats', label: '对话', icon: MessageSquare },
|
||||
{ key: 'clones', label: '分身', icon: Bot },
|
||||
{ key: 'channels', label: '频道', icon: Radio },
|
||||
{ key: 'tasks', label: '任务', icon: Clock },
|
||||
];
|
||||
|
||||
export function Sidebar({ onOpenSettings }: SidebarProps) {
|
||||
const { connectionState } = useGatewayStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('chats');
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
|
||||
{/* 顶部标签 */}
|
||||
<div className="flex border-b border-gray-200 bg-white">
|
||||
<button
|
||||
className={lex-1 py-3 px-4 text-xs font-medium }
|
||||
onClick={() => setActiveTab('agents')}
|
||||
>
|
||||
分身
|
||||
</button>
|
||||
<button
|
||||
className={lex-1 py-3 px-4 text-xs font-medium }
|
||||
onClick={() => setActiveTab('channels')}
|
||||
>
|
||||
IM 频道
|
||||
</button>
|
||||
<button
|
||||
className={lex-1 py-3 px-4 text-xs font-medium }
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
>
|
||||
定时任务
|
||||
</button>
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`flex-1 py-3 px-2 text-xs font-medium transition-colors ${
|
||||
activeTab === key
|
||||
? 'text-orange-600 border-b-2 border-orange-500'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Agent 列表 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar py-2">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={sidebar-item mx-2 px-3 py-3 rounded-lg cursor-pointer mb-1 }
|
||||
onClick={() => setCurrentAgent(agent)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0}>
|
||||
<span className="text-xl">{agent.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-0.5">
|
||||
<span className="font-semibold text-gray-900 truncate">{agent.name}</span>
|
||||
<span className="text-xs text-gray-400">{agent.time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate leading-relaxed">{agent.lastMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'chats' && <ConversationList />}
|
||||
{activeTab === 'clones' && <CloneManager />}
|
||||
{activeTab === 'channels' && <ChannelList onOpenSettings={onOpenSettings} />}
|
||||
{activeTab === 'tasks' && <TaskList />}
|
||||
</div>
|
||||
|
||||
{/* 底部用户 */}
|
||||
@@ -59,8 +58,13 @@ export function Sidebar() {
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||
🦞
|
||||
</div>
|
||||
<span className="font-medium text-gray-700">用户7141</span>
|
||||
<button className="ml-auto text-gray-400 hover:text-gray-600">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-700 text-sm">用户7141</span>
|
||||
<div className={`text-xs ${connected ? 'text-green-500' : 'text-gray-400'}`}>
|
||||
{connected ? '已连接' : '未连接'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-gray-400 hover:text-gray-600" onClick={onOpenSettings}>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
107
desktop/src/components/TaskList.tsx
Normal file
107
desktop/src/components/TaskList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Clock, RefreshCw, Play, Pause, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: typeof Play; color: string; label: string }> = {
|
||||
active: { icon: Play, color: 'text-green-500', label: '运行中' },
|
||||
paused: { icon: Pause, color: 'text-yellow-500', label: '已暂停' },
|
||||
completed: { icon: CheckCircle2, color: 'text-blue-500', label: '已完成' },
|
||||
error: { icon: AlertCircle, color: 'text-red-500', label: '错误' },
|
||||
};
|
||||
|
||||
export function TaskList() {
|
||||
const { scheduledTasks, connectionState, loadScheduledTasks } = useGatewayStore();
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadScheduledTasks();
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
|
||||
<Clock className="w-8 h-8 mb-2 opacity-30" />
|
||||
<p>定时任务</p>
|
||||
<p className="mt-1">连接 Gateway 后可用</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-xs font-medium text-gray-500">Heartbeat 任务</span>
|
||||
<button
|
||||
onClick={loadScheduledTasks}
|
||||
className="p-1 text-gray-400 hover:text-orange-500 rounded"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{scheduledTasks.length > 0 ? (
|
||||
scheduledTasks.map((task) => {
|
||||
const cfg = STATUS_CONFIG[task.status] || STATUS_CONFIG.active;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="px-3 py-3 border-b border-gray-50 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<StatusIcon className={`w-3.5 h-3.5 flex-shrink-0 ${cfg.color}`} />
|
||||
<span className="text-xs font-medium text-gray-900 truncate">{task.name}</span>
|
||||
</div>
|
||||
<div className="pl-5.5 space-y-0.5">
|
||||
<div className="text-[11px] text-gray-500 font-mono">{task.schedule}</div>
|
||||
{task.description && (
|
||||
<div className="text-[11px] text-gray-400 truncate">{task.description}</div>
|
||||
)}
|
||||
<div className="flex gap-3 text-[10px] text-gray-400">
|
||||
{task.lastRun && <span>上次: {formatTaskTime(task.lastRun)}</span>}
|
||||
{task.nextRun && <span>下次: {formatTaskTime(task.nextRun)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
|
||||
<Clock className="w-8 h-8 mb-2 opacity-30" />
|
||||
<p>暂无定时任务</p>
|
||||
<p className="mt-1">Heartbeat 引擎管理的定时任务</p>
|
||||
<p className="mt-0.5 text-[11px]">默认心跳周期: 1h</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTaskTime(timeStr: string): string {
|
||||
try {
|
||||
const d = new Date(timeStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const future = diffMs < 0;
|
||||
const absDiff = Math.abs(diffMs);
|
||||
const mins = Math.floor(absDiff / 60000);
|
||||
|
||||
if (mins < 1) return future ? '即将' : '刚刚';
|
||||
if (mins < 60) return future ? `${mins}分钟后` : `${mins}分钟前`;
|
||||
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return future ? `${hrs}小时后` : `${hrs}小时前`;
|
||||
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
} catch {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user