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

989 lines
48 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 AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule } from '../../lib/form-rules'
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,
NPopover,
NScrollbar,
NSelect,
NTag
} from 'naive-ui'
import { computed, onScopeDispose, ref, watch, type Directive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Search,
RefreshCw,
Download,
ImagePlus,
ArrowLeft,
ArrowRight,
LayoutGrid,
Columns3,
Settings2
} 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, 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 { masonryRowSpan, type GalleryLayout } from './layout'
/** 形态图库以正式数据库为准;首次读取、手动刷新和自身操作完成后更新,不定时刷新。 */
const route = useRoute()
const router = useRouter()
const {
id,
forms,
query,
refreshProject,
operation,
session,
concurrency,
force,
limit,
promptConcurrency,
promptForce,
batchModule,
generatePrompt,
generatePrompts,
blocked,
running,
batchValid,
generate,
generateProject
} = 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)
/** 移动端禁用纯图标按钮的悬浮说明,避免触摸操作弹出多余浮层。 */
const isMobile = ref(false)
if (typeof window !== 'undefined') {
const mobileMedia = window.matchMedia('(max-width: 760px)')
const syncMobile = () => (isMobile.value = mobileMedia.matches)
syncMobile()
mobileMedia.addEventListener('change', syncMobile)
onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile))
}
/** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */
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>('masonry')
const module = ref('all')
/** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */
type ImageStatusFilter = 'all' | 'missing'
const statusFilter = ref<ImageStatusFilter>('all')
const statusFilterOptions: Array<{ label: string; value: ImageStatusFilter }> = [
{ label: '全部状态', value: 'all' },
{ label: '缺少主图', value: 'missing' }
]
/** 保存每张卡片的尺寸观察器,切换回网格时及时释放。 */
const masonryCardObservers = new WeakMap<HTMLElement, ResizeObserver>()
/**
* 按素材顺序重新计算整个瀑布流的位置。
* @param container 瀑布流容器元素
*/
function syncMasonryLayout(container: HTMLElement) {
if (!container.classList.contains('form-image-masonry')) return
const styles = getComputedStyle(container)
const rowHeight = Number.parseFloat(styles.gridAutoRows)
const rowGap = Number.parseFloat(styles.rowGap)
const cardGap = Number.parseFloat(styles.columnGap)
const columnCount = Math.max(1, styles.gridTemplateColumns.split(/\s+/).filter(Boolean).length)
const nextRows = Array<number>(columnCount).fill(1)
const cards = Array.from(container.children).filter(
(element): element is HTMLElement =>
element instanceof HTMLElement && element.classList.contains('form-image-card')
)
cards.forEach((card, index) => {
const column = index % columnCount
const span = masonryRowSpan(card.getBoundingClientRect().height, rowHeight, rowGap)
card.style.setProperty('--form-image-column', String(column + 1))
const rowStart = nextRows[column] ?? 1
card.style.setProperty('--form-image-row-start', String(rowStart))
card.style.setProperty('--form-image-row-span', String(span))
// 隐式网格行不加 gap,仅在两张卡片之间预留与横向一致的间距。
nextRows[column] = rowStart + span + Math.ceil(cardGap / rowHeight)
})
}
/**
* 开始监听瀑布流卡片尺寸变化。
* @param element 形态图片卡片元素
*/
function observeMasonryCard(element: HTMLElement) {
if (masonryCardObservers.has(element) || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => {
if (element.parentElement) syncMasonryLayout(element.parentElement)
})
observer.observe(element)
masonryCardObservers.set(element, observer)
if (element.parentElement) syncMasonryLayout(element.parentElement)
}
/**
* 停止监听瀑布流卡片并清理网格跨度。
* @param element 形态图片卡片元素
*/
function unobserveMasonryCard(element: HTMLElement) {
masonryCardObservers.get(element)?.disconnect()
masonryCardObservers.delete(element)
element.style.removeProperty('--form-image-column')
element.style.removeProperty('--form-image-row-start')
element.style.removeProperty('--form-image-row-span')
}
/** 让卡片按实际高度占据网格行,保持从左到右的 DOM 排列顺序。 */
const vMasonryCard: Directive<HTMLElement, boolean> = {
mounted(element, binding) {
if (binding.value) observeMasonryCard(element)
},
updated(element, binding) {
if (binding.value === binding.oldValue) return
if (binding.value) observeMasonryCard(element)
else unobserveMasonryCard(element)
},
beforeUnmount(element) {
unobserveMasonryCard(element)
}
}
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) &&
(statusFilter.value !== 'missing' || !primaryImage(form.images)) &&
`${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'
statusFilter.value = 'all'
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'
statusFilter.value = 'all'
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()])
}
const formModel = computed(() => ({ concurrency: concurrency.value, limit: limit.value }))
const rules = { concurrency: integerRule('批量并发'), limit: integerRule('本批上限', 1, true) }
</script>
<template>
<WorkspacePage compact class="gallery-workspace-page"
><template #default>
<!-- 抽屉只保留内容入口跟随图库显示控制移动到筛选区 -->
<WorkspaceTools
triggerless
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="mt-5 flex flex-wrap items-center gap-3">
<span class="text-xs text-muted">批处理范围</span>
<NSelect
v-model:value="batchModule"
class="w-40"
aria-label="形态批处理范围"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('仅人物'), value: 'character' },
{ label: String('仅场景'), value: 'scene' },
{ label: String('仅道具'), value: 'prop' }
]"
/>
<span class="text-xs text-muted">同时应用于批量提示词与批量生图</span>
</div>
<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 class="ml-3 text-xs text-muted">图片模型 · 后端配置</span>
</p>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换
</p>
<NCollapse class="mt-4 pt-4" :default-expanded-names="['details']"
><NCollapseItem name="details"
><template #header>项目批量生图</template>
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
<div class="form-controls mt-4 flex flex-wrap gap-4">
<NFormItem
class="w-28"
path="concurrency"
label="批量并发"
:label-props="{ for: 'image-concurrency' }"
>
<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>
</NFormItem>
<NFormItem class="w-36" path="limit" label="本批上限(可选)"
><NInputNumber
:disabled="operation.pending"
:value="typeof limit === 'number' ? limit : null"
@update:value="limit = $event ?? ''"
:min="1"
:step="1"
placeholder="不限制"
:input-props="{ 'aria-label': '生图本批上限' }"
/></NFormItem>
<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>
</AppForm>
<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>
<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>
<!-- 关联说明筛选和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<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
class="search-filter"
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></NInput>
<NSelect
class="module-filter"
aria-label="筛选主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
<NSelect
v-model:value="statusFilter"
:options="statusFilterOptions"
aria-label="筛选图片状态"
class="status-filter"
/>
<div class="form-image-display-controls">
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
<!-- 四个纯图标按钮使用统一间距桌面端通过 Popover 解释用途 -->
<div class="gallery-layout-switch" role="group" aria-label="图库视图与操作">
<NPopover trigger="hover" placement="top" :disabled="isMobile">
<template #trigger>
<NButton
class="icon-button layout-option-button"
:class="{ 'layout-option-active': layout === 'grid' }"
size="small"
:type="layout === 'grid' ? 'primary' : 'default'"
:aria-pressed="layout === 'grid'"
aria-label="网格布局"
@click="layout = 'grid'"
>
<template #icon><LayoutGrid :size="16" /></template>
</NButton>
</template>
网格布局
</NPopover>
<NPopover trigger="hover" placement="top" :disabled="isMobile">
<template #trigger>
<NButton
class="icon-button layout-option-button"
:class="{ 'layout-option-active': layout === 'masonry' }"
size="small"
:type="layout === 'masonry' ? 'primary' : 'default'"
:aria-pressed="layout === 'masonry'"
aria-label="瀑布流布局"
@click="layout = 'masonry'"
>
<template #icon><Columns3 :size="16" /></template>
</NButton>
</template>
瀑布流布局
</NPopover>
<NPopover trigger="hover" placement="top" :disabled="isMobile">
<template #trigger>
<NButton
class="icon-button gallery-action-button"
size="small"
:disabled="query.loading.value || impact.query.loading.value"
aria-label="刷新图库"
@click="refreshAssets"
>
<template #icon>
<RefreshCw
:size="16"
:class="{ 'animate-spin': query.loading.value }"
/>
</template>
</NButton>
</template>
刷新图库
</NPopover>
<NPopover trigger="hover" placement="top" :disabled="isMobile">
<template #trigger>
<NButton
class="icon-button gallery-action-button"
size="small"
data-workspace-tools
aria-label="批量生图"
aria-haspopup="dialog"
:aria-expanded="toolsOpen"
@click="toolsOpen = true"
>
<template #icon><Settings2 :size="16" /></template>
</NButton>
</template>
{{
session.receipt || session.promptReceipt ? '批量生图(有回执)' : '批量生图'
}}
</NPopover>
</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"
v-masonry-card="layout === 'masonry'"
:key="form.id"
class="panel form-image-card"
:class="{
'form-image-card-focused': targetFormId === form.id
}"
:data-form-id="form.id"
>
<AssetImage
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
:class="{ 'form-image-has-source': Boolean(coverImage(form)?.imageUrl) }"
: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 flex items-center gap-2 text-xs">
<span>{{ form.name }}</span>
<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-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="
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
? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。'
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
}}
<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" /> {{ 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>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.form-image-grid {
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))];
--gallery-gap: 12px;
gap: var(--gallery-gap);
}
.form-image-card {
@apply overflow-hidden;
}
/* 普通网格的有图与空状态统一使用正方形图片区;瀑布流仍按原图比例展示。 */
.form-image-grid:not(.form-image-masonry) .asset-image {
aspect-ratio: 1 / 1;
}
/* 浅色卡片使用轻灰底,与白色画布区分;网格和瀑布流共用,深色保持原有层次。 */
:root:not([data-theme='dark']) .form-image-card {
background: var(--app-subtle);
}
.gallery-workspace-page .workspace-scroll-content {
@apply pt-2;
}
.gallery-workspace-page .gallery-sticky-controls {
@apply sticky top-0 z-10 bg-(--app-body) mt-2.5 mb-0;
}
.gallery-workspace-page .form-image-grid {
@apply pt-3;
}
.gallery-workspace-page .form-image-masonry {
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))] items-start;
/* 避免每个 1px 网格行都叠加间距,造成卡片下方出现额外空白。 */
column-gap: var(--gallery-gap);
row-gap: 0;
grid-auto-flow: row dense;
grid-auto-rows: 1px;
}
.form-image-masonry > .form-image-card {
grid-column-start: var(--form-image-column, auto);
grid-row-start: var(--form-image-row-start, auto);
grid-row-end: span var(--form-image-row-span, 1);
}
.form-image-masonry .asset-image {
aspect-ratio: 1 / 1;
}
.form-image-masonry .asset-image.form-image-has-source {
@apply aspect-auto;
}
.form-image-masonry .asset-image.form-image-has-source .n-image,
.form-image-masonry .asset-image.form-image-has-source .n-image img {
@apply h-auto;
}
.gallery-sticky-controls > .form-image-filter-region {
@apply my-0;
}
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
@apply [overflow-anchor:none];
}
.form-image-filter-region {
@apply my-4 p-3;
/* 外层使用浅灰分组,输入与下拉恢复为表面色,避免同色后失去控件边界。 */
--app-field: var(--app-surface);
--app-field-hover: var(--app-surface);
background: var(--app-subtle);
container-type: inline-size;
}
.form-image-toolbar {
@apply grid grid-cols-[minmax(0,_1fr)] items-center gap-y-3 gap-x-4;
}
.form-image-toolbar.has-impact-picker {
@apply grid-cols-[minmax(220px,_340px)_minmax(0,_1fr)];
}
.form-image-toolbar.has-impact-picker.has-impact-actions {
@apply grid-cols-[minmax(260px,_420px)_minmax(0,_1fr)];
}
.asset-impact-picker {
@apply flex items-center gap-2 min-w-0;
}
.asset-impact-picker .asset-impact-select {
@apply flex-1;
}
.form-image-toolbar > * {
@apply min-w-0;
}
.form-image-filters {
@apply grid grid-cols-[minmax(200px,_360px)_152px_160px_auto] items-center gap-y-3 gap-x-4;
}
.form-image-filters > * {
@apply min-w-0;
}
.filter-result-count {
@apply justify-self-end text-muted text-xs whitespace-nowrap;
}
.form-image-display-controls {
@apply flex items-center justify-self-end gap-4;
}
.gallery-layout-switch {
@apply flex items-center gap-2;
}
/* 未选中的排版按钮与图库操作按钮共用浅灰底,同时不覆盖选中布局的绿色状态。 */
.gallery-layout-switch .layout-option-button:not(.layout-option-active),
.gallery-layout-switch .gallery-action-button {
background: var(--app-control-hover);
}
@container (max-width: 1200px) {
.form-image-toolbar.has-impact-picker,
.form-image-toolbar.has-impact-picker.has-impact-actions {
@apply grid-cols-[minmax(0,_1fr)];
}
}
@container (max-width: 900px) {
.form-image-filters {
@apply grid-cols-[minmax(0,_1fr)_152px];
}
.status-filter {
@apply col-start-1 row-start-2;
}
.form-image-display-controls {
@apply col-start-2 row-start-2;
}
}
@container (max-width: 460px) {
.form-image-filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* 移动端固定为两行:搜索给类型选择让位,状态可收缩以优先保留四个完整操作按钮。 */
.search-filter {
flex: 1 1 calc(100% - 130px);
min-width: 0;
}
.module-filter {
flex: 0 0 120px;
}
.status-filter {
flex: 0 1 110px;
min-width: 90px;
}
.form-image-display-controls {
flex: 1 0 152px;
width: auto;
justify-content: flex-end;
}
.filter-result-count {
@apply hidden;
}
}
.asset-impact-context {
@apply flex flex-col gap-2.5 py-[5px] px-4 bg-(--app-subtle) m-0 min-w-0;
}
.asset-impact-links-scroll.n-scrollbar {
@apply h-auto min-w-0 max-w-full;
}
.asset-impact-links {
@apply flex items-center gap-3 w-max whitespace-nowrap min-h-8 py-1.5;
}
.asset-impact-links > * {
@apply shrink-0;
}
.asset-impact-select {
@apply min-w-0 w-full;
}
.form-image-card-focused {
@apply shadow-[inset_3px_0_0_var(--app-accent-text)];
}
</style>