feat: 统一图片铺满展示与原生预览

This commit is contained in:
GouJ
2026-09-01 18:57:39 +08:00
parent 352d7bb73b
commit 3a60eebae0
13 changed files with 104 additions and 31 deletions
@@ -260,6 +260,7 @@ async function inspectSpecs() {
<AssetImage
:src="item.status === 'completed' ? item.imageUrl : null"
:alt="`镜头 ${shot.shotNo} 首帧`"
preview
:empty-text="item.status === 'failed' ? '首帧生成失败' : '首帧生成中'"
/>
<div class="p-3">
@@ -2,6 +2,7 @@
import { NScrollbar, NAlert, NButton, NCheckbox } from 'naive-ui'
import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
import { AssetImage } from '../../../components/ui'
import { storyboardApi } from '../api'
import { getStoryboardSession, referenceImageUrl } from '../model'
import type { DesignedShot, ShotReferences } from '../types'
@@ -17,7 +18,6 @@ const references = ref<ShotReferences | null>(null)
const spec = ref<ShotGenerationSpec | null>(null)
const errors = ref({ references: '', spec: '' })
const busy = ref({ references: false, spec: false })
const brokenImages = ref<string[]>([])
const force = ref(false)
const prompt = computed(() => getStoryboardSession(props.projectId).prompts[props.shot.shotId])
const operation = computed(() => getOperation(props.projectId))
@@ -33,7 +33,6 @@ function reset() {
spec.value = null
errors.value = { references: '', spec: '' }
busy.value = { references: false, spec: false }
brokenImages.value = []
}
watch(() => JSON.stringify([props.projectId, props.shot.shotId, props.shot.direction, props.shot.visualState]), reset)
onScopeDispose(reset)
@@ -54,7 +53,6 @@ async function load(kind: 'references' | 'spec') {
if (version !== revision) return
if (result.shotId !== shotId) throw new Error('参考图返回的 Shot ID 不匹配。')
references.value = result
brokenImages.value = []
} else {
const result = await storyboardApi.generationSpec(shotId, controller.signal)
if (version !== revision) return
@@ -117,20 +115,13 @@ function exportResult(kind: 'spec' | 'prompt') {
<template v-if="references">
<div class="reference-grid mt-4">
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
<a
v-if="referenceImageUrl(item.imageUrl) && !brokenImages.includes(item.imageId)"
:href="referenceImageUrl(item.imageUrl)!"
target="_blank"
rel="noopener noreferrer"
class="block"
><img
:src="referenceImageUrl(item.imageUrl)!"
:alt="`${item.subjectName} · ${item.subjectFormName}`"
loading="lazy"
referrerpolicy="no-referrer"
@error="brokenImages.push(item.imageId)"
/></a>
<div v-else class="reference-placeholder">图片不可用</div>
<AssetImage
:key="item.imageId"
:src="referenceImageUrl(item.imageUrl)"
:alt="`${item.subjectName} · ${item.subjectFormName}`"
empty-text="图片不可用"
preview
/>
<div class="p-3">
<p class="text-xs font-medium">{{ item.subjectName }} · {{ item.subjectFormName }}</p>
<code class="mt-1 block text-[10px] text-muted">{{ item.subjectRef }}</code
@@ -527,6 +527,7 @@ watch(
<AssetImage
:src="anchor.imageUrl"
:alt="`${subject.name}当前身份母版`"
preview
class="h-20 w-20 shrink-0 rounded"
/>
<div>
@@ -64,6 +64,7 @@ function chooseAnchor() {
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
class="asset-image-preview mt-4"
preview
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
/>
<div class="image-history-heading">
+38 -1
View File
@@ -1,6 +1,6 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NScrollbar } from 'naive-ui'
import { NImage, NScrollbar } from 'naive-ui'
import { AssetImage } from '../../components/ui'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import IdentityGallery from './components/IdentityGallery.vue'
@@ -45,6 +45,43 @@ function input(selector: string, value: string) {
}
describe('身份图与母版契约', () => {
it('大图和历史统一 cover,缩略图只切换记录,大图单独打开完整原图', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: true,
disabled: false,
images: [
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }),
identityImageFixture({
id: 'front',
viewType: 'front',
isAnchor: false,
imageUrl: '/storage/front.png'
})
]
}
})
const images = wrapper.findAllComponents(NImage)
expect(images).toHaveLength(3)
expect(images.every(image => image.props('objectFit') === 'cover')).toBe(true)
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
await flushPromises()
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
const main = wrapper.get('.asset-image-preview img')
expect(main.attributes('src')).toContain('/storage/front.png')
await main.trigger('click')
await flushPromises()
const original = document.querySelector<HTMLImageElement>('.n-image-preview')!
expect(original.getAttribute('src')).toBe(main.attributes('src'))
expect(original.style.objectFit).not.toBe('cover')
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
wrapper = mount(IdentityGallery, {
@@ -101,6 +101,7 @@ async function choosePrimary() {
:alt="`${form?.subject.name} · ${form?.name}`"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
class="asset-image-preview"
preview
/>
<div class="image-history-heading">
<h4 class="font-medium">历史记录 · {{ images.length }} </h4>
@@ -85,6 +85,7 @@ function remove(image: VisualStyleImage) {
:src="image.imageUrl"
:alt="`${categories[image.category]}风格参考图`"
class="aspect-square"
preview
/>
<p class="mt-3 text-xs">
{{ categories[image.category] }} · {{ image.enabled ? '已启用' : '已停用' }} · 排序
+39
View File
@@ -525,6 +525,10 @@ describe('视觉风格与主体身份工作区', () => {
'/api/subjects/subject-db-1/identity/images/candidate/anchor'
)
expect(wrapper!.text()).toContain('当前身份母版')
const anchorImage = wrapper!
.findAllComponents(NImage)
.find(image => image.props('alt')?.endsWith('当前身份母版'))!
expect(anchorImage.props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
})
it('选角进度卡片分层显示长姓名、编号和状态,点击仍按正式 ID 切换主体', async () => {
@@ -874,6 +878,41 @@ describe('工作台页面交互', () => {
expect(wrapper!.find('.form-image-card img').exists()).toBe(true)
expect(button('补齐项目主参考图').disabled).toBe(true)
})
it('分镜参考图使用 cover 和原生预览,查看图片不触发生成功能', async () => {
const shot = designedShot()
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({
shotId: shot.shotId,
references: [
{
shotSubjectId: 'binding-db',
subjectId: 'subject-db',
subjectRef: '@CH0001',
subjectName: '林知夏',
module: 'character',
subjectFormId: 'form-db',
subjectFormName: '日常',
imageId: 'image-db',
imageUrl: '/storage/reference.png'
}
],
missing: []
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ShotTools, { attachTo: document.body, props: { projectId: fixture.id, shot, disabled: false } })
button('读取参考图').click()
await flushPromises()
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
const image = wrapper.get('.reference-card img')
expect(image.element.parentElement?.closest('a')).toBeNull()
await image.trigger('click')
await flushPromises()
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toBe(image.attributes('src'))
expect(fetcher.mock.calls).toHaveLength(1)
expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === 'GET')).toBe(true)
})
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
const shot = designedShot()
const fetcher = vi