feat: 全局资产库直接展示全部图片

This commit is contained in:
GouJ
2026-09-22 10:54:23 +08:00
parent eec957a2e4
commit fec3b8dcac
5 changed files with 250 additions and 59 deletions
+130 -44
View File
@@ -1,26 +1,74 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { NAlert, NButton, NInput, NTag } from 'naive-ui' import { NAlert, NButton, NInput, NSelect, NTag } from 'naive-ui'
import { ArrowRight, RefreshCw, Search } from '@lucide/vue' import { ArrowRight, RefreshCw, Search } from '@lucide/vue'
import { EmptyState } from '../../components/ui' import { AssetImage, EmptyState } from '../../components/ui'
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { useQuery } from '../../composables/useQuery' import { useQuery } from '../../composables/useQuery'
import { projectsApi } from '../projects/api' import { projectAssetsApi } from './api'
import type { GlobalProjectAsset, ProjectAssetProject } from './types'
/** 全局资产库只负责选择项目;素材读写仍严格使用后端的项目级接口。 */ const categoryLabels: Record<string, string> = {
const query = useQuery(ref('asset-projects'), (_, signal) => projectsApi.list(signal)) overall: '综合',
character: '人物',
scene: '场景',
prop: '道具',
reference: '参考素材'
}
/** 全局接口一次返回全部图片及所属剧本,避免按项目产生 N 次请求。 */
const query = useQuery(ref('all-project-assets'), (_, signal) => projectAssetsApi.listAll(signal))
const search = ref('') const search = ref('')
const projects = computed(() => { const projectId = ref<string | null>(null)
const category = ref<string | null>(null)
const assets = computed(() => query.data.value ?? [])
const projectOptions = computed(() => {
const projects = new Map<string, ProjectAssetProject>()
assets.value.forEach(asset => projects.set(asset.project.id, asset.project))
return [...projects.values()]
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
.map(project => ({ label: projectName(project), value: project.id }))
})
const categoryOptions = computed(() =>
[...new Set(assets.value.map(asset => asset.category))]
.toSorted()
.map(value => ({ label: categoryLabel(value), value }))
)
const filteredAssets = computed(() => {
const keyword = search.value.trim().toLowerCase() const keyword = search.value.trim().toLowerCase()
return (query.data.value ?? []).filter(project => return assets.value.filter(asset => {
[project.title, project.topic, project.style].some(value => value?.toLowerCase().includes(keyword)) if (projectId.value && asset.projectId !== projectId.value) return false
if (category.value && asset.category !== category.value) return false
if (!keyword) return true
return [asset.name, asset.category, projectName(asset.project)].some(value =>
value.toLowerCase().includes(keyword)
) )
})
}) })
function projectName(title: string | null, topic: string) { function projectName(project: ProjectAssetProject) {
return title?.trim() || topic return project.title?.trim() || project.topic
} }
function categoryLabel(value: string) {
return categoryLabels[value] ?? value
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function clearFilters() {
search.value = ''
projectId.value = null
category.value = null
}
/** 类型标记帮助模板保持后端全局资产契约。 */
const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
</script> </script>
<template> <template>
@@ -29,14 +77,14 @@ function projectName(title: string | null, topic: string) {
<div class="page-heading asset-library-heading"> <div class="page-heading asset-library-heading">
<div> <div>
<h1>资产库</h1> <h1>资产库</h1>
<p class="page-description">按剧本管理人物场景道具和参考素材</p> <p class="page-description">汇总所有剧本的人物场景道具和参考图片</p>
</div> </div>
<NButton <NButton
quaternary quaternary
class="icon-button" class="icon-button"
:loading="query.loading.value" :loading="query.loading.value"
aria-label="刷新资产项目" aria-label="刷新资产"
title="刷新资产项目" title="刷新资产"
@click="query.refresh" @click="query.refresh"
> >
<template #icon><RefreshCw :size="16" /></template> <template #icon><RefreshCw :size="16" /></template>
@@ -48,74 +96,112 @@ function projectName(title: string | null, topic: string) {
{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton> {{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton>
</NAlert> </NAlert>
<div class="asset-library-filters">
<NInput <NInput
v-model:value="search" v-model:value="search"
clearable clearable
class="asset-project-search" placeholder="搜索图片、分类或所属剧本"
placeholder="搜索剧本名称、主题或风格" :input-props="{ 'aria-label': '搜索资产图片' }"
:input-props="{ 'aria-label': '搜索资产所属剧本' }"
> >
<template #prefix><Search :size="15" /></template> <template #prefix><Search :size="15" /></template>
</NInput> </NInput>
<NSelect
v-model:value="projectId"
clearable
filterable
placeholder="全部剧本"
:options="projectOptions"
aria-label="筛选所属剧本"
/>
<NSelect
v-model:value="category"
clearable
placeholder="全部分类"
:options="categoryOptions"
aria-label="筛选资产分类"
/>
<span class="text-xs text-muted">{{ typedAssets.length }} / {{ assets.length }} </span>
</div>
<div v-if="projects.length" class="asset-project-list"> <div v-if="typedAssets.length" class="asset-library-grid">
<article v-for="project in projects" :key="project.id" class="asset-project-item"> <article v-for="asset in typedAssets" :key="asset.id" class="asset-library-card">
<AssetImage :src="asset.publicUrl" :alt="asset.name" preview class="asset-library-image" />
<div class="asset-library-card-content">
<div class="flex min-w-0 items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-2"> <h2 class="truncate text-sm font-semibold" :title="asset.name">{{ asset.name }}</h2>
<h2 class="truncate text-sm font-semibold" :title="projectName(project.title, project.topic)"> <p class="mt-1 truncate text-xs text-muted" :title="projectName(asset.project)">
{{ projectName(project.title, project.topic) }} {{ projectName(asset.project) }}
</h2> </p>
<NTag v-if="project.status !== 'completed'" size="small" :bordered="false">剧本未完成</NTag>
</div> </div>
<p class="mt-1 truncate text-xs text-muted">{{ project.topic }}</p> <NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag>
</div> </div>
<div class="mt-3 flex items-center justify-between gap-3">
<span class="text-[11px] text-muted">
{{ asset.extension.toUpperCase() }} · {{ formatBytes(asset.size) }}
</span>
<RouterLink <RouterLink
v-if="project.status === 'completed'"
v-slot="{ href, navigate }" v-slot="{ href, navigate }"
:to="`/projects/${encodeURIComponent(project.id)}/assets`" :to="`/projects/${encodeURIComponent(asset.projectId)}/assets`"
custom custom
> >
<NButton tag="a" :href="href" text type="primary" @click="navigate"> <NButton tag="a" :href="href" text size="small" type="primary" @click="navigate">
管理素材<template #icon><ArrowRight :size="15" /></template> 管理<template #icon><ArrowRight :size="14" /></template>
</NButton> </NButton>
</RouterLink> </RouterLink>
<NButton v-else text disabled </div>
>管理素材<template #icon><ArrowRight :size="15" /></template </div>
></NButton>
</article> </article>
</div> </div>
<EmptyState <EmptyState
v-else-if="query.data.value" v-else-if="query.data.value"
:title="search ? '没有匹配的剧本' : '还没有可管理的剧本'" :title="assets.length ? '没有匹配的图片' : '资产库还是空的'"
:description="search ? '调整搜索关键词。' : '创建剧本后,可在这里进入对应的项目资产库。'" :description="assets.length ? '调整搜索、剧本或分类筛选。' : '进入剧本资产库上传图片后,会自动汇总到这里。'"
> >
<NButton v-if="search" @click="search = ''">清除搜索</NButton> <NButton v-if="assets.length" @click="clearFilters">清除筛选</NButton>
<RouterLink v-else v-slot="{ href, navigate }" to="/projects" custom> <RouterLink v-else v-slot="{ href, navigate }" to="/projects" custom>
<NButton tag="a" :href="href" type="primary" @click="navigate">前往我的剧本</NButton> <NButton tag="a" :href="href" type="primary" @click="navigate">前往我的剧本</NButton>
</RouterLink> </RouterLink>
</EmptyState> </EmptyState>
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取剧本列表</p> <p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片</p>
</WorkspacePage> </WorkspacePage>
</template> </template>
<style scoped> <style scoped>
@reference "../../styles/styles.css"; @reference "../../styles/styles.css";
.asset-library-page {
container: asset-library / inline-size;
}
.asset-library-heading { .asset-library-heading {
@apply flex items-start justify-between gap-4; @apply flex items-start justify-between gap-4;
} }
.asset-project-search { .asset-library-filters {
@apply max-w-[420px] mb-4; @apply grid grid-cols-[minmax(220px,_420px)_220px_180px_auto] items-center gap-3 mb-4 p-3 bg-(--app-subtle);
} }
.asset-project-list { .asset-library-grid {
@apply grid gap-2; @apply grid grid-cols-[repeat(auto-fill,_minmax(min(240px,_100%),_1fr))] gap-4;
} }
.asset-project-item { .asset-library-card {
@apply flex min-w-0 items-center justify-between gap-5 px-4 py-3 bg-(--app-surface); @apply min-w-0 overflow-hidden bg-(--app-surface);
} }
@media (max-width: 640px) { .asset-library-image {
.asset-project-item { @apply aspect-[4/3];
@apply items-start gap-3; }
.asset-library-card-content {
@apply p-4;
}
@container asset-library (max-width: 840px) {
.asset-library-filters {
@apply grid-cols-2;
}
}
@container asset-library (max-width: 560px) {
.asset-library-filters {
@apply grid-cols-1;
}
.asset-library-filters > span {
@apply justify-self-start;
} }
} }
</style> </style>
+2 -1
View File
@@ -1,5 +1,5 @@
import { request } from '../../lib/http' import { request } from '../../lib/http'
import type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types' import type { GlobalProjectAsset, ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
/** 固定项目资产路径并编码正式数据库 ID。 */ /** 固定项目资产路径并编码正式数据库 ID。 */
function assetPath(projectId: string, assetId?: string) { function assetPath(projectId: string, assetId?: string) {
@@ -9,6 +9,7 @@ function assetPath(projectId: string, assetId?: string) {
/** 项目素材 API;上传使用 multipart,浏览和编辑不调用生成模型。 */ /** 项目素材 API;上传使用 multipart,浏览和编辑不调用生成模型。 */
export const projectAssetsApi = { export const projectAssetsApi = {
listAll: (signal?: AbortSignal) => request<GlobalProjectAsset[]>('/assets', { signal }),
list: (projectId: string, signal?: AbortSignal) => request<ProjectAsset[]>(assetPath(projectId), { signal }), list: (projectId: string, signal?: AbortSignal) => request<ProjectAsset[]>(assetPath(projectId), { signal }),
get: (projectId: string, assetId: string, signal?: AbortSignal) => get: (projectId: string, assetId: string, signal?: AbortSignal) =>
request<ProjectAsset>(assetPath(projectId, assetId), { signal }), request<ProjectAsset>(assetPath(projectId, assetId), { signal }),
+13
View File
@@ -14,6 +14,19 @@ export interface ProjectAsset {
updatedAt: string updatedAt: string
} }
/** 全局资产列表附带最小剧本信息,供筛选和来源标识使用。 */
export interface ProjectAssetProject {
id: string
title: string | null
topic: string
status: string
}
/** 全局资产接口记录。 */
export interface GlobalProjectAsset extends ProjectAsset {
project: ProjectAssetProject
}
/** 素材编辑只修改展示信息,不替换物理文件。 */ /** 素材编辑只修改展示信息,不替换物理文件。 */
export interface UpdateProjectAssetInput { export interface UpdateProjectAssetInput {
name?: string name?: string
+13
View File
@@ -20,6 +20,19 @@ const asset: ProjectAsset = {
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals())
describe('项目资产 API', () => { describe('项目资产 API', () => {
it('通过单个全局接口读取全部项目素材', async () => {
const globalAsset = {
...asset,
project: { id: 'project-db-1', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
}
const fetcher = vi.fn<typeof fetch>().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 () => { it('使用 multipart 上传文件,不手工设置 Content-Type', async () => {
const fetcher = vi const fetcher = vi
.fn<typeof fetch>() .fn<typeof fetch>()
@@ -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<typeof fetch>().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: '<div />' } }
]
})
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('雨夜街道')
})
})