Initial commit

This commit is contained in:
Xin Wang
2026-02-02 00:29:23 +08:00
commit ae391a8aa7
19 changed files with 5081 additions and 0 deletions

703
pages/Assistants.tsx Normal file
View File

@@ -0,0 +1,703 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle } from 'lucide-react';
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
import { Assistant, TabValue } from '../types';
import { GoogleGenAI } from "@google/genai";
export const AssistantsPage: React.FC = () => {
const [assistants, setAssistants] = useState<Assistant[]>(mockAssistants);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
const [debugOpen, setDebugOpen] = useState(false);
const [hotwordInput, setHotwordInput] = useState('');
// State for delete confirmation dialog
const [deleteId, setDeleteId] = useState<string | null>(null);
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
const filteredAssistants = assistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleCreate = () => {
const newId = Date.now().toString();
const newAssistant: Assistant = {
id: newId,
name: 'New Assistant',
callCount: 0,
opener: '',
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voice: 'default',
speed: 1,
hotwords: []
};
setAssistants([...assistants, newAssistant]);
setSelectedId(newId);
};
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
e.stopPropagation();
const newAssistant = { ...assistant, id: Date.now().toString(), name: `${assistant.name} (Copy)` };
setAssistants([...assistants, newAssistant]);
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = () => {
if (deleteId) {
setAssistants(prev => prev.filter(a => a.id !== deleteId));
if (selectedId === deleteId) setSelectedId(null);
setDeleteId(null);
}
};
const updateAssistant = (field: keyof Assistant, value: any) => {
if (!selectedId) return;
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
};
const addHotword = () => {
if (hotwordInput.trim() && selectedAssistant) {
updateAssistant('hotwords', [...selectedAssistant.hotwords, hotwordInput.trim()]);
setHotwordInput('');
}
};
const removeHotword = (word: string) => {
if (selectedAssistant) {
updateAssistant('hotwords', selectedAssistant.hotwords.filter(w => w !== word));
}
};
return (
<div className="flex h-[calc(100vh-6rem)] gap-6 animate-in fade-in">
{/* LEFT COLUMN: List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<div className="flex items-center justify-between px-1">
<h2 className="text-xl font-bold tracking-tight"></h2>
</div>
<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">
{filteredAssistants.map(assistant => (
<div
key={assistant.id}
onClick={() => setSelectedId(assistant.id)}
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
selectedId === assistant.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 justify-between items-start mb-2">
<span className={`font-semibold truncate pr-6 ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
{assistant.name}
</span>
</div>
<div className="flex items-center text-xs text-muted-foreground">
<Phone className="h-3 w-3 mr-1.5 opacity-70" />
<span>{assistant.callCount} </span>
</div>
{/* Hover Actions */}
<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">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, assistant)} 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, assistant.id)} title="删除">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
{filteredAssistants.length === 0 && (
<div className="text-center py-10 text-muted-foreground text-sm">
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: 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">
{selectedAssistant ? (
<>
{/* Header Area */}
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-4">
{/* Row 1: Name and Actions - Aligned with items-end */}
<div className="flex items-end justify-between gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground font-mono mb-2 block ml-1">ASSISTANT NAME</label>
<Input
value={selectedAssistant.name}
onChange={(e) => updateAssistant('name', e.target.value)}
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
/>
</div>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
onClick={() => setDebugOpen(true)}
className="border border-primary/20 hover:border-primary/50 text-primary hover:text-primary hover:bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)]"
>
<Play className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={() => alert("发布成功!")}
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
>
<Rocket className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* Row 2: Tabs */}
<div className="flex bg-white/5 p-1 rounded-lg w-fit">
<button
onClick={() => setActiveTab(TabValue.GLOBAL)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.GLOBAL ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.VOICE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</div>
</div>
{/* Content Scroll Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
{activeTab === TabValue.GLOBAL ? (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex items-center">
<MessageSquare className="w-4 h-4 mr-2 text-primary"/> (Opener)
</label>
<Input
value={selectedAssistant.opener}
onChange={(e) => updateAssistant('opener', e.target.value)}
placeholder="例如您好我是您的专属AI助手..."
className="bg-white/5 border-white/10 focus:border-primary/50"
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex items-center">
<BotIcon className="w-4 h-4 mr-2 text-primary"/> (Prompt)
</label>
<textarea
className="flex min-h-[200px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y"
value={selectedAssistant.prompt}
onChange={(e) => updateAssistant('prompt', e.target.value)}
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground"></label>
<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 [&>option]:bg-card text-foreground"
value={selectedAssistant.knowledgeBaseId}
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
>
<option value="">使</option>
{mockKnowledgeBases.map(kb => (
<option key={kb.id} value={kb.id}>{kb.name}</option>
))}
</select>
</div>
</div>
) : (
<div className="space-y-8">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground"> (Language)</label>
<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 [&>option]:bg-card text-foreground"
value={selectedAssistant.language}
onChange={(e) => updateAssistant('language', e.target.value)}
>
<option value="zh"> (Chinese)</option>
<option value="en"> (English)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground"> (Voice)</label>
<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 [&>option]:bg-card text-foreground"
value={selectedAssistant.voice}
onChange={(e) => updateAssistant('voice', e.target.value)}
>
<option value="default"> (Default)</option>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="onyx">Onyx</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
</div>
<div className="space-y-4 p-4 rounded-xl border border-white/5 bg-white/[0.02]">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-foreground"> (Speed)</label>
<span className="text-sm font-mono text-primary bg-primary/10 px-2 py-0.5 rounded">{selectedAssistant.speed}x</span>
</div>
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={selectedAssistant.speed}
onChange={(e) => updateAssistant('speed', parseFloat(e.target.value))}
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0.5x (Slow)</span>
<span>1.0x (Normal)</span>
<span>2.0x (Fast)</span>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-foreground flex items-center">
<Mic className="w-4 h-4 mr-2 text-primary"/> ASR (Hotwords)
</label>
<div className="flex space-x-2">
<Input
value={hotwordInput}
onChange={(e) => setHotwordInput(e.target.value)}
placeholder="输入专有名词或高频词汇..."
onKeyDown={(e) => e.key === 'Enter' && addHotword()}
className="bg-white/5 border-white/10"
/>
<Button variant="secondary" onClick={addHotword}></Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-lg border border-dashed border-white/10">
{selectedAssistant.hotwords.length === 0 && (
<span className="text-xs text-muted-foreground py-1"></span>
)}
{selectedAssistant.hotwords.map((word, idx) => (
<Badge key={idx} variant="outline">
{word}
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors">×</button>
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground"></p>
</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">
<BotIcon className="h-8 w-8 opacity-50" />
</div>
<p className="text-lg font-medium"></p>
<p className="text-sm opacity-60"></p>
</div>
)}
</div>
{selectedAssistant && (
<DebugDrawer
isOpen={debugOpen}
onClose={() => setDebugOpen(false)}
assistant={selectedAssistant}
/>
)}
{/* Delete Confirmation Dialog */}
<Dialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
title="确认删除"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteId(null)}></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-foreground">
</p>
{deleteId && (
<p className="text-xs text-muted-foreground mt-1">
: {assistants.find(a => a.id === deleteId)?.name}
</p>
)}
</div>
</div>
</Dialog>
</div>
);
};
// Icon helper
const BotIcon = ({className}: {className?: string}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
// --- Debug Drawer Component ---
const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
const [messages, setMessages] = useState<{role: 'user' | 'model', text: string}[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Media State
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('');
const [selectedMic, setSelectedMic] = useState<string>('');
const [isSwapped, setIsSwapped] = useState(false); // False: AI is Big, Local is Small. True: Local is Big, AI is Small.
// Initialize with opener
useEffect(() => {
if (isOpen) {
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
} else {
// Reset and stop media when closed
setMode('text');
stopMedia();
setIsSwapped(false);
}
}, [isOpen, assistant]);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, mode]);
// Fetch Devices
useEffect(() => {
if (isOpen && mode === 'video') {
const getDevices = async () => {
try {
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
const dev = await navigator.mediaDevices.enumerateDevices();
setDevices(dev);
const cams = dev.filter(d => d.kind === 'videoinput');
const mics = dev.filter(d => d.kind === 'audioinput');
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
} catch (e) {
console.error("Error enumerating devices", e);
}
};
getDevices();
}
}, [isOpen, mode]);
// Handle Video/Media stream
const stopMedia = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
useEffect(() => {
const handleStream = async () => {
if (isOpen && mode === 'video') {
try {
stopMedia();
const constraints = {
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
audio: selectedMic ? { deviceId: { exact: selectedMic } } : true
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("Failed to access camera/mic:", err);
}
} else {
stopMedia();
}
};
handleStream();
return () => stopMedia();
}, [mode, isOpen, selectedCamera, selectedMic]);
const handleSend = async () => {
if (!inputText.trim()) return;
const userMsg = inputText;
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
setInputText('');
setIsLoading(true);
try {
if (process.env.API_KEY) {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const chat = ai.chats.create({
model: "gemini-3-flash-preview",
config: { systemInstruction: assistant.prompt },
history: messages.map(m => ({ role: m.role, parts: [{ text: m.text }] }))
});
const result = await chat.sendMessage({ message: userMsg });
setMessages(prev => [...prev, { role: 'model', text: result.text || '' }]);
} else {
setTimeout(() => {
setMessages(prev => [...prev, { role: 'model', text: `[Mock Response]: Received "${userMsg}"` }]);
setIsLoading(false);
}, 1000);
}
} catch (e) {
console.error(e);
setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]);
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
stopMedia();
onClose();
};
// Reusable Messages List Component
const TranscriptionLog = () => (
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4"></div>}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-card border border-white/10 shadow-sm text-foreground'}`}>
<span className="text-[10px] opacity-70 block mb-0.5 uppercase tracking-wider">{m.role === 'user' ? 'Me' : 'AI'}</span>
{m.text}
</div>
</div>
))}
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
</div>
);
// Helper to render the Local Video Element
const renderLocalVideo = (isSmall: boolean) => (
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover transform scale-x-[-1]"
/>
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">
Me
</div>
</div>
);
// Helper to render the "Remote" AI Video Element (Simulated)
const renderRemoteVideo = (isSmall: boolean) => (
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
<div className="relative flex items-center justify-center">
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
<div className={`absolute rounded-full bg-primary/40 animate-ping ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}></div>
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
</div>
</div>
{!isSmall && (
<div className="mt-4 font-mono text-primary animate-pulse text-sm">
{assistant.name}
</div>
)}
</div>
);
return (
<Drawer isOpen={isOpen} onClose={handleClose} title={`调试: ${assistant.name}`}>
<div className="flex flex-col h-full">
{/* Mode Toggle */}
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
<button
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'text' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
onClick={() => setMode('text')}
>
<MessageSquare className="inline w-4 h-4 mr-1"/>
</button>
<button
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'voice' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
onClick={() => setMode('voice')}
>
<Mic className="inline w-4 h-4 mr-1"/>
</button>
<button
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'video' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
onClick={() => setMode('video')}
>
<Video className="inline w-4 h-4 mr-1"/>
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4 gap-2">
{mode === 'text' && <TranscriptionLog />}
{mode === 'voice' && (
<div className="flex flex-col h-full">
{/* Visualizer Area */}
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20 pointer-events-none"></div>
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
<Mic className="h-10 w-10 text-primary" />
</div>
<p className="text-sm relative z-10">...</p>
</div>
{/* Transcript */}
<h4 className="text-xs font-medium text-muted-foreground px-1"> / Live Transcription</h4>
<TranscriptionLog />
</div>
)}
{mode === 'video' && (
<div className="flex flex-col h-full space-y-2">
{/* Video Area (Top) */}
<div className="h-3/5 shrink-0 flex flex-col gap-2">
{/* Device Settings Bar */}
<div className="flex gap-2 shrink-0">
<div className="flex-1">
<select
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
value={selectedCamera}
onChange={(e) => setSelectedCamera(e.target.value)}
>
{devices.filter(d => d.kind === 'videoinput').map(d => (
<option key={d.deviceId} value={d.deviceId}>{d.label || `Camera...`}</option>
))}
</select>
</div>
<div className="flex-1">
<select
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
value={selectedMic}
onChange={(e) => setSelectedMic(e.target.value)}
>
{devices.filter(d => d.kind === 'audioinput').map(d => (
<option key={d.deviceId} value={d.deviceId}>{d.label || `Mic...`}</option>
))}
</select>
</div>
</div>
{/* Video Container (PiP) */}
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
{/* Main Window */}
<div className="absolute inset-0">
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
</div>
{/* Small Window */}
<div className="absolute bottom-2 right-2 w-24 h-36 z-10 transition-all duration-300">
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
</div>
{/* Swap Button */}
<div className="absolute top-2 right-2 z-20">
<Button
variant="secondary"
size="icon"
className="rounded-full h-7 w-7 bg-black/50 hover:bg-primary/80 backdrop-blur text-white border border-white/20"
onClick={() => setIsSwapped(!isSwapped)}
title="切换窗口"
>
<ArrowLeftRight className="h-3 w-3" />
</Button>
</div>
</div>
</div>
{/* Transcript Area (Bottom) */}
<div className="flex-1 flex flex-col min-h-0">
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1"> / Live Transcription</h4>
<TranscriptionLog />
</div>
</div>
)}
</div>
{/* Footer Actions - Unified Input */}
<div className="shrink-0 space-y-2">
{(mode === 'voice' || mode === 'video') && (
<div className="flex justify-end">
<Button variant="destructive" size="sm" className="w-full shadow-red-500/20 shadow-lg" onClick={handleClose}>
<PhoneOff className="mr-2 h-3 w-3" />
</Button>
</div>
)}
<div className="flex space-x-2">
<Input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={mode === 'text' ? "输入消息..." : "输入文字模拟语音/Input to simulate speech..."}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
disabled={isLoading}
className="flex-1"
/>
<Button size="icon" onClick={handleSend} disabled={isLoading}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Drawer>
);
};

132
pages/CallLogs.tsx Normal file
View File

@@ -0,0 +1,132 @@
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(','), ...rows].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">
<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>
);
};

187
pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,187 @@
import React, { useState, useMemo } from 'react';
import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter } 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="space-y-6 animate-in fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-foreground"></h1>
{/* Filters */}
<div className="flex flex-col sm:flex-row items-center gap-3 bg-card/40 backdrop-blur-md p-2 rounded-lg border border-border/50">
<div className="flex items-center px-2">
<Filter className="h-4 w-4 text-primary mr-2" />
<select
className="bg-transparent text-sm font-medium focus:outline-none text-foreground [&>option]:bg-background"
value={selectedAssistantId}
onChange={(e) => setSelectedAssistantId(e.target.value)}
>
<option value="all"></option>
{mockAssistants.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="h-4 w-px bg-border/50 hidden sm:block"></div>
<div className="flex bg-muted/50 rounded-md p-1">
{(['week', 'month', 'year'] as const).map((r) => (
<button
key={r}
onClick={() => setTimeRange(r)}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-all ${timeRange === r ? 'bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.3)]' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
{r === 'week' ? '近一周' : r === 'month' ? '近一个月' : '近一年'}
</button>
))}
</div>
</div>
</div>
{/* Metrics Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="通话数量"
value={stats.totalCalls.toString()}
icon={<Phone className="h-4 w-4 text-primary" />}
trend="+12.5% 较上期"
/>
<StatCard
title="接通率"
value={`${stats.answerRate}%`}
icon={<CheckCircle className="h-4 w-4 text-green-400" />}
trend="+2.1% 较上期"
/>
<StatCard
title="平均通话时长"
value={stats.avgDuration}
icon={<Clock className="h-4 w-4 text-blue-400" />}
trend="-0.5% 较上期"
/>
<StatCard
title="转人工数量"
value={stats.humanTransferCount.toString()}
icon={<UserCheck className="h-4 w-4 text-purple-400" />}
trend="+5% 较上期"
/>
</div>
{/* Charts Section */}
<div className="grid gap-4 md:grid-cols-1">
<Card className="p-6 border-primary/20 bg-card/30">
<div className="flex items-center justify-between mb-6">
<div className="space-y-1">
<h3 className="text-lg font-medium leading-none flex items-center">
<Activity className="h-5 w-5 text-primary mr-2" />
</h3>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<div className="h-[300px] w-full">
<SimpleAreaChart data={stats.trend} />
</div>
</Card>
</div>
</div>
);
};
// --- Sub Components ---
const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode; trend?: string }> = ({ title, value, icon, trend }) => (
<Card className="p-6 border-border/40 hover:border-primary/50 transition-colors">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
{icon}
</div>
<div className="content-end">
<div className="text-2xl font-bold tracking-tight text-foreground">{value}</div>
{trend && <p className="text-xs text-muted-foreground mt-1">{trend}</p>}
</div>
</Card>
);
const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> = ({ data }) => {
if (!data || data.length === 0) return null;
const height = 250;
const width = 1000;
const padding = 20;
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">
{/* Tech Grid Lines */}
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="hsl(var(--border))" strokeWidth="1" />
<line x1={padding} y1={padding} x2={width - padding} y2={padding} stroke="hsl(var(--border))" strokeWidth="1" strokeDasharray="4 4" opacity="0.3" />
{/* 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>
{/* Glow Filter */}
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Fill Area */}
<polygon points={fillPath} fill="url(#chartGradient)" />
{/* Main Line with Glow */}
<polyline
points={points}
fill="none"
stroke="hsl(var(--primary))"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#glow)"
className="drop-shadow-sm"
/>
{/* Data Points */}
{data.length < 20 && 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 (
<circle key={i} cx={x} cy={y} r="4" fill="hsl(var(--background))" stroke="hsl(var(--primary))" strokeWidth="2" />
);
})}
</svg>
{/* X-Axis Labels */}
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-[2%] text-xs text-muted-foreground pointer-events-none font-mono">
{data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map((d, i) => (
<span key={i}>{d.label}</span>
))}
</div>
</div>
);
};

248
pages/KnowledgeBase.tsx Normal file
View File

@@ -0,0 +1,248 @@
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 filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
const handleSelect = (kb: KnowledgeBase) => {
setSelectedKb(kb);
setView('detail');
};
const handleImportClick = () => {
setIsUploadOpen(true);
};
if (view === 'detail' && selectedKb) {
return (
<>
<KnowledgeBaseDetail
kb={selectedKb}
onBack={() => setView('list')}
onImport={handleImportClick}
/>
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
</>
);
}
return (
<div className="space-y-6 animate-in fade-in">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
</div>
<div className="flex items-center space-x-2 bg-card/50 p-2 rounded-lg border border-white/5 shadow-sm w-full md:w-1/3">
<Search className="h-4 w-4 text-muted-foreground ml-2" />
<Input
placeholder="搜索知识库名称..."
className="border-0 shadow-none bg-transparent focus-visible:ring-0"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</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">{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 Placeholer */}
<div 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>
</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">{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"></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">
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name}
</TableCell>
<TableCell>{doc.size}</TableCell>
<TableCell>{doc.uploadDate}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80"></Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell className="text-center py-8 text-muted-foreground"></TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </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]) {
// Add new files to existing state
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"> 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]">{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">
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</Dialog>
);
};

56
pages/Profile.tsx Normal file
View 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>
);
};

260
pages/VoiceLibrary.tsx Normal file
View File

@@ -0,0 +1,260 @@
import React, { useState, useRef } from 'react';
import { Search, Mic2, Play, Pause, Upload, X, Filter } 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 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);
// Mock auto-stop after 3 seconds
setTimeout(() => {
setPlayingVoiceId((current) => current === id ? null : current);
}, 3000);
}
};
const handleCloneSuccess = (newVoice: Voice) => {
setVoices([newVoice, ...voices]);
setIsCloneModalOpen(false);
};
return (
<div className="space-y-6 animate-in fade-in">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<Button onClick={() => setIsCloneModalOpen(true)}>
<Mic2 className="mr-2 h-4 w-4" />
</Button>
</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"
value={vendorFilter}
onChange={(e) => setVendorFilter(e.target.value as any)}
>
<option value="all"></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"
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"
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">
<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>{voice.name}</span>
{voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{voice.vendor}</Badge>
</TableCell>
<TableCell>{voice.gender === 'Male' ? '男' : '女'}</TableCell>
<TableCell>{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 className="text-center py-6 text-muted-foreground"></TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
<CloneVoiceModal
isOpen={isCloneModalOpen}
onClose={() => setIsCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
</div>
);
};
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;
}
// Mock creation
const newVoice: Voice = {
id: `v-${Date.now()}`,
name: name,
vendor: 'Volcano', // Default for cloned voices
gender: 'Female', // Mock default
language: 'zh',
description: description || 'User cloned voice'
};
onSuccess(newVoice);
// Reset
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"></label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给新声音起个名字"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> ()</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"></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"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="描述声音特点(如:年轻、沉稳..."
/>
</div>
</div>
</Dialog>
);
};