1
This commit is contained in:
151
src/components/avatar/AvatarEditDialog.vue
Normal file
151
src/components/avatar/AvatarEditDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useAvatarStore } from "../../stores/avatar.js";
|
||||
import { pickVideoFile, importAvatarVideo } from "../../services/avatarVideo.js";
|
||||
import { localVideoToPlayableUrl } from "../../services/localVideo.js";
|
||||
import { avatarNameExists } from "../../services/avatarDb.js";
|
||||
|
||||
const emit = defineEmits(["saved"]);
|
||||
|
||||
const avatarStore = useAvatarStore();
|
||||
|
||||
const visible = ref(false);
|
||||
const name = ref("");
|
||||
const localSourcePath = ref("");
|
||||
const previewSrc = ref("");
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
function resetForm() {
|
||||
name.value = "";
|
||||
localSourcePath.value = "";
|
||||
previewSrc.value = "";
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
resetForm();
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function onPickVideo() {
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const path = await pickVideoFile();
|
||||
if (!path) return;
|
||||
localSourcePath.value = path;
|
||||
previewSrc.value = localVideoToPlayableUrl(path);
|
||||
} catch (err) {
|
||||
errorMessage.value =
|
||||
err instanceof Error ? err.message : String(err || "选择文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
errorMessage.value = "";
|
||||
const title = name.value.trim();
|
||||
if (!title) {
|
||||
errorMessage.value = "请输入名称";
|
||||
return;
|
||||
}
|
||||
if (await avatarNameExists(title)) {
|
||||
errorMessage.value = "名称重复";
|
||||
return;
|
||||
}
|
||||
if (!localSourcePath.value) {
|
||||
errorMessage.value = "请选择视频";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const savedPath = await importAvatarVideo(localSourcePath.value);
|
||||
await avatarStore.addTemplate({ name: title, filePath: savedPath });
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} catch (err) {
|
||||
errorMessage.value =
|
||||
err instanceof Error ? err.message : String(err || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openAdd });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
header="添加视频形象"
|
||||
:style="{ width: '720px' }"
|
||||
:draggable="false"
|
||||
class="avatar-edit-dialog"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<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">视频 <span class="text-red-400">*</span></div>
|
||||
<div
|
||||
v-if="!previewSrc"
|
||||
class="flex h-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 text-center text-sm text-slate-400 transition-colors hover:border-blue-400/40 hover:bg-white/10"
|
||||
@click="onPickVideo"
|
||||
>
|
||||
<i class="pi pi-video mb-2 text-2xl text-slate-500" />
|
||||
<span>点击选择视频文件</span>
|
||||
<span class="mt-1 text-xs">支持 mp4 / mov / webm 等</span>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div class="overflow-hidden rounded-lg bg-black p-1">
|
||||
<video :src="previewSrc" controls class="max-h-48 w-full" />
|
||||
</div>
|
||||
<Button
|
||||
label="重新选择"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
@click="onPickVideo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
<div class="mb-2 text-base font-medium text-slate-200">形象示例</div>
|
||||
<div class="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">正脸自拍</div>
|
||||
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">可张口闭口</div>
|
||||
<div class="rounded-lg bg-red-500/10 p-2 text-red-400">面部有干扰</div>
|
||||
</div>
|
||||
<div class="dashboard-field-label mb-2">形象视频要求</div>
|
||||
<ul class="space-y-1.5 text-xs leading-relaxed">
|
||||
<li>视频时长建议 10~30 秒,格式 MP4,分辨率 1080p~4K</li>
|
||||
<li>每一帧需正面露脸、无遮挡,且仅出现同一人脸</li>
|
||||
<li>建议闭口或微微张口,张口幅度不宜过大</li>
|
||||
<li>可正常语气循环说「一二三四五六七八九」等文字</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" @click="onCancel" />
|
||||
<Button label="保存" :loading="saving" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { useAvatarStore } from "../../stores/avatar.js";
|
||||
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const avatarStore = useAvatarStore();
|
||||
const {
|
||||
voiceEmotion,
|
||||
speechRate,
|
||||
@@ -22,7 +24,7 @@ const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
|
||||
|
||||
const audioHistoryOptions = computed(() =>
|
||||
audioHistory.value.map((item) => ({
|
||||
label: item.label,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
@@ -44,10 +46,15 @@ async function onGenerateSpeech() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPickAudioHistory() {
|
||||
async function onPickAudioHistory() {
|
||||
if (!currentAudioId.value) return;
|
||||
workflow.pickAudioHistory(currentAudioId.value);
|
||||
await workflow.pickAudioHistory(currentAudioId.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
avatarStore.refresh();
|
||||
workflow.refreshAudioHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,7 +146,7 @@ function onPickAudioHistory() {
|
||||
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
||||
<Select
|
||||
v-model="avatarSelect"
|
||||
:options="workflow.avatarOptions"
|
||||
:options="avatarStore.selectOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="请选择形象"
|
||||
|
||||
@@ -26,7 +26,7 @@ const mainChildren = [
|
||||
|
||||
name: "avatar",
|
||||
|
||||
component: placeholder,
|
||||
component: () => import("../views/AvatarView.vue"),
|
||||
|
||||
meta: { title: "形象" },
|
||||
|
||||
|
||||
35
src/services/audioDb.js
Normal file
35
src/services/audioDb.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("语音历史需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} GeneratedAudioRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<GeneratedAudioRecord[]>} */
|
||||
export async function listGeneratedAudios() {
|
||||
ensureTauri();
|
||||
return invoke("list_generated_audios");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name 可为空,空时由后端填入当前时间
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<GeneratedAudioRecord>}
|
||||
*/
|
||||
export async function insertGeneratedAudio(name, filePath) {
|
||||
ensureTauri();
|
||||
return invoke("insert_generated_audio", { name, filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteGeneratedAudio(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_generated_audio", { id });
|
||||
}
|
||||
89
src/services/avatarDb.js
Normal file
89
src/services/avatarDb.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "aiclient_video_templates";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("形象管理需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} AvatarRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<AvatarRecord[]>} */
|
||||
export async function listAvatars() {
|
||||
ensureTauri();
|
||||
return invoke("list_avatars");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<AvatarRecord>}
|
||||
*/
|
||||
export async function insertAvatar(name, filePath) {
|
||||
ensureTauri();
|
||||
return invoke("insert_avatar", { name, filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateAvatarName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_avatar_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteAvatar(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_avatar", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number | null} [excludeId]
|
||||
*/
|
||||
export async function avatarNameExists(name, excludeId = null) {
|
||||
ensureTauri();
|
||||
return invoke("avatar_name_exists", {
|
||||
name,
|
||||
excludeId: excludeId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 将旧版 localStorage 数据迁入 SQLite(仅当库为空且存在 legacy 数据时) */
|
||||
export async function migrateLegacyAvatarsIfNeeded() {
|
||||
ensureTauri();
|
||||
const current = await listAvatars();
|
||||
if (current.length > 0) return;
|
||||
|
||||
let legacy = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
legacy = Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
} catch {
|
||||
legacy = [];
|
||||
}
|
||||
if (!legacy.length) return;
|
||||
|
||||
for (const item of legacy) {
|
||||
const name = String(item.name || "").trim();
|
||||
const filePath = String(item.video || item.filePath || "").trim();
|
||||
if (!name || !filePath) continue;
|
||||
try {
|
||||
await insertAvatar(name, filePath);
|
||||
} catch {
|
||||
/* 重名等跳过 */
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
32
src/services/avatarVideo.js
Normal file
32
src/services/avatarVideo.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickVideoFile() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择视频文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "avi"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sourcePath
|
||||
* @returns {Promise<string>} 应用内保存路径
|
||||
*/
|
||||
export async function importAvatarVideo(sourcePath) {
|
||||
return invoke("import_avatar_video", { sourcePath });
|
||||
}
|
||||
|
||||
12
src/services/localVideo.js
Normal file
12
src/services/localVideo.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* 本地视频文件转为 WebView 可播放地址
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localVideoToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览视频");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
@@ -1,99 +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";
|
||||
|
||||
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 audioSrc = await localAudioToPlayableUrl(result.audioPath);
|
||||
const label = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
|
||||
const entry = {
|
||||
id: `audio_${Date.now()}`,
|
||||
label,
|
||||
audioPath: result.audioPath,
|
||||
audioSrc,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
store.generatedAudioPath = result.audioPath;
|
||||
store.generatedAudioSrc = audioSrc;
|
||||
store.currentAudioId = entry.id;
|
||||
store.audioHistory = [entry, ...store.audioHistory].slice(0, 50);
|
||||
|
||||
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 {string} audioId
|
||||
*/
|
||||
export function selectAudioHistory(store, audioId) {
|
||||
const item = store.audioHistory.find((h) => h.id === audioId);
|
||||
if (!item) return;
|
||||
store.currentAudioId = item.id;
|
||||
store.generatedAudioPath = item.audioPath;
|
||||
store.generatedAudioSrc = item.audioSrc;
|
||||
}
|
||||
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,
|
||||
|
||||
75
src/stores/avatar.js
Normal file
75
src/stores/avatar.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||
import {
|
||||
listAvatars,
|
||||
insertAvatar,
|
||||
updateAvatarName,
|
||||
deleteAvatar,
|
||||
avatarNameExists,
|
||||
migrateLegacyAvatarsIfNeeded,
|
||||
} from "../services/avatarDb.js";
|
||||
|
||||
export const useAvatarStore = defineStore("avatar", {
|
||||
state: () => ({
|
||||
templates: [],
|
||||
loading: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
selectOptions: (state) => [
|
||||
{ label: "请选择形象", value: null },
|
||||
...state.templates.map((t) => ({
|
||||
label: t.name,
|
||||
value: t.id,
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
actions: {
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await migrateLegacyAvatarsIfNeeded();
|
||||
this.templates = await listAvatars();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ name: string, filePath: string }} payload
|
||||
*/
|
||||
async addTemplate(payload) {
|
||||
const record = await insertAvatar(payload.name, payload.filePath);
|
||||
this.templates = await listAvatars();
|
||||
return record;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
async renameTemplate(id, name) {
|
||||
const exists = await avatarNameExists(name, id);
|
||||
if (exists) {
|
||||
return { ok: false, message: "名称重复" };
|
||||
}
|
||||
try {
|
||||
await updateAvatarName(id, name);
|
||||
this.templates = await listAvatars();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg || "重命名失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async removeTemplate(item) {
|
||||
await deleteAvatar(item.id);
|
||||
this.templates = await listAvatars();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAvatarStore, import.meta.hot));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
executeGenerateSpeech,
|
||||
selectAudioHistory,
|
||||
} from "../services/voiceGenerate.js";
|
||||
import { listGeneratedAudios } from "../services/audioDb.js";
|
||||
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||
|
||||
|
||||
|
||||
@@ -165,7 +167,7 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
currentAudioId: null,
|
||||
|
||||
/** @type {Array<{ id: string, label: string, audioPath: string, audioSrc: string, createdAt: number }>} */
|
||||
/** @type {Array<{ id: number, name: string, filePath: string, createdAt: string, audioSrc?: string }>} */
|
||||
audioHistory: [],
|
||||
|
||||
|
||||
@@ -451,10 +453,27 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
},
|
||||
|
||||
pickAudioHistory(audioId) {
|
||||
|
||||
selectAudioHistory(this, audioId);
|
||||
async refreshAudioHistory() {
|
||||
try {
|
||||
const rows = await listGeneratedAudios();
|
||||
this.audioHistory = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
let audioSrc = "";
|
||||
try {
|
||||
audioSrc = await localAudioToPlayableUrl(row.filePath);
|
||||
} catch {
|
||||
audioSrc = "";
|
||||
}
|
||||
return { ...row, audioSrc };
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
this.audioHistory = [];
|
||||
}
|
||||
},
|
||||
|
||||
async pickAudioHistory(audioId) {
|
||||
await selectAudioHistory(this, audioId);
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
|
||||
185
src/views/AvatarView.vue
Normal file
185
src/views/AvatarView.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAvatarStore } from "../stores/avatar.js";
|
||||
import AvatarEditDialog from "../components/avatar/AvatarEditDialog.vue";
|
||||
import { localVideoToPlayableUrl } from "../services/localVideo.js";
|
||||
|
||||
const avatarStore = useAvatarStore();
|
||||
const { templates, loading } = storeToRefs(avatarStore);
|
||||
|
||||
const editDialogRef = ref(null);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const editingId = ref(null);
|
||||
const editingName = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
avatarStore.refresh();
|
||||
});
|
||||
|
||||
function showFeedback(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
function videoSrc(item) {
|
||||
if (!item?.filePath) return "";
|
||||
try {
|
||||
return localVideoToPlayableUrl(item.filePath);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
editDialogRef.value?.openAdd();
|
||||
}
|
||||
|
||||
function onCloud() {
|
||||
showFeedback("warn", "云端形象功能即将上线");
|
||||
}
|
||||
|
||||
function startRename(item) {
|
||||
editingId.value = item.id;
|
||||
editingName.value = item.name;
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId.value = null;
|
||||
editingName.value = "";
|
||||
}
|
||||
|
||||
async function commitRename(item) {
|
||||
const result = await avatarStore.renameTemplate(item.id, editingName.value);
|
||||
if (!result.ok) {
|
||||
showFeedback("error", result.message || "重命名失败");
|
||||
return;
|
||||
}
|
||||
cancelRename();
|
||||
}
|
||||
|
||||
async function onDelete(item) {
|
||||
if (!window.confirm(`确认删除形象「${item.name}」?`)) return;
|
||||
await avatarStore.removeTemplate(item);
|
||||
showFeedback("success", "已删除");
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
showFeedback("success", "形象已添加");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar-page custom-scrollbar h-full overflow-y-auto p-5">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-100">数字人形象</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">管理多个数字人形象</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
label="云端形象"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-cloud"
|
||||
@click="onCloud"
|
||||
/>
|
||||
<Button label="添加" size="small" icon="pi pi-plus" @click="onAdd" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mb-3 text-sm"
|
||||
:class="{
|
||||
'text-red-400': feedback.severity === 'error',
|
||||
'text-amber-400': feedback.severity === 'warn',
|
||||
'text-emerald-400': feedback.severity === 'success',
|
||||
}"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<p v-if="loading" class="py-12 text-center text-sm text-slate-500">加载中...</p>
|
||||
<p
|
||||
v-else-if="templates.length === 0"
|
||||
class="rounded-xl border border-white/10 bg-white/5 py-16 text-center text-sm text-slate-500"
|
||||
>
|
||||
暂无形象,点击「添加」上传视频
|
||||
</p>
|
||||
|
||||
<div v-else class="-mx-2 flex flex-wrap">
|
||||
<div
|
||||
v-for="item in templates"
|
||||
:key="item.id"
|
||||
class="w-full p-2 md:w-1/2 xl:w-1/3"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-shadow hover:border-purple-500/40 hover:shadow-md"
|
||||
>
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
v-if="editingId !== item.id"
|
||||
class="inline-flex max-w-full items-center rounded-full bg-blue-500/15 px-2 py-1"
|
||||
>
|
||||
<span
|
||||
class="cursor-pointer truncate text-sm font-medium text-slate-100"
|
||||
:title="item.name"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 shrink-0 text-xs text-slate-500 hover:text-blue-400"
|
||||
aria-label="重命名"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex gap-1">
|
||||
<InputText v-model="editingName" size="small" class="flex-1" />
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
severity="success"
|
||||
aria-label="确认"
|
||||
@click="commitRename(item)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
aria-label="取消"
|
||||
@click="cancelRename"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
aria-label="删除"
|
||||
@click="onDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg bg-black p-1">
|
||||
<video
|
||||
v-if="videoSrc(item)"
|
||||
:src="videoSrc(item)"
|
||||
controls
|
||||
class="aspect-video w-full bg-black object-contain"
|
||||
/>
|
||||
<p v-else class="py-12 text-center text-xs text-slate-500">无法预览视频</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarEditDialog ref="editDialogRef" @saved="onSaved" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user