From d4e017bd6d98423b3f218ce9bdac0aa23beddc85 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Mon, 25 May 2026 01:08:30 +0800 Subject: [PATCH] 11 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/fs_util.rs | 162 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/services/fileExport.js | 101 +++++++++++++++++++ src/stores/voicePage.js | 1 - src/views/VoiceView.vue | 116 ++++++++++++--------- 7 files changed, 339 insertions(+), 46 deletions(-) create mode 100644 src/services/fileExport.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3a3f27e..9af45e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4178,6 +4178,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs 5.0.1", + "dunce", "env_logger", "futures-util", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3877bfc..8922aca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -75,6 +75,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } # Misc mime_guess = "2" dirs = "5" +dunce = "1" rand = "0.8" tempfile = "3" uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/src/commands/fs_util.rs b/src-tauri/src/commands/fs_util.rs index ec9be2d..525d081 100644 --- a/src-tauri/src/commands/fs_util.rs +++ b/src-tauri/src/commands/fs_util.rs @@ -74,3 +74,165 @@ pub async fn get_media_duration_seconds(path: String) -> Result { .await .map_err(|e| e.to_string()) } + +/// 将受信任的本地媒体文件复制到用户指定路径(供「另存为」下载)。 +#[tauri::command] +pub async fn copy_local_file(source_path: String, dest_path: String) -> Result<(), String> { + let source = normalize_path(source_path.trim()); + if source.as_os_str().is_empty() { + return Err("源路径为空".into()); + } + if !source.is_file() { + return Err(format!("源文件不存在: {}", source.display())); + } + if !is_allowed_local_media(&source) { + return Err("不允许复制该路径下的文件".into()); + } + + let dest = normalize_path(dest_path.trim()); + if dest.as_os_str().is_empty() { + return Err("目标路径为空".into()); + } + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建目标目录失败: {e}"))?; + } + } + + tokio::fs::copy(&source, &dest) + .await + .map_err(|e| format!("保存文件失败: {e}"))?; + Ok(()) +} + +/// 将文本写入用户指定路径(供 ASR 结果「另存为」)。 +#[tauri::command] +pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> { + let dest = normalize_path(path.trim()); + if dest.as_os_str().is_empty() { + return Err("目标路径为空".into()); + } + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建目标目录失败: {e}"))?; + } + } + tokio::fs::write(&dest, content.as_bytes()) + .await + .map_err(|e| format!("保存文件失败: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +mod win_shell { + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + + pub fn select_file_in_explorer(path: &Path) -> Result<(), String> { + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let _ = ffi::CoInitialize(std::ptr::null()); + let pidl = ffi::ILCreateFromPathW(wide.as_ptr()); + if pidl.is_null() { + return Err(format!("无法定位文件: {}", path.display())); + } + let hr = ffi::SHOpenFolderAndSelectItems(pidl, 0, std::ptr::null(), 0); + ffi::ILFree(pidl); + if hr != 0 { + return Err(format!("打开文件位置失败 (错误码 {hr})")); + } + } + Ok(()) + } + + mod ffi { + #[repr(C, packed(1))] + pub struct ITEMIDLIST { + pub mkid: SHITEMID, + } + + #[repr(C, packed(1))] + pub struct SHITEMID { + pub cb: u16, + pub ab_id: [u8; 1], + } + + #[link(name = "shell32")] + extern "system" { + pub fn ILCreateFromPathW(pszpath: *const u16) -> *mut ITEMIDLIST; + pub fn SHOpenFolderAndSelectItems( + pidlfolder: *const ITEMIDLIST, + cidl: u32, + apidl: *const *const ITEMIDLIST, + dwflags: u32, + ) -> i32; + pub fn ILFree(pidl: *const ITEMIDLIST) -> i32; + } + + #[link(name = "ole32")] + extern "system" { + pub fn CoInitialize(pvreserved: *const core::ffi::c_void) -> i32; + } + } +} + +fn reveal_in_folder_sync(path: &Path) -> Result<(), String> { + let absolute = std::path::absolute(path).map_err(|e| format!("无法解析路径: {e}"))?; + let absolute = dunce::simplified(&absolute); + + #[cfg(target_os = "windows")] + { + return win_shell::select_file_in_explorer(&absolute); + } + + #[cfg(target_os = "macos")] + { + use std::process::Command; + let path_str = absolute.display().to_string(); + Command::new("open") + .args(["-R", &path_str]) + .spawn() + .map_err(|e| format!("打开文件位置失败: {e}"))?; + return Ok(()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + use std::process::Command; + let parent = absolute + .parent() + .ok_or_else(|| "无法获取文件所在目录".to_string())?; + Command::new("xdg-open") + .arg(parent) + .spawn() + .map_err(|e| format!("打开文件位置失败: {e}"))?; + Ok(()) + } +} + +/// 在系统文件管理器中打开并选中本地文件(Windows: `explorer /select`)。 +#[tauri::command] +pub async fn reveal_local_file_in_folder(path: String) -> Result<(), String> { + let normalized = normalize_path(path.trim()); + if normalized.as_os_str().is_empty() { + return Err("路径为空".into()); + } + if !normalized.is_file() { + return Err(format!("文件不存在: {}", normalized.display())); + } + if !is_allowed_local_media(&normalized) { + return Err("不允许打开该路径下的文件".into()); + } + tokio::task::spawn_blocking(move || reveal_in_folder_sync(&normalized)) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ce3a1a..af8c031 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,9 @@ pub fn run() { commands::nodejs::list_nodejs_scripts, commands::fs_util::read_local_file_base64, commands::fs_util::get_media_duration_seconds, + commands::fs_util::copy_local_file, + commands::fs_util::write_local_text_file, + commands::fs_util::reveal_local_file_in_folder, commands::avatar::list_avatars, commands::avatar::insert_avatar, commands::avatar::update_avatar_name, diff --git a/src/services/fileExport.js b/src/services/fileExport.js new file mode 100644 index 0000000..a214d85 --- /dev/null +++ b/src/services/fileExport.js @@ -0,0 +1,101 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; + +function ensureTauri() { + if (!isTauri()) { + throw new Error("请在 Tauri 桌面端下载文件"); + } +} + +/** + * @param {string} filePath + */ +function basename(filePath) { + return String(filePath || "") + .replace(/\\/g, "/") + .split("/") + .pop(); +} + +/** + * @param {string} name + * @param {string} filePath + * @param {string} fallbackExt + */ +export function suggestExportName(name, filePath, fallbackExt = "bin") { + const base = basename(filePath) || ""; + const dot = base.lastIndexOf("."); + const ext = dot > 0 ? base.slice(dot) : `.${fallbackExt}`; + const safe = String(name || "file").replace(/[<>:"/\\|?*]/g, "_").trim() || "file"; + if (safe.toLowerCase().endsWith(ext.toLowerCase())) return safe; + return `${safe}${ext}`; +} + +/** + * @param {string} defaultName + * @returns {string | undefined} + */ +function extensionFromName(defaultName) { + const base = basename(defaultName) || ""; + const dot = base.lastIndexOf("."); + if (dot <= 0 || dot === base.length - 1) return undefined; + return base.slice(dot + 1).toLowerCase(); +} + +/** + * @param {string} sourcePath + * @param {string} defaultName + * @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>} + */ +export async function exportLocalFile(sourcePath, defaultName) { + ensureTauri(); + const ext = extensionFromName(defaultName); + const dest = await save({ + title: "保存文件", + defaultPath: defaultName, + filters: ext ? [{ name: "文件", extensions: [ext] }] : undefined, + }); + if (dest == null) { + return { ok: false, cancelled: true }; + } + await invoke("copy_local_file", { + sourcePath, + destPath: String(dest), + }); + return { ok: true }; +} + +/** + * @param {string} text + * @param {string} defaultName + * @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>} + */ +/** + * @param {string} filePath + */ +export async function revealLocalFileInFolder(filePath) { + ensureTauri(); + await invoke("reveal_local_file_in_folder", { path: filePath }); +} + +/** + * @param {string} text + * @param {string} defaultName + * @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>} + */ +export async function exportTextFile(text, defaultName) { + ensureTauri(); + const dest = await save({ + title: "保存文本", + defaultPath: defaultName, + filters: [{ name: "文本", extensions: ["txt"] }], + }); + if (dest == null) { + return { ok: false, cancelled: true }; + } + await invoke("write_local_text_file", { + path: String(dest), + content: String(text ?? ""), + }); + return { ok: true }; +} diff --git a/src/stores/voicePage.js b/src/stores/voicePage.js index b71271a..cc41085 100644 --- a/src/stores/voicePage.js +++ b/src/stores/voicePage.js @@ -18,7 +18,6 @@ import { getVoiceDisplayLabel } from "../utils/voiceLabel.js"; export const useVoicePageStore = defineStore("voicePage", { state: () => ({ sideTab: "synthesis", - workspaceTab: "workspace", mobileSidebarOpen: false, synthesisText: "", diff --git a/src/views/VoiceView.vue b/src/views/VoiceView.vue index 18b213e..603f75a 100644 --- a/src/views/VoiceView.vue +++ b/src/views/VoiceView.vue @@ -6,11 +6,16 @@ import { TTS_MODEL_OPTIONS } from "../config/ttsModels.js"; import VoiceManageDialog from "../components/workflow/VoiceManageDialog.vue"; import BatchTextSynthesizeDialog from "../components/voice/BatchTextSynthesizeDialog.vue"; import { pickAsrMediaFile } from "../services/soundMedia.js"; +import { + exportLocalFile, + exportTextFile, + revealLocalFileInFolder, + suggestExportName, +} from "../services/fileExport.js"; const voicePage = useVoicePageStore(); const { sideTab, - workspaceTab, mobileSidebarOpen, synthesisText, selectedVoiceId, @@ -96,6 +101,26 @@ async function onClearHistory() { } } +async function onDeleteSynthesis(item) { + if (!window.confirm("确认删除?")) return; + try { + await voicePage.deleteSynthesisItem(item); + showFeedback("success", "已删除"); + } catch (err) { + showFeedback("error", err instanceof Error ? err.message : String(err)); + } +} + +async function onDeleteAsr(item) { + if (!window.confirm("确认删除?")) return; + try { + await voicePage.deleteAsrItem(item); + showFeedback("success", "已删除"); + } catch (err) { + showFeedback("error", err instanceof Error ? err.message : String(err)); + } +} + function formatDuration(sec) { const s = Math.max(0, Math.round(Number(sec) || 0)); if (s < 60) return `${s}秒`; @@ -133,22 +158,42 @@ async function copyText(text) { } } -function downloadAudio(item) { - if (!item.audioSrc) return; - const a = document.createElement("a"); - a.href = item.audioSrc; - a.download = `${item.name || "audio"}.mp3`; - a.click(); +async function openAudioInExplorer(item) { + if (!item.filePath) { + showFeedback("error", "音频文件不存在"); + return; + } + try { + await revealLocalFileInFolder(item.filePath); + } catch (err) { + showFeedback("error", err instanceof Error ? err.message : String(err)); + } } -function downloadText(item) { - const blob = new Blob([item.resultText || ""], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${item.name || "asr"}.txt`; - a.click(); - URL.revokeObjectURL(url); +async function downloadAudio(item) { + if (!item.filePath) { + showFeedback("error", "音频文件不存在"); + return; + } + try { + const defaultName = suggestExportName(item.name, item.filePath, "mp3"); + const result = await exportLocalFile(item.filePath, defaultName); + if (result.cancelled) return; + showFeedback("success", "已保存"); + } catch (err) { + showFeedback("error", err instanceof Error ? err.message : String(err)); + } +} + +async function downloadText(item) { + try { + const defaultName = suggestExportName(item.name, "", "txt"); + const result = await exportTextFile(item.resultText, defaultName); + if (result.cancelled) return; + showFeedback("success", "已保存"); + } catch (err) { + showFeedback("error", err instanceof Error ? err.message : String(err)); + } } const editingId = ref(null); @@ -375,32 +420,7 @@ async function commitRenameAsr(item) { @@ -647,7 +673,7 @@ async function commitRenameAsr(item) { size="small" severity="danger" outlined - @click="window.confirm('确认删除?') && voicePage.deleteAsrItem(item)" + @click="onDeleteAsr(item)" />