411 lines
15 KiB
TypeScript
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;
|