742 lines
40 KiB
Vue
742 lines
40 KiB
Vue
<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>
|