198 lines
9.0 KiB
Vue
198 lines
9.0 KiB
Vue
<script setup lang="ts">
|
||
import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
|
||
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, currentIdentityAnchorId } 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
|
||
},
|
||
false
|
||
)
|
||
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
|
||
><NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
|
||
</NButton>
|
||
</div>
|
||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-3"
|
||
>{{ query.error.value }} 当前保留上次读取的记录,主图操作已暂停。
|
||
</NAlert>
|
||
<NAlert v-if="operation.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||
operation.error
|
||
}}</NAlert>
|
||
<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"
|
||
object-fit="contain"
|
||
preview
|
||
/>
|
||
<div class="image-history-heading">
|
||
<h4 class="font-medium">历史记录 · {{ images.length }} 条</h4>
|
||
<span class="text-muted">点击缩略图切换预览,可横向滚动</span>
|
||
</div>
|
||
<NScrollbar
|
||
x-scrollable
|
||
trigger="none"
|
||
content-class="image-history-content"
|
||
:content-style="{ width: 'max-content' }"
|
||
class="image-history"
|
||
aria-label="图片历史"
|
||
>
|
||
<NButton
|
||
v-for="image in images"
|
||
:key="image.id"
|
||
:aria-label="`查看图片 ${image.id}`"
|
||
:aria-pressed="image.id === selected.id"
|
||
@click="selectedId = image.id"
|
||
class="image-history-item"
|
||
:class="{ selected: image.id === selected.id }"
|
||
><AssetImage
|
||
:src="image.status === 'completed' ? image.imageUrl : null"
|
||
:alt="`${form?.name}候选图`"
|
||
:retryable="false"
|
||
:empty-text="
|
||
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
|
||
image.status
|
||
]
|
||
"
|
||
/><span class="image-history-label">{{
|
||
image.isPrimary
|
||
? '主参考图'
|
||
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
||
image.status
|
||
]
|
||
}}</span></NButton
|
||
>
|
||
</NScrollbar>
|
||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2">
|
||
<StatusBadge :status="selected.status" /><NTag
|
||
v-if="selected.isPrimary"
|
||
size="small"
|
||
:bordered="false"
|
||
>当前主参考图</NTag
|
||
><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>
|
||
<NAlert
|
||
v-if="selected.error"
|
||
role="alert"
|
||
type="error"
|
||
:show-icon="false"
|
||
class="mt-3 whitespace-pre-wrap"
|
||
>{{ selected.error }}</NAlert
|
||
>
|
||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||
<NButton v-if="!selected.isPrimary" :disabled="!canSetPrimary" @click="confirming = true">
|
||
设为主参考图</NButton
|
||
><span class="text-[11px] text-muted"
|
||
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
||
>
|
||
</div>
|
||
<NAlert v-if="confirming" type="info" :show-icon="false" class="mt-3"
|
||
><p class="text-xs">确认将这张图用于此形态后续的分镜参考?旧主图仍会保留,此操作不调用模型。</p>
|
||
<div class="mt-3 flex gap-3">
|
||
<NButton :disabled="!canSetPrimary" @click="choosePrimary" type="primary"> 确认切换主图</NButton
|
||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||
</div></NAlert
|
||
>
|
||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
|
||
<p v-if="form && currentIdentityAnchorId(form)" class="mt-2 break-all text-[11px] text-muted">
|
||
当前已锁定身份母版 ID:{{ currentIdentityAnchorId(form) }}
|
||
</p>
|
||
</div>
|
||
<p v-else class="py-10 text-center text-sm text-muted">
|
||
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
|
||
</p>
|
||
</AppDialog>
|
||
</template>
|