feat: 新增项目资产库与形态参考素材

This commit is contained in:
GouJ
2026-09-21 19:54:51 +08:00
parent e6d02a20a7
commit b834d1d259
18 changed files with 737 additions and 41 deletions
+64
View File
@@ -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('没有匹配的素材')
})
})
+4 -4
View File
@@ -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)