11
This commit is contained in:
119
src/components/voice/BatchTextSynthesizeDialog.vue
Normal file
119
src/components/voice/BatchTextSynthesizeDialog.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const visible = defineModel("visible", { type: Boolean, default: false });
|
||||
const emit = defineEmits(["submit"]);
|
||||
|
||||
const lines = ref([{ text: "" }]);
|
||||
const pasteVisible = ref(false);
|
||||
const pasteRaw = ref("");
|
||||
|
||||
function addLine() {
|
||||
lines.value.push({ text: "" });
|
||||
}
|
||||
|
||||
function removeLine(index) {
|
||||
lines.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function openPaste() {
|
||||
pasteRaw.value = "";
|
||||
pasteVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmPaste() {
|
||||
const items = pasteRaw.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (items.length) {
|
||||
lines.value = items.map((text) => ({ text }));
|
||||
}
|
||||
pasteVisible.value = false;
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const payload = lines.value.map((l) => ({ text: String(l.text || "").trim() }));
|
||||
if (!payload.length || payload.some((l) => !l.text)) {
|
||||
window.alert("所有内容不能为空");
|
||||
return;
|
||||
}
|
||||
emit("submit", payload);
|
||||
visible.value = false;
|
||||
lines.value = [{ text: "" }];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
header="批量文本合成"
|
||||
:style="{ width: '80vw', maxWidth: '960px' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Button label="添加一条" size="small" icon="pi pi-plus" @click="addLine" />
|
||||
<Button
|
||||
label="批量粘贴"
|
||||
size="small"
|
||||
icon="pi pi-clone"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openPaste"
|
||||
/>
|
||||
<span class="text-sm text-slate-500">共 {{ lines.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!lines.length" class="py-12 text-center text-sm text-slate-500">
|
||||
暂无内容
|
||||
</div>
|
||||
<div v-else class="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(line, index) in lines"
|
||||
:key="index"
|
||||
class="flex gap-2 rounded-lg border border-white/10 bg-white/5 p-3"
|
||||
>
|
||||
<Textarea
|
||||
v-model="line.text"
|
||||
rows="2"
|
||||
auto-resize
|
||||
class="flex-1"
|
||||
placeholder="输入内容"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
aria-label="删除"
|
||||
@click="removeLine(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="visible = false" />
|
||||
<Button label="提交合成" @click="onSubmit" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="pasteVisible"
|
||||
modal
|
||||
header="批量粘贴,每行一条"
|
||||
:style="{ width: '70vw', maxWidth: '720px' }"
|
||||
>
|
||||
<Textarea
|
||||
v-model="pasteRaw"
|
||||
rows="12"
|
||||
class="w-full"
|
||||
placeholder="批量粘贴,每行一个"
|
||||
/>
|
||||
<template #footer>
|
||||
<Button label="确定" @click="confirmPaste" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -13,8 +13,44 @@ import {
|
||||
import { previewVoice } from "../../services/voicePreview.js";
|
||||
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
/** 声音页等独立场景:由父组件控制显隐与选中音色 */
|
||||
standalone: { type: Boolean, default: false },
|
||||
visible: { type: Boolean, default: false },
|
||||
selectedVoiceId: { type: String, default: "sys_Cherry" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "update:selectedVoiceId", "select"]);
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { voiceManageModalVisible, selectedVoiceId } = storeToRefs(workflow);
|
||||
const { voiceManageModalVisible, selectedVoiceId: workflowVoiceId } =
|
||||
storeToRefs(workflow);
|
||||
|
||||
const dialogVisible = computed({
|
||||
get() {
|
||||
return props.standalone ? props.visible : voiceManageModalVisible.value;
|
||||
},
|
||||
set(v) {
|
||||
if (props.standalone) {
|
||||
emit("update:visible", v);
|
||||
} else {
|
||||
voiceManageModalVisible.value = v;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const currentVoiceId = computed({
|
||||
get() {
|
||||
return props.standalone ? props.selectedVoiceId : workflowVoiceId.value;
|
||||
},
|
||||
set(v) {
|
||||
if (props.standalone) {
|
||||
emit("update:selectedVoiceId", v);
|
||||
} else {
|
||||
workflowVoiceId.value = v;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const activeTab = ref("system");
|
||||
const voiceCategory = ref("putonghua");
|
||||
@@ -41,7 +77,7 @@ function loadCloneVoices() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(voiceManageModalVisible, (open) => {
|
||||
watch(dialogVisible, (open) => {
|
||||
if (open) {
|
||||
activeTab.value = "system";
|
||||
voiceCategory.value = "putonghua";
|
||||
@@ -63,11 +99,21 @@ function stopPreview() {
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
workflow.closeVoiceManageDialog();
|
||||
if (props.standalone) {
|
||||
dialogVisible.value = false;
|
||||
} else {
|
||||
workflow.closeVoiceManageDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectVoice(voiceId) {
|
||||
workflow.selectVoice(voiceId);
|
||||
if (props.standalone) {
|
||||
currentVoiceId.value = voiceId;
|
||||
emit("select", voiceId);
|
||||
dialogVisible.value = false;
|
||||
} else {
|
||||
workflow.selectVoice(voiceId);
|
||||
}
|
||||
}
|
||||
|
||||
function onAddClone() {
|
||||
@@ -81,8 +127,12 @@ function onEditClone(record) {
|
||||
function onDeleteClone(record) {
|
||||
if (!window.confirm(`确认删除音色「${record.title}」?`)) return;
|
||||
deleteCloneVoice(record.id);
|
||||
if (selectedVoiceId.value === record.id) {
|
||||
workflow.selectVoice("sys_Cherry");
|
||||
if (currentVoiceId.value === record.id) {
|
||||
if (props.standalone) {
|
||||
currentVoiceId.value = "sys_Cherry";
|
||||
} else {
|
||||
workflow.selectVoice("sys_Cherry");
|
||||
}
|
||||
}
|
||||
if (previewPanelKey.value === record.id) {
|
||||
stopPreview();
|
||||
@@ -144,7 +194,7 @@ function showPreviewPlayer(key) {
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="voiceManageModalVisible"
|
||||
v-model:visible="dialogVisible"
|
||||
modal
|
||||
header="音色管理"
|
||||
:style="{ width: '900px' }"
|
||||
|
||||
8
src/config/ttsModels.js
Normal file
8
src/config/ttsModels.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 语音合成模型选项(对齐 Electron CosyVoice 版本说明) */
|
||||
export const TTS_MODEL_OPTIONS = [
|
||||
{
|
||||
label: "V1 多语言/方言",
|
||||
value: "cosyvoice-v3-flash",
|
||||
hint: "选择他国语言需要文案也对应他国,选择方言需对应匹配的音色或克隆音色本身是方言",
|
||||
},
|
||||
];
|
||||
@@ -50,7 +50,7 @@ const mainChildren = [
|
||||
|
||||
name: "voice",
|
||||
|
||||
component: placeholder,
|
||||
component: () => import("../views/VoiceView.vue"),
|
||||
|
||||
meta: { title: "声音" },
|
||||
|
||||
|
||||
47
src/services/asrDb.js
Normal file
47
src/services/asrDb.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("语音识别需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, sourcePath: string, resultText: string, createdAt: string }} AsrHistoryRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<AsrHistoryRecord[]>} */
|
||||
export async function listAsrHistory() {
|
||||
ensureTauri();
|
||||
return invoke("list_asr_history");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @param {string} [model]
|
||||
* @returns {Promise<AsrHistoryRecord>}
|
||||
*/
|
||||
export async function transcribeMediaFile(filePath, model) {
|
||||
ensureTauri();
|
||||
return invoke("transcribe_media_file", {
|
||||
filePath,
|
||||
model: model || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteAsrHistory(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_asr_history", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateAsrHistoryName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_asr_history_name", { id, name });
|
||||
}
|
||||
@@ -7,7 +7,7 @@ function ensureTauri() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} GeneratedAudioRecord
|
||||
* @typedef {{ id: number, name: string, filePath: string, sourceText: string, createdAt: string }} GeneratedAudioRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<GeneratedAudioRecord[]>} */
|
||||
@@ -19,11 +19,25 @@ export async function listGeneratedAudios() {
|
||||
/**
|
||||
* @param {string} name 可为空,空时由后端填入当前时间
|
||||
* @param {string} filePath
|
||||
* @param {string} [sourceText]
|
||||
* @returns {Promise<GeneratedAudioRecord>}
|
||||
*/
|
||||
export async function insertGeneratedAudio(name, filePath) {
|
||||
export async function insertGeneratedAudio(name, filePath, sourceText = "") {
|
||||
ensureTauri();
|
||||
return invoke("insert_generated_audio", { name, filePath });
|
||||
return invoke("insert_generated_audio", {
|
||||
name,
|
||||
filePath,
|
||||
sourceText: sourceText || "",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateGeneratedAudioName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_generated_audio_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
src/services/soundMedia.js
Normal file
24
src/services/soundMedia.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickAsrMediaFile() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
title: "选择音频/视频文件",
|
||||
filters: [
|
||||
{
|
||||
name: "媒体文件",
|
||||
extensions: ["mp3", "wav", "flac", "aac", "m4a", "mp4", "avi", "mov", "mkv"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
82
src/services/soundSynthesize.js
Normal file
82
src/services/soundSynthesize.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
|
||||
import { insertGeneratedAudio } from "./audioDb.js";
|
||||
|
||||
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* text: string,
|
||||
* selectedVoiceId: string,
|
||||
* voiceLanguage?: string,
|
||||
* voiceEmotion?: string,
|
||||
* speechRate?: number,
|
||||
* }} opts
|
||||
*/
|
||||
export async function synthesizeSpeech(opts) {
|
||||
const text = String(opts.text || "").trim();
|
||||
if (!text) {
|
||||
return { ok: false, message: "请输入合成内容" };
|
||||
}
|
||||
if (!opts.selectedVoiceId) {
|
||||
return { ok: false, message: "请选择音色" };
|
||||
}
|
||||
|
||||
const cfg = await ensureAppConfigReady();
|
||||
if (!cfg.ok) {
|
||||
return { ok: false, message: cfg.message };
|
||||
}
|
||||
|
||||
const voice = resolveAliyunVoiceId(opts.selectedVoiceId);
|
||||
const instruction = buildTtsInstruction(
|
||||
opts.voiceLanguage || "中文(普通话)",
|
||||
opts.selectedVoiceId,
|
||||
opts.voiceEmotion || "自然",
|
||||
opts.speechRate ?? 1,
|
||||
);
|
||||
const languageType = buildTtsLanguageType(opts.voiceLanguage || "中文(普通话)");
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: VOICE_GENERATE_SCRIPT,
|
||||
params: {
|
||||
text,
|
||||
voice,
|
||||
instruction,
|
||||
languageType: languageType || null,
|
||||
format: "mp3",
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.audioPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "语音合成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const title = truncateTitle(text, 32);
|
||||
const displayName = `${getVoiceDisplayLabel(opts.selectedVoiceId)} · ${formatHistoryTime()}`;
|
||||
const record = await insertGeneratedAudio(displayName, result.audioPath, text);
|
||||
|
||||
return { ok: true, record, title };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `语音合成失败: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTitle(text, max) {
|
||||
const t = String(text).replace(/\s+/g, " ");
|
||||
if (t.length <= max) return t;
|
||||
return `${t.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
@@ -1,96 +1,96 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
|
||||
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||
|
||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||
|
||||
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
|
||||
|
||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||
|
||||
import { insertGeneratedAudio } from "./audioDb.js";
|
||||
|
||||
|
||||
|
||||
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 生成语音(对齐 Electron generateAudio CosyVoice)
|
||||
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
|
||||
*/
|
||||
|
||||
export async function executeGenerateSpeech(store) {
|
||||
|
||||
const text = String(store.scriptContent || "").trim();
|
||||
|
||||
if (!text) {
|
||||
|
||||
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!store.selectedVoiceId) {
|
||||
|
||||
return { ok: false, message: "请先选择音色" };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const cfg = await ensureAppConfigReady();
|
||||
|
||||
if (!cfg.ok) {
|
||||
|
||||
return { ok: false, message: cfg.message };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
|
||||
|
||||
const instruction = buildTtsInstruction(
|
||||
|
||||
store.voiceLanguage,
|
||||
|
||||
store.selectedVoiceId,
|
||||
|
||||
store.voiceEmotion,
|
||||
|
||||
store.speechRate,
|
||||
|
||||
);
|
||||
|
||||
const languageType = buildTtsLanguageType(store.voiceLanguage);
|
||||
|
||||
|
||||
|
||||
store.speechGenerating = true;
|
||||
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
|
||||
scriptName: VOICE_GENERATE_SCRIPT,
|
||||
|
||||
params: {
|
||||
|
||||
text,
|
||||
|
||||
voice,
|
||||
|
||||
instruction,
|
||||
|
||||
languageType: languageType || null,
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
|
||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||
import { insertGeneratedAudio } from "./audioDb.js";
|
||||
|
||||
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
|
||||
|
||||
/**
|
||||
* 生成语音(对齐 Electron generateAudio CosyVoice)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateSpeech(store) {
|
||||
const text = String(store.scriptContent || "").trim();
|
||||
if (!text) {
|
||||
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
|
||||
}
|
||||
|
||||
if (!store.selectedVoiceId) {
|
||||
return { ok: false, message: "请先选择音色" };
|
||||
}
|
||||
|
||||
const cfg = await ensureAppConfigReady();
|
||||
if (!cfg.ok) {
|
||||
return { ok: false, message: cfg.message };
|
||||
}
|
||||
|
||||
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
|
||||
const instruction = buildTtsInstruction(
|
||||
store.voiceLanguage,
|
||||
store.selectedVoiceId,
|
||||
store.voiceEmotion,
|
||||
store.speechRate,
|
||||
);
|
||||
const languageType = buildTtsLanguageType(store.voiceLanguage);
|
||||
|
||||
store.speechGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: VOICE_GENERATE_SCRIPT,
|
||||
params: {
|
||||
text,
|
||||
voice,
|
||||
instruction,
|
||||
languageType: languageType || null,
|
||||
format: "mp3",
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.audioPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "语音生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const displayName = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
|
||||
const record = await insertGeneratedAudio(displayName, result.audioPath, text);
|
||||
const audioSrc = await localAudioToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedAudioPath = record.filePath;
|
||||
store.generatedAudioSrc = audioSrc;
|
||||
store.currentAudioId = record.id;
|
||||
await store.refreshAudioHistory();
|
||||
|
||||
return { ok: true, message: "语音生成完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `语音生成失败: ${msg}` };
|
||||
} finally {
|
||||
store.speechGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
* @param {number} audioId
|
||||
*/
|
||||
export async function selectAudioHistory(store, audioId) {
|
||||
const item = store.audioHistory.find((h) => h.id === audioId);
|
||||
if (!item) return;
|
||||
store.currentAudioId = item.id;
|
||||
store.generatedAudioPath = item.filePath;
|
||||
store.generatedAudioSrc =
|
||||
item.audioSrc || (await localAudioToPlayableUrl(item.filePath));
|
||||
}
|
||||
|
||||
247
src/stores/voicePage.js
Normal file
247
src/stores/voicePage.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||
import {
|
||||
listGeneratedAudios,
|
||||
deleteGeneratedAudio,
|
||||
updateGeneratedAudioName,
|
||||
} from "../services/audioDb.js";
|
||||
import {
|
||||
listAsrHistory,
|
||||
deleteAsrHistory,
|
||||
updateAsrHistoryName,
|
||||
transcribeMediaFile,
|
||||
} from "../services/asrDb.js";
|
||||
import { synthesizeSpeech } from "../services/soundSynthesize.js";
|
||||
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||
import { getMediaDurationSeconds } from "../services/mediaDuration.js";
|
||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||
|
||||
export const useVoicePageStore = defineStore("voicePage", {
|
||||
state: () => ({
|
||||
sideTab: "synthesis",
|
||||
workspaceTab: "workspace",
|
||||
mobileSidebarOpen: false,
|
||||
|
||||
synthesisText: "",
|
||||
selectedVoiceId: "sys_Cherry",
|
||||
voiceLanguage: "中文(普通话)",
|
||||
voiceEmotion: "自然",
|
||||
speechRate: 1,
|
||||
ttsModel: "cosyvoice-v3-flash",
|
||||
synthesizing: false,
|
||||
|
||||
asrFilePath: "",
|
||||
asrRecognizing: false,
|
||||
asrModel: "qwen3-asr-flash-filetrans",
|
||||
|
||||
voiceManageVisible: false,
|
||||
|
||||
/** @type {Array<{ id: number, name: string, filePath: string, sourceText: string, createdAt: string, audioSrc?: string, durationSec?: number, _check?: boolean }>} */
|
||||
synthesisHistory: [],
|
||||
synthesisLoading: false,
|
||||
|
||||
/** @type {Array<{ id: number, name: string, sourcePath: string, resultText: string, createdAt: string, _check?: boolean }>} */
|
||||
asrHistory: [],
|
||||
asrLoading: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
selectedVoiceLabel: (state) => getVoiceDisplayLabel(state.selectedVoiceId),
|
||||
voiceEmotions: () => ["自然", "热情", "沉稳"],
|
||||
languages: () => [
|
||||
"中文(普通话)",
|
||||
"中文(粤语)",
|
||||
"English",
|
||||
"日语",
|
||||
"韩语",
|
||||
"法语",
|
||||
"德语",
|
||||
"西班牙语",
|
||||
],
|
||||
checkedSynthesisItems: (state) =>
|
||||
state.synthesisHistory.filter((i) => i._check),
|
||||
checkedAsrItems: (state) => state.asrHistory.filter((i) => i._check),
|
||||
synthesisAllChecked: (state) => {
|
||||
const list = state.synthesisHistory;
|
||||
return list.length > 0 && list.every((i) => i._check);
|
||||
},
|
||||
asrAllChecked: (state) => {
|
||||
const list = state.asrHistory;
|
||||
return list.length > 0 && list.every((i) => i._check);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async refreshSynthesisHistory() {
|
||||
this.synthesisLoading = true;
|
||||
try {
|
||||
const rows = await listGeneratedAudios();
|
||||
const enriched = await Promise.all(
|
||||
rows.map(async (item) => {
|
||||
let audioSrc = "";
|
||||
let durationSec = 0;
|
||||
try {
|
||||
audioSrc = await localAudioToPlayableUrl(item.filePath);
|
||||
durationSec = await getMediaDurationSeconds(item.filePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
audioSrc,
|
||||
durationSec,
|
||||
_check: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
this.synthesisHistory = enriched;
|
||||
} finally {
|
||||
this.synthesisLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAsrHistory() {
|
||||
this.asrLoading = true;
|
||||
try {
|
||||
this.asrHistory = (await listAsrHistory()).map((item) => ({
|
||||
...item,
|
||||
_check: false,
|
||||
}));
|
||||
} finally {
|
||||
this.asrLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async startSynthesis() {
|
||||
this.synthesizing = true;
|
||||
try {
|
||||
const result = await synthesizeSpeech({
|
||||
text: this.synthesisText,
|
||||
selectedVoiceId: this.selectedVoiceId,
|
||||
voiceLanguage: this.voiceLanguage,
|
||||
voiceEmotion: this.voiceEmotion,
|
||||
speechRate: this.speechRate,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
this.synthesisText = "";
|
||||
await this.refreshSynthesisHistory();
|
||||
return { ok: true, message: "语音合成成功" };
|
||||
} finally {
|
||||
this.synthesizing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async batchSynthesize(lines) {
|
||||
const texts = lines.map((l) => String(l.text || "").trim()).filter(Boolean);
|
||||
if (!texts.length) {
|
||||
return { ok: false, message: "请添加合成内容" };
|
||||
}
|
||||
this.synthesizing = true;
|
||||
try {
|
||||
for (const text of texts) {
|
||||
const result = await synthesizeSpeech({
|
||||
text,
|
||||
selectedVoiceId: this.selectedVoiceId,
|
||||
voiceLanguage: this.voiceLanguage,
|
||||
voiceEmotion: this.voiceEmotion,
|
||||
speechRate: this.speechRate,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
await this.refreshSynthesisHistory();
|
||||
return { ok: true, message: "批量合成完成" };
|
||||
} finally {
|
||||
this.synthesizing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async startAsr() {
|
||||
if (!this.asrFilePath) {
|
||||
return { ok: false, message: "请选择音频/视频文件" };
|
||||
}
|
||||
this.asrRecognizing = true;
|
||||
try {
|
||||
await transcribeMediaFile(this.asrFilePath, this.asrModel);
|
||||
this.asrFilePath = "";
|
||||
await this.refreshAsrHistory();
|
||||
return { ok: true, message: "语音识别完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg || "识别失败" };
|
||||
} finally {
|
||||
this.asrRecognizing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async renameSynthesisItem(item, name) {
|
||||
await updateGeneratedAudioName(item.id, name);
|
||||
await this.refreshSynthesisHistory();
|
||||
},
|
||||
|
||||
async deleteSynthesisItem(item) {
|
||||
await deleteGeneratedAudio(item.id);
|
||||
await this.refreshSynthesisHistory();
|
||||
},
|
||||
|
||||
async renameAsrItem(item, name) {
|
||||
await updateAsrHistoryName(item.id, name);
|
||||
await this.refreshAsrHistory();
|
||||
},
|
||||
|
||||
async deleteAsrItem(item) {
|
||||
await deleteAsrHistory(item.id);
|
||||
await this.refreshAsrHistory();
|
||||
},
|
||||
|
||||
async deleteCheckedSynthesis() {
|
||||
const items = this.checkedSynthesisItems;
|
||||
if (!items.length) return;
|
||||
for (const item of items) {
|
||||
await deleteGeneratedAudio(item.id);
|
||||
}
|
||||
await this.refreshSynthesisHistory();
|
||||
},
|
||||
|
||||
async deleteCheckedAsr() {
|
||||
const items = this.checkedAsrItems;
|
||||
if (!items.length) return;
|
||||
for (const item of items) {
|
||||
await deleteAsrHistory(item.id);
|
||||
}
|
||||
await this.refreshAsrHistory();
|
||||
},
|
||||
|
||||
toggleSynthesisCheckAll(checked) {
|
||||
this.synthesisHistory.forEach((i) => {
|
||||
i._check = checked;
|
||||
});
|
||||
},
|
||||
|
||||
toggleAsrCheckAll(checked) {
|
||||
this.asrHistory.forEach((i) => {
|
||||
i._check = checked;
|
||||
});
|
||||
},
|
||||
|
||||
async clearCurrentHistory() {
|
||||
if (this.sideTab === "synthesis") {
|
||||
for (const item of [...this.synthesisHistory]) {
|
||||
await deleteGeneratedAudio(item.id);
|
||||
}
|
||||
await this.refreshSynthesisHistory();
|
||||
} else {
|
||||
for (const item of [...this.asrHistory]) {
|
||||
await deleteAsrHistory(item.id);
|
||||
}
|
||||
await this.refreshAsrHistory();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useVoicePageStore, import.meta.hot));
|
||||
}
|
||||
665
src/views/VoiceView.vue
Normal file
665
src/views/VoiceView.vue
Normal file
@@ -0,0 +1,665 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useVoicePageStore } from "../stores/voicePage.js";
|
||||
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";
|
||||
|
||||
const voicePage = useVoicePageStore();
|
||||
const {
|
||||
sideTab,
|
||||
workspaceTab,
|
||||
mobileSidebarOpen,
|
||||
synthesisText,
|
||||
selectedVoiceId,
|
||||
voiceLanguage,
|
||||
voiceEmotion,
|
||||
speechRate,
|
||||
synthesizing,
|
||||
asrFilePath,
|
||||
asrRecognizing,
|
||||
voiceManageVisible,
|
||||
synthesisHistory,
|
||||
synthesisLoading,
|
||||
asrHistory,
|
||||
asrLoading,
|
||||
} = storeToRefs(voicePage);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const batchVisible = ref(false);
|
||||
|
||||
const ttsModelHint = computed(
|
||||
() => TTS_MODEL_OPTIONS.find((m) => m.value === voicePage.ttsModel)?.hint || "",
|
||||
);
|
||||
|
||||
const synthesisIndeterminate = computed(() => {
|
||||
const n = voicePage.checkedSynthesisItems.length;
|
||||
return n > 0 && n < synthesisHistory.value.length;
|
||||
});
|
||||
|
||||
const asrIndeterminate = computed(() => {
|
||||
const n = voicePage.checkedAsrItems.length;
|
||||
return n > 0 && n < asrHistory.value.length;
|
||||
});
|
||||
|
||||
const asrFileLabel = computed(() => {
|
||||
if (!asrFilePath.value) return "选择音频/视频文件";
|
||||
const name = asrFilePath.value.replace(/\\/g, "/").split("/").pop();
|
||||
return name || asrFilePath.value;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await voicePage.refreshSynthesisHistory();
|
||||
await voicePage.refreshAsrHistory();
|
||||
});
|
||||
|
||||
function showFeedback(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
async function onSynthesize() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await voicePage.startSynthesis();
|
||||
showFeedback(result.ok ? "success" : "error", result.message || "");
|
||||
}
|
||||
|
||||
async function onBatchSubmit(lines) {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await voicePage.batchSynthesize(lines);
|
||||
showFeedback(result.ok ? "success" : "error", result.message || "");
|
||||
}
|
||||
|
||||
async function onAsr() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await voicePage.startAsr();
|
||||
showFeedback(result.ok ? "success" : "error", result.message || "");
|
||||
}
|
||||
|
||||
async function onPickAsrFile() {
|
||||
try {
|
||||
const path = await pickAsrMediaFile();
|
||||
if (path) asrFilePath.value = path;
|
||||
} catch (err) {
|
||||
showFeedback("error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearHistory() {
|
||||
if (!window.confirm("确认清空当前列表?此操作不可恢复。")) return;
|
||||
try {
|
||||
await voicePage.clearCurrentHistory();
|
||||
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}秒`;
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return r ? `${m}分${r}秒` : `${m}分钟`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso) {
|
||||
if (!iso) return "";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return "";
|
||||
const diff = Date.now() - t;
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days > 0) return `${days} 天前`;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours > 0) return `${hours} 小时前`;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins > 0) return `${mins} 分钟前`;
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
function truncateText(text, max = 120) {
|
||||
const t = String(text || "");
|
||||
if (t.length <= max) return t;
|
||||
return `${t.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text || ""));
|
||||
showFeedback("success", "已复制");
|
||||
} catch {
|
||||
showFeedback("error", "复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAudio(item) {
|
||||
if (!item.audioSrc) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = item.audioSrc;
|
||||
a.download = `${item.name || "audio"}.mp3`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const editingId = ref(null);
|
||||
const editingName = ref("");
|
||||
|
||||
function startRename(item) {
|
||||
editingId.value = item.id;
|
||||
editingName.value = item.name;
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId.value = null;
|
||||
editingName.value = "";
|
||||
}
|
||||
|
||||
async function commitRenameSynthesis(item) {
|
||||
try {
|
||||
await voicePage.renameSynthesisItem(item, editingName.value);
|
||||
cancelRename();
|
||||
} catch (err) {
|
||||
showFeedback("error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function commitRenameAsr(item) {
|
||||
try {
|
||||
await voicePage.renameAsrItem(item, editingName.value);
|
||||
cancelRename();
|
||||
} catch (err) {
|
||||
showFeedback("error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="voice-workbench flex h-full min-h-0 overflow-hidden bg-[#0a0a0a] font-sans text-white"
|
||||
>
|
||||
<!-- 移动端顶栏 -->
|
||||
<div
|
||||
class="fixed left-0 right-0 top-0 z-50 flex h-14 items-center justify-between border-b border-white/10 bg-[#141414] px-4 md:hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-gradient-to-r from-purple-400 to-pink-500 bg-clip-text text-lg font-bold text-transparent"
|
||||
>
|
||||
SOUND:AI
|
||||
</div>
|
||||
<button type="button" class="text-white" @click="mobileSidebarOpen = !mobileSidebarOpen">
|
||||
<i class="pi pi-bars text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 左侧栏 -->
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-80 flex-col border-r border-white/10 bg-[#141414] transition-transform duration-300 md:relative md:translate-x-0"
|
||||
:class="mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="hidden h-16 shrink-0 items-center border-b border-white/10 px-6 md:flex">
|
||||
<span class="text-xl font-bold tracking-wider">
|
||||
SOUND<span class="text-purple-500">:AI</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-2 pt-16 md:pt-6">
|
||||
<div class="flex rounded-lg bg-[#1a1a1a] p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
|
||||
:class="
|
||||
sideTab === 'synthesis'
|
||||
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
"
|
||||
@click="sideTab = 'synthesis'"
|
||||
>
|
||||
<i class="pi pi-volume-up text-sm" />
|
||||
声音合成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
|
||||
:class="
|
||||
sideTab === 'asr'
|
||||
? 'bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
"
|
||||
@click="sideTab = 'asr'"
|
||||
>
|
||||
<i class="pi pi-microphone text-sm" />
|
||||
语音识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-500/40 py-2.5 text-sm font-bold text-purple-300 transition-all hover:border-purple-400 hover:bg-purple-600/20 hover:text-white"
|
||||
@click="voiceManageVisible = true"
|
||||
>
|
||||
<i class="pi pi-sliders-h" />
|
||||
音色管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="custom-scrollbar flex-1 space-y-6 overflow-y-auto px-6 py-4">
|
||||
<!-- 声音合成表单 -->
|
||||
<div
|
||||
v-if="sideTab === 'synthesis'"
|
||||
class="rounded-xl border border-[#333] bg-[#141414] p-4"
|
||||
>
|
||||
<Textarea
|
||||
v-model="synthesisText"
|
||||
rows="4"
|
||||
auto-resize
|
||||
class="mb-3 w-full"
|
||||
placeholder="输入语音内容开始合成"
|
||||
/>
|
||||
|
||||
<div class="mb-3 text-sm font-bold text-gray-200">
|
||||
<i class="pi pi-cog mr-1 text-slate-400" />
|
||||
语音合成模型
|
||||
</div>
|
||||
<Select
|
||||
v-model="voicePage.ttsModel"
|
||||
:options="TTS_MODEL_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
class="mb-2 w-full"
|
||||
/>
|
||||
<p v-if="ttsModelHint" class="mb-3 ml-1 text-xs text-gray-500">{{ ttsModelHint }}</p>
|
||||
|
||||
<div class="mb-3 flex items-center gap-2 text-sm text-gray-200">
|
||||
<i class="pi pi-user text-slate-400" />
|
||||
<Button
|
||||
:label="voicePage.selectedVoiceLabel"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="flex-1 truncate"
|
||||
@click="voiceManageVisible = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500">情绪</label>
|
||||
<Select
|
||||
v-model="voiceEmotion"
|
||||
:options="voicePage.voiceEmotions"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500">语言</label>
|
||||
<Select
|
||||
v-model="voiceLanguage"
|
||||
:options="voicePage.languages"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500">语速</label>
|
||||
<InputNumber
|
||||
v-model="speechRate"
|
||||
:min="0.5"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
label="开始合成"
|
||||
:loading="synthesizing"
|
||||
@click="onSynthesize"
|
||||
/>
|
||||
<Button
|
||||
label="批量文本合成"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="synthesizing"
|
||||
@click="batchVisible = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音识别表单 -->
|
||||
<div v-else class="rounded-xl border border-[#333] bg-[#141414] p-4">
|
||||
<div class="mb-3 text-sm font-bold text-gray-200">
|
||||
<i class="pi pi-file-audio mr-1 text-slate-400" />
|
||||
音频/视频文件
|
||||
</div>
|
||||
<Button
|
||||
:label="asrFileLabel"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="mb-3 w-full truncate"
|
||||
icon="pi pi-folder-open"
|
||||
@click="onPickAsrFile"
|
||||
/>
|
||||
<Button
|
||||
label="开始识别"
|
||||
icon="pi pi-play"
|
||||
:loading="asrRecognizing"
|
||||
@click="onAsr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="mobileSidebarOpen"
|
||||
class="fixed inset-0 z-30 bg-black/80 backdrop-blur-sm md:hidden"
|
||||
@click="mobileSidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 右侧主区 -->
|
||||
<div class="relative z-0 flex min-w-0 flex-1 flex-col bg-[#0a0a0a] pt-14 md:pt-0">
|
||||
<div
|
||||
class="hidden h-16 shrink-0 items-center justify-between border-b border-white/10 px-8 md:flex"
|
||||
>
|
||||
<div class="flex items-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
workspaceTab === 'history'
|
||||
? 'border-purple-500 text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
"
|
||||
@click="workspaceTab = 'history'"
|
||||
>
|
||||
历史任务
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
workspaceTab === 'workspace'
|
||||
? 'border-purple-500 text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
"
|
||||
@click="workspaceTab = 'workspace'"
|
||||
>
|
||||
工作台
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="secondary"
|
||||
text
|
||||
class="text-gray-400 hover:text-red-400"
|
||||
title="清空当前列表"
|
||||
@click="onClearHistory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mx-4 mt-2 text-sm md:mx-6"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<!-- 合成历史 -->
|
||||
<template v-if="sideTab === 'synthesis'">
|
||||
<div
|
||||
v-if="synthesisHistory.length"
|
||||
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="voicePage.synthesisAllChecked"
|
||||
:binary="true"
|
||||
:indeterminate="synthesisIndeterminate"
|
||||
input-id="syn-all"
|
||||
@update:model-value="voicePage.toggleSynthesisCheckAll"
|
||||
/>
|
||||
<label for="syn-all" class="text-sm">全选</label>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="!voicePage.checkedSynthesisItems.length"
|
||||
@click="voicePage.deleteCheckedSynthesis()"
|
||||
/>
|
||||
<span class="ml-auto text-sm text-gray-500">
|
||||
共 {{ synthesisHistory.length }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="synthesisLoading" class="py-16 text-center text-sm text-gray-500">
|
||||
加载中...
|
||||
</p>
|
||||
<div
|
||||
v-else-if="!synthesisHistory.length"
|
||||
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center"
|
||||
>
|
||||
<i class="pi pi-volume-up mb-4 text-5xl text-gray-600" />
|
||||
<h2 class="text-2xl font-bold text-gray-300">准备好创作了吗?</h2>
|
||||
<p class="mt-2 max-w-sm text-gray-500">
|
||||
在左侧输入文本并选择声音,开始您的创作之旅。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4 pb-20">
|
||||
<div
|
||||
v-for="item in synthesisHistory"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
>
|
||||
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
|
||||
<div v-if="editingId !== item.id" class="min-w-0">
|
||||
<span
|
||||
class="block max-w-md cursor-pointer truncate text-sm"
|
||||
:title="item.name"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex gap-1">
|
||||
<InputText v-model="editingName" size="small" />
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
@click="commitRenameSynthesis(item)"
|
||||
/>
|
||||
<Button icon="pi pi-times" size="small" severity="secondary" @click="cancelRename" />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.durationSec"
|
||||
class="rounded-full bg-white/10 px-2 py-1 text-xs"
|
||||
>
|
||||
{{ formatDuration(item.durationSec) }}
|
||||
</span>
|
||||
<Tag severity="success" value="成功" class="ml-auto" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.sourceText"
|
||||
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300"
|
||||
>
|
||||
<span>{{ truncateText(item.sourceText) }}</span>
|
||||
<div class="mt-2 flex gap-1">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="copyText(item.sourceText)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.audioSrc" class="mt-3">
|
||||
<audio :src="item.audioSrc" controls class="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatRelativeTime(item.createdAt) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="downloadAudio(item)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
@click="
|
||||
window.confirm('确认删除?') && voicePage.deleteSynthesisItem(item)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ASR 历史 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="asrHistory.length"
|
||||
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="voicePage.asrAllChecked"
|
||||
:binary="true"
|
||||
:indeterminate="asrIndeterminate"
|
||||
input-id="asr-all"
|
||||
@update:model-value="voicePage.toggleAsrCheckAll"
|
||||
/>
|
||||
<label for="asr-all" class="text-sm">全选</label>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="!voicePage.checkedAsrItems.length"
|
||||
@click="voicePage.deleteCheckedAsr()"
|
||||
/>
|
||||
<span class="ml-auto text-sm text-gray-500">共 {{ asrHistory.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<p v-if="asrLoading" class="py-16 text-center text-sm text-gray-500">加载中...</p>
|
||||
<div
|
||||
v-else-if="!asrHistory.length"
|
||||
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center"
|
||||
>
|
||||
<i class="pi pi-microphone mb-4 text-5xl text-gray-600" />
|
||||
<h2 class="text-2xl font-bold text-gray-300">准备好识别了吗?</h2>
|
||||
<p class="mt-2 max-w-sm text-gray-500">
|
||||
在左侧上传音频或视频文件,开始您的识别之旅。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4 pb-20">
|
||||
<div
|
||||
v-for="item in asrHistory"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
>
|
||||
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
|
||||
<div v-if="editingId !== item.id" class="min-w-0">
|
||||
<span
|
||||
class="block max-w-md cursor-pointer truncate text-sm"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex gap-1">
|
||||
<InputText v-model="editingName" size="small" />
|
||||
<Button icon="pi pi-check" size="small" @click="commitRenameAsr(item)" />
|
||||
<Button icon="pi pi-times" size="small" severity="secondary" @click="cancelRename" />
|
||||
</div>
|
||||
</div>
|
||||
<Tag severity="success" value="成功" class="ml-auto" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.resultText"
|
||||
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300"
|
||||
>
|
||||
{{ truncateText(item.resultText, 300) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatRelativeTime(item.createdAt) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="copyText(item.resultText)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="downloadText(item)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
@click="window.confirm('确认删除?') && voicePage.deleteAsrItem(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VoiceManageDialog
|
||||
standalone
|
||||
v-model:visible="voiceManageVisible"
|
||||
v-model:selected-voice-id="selectedVoiceId"
|
||||
/>
|
||||
<BatchTextSynthesizeDialog v-model:visible="batchVisible" @submit="onBatchSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user