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:
杨明明 2025-07-31 13:07:00 +08:00
parent 6d4bf9150c
commit 10f3d93a19
5 changed files with 1368 additions and 3 deletions

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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';

View File

@ -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;