feat: 接入形态图库与 Seedream 生图操作
展示已有主图、候选图和失败记录,支持单图及批量生成、主图选择。 补充正式 ID 校验、费用确认、图片加载占位和回归测试。 依赖后端 eac8dc3 的项目形态图库只读接口。
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user