feat: 添加语音生成历史页面的下载到指定目录和批量下载功能

后端新增功能:
� download_audio_to_directory - 下载单个音频文件到指定目录
� batch_download_audio_to_directory - 批量下载音频文件到指定目录
� 智能文件名生成,基于文本内容和记录ID
�️ 文件名安全处理,移除非法字符

前端新增功能:
� 批量选择模式,支持选择多个已完成的记录
� 全选/取消全选功能
� 下载到指定目录(使用文件夹选择对话框)
 快速下载(原浏览器下载方式)
� 批量下载进度显示和状态管理

用户体验改进:
 双重下载选项:快速下载 + 指定目录下载
 批量操作界面,支持选择和批量下载
 智能文件命名,包含文本内容片段
 完整的错误处理和用户反馈
 响应式UI设计,适配不同操作模式

技术实现:
- 使用Tauri的文件夹选择API
- 异步批量下载处理
- 状态管理和UI交互优化
- 类型安全的TypeScript实现
This commit is contained in:
杨明明 2025-07-31 14:08:59 +08:00
parent afb7ff538d
commit f025f1daf8
3 changed files with 349 additions and 26 deletions

View File

@ -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,
// 系统音色命令

View File

@ -561,6 +561,99 @@ pub async fn download_audio(audio_url: String, save_path: String) -> Result<Stri
Ok(save_path)
}
/// 下载单个音频文件到指定目录
#[command]
pub async fn download_audio_to_directory(
record_id: String,
audio_url: String,
directory_path: String,
filename: Option<String>
) -> Result<String, String> {
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<serde_json::Value>,
directory_path: String
) -> Result<Vec<String>, 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::<String>()
.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(

View File

@ -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<Set<string>>(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<string | null>('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<string>('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<string | null>('select_directory_with_options', {
title: '选择批量下载目录',
defaultDirectory: null
});
if (!selectedDirectory) {
return; // 用户取消选择
}
setIsDownloading(true);
// 批量下载
const downloadedFiles = await invoke<string[]>('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 = () => {
</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>
{/* 批量操作按钮 */}
{!isSelectionMode ? (
<>
<button
onClick={toggleSelectionMode}
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
<CheckSquare className="w-4 h-4" />
</button>
<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>
</>
) : (
<>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<CheckSquare className="w-4 h-4" />
{records.filter(r => (r.audio_url || r.local_file_path) && r.status === SpeechGenerationRecordStatus.COMPLETED).every(r => selectedRecords.has(r.id)) ? '取消全选' : '全选'}
</button>
<button
onClick={handleBatchDownload}
disabled={selectedRecords.size === 0 || isDownloading}
className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<FolderDown className="w-4 h-4" />
({selectedRecords.size})
</>
)}
</button>
<button
onClick={toggleSelectionMode}
className="flex items-center gap-2 px-3 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
</>
)}
</div>
</div>
</div>
@ -437,8 +637,23 @@ const VoiceGenerationHistory: React.FC = () => {
{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-start gap-4 flex-1 min-w-0">
{/* 选择框 */}
{isSelectionMode && (record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && (
<button
onClick={() => toggleRecordSelection(record.id)}
className="mt-1 p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
{selectedRecords.has(record.id) ? (
<CheckSquare className="w-5 h-5 text-blue-600" />
) : (
<Square className="w-5 h-5" />
)}
</button>
)}
<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)}
@ -490,6 +705,7 @@ const VoiceGenerationHistory: React.FC = () => {
<p className="text-sm text-red-700">{record.error_message}</p>
</div>
)}
</div>
</div>
{/* 操作按钮 */}
@ -511,13 +727,25 @@ const VoiceGenerationHistory: React.FC = () => {
{/* 下载按钮 */}
{(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>
<div className="flex items-center gap-1">
{/* 快速下载(浏览器下载) */}
<button
onClick={() => handleQuickDownload(record)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="快速下载"
>
<Download className="w-4 h-4" />
</button>
{/* 下载到指定目录 */}
<button
onClick={() => handleDownloadToDirectory(record)}
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="下载到指定目录"
>
<FolderDown className="w-4 h-4" />
</button>
</div>
)}
{/* 删除按钮 */}