refactor: 将资产库移至全局导航
This commit is contained in:
+5
-1
@@ -108,7 +108,6 @@ const workflowItems = [
|
|||||||
['visual-style', '视觉风格', Palette],
|
['visual-style', '视觉风格', Palette],
|
||||||
['subject-identity', '主体身份', Fingerprint],
|
['subject-identity', '主体身份', Fingerprint],
|
||||||
['subject-images', '形态图片', Images],
|
['subject-images', '形态图片', Images],
|
||||||
['assets', '资产库', Archive],
|
|
||||||
['storyboard', '分镜设计', Camera],
|
['storyboard', '分镜设计', Camera],
|
||||||
['production', '镜头生产', Clapperboard]
|
['production', '镜头生产', Clapperboard]
|
||||||
] as const
|
] as const
|
||||||
@@ -126,6 +125,11 @@ const menuOptions = computed<MenuOption[]>(() => {
|
|||||||
label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')),
|
label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')),
|
||||||
icon: createMenuIcon(FolderOpen)
|
icon: createMenuIcon(FolderOpen)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/assets',
|
||||||
|
label: () => h(RouterLink, { to: '/assets' }, () => h('span', { class: 'admin-menu-label' }, '资产库')),
|
||||||
|
icon: createMenuIcon(Archive)
|
||||||
|
},
|
||||||
...(projectId.value
|
...(projectId.value
|
||||||
? [
|
? [
|
||||||
{ type: 'divider' as const, key: 'divider' },
|
{ type: 'divider' as const, key: 'divider' },
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NInput, NTag } from 'naive-ui'
|
||||||
|
import { ArrowRight, RefreshCw, Search } from '@lucide/vue'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { useQuery } from '../../composables/useQuery'
|
||||||
|
import { projectsApi } from '../projects/api'
|
||||||
|
|
||||||
|
/** 全局资产库只负责选择项目;素材读写仍严格使用后端的项目级接口。 */
|
||||||
|
const query = useQuery(ref('asset-projects'), (_, signal) => projectsApi.list(signal))
|
||||||
|
const search = ref('')
|
||||||
|
const projects = 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))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function projectName(title: string | null, topic: string) {
|
||||||
|
return title?.trim() || topic
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WorkspacePage compact class="asset-library-page">
|
||||||
|
<template #header>
|
||||||
|
<div class="page-heading asset-library-heading">
|
||||||
|
<div>
|
||||||
|
<h1>资产库</h1>
|
||||||
|
<p class="page-description">按剧本管理人物、场景、道具和参考素材。</p>
|
||||||
|
</div>
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
class="icon-button"
|
||||||
|
:loading="query.loading.value"
|
||||||
|
aria-label="刷新资产项目"
|
||||||
|
title="刷新资产项目"
|
||||||
|
@click="query.refresh"
|
||||||
|
>
|
||||||
|
<template #icon><RefreshCw :size="16" /></template>
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<NInput
|
||||||
|
v-model:value="search"
|
||||||
|
clearable
|
||||||
|
class="asset-project-search"
|
||||||
|
placeholder="搜索剧本名称、主题或风格"
|
||||||
|
:input-props="{ 'aria-label': '搜索资产所属剧本' }"
|
||||||
|
>
|
||||||
|
<template #prefix><Search :size="15" /></template>
|
||||||
|
</NInput>
|
||||||
|
|
||||||
|
<div v-if="projects.length" class="asset-project-list">
|
||||||
|
<article v-for="project in projects" :key="project.id" class="asset-project-item">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 truncate text-xs text-muted">{{ project.topic }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-if="project.status === 'completed'"
|
||||||
|
v-slot="{ href, navigate }"
|
||||||
|
:to="`/projects/${encodeURIComponent(project.id)}/assets`"
|
||||||
|
custom
|
||||||
|
>
|
||||||
|
<NButton tag="a" :href="href" text type="primary" @click="navigate">
|
||||||
|
管理素材<template #icon><ArrowRight :size="15" /></template>
|
||||||
|
</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
<NButton v-else text disabled
|
||||||
|
>管理素材<template #icon><ArrowRight :size="15" /></template
|
||||||
|
></NButton>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="query.data.value"
|
||||||
|
:title="search ? '没有匹配的剧本' : '还没有可管理的剧本'"
|
||||||
|
:description="search ? '请调整搜索关键词。' : '创建剧本后,可在这里进入对应的项目资产库。'"
|
||||||
|
>
|
||||||
|
<NButton v-if="search" @click="search = ''">清除搜索</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>
|
||||||
|
</WorkspacePage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../styles/styles.css";
|
||||||
|
.asset-library-heading {
|
||||||
|
@apply flex items-start justify-between gap-4;
|
||||||
|
}
|
||||||
|
.asset-project-search {
|
||||||
|
@apply max-w-[420px] mb-4;
|
||||||
|
}
|
||||||
|
.asset-project-list {
|
||||||
|
@apply grid gap-2;
|
||||||
|
}
|
||||||
|
.asset-project-item {
|
||||||
|
@apply flex min-w-0 items-center justify-between gap-5 px-4 py-3 bg-(--app-surface);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.asset-project-item {
|
||||||
|
@apply items-start gap-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,11 @@ export const router = createRouter({
|
|||||||
component: () => import('../features/projects/ProjectsPage.vue'),
|
component: () => import('../features/projects/ProjectsPage.vue'),
|
||||||
meta: { title: '我的剧本' }
|
meta: { title: '我的剧本' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/assets',
|
||||||
|
component: () => import('../features/project-assets/AssetLibraryPage.vue'),
|
||||||
|
meta: { title: '资产库' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/projects/:projectId',
|
path: '/projects/:projectId',
|
||||||
component: () => import('../features/projects/ProjectLayout.vue'),
|
component: () => import('../features/projects/ProjectLayout.vue'),
|
||||||
|
|||||||
@@ -189,12 +189,12 @@ describe('管理后台组件边界', () => {
|
|||||||
const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text())
|
const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text())
|
||||||
expect(labels).toEqual([
|
expect(labels).toEqual([
|
||||||
'我的剧本',
|
'我的剧本',
|
||||||
|
'资产库',
|
||||||
'剧本创作',
|
'剧本创作',
|
||||||
'剧本拆解',
|
'剧本拆解',
|
||||||
'视觉风格',
|
'视觉风格',
|
||||||
'主体身份',
|
'主体身份',
|
||||||
'形态图片',
|
'形态图片',
|
||||||
'资产库',
|
|
||||||
'分镜设计',
|
'分镜设计',
|
||||||
'镜头生产'
|
'镜头生产'
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('剧本完成前的下游访问限制', () => {
|
|||||||
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
||||||
}
|
}
|
||||||
expect(mounted).not.toHaveBeenCalled()
|
expect(mounted).not.toHaveBeenCalled()
|
||||||
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(7)
|
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6)
|
||||||
await wrapper!.get('.project-access-gate button').trigger('click')
|
await wrapper!.get('.project-access-gate button').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
|
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
|
||||||
@@ -147,7 +147,7 @@ describe('剧本完成前的下游访问限制', () => {
|
|||||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
|
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
|
||||||
await router.push('/projects/second/production')
|
await router.push('/projects/second/production')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
|
||||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||||
resolveSecond(project('second', 'draft'))
|
resolveSecond(project('second', 'draft'))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -155,7 +155,7 @@ describe('剧本完成前的下游访问限制', () => {
|
|||||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||||
await router.push('/projects')
|
await router.push('/projects')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
|
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
|
it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
|
||||||
@@ -166,7 +166,7 @@ describe('剧本完成前的下游访问限制', () => {
|
|||||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||||
const { mounted } = await openProject('/projects/retry/storyboard')
|
const { mounted } = await openProject('/projects/retry/storyboard')
|
||||||
expect(mounted).not.toHaveBeenCalled()
|
expect(mounted).not.toHaveBeenCalled()
|
||||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
|
||||||
expect(wrapper!.find('.project-header').exists()).toBe(false)
|
expect(wrapper!.find('.project-header').exists()).toBe(false)
|
||||||
await wrapper!.get('.project-notices button').trigger('click')
|
await wrapper!.get('.project-notices button').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
Reference in New Issue
Block a user