This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 10:09:55 +08:00
parent fb1c3c55d6
commit beb5eaeb42
7 changed files with 605 additions and 1 deletions

View File

@@ -1,9 +1,17 @@
<script setup>
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import VoiceManageDialog from "./VoiceManageDialog.vue";
const workflow = useWorkflowStore();
const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(workflow);
const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
function onOpenVoiceManage() {
workflow.openVoiceManageDialog();
}
</script>
<template>
@@ -11,7 +19,15 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo
<div class="grid grid-cols-3 gap-2">
<div>
<div class="mb-1 text-xs text-slate-200">音色</div>
<Button label="选择音色" size="small" severity="secondary" outlined class="w-full" />
<Button
:label="voiceButtonLabel"
size="small"
severity="secondary"
outlined
class="w-full truncate"
:title="voiceButtonLabel"
@click="onOpenVoiceManage"
/>
</div>
<div>
<div class="mb-1 text-xs text-slate-200">情绪</div>
@@ -72,5 +88,7 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo
<span class="text-xs text-gray-400">暂无视频预览</span>
</div>
</div>
<VoiceManageDialog />
</DashboardCard>
</template>

View File

@@ -0,0 +1,133 @@
<script setup>
import { ref, computed } from "vue";
import {
addCloneVoice,
updateCloneVoice,
cloneVoiceTitleExists,
} from "../../services/soundPromptStorage.js";
const emit = defineEmits(["saved"]);
const visible = ref(false);
const editingId = ref(null);
const name = ref("");
const promptText = ref("");
const aliyunVoiceId = ref("");
const saving = ref(false);
const errorMessage = ref("");
const isEdit = computed(() => Boolean(editingId.value));
function resetForm() {
editingId.value = null;
name.value = "";
promptText.value = "";
aliyunVoiceId.value = "";
errorMessage.value = "";
}
function openAdd() {
resetForm();
visible.value = true;
}
/**
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string } }} record
*/
function openEdit(record) {
editingId.value = record.id;
name.value = record.title || "";
promptText.value = record.content?.promptText || "";
aliyunVoiceId.value = record.content?.aliyunVoiceId || "";
errorMessage.value = "";
visible.value = true;
}
function onCancel() {
visible.value = false;
}
async function onSave() {
errorMessage.value = "";
const title = name.value.trim();
if (!title) {
errorMessage.value = "请输入音色名称";
return;
}
if (cloneVoiceTitleExists(title, editingId.value)) {
errorMessage.value = "名称重复";
return;
}
saving.value = true;
try {
const payload = {
title,
content: {
promptText: promptText.value.trim(),
aliyunVoiceId: aliyunVoiceId.value.trim(),
url: "",
},
};
if (editingId.value) {
updateCloneVoice(editingId.value, payload);
} else {
addCloneVoice(payload);
}
visible.value = false;
emit("saved");
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "保存失败");
} finally {
saving.value = false;
}
}
defineExpose({ openAdd, openEdit });
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:header="isEdit ? '编辑音色' : '添加音色'"
:style="{ width: '520px' }"
:draggable="false"
class="voice-clone-edit-dialog"
>
<div class="space-y-3">
<div>
<div class="dashboard-field-label mb-1">名称 <span class="text-red-400">*</span></div>
<InputText v-model="name" class="w-full" size="small" placeholder="例如:我的克隆音色" />
</div>
<div>
<div class="dashboard-field-label mb-1">参考文字</div>
<InputText
v-model="promptText"
class="w-full"
size="small"
placeholder="可选,部分模型需要参考文字"
/>
<p class="mt-1 text-xs text-slate-500">部分模型需要使用可选填写参考声音的文字内容</p>
</div>
<div>
<div class="dashboard-field-label mb-1">音色 ID可选</div>
<InputText
v-model="aliyunVoiceId"
class="w-full"
size="small"
placeholder="复刻成功后填入,留空则使用默认音色"
/>
<p class="mt-1 text-xs text-slate-500">
仅限复刻音色参考音频录制与一键复刻功能后续版本支持
</p>
</div>
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="onCancel" />
<Button label="保存" :loading="saving" @click="onSave" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,197 @@
<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 VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
const workflow = useWorkflowStore();
const { voiceManageModalVisible, selectedVoiceId } = storeToRefs(workflow);
const activeTab = ref("system");
const voiceCategory = ref("putonghua");
const cloneVoices = ref([]);
const cloneLoading = ref(false);
const editDialogRef = ref(null);
const filteredSystemVoices = computed(() =>
filterSystemVoices(voiceCategory.value),
);
function loadCloneVoices() {
cloneLoading.value = true;
try {
cloneVoices.value = listCloneVoices();
} finally {
cloneLoading.value = false;
}
}
watch(voiceManageModalVisible, (open) => {
if (open) {
activeTab.value = "system";
voiceCategory.value = "putonghua";
loadCloneVoices();
}
});
function onClose() {
workflow.closeVoiceManageDialog();
}
function onSelectVoice(voiceId) {
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 (selectedVoiceId.value === record.id) {
workflow.selectVoice("sys_Cherry");
}
loadCloneVoices();
}
function stripVoiceIdPrefix(id) {
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
}
</script>
<template>
<Dialog
v-model:visible="voiceManageModalVisible"
modal
header="音色管理"
:style="{ width: '900px' }"
:draggable="false"
class="voice-manage-dialog"
@hide="onClose"
>
<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>
<Button
label="选择"
size="small"
icon="pi pi-check"
@click="onSelectVoice(voice.id)"
/>
</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" @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>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
</Dialog>
</template>