Files
short-drama-agent-front/src/features/subject-identity/SubjectIdentityPage.vue
T

603 lines
34 KiB
Vue
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.
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NScrollbar,
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NSelect,
NTag
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { RefreshCw, UserCheck } from '@lucide/vue'
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { castingStatusLabel, currentAnchor } from './model'
import { useSubjectIdentity } from './useSubjectIdentity'
import IdentityEditor from './components/IdentityEditor.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
const route = useRoute()
const {
projectId,
operation,
selectedId,
subject,
subjects,
catalog,
castingQuery,
styleQuery,
detail,
identity,
images,
castingItem,
session,
concurrency,
candidateLimit,
force,
editorRevision,
dirty,
blocked,
castingBlocked,
detailBlocked,
hasStyle,
canGenerateText,
canGenerateImage,
canGenerateCandidate,
batchValid,
candidateLimitValid,
save,
generate,
generateProject,
generateCastingCandidates,
generateImage,
generateCastingCandidate,
setAnchor,
confirmCasting
} = useSubjectIdentity()
const search = ref('')
const module = ref('all')
const imageOpen = ref(false)
const castingOpen = ref(false)
const pendingSelection = ref('')
const anchor = computed(() => currentAnchor(images.value))
const casting = computed(() => {
const value = castingQuery.data.value
return value && Array.isArray(value.items) ? value : null
})
const filtered = computed(() =>
subjects.value.filter(
item =>
(module.value === 'all' || item.module === module.value) &&
`${item.name} ${item.ref}`.toLowerCase().includes(search.value.trim().toLowerCase())
)
)
/** 深链接仅在目标 ID 变化时选择,不因目录轮询把用户切回原主体。 */
const linkedSubjectId = computed(
() => subjects.value.find(item => item.id === route.query.subjectId || item.ref === route.query.subjectRef)?.id
)
watch(
linkedSubjectId,
id => {
if (id) select(id)
},
{ immediate: true }
)
watch(selectedId, () => {
imageOpen.value = false
castingOpen.value = false
pendingSelection.value = ''
})
/** 切换主体前确认放弃当前草稿,避免误丢尚未保存的文本。 */
function select(id: string) {
if (id === selectedId.value) return
if (dirty.value) pendingSelection.value = id
else selectedId.value = id
}
/** 用户明确放弃草稿后切换,查询层会取消旧请求。 */
function discardAndSelect() {
if (!pendingSelection.value) return
dirty.value = false
selectedId.value = pendingSelection.value
}
/** 保存真实回执便于定位部分失败主体。 */
function exportReceipt() {
downloadText('subject-identities-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
}
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
function refreshDirectory() {
void Promise.all([catalog.refresh(), castingQuery.refresh()])
}
/** Character 使用确认选角事务;Scene / Prop 保留普通母版切换。 */
function chooseAnchor(imageId: string) {
if (subject.value?.module === 'character') void confirmCasting(imageId)
else void setAnchor(imageId)
}
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
const toolsOpen = ref(false)
const overview = ref<string[]>(['casting'])
watch(
() => session.value.receipt,
receipt => {
if (receipt) {
overview.value = ['casting']
toolsOpen.value = true
}
}
)
</script>
<template>
<WorkspacePage split compact
><template #header
><div class="workspace-toolbar">
<div class="toolbar-type-filter">
<NSelect
aria-label="身份主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
</div>
<span class="toolbar-summary"> {{ subjects.length }} 个主体</span>
<div class="toolbar-actions">
<WorkspaceTools
v-model:open="toolsOpen"
title="角色选角总览与批量操作"
:has-receipt="!!session.receipt"
>
<div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">主体身份</h2>
<p class="mt-2 text-sm text-muted">
为同一个人物场景或道具固定稳定特征再生成不同造型
</p>
</div>
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
>下一步形态图片 </RouterLink
>
</div>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="casting" title="角色选角总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
Character 现在需要依次完成身份文本选角候选确认演员并锁定
Identity之后首帧才能就绪未明确人物背景时AI
默认以中国人物作为选角基线项目和角色的明确事实始终优先场景和道具仍使用普通身份母版流程
</NAlert>
<NAlert
v-if="styleQuery.data.value && !hasStyle"
type="info"
:show-icon="false"
class="mt-4"
>
先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink
:to="`/projects/${projectId}/visual-style`"
class="text-button ml-2"
>设置视觉风格 </RouterLink
></NAlert
>
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="production-step"><UserCheck :size="17" /></span>
<div>
<h3 class="text-sm font-semibold">角色选角</h3>
<p class="mt-2 text-xs leading-6 text-muted">
只处理 Character每张候选都从 Identity 独立生成不复用旧
Anchor确认选择时才会同时切换 Anchor 并锁定 Identity
</p>
</div>
</div>
<span class="font-mono text-xs text-muted"
>{{ casting.ready }} / {{ casting.total }} 已完成</span
>
</div>
<dl class="casting-stats mt-4">
<div>
<dt>缺身份文本</dt>
<dd>{{ casting.missingIdentity }}</dd>
</div>
<div>
<dt>缺候选图</dt>
<dd>{{ casting.missingAnchor }}</dd>
</div>
<div>
<dt>待确认演员</dt>
<dd>{{ casting.candidatePending }}</dd>
</div>
<div>
<dt>母版未锁定</dt>
<dd>{{ casting.unlocked }}</dd>
</div>
<div>
<dt>选角完成</dt>
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-28">
<span class="field-label">本批上限</span>
<NInputNumber
:disabled="operation.pending"
:value="
typeof candidateLimit === 'number' ? candidateLimit : null
"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex gap-2 pb-2 text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !casting.total
"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
!casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
<p
v-if="!batchValid || !candidateLimitValid"
class="mt-3 text-xs text-danger"
>
并发数与本批上限都必须是正整数
</p>
<div class="casting-list" role="group" aria-label="角色选角进度">
<NButton
v-for="item in casting.items"
:key="item.subjectId"
@click="select(item.subjectId)"
class="casting-item"
:class="{ selected: selectedId === item.subjectId }"
:aria-pressed="selectedId === item.subjectId"
><span class="casting-item-identity">
<span class="casting-item-name">{{ item.subjectName }}</span>
<span class="casting-item-ref">{{
item.subjectRef
}}</span> </span
><StatusBadge
class="casting-item-status"
:status="item.status"
:label="castingStatusLabel(item.status)"
/></NButton>
</div>
</section>
<NCollapse class="panel mb-5 p-5"
><NCollapseItem name="details"
><template #header>高级全部主体身份文本</template>
<p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene
Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !subjects.length
"
acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)" /></NCollapseItem
></NCollapse>
<section
v-if="session.receipt"
class="panel mb-5 p-5"
aria-label="身份文本批量回执"
>
<div class="flex justify-between gap-3">
<h3 class="text-sm font-medium">
{{ session.receipt.title }} · 本次回执
</h3>
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
</div>
<p
v-if="session.receipt.kind === 'identities'"
class="mt-3 text-xs text-muted"
>
{{ session.receipt.result.total }} 个主体 · 目标
{{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.result.generated }} · 跳过
{{ session.receipt.result.skipped }}含锁定
{{ session.receipt.result.skippedLocked }})· 失败
{{ session.receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }}
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li
v-for="failure in session.receipt.result.failures"
:key="failure.subjectId"
>
{{ failure.subjectRef }} · {{ failure.subjectId }}{{
failure.error
}}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准
</p>
</section>
</div></NCollapseItem
></NCollapse
>
</div>
</WorkspaceTools>
</div>
</div></template
>
<NScrollbar
class="workspace-feedback"
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
><NAlert role="alert" type="error" :show-icon="false"
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</NAlert></NScrollbar
>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel directory-panel identity-directory">
<div class="directory-heading">
<h3>
主体目录 <span>{{ filtered.length }} / {{ subjects.length }}</span>
</h3>
<NButton
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
quaternary
circle
size="small"
aria-label="刷新主体目录"
title="刷新主体目录"
><RefreshCw :size="14"
/></NButton>
</div>
<div class="directory-filters">
<NInput
placeholder="搜索主体或引用"
aria-label="搜索主体身份"
:input-props="{ 'aria-label': '搜索主体身份' }"
v-model:value="search"
clearable
></NInput>
</div>
<NScrollbar
class="identity-subject-list panel-scroll directory-scroll"
content-class="directory-list-content identity-subject-list-content"
x-scrollable
>
<DirectoryItem
v-for="item in filtered"
:key="item.id"
:aria-pressed="selectedId === item.id"
:active="selectedId === item.id"
@click="select(item.id)"
class="identity-subject-item"
>{{ item.name }}
<template #eyebrow>{{ item.ref }}</template>
<template #meta>
<span>{{ item.forms.length }} 个形态</span
><span
v-if="casting?.items.find(row => row.subjectId === item.id)"
class="directory-status"
>
{{
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
}}</span
>
</template></DirectoryItem
>
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</NScrollbar>
</aside>
<NScrollbar class="identity-detail panel-scroll min-w-0">
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
><p class="text-xs">当前主体有未保存修改切换会丢弃草稿</p>
<div class="mt-3 flex gap-3">
<NButton @click="discardAndSelect">放弃草稿并切换</NButton
><NButton @click="pendingSelection = ''" text size="small">继续编辑</NButton>
</div></NAlert
>
<section v-if="subject" class="panel p-5 sm:p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="font-semibold">
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
</h3>
<p class="mt-2 text-xs text-muted">
<template v-if="subject.module === 'character' && castingItem">
选角状态{{ castingStatusLabel(castingItem.status) }}
</template>
<template v-else>
{{
identity
? identity.isLocked
? '身份文本已锁定'
: '身份文本未锁定'
: '尚未创建身份'
}}
</template>
</p>
</div>
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
刷新身份
</NButton>
</div>
<NAlert v-if="detail.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ detail.error.value }} 查询失败时不允许生成或覆盖
</NAlert>
<p v-if="!detail.data.value && detail.loading.value" class="py-8 text-sm text-muted" role="status">
正在读取主体身份
</p>
<template v-if="detail.data.value">
<div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'"
:disabled="!canGenerateText"
acknowledgement
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
@confirm="generate"
/><NButton
v-if="subject.module === 'character'"
:disabled="!canGenerateCandidate"
@click="castingOpen = true"
>
生成选角候选</NButton
><NButton
v-if="subject.module !== 'character' || !!anchor"
:disabled="!canGenerateImage"
@click="imageOpen = true"
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
>
</div>
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p>
<IdentityEditor
:key="`${selectedId}:${editorRevision}`"
:identity="identity"
:module="subject.module"
:disabled="detailBlocked"
@save="save"
@dirty="dirty = $event"
/>
<div v-if="anchor" class="mt-5 flex items-center gap-4 border-t border-line pt-5">
<AssetImage
:src="anchor.imageUrl"
:alt="`${subject.name}当前身份母版`"
class="h-20 w-20 shrink-0 rounded"
/>
<div>
<p class="text-sm font-medium">当前身份母版</p>
<p class="mt-2 text-xs text-muted">
{{
subject.module === 'character'
? identity?.isLocked
? '演员身份已确认并锁定,可用于形态图与镜头首帧。'
: '已有演员母版但 Identity 尚未锁定,镜头首帧仍会被阻塞。'
: subject.module === 'prop'
? '道具形态生图暂不自动引用此母版。'
: '后续场景形态图会继承此图的空间结构。'
}}
</p>
</div>
</div>
<IdentityGallery
:key="selectedId"
:images="images"
:subject-name="subject.name"
:module="subject.module"
:identity-locked="!!identity?.isLocked"
:disabled="detailBlocked || (subject.module === 'character' && !!castingQuery.error.value)"
@anchor="chooseAnchor"
/>
</template>
<div class="mt-5 border-t border-line pt-5">
<h4 class="text-xs font-medium">此主体的形态</h4>
<div class="mt-3 flex flex-wrap gap-2">
<NTag v-for="form in subject.forms" :key="form.id" size="small" :bordered="false"
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</NTag
>
</div>
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
此角色尚无正式形态但可以先完成 Identity 与选角
</p>
<RouterLink
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
class="text-button mt-3"
>查看此主体形态图片 </RouterLink
>
</div>
</section>
</NScrollbar>
</div>
<p v-else-if="catalog.loading.value" class="py-8 text-sm text-muted" role="status">正在读取正式主体目录</p>
<EmptyState
v-else-if="catalog.data.value"
title="还没有可展示的正式主体"
description="先完成剧本拆解与主体形态持久化,再来管理稳定身份。"
><RouterLink :to="`/projects/${projectId}/breakdown`" class="button button-secondary"
>前往剧本拆解</RouterLink
></EmptyState
>
<IdentityImageDialog
v-if="subject"
v-model:open="imageOpen"
:subject-id="subject.id"
:subject-name="subject.name"
:module="subject.module"
:images="images"
:disabled="!canGenerateImage"
@generate="generateImage" />
<CastingCandidateDialog
v-if="subject?.module === 'character'"
v-model:open="castingOpen"
:subject-id="subject.id"
:subject-name="subject.name"
:disabled="!canGenerateCandidate"
@generate="generateCastingCandidate"
/></WorkspacePage>
</template>