feat: 同步视觉风格与主体身份母版工作区
对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。 整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。 补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。 未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
@@ -22,6 +22,30 @@ 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'
|
||||
import VisualStylePage from '../visual-style/VisualStylePage.vue'
|
||||
import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
|
||||
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
||||
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
||||
import { getIdentitySession } from '../subject-identity/model'
|
||||
|
||||
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
|
||||
async function mountAssets(page: 'style' | 'identity', query = '') {
|
||||
const component = page === 'style' ? VisualStylePage : SubjectIdentityPage
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/assets', component }] })
|
||||
await router.push('/assets' + query)
|
||||
const provided = context()
|
||||
wrapper = mount(component, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
|
||||
})
|
||||
await flushPromises()
|
||||
return provided
|
||||
}
|
||||
|
||||
/** 统一构造独立响应,避免复用已消费的 Response body。 */
|
||||
function jsonResponse(data: unknown) {
|
||||
return new Response(JSON.stringify({ data }))
|
||||
}
|
||||
|
||||
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
|
||||
const fixture: ProjectDetail = {
|
||||
@@ -99,6 +123,7 @@ async function mountSubjectImages() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getIdentitySession(fixture.id).receipt = null
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
@@ -108,6 +133,362 @@ afterEach(() => {
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('视觉风格与主体身份工作区', () => {
|
||||
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
||||
const form = formFixture()
|
||||
const second = {
|
||||
...form,
|
||||
id: 'form-2',
|
||||
subjectId: 'subject-2',
|
||||
images: [],
|
||||
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
return jsonResponse(null)
|
||||
})
|
||||
)
|
||||
await mountAssets('identity', '?subjectId=subject-db-1')
|
||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
button('刷新主体目录').click()
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('身份文本保存发送正式主体 ID 与锁定状态,草稿切换需确认', async () => {
|
||||
let identity = identityFixture()
|
||||
const form = formFixture()
|
||||
const second = {
|
||||
...form,
|
||||
id: 'form-2',
|
||||
subjectId: 'subject-2',
|
||||
images: [],
|
||||
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
||||
if (String(url).includes('/subject-2/')) return jsonResponse(null)
|
||||
if (init?.method === 'PUT') identity = { ...identity, ...JSON.parse(init.body as string) }
|
||||
return jsonResponse(identity)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
await wrapper!.get('#identity-description').setValue('人工稳定身份')
|
||||
await wrapper!.get('#identity-lock').setValue(true)
|
||||
button('保存主体身份').click()
|
||||
await flushPromises()
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
||||
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
|
||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
||||
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('新的未保存草稿')
|
||||
button('放弃草稿并切换').click()
|
||||
await flushPromises()
|
||||
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('风格图拒绝危险 URL,登记只传已有地址及分类排序', async () => {
|
||||
const image = styleImageFixture()
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (_url, init) => jsonResponse(init?.method === 'POST' ? image : styleFixture()))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('style')
|
||||
await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)')
|
||||
expect(button('登记参考图').disabled).toBe(true)
|
||||
await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png')
|
||||
button('登记参考图').click()
|
||||
await flushPromises()
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/projects/page-test-project/visual-style/images')
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({
|
||||
imageUrl: '/storage/style.png',
|
||||
category: 'overall',
|
||||
source: 'upload',
|
||||
enabled: true,
|
||||
sortOrder: 0
|
||||
})
|
||||
expect((wrapper!.get('[aria-label="风格图片地址"]').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('AI 确认取消后重新打开必须再次确认,不能复用上次勾选', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(styleFixture()))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('style')
|
||||
button('AI 重新生成风格').click()
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
||||
button('取消').click()
|
||||
await flushPromises()
|
||||
button('AI 重新生成风格').click()
|
||||
await flushPromises()
|
||||
expect(button('确认AI 重新生成风格').disabled).toBe(true)
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||
})
|
||||
it('视觉风格首次查询为空时可人工保存,JSON 校验和锁定字段准确传递', async () => {
|
||||
let style: ReturnType<typeof styleFixture> | null = null
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||
if (init?.method === 'PUT') style = styleFixture(JSON.parse(init.body as string))
|
||||
return jsonResponse(style)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('style')
|
||||
expect(wrapper!.text()).toContain('尚未创建')
|
||||
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
||||
expect(button('保存视觉风格').disabled).toBe(true)
|
||||
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
||||
await wrapper!.get('#style-lock').setValue(true)
|
||||
await wrapper!.get('#style-name').setValue('雨夜风格')
|
||||
button('保存视觉风格').click()
|
||||
await flushPromises()
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/projects/page-test-project/visual-style')
|
||||
expect(JSON.parse(put[1]!.body as string)).toMatchObject({
|
||||
name: '雨夜风格',
|
||||
isLocked: true,
|
||||
hardConstraints: ['真人写实']
|
||||
})
|
||||
expect(button('AI 重新生成风格').disabled).toBe(true)
|
||||
expect(button('保存视觉风格').disabled).toBe(false)
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||
})
|
||||
|
||||
it('刷新风格不覆盖未保存草稿,草稿存在时禁止 AI 重生成', async () => {
|
||||
let style = styleFixture()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(style))
|
||||
)
|
||||
await mountAssets('style')
|
||||
await wrapper!.get('#style-prompt').setValue('人工草稿')
|
||||
style = styleFixture({ prompt: '后台新内容' })
|
||||
button('刷新风格').click()
|
||||
await flushPromises()
|
||||
expect((wrapper!.get('#style-prompt').element as HTMLTextAreaElement).value).toBe('人工草稿')
|
||||
expect(button('AI 重新生成风格').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('风格接口 404 显示错误而不是当作空风格,禁止写入', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockResolvedValue(new Response('{"message":"路由不存在"}', { status: 404 }))
|
||||
)
|
||||
await mountAssets('style')
|
||||
expect(wrapper!.text()).toContain('路由不存在')
|
||||
expect(button('AI 生成风格').disabled).toBe(true)
|
||||
expect(wrapper!.find('#style-prompt').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('风格图启停用 PUT,移除经确认用 DELETE,均不调用生图接口', async () => {
|
||||
let image = styleImageFixture()
|
||||
let removed = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'PUT') {
|
||||
image = { ...image, enabled: false }
|
||||
return jsonResponse(image)
|
||||
}
|
||||
if (init?.method === 'DELETE') {
|
||||
removed = true
|
||||
return jsonResponse(image)
|
||||
}
|
||||
expect(String(url)).toContain('/visual-style')
|
||||
return jsonResponse(styleFixture({ images: removed ? [] : [image] }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('style')
|
||||
button('停用').click()
|
||||
await flushPromises()
|
||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(put[0]).toBe('/api/projects/page-test-project/visual-style/images/style-image-1/enabled')
|
||||
expect(JSON.parse(put[1]!.body as string)).toEqual({ enabled: false })
|
||||
button('移除').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
|
||||
button('确认移除').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'DELETE')?.[0]).toBe(
|
||||
'/api/projects/page-test-project/visual-style/images/style-image-1'
|
||||
)
|
||||
expect(wrapper!.text()).toContain('尚无风格参考图')
|
||||
})
|
||||
|
||||
it('主体目录按正式 Subject ID 去重,无 Identity 时不请求会报错的图片接口', async () => {
|
||||
const form = formFixture()
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
if (String(url).endsWith('/subject-forms'))
|
||||
return jsonResponse([form, { ...form, id: 'form-db-2', images: [], name: '雨夜造型' }])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
return jsonResponse(null)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
expect(wrapper!.findAll('.identity-subject-item')).toHaveLength(1)
|
||||
expect(wrapper!.text()).toContain('2 个形态')
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
|
||||
expect(button('生成身份参考图').disabled).toBe(true)
|
||||
expect(button('AI 生成身份').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
||||
if (init?.method === 'POST') return jsonResponse(identityImageFixture())
|
||||
if (String(url).endsWith('/identity/images')) return jsonResponse([identityImageFixture()])
|
||||
return jsonResponse(identityFixture({ isLocked: true }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(button('保存主体身份').disabled).toBe(false)
|
||||
button('生成身份参考图').click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成身份图').click()
|
||||
await flushPromises()
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'primary' })
|
||||
})
|
||||
|
||||
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(null)
|
||||
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
)
|
||||
await mountAssets('identity')
|
||||
expect(button('AI 重新生成身份').disabled).toBe(true)
|
||||
expect(button('生成身份参考图').disabled).toBe(true)
|
||||
expect(button('保存主体身份').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('身份批量生成区分锁定跳过和部分失败,不将 HTTP 200 当作全部成功', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return jsonResponse({
|
||||
total: 3,
|
||||
targetCount: 2,
|
||||
generated: 1,
|
||||
skipped: 1,
|
||||
skippedLocked: 1,
|
||||
failed: 1,
|
||||
failures: [{ subjectId: 'failed-db', subjectRef: '@CH0003', error: '文本模型超时' }]
|
||||
})
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
return jsonResponse(null)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
await confirmGeneration('补齐项目身份文本')
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
|
||||
expect(wrapper!.text()).toContain('部分主体生成失败')
|
||||
expect(wrapper!.text()).toContain('含锁定 1')
|
||||
expect(wrapper!.text()).toContain('文本模型超时')
|
||||
})
|
||||
|
||||
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
|
||||
let selected = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
if (init?.method === 'PUT') {
|
||||
selected = true
|
||||
return jsonResponse(identityImageFixture({ id: 'candidate' }))
|
||||
}
|
||||
if (String(url).endsWith('/identity/images'))
|
||||
return jsonResponse([identityImageFixture({ id: 'candidate', enabled: selected, isAnchor: selected })])
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
button('设为身份母版').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
|
||||
button('确认切换身份母版').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
|
||||
'/api/subjects/subject-db-1/identity/images/candidate/anchor'
|
||||
)
|
||||
expect(wrapper!.text()).toContain('当前身份母版')
|
||||
})
|
||||
|
||||
it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => {
|
||||
let finish!: (value: Response) => void
|
||||
const form = formFixture()
|
||||
const second = {
|
||||
...form,
|
||||
id: 'form-2',
|
||||
subjectId: 'subject-2',
|
||||
images: [],
|
||||
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
if (path.endsWith('/subject-forms')) return jsonResponse([form, second])
|
||||
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
if (path.endsWith('/subject-db-1/identity'))
|
||||
return new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
if (path.endsWith('/identity/images')) return jsonResponse([])
|
||||
return jsonResponse(
|
||||
identityFixture({ id: 'identity-2', subjectId: 'subject-2', description: '陆川的稳定身份' })
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('identity')
|
||||
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(
|
||||
fetcher.mock.calls.find(([url]) => String(url).endsWith('/subject-db-1/identity'))?.[1]?.signal?.aborted
|
||||
).toBe(true)
|
||||
finish(jsonResponse(identityFixture()))
|
||||
await flushPromises()
|
||||
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('陆川的稳定身份')
|
||||
})
|
||||
|
||||
it('生成失败不自动重发,后端正在拆解时禁止身份写操作', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
||||
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
||||
if (init?.method === 'POST') throw new Error('connection lost')
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = await mountAssets('identity')
|
||||
await confirmGeneration('AI 重新生成身份')
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
||||
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
||||
provided.data.value!.project = { ...fixture, status: 'generating' }
|
||||
await flushPromises()
|
||||
expect(button('保存主体身份').disabled).toBe(true)
|
||||
expect(button('生成身份参考图').disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('工作台页面交互', () => {
|
||||
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
||||
const fetcher = vi
|
||||
|
||||
Reference in New Issue
Block a user