import React, { useEffect, useState, useRef } from 'react'; import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown, Pencil, Trash2 } from 'lucide-react'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI'; import { VendorCredential, Voice } from '../types'; import { createVoice, deleteVoice, fetchVendorCredentials, fetchVoices, previewVoice, saveVendorCredential, updateVoice } from '../services/backendApi'; const VENDOR_OPTIONS = [ { key: 'siliconflow', label: '硅基流动 (SiliconFlow)' }, { key: 'ali', label: 'Ali' }, { key: 'volcano', label: 'Volcano' }, { key: 'minimax', label: 'Minimax' }, ]; export const VoiceLibraryPage: React.FC = () => { const [voices, setVoices] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动' | 'SiliconFlow'>('all'); const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all'); const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all'); const [playingVoiceId, setPlayingVoiceId] = useState(null); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingVoice, setEditingVoice] = useState(null); const [isLoading, setIsLoading] = useState(true); const [playLoadingId, setPlayLoadingId] = useState(null); const [vendorCredentials, setVendorCredentials] = useState>({}); const [credentialVendorKey, setCredentialVendorKey] = useState('siliconflow'); const [credentialApiKey, setCredentialApiKey] = useState(''); const [credentialBaseUrl, setCredentialBaseUrl] = useState(''); const [isSavingCredential, setIsSavingCredential] = useState(false); const audioRef = useRef(null); useEffect(() => { const loadVoicesAndCredentials = async () => { setIsLoading(true); try { const [list, credentials] = await Promise.all([fetchVoices(), fetchVendorCredentials()]); setVoices(list); const mapped = credentials.reduce((acc, item) => { acc[item.vendorKey] = item; return acc; }, {} as Record); setVendorCredentials(mapped); } catch (error) { console.error(error); setVoices([]); } finally { setIsLoading(false); } }; loadVoicesAndCredentials(); }, []); useEffect(() => { const selected = vendorCredentials[credentialVendorKey]; setCredentialApiKey(selected?.apiKey || ''); setCredentialBaseUrl(selected?.baseUrl || ''); }, [credentialVendorKey, vendorCredentials]); 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 = async (voice: Voice) => { if (playingVoiceId === voice.id && audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; setPlayingVoiceId(null); return; } try { setPlayLoadingId(voice.id); const audioUrl = await previewVoice( voice.id, voice.language === 'en' ? 'Hello, this is a voice preview.' : '你好,这是一段语音试听。', voice.speed ); if (audioRef.current) { audioRef.current.pause(); } const audio = new Audio(audioUrl); audio.onended = () => setPlayingVoiceId(null); audio.onerror = () => { setPlayingVoiceId(null); alert('试听失败,请检查 SiliconFlow 配置。'); }; audioRef.current = audio; setPlayingVoiceId(voice.id); await audio.play(); } catch (error: any) { alert(error?.message || '试听失败'); setPlayingVoiceId(null); } finally { setPlayLoadingId(null); } }; const handleAddSuccess = async (newVoice: Voice) => { const created = await createVoice(newVoice); setVoices((prev) => [created, ...prev]); setIsAddModalOpen(false); setIsCloneModalOpen(false); }; const handleUpdateSuccess = async (id: string, data: Voice) => { const updated = await updateVoice(id, data); setVoices((prev) => prev.map((voice) => (voice.id === id ? updated : voice))); setEditingVoice(null); }; const handleDelete = async (id: string) => { if (!confirm('确认删除这个声音吗?')) return; await deleteVoice(id); setVoices((prev) => prev.filter((voice) => voice.id !== id)); }; const handleSaveVendorCredential = async () => { if (!credentialApiKey.trim()) { alert('请填写 API Key'); return; } try { setIsSavingCredential(true); const option = VENDOR_OPTIONS.find((item) => item.key === credentialVendorKey); const saved = await saveVendorCredential(credentialVendorKey, { vendorName: option?.label || credentialVendorKey, apiKey: credentialApiKey.trim(), baseUrl: credentialBaseUrl.trim(), }); setVendorCredentials((prev) => ({ ...prev, [saved.vendorKey]: saved })); } catch (error: any) { alert(error?.message || '保存厂商配置失败'); } finally { setIsSavingCredential(false); } }; return (

声音资源

{/* Filter Bar */}
setSearchTerm(e.target.value)} />
setCredentialApiKey(e.target.value)} /> setCredentialBaseUrl(e.target.value)} />
声音名称 厂商 性别 语言 试听 操作 {!isLoading && filteredVoices.map(voice => (
{voice.vendor === '硅基流动' && } {voice.name} {voice.description && {voice.description}}
{voice.vendor} {voice.gender === 'Male' ? '男' : '女'} {voice.language === 'zh' ? '中文' : 'English'}
))} {!isLoading && filteredVoices.length === 0 && ( 暂无声音数据 )} {isLoading && ( 加载中... )}
setIsAddModalOpen(false)} onSuccess={handleAddSuccess} /> setEditingVoice(null)} onSuccess={(voice) => handleUpdateSuccess(editingVoice!.id, voice)} initialVoice={editingVoice || undefined} /> setIsCloneModalOpen(false)} onSuccess={handleAddSuccess} />
); }; // --- Unified Add Voice Modal --- const AddVoiceModal: React.FC<{ isOpen: boolean; onClose: () => void; onSuccess: (voice: Voice) => Promise; initialVoice?: Voice; }> = ({ isOpen, onClose, onSuccess, initialVoice }) => { const [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动'); const [name, setName] = useState(''); const [sfModel, setSfModel] = useState('FunAudioLLM/CosyVoice2-0.5B'); const [sfVoiceId, setSfVoiceId] = useState('FunAudioLLM/CosyVoice2-0.5B:anna'); const [sfSpeed, setSfSpeed] = useState(1); const [sfGain, setSfGain] = useState(0); const [sfPitch, setSfPitch] = 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 [isSaving, setIsSaving] = useState(false); const testAudioRef = useRef(null); useEffect(() => { if (!initialVoice) return; const nextVendor = initialVoice.vendor === 'SiliconFlow' ? '硅基流动' : initialVoice.vendor; setVendor((nextVendor as any) || '硅基流动'); setName(initialVoice.name || ''); setGender(initialVoice.gender || 'Female'); setLanguage(initialVoice.language || 'zh'); setDescription(initialVoice.description || ''); setModel(initialVoice.model || ''); setVoiceKey(initialVoice.voiceKey || ''); setSfModel(initialVoice.model || 'FunAudioLLM/CosyVoice2-0.5B'); setSfVoiceId(initialVoice.voiceKey || 'FunAudioLLM/CosyVoice2-0.5B:anna'); setSfSpeed(initialVoice.speed ?? 1); setSfGain(initialVoice.gain ?? 0); setSfPitch(initialVoice.pitch ?? 0); }, [initialVoice, isOpen]); const handleAudition = async () => { if (!testInput.trim()) return; if (!initialVoice?.id) { alert('请先创建声音,再进行试听。'); return; } try { setIsAuditioning(true); const audioUrl = await previewVoice(initialVoice.id, testInput, sfSpeed); if (testAudioRef.current) { testAudioRef.current.pause(); } const audio = new Audio(audioUrl); testAudioRef.current = audio; await audio.play(); } catch (error: any) { alert(error?.message || '试听失败'); } finally { setIsAuditioning(false); } }; const handleSubmit = async () => { if (!name) { alert("请填写声音显示名称"); return; } const newVoice: Voice = { id: initialVoice?.id || `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`, name: name, vendor: vendor, gender: gender, language: language, description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`), model: vendor === '硅基流动' ? sfModel : model, voiceKey: vendor === '硅基流动' ? sfVoiceId : voiceKey, speed: sfSpeed, gain: sfGain, pitch: sfPitch, }; try { setIsSaving(true); await onSuccess(newVoice); setName(''); setVendor('硅基流动'); setDescription(''); setModel(''); setVoiceKey(''); } catch (error: any) { alert(error?.message || '保存失败'); } finally { setIsSaving(false); } }; return ( } >
setName(e.target.value)} placeholder="例如: 客服小美" />
{vendor === '硅基流动' ? (
setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" />
setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" /> {sfSpeed}x
setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" /> {sfGain}dB
setSfPitch(parseInt(e.target.value))} className="flex-1 accent-primary" /> {sfPitch}
) : (
setModel(e.target.value)} placeholder="API Model Key" />
setVoiceKey(e.target.value)} placeholder="Voice Key" />
)}