This commit is contained in:
fengchuanhn@gmail.com
2026-05-25 01:08:30 +08:00
parent 8293ebf489
commit d4e017bd6d
7 changed files with 339 additions and 46 deletions

101
src/services/fileExport.js Normal file
View 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 };
}

View File

@@ -18,7 +18,6 @@ import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
export const useVoicePageStore = defineStore("voicePage", {
state: () => ({
sideTab: "synthesis",
workspaceTab: "workspace",
mobileSidebarOpen: false,
synthesisText: "",

View File

@@ -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>