11
This commit is contained in:
@@ -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>
|
||||
|
||||
133
src/components/workflow/VoiceCloneEditDialog.vue
Normal file
133
src/components/workflow/VoiceCloneEditDialog.vue
Normal 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>
|
||||
197
src/components/workflow/VoiceManageDialog.vue
Normal file
197
src/components/workflow/VoiceManageDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user