feat: 全局资产库直接展示全部图片
This commit is contained in:
@@ -1,26 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 { EmptyState } from '../../components/ui'
|
||||
import { AssetImage, EmptyState } from '../../components/ui'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { projectsApi } from '../projects/api'
|
||||
import { projectAssetsApi } from './api'
|
||||
import type { GlobalProjectAsset, ProjectAssetProject } from './types'
|
||||
|
||||
/** 全局资产库只负责选择项目;素材读写仍严格使用后端的项目级接口。 */
|
||||
const query = useQuery(ref('asset-projects'), (_, signal) => projectsApi.list(signal))
|
||||
const categoryLabels: Record<string, string> = {
|
||||
overall: '综合',
|
||||
character: '人物',
|
||||
scene: '场景',
|
||||
prop: '道具',
|
||||
reference: '参考素材'
|
||||
}
|
||||
|
||||
/** 全局接口一次返回全部图片及所属剧本,避免按项目产生 N 次请求。 */
|
||||
const query = useQuery(ref('all-project-assets'), (_, signal) => projectAssetsApi.listAll(signal))
|
||||
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()
|
||||
return (query.data.value ?? []).filter(project =>
|
||||
[project.title, project.topic, project.style].some(value => value?.toLowerCase().includes(keyword))
|
||||
return assets.value.filter(asset => {
|
||||
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) {
|
||||
return title?.trim() || topic
|
||||
function projectName(project: ProjectAssetProject) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -29,14 +77,14 @@ function projectName(title: string | null, topic: string) {
|
||||
<div class="page-heading asset-library-heading">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<p class="page-description">按剧本管理人物、场景、道具和参考素材。</p>
|
||||
<p class="page-description">汇总所有剧本的人物、场景、道具和参考图片。</p>
|
||||
</div>
|
||||
<NButton
|
||||
quaternary
|
||||
class="icon-button"
|
||||
:loading="query.loading.value"
|
||||
aria-label="刷新资产项目"
|
||||
title="刷新资产项目"
|
||||
aria-label="刷新资产库"
|
||||
title="刷新资产库"
|
||||
@click="query.refresh"
|
||||
>
|
||||
<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>
|
||||
</NAlert>
|
||||
|
||||
<div class="asset-library-filters">
|
||||
<NInput
|
||||
v-model:value="search"
|
||||
clearable
|
||||
class="asset-project-search"
|
||||
placeholder="搜索剧本名称、主题或风格"
|
||||
:input-props="{ 'aria-label': '搜索资产所属剧本' }"
|
||||
placeholder="搜索图片、分类或所属剧本"
|
||||
:input-props="{ 'aria-label': '搜索资产图片' }"
|
||||
>
|
||||
<template #prefix><Search :size="15" /></template>
|
||||
</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">
|
||||
<article v-for="project in projects" :key="project.id" class="asset-project-item">
|
||||
<div v-if="typedAssets.length" class="asset-library-grid">
|
||||
<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="flex min-w-0 items-center gap-2">
|
||||
<h2 class="truncate text-sm font-semibold" :title="projectName(project.title, project.topic)">
|
||||
{{ projectName(project.title, project.topic) }}
|
||||
</h2>
|
||||
<NTag v-if="project.status !== 'completed'" size="small" :bordered="false">剧本未完成</NTag>
|
||||
<h2 class="truncate text-sm font-semibold" :title="asset.name">{{ asset.name }}</h2>
|
||||
<p class="mt-1 truncate text-xs text-muted" :title="projectName(asset.project)">
|
||||
{{ projectName(asset.project) }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-xs text-muted">{{ project.topic }}</p>
|
||||
<NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag>
|
||||
</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
|
||||
v-if="project.status === 'completed'"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="`/projects/${encodeURIComponent(project.id)}/assets`"
|
||||
:to="`/projects/${encodeURIComponent(asset.projectId)}/assets`"
|
||||
custom
|
||||
>
|
||||
<NButton tag="a" :href="href" text type="primary" @click="navigate">
|
||||
管理素材<template #icon><ArrowRight :size="15" /></template>
|
||||
<NButton tag="a" :href="href" text size="small" type="primary" @click="navigate">
|
||||
管理<template #icon><ArrowRight :size="14" /></template>
|
||||
</NButton>
|
||||
</RouterLink>
|
||||
<NButton v-else text disabled
|
||||
>管理素材<template #icon><ArrowRight :size="15" /></template
|
||||
></NButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="query.data.value"
|
||||
:title="search ? '没有匹配的剧本' : '还没有可管理的剧本'"
|
||||
:description="search ? '请调整搜索关键词。' : '创建剧本后,可在这里进入对应的项目资产库。'"
|
||||
:title="assets.length ? '没有匹配的图片' : '资产库还是空的'"
|
||||
: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>
|
||||
<NButton tag="a" :href="href" type="primary" @click="navigate">前往我的剧本</NButton>
|
||||
</RouterLink>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../styles/styles.css";
|
||||
.asset-library-page {
|
||||
container: asset-library / inline-size;
|
||||
}
|
||||
.asset-library-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.asset-project-search {
|
||||
@apply max-w-[420px] mb-4;
|
||||
.asset-library-filters {
|
||||
@apply grid grid-cols-[minmax(220px,_420px)_220px_180px_auto] items-center gap-3 mb-4 p-3 bg-(--app-subtle);
|
||||
}
|
||||
.asset-project-list {
|
||||
@apply grid gap-2;
|
||||
.asset-library-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(240px,_100%),_1fr))] gap-4;
|
||||
}
|
||||
.asset-project-item {
|
||||
@apply flex min-w-0 items-center justify-between gap-5 px-4 py-3 bg-(--app-surface);
|
||||
.asset-library-card {
|
||||
@apply min-w-0 overflow-hidden bg-(--app-surface);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.asset-project-item {
|
||||
@apply items-start gap-3;
|
||||
.asset-library-image {
|
||||
@apply aspect-[4/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>
|
||||
|
||||
@@ -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<GlobalProjectAsset[]>('/assets', { signal }),
|
||||
list: (projectId: string, signal?: AbortSignal) => request<ProjectAsset[]>(assetPath(projectId), { signal }),
|
||||
get: (projectId: string, assetId: string, signal?: AbortSignal) =>
|
||||
request<ProjectAsset>(assetPath(projectId, assetId), { signal }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 () => {
|
||||
const fetcher = vi
|
||||
.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('雨夜街道')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user