init
This commit is contained in:
@@ -0,0 +1,877 @@
|
||||
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
|
||||
|
||||
/** 角色库弹窗 Tab:library | 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,
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user