11
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -4178,6 +4178,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"dunce",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -74,3 +74,165 @@ pub async fn get_media_duration_seconds(path: String) -> Result<f64, String> {
|
||||
.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<u16> = 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())?
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
src/services/fileExport.js
Normal file
101
src/services/fileExport.js
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||
export const useVoicePageStore = defineStore("voicePage", {
|
||||
state: () => ({
|
||||
sideTab: "synthesis",
|
||||
workspaceTab: "workspace",
|
||||
mobileSidebarOpen: false,
|
||||
|
||||
synthesisText: "",
|
||||
|
||||
@@ -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) {
|
||||
<div
|
||||
class="hidden shrink-0 items-center justify-between border-b border-white/10 px-6 py-3 md:flex"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 pb-2 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
workspaceTab === 'history'
|
||||
? 'border-purple-500 text-slate-100'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-200'
|
||||
"
|
||||
@click="workspaceTab = 'history'"
|
||||
>
|
||||
历史任务
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 pb-2 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
workspaceTab === 'workspace'
|
||||
? 'border-purple-500 text-slate-100'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-200'
|
||||
"
|
||||
@click="workspaceTab = 'workspace'"
|
||||
>
|
||||
工作台
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="text-sm font-medium text-slate-100">历史任务</h2>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
@@ -529,6 +549,14 @@ async function commitRenameAsr(item) {
|
||||
{{ formatRelativeTime(item.createdAt) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
label="打开"
|
||||
icon="pi pi-folder-open"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openAudioInExplorer(item)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
@@ -541,9 +569,7 @@ async function commitRenameAsr(item) {
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
@click="
|
||||
window.confirm('确认删除?') && voicePage.deleteSynthesisItem(item)
|
||||
"
|
||||
@click="onDeleteSynthesis(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -647,7 +673,7 @@ async function commitRenameAsr(item) {
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
@click="window.confirm('确认删除?') && voicePage.deleteAsrItem(item)"
|
||||
@click="onDeleteAsr(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user