mixvideo-v2/apps/desktop/src/components/SystemVoiceSelector.tsx

411 lines
15 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Search,
Volume2,
CheckCircle,
Loader2,
RefreshCw,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
SystemVoice,
SystemVoiceType,
SystemVoiceQuery,
VoiceSelectorProps,
VOICE_TYPE_LABELS,
GENDER_LABELS,
GENDER_ICONS
} from '../types/systemVoice';
import { SystemVoiceService } from '../services/systemVoiceService';
import { useNotifications } from './NotificationSystem';
/**
* 系统音色选择器组件
* 支持搜索、筛选、分类显示系统音色
*/
const SystemVoiceSelector: React.FC<VoiceSelectorProps> = ({
selectedVoiceId,
onVoiceSelect,
showSearch = true,
showTypeFilter = true,
showGenderFilter = true,
className = '',
disabled = false
}) => {
const { addNotification } = useNotifications();
// ============= 状态管理 =============
const [voices, setVoices] = useState<SystemVoice[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(true);
// 筛选和搜索状态
const [filters, setFilters] = useState<SystemVoiceQuery>({
keyword: '',
voice_type: '',
gender: '',
language: ''
});
// ============= 数据加载 =============
const loadSystemVoices = useCallback(async () => {
console.log('🎵 开始加载系统音色...');
setLoading(true);
setError(null);
try {
const systemVoices = await SystemVoiceService.getAllSystemVoices();
console.log('🎵 成功加载系统音色:', systemVoices.length, '个音色');
console.log('🎵 音色详情:', systemVoices);
console.log('🎵 第一个音色示例:', systemVoices[0]);
console.log('🎵 音色类型:', typeof systemVoices, Array.isArray(systemVoices));
setVoices(systemVoices);
} catch (err) {
console.error('❌ 加载系统音色失败:', err);
const errorMessage = err instanceof Error ? err.message : '加载系统音色失败';
setError(errorMessage);
addNotification({
type: 'error',
title: '加载失败',
message: errorMessage
});
} finally {
setLoading(false);
}
}, [addNotification]);
// ============= 筛选逻辑 =============
const filteredVoices = useMemo(() => {
console.log('🔍 开始筛选音色,原始数据:', voices.length, '个');
console.log('🔍 筛选条件:', filters);
let filtered = SystemVoiceService.filterVoices(voices, filters);
console.log('🔍 筛选后数据:', filtered.length, '个');
let sorted = SystemVoiceService.sortVoices(filtered, 'order');
console.log('🔍 排序后数据:', sorted.length, '个');
return sorted;
}, [voices, filters]);
// 按类型分组
const groupedVoices = useMemo(() => {
console.log('📊 开始分组,筛选后数据:', filteredVoices.length, '个');
const grouped = SystemVoiceService.groupVoicesByType(filteredVoices);
console.log('📊 分组结果:', grouped);
console.log('📊 各组数量:', Object.entries(grouped).map(([type, voices]) => `${type}: ${voices.length}`));
return grouped;
}, [filteredVoices]);
// ============= 事件处理 =============
const handleVoiceSelect = useCallback((voice: SystemVoice) => {
if (disabled) return;
onVoiceSelect(voice.voice_id, voice);
}, [onVoiceSelect, disabled]);
const handleFilterChange = useCallback((key: keyof SystemVoiceQuery, value: string) => {
setFilters(prev => ({
...prev,
[key]: value
}));
}, []);
const clearFilters = useCallback(() => {
setFilters({
keyword: '',
voice_type: '',
gender: '',
language: ''
});
}, []);
// ============= 初始化 =============
useEffect(() => {
loadSystemVoices();
}, [loadSystemVoices]);
// ============= 渲染函数 =============
const getTypeBadgeClassName = (voiceType: SystemVoiceType) => {
const baseClass = 'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium';
switch (voiceType) {
case 'system':
return baseClass + ' bg-blue-100 text-blue-800';
case 'premium':
return baseClass + ' bg-purple-100 text-purple-800';
case 'child':
return baseClass + ' bg-pink-100 text-pink-800';
case 'character':
return baseClass + ' bg-green-100 text-green-800';
case 'holiday':
return baseClass + ' bg-red-100 text-red-800';
case 'english':
return baseClass + ' bg-orange-100 text-orange-800';
default:
return baseClass + ' bg-gray-100 text-gray-800';
}
};
const renderVoiceCard = (voice: SystemVoice) => {
const isSelected = selectedVoiceId === voice.voice_id;
const genderIcon = GENDER_ICONS[voice.gender] || '🎭';
// 使用固定的CSS类名而不是动态生成
const getCardClassName = () => {
let baseClass = 'p-3 border rounded-lg cursor-pointer transition-all duration-200';
if (disabled) {
baseClass += ' opacity-50 cursor-not-allowed';
}
if (isSelected) {
switch (voice.voice_type) {
case 'system':
return baseClass + ' border-blue-500 bg-blue-50 shadow-md';
case 'premium':
return baseClass + ' border-purple-500 bg-purple-50 shadow-md';
case 'child':
return baseClass + ' border-pink-500 bg-pink-50 shadow-md';
case 'character':
return baseClass + ' border-green-500 bg-green-50 shadow-md';
case 'holiday':
return baseClass + ' border-red-500 bg-red-50 shadow-md';
case 'english':
return baseClass + ' border-orange-500 bg-orange-50 shadow-md';
default:
return baseClass + ' border-gray-500 bg-gray-50 shadow-md';
}
} else {
return baseClass + ' border-gray-200 hover:border-gray-300 hover:shadow-sm';
}
};
return (
<div
key={voice.voice_id}
className={getCardClassName()}
onClick={() => handleVoiceSelect(voice)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{genderIcon}</span>
<h4 className="font-medium text-gray-900 truncate">
{voice.voice_name}
</h4>
{isSelected && (
<CheckCircle className={`w-4 h-4 flex-shrink-0`} />
)}
</div>
{voice.voice_name_en && (
<p className="text-sm text-gray-500 truncate mb-1">
{voice.voice_name_en}
</p>
)}
{voice.description && (
<p className="text-xs text-gray-600 truncate mb-2">
{voice.description}
</p>
)}
<div className="flex items-center gap-2">
<span className={getTypeBadgeClassName(voice.voice_type)}>
{VOICE_TYPE_LABELS[voice.voice_type]}
</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{GENDER_LABELS[voice.gender]}
</span>
</div>
</div>
</div>
</div>
);
};
const renderTypeGroup = (type: SystemVoiceType, voices: SystemVoice[]) => {
if (voices.length === 0) return null;
const typeLabel = VOICE_TYPE_LABELS[type];
const getTypeGroupClassName = () => {
switch (type) {
case 'system':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-blue-200';
case 'premium':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-purple-200';
case 'child':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-pink-200';
case 'character':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-green-200';
case 'holiday':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-red-200';
case 'english':
return 'flex items-center gap-2 mb-3 pb-2 border-b border-orange-200';
default:
return 'flex items-center gap-2 mb-3 pb-2 border-b border-gray-200';
}
};
const getTypeDotClassName = () => {
switch (type) {
case 'system':
return 'w-3 h-3 rounded-full bg-blue-500';
case 'premium':
return 'w-3 h-3 rounded-full bg-purple-500';
case 'child':
return 'w-3 h-3 rounded-full bg-pink-500';
case 'character':
return 'w-3 h-3 rounded-full bg-green-500';
case 'holiday':
return 'w-3 h-3 rounded-full bg-red-500';
case 'english':
return 'w-3 h-3 rounded-full bg-orange-500';
default:
return 'w-3 h-3 rounded-full bg-gray-500';
}
};
return (
<div key={type} className="mb-4">
<div className={getTypeGroupClassName()}>
<div className={getTypeDotClassName()}></div>
<h3 className="font-medium text-gray-900">{typeLabel}</h3>
<span className="text-sm text-gray-500">({voices.length})</span>
</div>
<div className="grid grid-cols-1 gap-2">
{voices.map(renderVoiceCard)}
</div>
</div>
);
};
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 ${className}`}>
{/* 头部 */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Volume2 className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900"></h3>
<span className="text-sm text-gray-500">
({filteredVoices.length}/{voices.length})
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadSystemVoices}
disabled={loading}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="刷新音色列表"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* 筛选器 */}
{expanded && (showSearch || showTypeFilter || showGenderFilter) && (
<div className="mt-4 space-y-3">
{/* 搜索框 */}
{showSearch && (
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索音色名称..."
value={filters.keyword || ''}
onChange={(e) => handleFilterChange('keyword', e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
)}
{/* 筛选选项 */}
{(showTypeFilter || showGenderFilter) && (
<div className="flex flex-wrap gap-2">
{showTypeFilter && (
<select
value={filters.voice_type || ''}
onChange={(e) => handleFilterChange('voice_type', e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value=""></option>
{Object.entries(VOICE_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)}
{showGenderFilter && (
<select
value={filters.gender || ''}
onChange={(e) => handleFilterChange('gender', e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value=""></option>
{Object.entries(GENDER_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)}
{(filters.keyword || filters.voice_type || filters.gender) && (
<button
onClick={clearFilters}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
</button>
)}
</div>
)}
</div>
)}
</div>
{/* 内容区域 */}
{expanded && (
<div className="p-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500">...</span>
</div>
) : error ? (
<div className="text-center py-8">
<div className="text-red-500 mb-2"></div>
<div className="text-sm text-gray-500 mb-4">{error}</div>
<button
onClick={loadSystemVoices}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : filteredVoices.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Volume2 className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p></p>
<p className="text-sm"></p>
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{Object.entries(groupedVoices).map(([type, voices]) =>
renderTypeGroup(type as SystemVoiceType, voices)
)}
</div>
)}
</div>
)}
</div>
);
};
export default SystemVoiceSelector;