fix: 修复身份与形态图库历史图片布局

This commit is contained in:
GouJ
2026-09-01 18:26:17 +08:00
parent 7773721399
commit bf19128580
8 changed files with 307 additions and 76 deletions
@@ -60,11 +60,53 @@ function chooseAnchor() {
</NAlert>
<template v-if="selected">
<AssetImage
:key="selected.id"
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
class="asset-image-preview mt-4"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
/>
<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="identityViewLabels[image.viewType]"
:retryable="false"
:empty-text="
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
image.status
]
"
/><span class="image-history-label">{{
image.isAnchor
? '当前母版'
: image.viewType === 'primary'
? '母版候选'
: identityViewLabels[image.viewType]
}}</span>
<span class="image-history-meta"
><StatusBadge :status="image.status" /><span>{{ image.enabled ? '启用' : '停用' }}</span></span
>
</NButton>
</NScrollbar>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="selected.status" /><NTag size="small" :bordered="false">{{
@@ -132,34 +174,6 @@ function chooseAnchor() {
本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }}
</p>
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
<NScrollbar
x-scrollable
content-class="image-history-content"
class="image-history mt-4"
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="identityViewLabels[image.viewType]"
:retryable="false"
:empty-text="image.status === 'failed' ? '失败' : '无图'"
/><span class="mt-1 block text-[10px]">{{
image.isAnchor
? '当前母版'
: image.viewType === 'primary'
? '母版候选'
: identityViewLabels[image.viewType]
}}</span></NButton
>
</NScrollbar>
</template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section>
@@ -1,5 +1,7 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NScrollbar } from 'naive-ui'
import { AssetImage } from '../../components/ui'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
@@ -43,6 +45,129 @@ function input(selector: string, value: string) {
}
describe('身份图与母版契约', () => {
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
const images = [
identityImageFixture({
id: 'candidate',
imageUrl: '/storage/candidate.png',
isAnchor: false,
enabled: false
}),
identityImageFixture({
id: 'failed',
status: 'failed',
imageUrl: null,
isAnchor: false,
enabled: false,
error: '图片模型超时'
}),
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
identityImageFixture({
id: 'generating',
status: 'generating',
imageUrl: null,
isAnchor: false,
enabled: false
})
]
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images }
})
const history = wrapper.get('[aria-label="身份图片历史"]')
expect(history.findAll('.image-history-item')).toHaveLength(6)
expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({
xScrollable: true,
trigger: 'none',
contentStyle: { width: 'max-content' }
})
expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条')
expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element)
expect(history.text()).toContain('当前母版')
expect(history.text()).toContain('母版候选')
expect(history.text()).toContain('正面')
expect(history.text()).toContain('生成失败')
expect(history.text()).toContain('排队中')
expect(history.text()).toContain('生成中')
expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
for (const image of images) {
await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click')
expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1)
expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true')
const preview = wrapper
.findAllComponents(AssetImage)
.find(item => item.classes().includes('asset-image-preview'))!
expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null)
expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`)
}
await history.get('[aria-label="查看身份图片 failed"]').trigger('click')
expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败')
expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时')
expect(button('确认选角并锁定').disabled).toBe(true)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
const anchor = identityImageFixture({ id: 'anchor' })
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
wrapper = mount(IdentityGallery, {
props: {
subjectName: '林夏',
module: 'character',
identityLocked: true,
disabled: false,
images: [anchor, front]
}
})
await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click')
const viewport = wrapper.get<HTMLElement>('.image-history .n-scrollbar-container').element
viewport.scrollLeft = 120
await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] })
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport)
expect(viewport.scrollLeft).toBe(120)
await wrapper.setProps({ images: [anchor] })
expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条')
})
it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: ['candidate-a', 'candidate-b'].map(id =>
identityImageFixture({ id, isAnchor: false, enabled: false })
)
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(button('确认演员选择').disabled).toBe(false)
await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click')
expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员')
expect(wrapper.emitted('anchor')).toBeUndefined()
button('确认选角并锁定').click()
await flushPromises()
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['candidate-b']])
})
it('无历史图片时只展示空状态,不伪造缩略图或母版', () => {
wrapper = mount(IdentityGallery, {
props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] }
})
expect(wrapper.text()).toContain('身份参考图 · 0')
expect(wrapper.text()).toContain('尚无身份参考图')
expect(wrapper.find('.image-history').exists()).toBe(false)
expect(wrapper.find('.asset-image-preview').exists()).toBe(false)
})
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })