feat: 在全局资产库内完成素材管理
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { NAlert, NButton, NInput, NSelect, NTag } from 'naive-ui'
|
||||
import { ArrowRight, RefreshCw, Search } from '@lucide/vue'
|
||||
import { AssetImage, EmptyState } from '../../components/ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag } from 'naive-ui'
|
||||
import { Pencil, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import { useQuery } from '../../composables/useQuery'
|
||||
import { fieldRule } from '../../lib/form-rules'
|
||||
import { errorMessage } from '../../lib/http'
|
||||
import { projectsApi } from '../projects/api'
|
||||
import { projectAssetsApi } from './api'
|
||||
import type { GlobalProjectAsset, ProjectAssetProject } from './types'
|
||||
|
||||
/** 后端当前允许的图片格式与大小。 */
|
||||
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
const categoryPattern = /^[a-z0-9_-]+$/
|
||||
const categoryLabels: Record<string, string> = {
|
||||
overall: '综合',
|
||||
character: '人物',
|
||||
@@ -19,9 +26,21 @@ const categoryLabels: Record<string, string> = {
|
||||
|
||||
/** 全局接口一次返回全部图片及所属剧本,避免按项目产生 N 次请求。 */
|
||||
const query = useQuery(ref('all-project-assets'), (_, signal) => projectAssetsApi.listAll(signal))
|
||||
/** 上传需要列出包括尚无资产在内的全部剧本。 */
|
||||
const projectsQuery = useQuery(ref('asset-project-options'), (_, signal) => projectsApi.list(signal))
|
||||
const search = ref('')
|
||||
const projectId = ref<string | null>(null)
|
||||
const category = ref<string | null>(null)
|
||||
const busy = ref(false)
|
||||
const actionError = ref('')
|
||||
const fileError = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadOpen = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedAsset = ref<GlobalProjectAsset | null>(null)
|
||||
const uploadForm = reactive({ projectId: '', name: '', category: 'reference' })
|
||||
const editForm = reactive({ name: '', category: '' })
|
||||
const assets = computed(() => query.data.value ?? [])
|
||||
const projectOptions = computed(() => {
|
||||
const projects = new Map<string, ProjectAssetProject>()
|
||||
@@ -30,6 +49,11 @@ const projectOptions = computed(() => {
|
||||
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
|
||||
.map(project => ({ label: projectName(project), value: project.id }))
|
||||
})
|
||||
const uploadProjectOptions = computed(() =>
|
||||
(projectsQuery.data.value ?? [])
|
||||
.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()
|
||||
@@ -46,8 +70,16 @@ const filteredAssets = computed(() => {
|
||||
)
|
||||
})
|
||||
})
|
||||
const formRules = {
|
||||
projectId: fieldRule(value => typeof value === 'string' && !!value, '请选择所属剧本'),
|
||||
name: fieldRule(value => typeof value === 'string' && !!value.trim(), '请输入素材名称'),
|
||||
category: fieldRule(
|
||||
value => typeof value === 'string' && categoryPattern.test(value.trim()),
|
||||
'分类只能包含小写字母、数字、下划线和连字符'
|
||||
)
|
||||
}
|
||||
|
||||
function projectName(project: ProjectAssetProject) {
|
||||
function projectName(project: Pick<ProjectAssetProject, 'title' | 'topic'>) {
|
||||
return project.title?.trim() || project.topic
|
||||
}
|
||||
|
||||
@@ -67,8 +99,99 @@ function clearFilters() {
|
||||
category.value = null
|
||||
}
|
||||
|
||||
/** 类型标记帮助模板保持后端全局资产契约。 */
|
||||
const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
/** 同时刷新图片与上传所需的剧本选项。 */
|
||||
async function refreshAll() {
|
||||
await Promise.all([query.refresh(), projectsQuery.refresh()])
|
||||
}
|
||||
|
||||
/** 先校验本地文件,再在当前页选择剧本并确认资料。 */
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
fileError.value = ''
|
||||
actionError.value = ''
|
||||
if (!file) return
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
fileError.value = '仅支持 JPEG、PNG、WebP 图片。'
|
||||
return
|
||||
}
|
||||
if (!file.size || file.size > MAX_FILE_SIZE) {
|
||||
fileError.value = '上传图片不能为空且不能超过 20MB。'
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
uploadForm.projectId = projectId.value ?? ''
|
||||
uploadForm.name = file.name.replace(/\.[^.]+$/, '') || file.name
|
||||
uploadForm.category = 'reference'
|
||||
uploadOpen.value = true
|
||||
}
|
||||
|
||||
async function uploadAsset() {
|
||||
const file = selectedFile.value
|
||||
if (!file || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.upload(uploadForm.projectId, {
|
||||
file,
|
||||
name: uploadForm.name.trim(),
|
||||
category: uploadForm.category.trim()
|
||||
})
|
||||
uploadOpen.value = false
|
||||
selectedFile.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(asset: GlobalProjectAsset) {
|
||||
selectedAsset.value = asset
|
||||
editForm.name = asset.name
|
||||
editForm.category = asset.category
|
||||
actionError.value = ''
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function updateAsset() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.update(asset.projectId, asset.id, {
|
||||
name: editForm.name.trim(),
|
||||
category: editForm.category.trim()
|
||||
})
|
||||
editOpen.value = false
|
||||
selectedAsset.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAsset() {
|
||||
const asset = selectedAsset.value
|
||||
if (!asset || busy.value) return
|
||||
busy.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
await projectAssetsApi.remove(asset.projectId, asset.id)
|
||||
editOpen.value = false
|
||||
selectedAsset.value = null
|
||||
await query.refresh()
|
||||
} catch (error) {
|
||||
actionError.value = errorMessage(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,24 +200,46 @@ const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
<div class="page-heading asset-library-heading">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<p class="page-description">汇总所有剧本的人物、场景、道具和参考图片。</p>
|
||||
<p class="page-description">汇总并管理所有剧本的人物、场景、道具和参考图片。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
aria-label="选择全局资产图片"
|
||||
@change="selectFile"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
:disabled="busy || !uploadProjectOptions.length"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<template #icon><Upload :size="16" /></template>上传图片
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
class="icon-button"
|
||||
:loading="query.loading.value"
|
||||
:loading="query.loading.value || projectsQuery.loading.value"
|
||||
aria-label="刷新资产库"
|
||||
title="刷新资产库"
|
||||
@click="query.refresh"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<template #icon><RefreshCw :size="16" /></template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NAlert v-if="fileError" type="error" :show-icon="false" class="mb-4">{{ fileError }}</NAlert>
|
||||
<NAlert v-if="query.error.value" type="error" :show-icon="false" class="mb-4">
|
||||
{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton>
|
||||
</NAlert>
|
||||
<NAlert v-if="projectsQuery.error.value" type="warning" :show-icon="false" class="mb-4">
|
||||
剧本列表读取失败,暂时不能上传新图片。
|
||||
<NButton text class="ml-3" @click="projectsQuery.refresh">重新读取</NButton>
|
||||
</NAlert>
|
||||
|
||||
<div class="asset-library-filters">
|
||||
<NInput
|
||||
@@ -120,11 +265,11 @@ const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
:options="categoryOptions"
|
||||
aria-label="筛选资产分类"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ typedAssets.length }} / {{ assets.length }} 张</span>
|
||||
<span class="text-xs text-muted">{{ filteredAssets.length }} / {{ assets.length }} 张</span>
|
||||
</div>
|
||||
|
||||
<div v-if="typedAssets.length" class="asset-library-grid">
|
||||
<article v-for="asset in typedAssets" :key="asset.id" class="asset-library-card">
|
||||
<div v-if="filteredAssets.length" class="asset-library-grid">
|
||||
<article v-for="asset in filteredAssets" :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">
|
||||
@@ -140,15 +285,9 @@ const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
<span class="text-[11px] text-muted">
|
||||
{{ asset.extension.toUpperCase() }} · {{ formatBytes(asset.size) }}
|
||||
</span>
|
||||
<RouterLink
|
||||
v-slot="{ href, navigate }"
|
||||
:to="`/projects/${encodeURIComponent(asset.projectId)}/assets`"
|
||||
custom
|
||||
>
|
||||
<NButton tag="a" :href="href" text size="small" type="primary" @click="navigate">
|
||||
管理<template #icon><ArrowRight :size="14" /></template>
|
||||
<NButton text size="small" type="primary" :disabled="busy" @click="openEdit(asset)">
|
||||
<template #icon><Pencil :size="14" /></template>管理
|
||||
</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -157,14 +296,72 @@ const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
<EmptyState
|
||||
v-else-if="query.data.value"
|
||||
:title="assets.length ? '没有匹配的图片' : '资产库还是空的'"
|
||||
:description="assets.length ? '调整搜索、剧本或分类筛选。' : '进入剧本资产库上传图片后,会自动汇总到这里。'"
|
||||
:description="assets.length ? '调整搜索、剧本或分类筛选。' : '选择剧本并上传第一张图片。'"
|
||||
>
|
||||
<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>
|
||||
<NButton v-else type="primary" :disabled="!uploadProjectOptions.length" @click="fileInput?.click()">
|
||||
上传第一张图片
|
||||
</NButton>
|
||||
</EmptyState>
|
||||
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片…</p>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="uploadOpen"
|
||||
title="上传资产图片"
|
||||
description="选择所属剧本并确认名称和分类,提交后仍停留在当前资产库。"
|
||||
:busy="busy"
|
||||
>
|
||||
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
|
||||
<AppForm :model="uploadForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="uploadAsset">
|
||||
<p class="mb-4 text-sm">{{ selectedFile?.name }} · {{ formatBytes(selectedFile?.size ?? 0) }}</p>
|
||||
<NFormItem path="projectId" label="所属剧本">
|
||||
<NSelect v-model:value="uploadForm.projectId" filterable :options="uploadProjectOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="uploadForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识">
|
||||
<NInput v-model:value="uploadForm.category" placeholder="例如 character、scene、prop" />
|
||||
</NFormItem>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<NButton :disabled="busy" @click="uploadOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="busy">确认上传</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model:open="editOpen"
|
||||
title="管理素材信息"
|
||||
description="可在当前页面修改名称和分类,或删除素材文件。"
|
||||
:busy="busy"
|
||||
>
|
||||
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
|
||||
<AppForm :model="editForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="updateAsset">
|
||||
<NFormItem label="所属剧本">
|
||||
<NInput :value="selectedAsset ? projectName(selectedAsset.project) : ''" disabled />
|
||||
</NFormItem>
|
||||
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
|
||||
<NFormItem path="category" label="分类标识"><NInput v-model:value="editForm.category" /></NFormItem>
|
||||
<div class="mt-4 flex items-center justify-between gap-3">
|
||||
<NPopconfirm
|
||||
:positive-button-props="{ disabled: busy }"
|
||||
positive-text="确认删除"
|
||||
negative-text="取消"
|
||||
@positive-click="removeAsset"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton class="text-danger" :disabled="busy">
|
||||
<template #icon><Trash2 :size="14" /></template>删除素材
|
||||
</NButton>
|
||||
</template>
|
||||
删除后物理文件也会移除;正在被视觉风格引用的素材会由后端阻止删除。
|
||||
</NPopconfirm>
|
||||
<div class="flex gap-3">
|
||||
<NButton :disabled="busy" @click="editOpen = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="busy">保存修改</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
@@ -197,6 +394,9 @@ const typedAssets = computed<GlobalProjectAsset[]>(() => filteredAssets.value)
|
||||
}
|
||||
}
|
||||
@container asset-library (max-width: 560px) {
|
||||
.asset-library-heading {
|
||||
@apply items-start;
|
||||
}
|
||||
.asset-library-filters {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ afterEach(() => {
|
||||
|
||||
describe('全局资产库', () => {
|
||||
it('单次读取并直接显示全部剧本图片,支持按图片与剧本搜索', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
const assets = [
|
||||
{
|
||||
id: 'asset-scene',
|
||||
projectId: 'project-one',
|
||||
@@ -48,9 +45,16 @@ describe('全局资产库', () => {
|
||||
project: { id: 'project-two', title: '天降甘霖', topic: '乡村故事', status: 'completed' }
|
||||
}
|
||||
]
|
||||
const projects = assets.map(asset => ({
|
||||
...asset.project,
|
||||
style: null,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}))
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async input => {
|
||||
const url = String(input)
|
||||
return new Response(JSON.stringify({ data: url.endsWith('/projects') ? projects : assets }))
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
@@ -63,13 +67,21 @@ describe('全局资产库', () => {
|
||||
wrapper = mount(AssetLibraryPage, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
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
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '管理')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"][aria-label="管理素材信息"]')).not.toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/assets')
|
||||
|
||||
await wrapper.get('input[aria-label="搜索资产图片"]').setValue('天降甘霖')
|
||||
expect(wrapper.findAll('.asset-library-card')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('林默正面照')
|
||||
|
||||
Reference in New Issue
Block a user