feat: 接入形态图库与 Seedream 生图操作

展示已有主图、候选图和失败记录,支持单图及批量生成、主图选择。
补充正式 ID 校验、费用确认、图片加载占位和回归测试。
依赖后端 eac8dc3 的项目形态图库只读接口。
This commit is contained in:
GouJ
2026-08-28 15:16:25 +08:00
parent bae69c3c62
commit 47443c0efe
26 changed files with 1338 additions and 25 deletions
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Search, RefreshCw, Download, ImagePlus, ArrowRight } 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'
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
const route = useRoute()
const {
id,
forms,
query,
operation,
session,
concurrency,
force,
blocked,
running,
batchValid,
generate,
generateProject
} = useSubjectImages()
const search = ref('')
const module = ref('all')
const onlyMissing = ref(false)
const generateOpen = ref(false)
const galleryOpen = ref(false)
const generateId = ref('')
const galleryId = ref('')
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 =>
(module.value === 'all' || form.subject.module === module.value) &&
(!onlyMissing.value || !primaryImage(form.images)) &&
`${form.subject.name} ${form.subject.ref} ${form.name}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
)
)
watch(
() => route.query.subjectRef,
value => {
search.value = typeof value === 'string' ? value : ''
},
{ immediate: 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
}
</script>
<template>
<section class="mt-7">
<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>
<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"
>Seedream · 后端配置</span
>
</p>
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
</button>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
6 秒更新图片记录主图供分镜引用候选图和历史失败记录均保留覆盖率不是当前批次进度
</p>
<details class="mt-4 border-t border-line pt-4">
<summary class="cursor-pointer text-xs font-medium">项目批量生图</summary>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28"
><span class="field-label">批量并发</span
><input
id="image-concurrency"
v-model.number="concurrency"
type="number"
min="1"
step="1"
class="input"
:disabled="operation.pending" /></label
><label class="flex items-center gap-2 pb-2 text-xs"
><input
v-model="force"
type="checkbox"
class="accent-accent"
:disabled="operation.pending"
/>已有主图也新增候选图</label
><ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@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>
</details>
</div>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span>
</p>
<p v-if="running" class="alert mt-4" role="status">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</p>
<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>
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
</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 }}
</p>
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图或关闭新增候选图后补齐缺失主图
</p>
<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>
<div class="my-6 flex flex-wrap items-center gap-4">
<label class="search-field"
><Search :size="14" /><input
v-model="search"
placeholder="搜索主体、引用或形态"
aria-label="搜索形态图片" /></label
><select v-model="module" class="input w-auto" aria-label="筛选主体类型">
<option value="all">全部类型</option>
<option value="character">人物</option>
<option value="scene">场景</option>
<option value="prop">道具</option></select
><label class="flex items-center gap-2 text-xs"
><input v-model="onlyMissing" type="checkbox" class="accent-accent" />仅看缺少主图</label
><span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
</div>
<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">
<article v-for="form in filtered" :key="form.id" class="panel form-image-card">
<AssetImage
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
: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 }}<span v-if="form.isDefault" class="tag ml-2">默认形态</span>
</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" /><span
v-if="primaryImage(form.images)"
class="tag"
>主参考图</span
><span v-else-if="coverImage(form)" class="tag">候选图 · 尚未设主图</span
><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>
<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">
<button class="text-button" @click="openGallery(form)">查看图片与记录</button
><button class="button button-secondary" :disabled="blocked" @click="openGenerate(form)">
<ImagePlus :size="14" />{{ coverImage(form) ? '再生成一张' : '生成图片' }}
</button>
</div>
</div>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
:title="forms.length ? '没有匹配的形态' : '还没有正式形态'"
:description="
forms.length
? '调整筛选条件后再查看。批量生图不受筛选影响。'
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
"
><button v-if="forms.length" class="button button-secondary" @click="resetFilters">清除筛选</button
><RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
>前往剧本拆解</RouterLink
></EmptyState
>
<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="query.refresh"
/>
</section>
</template>