Files
yaoayanui/src/components/workflow/VoiceManageDialog.vue
949036910@qq.com 30e019d345 11
2026-05-29 23:16:42 +08:00

360 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, watch } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import {
VOICE_CATEGORY_OPTIONS,
filterSystemVoices,
} from "../../data/systemVoices.js";
import {
listCloneVoices,
deleteCloneVoice,
} from "../../services/soundPromptStorage.js";
import { previewVoice } from "../../services/voicePreview.js";
import { stripVoiceIdPrefix } from "../../utils/voiceIdPrefix.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: 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");
const cloneVoices = ref([]);
const cloneLoading = ref(false);
const editDialogRef = ref(null);
const previewingKey = ref(null);
const previewPanelKey = ref(null);
const previewAudioSrc = ref("");
const previewError = ref("");
const audioRef = ref(null);
const filteredSystemVoices = computed(() =>
filterSystemVoices(voiceCategory.value),
);
function loadCloneVoices() {
cloneLoading.value = true;
try {
cloneVoices.value = listCloneVoices();
} finally {
cloneLoading.value = false;
}
}
watch(dialogVisible, (open) => {
if (open) {
activeTab.value = "system";
voiceCategory.value = "putonghua";
loadCloneVoices();
} else {
stopPreview();
}
});
function stopPreview() {
previewingKey.value = null;
previewPanelKey.value = null;
previewAudioSrc.value = "";
previewError.value = "";
if (audioRef.value) {
audioRef.value.pause();
audioRef.value.removeAttribute("src");
}
}
function onClose() {
if (props.standalone) {
dialogVisible.value = false;
} else {
workflow.closeVoiceManageDialog();
}
}
function onSelectVoice(voiceId) {
if (props.standalone) {
currentVoiceId.value = voiceId;
emit("select", voiceId);
dialogVisible.value = false;
} else {
workflow.selectVoice(voiceId);
}
}
function onAddClone() {
editDialogRef.value?.openAdd();
}
function onEditClone(record) {
editDialogRef.value?.openEdit(record);
}
function onDeleteClone(record) {
if (!window.confirm(`确认删除音色「${record.title}」?`)) return;
deleteCloneVoice(record.id);
if (currentVoiceId.value === record.id) {
if (props.standalone) {
currentVoiceId.value = "sys_Cherry";
} else {
workflow.selectVoice("sys_Cherry");
}
}
if (previewPanelKey.value === record.id) {
stopPreview();
}
loadCloneVoices();
}
async function runPreview(opts) {
previewError.value = "";
previewingKey.value = opts.listKey;
const result = await previewVoice(opts);
previewingKey.value = null;
if (!result.ok) {
previewError.value = result.message;
return;
}
previewPanelKey.value = opts.listKey;
previewAudioSrc.value = result.audioSrc;
requestAnimationFrame(() => {
audioRef.value?.play()?.catch(() => {});
});
}
function onPreviewSystem(voice) {
runPreview({
listKey: voice.id,
title: voice.title,
aliyunVoiceId: voice.aliyunVoiceId,
});
}
function onPreviewClone(item) {
const voiceId = String(item.content?.aliyunVoiceId || "").trim();
if (!voiceId) {
previewError.value = "请先配置音色 ID 后再试听";
return;
}
runPreview({
listKey: item.id,
title: item.title,
aliyunVoiceId: voiceId,
});
}
function isPreviewing(key) {
return previewingKey.value === key;
}
function showPreviewPlayer(key) {
return previewPanelKey.value === key && previewAudioSrc.value;
}
</script>
<template>
<Dialog
v-model:visible="dialogVisible"
modal
header="音色管理"
:style="{ width: '900px' }"
:draggable="false"
class="voice-manage-dialog"
@hide="onClose"
>
<audio ref="audioRef" class="hidden" :src="previewAudioSrc || undefined" />
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="system">系统音色</Tab>
<Tab value="clone">克隆音色</Tab>
</TabList>
<TabPanels>
<TabPanel value="system">
<SelectButton
v-model="voiceCategory"
:options="VOICE_CATEGORY_OPTIONS"
option-label="label"
option-value="key"
size="small"
class="mb-3"
/>
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<div
v-for="voice in filteredSystemVoices"
:key="voice.id"
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div
class="inline-flex max-w-full flex-wrap items-center gap-x-2 rounded-full bg-white/5 px-2 py-1"
>
<span class="text-sm text-slate-100">{{ voice.title }}</span>
<span class="text-xs text-slate-500">{{ voice.desc }}</span>
</div>
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(voice.id)"
:disabled="Boolean(previewingKey && !isPreviewing(voice.id))"
@click="onPreviewSystem(voice)"
/>
<Button
label="选择"
size="small"
icon="pi pi-check"
@click="onSelectVoice(voice.id)"
/>
</div>
</div>
<div
v-if="showPreviewPlayer(voice.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio
:src="previewAudioSrc"
controls
class="h-8 w-full"
autoplay
/>
</div>
</div>
<p
v-if="filteredSystemVoices.length === 0"
class="py-8 text-center text-sm text-slate-500"
>
暂无音色
</p>
</div>
</TabPanel>
<TabPanel value="clone">
<div class="mb-3 flex justify-end">
<Button
label="添加克隆音色"
size="small"
icon="pi pi-plus"
@click="onAddClone"
/>
</div>
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<p v-if="cloneLoading" class="py-6 text-center text-sm text-slate-500">加载中...</p>
<p
v-else-if="cloneVoices.length === 0"
class="py-8 text-center text-sm text-slate-500"
>
暂无克隆音色点击右上角添加
</p>
<div
v-for="item in cloneVoices"
:key="item.id"
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-100">
{{ item.title }}
</div>
<p v-if="item.content?.promptText" class="mt-1 text-xs text-slate-500">
{{ item.content.promptText }}
</p>
<p
v-if="item.content?.aliyunVoiceId"
class="mt-1 text-xs text-blue-400"
>
音色ID: {{ stripVoiceIdPrefix(item.content.aliyunVoiceId) }}
</p>
<p v-else class="mt-1 text-xs text-amber-500/90">
未配置音色ID将使用默认音色
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(item.id)"
:disabled="
!item.content?.aliyunVoiceId ||
Boolean(previewingKey && !isPreviewing(item.id))
"
@click="onPreviewClone(item)"
/>
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
aria-label="编辑"
@click="onEditClone(item)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
aria-label="删除"
@click="onDeleteClone(item)"
/>
</div>
</div>
<div
v-if="showPreviewPlayer(item.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio :src="previewAudioSrc" controls class="h-8 w-full" autoplay />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<p v-if="previewError" class="mt-2 text-sm text-red-400">{{ previewError }}</p>
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
</Dialog>
</template>