feat: 新增项目资产库与形态参考素材
This commit is contained in:
@@ -40,27 +40,6 @@ async function submit() {
|
||||
}
|
||||
|
||||
describe('统一的 Naive UI 表单校验', () => {
|
||||
it('新建剧本要求填写项目标题,并将清理后的标题提交给后端', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
|
||||
button('新建剧本').click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('#title')).toBeInstanceOf(HTMLInputElement)
|
||||
await input('#topic', '雨夜来信')
|
||||
await submit()
|
||||
expect(feedback()).toContain('请填写项目标题')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await input('#title', ' 记忆当铺 ')
|
||||
await submit()
|
||||
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toMatchObject({
|
||||
title: '记忆当铺',
|
||||
topic: '雨夜来信'
|
||||
})
|
||||
})
|
||||
|
||||
it('新建剧本使用字段反馈拦截空白主题和小数集数,修正后只发送一次请求', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
@@ -71,7 +50,6 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
await flushPromises()
|
||||
expect(document.querySelector('form')!.noValidate).toBe(true)
|
||||
expect(document.querySelector('[required]')).toBeNull()
|
||||
await input('#title', '测试剧本')
|
||||
await input('#topic', ' ')
|
||||
const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
await submit()
|
||||
@@ -131,7 +109,10 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
})
|
||||
|
||||
it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => {
|
||||
wrapper = mount(StyleImages, { attachTo: document.body, props: { images: [], disabled: false } })
|
||||
wrapper = mount(StyleImages, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'form-test-project', images: [], disabled: false }
|
||||
})
|
||||
await input('[aria-label="风格图片地址"]', 'javascript:alert(1)')
|
||||
await input('[aria-label="风格图片排序"]', '1.5')
|
||||
await submit()
|
||||
@@ -144,6 +125,55 @@ describe('统一的 Naive UI 表单校验', () => {
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 })
|
||||
})
|
||||
|
||||
it('按需读取项目资产并用正式素材 ID 登记风格参考图', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'asset-db-1',
|
||||
projectId: 'form-test-project',
|
||||
name: '人物质感参考',
|
||||
type: 'image',
|
||||
category: 'character',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 1024,
|
||||
publicUrl: '/storage/reference.png',
|
||||
metadata: null,
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(StyleImages, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'form-test-project', images: [], disabled: false }
|
||||
})
|
||||
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
button('从资产库选择').click()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/projects/form-test-project/assets',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
const option = document.querySelector<HTMLButtonElement>('.style-asset-option')!
|
||||
expect(option.textContent).toContain('人物质感参考')
|
||||
option.click()
|
||||
await flushPromises()
|
||||
button('登记为整体参考图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({
|
||||
projectAssetId: 'asset-db-1',
|
||||
category: 'overall',
|
||||
source: 'upload'
|
||||
})
|
||||
})
|
||||
|
||||
it('异步验证期间切换对象或重复提交不会执行旧动作', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
const model = reactive({ text: '有效输入' })
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('管理后台组件边界', () => {
|
||||
await router.push('/projects/test/production')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(9)
|
||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||
@@ -194,6 +194,7 @@ describe('管理后台组件边界', () => {
|
||||
'视觉风格',
|
||||
'主体身份',
|
||||
'形态图片',
|
||||
'资产库',
|
||||
'分镜设计',
|
||||
'镜头生产'
|
||||
])
|
||||
@@ -202,7 +203,7 @@ describe('管理后台组件边界', () => {
|
||||
await settings.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
|
||||
@@ -304,7 +305,7 @@ describe('管理后台组件边界', () => {
|
||||
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
|
||||
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
|
||||
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(9)
|
||||
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
|
||||
const expandedBrand = wrapper.get('.admin-brand')
|
||||
expect(expandedBrand.classes()).not.toContain('is-collapsed')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectAssetsApi } from '@/features/project-assets/api'
|
||||
import type { ProjectAsset } from '@/features/project-assets/types'
|
||||
|
||||
const asset: ProjectAsset = {
|
||||
id: 'asset-db-1',
|
||||
projectId: 'project-db-1',
|
||||
name: '雨夜街道',
|
||||
type: 'image',
|
||||
category: 'scene',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 2048,
|
||||
publicUrl: '/storage/project/scene.png',
|
||||
metadata: { originalName: 'scene.png' },
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('项目资产 API', () => {
|
||||
it('使用 multipart 上传文件,不手工设置 Content-Type', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ data: asset }), { status: 201 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const file = new File(['image'], 'scene.png', { type: 'image/png' })
|
||||
|
||||
await expect(
|
||||
projectAssetsApi.upload('project-db-1', { file, name: '雨夜街道', category: 'scene' })
|
||||
).resolves.toEqual(asset)
|
||||
|
||||
const [url, init] = fetcher.mock.calls[0]!
|
||||
expect(url).toBe('/api/projects/project-db-1/assets')
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(init?.headers).toEqual({ Accept: 'application/json' })
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const body = init?.body as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('name')).toBe('雨夜街道')
|
||||
expect(body.get('category')).toBe('scene')
|
||||
})
|
||||
|
||||
it('按正式项目和素材 ID 读取、编辑及删除', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ data: asset })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
|
||||
await projectAssetsApi.list('project/db')
|
||||
await projectAssetsApi.get('project/db', 'asset/db')
|
||||
await projectAssetsApi.update('project/db', 'asset/db', { name: '新名称', category: 'reference' })
|
||||
await projectAssetsApi.remove('project/db', 'asset/db')
|
||||
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET'])).toEqual([
|
||||
['/api/projects/project%2Fdb/assets', 'GET'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'GET'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'PUT'],
|
||||
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'DELETE']
|
||||
])
|
||||
expect(fetcher.mock.calls[2]?.[1]?.body).toBe(JSON.stringify({ name: '新名称', category: 'reference' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ProjectAssetsPage from '@/features/project-assets/ProjectAssetsPage.vue'
|
||||
import { projectContextKey } from '@/features/projects/context'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('项目资产库页面', () => {
|
||||
it('读取当前项目素材并支持名称、分类和原文件名筛选', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'asset-scene',
|
||||
projectId: 'capability-test',
|
||||
name: '雨夜街道',
|
||||
type: 'image',
|
||||
category: 'scene',
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
size: 4096,
|
||||
publicUrl: '/storage/scene.png',
|
||||
metadata: { originalName: 'street-original.png' },
|
||||
createdAt: '2026-09-21T00:00:00Z',
|
||||
updatedAt: '2026-09-21T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'asset-character',
|
||||
projectId: 'capability-test',
|
||||
name: '林默正面照',
|
||||
type: 'image',
|
||||
category: 'character',
|
||||
mimeType: 'image/jpeg',
|
||||
extension: 'jpg',
|
||||
size: 8192,
|
||||
publicUrl: '/storage/character.jpg',
|
||||
metadata: null,
|
||||
createdAt: '2026-09-21T01:00:00Z',
|
||||
updatedAt: '2026-09-21T01:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/projects/:projectId/assets', component: ProjectAssetsPage }]
|
||||
})
|
||||
await router.push('/projects/capability-test/assets')
|
||||
wrapper = mount(ProjectAssetsPage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router],
|
||||
provide: { [projectContextKey as symbol]: testProjectContext() }
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/projects/capability-test/assets',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('雨夜街道')
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('street-original')
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('雨夜街道')
|
||||
expect(wrapper.text()).not.toContain('林默正面照')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('没有结果')
|
||||
expect(wrapper.findAll('.project-asset-card')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('没有匹配的素材')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { projectsApi } from '@/features/projects/api'
|
||||
import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
|
||||
import { readAllStyles } from '@/testing/styles'
|
||||
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'assets', 'storyboard', 'production']
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
@@ -107,7 +107,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
||||
}
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6)
|
||||
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(7)
|
||||
await wrapper!.get('.project-access-gate button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
|
||||
@@ -130,7 +130,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('剧本完成前的下游访问限制', () => {
|
||||
)
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/first/production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
|
||||
await router.push('/projects/second/production')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
|
||||
Reference in New Issue
Block a user