资产库
-
按剧本管理人物、场景、道具和参考素材。
+
汇总所有剧本的人物、场景、道具和参考图片。
@@ -48,74 +96,112 @@ function projectName(title: string | null, topic: string) {
{{ query.error.value }}重新连接
-
-
-
+
+
+
+
+
+
+ {{ typedAssets.length }} / {{ assets.length }} 张
+
-
-
-
-
-
- {{ projectName(project.title, project.topic) }}
-
-
剧本未完成
+
+
+
+
+
+
+
{{ asset.name }}
+
+ {{ projectName(asset.project) }}
+
+
+
{{ categoryLabel(asset.category) }}
+
+
+
+ {{ asset.extension.toUpperCase() }} · {{ formatBytes(asset.size) }}
+
+
+
+ 管理
+
+
-
{{ project.topic }}
-
-
- 管理素材
-
-
- 管理素材
- 清除搜索
+ 清除筛选
前往我的剧本
-
正在读取剧本列表…
+
正在读取全部资产图片…
diff --git a/src/features/project-assets/api.ts b/src/features/project-assets/api.ts
index 6aafee2..efeecbf 100644
--- a/src/features/project-assets/api.ts
+++ b/src/features/project-assets/api.ts
@@ -1,5 +1,5 @@
import { request } from '../../lib/http'
-import type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
+import type { GlobalProjectAsset, ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
/** 固定项目资产路径并编码正式数据库 ID。 */
function assetPath(projectId: string, assetId?: string) {
@@ -9,6 +9,7 @@ function assetPath(projectId: string, assetId?: string) {
/** 项目素材 API;上传使用 multipart,浏览和编辑不调用生成模型。 */
export const projectAssetsApi = {
+ listAll: (signal?: AbortSignal) => request
('/assets', { signal }),
list: (projectId: string, signal?: AbortSignal) => request(assetPath(projectId), { signal }),
get: (projectId: string, assetId: string, signal?: AbortSignal) =>
request(assetPath(projectId, assetId), { signal }),
diff --git a/src/features/project-assets/types.ts b/src/features/project-assets/types.ts
index 5f6970c..4ad861b 100644
--- a/src/features/project-assets/types.ts
+++ b/src/features/project-assets/types.ts
@@ -14,6 +14,19 @@ export interface ProjectAsset {
updatedAt: string
}
+/** 全局资产列表附带最小剧本信息,供筛选和来源标识使用。 */
+export interface ProjectAssetProject {
+ id: string
+ title: string | null
+ topic: string
+ status: string
+}
+
+/** 全局资产接口记录。 */
+export interface GlobalProjectAsset extends ProjectAsset {
+ project: ProjectAssetProject
+}
+
/** 素材编辑只修改展示信息,不替换物理文件。 */
export interface UpdateProjectAssetInput {
name?: string
diff --git a/tests/features/project-assets/api.test.ts b/tests/features/project-assets/api.test.ts
index b2f03b7..654fcdd 100644
--- a/tests/features/project-assets/api.test.ts
+++ b/tests/features/project-assets/api.test.ts
@@ -20,6 +20,19 @@ const asset: ProjectAsset = {
afterEach(() => vi.unstubAllGlobals())
describe('项目资产 API', () => {
+ it('通过单个全局接口读取全部项目素材', async () => {
+ const globalAsset = {
+ ...asset,
+ project: { id: 'project-db-1', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
+ }
+ const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [globalAsset] })))
+ vi.stubGlobal('fetch', fetcher)
+
+ await expect(projectAssetsApi.listAll()).resolves.toEqual([globalAsset])
+ expect(fetcher).toHaveBeenCalledOnce()
+ expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
+ })
+
it('使用 multipart 上传文件,不手工设置 Content-Type', async () => {
const fetcher = vi
.fn()
diff --git a/tests/features/project-assets/library.test.ts b/tests/features/project-assets/library.test.ts
new file mode 100644
index 0000000..6e489a4
--- /dev/null
+++ b/tests/features/project-assets/library.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import AssetLibraryPage from '@/features/project-assets/AssetLibraryPage.vue'
+
+let wrapper: VueWrapper | undefined
+afterEach(() => {
+ wrapper?.unmount()
+ wrapper = undefined
+ document.body.innerHTML = ''
+ vi.unstubAllGlobals()
+})
+
+describe('全局资产库', () => {
+ it('单次读取并直接显示全部剧本图片,支持按图片与剧本搜索', async () => {
+ const fetcher = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ id: 'asset-scene',
+ projectId: 'project-one',
+ name: '雨夜街道',
+ type: 'image',
+ category: 'scene',
+ mimeType: 'image/png',
+ extension: 'png',
+ size: 4096,
+ publicUrl: '/storage/scene.png',
+ metadata: null,
+ createdAt: '2026-09-22T00:00:00Z',
+ updatedAt: '2026-09-22T00:00:00Z',
+ project: { id: 'project-one', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
+ },
+ {
+ id: 'asset-character',
+ projectId: 'project-two',
+ name: '林默正面照',
+ type: 'image',
+ category: 'character',
+ mimeType: 'image/jpeg',
+ extension: 'jpg',
+ size: 8192,
+ publicUrl: '/storage/character.jpg',
+ metadata: null,
+ createdAt: '2026-09-22T01:00:00Z',
+ updatedAt: '2026-09-22T01:00:00Z',
+ project: { id: 'project-two', title: '天降甘霖', topic: '乡村故事', status: 'completed' }
+ }
+ ]
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetcher)
+ const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/assets', component: AssetLibraryPage },
+ { path: '/projects/:projectId/assets', component: { template: '' } }
+ ]
+ })
+ await router.push('/assets')
+ wrapper = mount(AssetLibraryPage, { attachTo: document.body, global: { plugins: [router] } })
+ await flushPromises()
+
+ expect(fetcher).toHaveBeenCalledOnce()
+ expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
+ expect(wrapper.findAll('.asset-library-card')).toHaveLength(2)
+ expect(wrapper.text()).toContain('雨夜街道')
+ expect(wrapper.text()).toContain('林默正面照')
+ expect(wrapper.text()).toContain('记忆当铺')
+
+ await wrapper.get('input[aria-label="搜索资产图片"]').setValue('天降甘霖')
+ expect(wrapper.findAll('.asset-library-card')).toHaveLength(1)
+ expect(wrapper.text()).toContain('林默正面照')
+ expect(wrapper.text()).not.toContain('雨夜街道')
+ })
+})