Files
short-drama-agent-front/src/features/subject-images/SubjectImagesPage.vue
T

742 lines
40 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 ActionMenu from '../../components/ui/ActionMenu.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NScrollbar,
NSelect,
NTag
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Search,
RefreshCw,
Download,
ImagePlus,
ArrowLeft,
ArrowRight,
LayoutGrid,
Columns3,
AlertTriangle
} from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { useSubjectImages } from './useSubjectImages'
import { coverImage, hasRunningImages, isPrimaryIdentityStale, primaryImage } from './model'
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
import FormPromptDialog from './components/FormPromptDialog.vue'
import FormPromptBatch from './components/FormPromptBatch.vue'
import { useKeyframeImpact } from './useKeyframeImpact'
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
import { formCoverAspectRatio, type GalleryLayout } from './layout'
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
const route = useRoute()
const router = useRouter()
const {
id,
forms,
staleForms,
query,
refreshProject,
operation,
session,
concurrency,
force,
limit,
promptConcurrency,
promptForce,
concurrencyValid,
generatePrompt,
generatePrompts,
blocked,
running,
batchValid,
generate,
generateProject,
generateStale
} = useSubjectImages()
const sourceShotId = computed(() => queryText(route.query.sourceShotId))
const targetFormId = computed(() => queryText(route.query.subjectFormId))
const targetForm = computed(() => forms.value.find(form => form.id === targetFormId.value))
const impact = useKeyframeImpact(id, sourceShotId)
const sourceShot = impact.source
const linkedReferences = computed(() => referenceTargets(impact.references.data.value, []))
/** 定位说明仅在有实际状态、引用或错误时出现,避免两项导航操作单独撑起空白横条。 */
const sourceMissing = computed(
() => sourceShotId.value && !sourceShot.value && !impact.query.loading.value && !impact.query.error.value
)
const sourceCurrent = computed(
() =>
sourceShot.value &&
!sourceShot.value.primaryKeyframeStale &&
!impact.query.loading.value &&
!impact.query.error.value
)
const targetUnlinked = computed(
() =>
targetForm.value &&
impact.references.data.value &&
!impact.references.error.value &&
!impact.relatedForms.value.has(targetForm.value.id)
)
const showImpactDetails = computed(
() =>
sourceShotId.value &&
(sourceMissing.value ||
sourceCurrent.value ||
sourceShot.value?.inconsistent ||
targetUnlinked.value ||
linkedReferences.value.length ||
impact.references.loading.value ||
impact.references.error.value)
)
const sourceLocation = computed(() =>
sourceShot.value
? {
path: `/projects/${encodeURIComponent(id.value)}/production`,
query: { episodeNo: String(sourceShot.value.episodeNo), shotId: sourceShot.value.shotId }
}
: null
)
const impactOptions = computed(() => {
const items = [...impact.stale.value]
if (sourceShot.value && !items.some(item => item.shotId === sourceShot.value?.shotId)) items.push(sourceShot.value)
return items.map(item => ({
value: item.shotId,
label: `第 ${item.episodeNo} 集 · BEAT ${item.beatNo} / 镜头 ${item.shotNo}${item.inconsistent ? ' · 状态待核对' : item.primaryKeyframeStale ? ' · 首帧过期' : ' · 当前镜头'}`
}))
})
const toolsOpen = ref(false)
/** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */
watch(
() => session.value.receipt,
receipt => {
if (receipt) toolsOpen.value = true
}
)
watch(
() => session.value.promptReceipt,
receipt => {
if (receipt) toolsOpen.value = true
}
)
const search = ref('')
/** 切换排版复用同一份卡片和查询结果,不重新读取图库或清空筛选。 */
const layout = ref<GalleryLayout>('grid')
const module = ref('all')
const onlyMissing = ref(false)
const onlyStale = ref(false)
const generateOpen = ref(false)
const galleryOpen = ref(false)
const generateId = ref('')
const galleryId = ref('')
const promptOpen = ref(false)
const promptId = ref('')
const promptForm = computed(() => forms.value.find(form => form.id === promptId.value) ?? null)
const generateForm = computed(() => forms.value.find(form => form.id === generateId.value) ?? null)
const galleryForm = computed(() => forms.value.find(form => form.id === galleryId.value) ?? null)
const completeCount = computed(() => forms.value.filter(form => primaryImage(form.images)).length)
const filtered = computed(() =>
forms.value.filter(
form =>
(!targetFormId.value || form.id === targetFormId.value) &&
(module.value === 'all' || form.subject.module === module.value) &&
(!onlyMissing.value || !primaryImage(form.images)) &&
(!onlyStale.value || isPrimaryIdentityStale(form)) &&
`${form.subject.name} ${form.subject.ref} ${form.name}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
)
)
watch(
() => [route.query.subjectRef, route.query.subjectFormId, route.query.sourceShotId],
() => {
search.value = targetFormId.value ? '' : queryText(route.query.subjectRef)
module.value = 'all'
onlyMissing.value = false
onlyStale.value = false
galleryOpen.value = false
generateOpen.value = false
promptOpen.value = false
},
{ immediate: true }
)
/** 打开指定正式形态的提示词管理。 */
function openPrompt(form: SubjectFormAsset) {
promptId.value = form.id
promptOpen.value = true
}
/** 打开指定正式形态的生图表单。 */
function openGenerate(form: SubjectFormAsset) {
generateId.value = form.id
generateOpen.value = true
}
/** 查看形态全部候选图和失败记录。 */
function openGallery(form: SubjectFormAsset) {
galleryId.value = form.id
galleryOpen.value = true
}
/** 本次提交的 ID 和配置由弹窗固定,关闭弹窗不取消后台任务。 */
function submitGenerate(formId: string, input: GenerateFormImageInput) {
void generate(formId, input)
}
/** 导出真实批量回执供排障,不构造成功图片地址。 */
function exportReceipt() {
downloadText('subject-images-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
}
/** 搜索为空时可一次恢复完整图库。 */
function resetFilters() {
search.value = ''
module.value = 'all'
onlyMissing.value = false
onlyStale.value = false
void router.replace({
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
})
}
/** 切换受影响镜头时清空之前的素材过滤,避免相关素材被旧筛选隐藏。 */
function selectImpactShot(shotId: string | null) {
void router.replace({
query: { ...route.query, sourceShotId: shotId || undefined, subjectFormId: undefined, subjectRef: undefined }
})
}
/** 同时刷新素材和镜头影响状态,查询仍不启动生成。 */
async function refreshAssets() {
await Promise.all([refreshProject(), query.refresh(), impact.query.refresh(), impact.references.refresh()])
}
/** 全局过期筛选不沿用深链接的单个素材限制。 */
async function showStaleForms() {
await router.replace({
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
})
search.value = ''
module.value = 'all'
onlyMissing.value = false
onlyStale.value = true
}
</script>
<template>
<WorkspacePage compact class="gallery-workspace-page"
><template #default>
<!-- 筛选关联说明和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<div class="workspace-toolbar">
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
>{{ staleForms.length }} 个形态主图过期</NButton
>
<div class="toolbar-actions">
<NButton
:disabled="query.loading.value || impact.query.loading.value"
@click="refreshAssets"
text
size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton
><WorkspaceTools
v-model:open="toolsOpen"
title="形态生图配置与回执"
label="批量生图"
:has-receipt="!!session.receipt || !!session.promptReceipt"
><div class="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/${id}/storyboard`" class="text-button"
>去检查分镜参考图<ArrowRight :size="14"
/></RouterLink>
</div>
<DetailDisclosure title="母版继承与过期处理" class="mt-4">
人物场景道具形态图均可继承已锁定 Identity
的母版并记录本次引用来源母版变化后可筛选过期图重新生成候选并人工确认新的主参考图不要直接沿用旧资产进入下游
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
>管理主体身份 </RouterLink
></DetailDisclosure
>
<FormPromptBatch
:forms="forms"
:disabled="blocked"
:pending="operation.pending"
:receipt="session.promptReceipt"
v-model:concurrency="promptConcurrency"
v-model:force="promptForce"
@generate="generatePrompts"
/>
<div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
{{ staleForms.length }} 个形态主图已过期
</span>
<span class="ml-3 text-xs text-muted">图片模型 · 后端配置</span>
</p>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换
</p>
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
><div class="flex flex-wrap items-center justify-between gap-3">
<span class="flex items-center gap-2 text-xs">
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个形态 仍在使用旧
Identity Anchor
</span>
<ConfirmAction
label="重新生成过期形态"
:disabled="blocked || !concurrencyValid || !staleForms.length"
acknowledgement
description="只为身份母版已经变化的形态调用 图片模型,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
@confirm="generateStale"
/></div
></NAlert>
<NCollapse class="mt-4 pt-4"
><NCollapseItem name="details"
><template #header>项目批量生图</template>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28">
<span class="field-label">批量并发</span>
<NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'image-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-36"
><span class="field-label">本批上限可选</span
><NInputNumber
:disabled="operation.pending"
:value="typeof limit === 'number' ? limit : null"
@update:value="limit = $event ?? ''"
:min="1"
:step="1"
placeholder="不限制"
:input-props="{ 'aria-label': '生图本批上限' }"
/></label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">
并发和填写的数量上限必须是正整数
</p>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响上限只限制本次真正生图数量其余显示为待后续处理”,不计入跳过默认使用后端尺寸与正式提示词不会启动视频生成
</p></NCollapseItem
></NCollapse
>
</div>
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<NButton @click="exportReceipt" text size="small"
><Download :size="13" />导出诊断</NButton
>
</div>
<p 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.failed }}
<span v-if="session.receipt.result.remaining !== undefined">
· 待后续处理 {{ session.receipt.result.remaining }}</span
>
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
v-else-if="session.receipt.title === '刷新过期形态图'"
type="info"
:show-icon="false"
class="mt-3 text-xs"
>
新图当前仍是候选图请进入对应形态的查看图片与记录”,确认人物身份和造型后再设为主参考图旧主图在此之前仍保持过期状态
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
>{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准
</p>
</section>
</WorkspaceTools>
</div>
</div>
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
{{ staleForms.length }}
个形态主图与当前已锁定身份母版不一致请生成候选验图并切换主图再重建相关首帧
<NButton text size="small" @click="showStaleForms">筛选身份过期形态</NButton>
</NAlert>
<NAlert
v-if="impact.stale.value.length"
type="warning"
:show-icon="false"
class="mt-3"
title="下游主首帧需要处理"
data-keyframe-impact
>
{{ impact.stale.value.length }}
个镜头被后端报告主首帧过期此警告不表示形态图片本身失效请选择镜头核对当前素材确认过期后再重建首帧
<p v-if="impact.stale.value.some(item => item.inconsistent)" class="mt-2">
其中
{{ impact.stale.value.filter(item => item.inconsistent).length }}
个镜头的首帧与视频检查结果不一致不建议直接重复生图
</p>
</NAlert>
<NAlert v-if="impact.query.error.value" type="warning" :show-icon="false" class="mt-3"
>镜头影响检查失败暂时无法确认有无过期首帧{{ impact.query.error.value }}
<NButton text size="small" @click="impact.query.refresh">重试检查</NButton></NAlert
>
<div class="gallery-sticky-controls">
<div class="form-image-filter-region">
<div
class="form-image-toolbar"
:class="{
'has-impact-picker': impactOptions.length || sourceShotId,
'has-impact-actions': sourceShotId || targetFormId
}"
>
<div
v-if="impactOptions.length || sourceShotId"
class="asset-impact-picker"
role="group"
aria-label="镜头定位与导航"
>
<NSelect
:value="sourceShot?.shotId ?? null"
:options="impactOptions"
placeholder="选择过期镜头,查看关联素材"
clearable
aria-label="选择关联镜头"
class="asset-impact-select"
@update:value="selectImpactShot"
/>
<RouterLink v-if="sourceLocation" :to="sourceLocation" custom v-slot="{ href, navigate }">
<NButton
tag="a"
:href="href"
@click="navigate"
class="icon-button"
:aria-label="`返回第 ${sourceShot?.episodeNo} · 镜头 ${sourceShot?.shotNo}`"
:title="`返回第 ${sourceShot?.episodeNo} 集 · 镜头 ${sourceShot?.shotNo}`"
>
<template #icon><ArrowLeft :size="16" /></template>
<span class="sr-only"
>返回第 {{ sourceShot?.episodeNo }} · 镜头 {{ sourceShot?.shotNo }}</span
>
</NButton>
</RouterLink>
<NButton
v-if="sourceShotId || targetFormId"
class="icon-button"
aria-label="查看全部素材"
title="查看全部素材(清除定位与筛选)"
@click="resetFilters"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
</div>
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></NInput>
<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 class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
</div>
<div class="form-image-display-controls">
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
<div class="gallery-layout-switch" role="group" aria-label="图库显示方式">
<NButton
class="icon-button"
size="small"
:type="layout === 'grid' ? 'primary' : 'default'"
:aria-pressed="layout === 'grid'"
aria-label="网格布局"
title="网格布局"
@click="layout = 'grid'"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
<NButton
class="icon-button"
size="small"
:type="layout === 'masonry' ? 'primary' : 'default'"
:aria-pressed="layout === 'masonry'"
aria-label="瀑布流布局"
title="瀑布流布局"
@click="layout = 'masonry'"
>
<template #icon><Columns3 :size="16" /></template>
</NButton>
</div>
</div>
</div>
</div>
</div>
<div v-if="showImpactDetails" class="asset-impact-context" aria-label="镜头与素材定位">
<p v-if="sourceMissing" class="text-danger">来源镜头不存在或不属于当前项目未读取其素材</p>
<p v-if="sourceCurrent" class="text-xs">
当前检查{{
sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
}}请以后端最新就绪状态为准
</p>
<p v-if="sourceShot?.inconsistent" class="text-xs text-danger">
当前镜头首帧检查未标记过期但视频检查报告过期请核对后端检查逻辑与身份母版暂不建议重复生图
</p>
<p v-if="targetUnlinked" class="text-xs text-danger">
指定形态不在该镜头当前的有效引用列表中关联可能已调整或主图缺失请从下方关联素材重新核对
</p>
<NScrollbar
v-if="linkedReferences.length && !impact.references.error.value"
:key="sourceShotId"
class="asset-impact-links-scroll"
content-class="asset-impact-links"
x-scrollable
trigger="none"
role="region"
aria-label="本镜头关联素材可横向滚动"
>
<span class="text-xs text-muted">本镜头当前关联素材</span
><RouterLink
v-for="target in linkedReferences"
:key="target.formId || target.subjectRef"
:to="referenceLocation(id, sourceShotId, target)"
class="text-button"
>{{ target.label }} </RouterLink
>
</NScrollbar>
<p v-if="impact.references.loading.value" class="text-xs text-muted">正在读取镜头关联素材</p>
<p v-if="impact.references.error.value" class="text-xs text-danger">
{{ impact.references.error.value }}
<NButton text size="small" @click="impact.references.refresh">重试读取素材</NButton>
</p>
</div>
</div>
<NAlert
v-if="targetFormId"
:type="targetForm ? 'info' : 'warning'"
:show-icon="false"
class="mt-3"
data-focused-material
>
<template v-if="targetForm"
>已定位{{ targetForm.subject.name }} · {{ targetForm.name }}{{
targetForm.subject.ref
}})。可直接查看下方图片与记录主体身份与母版</template
>
<template v-else>{{
query.loading.value ? '正在定位素材…' : '指定形态不存在或已被移除请从本镜头当前关联素材重新选择。'
}}</template>
<NButton text size="small" @click="resetFilters">清除定位</NButton>
</NAlert>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span></NAlert
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</NAlert>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片
</p>
<div
v-else-if="filtered.length"
class="form-image-grid"
:class="{ 'form-image-masonry': layout === 'masonry' }"
>
<article
v-for="form in filtered"
:key="form.id"
class="panel form-image-card"
:class="{
'ring-1 ring-danger': isPrimaryIdentityStale(form),
'form-image-card-focused': targetFormId === form.id
}"
:data-form-id="form.id"
:style="{ '--form-image-aspect': formCoverAspectRatio(form) }"
>
<AssetImage
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
:object-fit="layout === 'masonry' ? 'contain' : 'cover'"
preview
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
/>
<div class="p-4">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">{{ form.subject.name }}</h3>
<code class="subject-ref">{{ form.subject.ref }}</code>
</div>
<p class="mt-2 text-xs">
{{ form.name }}<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag>
</p>
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
{{ form.description }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false"
>身份母版已变更</NTag
>
<NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag>
<NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag>
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
>最近一次生成失败</span
>
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
</div>
<NAlert
v-if="isPrimaryIdentityStale(form)"
type="error"
:show-icon="false"
class="mt-3 text-xs leading-6"
>
当前主图未能匹配最新已锁定母版请基于当前 Identity Anchor
重新生成候选图并在确认后设置为新的主参考图
</NAlert>
<NAlert
v-if="
sourceShot?.primaryKeyframeStale &&
impact.relatedForms.value.has(form.id) &&
!impact.references.error.value
"
type="warning"
:show-icon="false"
class="mt-3 text-xs leading-6"
>
第 {{ sourceShot.episodeNo }} 集 · BEAT {{ sourceShot.beatNo }} / 镜头
{{ sourceShot.shotNo }} 的待处理首帧关联此素材。
{{
sourceShot.inconsistent
? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。'
: isPrimaryIdentityStale(form)
? '请先更新该形态主图,再重建首帧。'
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
}}
<RouterLink v-if="sourceLocation" :to="sourceLocation" class="text-button mt-2"
>返回该镜头处理 →</RouterLink
>
</NAlert>
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
{{ form.images[0].error }}
</p>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
<ActionMenu
:label="`${form.subject.name} · ${form.name} 更多操作`"
:items="[
{ key: 'prompt', label: '提示词与生成' },
{ key: 'identity', label: '主体身份与母版' }
]"
@select="
$event === 'prompt'
? openPrompt(form)
: router.push({
path: `/projects/${id}/subject-identity`,
query: { subjectId: form.subjectId }
})
"
/>
<NButton :disabled="blocked" @click="openGenerate(form)"
><ImagePlus :size="14" />
{{
isPrimaryIdentityStale(form)
? '基于当前母版重新生成'
: coverImage(form)
? '再生成一张'
: '生成图片'
}}</NButton
>
</div>
</div>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
:title="forms.length ? '没有匹配的形态' : '还没有正式形态'"
:description="
forms.length
? '调整筛选条件后再查看。批量生图不受筛选影响。'
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
"
>
<NButton v-if="forms.length" @click="resetFilters">清除筛选</NButton>
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
>前往剧本拆解</RouterLink
> </EmptyState
><FormPromptDialog
v-model:open="promptOpen"
:form="promptForm"
:disabled="blocked || !promptForm"
@generate="generatePrompt" /><GenerateImageDialog
v-model:open="generateOpen"
:form="generateForm"
:disabled="blocked || !generateForm"
@generate="submitGenerate" /><ImageGalleryDialog
v-model:open="galleryOpen"
:project-id="id"
:form="galleryForm"
:disabled="blocked || !galleryForm"
@changed="refreshAssets" /></template
></WorkspacePage>
</template>