feat: 添加语音生成历史页面的下载到指定目录和批量下载功能
后端新增功能: � download_audio_to_directory - 下载单个音频文件到指定目录 � batch_download_audio_to_directory - 批量下载音频文件到指定目录 � 智能文件名生成,基于文本内容和记录ID �️ 文件名安全处理,移除非法字符 前端新增功能: � 批量选择模式,支持选择多个已完成的记录 � 全选/取消全选功能 � 下载到指定目录(使用文件夹选择对话框) ⚡ 快速下载(原浏览器下载方式) � 批量下载进度显示和状态管理 用户体验改进: ✅ 双重下载选项:快速下载 + 指定目录下载 ✅ 批量操作界面,支持选择和批量下载 ✅ 智能文件命名,包含文本内容片段 ✅ 完整的错误处理和用户反馈 ✅ 响应式UI设计,适配不同操作模式 技术实现: - 使用Tauri的文件夹选择API - 异步批量下载处理 - 状态管理和UI交互优化 - 类型安全的TypeScript实现
This commit is contained in:
parent
afb7ff538d
commit
f025f1daf8
|
|
@ -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,
|
||||
// 系统音色命令
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue