Files
ai-videoassistant-frontend/pages/VoiceLibrary.tsx
2026-02-02 00:29:23 +08:00

261 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};