feat: 升级声音克隆与TTS工具页面
- 将首页改为语音生成历史列表页面,支持搜索和筛选功能 - 创建VoiceCloneModal组件,将声音克隆功能封装为弹框 - 创建SpeechGenerationModal组件,将语音合成功能封装为弹框 - 更新路由配置,/tools/voice-clone指向新的历史页面 - 支持音频播放、下载、删除等操作 - 使用App.tsx中的modal-root容器渲染Modal组件 主要变更: - 新增 VoiceGenerationHistory.tsx - 语音生成历史页面 - 新增 VoiceCloneModal.tsx - 声音克隆弹框组件 - 新增 SpeechGenerationModal.tsx - 语音合成弹框组件 - 修改 App.tsx - 更新路由配置 - 修改 VoiceCloneTool.tsx - 添加新的图标导入
This commit is contained in:
parent
6d4bf9150c
commit
10f3d93a19
|
|
@ -26,7 +26,7 @@ import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool';
|
|||
import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
|
||||
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
|
||||
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
||||
import VoiceCloneTool from './pages/tools/VoiceCloneTool';
|
||||
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
|
||||
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
||||
import MaterialCenter from './pages/MaterialCenter';
|
||||
import VideoGeneration from './pages/VideoGeneration';
|
||||
|
|
@ -141,7 +141,7 @@ function App() {
|
|||
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
||||
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
||||
<Route path="/tools/image-generation" element={<ImageGenerationTool />} />
|
||||
<Route path="/tools/voice-clone" element={<VoiceCloneTool />} />
|
||||
<Route path="/tools/voice-clone" element={<VoiceGenerationHistory />} />
|
||||
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
||||
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Volume2,
|
||||
Play,
|
||||
Download,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Users,
|
||||
Mic
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Modal } from './Modal';
|
||||
import { useNotifications } from './NotificationSystem';
|
||||
import SystemVoiceSelector from './SystemVoiceSelector';
|
||||
import {
|
||||
SpeechGenerationRequest,
|
||||
SpeechGenerationResponse,
|
||||
SpeechGenerationStatus,
|
||||
SpeechGenerationState,
|
||||
VoiceInfo,
|
||||
GetVoicesResponse
|
||||
} from '../types/voiceClone';
|
||||
import { SystemVoice } from '../types/systemVoice';
|
||||
|
||||
interface SpeechGenerationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (audioUrl: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音合成Modal组件
|
||||
* 封装语音合成功能为独立的弹框组件
|
||||
*/
|
||||
export const SpeechGenerationModal: React.FC<SpeechGenerationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// ============= 状态管理 =============
|
||||
|
||||
// 音色管理状态
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>('');
|
||||
const [isLoadingVoices, setIsLoadingVoices] = useState(false);
|
||||
|
||||
// 系统音色状态
|
||||
const [selectedSystemVoice, setSelectedSystemVoice] = useState<SystemVoice | null>(null);
|
||||
const [voiceSource, setVoiceSource] = useState<'system' | 'custom'>('system');
|
||||
|
||||
// 语音生成状态
|
||||
const [speechRequest, setSpeechRequest] = useState<SpeechGenerationRequest>({
|
||||
text: '',
|
||||
voice_id: '',
|
||||
speed: 1.0,
|
||||
vol: 1.0,
|
||||
emotion: 'calm'
|
||||
});
|
||||
const [speechState, setSpeechState] = useState<SpeechGenerationState>({
|
||||
status: SpeechGenerationStatus.IDLE
|
||||
});
|
||||
|
||||
// ============= 数据加载函数 =============
|
||||
|
||||
// 加载自定义音色列表
|
||||
const loadVoices = useCallback(async () => {
|
||||
setIsLoadingVoices(true);
|
||||
try {
|
||||
const response = await invoke<GetVoicesResponse>('get_voices');
|
||||
if (response.status && response.data) {
|
||||
setVoices(response.data);
|
||||
} else {
|
||||
console.warn('获取音色列表失败:', response.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载音色列表失败:', error);
|
||||
} finally {
|
||||
setIsLoadingVoices(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ============= 音色选择功能 =============
|
||||
|
||||
const handleVoiceSelect = useCallback((voiceId: string) => {
|
||||
setSelectedVoiceId(voiceId);
|
||||
setVoiceSource('custom');
|
||||
setSpeechRequest(prev => ({
|
||||
...prev,
|
||||
voice_id: voiceId
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 系统音色选择处理
|
||||
const handleSystemVoiceSelect = useCallback((voiceId: string, voice: SystemVoice) => {
|
||||
setSelectedSystemVoice(voice);
|
||||
setVoiceSource('system');
|
||||
setSpeechRequest(prev => ({
|
||||
...prev,
|
||||
voice_id: voiceId
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ============= 语音生成功能 =============
|
||||
|
||||
const handleGenerateSpeech = useCallback(async () => {
|
||||
if (!speechRequest.text.trim()) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '请输入要合成的文本',
|
||||
message: '请输入要转换为语音的文本内容'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!speechRequest.voice_id) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '请选择音色',
|
||||
message: '请选择要使用的音色'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeechState({
|
||||
status: SpeechGenerationStatus.GENERATING,
|
||||
progress: '正在生成语音...'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await invoke<SpeechGenerationResponse>('generate_speech', {
|
||||
request: speechRequest
|
||||
});
|
||||
|
||||
if (response.status && response.data) {
|
||||
setSpeechState({
|
||||
status: SpeechGenerationStatus.SUCCESS,
|
||||
result: response
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '语音生成成功',
|
||||
message: '语音已成功生成,可以播放或下载'
|
||||
});
|
||||
|
||||
// 调用成功回调
|
||||
if (onSuccess) {
|
||||
onSuccess(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.msg || '生成失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('语音生成失败:', error);
|
||||
setSpeechState({
|
||||
status: SpeechGenerationStatus.ERROR,
|
||||
error: String(error)
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '语音生成失败',
|
||||
message: `生成失败: ${error}`
|
||||
});
|
||||
}
|
||||
}, [speechRequest, addNotification, onSuccess]);
|
||||
|
||||
// ============= Modal控制 =============
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// 重置状态
|
||||
setSpeechRequest({
|
||||
text: '',
|
||||
voice_id: '',
|
||||
speed: 1.0,
|
||||
vol: 1.0,
|
||||
emotion: 'calm'
|
||||
});
|
||||
setSpeechState({ status: SpeechGenerationStatus.IDLE });
|
||||
setSelectedVoiceId('');
|
||||
setSelectedSystemVoice(null);
|
||||
setVoiceSource('system');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// ============= 生命周期 =============
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadVoices();
|
||||
}
|
||||
}, [isOpen, loadVoices]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="语音合成"
|
||||
subtitle="将文本转换为语音"
|
||||
icon={<Volume2 className="w-6 h-6 text-white" />}
|
||||
size="lg"
|
||||
variant="default"
|
||||
closeOnBackdropClick={speechState.status !== SpeechGenerationStatus.GENERATING}
|
||||
closeOnEscape={speechState.status !== SpeechGenerationStatus.GENERATING}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
合成文本 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={speechRequest.text}
|
||||
onChange={(e) => setSpeechRequest(prev => ({ ...prev, text: e.target.value }))}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
placeholder="请输入要转换为语音的文本内容..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
选择音色 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
{/* 音色来源选择 */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="system-voice"
|
||||
name="voice-source"
|
||||
checked={voiceSource === 'system'}
|
||||
onChange={() => setVoiceSource('system')}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="system-voice" className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Settings className="w-4 h-4 text-blue-600" />
|
||||
系统音色
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="custom-voice"
|
||||
name="voice-source"
|
||||
checked={voiceSource === 'custom'}
|
||||
onChange={() => setVoiceSource('custom')}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="custom-voice" className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Users className="w-4 h-4 text-orange-600" />
|
||||
自定义音色
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统音色选择器 */}
|
||||
{voiceSource === 'system' && (
|
||||
<SystemVoiceSelector
|
||||
selectedVoiceId={voiceSource === 'system' ? speechRequest.voice_id : ''}
|
||||
onVoiceSelect={handleSystemVoiceSelect}
|
||||
showSearch={true}
|
||||
showTypeFilter={true}
|
||||
showGenderFilter={true}
|
||||
className="border-0 shadow-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 自定义音色选择 */}
|
||||
{voiceSource === 'custom' && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">自定义音色列表</span>
|
||||
<button
|
||||
onClick={loadVoices}
|
||||
disabled={isLoadingVoices || speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{isLoadingVoices ? (
|
||||
<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>
|
||||
) : voices.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>
|
||||
) : (
|
||||
voices.map((voice) => (
|
||||
<div
|
||||
key={voice.voice_id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
voiceSource === 'custom' && selectedVoiceId === voice.voice_id
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'border-gray-200 hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => handleVoiceSelect(voice.voice_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{voice.voice_name || voice.voice_id}</p>
|
||||
<p className="text-sm text-gray-500">ID: {voice.voice_id}</p>
|
||||
</div>
|
||||
{voiceSource === 'custom' && selectedVoiceId === voice.voice_id && (
|
||||
<CheckCircle className="w-5 h-5 text-orange-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参数控制 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
语音参数
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
语速: {speechRequest.speed}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={speechRequest.speed}
|
||||
onChange={(e) => setSpeechRequest(prev => ({
|
||||
...prev,
|
||||
speed: parseFloat(e.target.value)
|
||||
}))}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.5x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
音量: {speechRequest.vol}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={speechRequest.vol}
|
||||
onChange={(e) => setSpeechRequest(prev => ({
|
||||
...prev,
|
||||
vol: parseFloat(e.target.value)
|
||||
}))}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.1x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 情感选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
情感风格
|
||||
</label>
|
||||
<select
|
||||
value={speechRequest.emotion || 'calm'}
|
||||
onChange={(e) => setSpeechRequest(prev => ({ ...prev, emotion: e.target.value }))}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
>
|
||||
<option value="calm">平静</option>
|
||||
<option value="happy">快乐</option>
|
||||
<option value="sad">悲伤</option>
|
||||
<option value="angry">愤怒</option>
|
||||
<option value="excited">兴奋</option>
|
||||
<option value="gentle">温柔</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 生成状态显示 */}
|
||||
{speechState.status !== SpeechGenerationStatus.IDLE && (
|
||||
<div className="p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
{speechState.status === SpeechGenerationStatus.SUCCESS && (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-600">生成成功</p>
|
||||
<p className="text-xs text-gray-500">语音已生成完成</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{speechState.status === SpeechGenerationStatus.ERROR && (
|
||||
<>
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-600">生成失败</p>
|
||||
<p className="text-xs text-gray-500">{speechState.error}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{speechState.status === SpeechGenerationStatus.GENERATING && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-600">生成中</p>
|
||||
<p className="text-xs text-gray-500">{speechState.progress}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 音频播放器 */}
|
||||
{speechState.result?.data && speechState.status === SpeechGenerationStatus.SUCCESS && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">生成的语音</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 下载音频文件
|
||||
const link = document.createElement('a');
|
||||
link.href = speechState.result!.data!;
|
||||
link.download = 'generated_speech.wav';
|
||||
link.click();
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
controls
|
||||
src={speechState.result.data}
|
||||
className="w-full"
|
||||
>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={speechState.status === SpeechGenerationStatus.GENERATING}
|
||||
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{speechState.status === SpeechGenerationStatus.SUCCESS ? '完成' : '取消'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerateSpeech}
|
||||
disabled={
|
||||
speechState.status === SpeechGenerationStatus.GENERATING ||
|
||||
!speechRequest.text.trim() ||
|
||||
!speechRequest.voice_id
|
||||
}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{speechState.status === SpeechGenerationStatus.GENERATING ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="w-4 h-4" />
|
||||
生成语音
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Mic,
|
||||
Upload,
|
||||
Wand2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Music,
|
||||
FileAudio,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { Modal } from './Modal';
|
||||
import { useNotifications } from './NotificationSystem';
|
||||
import {
|
||||
AudioUploadRequest,
|
||||
AudioUploadResponse,
|
||||
VoiceCloneRequest,
|
||||
VoiceCloneResponse,
|
||||
VoiceCloneStatus,
|
||||
AudioFileInfo,
|
||||
VoiceCloneState
|
||||
} from '../types/voiceClone';
|
||||
|
||||
interface VoiceCloneModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (voiceId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 声音克隆Modal组件
|
||||
* 封装声音克隆功能为独立的弹框组件
|
||||
*/
|
||||
export const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// ============= 状态管理 =============
|
||||
|
||||
// 音频上传状态
|
||||
const [audioFile, setAudioFile] = useState<AudioFileInfo | null>(null);
|
||||
|
||||
// 声音克隆状态
|
||||
const [cloneText, setCloneText] = useState('');
|
||||
const [customVoiceId, setCustomVoiceId] = useState('');
|
||||
const [cloneState, setCloneState] = useState<VoiceCloneState>({
|
||||
status: VoiceCloneStatus.IDLE
|
||||
});
|
||||
|
||||
// 合并后的状态:是否正在处理(上传+克隆)
|
||||
const [isProcessingClone, setIsProcessingClone] = useState(false);
|
||||
|
||||
// ============= 文件选择功能 =============
|
||||
|
||||
const handleFileSelect = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: 'Audio Files',
|
||||
extensions: ['wav', 'mp3', 'flac', 'm4a', 'aac', 'ogg']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
const file = new File([], selected.split('/').pop() || 'audio');
|
||||
const audioInfo: AudioFileInfo = {
|
||||
file,
|
||||
name: file.name,
|
||||
size: 0, // 实际应该获取文件大小
|
||||
type: file.type || 'audio/wav',
|
||||
preview_url: selected
|
||||
};
|
||||
|
||||
setAudioFile(audioInfo);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '文件选择成功',
|
||||
message: `已选择音频文件: ${audioInfo.name}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('文件选择失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '文件选择失败',
|
||||
message: `选择文件时出错: ${error}`
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// ============= 声音克隆功能 =============
|
||||
|
||||
const handleVoiceClone = useCallback(async () => {
|
||||
if (!cloneText.trim()) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '请输入克隆文本',
|
||||
message: '请输入用于声音克隆的文本内容'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioFile?.preview_url) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '请先选择音频文件',
|
||||
message: '请先选择参考音频文件'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingClone(true);
|
||||
setCloneState({
|
||||
status: VoiceCloneStatus.PROCESSING,
|
||||
progress: '正在上传音频文件...'
|
||||
});
|
||||
|
||||
try {
|
||||
// 第一步:上传音频文件
|
||||
const uploadRequest: AudioUploadRequest = {
|
||||
audio_file_path: audioFile.preview_url,
|
||||
purpose: 'voice_clone'
|
||||
};
|
||||
|
||||
const uploadResponse = await invoke<AudioUploadResponse>('upload_audio_file', { request: uploadRequest });
|
||||
|
||||
if (!uploadResponse.status) {
|
||||
throw new Error(uploadResponse.msg || '音频上传失败');
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
setCloneState({
|
||||
status: VoiceCloneStatus.PROCESSING,
|
||||
progress: '音频上传成功,正在克隆声音...'
|
||||
});
|
||||
|
||||
// 第二步:执行声音克隆
|
||||
const finalVoiceId = customVoiceId.trim() || `voice_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
const cloneRequest: VoiceCloneRequest = {
|
||||
text: cloneText,
|
||||
model: 'speech-02-hd',
|
||||
need_noise_reduction: true,
|
||||
voice_id: finalVoiceId,
|
||||
prefix: 'BoWong-',
|
||||
audio_file_path: audioFile.preview_url
|
||||
};
|
||||
|
||||
const cloneResponse = await invoke<VoiceCloneResponse>('clone_voice', { request: cloneRequest });
|
||||
|
||||
if (cloneResponse.status && cloneResponse.data) {
|
||||
setCloneState({
|
||||
status: VoiceCloneStatus.SUCCESS,
|
||||
result: cloneResponse
|
||||
});
|
||||
|
||||
const voiceId = cloneResponse.extra?.voice_id || finalVoiceId;
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '声音克隆成功',
|
||||
message: `新音色ID: ${voiceId}`
|
||||
});
|
||||
|
||||
// 调用成功回调
|
||||
if (onSuccess) {
|
||||
onSuccess(voiceId);
|
||||
}
|
||||
|
||||
// 延迟关闭Modal,让用户看到成功状态
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(cloneResponse.msg || '克隆失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('声音克隆失败:', error);
|
||||
setCloneState({
|
||||
status: VoiceCloneStatus.ERROR,
|
||||
error: String(error)
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '声音克隆失败',
|
||||
message: `克隆失败: ${error}`
|
||||
});
|
||||
} finally {
|
||||
setIsProcessingClone(false);
|
||||
}
|
||||
}, [cloneText, audioFile, customVoiceId, addNotification, onSuccess]);
|
||||
|
||||
// ============= Modal控制 =============
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// 重置状态
|
||||
setAudioFile(null);
|
||||
setCloneText('');
|
||||
setCustomVoiceId('');
|
||||
setCloneState({ status: VoiceCloneStatus.IDLE });
|
||||
setIsProcessingClone(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="声音克隆"
|
||||
subtitle="上传参考音频,生成专属音色"
|
||||
icon={<Mic className="w-6 h-6 text-white" />}
|
||||
size="lg"
|
||||
variant="default"
|
||||
closeOnBackdropClick={!isProcessingClone}
|
||||
closeOnEscape={!isProcessingClone}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 音频文件上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
参考音频文件
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-purple-400 transition-colors">
|
||||
{audioFile ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<FileAudio className="w-8 h-8 text-purple-600" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-gray-900">{audioFile.name}</p>
|
||||
<p className="text-xs text-gray-500">{audioFile.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Music className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-gray-600 text-sm mb-1">选择参考音频文件</p>
|
||||
<p className="text-xs text-gray-500">支持 WAV, MP3, FLAC, M4A, AAC, OGG 格式</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessingClone}
|
||||
className="mt-3 px-4 py-2 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4 inline mr-2" />
|
||||
{audioFile ? '重新选择' : '选择文件'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 克隆文本输入 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
克隆文本 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={cloneText}
|
||||
onChange={(e) => setCloneText(e.target.value)}
|
||||
disabled={isProcessingClone}
|
||||
placeholder="请输入用于声音克隆的文本内容,建议使用清晰、标准的语音内容..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
建议使用与参考音频相同的语言和风格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 自定义音色ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
自定义音色ID(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customVoiceId}
|
||||
onChange={(e) => setCustomVoiceId(e.target.value)}
|
||||
disabled={isProcessingClone}
|
||||
placeholder="留空将自动生成唯一ID"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 克隆状态显示 */}
|
||||
{cloneState.status !== VoiceCloneStatus.IDLE && (
|
||||
<div className="p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
{cloneState.status === VoiceCloneStatus.SUCCESS && (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">克隆成功</p>
|
||||
<p className="text-xs text-gray-500">音色已生成,即将关闭窗口</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{cloneState.status === VoiceCloneStatus.ERROR && (
|
||||
<>
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">克隆失败</p>
|
||||
<p className="text-xs text-gray-500">{cloneState.error}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{cloneState.status === VoiceCloneStatus.PROCESSING && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-600">处理中</p>
|
||||
<p className="text-xs text-gray-500">{cloneState.progress}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isProcessingClone}
|
||||
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVoiceClone}
|
||||
disabled={isProcessingClone || !cloneText.trim() || !audioFile}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isProcessingClone ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
开始克隆
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,14 @@ import {
|
|||
FileAudio,
|
||||
Trash2,
|
||||
Star,
|
||||
Users
|
||||
Users,
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Calendar,
|
||||
Clock,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Clock,
|
||||
Filter,
|
||||
Music,
|
||||
Loader2,
|
||||
Volume2,
|
||||
Mic,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useNotifications } from '../../components/NotificationSystem';
|
||||
import { VoiceCloneModal } from '../../components/VoiceCloneModal';
|
||||
import { SpeechGenerationModal } from '../../components/SpeechGenerationModal';
|
||||
import {
|
||||
SpeechGenerationRecord,
|
||||
SpeechGenerationRecordStatus
|
||||
} from '../../types/voiceClone';
|
||||
|
||||
/**
|
||||
* 语音生成历史页面
|
||||
* 展示语音生成记录列表,支持搜索、筛选、播放等功能
|
||||
*/
|
||||
const VoiceGenerationHistory: React.FC = () => {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// ============= 状态管理 =============
|
||||
|
||||
// 记录列表
|
||||
const [records, setRecords] = useState<SpeechGenerationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<SpeechGenerationRecordStatus | 'all'>('all');
|
||||
|
||||
// 音频播放状态
|
||||
const [playingRecordId, setPlayingRecordId] = useState<string | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
// Modal状态
|
||||
const [showVoiceCloneModal, setShowVoiceCloneModal] = useState(false);
|
||||
const [showSpeechGenerationModal, setShowSpeechGenerationModal] = useState(false);
|
||||
|
||||
// ============= 数据加载函数 =============
|
||||
|
||||
// 加载语音生成记录
|
||||
const loadRecords = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allRecords = await invoke<SpeechGenerationRecord[]>('get_speech_generation_records', {
|
||||
limit: 100 // 限制最近100条记录
|
||||
});
|
||||
|
||||
// 根据搜索和筛选条件过滤记录
|
||||
let filteredRecords = allRecords;
|
||||
|
||||
if (searchText.trim()) {
|
||||
filteredRecords = filteredRecords.filter(record =>
|
||||
record.text.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(record.voice_name && record.voice_name.toLowerCase().includes(searchText.toLowerCase())) ||
|
||||
record.voice_id.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filteredRecords = filteredRecords.filter(record => record.status === statusFilter);
|
||||
}
|
||||
|
||||
setRecords(filteredRecords);
|
||||
} catch (error) {
|
||||
console.error('加载语音生成记录失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '加载失败',
|
||||
message: `加载语音生成记录失败: ${error}`
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchText, statusFilter, addNotification]);
|
||||
|
||||
// 删除记录
|
||||
const handleDeleteRecord = useCallback(async (recordId: string) => {
|
||||
try {
|
||||
await invoke('delete_speech_generation_record', { recordId });
|
||||
setRecords(prev => prev.filter(r => r.id !== recordId));
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '删除成功',
|
||||
message: '语音生成记录已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除记录失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '删除失败',
|
||||
message: `删除记录失败: ${error}`
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 播放/暂停音频
|
||||
const handlePlayPause = useCallback((record: SpeechGenerationRecord) => {
|
||||
if (!record.audio_url && !record.local_file_path) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '无法播放',
|
||||
message: '该记录没有可播放的音频文件'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const audioUrl = record.local_file_path || record.audio_url;
|
||||
|
||||
if (playingRecordId === record.id) {
|
||||
// 暂停当前播放
|
||||
if (audioElement) {
|
||||
audioElement.pause();
|
||||
setPlayingRecordId(null);
|
||||
}
|
||||
} else {
|
||||
// 停止之前的播放
|
||||
if (audioElement) {
|
||||
audioElement.pause();
|
||||
}
|
||||
|
||||
// 开始新的播放
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.onended = () => setPlayingRecordId(null);
|
||||
audio.onerror = () => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '播放失败',
|
||||
message: '音频文件播放失败'
|
||||
});
|
||||
setPlayingRecordId(null);
|
||||
};
|
||||
|
||||
audio.play().then(() => {
|
||||
setPlayingRecordId(record.id);
|
||||
setAudioElement(audio);
|
||||
}).catch(error => {
|
||||
console.error('播放失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '播放失败',
|
||||
message: '音频文件播放失败'
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [playingRecordId, audioElement, addNotification]);
|
||||
|
||||
// 下载音频文件
|
||||
const handleDownload = useCallback((record: SpeechGenerationRecord) => {
|
||||
if (!record.audio_url && !record.local_file_path) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '无法下载',
|
||||
message: '该记录没有可下载的音频文件'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const audioUrl = record.local_file_path || record.audio_url;
|
||||
const link = document.createElement('a');
|
||||
link.href = audioUrl!;
|
||||
link.download = `speech_${record.id}.wav`;
|
||||
link.click();
|
||||
}, [addNotification]);
|
||||
|
||||
// ============= Modal处理函数 =============
|
||||
|
||||
// 声音克隆成功回调
|
||||
const handleVoiceCloneSuccess = useCallback((voiceId: string) => {
|
||||
console.log('声音克隆成功,音色ID:', voiceId);
|
||||
// 刷新记录列表
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
// 语音合成成功回调
|
||||
const handleSpeechGenerationSuccess = useCallback((audioUrl: string) => {
|
||||
console.log('语音合成成功,音频URL:', audioUrl);
|
||||
// 刷新记录列表
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
// ============= 生命周期 =============
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
// 清理音频元素
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioElement) {
|
||||
audioElement.pause();
|
||||
}
|
||||
};
|
||||
}, [audioElement]);
|
||||
|
||||
// ============= 辅助函数 =============
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr: string) => {
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 格式化持续时间
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusStyle = (status: SpeechGenerationRecordStatus) => {
|
||||
switch (status) {
|
||||
case SpeechGenerationRecordStatus.Completed:
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case SpeechGenerationRecordStatus.Failed:
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case SpeechGenerationRecordStatus.Processing:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: SpeechGenerationRecordStatus) => {
|
||||
switch (status) {
|
||||
case SpeechGenerationRecordStatus.Completed:
|
||||
return <CheckCircle className="w-3 h-3" />;
|
||||
case SpeechGenerationRecordStatus.Failed:
|
||||
return <XCircle className="w-3 h-3" />;
|
||||
case SpeechGenerationRecordStatus.Processing:
|
||||
return <Loader2 className="w-3 h-3 animate-spin" />;
|
||||
default:
|
||||
return <AlertCircle className="w-3 h-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: SpeechGenerationRecordStatus) => {
|
||||
switch (status) {
|
||||
case SpeechGenerationRecordStatus.Completed:
|
||||
return '已完成';
|
||||
case SpeechGenerationRecordStatus.Failed:
|
||||
return '失败';
|
||||
case SpeechGenerationRecordStatus.Processing:
|
||||
return '处理中';
|
||||
default:
|
||||
return '待处理';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 页面头部 */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center shadow-sm">
|
||||
<Volume2 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">语音生成历史</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">管理和播放您的语音生成记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowVoiceCloneModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
声音克隆
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSpeechGenerationModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
语音合成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选栏 */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 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="搜索文本内容、音色名称或ID..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(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>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as SpeechGenerationRecordStatus | 'all')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value={SpeechGenerationRecordStatus.Completed}>已完成</option>
|
||||
<option value={SpeechGenerationRecordStatus.Processing}>处理中</option>
|
||||
<option value={SpeechGenerationRecordStatus.Failed}>失败</option>
|
||||
<option value={SpeechGenerationRecordStatus.Pending}>待处理</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<button
|
||||
onClick={loadRecords}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记录列表 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Music className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">暂无语音生成记录</h3>
|
||||
<p className="text-gray-500 mb-6">开始创建您的第一个语音生成记录</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowVoiceCloneModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
声音克隆
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSpeechGenerationModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
语音合成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 状态和基本信息 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${getStatusStyle(record.status)}`}>
|
||||
{getStatusIcon(record.status)}
|
||||
{getStatusText(record.status)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
音色: {record.voice_name || record.voice_id}
|
||||
</span>
|
||||
{record.duration_ms && (
|
||||
<span className="text-sm text-gray-500">
|
||||
耗时: {formatDuration(record.duration_ms)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-900 text-sm leading-relaxed line-clamp-3">
|
||||
{record.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 参数信息 */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||
<span>语速: {record.speed}x</span>
|
||||
<span>音量: {record.volume}</span>
|
||||
{record.emotion && <span>情感: {record.emotion}</span>}
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>创建: {formatTime(record.created_at)}</span>
|
||||
</div>
|
||||
{record.completed_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>完成: {formatTime(record.completed_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{record.error_message && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-700">{record.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* 播放/暂停按钮 */}
|
||||
{(record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.Completed && (
|
||||
<button
|
||||
onClick={() => handlePlayPause(record)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title={playingRecordId === record.id ? "暂停播放" : "播放音频"}
|
||||
>
|
||||
{playingRecordId === record.id ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下载按钮 */}
|
||||
{(record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.Completed && (
|
||||
<button
|
||||
onClick={() => handleDownload(record)}
|
||||
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="下载音频"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={() => handleDeleteRecord(record.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal组件 */}
|
||||
<VoiceCloneModal
|
||||
isOpen={showVoiceCloneModal}
|
||||
onClose={() => setShowVoiceCloneModal(false)}
|
||||
onSuccess={handleVoiceCloneSuccess}
|
||||
/>
|
||||
|
||||
<SpeechGenerationModal
|
||||
isOpen={showSpeechGenerationModal}
|
||||
onClose={() => setShowSpeechGenerationModal(false)}
|
||||
onSuccess={handleSpeechGenerationSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceGenerationHistory;
|
||||
Loading…
Reference in New Issue