Add web
This commit is contained in:
1129
web/pages/Assistants.tsx
Normal file
1129
web/pages/Assistants.tsx
Normal file
File diff suppressed because it is too large
Load Diff
314
web/pages/AutoTest.tsx
Normal file
314
web/pages/AutoTest.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ClipboardCheck, X } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
|
||||
import { mockAutoTestAssistants, mockAssistants } from '../services/mockData';
|
||||
import { AutoTestAssistant, TestType, TestMethod } from '../types';
|
||||
|
||||
export const AutoTestPage: React.FC = () => {
|
||||
const [testAssistants, setTestAssistants] = useState<AutoTestAssistant[]>(mockAutoTestAssistants);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
const filteredTests = testAssistants.filter(t =>
|
||||
t.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedTest = testAssistants.find(t => t.id === selectedId) || null;
|
||||
|
||||
const handleCreate = () => {
|
||||
const newId = crypto.randomUUID();
|
||||
const newTest: AutoTestAssistant = {
|
||||
id: newId,
|
||||
name: '新测试任务',
|
||||
type: TestType.FIXED,
|
||||
method: TestMethod.TEXT,
|
||||
targetAssistantId: mockAssistants[0]?.id || '',
|
||||
fixedWorkflowSteps: [],
|
||||
intelligentPrompt: '',
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
setTestAssistants([newTest, ...testAssistants]);
|
||||
setSelectedId(newId);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent, test: AutoTestAssistant) => {
|
||||
e.stopPropagation();
|
||||
const newId = crypto.randomUUID();
|
||||
const newTest: AutoTestAssistant = {
|
||||
...test,
|
||||
id: newId,
|
||||
name: `${test.name} (复制)`,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
setTestAssistants([newTest, ...testAssistants]);
|
||||
setSelectedId(newId);
|
||||
};
|
||||
|
||||
const updateTest = (field: keyof AutoTestAssistant, value: any) => {
|
||||
if (!selectedId) return;
|
||||
setTestAssistants(prev => prev.map(t => t.id === selectedId ? { ...t, [field]: value } : t));
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(id);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
setTestAssistants(prev => prev.filter(t => t.id !== deleteId));
|
||||
if (selectedId === deleteId) setSelectedId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = (id: string) => {
|
||||
navigator.clipboard.writeText(id);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] gap-6 animate-in fade-in py-4">
|
||||
{/* Left List */}
|
||||
<div className="w-80 flex flex-col gap-4 shrink-0">
|
||||
<h2 className="text-xl font-bold tracking-tight text-white">测试助手列表</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索测试..."
|
||||
className="pl-9 bg-card/50 border-white/5"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="icon" onClick={handleCreate} title="新建测试">
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{filteredTests.map(test => (
|
||||
<div
|
||||
key={test.id}
|
||||
onClick={() => setSelectedId(test.id)}
|
||||
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
selectedId === test.id
|
||||
? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.15)]'
|
||||
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 mb-2 pr-16 overflow-hidden">
|
||||
<span className={`font-semibold truncate ${selectedId === test.id ? 'text-primary' : 'text-foreground'}`}>
|
||||
{test.name}
|
||||
</span>
|
||||
<div className="flex">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] uppercase tracking-tighter shrink-0 opacity-70 border-white/10 ${
|
||||
test.type === TestType.FIXED ? 'text-blue-400 bg-blue-400/5' : 'text-purple-400 bg-purple-400/5'
|
||||
}`}
|
||||
>
|
||||
{test.type === TestType.FIXED ? '固定' : '智能'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono opacity-60">
|
||||
创建于: {test.createdAt}
|
||||
</div>
|
||||
|
||||
{/* Hover Actions Toolbar */}
|
||||
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5 shadow-lg border border-white/5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, test)} title="复制测试任务">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, test.id)} title="删除测试任务">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredTests.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
未找到测试助手
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Config Panel */}
|
||||
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
|
||||
{selectedTest ? (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.02]">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1">测试助手名称</label>
|
||||
</div>
|
||||
<Input
|
||||
value={selectedTest.name}
|
||||
onChange={(e) => updateTest('name', e.target.value)}
|
||||
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => alert("开始自动化测试脚本生成...")}>
|
||||
<Zap className="mr-2 h-4 w-4" /> 开始测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="max-w-3xl mx-auto space-y-8 animate-in slide-in-from-bottom-2">
|
||||
{/* Basic Config */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">目标小助手</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm text-foreground focus:ring-1 focus:ring-primary/50 [&>option]:bg-card"
|
||||
value={selectedTest.targetAssistantId}
|
||||
onChange={(e) => updateTest('targetAssistantId', e.target.value)}
|
||||
>
|
||||
{mockAssistants.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">测试方式</label>
|
||||
<div className="flex bg-white/5 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => updateTest('method', TestMethod.TEXT)}
|
||||
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.TEXT ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3 mr-1.5" /> 文本
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTest('method', TestMethod.AUDIO)}
|
||||
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.AUDIO ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
<Mic className="w-3 h-3 mr-1.5" /> 语音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Logic Type */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-sm font-medium text-white">测试逻辑类型</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.FIXED ? 'border-primary bg-primary/5' : 'border-white/5'}`}
|
||||
onClick={() => updateTest('type', TestType.FIXED)}
|
||||
>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Badge variant={selectedTest.type === TestType.FIXED ? 'default' : 'outline'}>固定流程</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground text-white/70">按照预设的消息序列依次发送给目标小助手,验证其回复是否符合预期。</p>
|
||||
</Card>
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.INTELLIGENT ? 'border-primary bg-primary/5' : 'border-white/5'}`}
|
||||
onClick={() => updateTest('type', TestType.INTELLIGENT)}
|
||||
>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Badge variant={selectedTest.type === TestType.INTELLIGENT ? 'default' : 'outline'}>智能提示词</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground text-white/70">由 AI 扮演用户,根据设定的角色和场景与目标小助手进行动态对话。</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Config */}
|
||||
{selectedTest.type === TestType.FIXED ? (
|
||||
<div className="space-y-4">
|
||||
<label className="text-sm font-medium text-white">对话步骤列表</label>
|
||||
<div className="space-y-2">
|
||||
{selectedTest.fixedWorkflowSteps.map((step, idx) => (
|
||||
<div key={idx} className="flex gap-2 animate-in slide-in-from-left-2" style={{ animationDelay: `${idx * 50}ms` }}>
|
||||
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/5 rounded-lg text-xs font-mono text-muted-foreground">{idx + 1}</div>
|
||||
<Input
|
||||
value={step}
|
||||
onChange={(e) => {
|
||||
const newSteps = [...selectedTest.fixedWorkflowSteps];
|
||||
newSteps[idx] = e.target.value;
|
||||
updateTest('fixedWorkflowSteps', newSteps);
|
||||
}}
|
||||
placeholder="输入用户消息..."
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => {
|
||||
updateTest('fixedWorkflowSteps', selectedTest.fixedWorkflowSteps.filter((_, i) => i !== idx));
|
||||
}}>
|
||||
<X size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" className="w-full border-dashed" onClick={() => {
|
||||
updateTest('fixedWorkflowSteps', [...selectedTest.fixedWorkflowSteps, '']);
|
||||
}}>
|
||||
<Plus size={14} className="mr-2" /> 添加步骤
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<label className="text-sm font-medium text-white">测试助手人设 (Intelligent Prompt)</label>
|
||||
<textarea
|
||||
className="w-full h-48 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
|
||||
value={selectedTest.intelligentPrompt}
|
||||
onChange={(e) => updateTest('intelligentPrompt', e.target.value)}
|
||||
placeholder="描述扮演的角色背景、性格特点以及本次测试需要达成的目标..."
|
||||
/>
|
||||
<div className="p-3 bg-primary/5 border border-primary/20 rounded-lg text-[10px] text-muted-foreground flex items-start gap-2">
|
||||
<Zap className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<p>智能测试将使用另一个 AI 模型来模拟真实用户,它可以更好地检测目标小助手的异常处理能力和话术柔顺度。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<Zap className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">请选择或创建一个测试任务</p>
|
||||
<p className="text-sm opacity-60">用于自动化验证小助手的逻辑表现</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
title="确认删除"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setIsDeleteModalOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>确认删除</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-destructive/10 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white">您确定要删除此测试任务吗?此操作无法撤销。</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
测试任务: {testAssistants.find(t => t.id === deleteId)?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
web/pages/CallLogs.tsx
Normal file
133
web/pages/CallLogs.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Search, Calendar, Filter } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
|
||||
import { mockCallLogs } from '../services/mockData';
|
||||
|
||||
export const CallLogsPage: React.FC = () => {
|
||||
const [logs] = useState(mockCallLogs);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || log.status === statusFilter;
|
||||
const matchesSource = sourceFilter === 'all' || log.source === sourceFilter;
|
||||
return matchesSearch && matchesStatus && matchesSource;
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
// Generate CSV content
|
||||
const headers = ['ID', 'Agent', 'Source', 'Status', 'Start Time', 'Duration'];
|
||||
const rows = filteredLogs.map(log => [
|
||||
log.id,
|
||||
log.agentName,
|
||||
log.source,
|
||||
log.status,
|
||||
log.startTime,
|
||||
log.duration
|
||||
].join(','));
|
||||
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...headers.join(',')].join('\n');
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "call_logs.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-2 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">视频通话记录</h1>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="mr-2 h-4 w-4" /> 导出记录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索代理小助手..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有来源</option>
|
||||
<option value="debug">调试 (Debug)</option>
|
||||
<option value="external">外部测试 (External)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="connected">已接通</option>
|
||||
<option value="missed">未接通</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="date" className="pl-9 border-0 bg-white/5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编号</TableHead>
|
||||
<TableHead>代理小助手</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>接听状态</TableHead>
|
||||
<TableHead>通话接通时间</TableHead>
|
||||
<TableHead>通话时长</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredLogs.map(log => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">#{log.id}</TableCell>
|
||||
<TableCell className="font-medium">{log.agentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.source === 'debug' ? '调试' : '外部'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.status === 'connected' ? 'success' : 'warning'}>
|
||||
{log.status === 'connected' ? '已接通' : '未接通'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{log.startTime}</TableCell>
|
||||
<TableCell>{log.duration}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLogs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-6 text-muted-foreground">暂无记录</TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
309
web/pages/Dashboard.tsx
Normal file
309
web/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter, ChevronDown, BarChart3, HelpCircle, Mail, Sparkles, Terminal, Box, Zap, ShieldCheck } from 'lucide-react';
|
||||
import { Card, Button } from '../components/UI';
|
||||
import { mockAssistants, getDashboardStats } from '../services/mockData';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
|
||||
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('all');
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getDashboardStats(timeRange, selectedAssistantId);
|
||||
}, [timeRange, selectedAssistantId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col animate-in fade-in py-1">
|
||||
<div className="w-full max-w-[1600px] mx-auto space-y-4 px-2 lg:px-6">
|
||||
|
||||
{/* 1. Utility Row (Top Navigation Actions) */}
|
||||
<div className="flex justify-end items-center gap-2 border-b border-white/[0.03] pb-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2.5 text-[10px] font-bold border border-white/5 hover:bg-primary/10 hover:text-primary transition-all text-white/70">
|
||||
<HelpCircle className="w-3 h-3 mr-1.5 opacity-70" /> 文档
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2.5 text-[10px] font-bold border border-white/5 hover:bg-primary/10 hover:text-primary transition-all text-white/70">
|
||||
<Mail className="w-3 h-3 mr-1.5 opacity-70" /> 联系方式
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 2. Welcome Header */}
|
||||
<div className="flex flex-col space-y-4 text-center md:text-left pt-1">
|
||||
<div className="space-y-0.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
欢迎, <span className="text-primary">Admin User</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground flex items-center justify-center md:justify-start text-[11px]">
|
||||
系统当前运行环境:
|
||||
<span className="flex items-center ml-2 text-green-400 text-[10px] font-mono bg-green-400/10 px-1.5 py-0.5 rounded-full border border-green-400/20">
|
||||
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse mr-1"></span>
|
||||
HEALTHY
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Section Header: Title + Filters */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between border-b border-white/5 pb-3 pt-2 gap-3">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="p-1 bg-primary/10 rounded-lg">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-sm font-bold text-white tracking-wide leading-none">用量标准</h2>
|
||||
<span className="text-[8px] font-mono text-muted-foreground uppercase tracking-[0.2em] opacity-40 mt-1">Metrics Overview</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 bg-black/20 p-0.5 rounded-lg border border-white/5 shadow-inner scale-95 origin-right">
|
||||
<div className="relative group min-w-[130px]">
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground group-focus-within:text-primary transition-colors">
|
||||
<Filter className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
<select
|
||||
className="w-full bg-transparent border-0 rounded-lg pl-7 pr-6 py-1 text-[10px] font-bold focus:outline-none appearance-none cursor-pointer transition-all text-white/80"
|
||||
value={selectedAssistantId}
|
||||
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
||||
>
|
||||
<option value="all" className="bg-background">所有小助手</option>
|
||||
{mockAssistants.map(a => (
|
||||
<option key={a.id} value={a.id} className="bg-background">{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 h-2.5 w-2.5 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="h-3 w-px bg-white/10 mx-0.5"></div>
|
||||
|
||||
<div className="flex gap-0.5">
|
||||
{(['week', 'month', 'year'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setTimeRange(r)}
|
||||
className={`px-2.5 py-0.5 text-[9px] font-black uppercase tracking-tight rounded transition-all ${timeRange === r ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
{r === 'week' ? '周' : r === 'month' ? '月' : '年'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Metrics Grid (Cards) */}
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="通话数量"
|
||||
value={stats.totalCalls.toString()}
|
||||
icon={<Phone className="h-3.5 w-3.5 text-primary" />}
|
||||
trend="+12.5% UP"
|
||||
/>
|
||||
<StatCard
|
||||
title="接通率"
|
||||
value={`${stats.answerRate}%`}
|
||||
icon={<CheckCircle className="h-3.5 w-3.5 text-green-400" />}
|
||||
trend="+2.1% UP"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均时长"
|
||||
value={stats.avgDuration}
|
||||
icon={<Clock className="h-3.5 w-3.5 text-blue-400" />}
|
||||
trend="-0.5% LOW"
|
||||
/>
|
||||
<StatCard
|
||||
title="转人工数"
|
||||
value={stats.humanTransferCount.toString()}
|
||||
icon={<UserCheck className="h-3.5 w-3.5 text-purple-400" />}
|
||||
trend="+5% STABLE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 5. Charts Section */}
|
||||
<div className="pt-1">
|
||||
<Card className="p-5 border-white/5 bg-card/20 backdrop-blur-sm overflow-hidden shadow-xl">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between mb-6 gap-3">
|
||||
<div className="space-y-0.5 text-center md:text-left">
|
||||
<h3 className="text-base font-bold leading-none flex items-center justify-center md:justify-start text-white">
|
||||
<Activity className="h-4 w-4 text-primary mr-2" />
|
||||
通话趋势 (Performance Insight)
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground font-mono opacity-40">REAL-TIME DATA PROCESSING PIPELINE ENABLED</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-6 w-6 rounded-full border border-primary/20 flex items-center justify-center animate-spin-slow">
|
||||
<div className="h-1 w-1 rounded-full bg-primary shadow-[0_0_8px_rgba(6,182,212,0.8)]"></div>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-primary animate-pulse tracking-widest uppercase">Streaming</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] w-full">
|
||||
<SimpleAreaChart data={stats.trend} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 6. Platform Feature Intro - Moved to Bottom, Full Width */}
|
||||
<div className="w-full bg-white/[0.02] border border-white/5 rounded-2xl p-6 animate-in slide-in-from-bottom-4 duration-700 shadow-2xl relative overflow-hidden group pb-10 mb-10">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 blur-[100px] -mr-32 -mt-32 rounded-full pointer-events-none group-hover:bg-primary/10 transition-colors"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1.5 bg-primary/20 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white tracking-wide">关于平台</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-white/80 leading-relaxed font-medium">
|
||||
AI视频助手是一个领先的多模态智能体管理平台,致力于通过先进的 AI 技术为企业和个人提供高效、低延迟、拟人化的音视频通话解决方案。🚀
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2 group/item">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<h4 className="text-xs font-bold text-primary uppercase tracking-wider">多模态智能体</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
|
||||
支持构建具备文本对话、高保真语音输出以及双向实时视频通话能力的智能助手,覆盖 7x24h 智能客服场景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 group/item">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📚</span>
|
||||
<h4 className="text-xs font-bold text-primary uppercase tracking-wider">动态知识检索</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
|
||||
深度集成 RAG 技术,允许上传私有 PDF/DOCX 文档,让智能体在通话中基于企业私域知识库提供精准、权威的回复。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 group/item">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🎙️</span>
|
||||
<h4 className="text-xs font-bold text-primary uppercase tracking-wider">音色库与克隆</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
|
||||
集成多家主流 TTS 引擎,支持极致的声音克隆与微调,为您的品牌定制专属的、富有情感表现力的真人音色。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 group/item">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🛡️</span>
|
||||
<h4 className="text-xs font-bold text-primary uppercase tracking-wider">端到端测试</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
|
||||
内置自动化测试助手,可通过固定流程或 AI 智能模拟用户进行压力测试与逻辑验证,确保发布前的服务稳定性。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub Components ---
|
||||
|
||||
const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode; trend?: string }> = ({ title, value, icon, trend }) => (
|
||||
<Card className="p-4 border-white/5 bg-card/30 hover:border-primary/40 hover:bg-card/50 transition-all duration-300 group flex flex-col justify-between min-h-[110px] shadow-lg">
|
||||
<div className="flex flex-row items-center justify-between space-y-0">
|
||||
<h3 className="text-[9px] font-mono font-bold text-muted-foreground uppercase tracking-[0.15em]">{title}</h3>
|
||||
<div className="p-1.5 bg-white/5 rounded-lg group-hover:bg-primary/20 transition-all group-hover:scale-110">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="text-2xl font-black tracking-tight text-white group-hover:text-primary transition-colors">{value}</div>
|
||||
{trend && (
|
||||
<p className={`text-[8px] font-bold font-mono mt-1 flex items-center ${trend.includes('+') ? 'text-green-400' : 'text-red-400'}`}>
|
||||
<span className="bg-white/5 px-1 rounded-sm mr-1 opacity-70">{trend}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> = ({ data }) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const height = 300;
|
||||
const width = 1400;
|
||||
const padding = 30;
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.value)) * 1.2;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * (width - padding * 2) + padding;
|
||||
const y = height - (d.value / maxValue) * (height - padding * 2) - padding;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
const firstPoint = points.split(' ')[0];
|
||||
const lastPoint = points.split(' ')[points.split(' ').length - 1];
|
||||
const fillPath = `${points} ${lastPoint.split(',')[0]},${height} ${firstPoint.split(',')[0]},${height}`;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible" preserveAspectRatio="none">
|
||||
{/* Grid Lines */}
|
||||
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
{[0.25, 0.5, 0.75].map(v => (
|
||||
<line
|
||||
key={v}
|
||||
x1={padding} y1={height - padding - ((height - padding * 2) * v)}
|
||||
x2={width - padding} y2={height - padding - ((height - padding * 2) * v)}
|
||||
stroke="rgba(255,255,255,0.02)" strokeWidth="1" strokeDasharray="8 6"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Area Fill Gradient */}
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="5" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<polygon points={fillPath} fill="url(#chartGradient)" />
|
||||
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
|
||||
{data.length < 32 && data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * (width - padding * 2) + padding;
|
||||
const y = height - (d.value / maxValue) * (height - padding * 2) - padding;
|
||||
return (
|
||||
<g key={i} className="group/dot">
|
||||
<circle cx={x} cy={y} r="3.5" fill="hsl(var(--background))" stroke="hsl(var(--primary))" strokeWidth="2" className="transition-all duration-300 group-hover/dot:r-5 group-hover/dot:stroke-white" />
|
||||
<circle cx={x} cy={y} r="10" fill="hsl(var(--primary))" fillOpacity="0" className="cursor-pointer" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* X-Axis Labels */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-[3%] text-[8px] text-muted-foreground pointer-events-none font-mono opacity-30 mt-2">
|
||||
{data.filter((_, i) => i % Math.ceil(data.length / 7) === 0).map((d, i) => (
|
||||
<span key={i}>{d.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
128
web/pages/History.tsx
Normal file
128
web/pages/History.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Search, Calendar, Filter } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
|
||||
import { mockCallLogs } from '../services/mockData';
|
||||
|
||||
export const HistoryPage: React.FC = () => {
|
||||
const [logs] = useState(mockCallLogs);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || log.status === statusFilter;
|
||||
const matchesSource = sourceFilter === 'all' || log.source === sourceFilter;
|
||||
return matchesSearch && matchesStatus && matchesSource;
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
// Generate CSV content
|
||||
const headers = ['ID', 'Agent', 'Source', 'Status', 'Start Time', 'Duration'];
|
||||
const rows = filteredLogs.map(log => [
|
||||
log.id,
|
||||
log.agentName,
|
||||
log.source,
|
||||
log.status,
|
||||
log.startTime,
|
||||
log.duration
|
||||
].join(','));
|
||||
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...headers.join(',')].join('\n');
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "history_logs.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">历史记录</h1>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="mr-2 h-4 w-4" /> 导出记录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索代理小助手..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有来源</option>
|
||||
<option value="debug">调试 (Debug)</option>
|
||||
<option value="external">外部测试 (External)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="connected">已接通</option>
|
||||
<option value="missed">未接通</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="date" className="pl-9 border-0 bg-white/5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编号</TableHead>
|
||||
<TableHead>代理小助手</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>接听状态</TableHead>
|
||||
<TableHead>通话接通时间</TableHead>
|
||||
<TableHead>通话时长</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredLogs.map(log => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">#{log.id}</TableCell>
|
||||
<TableCell className="font-medium text-white">{log.agentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.source === 'debug' ? '调试' : '外部'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.status === 'connected' ? 'success' : 'warning'}>
|
||||
{log.status === 'connected' ? '已接通' : '未接通'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{log.startTime}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{log.duration}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLogs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">暂无记录</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
298
web/pages/KnowledgeBase.tsx
Normal file
298
web/pages/KnowledgeBase.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
|
||||
import { mockKnowledgeBases } from '../services/mockData';
|
||||
import { KnowledgeBase } from '../types';
|
||||
|
||||
export const KnowledgeBasePage: React.FC = () => {
|
||||
const [view, setView] = useState<'list' | 'detail'>('list');
|
||||
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [kbs, setKbs] = useState(mockKnowledgeBases);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
|
||||
const [newKbName, setNewKbName] = useState('');
|
||||
|
||||
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const handleSelect = (kb: KnowledgeBase) => {
|
||||
setSelectedKb(kb);
|
||||
setView('detail');
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
setIsUploadOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateKb = () => {
|
||||
if (!newKbName.trim()) return;
|
||||
|
||||
const newKb: KnowledgeBase = {
|
||||
id: `kb_${Date.now()}`,
|
||||
name: newKbName.trim(),
|
||||
creator: 'Admin User',
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
documents: []
|
||||
};
|
||||
|
||||
setKbs([newKb, ...kbs]);
|
||||
setIsCreateKbOpen(false);
|
||||
setNewKbName('');
|
||||
};
|
||||
|
||||
if (view === 'detail' && selectedKb) {
|
||||
return (
|
||||
<div className="py-4 pb-10">
|
||||
<KnowledgeBaseDetail
|
||||
kb={selectedKb}
|
||||
onBack={() => setView('list')}
|
||||
onImport={handleImportClick}
|
||||
/>
|
||||
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">知识库</h1>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - Layout aligned with History Page and width filled */}
|
||||
<div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索知识库名称..."
|
||||
className="pl-9 border-0 bg-white/5 w-full"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredKbs.map(kb => (
|
||||
<Card
|
||||
key={kb.id}
|
||||
className="p-6 hover:border-primary/50 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div onClick={() => handleSelect(kb)}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3>
|
||||
<div className="mt-4 space-y-1 text-sm text-muted-foreground">
|
||||
<p>文档数量: {kb.documents.length}</p>
|
||||
<p>创建人: {kb.creator}</p>
|
||||
<p>创建时间: {kb.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Add New Placeholder */}
|
||||
<div
|
||||
onClick={() => setIsCreateKbOpen(true)}
|
||||
className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]"
|
||||
>
|
||||
<Plus className="h-8 w-8 mb-2 opacity-50" />
|
||||
<span>新建知识库</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Knowledge Base Dialog */}
|
||||
<Dialog
|
||||
isOpen={isCreateKbOpen}
|
||||
onClose={() => setIsCreateKbOpen(false)}
|
||||
title="新建知识库"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setIsCreateKbOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateKb} disabled={!newKbName.trim()}>确认创建</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">知识库名称</label>
|
||||
<Input
|
||||
value={newKbName}
|
||||
onChange={(e) => setNewKbName(e.target.value)}
|
||||
placeholder="请输入知识库名称..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateKb()}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
知识库用于存储私域文档,AI 小助手在回答问题时会优先检索绑定的知识库内容。
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KnowledgeBaseDetail: React.FC<{
|
||||
kb: KnowledgeBase;
|
||||
onBack: () => void;
|
||||
onImport: () => void;
|
||||
}> = ({ kb, onBack, onImport }) => {
|
||||
const [docSearch, setDocSearch] = useState('');
|
||||
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in slide-in-from-right-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{kb.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">创建于 {kb.createdAt} · by {kb.creator}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onImport}>
|
||||
<Upload className="mr-2 h-4 w-4" /> 新增知识 (导入)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-white/5">
|
||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
|
||||
<h3 className="font-medium text-white">文档列表</h3>
|
||||
<div className="w-64">
|
||||
<Input
|
||||
placeholder="搜索文档..."
|
||||
value={docSearch}
|
||||
onChange={(e) => setDocSearch(e.target.value)}
|
||||
className="bg-black/20 border-transparent focus:bg-black/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文档名称</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>上传时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredDocs.length > 0 ? filteredDocs.map(doc => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell className="font-medium flex items-center text-white">
|
||||
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{doc.size}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80">删除</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">暂无文档</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFiles(prev => [...prev, ...Array.from(e.target.files || [])]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="上传知识文档"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={() => { alert('Upload Started!'); onClose(); setFiles([]); }}>确认上传</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-colors ${dragActive ? "border-primary bg-primary/10" : "border-white/10 bg-white/5 hover:bg-white/10"}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
/>
|
||||
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<span className="font-semibold text-primary">点击上传</span> 或将文件拖拽到此处
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 text-white/50">支持 PDF, DOCX, TXT (Max 10MB)</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4 space-y-2 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<FileIcon className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm truncate max-w-[200px] text-white">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</div>
|
||||
<button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
56
web/pages/Profile.tsx
Normal file
56
web/pages/Profile.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { User, Globe, LogOut, Settings } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../components/UI';
|
||||
|
||||
export const ProfilePage: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-in fade-in pt-10">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-20 w-20 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-3xl font-bold">
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin User</h1>
|
||||
<p className="text-muted-foreground">admin@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<Card className="p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center"><User className="mr-2 h-5 w-5"/> 账户信息</h2>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">用户名</label>
|
||||
<Input defaultValue="Admin User" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">邮箱</label>
|
||||
<Input defaultValue="admin@example.com" disabled className="bg-muted"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button>保存更改</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center"><Settings className="mr-2 h-5 w-5"/> 系统设置</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<span>语言选择 / Language</span>
|
||||
</div>
|
||||
<select className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
<option>中文</option>
|
||||
<option>English</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button variant="destructive" className="w-full">
|
||||
<LogOut className="mr-2 h-4 w-4" /> 退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
471
web/pages/VoiceLibrary.tsx
Normal file
471
web/pages/VoiceLibrary.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||||
import { mockVoices } from '../services/mockData';
|
||||
import { Voice } from '../types';
|
||||
|
||||
export const VoiceLibraryPage: React.FC = () => {
|
||||
const [voices, setVoices] = useState<Voice[]>(mockVoices);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all');
|
||||
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
|
||||
const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all');
|
||||
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
const filteredVoices = voices.filter(voice => {
|
||||
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesVendor = vendorFilter === 'all' || voice.vendor === vendorFilter;
|
||||
const matchesGender = genderFilter === 'all' || voice.gender === genderFilter;
|
||||
const matchesLang = langFilter === 'all' || voice.language === langFilter;
|
||||
return matchesSearch && matchesVendor && matchesGender && matchesLang;
|
||||
});
|
||||
|
||||
const handlePlayToggle = (id: string) => {
|
||||
if (playingVoiceId === id) {
|
||||
setPlayingVoiceId(null);
|
||||
} else {
|
||||
setPlayingVoiceId(id);
|
||||
setTimeout(() => {
|
||||
setPlayingVoiceId((current) => current === id ? null : current);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSuccess = (newVoice: Voice) => {
|
||||
setVoices([newVoice, ...voices]);
|
||||
setIsAddModalOpen(false);
|
||||
setIsCloneModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">声音库</h1>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="primary" onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||||
<Plus className="mr-2 h-4 w-4" /> 添加声音
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setIsCloneModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||||
<Mic2 className="mr-2 h-4 w-4" /> 克隆声音
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索声音名称..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={vendorFilter}
|
||||
onChange={(e) => setVendorFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有厂商</option>
|
||||
<option value="硅基流动">硅基流动 (SiliconFlow)</option>
|
||||
<option value="Ali">阿里 (Ali)</option>
|
||||
<option value="Volcano">火山 (Volcano)</option>
|
||||
<option value="Minimax">Minimax</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={genderFilter}
|
||||
onChange={(e) => setGenderFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有性别</option>
|
||||
<option value="Male">男 (Male)</option>
|
||||
<option value="Female">女 (Female)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={langFilter}
|
||||
onChange={(e) => setLangFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有语言</option>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>声音名称</TableHead>
|
||||
<TableHead>厂商</TableHead>
|
||||
<TableHead>性别</TableHead>
|
||||
<TableHead>语言</TableHead>
|
||||
<TableHead className="text-right">试听</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredVoices.map(voice => (
|
||||
<TableRow key={voice.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center text-white">
|
||||
{voice.vendor === '硅基流动' && <Sparkles className="w-3 h-3 text-primary mr-1.5" />}
|
||||
{voice.name}
|
||||
</span>
|
||||
{voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={voice.vendor === '硅基流动' ? 'default' : 'outline'}>{voice.vendor}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{voice.gender === 'Male' ? '男' : '女'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{voice.language === 'zh' ? '中文' : 'English'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePlayToggle(voice.id)}
|
||||
className={playingVoiceId === voice.id ? "text-primary animate-pulse" : ""}
|
||||
>
|
||||
{playingVoiceId === voice.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredVoices.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AddVoiceModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
|
||||
<CloneVoiceModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Unified Add Voice Modal ---
|
||||
const AddVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => void;
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [sfModel, setSfModel] = useState('fishaudio/fish-speech-1.5');
|
||||
const [sfVoiceId, setSfVoiceId] = useState('fishaudio:amy');
|
||||
const [sfSpeed, setSfSpeed] = useState(1);
|
||||
const [sfGain, setSfGain] = useState(0);
|
||||
|
||||
const [model, setModel] = useState('');
|
||||
const [voiceKey, setVoiceKey] = useState('');
|
||||
const [gender, setGender] = useState('Female');
|
||||
const [language, setLanguage] = useState('zh');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [testInput, setTestInput] = useState('你好,正在测试语音合成效果。');
|
||||
const [isAuditioning, setIsAuditioning] = useState(false);
|
||||
|
||||
const handleAudition = () => {
|
||||
if (!testInput.trim()) return;
|
||||
setIsAuditioning(true);
|
||||
setTimeout(() => setIsAuditioning(false), 2000);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name) { alert("请填写声音显示名称"); return; }
|
||||
|
||||
let newVoice: Voice = {
|
||||
id: `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
|
||||
name: name,
|
||||
vendor: vendor,
|
||||
gender: gender,
|
||||
language: language,
|
||||
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`)
|
||||
};
|
||||
|
||||
onSuccess(newVoice);
|
||||
setName('');
|
||||
setVendor('硅基流动');
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="添加声音"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90">确认添加</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 max-h-[75vh] overflow-y-auto px-1 custom-scrollbar">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">厂商 (Vendor)</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value as any)}
|
||||
>
|
||||
<option value="硅基流动">硅基流动 (SiliconFlow)</option>
|
||||
<option value="Ali">阿里 (Ali)</option>
|
||||
<option value="Volcano">火山 (Volcano)</option>
|
||||
<option value="Minimax">Minimax</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/5"></div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音名称</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="例如: 客服小美" />
|
||||
</div>
|
||||
|
||||
{vendor === '硅基流动' ? (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">模型 (Model)</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={sfModel}
|
||||
onChange={e => setSfModel(e.target.value)}
|
||||
>
|
||||
<option value="fishaudio/fish-speech-1.5">fishaudio/fish-speech-1.5</option>
|
||||
<option value="fishaudio/fish-speech-1.4">fishaudio/fish-speech-1.4</option>
|
||||
<option value="ByteDance/SA-Speech">ByteDance/SA-Speech</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音 ID (Voice)</label>
|
||||
<Input value={sfVoiceId} onChange={e => setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">语速 (Speed)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="range" min="0.5" max="2" step="0.1" value={sfSpeed} onChange={e => setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfSpeed}x</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">增益 (Gain)</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="range" min="-10" max="10" step="1" value={sfGain} onChange={e => setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" />
|
||||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfGain}dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">模型标识</label>
|
||||
<Input value={model} onChange={e => setModel(e.target.value)} placeholder="API Model Key" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">发音人标识</label>
|
||||
<Input value={voiceKey} onChange={e => setVoiceKey(e.target.value)} placeholder="Voice Key" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">性别</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={gender}
|
||||
onChange={e => setGender(e.target.value)}
|
||||
>
|
||||
<option value="Female">女 (Female)</option>
|
||||
<option value="Male">男 (Male)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">语言</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||||
value={language}
|
||||
onChange={e => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">备注</label>
|
||||
<textarea
|
||||
className="flex min-h-[60px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="记录该声音的特点..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-primary/20 bg-primary/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[10px] font-black text-primary flex items-center tracking-widest uppercase">
|
||||
<Volume2 className="w-3.5 h-3.5 mr-1.5" /> 参数试听 (Preview)
|
||||
</h4>
|
||||
{vendor === '硅基流动' && <Badge variant="outline" className="text-[8px] border-primary/20 text-primary/70">SiliconFlow Audio API</Badge>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={testInput}
|
||||
onChange={e => setTestInput(e.target.value)}
|
||||
placeholder="输入测试文本..."
|
||||
className="text-xs bg-black/20"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAudition}
|
||||
disabled={isAuditioning}
|
||||
className="shrink-0 h-9"
|
||||
>
|
||||
{isAuditioning ? <Pause className="h-3.5 w-3.5 animate-pulse" /> : <Play className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CloneVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => void
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name || !file) {
|
||||
alert("请填写名称并上传音频文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const newVoice: Voice = {
|
||||
id: `v-${Date.now()}`,
|
||||
name: name,
|
||||
vendor: 'Volcano',
|
||||
gender: 'Female',
|
||||
language: 'zh',
|
||||
description: description || 'User cloned voice'
|
||||
};
|
||||
|
||||
onSuccess(newVoice);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="克隆声音"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit}>开始克隆</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">语音名称</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="给新声音起个名字"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">上传音频文件 (参考音频)</label>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="flex items-center space-x-2 text-primary">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{file.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">点击上传 WAV/MP3 文件</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">语音描述</label>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="描述声音特点(如:年轻、沉稳...)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
402
web/pages/WorkflowEditor.tsx
Normal file
402
web/pages/WorkflowEditor.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Save, Rocket, Plus, Bot, UserCheck, Wrench, Ban, Zap, X, Copy, MousePointer2 } from 'lucide-react';
|
||||
import { Button, Input, Badge } from '../components/UI';
|
||||
import { mockAssistants, mockKnowledgeBases, mockWorkflows } from '../services/mockData';
|
||||
import { WorkflowNode, WorkflowEdge, Workflow } from '../types';
|
||||
import { DebugDrawer } from './Assistants';
|
||||
|
||||
export const WorkflowEditorPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Template data for new workflows
|
||||
const templateName = searchParams.get('name');
|
||||
const templateType = searchParams.get('template');
|
||||
|
||||
// Find initial workflow or create a new one
|
||||
const existingWf = mockWorkflows.find(w => w.id === id);
|
||||
const [name, setName] = useState(templateName || existingWf?.name || '新工作流');
|
||||
const [nodes, setNodes] = useState<WorkflowNode[]>(() => {
|
||||
if (existingWf) return existingWf.nodes;
|
||||
if (templateType === 'lead') {
|
||||
return [
|
||||
{
|
||||
name: 'introduction',
|
||||
type: 'conversation',
|
||||
isStart: true,
|
||||
metadata: { position: { x: 100, y: 100 } },
|
||||
prompt: "You are Morgan from GrowthPartners. Start with: 'Hello, this is Morgan from GrowthPartners. We help businesses improve their operational efficiency through custom software solutions. Do you have a few minutes to chat about how we might be able to help your business?'",
|
||||
messagePlan: { firstMessage: "Hello, this is Morgan from GrowthPartners. Do you have a few minutes to chat?" }
|
||||
},
|
||||
{
|
||||
name: 'need_discovery',
|
||||
type: 'conversation',
|
||||
metadata: { position: { x: 450, y: 250 } },
|
||||
prompt: "Conduct need discovery by asking about business challenges...",
|
||||
variableExtractionPlan: {
|
||||
output: [
|
||||
{ title: 'industry', type: 'string', description: 'user industry' },
|
||||
{ title: 'pain_points', type: 'string', description: 'main challenges' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'hangup_node',
|
||||
type: 'end',
|
||||
metadata: { position: { x: 450, y: 550 } },
|
||||
tool: {
|
||||
type: 'endCall',
|
||||
function: { name: 'hangup', parameters: {} },
|
||||
messages: [{ type: 'request-start', content: 'Thank you for your time!', blocking: true }]
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'start_node',
|
||||
type: 'conversation',
|
||||
isStart: true,
|
||||
metadata: { position: { x: 200, y: 200 } },
|
||||
prompt: '欢迎对话系统...',
|
||||
messagePlan: { firstMessage: '你好!' }
|
||||
}
|
||||
];
|
||||
});
|
||||
const [edges, setEdges] = useState<WorkflowEdge[]>(existingWf?.edges || []);
|
||||
|
||||
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
|
||||
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
|
||||
const [isDebugOpen, setIsDebugOpen] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const [draggingNodeName, setDraggingNodeName] = useState<string | null>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const panStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const selectedNode = nodes.find(n => n.name === selectedNodeName);
|
||||
|
||||
// Scroll Zoom handler
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoomSpeed = 0.001;
|
||||
const newZoom = Math.min(Math.max(zoom - e.deltaY * zoomSpeed, 0.2), 3);
|
||||
setZoom(newZoom);
|
||||
};
|
||||
|
||||
// Middle mouse click handler for panning
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Button 1 is middle mouse
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
setIsPanning(true);
|
||||
panStart.current = { x: e.clientX - panOffset.x, y: e.clientY - panOffset.y };
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeName: string) => {
|
||||
if (e.button !== 0) return; // Only left click for nodes
|
||||
e.stopPropagation();
|
||||
setSelectedNodeName(nodeName);
|
||||
setDraggingNodeName(nodeName);
|
||||
const node = nodes.find(n => n.name === nodeName);
|
||||
if (node) {
|
||||
dragOffset.current = {
|
||||
x: e.clientX - node.metadata.position.x * zoom,
|
||||
y: e.clientY - node.metadata.position.y * zoom
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (draggingNodeName) {
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.name === draggingNodeName
|
||||
? { ...n, metadata: { ...n.metadata, position: { x: (e.clientX - dragOffset.current.x) / zoom, y: (e.clientY - dragOffset.current.y) / zoom } } }
|
||||
: n
|
||||
));
|
||||
} else if (isPanning) {
|
||||
setPanOffset({
|
||||
x: e.clientX - panStart.current.x,
|
||||
y: e.clientY - panStart.current.y
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
setDraggingNodeName(null);
|
||||
setIsPanning(false);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [draggingNodeName, isPanning, zoom]);
|
||||
|
||||
const addNode = (type: WorkflowNode['type']) => {
|
||||
const newNode: WorkflowNode = {
|
||||
name: `${type}_${Date.now()}`,
|
||||
type,
|
||||
metadata: { position: { x: (300 - panOffset.x) / zoom, y: (300 - panOffset.y) / zoom } },
|
||||
prompt: type === 'conversation' ? '输入该节点的 Prompt...' : '',
|
||||
tool: type === 'end' ? { type: 'endCall', function: { name: 'hangup', parameters: {} } } : undefined
|
||||
};
|
||||
setNodes([...nodes, newNode]);
|
||||
setIsAddMenuOpen(false);
|
||||
};
|
||||
|
||||
const updateNodeData = (field: string, value: any) => {
|
||||
if (!selectedNodeName) return;
|
||||
setNodes(prev => prev.map(n => n.name === selectedNodeName ? { ...n, [field]: value } : n));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
||||
const updatedWorkflow: Workflow = {
|
||||
id: id || `wf_${Date.now()}`,
|
||||
name,
|
||||
nodeCount: nodes.length,
|
||||
createdAt: existingWf?.createdAt || now,
|
||||
updatedAt: now,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
const idx = mockWorkflows.findIndex(w => w.id === id);
|
||||
if (idx !== -1) mockWorkflows[idx] = updatedWorkflow;
|
||||
} else {
|
||||
mockWorkflows.push(updatedWorkflow);
|
||||
}
|
||||
alert('保存成功!工作流已同步至列表。');
|
||||
navigate('/workflows');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-6rem)] relative bg-card/10 border border-white/5 rounded-2xl overflow-hidden animate-in fade-in duration-300">
|
||||
{/* Editor Header */}
|
||||
<header className="h-14 border-b border-white/5 bg-white/[0.02] flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/workflows')} className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-white/10"></div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="bg-transparent border-transparent focus:bg-white/5 font-bold text-sm h-8 w-48 shadow-none text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsDebugOpen(true)}>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" /> 调试
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleSave}>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" /> 保存
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => alert('发布成功!')}>
|
||||
<Rocket className="mr-1.5 h-3.5 w-3.5" /> 发布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div
|
||||
className="flex-1 relative bg-[radial-gradient(rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:24px_24px] overflow-hidden select-none cursor-crosshair"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* Floating Controls */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-2">
|
||||
<Button
|
||||
className="rounded-xl shadow-lg h-10 px-5 bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
|
||||
onClick={() => setIsAddMenuOpen(!isAddMenuOpen)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> {isAddMenuOpen ? '取消' : '添加节点'}
|
||||
</Button>
|
||||
{isAddMenuOpen && (
|
||||
<div className="bg-background/90 backdrop-blur-xl border border-white/10 rounded-xl p-1.5 w-48 shadow-2xl animate-in slide-in-from-top-2">
|
||||
<button onClick={() => addNode('conversation')} className="flex items-center w-full p-2.5 hover:bg-primary/10 rounded-lg transition-colors text-sm text-left text-white">
|
||||
<Bot className="w-4 h-4 mr-3 text-primary" /> <span>对话节点</span>
|
||||
</button>
|
||||
<button onClick={() => addNode('tool')} className="flex items-center w-full p-2.5 hover:bg-purple-400/10 rounded-lg transition-colors text-sm text-left text-white">
|
||||
<Wrench className="w-4 h-4 mr-3 text-purple-400" /> <span>工具节点</span>
|
||||
</button>
|
||||
<button onClick={() => addNode('human')} className="flex items-center w-full p-2.5 hover:bg-orange-400/10 rounded-lg transition-colors text-sm text-left text-white">
|
||||
<UserCheck className="w-4 h-4 mr-3 text-orange-400" /> <span>转人工</span>
|
||||
</button>
|
||||
<button onClick={() => addNode('end')} className="flex items-center w-full p-2.5 hover:bg-destructive/10 rounded-lg transition-colors text-sm text-left text-destructive">
|
||||
<Ban className="w-4 h-4 mr-3" /> <span>结束对话</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-[10px] text-muted-foreground font-mono">
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>POS: {Math.round(panOffset.x)}, {Math.round(panOffset.y)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scalable Container */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0'
|
||||
}}
|
||||
>
|
||||
{nodes.map(node => (
|
||||
<div
|
||||
key={node.name}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.name)}
|
||||
style={{ left: node.metadata.position.x, top: node.metadata.position.y }}
|
||||
className={`absolute w-56 p-4 rounded-xl border bg-card/70 backdrop-blur-sm cursor-grab active:cursor-grabbing group transition-shadow ${selectedNodeName === node.name ? 'border-primary shadow-[0_0_30px_rgba(6,182,212,0.3)]' : 'border-white/10 hover:border-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<NodeIcon type={node.type} />
|
||||
<span className="font-semibold text-[10px] truncate uppercase tracking-tight text-white">{node.name}</span>
|
||||
</div>
|
||||
{node.isStart && <Badge variant="default" className="scale-75 origin-right">START</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground line-clamp-3 italic opacity-60 leading-relaxed">
|
||||
{node.prompt || (node.tool ? `Action: ${node.tool.type}` : '待配置逻辑...')}
|
||||
</div>
|
||||
|
||||
{/* Port simulation */}
|
||||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity shadow-[0_0_5px_rgba(6,182,212,0.5)]"></div>
|
||||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-muted rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Right: Minimap */}
|
||||
<div className="absolute bottom-6 right-6 w-48 h-32 bg-black/60 border border-white/10 rounded-2xl shadow-2xl pointer-events-none overflow-hidden flex flex-col backdrop-blur-md">
|
||||
<div className="p-2 border-b border-white/5 bg-white/5 text-[9px] text-muted-foreground font-bold tracking-[0.2em] text-center uppercase">Minimap</div>
|
||||
<div className="flex-1 relative p-3 opacity-60">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative w-full h-full">
|
||||
{nodes.map((n, i) => {
|
||||
// Simple scaling for minimap
|
||||
const mx = (n.metadata.position.x / 10) % 100;
|
||||
const my = (n.metadata.position.y / 10) % 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-3 h-2 rounded-sm ${n.type === 'conversation' ? 'bg-primary' : n.type === 'end' ? 'bg-destructive' : 'bg-white/40'}`}
|
||||
style={{ left: `${20 + mx}%`, top: `${20 + my}%` }}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Viewport visualization */}
|
||||
<div
|
||||
className="absolute border border-primary/40 bg-primary/5 rounded shadow-sm"
|
||||
style={{ width: '40%', height: '40%', left: '30%', top: '30%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigator Guide */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] text-muted-foreground/30 font-mono flex items-center gap-2">
|
||||
<MousePointer2 className="w-3 h-3" /> 鼠标中键拖拽画布 | 滚轮缩放
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Settings Panel */}
|
||||
{selectedNode && (
|
||||
<div className="absolute right-0 top-14 bottom-0 w-80 border-l border-white/10 bg-background/95 backdrop-blur-2xl flex flex-col animate-in slide-in-from-right duration-300 z-20 shadow-[-10px_0_30px_rgba(0,0,0,0.5)]">
|
||||
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">节点设置</h3>
|
||||
<Button variant="ghost" size="icon" onClick={() => setSelectedNodeName(null)} className="h-8 w-8">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">唯一标识 (Node Name)</label>
|
||||
<Input
|
||||
value={selectedNode.name}
|
||||
onChange={e => updateNodeData('name', e.target.value)}
|
||||
className="h-8 text-xs font-mono text-white bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">节点类型</label>
|
||||
<Badge variant="outline" className="w-fit">{selectedNode.type.toUpperCase()}</Badge>
|
||||
</div>
|
||||
|
||||
{selectedNode.type === 'conversation' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">Prompt (指令)</label>
|
||||
<textarea
|
||||
className="w-full h-48 bg-white/5 border border-white/10 rounded-lg p-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
|
||||
value={selectedNode.prompt || ''}
|
||||
onChange={e => updateNodeData('prompt', e.target.value)}
|
||||
placeholder="输入该节点的业务规则、语气要求和回复逻辑..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">First Message (开场白)</label>
|
||||
<Input
|
||||
value={selectedNode.messagePlan?.firstMessage || ''}
|
||||
onChange={e => updateNodeData('messagePlan', { ...selectedNode.messagePlan, firstMessage: e.target.value })}
|
||||
className="h-8 text-xs text-white"
|
||||
placeholder="进入该节点时 AI 主动发起的消息..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'end' && (
|
||||
<div className="p-4 rounded-xl border border-destructive/20 bg-destructive/5 text-xs text-destructive/80 space-y-2">
|
||||
<p className="font-bold">结束对话节点</p>
|
||||
<p>到达此节点后,系统将根据配置执行挂断操作。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNode.isStart}
|
||||
onChange={e => updateNodeData('isStart', e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-primary border-white/10 bg-white/5"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors uppercase font-mono tracking-widest">起始节点 (Start Node)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Side Drawer */}
|
||||
<DebugDrawer
|
||||
isOpen={isDebugOpen}
|
||||
onClose={() => setIsDebugOpen(false)}
|
||||
assistant={mockAssistants[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NodeIcon = ({ type }: { type: WorkflowNode['type'] }) => {
|
||||
switch (type) {
|
||||
case 'conversation': return <Bot className="h-4 w-4 text-primary" />;
|
||||
case 'human': return <UserCheck className="h-4 w-4 text-orange-400" />;
|
||||
case 'tool': return <Wrench className="h-4 w-4 text-purple-400" />;
|
||||
case 'end': return <Ban className="h-4 w-4 text-destructive" />;
|
||||
default: return <MousePointer2 className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
232
web/pages/Workflows.tsx
Normal file
232
web/pages/Workflows.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Search, Plus, Upload, MoreHorizontal, Code, Edit2, Copy, Trash2, Calendar, CloudUpload, File as FileIcon, X, Layout, FilePlus } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Card } from '../components/UI';
|
||||
import { mockWorkflows } from '../services/mockData';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const WorkflowsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState(mockWorkflows);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
|
||||
const [newWfName, setNewWfName] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<'blank' | 'lead'>('blank');
|
||||
|
||||
const filteredWorkflows = workflows.filter(wf =>
|
||||
wf.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
if (!newWfName.trim()) {
|
||||
alert('请输入工作流名称');
|
||||
return;
|
||||
}
|
||||
setIsCreateOpen(false);
|
||||
navigate(`/workflows/new?name=${encodeURIComponent(newWfName)}&template=${selectedTemplate}`);
|
||||
};
|
||||
|
||||
const handleDeleteWorkflow = (id: string) => {
|
||||
if (confirm('确定要删除这个工作流吗?')) {
|
||||
setWorkflows(prev => prev.filter(w => w.id !== id));
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">工作流</h1>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" onClick={() => setIsUploadOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" /> 上传 JSON 代码
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> 创建工作流
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索工作流..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-white/5 rounded-md px-3 border border-white/10 group focus-within:border-primary/50 transition-colors">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<select className="bg-transparent text-sm h-9 focus:outline-none border-none text-foreground cursor-pointer [&>option]:bg-background text-white">
|
||||
<option value="all">所有时间</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">近一周</option>
|
||||
<option value="month">近一月</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>节点数量</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredWorkflows.map(wf => (
|
||||
<TableRow key={wf.id} className="group">
|
||||
<TableCell className="font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/workflows/edit/${wf.id}`)}
|
||||
className="hover:text-primary transition-colors cursor-pointer text-left font-semibold text-white"
|
||||
>
|
||||
{wf.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{wf.nodeCount} 个节点</TableCell>
|
||||
<TableCell className="text-muted-foreground">{wf.createdAt}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{wf.updatedAt}</TableCell>
|
||||
<TableCell className="text-right relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setActiveMenu(activeMenu === wf.id ? null : wf.id)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{activeMenu === wf.id && (
|
||||
<div className="absolute right-0 top-12 z-50 w-48 bg-background border border-white/10 rounded-lg shadow-xl py-1 animate-in zoom-in-95">
|
||||
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => { alert('JSON copied!'); setActiveMenu(null); }}>
|
||||
<Code className="w-3.5 h-3.5 mr-2 opacity-70" /> 复制 JSON 代码
|
||||
</button>
|
||||
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => navigate(`/workflows/edit/${wf.id}`)}>
|
||||
<Edit2 className="w-3.5 h-3.5 mr-2 opacity-70" /> 编辑工作流
|
||||
</button>
|
||||
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => setActiveMenu(null)}>
|
||||
<Copy className="w-3.5 h-3.5 mr-2 opacity-70" /> 复制
|
||||
</button>
|
||||
<div className="h-px bg-white/10 my-1" />
|
||||
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-destructive" onClick={() => handleDeleteWorkflow(wf.id)}>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2 opacity-70" /> 删除工作流
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredWorkflows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">暂无工作流数据</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<UploadJsonModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
|
||||
|
||||
<Dialog
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
title="创建新工作流"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setIsCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateWorkflow}>创建</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">工作流名称</label>
|
||||
<Input
|
||||
value={newWfName}
|
||||
onChange={e => setNewWfName(e.target.value)}
|
||||
placeholder="例如: Lead Qualification Agent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">选择模板</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
onClick={() => setSelectedTemplate('blank')}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center text-center space-y-2 ${selectedTemplate === 'blank' ? 'border-primary bg-primary/10' : 'border-white/5 bg-white/5 hover:bg-white/10'}`}
|
||||
>
|
||||
<FilePlus className={`w-8 h-8 ${selectedTemplate === 'blank' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">空白模板</div>
|
||||
<div className="text-[10px] text-muted-foreground">从零开始构建</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setSelectedTemplate('lead')}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center text-center space-y-2 ${selectedTemplate === 'lead' ? 'border-primary bg-primary/10' : 'border-white/5 bg-white/5 hover:bg-white/10'}`}
|
||||
>
|
||||
<Layout className={`w-8 h-8 ${selectedTemplate === 'lead' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">销售获客</div>
|
||||
<div className="text-[10px] text-muted-foreground">标准 Lead 转化逻辑</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadJsonModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(e.type === "dragenter" || e.type === "dragover");
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files?.[0]) setFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen} onClose={onClose} title="上传工作流 JSON"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={() => { alert('Import Success!'); onClose(); }}>确定导入</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-all cursor-pointer ${dragActive ? "border-primary bg-primary/10" : "border-white/10 bg-white/5 hover:bg-white/10"}`}
|
||||
onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input ref={inputRef} type="file" className="hidden" accept=".json" onChange={e => e.target.files?.[0] && setFile(e.target.files[0])} />
|
||||
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{file ? <span className="text-primary font-medium">{file.name}</span> : <span className="text-white/70"><span className="font-semibold text-primary">点击上传</span> 或将 JSON 文件拖拽到此处</span>}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 text-white/40">仅支持 .json 格式的工作流配置文件</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user