feat: 接入形态图库与 Seedream 生图操作

展示已有主图、候选图和失败记录,支持单图及批量生成、主图选择。
补充正式 ID 校验、费用确认、图片加载占位和回归测试。
依赖后端 eac8dc3 的项目形态图库只读接口。
This commit is contained in:
GouJ
2026-08-28 15:16:25 +08:00
parent bae69c3c62
commit 47443c0efe
26 changed files with 1338 additions and 25 deletions
+120
View File
@@ -19,6 +19,9 @@ import {
} from '../storyboard/testing/fixtures'
import { getStoryboardSession } from '../storyboard/model'
import { getOperation } from './operations'
import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue'
import { formFixture, imageFixture } from '../subject-images/testing/fixtures'
import { getImageSession } from '../subject-images/model'
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = {
@@ -81,16 +84,133 @@ function mountStoryboard() {
return provided
}
/** 形态图库使用真实路由解析来自主体列表的筛选参数。 */
async function mountSubjectImages() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/projects/:projectId/:workspace', component: SubjectImagesPage }]
})
await router.push(`/projects/${fixture.id}/subject-images`)
wrapper = mount(SubjectImagesPage, {
attachTo: document.body,
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
})
await flushPromises()
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
getImageSession(fixture.id).receipt = null
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
})
describe('工作台页面交互', () => {
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () => new Response(JSON.stringify({ data: [formFixture()] })))
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
expect(wrapper!.get('.form-image-card img').attributes('src')).toContain(
'/storage/subjects/project/form/image.png'
)
expect(wrapper!.text()).toContain('林知夏')
expect(wrapper!.text()).toContain('主参考图')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/projects/page-test-project/subject-forms', 'GET']
])
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态')
expect(wrapper!.text()).toContain('没有匹配的形态')
button('清除筛选').click()
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
})
it('形态单图确认后发送正式 ID 并刷新候选图,不会调用 production/start', async () => {
const form = formFixture()
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'POST') {
const candidate = imageFixture({ id: 'new-candidate', isPrimary: false, imageUrl: '/storage/new.png' })
form.images.unshift(candidate)
return new Response(JSON.stringify({ data: candidate }))
}
return new Response(JSON.stringify({ data: [form] }))
})
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
button('再生成一张').click()
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', setPrimary: false })
expect(wrapper!.text()).toContain('2 条记录')
expect(wrapper!.get('.form-image-card img').attributes('src')).not.toContain('/storage/new.png')
expect(fetcher.mock.calls.some(([url]) => String(url).includes('production/start'))).toBe(false)
})
it('批量生图保留部分失败回执,默认跳过有主图项,筛选不会成为批量参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (_url, init) =>
new Response(
JSON.stringify({
data:
init?.method === 'POST'
? {
total: 2,
targetCount: 1,
generated: 0,
skipped: 1,
failed: 1,
failures: [{ subjectFormId: 'form-db-2', error: '服务暂不可用' }]
}
: [formFixture()]
})
)
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('@CH0001')
await confirmGeneration('补齐项目主参考图')
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
expect(wrapper!.text()).toContain('服务暂不可用')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
await wrapper!.get('.panel details input[type="checkbox"]').setValue(true)
await confirmGeneration('为全项目新增候选图')
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
})
it('后端持久化生成中或查询失败时暂停生图,旧图库仍可查看', async () => {
let fail = false
const form = formFixture()
form.images.unshift(imageFixture({ id: 'running', isPrimary: false, status: 'generating', imageUrl: null }))
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () =>
fail
? new Response('{"message":"数据库异常"}', { status: 500 })
: new Response(JSON.stringify({ data: [form] }))
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
expect(button('再生成一张').disabled).toBe(true)
expect(wrapper!.text()).toContain('数据库中仍有排队或生成中的图片')
fail = true
button('刷新图库').click()
await flushPromises()
expect(wrapper!.text()).toContain('数据库异常')
expect(wrapper!.find('.form-image-card img').exists()).toBe(true)
expect(button('补齐项目主参考图').disabled).toBe(true)
})
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
const shot = designedShot()
const fetcher = vi