feat: 全局资产库直接展示全部图片
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('雨夜街道')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user