feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
@@ -0,0 +1,422 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NImage, NScrollbar } from 'naive-ui'
import { AssetImage } from '@/components/ui'
import IdentityImageDialog from '@/features/subject-identity/components/IdentityImageDialog.vue'
import IdentityGallery from '@/features/subject-identity/components/IdentityGallery.vue'
import CastingCandidateDialog from '@/features/subject-identity/components/CastingCandidateDialog.vue'
import { subjectIdentityApi } from '@/features/subject-identity/api'
import {
canBeAnchor,
castingStatusLabel,
currentAnchor,
groupIdentitySubjects,
mergeCastingSubjects,
readImageProvenance
} from '@/features/subject-identity/model'
import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
import { formFixture } from '@/features/subject-images/testing/fixtures'
import { expandSections, selectControl } from '@/testing/naive'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从真实 Naive 弹窗内获取确认按钮。 */
function button(label: string) {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
function input(selector: string, value: string) {
if (selector === '#identity-view' || selector === '#identity-reference') {
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
return
}
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
item.value = value
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
}
describe('身份图与母版契约', () => {
it('详情大图 contain 完整显示,历史仍为 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.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
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.element as HTMLImageElement).style.objectFit).toBe('contain')
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, {
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
})
const panel = wrapper.get('.n-collapse-item')
expect(panel.classes()).toContain('n-collapse-item--active')
expect(panel.text()).toContain('本次实际生成提示词')
await panel.get('.n-collapse-item__header-main').trigger('click')
expect(panel.classes()).not.toContain('n-collapse-item--active')
await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
expect(panel.classes()).not.toContain('n-collapse-item--active')
await panel.get('.n-collapse-item__header-main').trigger('click')
expect(panel.classes()).toContain('n-collapse-item--active')
expect(panel.text()).toContain('更新后的实际提示词')
})
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 })
expect(canBeAnchor(front)).toBe(false)
expect(canBeAnchor(candidate)).toBe(true)
expect(currentAnchor([front, candidate])).toBeUndefined()
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
})
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
expect(readImageProvenance('{bad')).toEqual({})
expect(readImageProvenance(null)).toEqual({})
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
identityAnchorImageId: 'anchor',
referenceImageId: undefined
})
})
it('选角就绪结果可以补入尚无形态的角色目录', () => {
const rows = mergeCastingSubjects(
groupIdentitySubjects([formFixture()]),
[
{
subjectId: 'character-without-form',
subjectRef: '@CH0002',
subjectName: '陆川',
status: 'missing_identity',
isLocked: false
}
],
'project-1'
)
expect(rows).toHaveLength(2)
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
projectId: 'project-1',
module: 'character',
forms: []
})
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
})
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-view', 'three-quarter')
input('#identity-reference', 'identity-image-1')
input('#identity-width', '2048')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
input('#identity-height', '2048')
input('#identity-image-prompt', ' 自定义身份提示词 ')
await flushPromises()
button('确认生成身份图').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([
[
{
viewType: 'three-quarter',
referenceImageId: 'identity-image-1',
width: 2048,
height: 2048,
prompt: '自定义身份提示词'
}
]
])
})
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-reference', 'identity-image-1')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
await wrapper.setProps({ images: [] })
expect(button('确认生成身份图').disabled).toBe(true)
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
})
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
module: 'character',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
const select = selectControl(wrapper!, 'id', 'identity-view')
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
expect(select.props('value')).toBe('front')
})
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
}
})
expect(button('确认选角并锁定').disabled).toBe(true)
await expandSections()
expect(wrapper.text()).toContain('<script>不执行</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ isAnchor: true })]
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
})
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
wrapper = mount(CastingCandidateDialog, {
attachTo: document.body,
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
})
await flushPromises()
expect(document.body.textContent).toContain('不会复用当前身份母版')
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
await flushPromises()
button('确认生成候选').click()
expect(wrapper.emitted('generate')).toEqual([[{}]])
})
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async () =>
new Response(
JSON.stringify({
data: {
total: 0,
targetCount: 0,
generated: 0,
skipped: 0,
skippedLocked: 0,
failed: 0,
failures: []
}
})
)
)
vi.stubGlobal('fetch', fetcher)
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/project%2F1/character-identities/generate',
'/api/subjects/subject%2F1/identity/casting-candidates',
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
])
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
})
})