Files
localMiniDrama/frontweb/src/composables/filmCreate/useCharacters.js
T
2026-06-30 15:07:31 +08:00

878 lines
29 KiB
JavaScript
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.
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { characterAPI } from '@/api/characters'
import { characterLibraryAPI } from '@/api/characterLibrary'
import { dramaAPI } from '@/api/drama'
import { generationAPI } from '@/api/generation'
import { uploadAPI } from '@/api/upload'
import { useGenerationTaskStore, GEN_RESOURCE } from '@/stores/generationTaskStore'
import { buildExtractTaskMeta, isEpisodeExtractRunning } from '@/composables/useGenerationTaskSync'
/**
* 角色管理 Composable
* @param {object} deps - 共享依赖
* @param {object} deps.store - Pinia store
* @param {import('vue').ComputedRef} deps.dramaId
* @param {import('vue').ComputedRef} deps.currentEpisodeId
* @param {Function} deps.getSelectedStyle - 获取当前生成风格
* @param {Function} deps.loadDrama - 重新加载剧集数据
* @param {Function} deps.pollTask - 轮询异步任务
* @param {Function} deps.pollUntilResourceHasImage - 等待资源有图片
* @param {Function} deps.hasAssetImage - 判断资源是否有图片
*/
export function useCharacters(deps) {
const { store, dramaId, currentEpisodeId, getSelectedStyle, loadDrama, pollTask, pollUntilResourceHasImage, hasAssetImage } = deps
const genStore = useGenerationTaskStore()
function buildCharImageMeta(char) {
const dramaTitle = store.drama?.title || ''
const epNum = store.currentEpisode?.episode_number
const epLabel = dramaTitle ? `${dramaTitle} · 第${epNum ?? ''}集` : `第${epNum ?? ''}集`
return {
dramaId: dramaId.value,
episodeId: currentEpisodeId.value,
dramaTitle,
episodeNumber: epNum,
resourceType: GEN_RESOURCE.CHAR_IMAGE,
resourceId: char.id,
label: `${epLabel} 角色图: ${char.name || char.id}`,
}
}
function dataUrlToFile(dataUrl, filename) {
const arr = dataUrl.split(',')
const mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png'
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
return new File([u8arr], filename || 'reference.png', { type: mime })
}
// ── 角色弹窗状态 ─────────────────────────────────────
const showEditCharacter = ref(false)
const editCharacterForm = ref(null)
const editCharacterSaving = ref(false)
const editCharacterPromptGenerating = ref(false)
const extractingCharAppearance = ref(false)
const extractingAnchors = ref(false)
const addCharRefImage = ref(null) // { dataUrl, filename }
const addCharRefFileInput = ref(null)
let editCharacterPollTimer = null
// ── 角色生成状态 ──────────────────────────────────────
/** 仅当前集「提取角色」进行中时为 true(按集隔离,切集不误显示 loading) */
const charactersGenerating = computed(() =>
isEpisodeExtractRunning(genStore, dramaId.value, currentEpisodeId.value, GEN_RESOURCE.EXTRACT_CHARACTERS)
)
const generatingCharIds = reactive(new Set())
const sd2CertifyingId = ref(null)
const showCharSd2Cert = ref(false)
const charSd2CertPayload = ref(null)
const sd2VoiceUploadingId = ref(null)
// ── 角色库状态 ────────────────────────────────────────
const showCharLibrary = ref(false)
const charLibraryList = ref([])
const charLibraryLoading = ref(false)
const charLibraryPage = ref(1)
const charLibraryPageSize = ref(20)
const charLibraryTotal = ref(0)
const charLibraryKeyword = ref('')
const showEditCharLibrary = ref(false)
const editCharLibraryForm = ref(null)
const editCharLibrarySaving = ref(false)
const addingCharToLibraryId = ref(null)
const addingCharToMaterialId = ref(null)
const addingCharFromLibraryId = ref(null)
let charLibraryKeywordTimer = null
/** 角色库弹窗 Tablibrary | drama | team */
const charLibraryTab = ref('library')
const dramaAllCharList = ref([])
const dramaAllCharLoading = ref(false)
const dramaAllCharPage = ref(1)
const dramaAllCharPageSize = ref(20)
const dramaAllCharTotal = ref(0)
const dramaAllCharKeyword = ref('')
let dramaAllCharKeywordTimer = null
// ── 常量 ──────────────────────────────────────────────
const CHAR_ROLE_LABEL = { main: '主角', supporting: '配角', minor: '次要角色' }
function charRoleLabel(role) { return CHAR_ROLE_LABEL[role] || role || '' }
// ── 核心函数 ──────────────────────────────────────────
async function onGenerateCharacters() {
if (!store.dramaId) return
const epId = currentEpisodeId.value
if (!epId) {
ElMessage.warning('请先选择集次')
return
}
const meta = buildExtractTaskMeta(store, dramaId.value, epId, GEN_RESOURCE.EXTRACT_CHARACTERS, '提取角色')
genStore.markRunning(meta)
try {
const outline =
(store.scriptContent || '').toString().trim() || undefined
const res = await generationAPI.generateCharacters(store.dramaId, {
episode_id: epId,
outline: outline || undefined
})
const taskId = res?.task_id
if (taskId) {
await pollTask(taskId, () => loadDrama(), meta)
ElMessage.success('角色生成完成')
} else {
await loadDrama()
}
} catch (e) {
ElMessage.error(e.message || '生成失败')
} finally {
genStore.markDone(meta)
}
}
function openAddCharacter() {
editCharacterForm.value = {
name: '',
role: '',
appearance: '',
personality: '',
description: '',
polished_prompt: ''
}
showEditCharacter.value = true
}
function stopCharacterPromptPoll() {
if (editCharacterPollTimer) {
clearInterval(editCharacterPollTimer)
editCharacterPollTimer = null
}
}
function editCharacter(char) {
stopCharacterPromptPoll()
editCharacterForm.value = {
id: char.id,
name: char.name || '',
role: char.role || '',
appearance: char.appearance || '',
personality: char.personality || '',
description: char.description || '',
polished_prompt: char.polished_prompt || '',
image_url: char.image_url || '',
local_path: char.local_path || '',
ref_image: char.ref_image || '',
identity_anchors: char.identity_anchors || '',
stages: char.stages ? (typeof char.stages === 'string' ? char.stages : JSON.stringify(char.stages, null, 2)) : '',
}
showEditCharacter.value = true
if (!char.polished_prompt && char.id && (char.appearance || char.description)) {
editCharacterPromptGenerating.value = true
let elapsed = 0
editCharacterPollTimer = setInterval(async () => {
elapsed += 3
try {
const res = await characterAPI.get(char.id)
const prompt = res?.character?.polished_prompt
if (prompt) {
if (editCharacterForm.value?.id === char.id) {
editCharacterForm.value.polished_prompt = prompt
}
stopCharacterPromptPoll()
editCharacterPromptGenerating.value = false
} else if (elapsed >= 60) {
stopCharacterPromptPoll()
editCharacterPromptGenerating.value = false
}
} catch (_) {
stopCharacterPromptPoll()
editCharacterPromptGenerating.value = false
}
}, 3000)
}
}
async function saveCharRefImageIfAny(characterId) {
const refImg = addCharRefImage.value
if (!refImg || !characterId) return
try {
const file = dataUrlToFile(refImg.dataUrl, refImg.filename || 'reference.png')
const uploadRes = await uploadAPI.uploadImage(file, { dramaId: dramaId.value })
const refPath = uploadRes.local_path || uploadRes.url || ''
await characterAPI.putRefImage(characterId, refPath)
} catch (e) {
console.warn('[saveCharRefImage] 保存参考图失败:', e.message)
}
}
async function submitEditCharacter() {
const form = editCharacterForm.value
if (!form?.name?.trim() || !store.dramaId) return
editCharacterSaving.value = true
try {
if (form.id) {
await characterAPI.update(form.id, {
name: form.name.trim(),
role: form.role || undefined,
appearance: form.appearance || undefined,
personality: form.personality || undefined,
description: form.description || undefined,
polished_prompt: form.polished_prompt || undefined,
stages: form.stages ? form.stages.trim() || undefined : undefined
})
await saveCharRefImageIfAny(form.id)
ElMessage.success('角色已保存')
} else {
const existing = (store.drama?.characters || []).map((c) => ({
id: c.id,
name: c.name || '',
role: c.role || undefined,
description: c.description || undefined,
personality: c.personality || undefined,
appearance: c.appearance || undefined,
image_url: c.image_url || undefined,
local_path: c.local_path || undefined
}))
await dramaAPI.saveCharacters(store.dramaId, {
characters: [...existing, {
name: form.name.trim(),
role: form.role || undefined,
appearance: form.appearance || undefined,
personality: form.personality || undefined,
description: form.description || undefined
}],
episode_id: currentEpisodeId.value ?? undefined
})
await loadDrama()
if (addCharRefImage.value) {
const newChar = (store.drama?.characters || []).find(c => c.name === form.name.trim())
if (newChar?.id) await saveCharRefImageIfAny(newChar.id)
}
ElMessage.success('角色已添加')
}
await loadDrama()
showEditCharacter.value = false
} catch (e) {
ElMessage.error(e.message || (form.id ? '保存失败' : '添加失败'))
} finally {
editCharacterSaving.value = false
}
}
async function doGenerateCharacterPrompt() {
const form = editCharacterForm.value
if (!form?.id) return
editCharacterPromptGenerating.value = true
try {
const res = await characterAPI.generatePrompt(form.id)
if (res?.polished_prompt) {
form.polished_prompt = res.polished_prompt
ElMessage.success('提示词已生成')
await loadDrama()
}
} catch (e) {
ElMessage.error(e.message || '生成提示词失败')
} finally {
editCharacterPromptGenerating.value = false
}
}
async function doExtractCharFromImage() {
const form = editCharacterForm.value
if (!form?.id) return
extractingCharAppearance.value = true
try {
const res = await characterAPI.extractFromImage(form.id)
if (res?.appearance) {
form.appearance = res.appearance
ElMessage.success('已从图片提取外貌描述')
}
} catch (e) {
ElMessage.error(e.message || '提取失败,请检查角色是否已上传参考图片')
} finally {
extractingCharAppearance.value = false
}
}
async function clearCharRefImage() {
const form = editCharacterForm.value
if (!form?.id) return
try {
await characterAPI.putRefImage(form.id, null)
form.ref_image = ''
ElMessage.success('参考图已移除')
} catch (e) {
ElMessage.error('移除失败')
}
}
function onCloseCharDialog() {
showEditCharacter.value = false
stopCharacterPromptPoll()
editCharacterPromptGenerating.value = false
addCharRefImage.value = null
}
async function onDeleteCharacter(char) {
try {
await ElMessageBox.confirm(
`确定要删除角色「${(char.name || '未命名').slice(0, 20)}」吗?此操作不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
await characterAPI.delete(char.id)
await loadDrama()
ElMessage.success('角色已删除')
} catch (e) {
if (e === 'cancel') return
ElMessage.error(e.message || '删除失败')
}
}
async function onGenerateCharacterImage(char) {
char.errorMsg = ''
char.error_msg = ''
const meta = buildCharImageMeta(char)
generatingCharIds.add(char.id)
genStore.markRunning(meta)
try {
const res = await characterAPI.generateImage(char.id, undefined, getSelectedStyle())
const taskId = res?.image_generation?.task_id ?? res?.task_id
if (taskId) {
const pollRes = await pollTask(taskId, () => loadDrama(), meta)
if (pollRes?.status === 'failed') {
char.errorMsg = pollRes.error || '生成失败'
} else {
ElMessage.success('角色图片已生成')
}
} else {
await loadDrama()
await pollUntilResourceHasImage(() => {
const list = store.drama?.characters ?? store.currentEpisode?.characters ?? []
const c = list.find((x) => Number(x.id) === Number(char.id))
return !!(c && (c.image_url || c.local_path))
})
ElMessage.success('角色图片已生成')
}
} catch (e) {
console.error(e)
char.errorMsg = e.message || '生成失败'
ElMessage.error(e.message || '提交失败')
} finally {
generatingCharIds.delete(char.id)
genStore.markDone(meta)
}
}
// ── 角色库函数 ────────────────────────────────────────
async function loadCharLibraryList() {
charLibraryLoading.value = true
try {
const res = await characterLibraryAPI.list({
drama_id: dramaId.value,
page: charLibraryPage.value,
page_size: charLibraryPageSize.value,
keyword: charLibraryKeyword.value || undefined
})
charLibraryList.value = res?.items ?? []
const pagination = res?.pagination ?? {}
charLibraryTotal.value = pagination.total ?? 0
if (pagination.page != null) charLibraryPage.value = pagination.page
if (pagination.page_size != null) charLibraryPageSize.value = pagination.page_size
} catch (e) {
charLibraryList.value = []
} finally {
charLibraryLoading.value = false
}
}
function debouncedLoadCharLibrary() {
if (charLibraryKeywordTimer) clearTimeout(charLibraryKeywordTimer)
charLibraryKeywordTimer = setTimeout(() => {
charLibraryPage.value = 1
loadCharLibraryList()
}, 300)
}
async function loadDramaAllCharList() {
if (!dramaId.value) {
dramaAllCharList.value = []
dramaAllCharTotal.value = 0
return
}
dramaAllCharLoading.value = true
try {
const res = await dramaAPI.getCharacters(dramaId.value)
let list = Array.isArray(res) ? res : (res?.characters ?? res?.items ?? [])
const kw = (dramaAllCharKeyword.value || '').trim().toLowerCase()
if (kw) {
list = list.filter((c) => {
const name = (c.name || '').toLowerCase()
const desc = (c.description || '').toLowerCase()
const app = (c.appearance || '').toLowerCase()
return name.includes(kw) || desc.includes(kw) || app.includes(kw)
})
}
dramaAllCharTotal.value = list.length
const start = (dramaAllCharPage.value - 1) * dramaAllCharPageSize.value
dramaAllCharList.value = list.slice(start, start + dramaAllCharPageSize.value)
} catch {
dramaAllCharList.value = []
dramaAllCharTotal.value = 0
} finally {
dramaAllCharLoading.value = false
}
}
function debouncedLoadDramaAllCharList() {
if (dramaAllCharKeywordTimer) clearTimeout(dramaAllCharKeywordTimer)
dramaAllCharKeywordTimer = setTimeout(() => {
dramaAllCharPage.value = 1
loadDramaAllCharList()
}, 300)
}
function onCharLibraryDialogOpen() {
if (charLibraryTab.value === 'library') loadCharLibraryList()
else if (charLibraryTab.value === 'drama') loadDramaAllCharList()
}
function onCharLibraryTabChange() {
if (charLibraryTab.value === 'library') {
charLibraryPage.value = 1
loadCharLibraryList()
} else if (charLibraryTab.value === 'drama') {
dramaAllCharPage.value = 1
loadDramaAllCharList()
}
}
function charAddToEpisodeLoadingKey(scope, id) {
return `${scope}-${id}`
}
function isCharAddToEpisodeLoading(scope, id) {
return addingCharFromLibraryId.value === charAddToEpisodeLoadingKey(scope, id)
}
function openEditCharLibrary(item) {
editCharLibraryForm.value = {
id: item.id,
name: item.name ?? '',
category: item.category ?? '',
description: item.description ?? '',
tags: item.tags ?? ''
}
showEditCharLibrary.value = true
}
async function submitEditCharLibrary() {
if (!editCharLibraryForm.value?.id) return
editCharLibrarySaving.value = true
try {
await characterLibraryAPI.update(editCharLibraryForm.value.id, {
name: editCharLibraryForm.value.name,
category: editCharLibraryForm.value.category || null,
description: editCharLibraryForm.value.description || null,
tags: editCharLibraryForm.value.tags || null
})
ElMessage.success('已保存')
showEditCharLibrary.value = false
loadCharLibraryList()
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
editCharLibrarySaving.value = false
}
}
async function onDeleteCharLibrary(item) {
try {
await ElMessageBox.confirm(
`确定删除公共角色「${(item.name || '未命名').slice(0, 20)}」吗?`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
await characterLibraryAPI.delete(item.id)
ElMessage.success('已删除')
loadCharLibraryList()
} catch (e) {
if (e === 'cancel') return
ElMessage.error(e.message || '删除失败')
}
}
async function onAddCharacterToLibrary(char) {
if (!hasAssetImage(char)) { ElMessage.warning('请先为该角色生成或上传图片'); return }
addingCharToLibraryId.value = char.id
try {
await characterAPI.addToLibrary(char.id, {})
ElMessage.success('已加入本剧角色库')
if (showCharLibrary.value) loadCharLibraryList()
} catch (e) {
ElMessage.error(e.message || '加入失败')
} finally {
addingCharToLibraryId.value = null
}
}
async function onAddCharacterToMaterialLibrary(char) {
if (!hasAssetImage(char)) { ElMessage.warning('请先为该角色生成或上传图片'); return }
addingCharToMaterialId.value = char.id
try {
await characterAPI.addToMaterialLibrary(char.id)
ElMessage.success('已加入全局素材库')
} catch (e) {
ElMessage.error(e.message || '加入失败')
} finally {
addingCharToMaterialId.value = null
}
}
async function addCharToEpisode(item, scope) {
if (!store.dramaId) return
if (!currentEpisodeId.value) {
ElMessage.warning('请先选择本集')
return
}
const loadingKey = charAddToEpisodeLoadingKey(scope, item.id)
addingCharFromLibraryId.value = loadingKey
try {
const existing = (store.characters || []).map((c) => ({
id: c.id,
name: c.name || '',
role: c.role || undefined,
appearance: c.appearance || undefined,
personality: c.personality || undefined,
description: c.description || undefined,
image_url: c.image_url || undefined,
local_path: c.local_path || undefined,
}))
const newCharacters = [...existing]
const existingChar = newCharacters.find((c) => c.name === (item.name || '未命名'))
if (existingChar) {
existingChar.description = item.description || existingChar.description
existingChar.appearance = item.appearance || existingChar.appearance
existingChar.image_url = item.image_url || existingChar.image_url
existingChar.local_path = item.local_path || existingChar.local_path
if (item.role && !existingChar.role) existingChar.role = item.role
} else {
newCharacters.push({
name: item.name || '未命名',
role: item.role || undefined,
description: item.description || undefined,
appearance: item.appearance || undefined,
personality: item.personality || undefined,
image_url: item.image_url || undefined,
local_path: item.local_path || undefined,
})
}
await dramaAPI.saveCharacters(store.dramaId, {
characters: newCharacters,
episode_id: currentEpisodeId.value ?? undefined,
})
await loadDrama()
ElMessage.success(`「${item.name || '角色'}」已加入本集`)
} catch (e) {
ElMessage.error(e.message || '加入失败')
} finally {
addingCharFromLibraryId.value = null
}
}
function onAddCharFromLibrary(item) {
return addCharToEpisode(item, 'library')
}
function onAddDramaCharToEpisode(item) {
return addCharToEpisode(item, 'drama')
}
async function extractIdentityAnchors() {
const form = editCharacterForm.value
if (!form?.id) return
if (!form.appearance) {
ElMessage.warning('请先填写角色外貌描述')
return
}
extractingAnchors.value = true
try {
await characterAPI.extractAnchors(form.id)
ElMessage.success('视觉锚点提炼已启动,请稍后查看')
// 轮询等待锚点写入
let elapsed = 0
const timer = setInterval(async () => {
elapsed += 3
try {
const res = await characterAPI.get(form.id)
const anchors = res?.character?.identity_anchors
if (anchors && editCharacterForm.value?.id === form.id) {
editCharacterForm.value.identity_anchors = anchors
clearInterval(timer)
extractingAnchors.value = false
} else if (elapsed >= 60) {
clearInterval(timer)
extractingAnchors.value = false
}
} catch (_) {
clearInterval(timer)
extractingAnchors.value = false
}
}, 3000)
} catch (e) {
ElMessage.error(e.message || '提炼失败')
extractingAnchors.value = false
}
}
async function onSd2CertifyCharacter(char) {
if (!char?.id) return
if (!hasAssetImage(char)) {
ElMessage.warning('请先为该角色生成或上传图片')
return
}
sd2CertifyingId.value = char.id
try {
await characterAPI.sd2Certify(char.id)
await loadDrama()
ElMessage.success('SD2 认证请求已提交')
} catch (e) {
const msg = e?.message || ''
if (/已存在|已认证|already/i.test(msg)) {
try {
await characterAPI.sd2CertifyRefresh(char.id)
await loadDrama()
ElMessage.success('SD2 认证状态已刷新')
return
} catch (_) {
// fall through
}
}
ElMessage.error(msg || 'SD2 认证失败')
} finally {
sd2CertifyingId.value = null
}
}
async function onSd2CertifyRefresh(char) {
if (!char?.id) return
sd2CertifyingId.value = char.id
try {
await characterAPI.sd2CertifyRefresh(char.id)
await loadDrama()
ElMessage.success('SD2 认证状态已刷新')
} catch (e) {
ElMessage.error(e?.message || '刷新失败')
} finally {
sd2CertifyingId.value = null
}
}
function sd2ActionLabel(char) {
const status = String(char?.seedance2_asset?.status || '').toLowerCase()
if (status === 'active') return '查看认证'
if (status === 'processing') return '刷新认证'
if (status === 'failed') return '重新认证'
return 'sd2认证'
}
async function onSd2PrimaryAction(char) {
const status = String(char?.seedance2_asset?.status || '').toLowerCase()
if (status === 'active') {
openCharSd2CertDialog(char)
return
}
if (status === 'processing') {
await onSd2CertifyRefresh(char)
return
}
await onSd2CertifyCharacter(char)
}
function openCharSd2CertDialog(char) {
charSd2CertPayload.value = char?.seedance2_asset ? { ...char.seedance2_asset } : null
showCharSd2Cert.value = true
}
function sd2VoiceActionLabel(char) {
const status = String(char?.seedance2_voice_asset?.status || '').toLowerCase()
if (status === 'active') return '音色参考'
if (status === 'processing') return '刷新音色'
if (status === 'failed') return '重新上传'
return '上传音色'
}
async function onSd2VoicePrimaryAction(char) {
const status = String(char?.seedance2_voice_asset?.status || '').toLowerCase()
if (status === 'active') {
ElMessage.info('音色参考已设置,将在 Seedance 2.0 模型中使用')
return
}
if (status === 'processing' || status === 'stale') {
await onSd2VoiceRefresh(char)
return
}
// 触发文件选择上传
await triggerSd2VoiceUpload(char)
}
// 专门用于“更换”:无论当前是否 active,都直接触发文件选择上传(覆盖)
async function onSd2VoiceReplace(char) {
await triggerSd2VoiceUpload(char)
}
async function onSd2VoiceRefresh(char) {
if (!char?.id) return
sd2VoiceUploadingId.value = char.id
try {
const res = await characterAPI.sd2VoiceRefresh(char.id)
await loadDrama()
ElMessage.success(res?.data?.message || '音色状态已刷新')
} catch (e) {
ElMessage.error(e?.message || '刷新失败')
} finally {
sd2VoiceUploadingId.value = null
}
}
async function triggerSd2VoiceUpload(char) {
if (!char?.id) return
// 创建隐藏的 file input
const input = document.createElement('input')
input.type = 'file'
input.accept = 'audio/*'
input.onchange = async () => {
const file = input.files && input.files[0]
if (!file) return
sd2VoiceUploadingId.value = char.id
try {
const res = await characterAPI.sd2VoiceUpload(char.id, file)
ElMessage.success('Seedance 2.0 音色参考已上传')
// 强制重新加载整个剧本数据,确保 seedance2_voice_asset 被正确解析并更新到 store
await loadDrama()
} catch (e) {
ElMessage.error(e?.message || '音色上传失败')
} finally {
sd2VoiceUploadingId.value = null
}
}
input.click()
}
// 播放 Seedance 2.0 音色参考(仅 active 状态)
function playSd2Voice(char) {
const url = char?.seedance2_voice_asset?.url
if (!url) {
ElMessage.warning('该角色暂无音色参考音频')
return
}
try {
// 统一使用相对 /static/...(与图片 assetImageUrl 一致),由当前页面 origin + Vite/后端代理或静态服务处理
const audio = new Audio(url)
audio.onerror = () => {
// 常见原因:文件不在 static 根目录下(后端写盘路径与 express.static(storageRoot) 不一致)、404、格式不支持
ElMessage.error('音频播放失败:文件可能不存在或路径不匹配,请尝试重新上传该音色参考')
}
audio.play().catch((err) => {
ElMessage.error('音频播放失败,请检查文件或稍后重试')
})
} catch (e) {
ElMessage.error('无法播放音频')
}
}
return {
// 弹窗状态
showEditCharacter,
editCharacterForm,
editCharacterSaving,
editCharacterPromptGenerating,
extractingCharAppearance,
extractingAnchors,
addCharRefImage,
addCharRefFileInput,
// 生成状态
charactersGenerating,
generatingCharIds,
sd2CertifyingId,
showCharSd2Cert,
charSd2CertPayload,
sd2VoiceUploadingId,
// 库状态
showCharLibrary,
charLibraryList,
charLibraryLoading,
charLibraryPage,
charLibraryPageSize,
charLibraryTotal,
charLibraryKeyword,
charLibraryTab,
dramaAllCharList,
dramaAllCharLoading,
dramaAllCharPage,
dramaAllCharPageSize,
dramaAllCharTotal,
dramaAllCharKeyword,
showEditCharLibrary,
editCharLibraryForm,
editCharLibrarySaving,
addingCharToLibraryId,
addingCharToMaterialId,
addingCharFromLibraryId,
// 函数
charRoleLabel,
onGenerateCharacters,
openAddCharacter,
stopCharacterPromptPoll,
editCharacter,
saveCharRefImageIfAny,
submitEditCharacter,
doGenerateCharacterPrompt,
doExtractCharFromImage,
extractIdentityAnchors,
clearCharRefImage,
onCloseCharDialog,
onDeleteCharacter,
onGenerateCharacterImage,
onSd2CertifyCharacter,
onSd2CertifyRefresh,
sd2ActionLabel,
onSd2PrimaryAction,
openCharSd2CertDialog,
onSd2VoicePrimaryAction,
onSd2VoiceReplace,
sd2VoiceActionLabel,
playSd2Voice,
loadCharLibraryList,
debouncedLoadCharLibrary,
loadDramaAllCharList,
debouncedLoadDramaAllCharList,
onCharLibraryDialogOpen,
onCharLibraryTabChange,
isCharAddToEpisodeLoading,
openEditCharLibrary,
submitEditCharLibrary,
onDeleteCharLibrary,
onAddCharacterToLibrary,
onAddCharacterToMaterialLibrary,
onAddCharFromLibrary,
onAddDramaCharToEpisode,
}
}