989 lines
48 KiB
Vue
989 lines
48 KiB
Vue
<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>
|