From f025f1daf8f81580593b78492534e8c29fa8d351 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 31 Jul 2025 14:08:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8E=86=E5=8F=B2=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=88=B0=E6=8C=87=E5=AE=9A=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=92=8C=E6=89=B9=E9=87=8F=E4=B8=8B=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增功能: � download_audio_to_directory - 下载单个音频文件到指定目录 � batch_download_audio_to_directory - 批量下载音频文件到指定目录 � 智能文件名生成,基于文本内容和记录ID �️ 文件名安全处理,移除非法字符 前端新增功能: � 批量选择模式,支持选择多个已完成的记录 � 全选/取消全选功能 � 下载到指定目录(使用文件夹选择对话框) ⚡ 快速下载(原浏览器下载方式) � 批量下载进度显示和状态管理 用户体验改进: ✅ 双重下载选项:快速下载 + 指定目录下载 ✅ 批量操作界面,支持选择和批量下载 ✅ 智能文件命名,包含文本内容片段 ✅ 完整的错误处理和用户反馈 ✅ 响应式UI设计,适配不同操作模式 技术实现: - 使用Tauri的文件夹选择API - 异步批量下载处理 - 状态管理和UI交互优化 - 类型安全的TypeScript实现 --- apps/desktop/src-tauri/src/lib.rs | 2 + .../commands/voice_clone_commands.rs | 93 ++++++ .../pages/tools/VoiceGenerationHistory.tsx | 280 ++++++++++++++++-- 3 files changed, 349 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 31ebb65..9b581f1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -435,6 +435,8 @@ pub fn run() { commands::voice_clone_commands::get_voices, commands::voice_clone_commands::generate_speech, commands::voice_clone_commands::download_audio, + commands::voice_clone_commands::download_audio_to_directory, + commands::voice_clone_commands::batch_download_audio_to_directory, commands::voice_clone_commands::get_speech_generation_records, commands::voice_clone_commands::get_speech_generation_records_by_voice_id, // 系统音色命令 diff --git a/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs index ab4f665..2d49605 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs @@ -561,6 +561,99 @@ pub async fn download_audio(audio_url: String, save_path: String) -> Result +) -> Result { + info!("下载音频文件到目录: {} -> {}", audio_url, directory_path); + + // 生成文件名 + let file_name = filename.unwrap_or_else(|| { + format!("speech_{}.wav", record_id) + }); + + let save_path = std::path::Path::new(&directory_path).join(&file_name); + let save_path_str = save_path.to_string_lossy().to_string(); + + // 使用现有的下载函数 + download_audio(audio_url, save_path_str.clone()).await?; + + Ok(save_path_str) +} + +/// 批量下载音频文件到指定目录 +#[command] +pub async fn batch_download_audio_to_directory( + records: Vec, + directory_path: String +) -> Result, String> { + info!("批量下载 {} 个音频文件到目录: {}", records.len(), directory_path); + + let mut downloaded_files = Vec::new(); + let mut failed_downloads = Vec::new(); + + for (index, record) in records.iter().enumerate() { + let record_id = record.get("id") + .and_then(|v| v.as_str()) + .unwrap_or(&format!("unknown_{}", index)); + + let audio_url = record.get("audio_url") + .and_then(|v| v.as_str()) + .or_else(|| record.get("local_file_path").and_then(|v| v.as_str())); + + if let Some(url) = audio_url { + // 生成安全的文件名 + let text = record.get("text") + .and_then(|v| v.as_str()) + .unwrap_or("speech"); + + // 清理文件名,移除非法字符 + let safe_text = text.chars() + .take(30) // 限制长度 + .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_') + .collect::() + .trim() + .replace(' ', "_"); + + let filename = if safe_text.is_empty() { + format!("speech_{}.wav", record_id) + } else { + format!("{}_{}.wav", safe_text, record_id) + }; + + match download_audio_to_directory( + record_id.to_string(), + url.to_string(), + directory_path.clone(), + Some(filename) + ).await { + Ok(file_path) => { + info!("成功下载: {}", file_path); + downloaded_files.push(file_path); + } + Err(e) => { + error!("下载失败 {}: {}", record_id, e); + failed_downloads.push(format!("{}: {}", record_id, e)); + } + } + } else { + warn!("记录 {} 没有可用的音频URL", record_id); + failed_downloads.push(format!("{}: 没有可用的音频URL", record_id)); + } + } + + if !failed_downloads.is_empty() { + warn!("部分下载失败: {:?}", failed_downloads); + } + + info!("批量下载完成: 成功 {}, 失败 {}", downloaded_files.len(), failed_downloads.len()); + Ok(downloaded_files) +} + /// 获取语音生成记录列表 #[command] pub async fn get_speech_generation_records( diff --git a/apps/desktop/src/pages/tools/VoiceGenerationHistory.tsx b/apps/desktop/src/pages/tools/VoiceGenerationHistory.tsx index 81d68b3..a0dab57 100644 --- a/apps/desktop/src/pages/tools/VoiceGenerationHistory.tsx +++ b/apps/desktop/src/pages/tools/VoiceGenerationHistory.tsx @@ -16,7 +16,10 @@ import { Mic, CheckCircle, XCircle, - AlertCircle + AlertCircle, + FolderDown, + Square, + CheckSquare } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { useNotifications } from '../../components/NotificationSystem'; @@ -50,6 +53,11 @@ const VoiceGenerationHistory: React.FC = () => { const [showVoiceCloneModal, setShowVoiceCloneModal] = useState(false); const [showSpeechGenerationModal, setShowSpeechGenerationModal] = useState(false); + // 批量选择状态 + const [selectedRecords, setSelectedRecords] = useState>(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + // ============= 数据加载函数 ============= // 加载语音生成记录 @@ -170,8 +178,116 @@ const VoiceGenerationHistory: React.FC = () => { } }, [playingRecordId, audioElement, addNotification]); - // 下载音频文件 - const handleDownload = useCallback((record: SpeechGenerationRecord) => { + // 下载音频文件到指定目录 + const handleDownloadToDirectory = useCallback(async (record: SpeechGenerationRecord) => { + if (!record.audio_url && !record.local_file_path) { + addNotification({ + type: 'warning', + title: '无法下载', + message: '该记录没有可下载的音频文件' + }); + return; + } + + try { + // 选择下载目录 + const selectedDirectory = await invoke('select_directory_with_options', { + title: '选择下载目录', + defaultDirectory: null + }); + + if (!selectedDirectory) { + return; // 用户取消选择 + } + + const audioUrl = record.local_file_path || record.audio_url; + + // 生成文件名 + const text = record.text.substring(0, 30).replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_'); + const filename = text ? `${text}_${record.id}.wav` : `speech_${record.id}.wav`; + + // 下载文件 + const savedPath = await invoke('download_audio_to_directory', { + recordId: record.id, + audioUrl: audioUrl!, + directoryPath: selectedDirectory, + filename + }); + + addNotification({ + type: 'success', + title: '下载成功', + message: `文件已保存到: ${savedPath}` + }); + } catch (error) { + console.error('下载失败:', error); + addNotification({ + type: 'error', + title: '下载失败', + message: `下载失败: ${error}` + }); + } + }, [addNotification]); + + // 批量下载到指定目录 + const handleBatchDownload = useCallback(async () => { + const downloadableRecords = records.filter(record => + selectedRecords.has(record.id) && + (record.audio_url || record.local_file_path) && + record.status === SpeechGenerationRecordStatus.COMPLETED + ); + + if (downloadableRecords.length === 0) { + addNotification({ + type: 'warning', + title: '无可下载文件', + message: '请选择至少一个有音频文件的已完成记录' + }); + return; + } + + try { + // 选择下载目录 + const selectedDirectory = await invoke('select_directory_with_options', { + title: '选择批量下载目录', + defaultDirectory: null + }); + + if (!selectedDirectory) { + return; // 用户取消选择 + } + + setIsDownloading(true); + + // 批量下载 + const downloadedFiles = await invoke('batch_download_audio_to_directory', { + records: downloadableRecords, + directoryPath: selectedDirectory + }); + + addNotification({ + type: 'success', + title: '批量下载完成', + message: `成功下载 ${downloadedFiles.length} 个文件到: ${selectedDirectory}` + }); + + // 清空选择 + setSelectedRecords(new Set()); + setIsSelectionMode(false); + } catch (error) { + console.error('批量下载失败:', error); + addNotification({ + type: 'error', + title: '批量下载失败', + message: `批量下载失败: ${error}` + }); + } finally { + setIsDownloading(false); + } + }, [records, selectedRecords, addNotification]); + + // 兼容旧的下载方法(浏览器直接下载) + const handleQuickDownload = useCallback((record: SpeechGenerationRecord) => { if (!record.audio_url && !record.local_file_path) { addNotification({ type: 'warning', @@ -188,6 +304,45 @@ const VoiceGenerationHistory: React.FC = () => { link.click(); }, [addNotification]); + // ============= 批量选择处理函数 ============= + + // 切换选择模式 + const toggleSelectionMode = useCallback(() => { + setIsSelectionMode(!isSelectionMode); + if (isSelectionMode) { + setSelectedRecords(new Set()); + } + }, [isSelectionMode]); + + // 切换单个记录选择 + const toggleRecordSelection = useCallback((recordId: string) => { + setSelectedRecords(prev => { + const newSet = new Set(prev); + if (newSet.has(recordId)) { + newSet.delete(recordId); + } else { + newSet.add(recordId); + } + return newSet; + }); + }, []); + + // 全选/取消全选 + const toggleSelectAll = useCallback(() => { + const downloadableRecords = records.filter(record => + (record.audio_url || record.local_file_path) && + record.status === SpeechGenerationRecordStatus.COMPLETED + ); + + const allSelected = downloadableRecords.every(record => selectedRecords.has(record.id)); + + if (allSelected) { + setSelectedRecords(new Set()); + } else { + setSelectedRecords(new Set(downloadableRecords.map(record => record.id))); + } + }, [records, selectedRecords]); + // ============= Modal处理函数 ============= // 声音克隆成功回调 @@ -339,20 +494,65 @@ const VoiceGenerationHistory: React.FC = () => {
- - + {/* 批量操作按钮 */} + {!isSelectionMode ? ( + <> + + + + + ) : ( + <> + + + + + )}
@@ -437,8 +637,23 @@ const VoiceGenerationHistory: React.FC = () => { {records.map((record) => (
-
- {/* 状态和基本信息 */} +
+ {/* 选择框 */} + {isSelectionMode && (record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && ( + + )} + +
+ {/* 状态和基本信息 */}
{getStatusIcon(record.status)} @@ -490,6 +705,7 @@ const VoiceGenerationHistory: React.FC = () => {

{record.error_message}

)} +
{/* 操作按钮 */} @@ -511,13 +727,25 @@ const VoiceGenerationHistory: React.FC = () => { {/* 下载按钮 */} {(record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && ( - +
+ {/* 快速下载(浏览器下载) */} + + + {/* 下载到指定目录 */} + +
)} {/* 删除按钮 */}