feat: 同步视觉风格与主体身份母版工作区

对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。
整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。
补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。
未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
GouJ
2026-08-28 19:28:27 +08:00
parent 47443c0efe
commit 669f4d9df0
34 changed files with 2273 additions and 22 deletions
+381
View File
@@ -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