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,180 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { getOperation, runOperation } from '../../workflows/operations'
import { subjectImagesApi } from '../api'
import { hasRunningImages, primaryImage } from '../model'
import type { SubjectFormAsset } from '../types'
/** 形态图片历史与主图选择;历史查询只在弹窗打开时进行。 */
const props = defineProps<{ projectId: string; form: SubjectFormAsset | null; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ changed: [] }>()
const selectedId = ref('')
const confirming = ref(false)
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
const query = usePolling(key, async (id, signal) => {
if (!id) return null
const rows = await subjectImagesApi.listImages(id, signal)
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
return rows
})
const images = computed(() => query.data.value ?? [])
const selected = computed(
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
)
const operation = computed(() => getOperation(props.projectId))
const canSetPrimary = computed(
() =>
!props.disabled &&
!operation.value.pending &&
!query.error.value &&
!hasRunningImages(images.value) &&
selected.value?.status === 'completed' &&
!!selected.value.imageUrl &&
!selected.value.isPrimary
)
const imageUrl = computed(() => (selected.value?.imageUrl ? referenceImageUrl(selected.value.imageUrl) : null))
watch(key, () => {
selectedId.value = ''
confirming.value = false
})
watch(
() => selected.value?.id,
() => {
confirming.value = false
}
)
/** 切换主图只调用 PUT,不调用模型;目标在提交时固定。 */
async function choosePrimary() {
if (!canSetPrimary.value || !confirming.value || !selected.value || !props.form) return
const formId = props.form.id
const imageId = selected.value.id
const projectId = props.projectId
confirming.value = false
await runOperation(projectId, `设置 ${props.form.subject.name} · ${props.form.name} 主图`, async () => {
const result = await subjectImagesApi.setPrimary(formId, imageId)
if (
!result ||
result.id !== imageId ||
result.subjectFormId !== formId ||
!result.isPrimary ||
result.status !== 'completed'
)
throw new Error('接口未确认主图切换,请先刷新核对后端状态。')
})
emit('changed')
await query.refresh()
}
</script>
<template>
<AppDialog
v-model:open="open"
:title="`${form?.subject.name || ''} · ${form?.name || '形态图片'}`"
description="主参考图会供后续分镜使用。切换主图不会重新生成已有视频提示词。"
wide
>
<div class="mt-5 flex items-center justify-between">
<span class="text-xs text-muted">{{ images.length }} 条图片记录</span
><button class="text-button" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
</button>
</div>
<p v-if="query.error.value" class="alert alert-error mt-3" role="alert">
{{ query.error.value }} 当前保留上次读取的记录主图操作已暂停
</p>
<p v-if="operation.error" class="alert alert-error mt-3" role="alert">{{ operation.error }}</p>
<div v-if="selected" class="mt-4">
<AssetImage
:key="selected.id"
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${form?.subject.name} · ${form?.name}`"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
class="asset-image-preview"
/>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<StatusBadge :status="selected.status" /><span v-if="selected.isPrimary" class="tag"
>当前主参考图</span
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
</div>
<a
v-if="imageUrl && selected.status === 'completed'"
:href="imageUrl"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开原图<ExternalLink :size="12"
/></a>
</div>
<p v-if="selected.error" class="alert alert-error mt-3 whitespace-pre-wrap" role="alert">
{{ selected.error }}
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
v-if="!selected.isPrimary"
class="button button-secondary"
:disabled="!canSetPrimary"
@click="confirming = true"
>
设为主参考图</button
><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
>
</div>
<div v-if="confirming" class="alert mt-3">
<p class="text-xs">确认将这张图用于此形态后续的分镜参考旧主图仍会保留此操作不调用模型</p>
<div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSetPrimary" @click="choosePrimary">
确认切换主图</button
><button class="text-button" @click="confirming = false">取消</button>
</div>
</div>
<details v-if="selected.prompt" class="mt-4 text-xs">
<summary class="cursor-pointer text-muted">查看本次实际提示词</summary>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
Negative prompt: {{ selected.negativePrompt }}
</p>
</details>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
<div class="image-history mt-5" aria-label="图片历史">
<button
v-for="image in images"
:key="image.id"
class="image-history-item"
:class="{ selected: image.id === selected.id }"
:aria-label="`查看图片 ${image.id}`"
:aria-pressed="image.id === selected.id"
@click="selectedId = image.id"
>
<AssetImage
:src="image.status === 'completed' ? image.imageUrl : null"
:alt="`${form?.name}候选图`"
:retryable="false"
:empty-text="
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
image.status
]
"
/><span class="mt-1 block text-[10px]">{{
image.isPrimary
? '主参考图'
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
image.status
]
}}</span>
</button>
</div>
</div>
<p v-else class="py-10 text-center text-sm text-muted">
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
</p>
</AppDialog>
</template>