360 lines
11 KiB
Vue
360 lines
11 KiB
Vue
<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>
|