refactor: 精简导航并扩大工作区内容显示面积

This commit is contained in:
GouJ
2026-09-01 17:45:40 +08:00
parent 70ab4ba48f
commit 0279c701b4
15 changed files with 1256 additions and 821 deletions
+6 -2
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { NScrollbar } from 'naive-ui'
import { provide, ref } from 'vue'
import { workspaceHostKey } from './workspaceContext'
/** 工作区固定标题和操作区;仅内容容器滚动,不向 document 传播滚动。 */
defineProps<{ split?: boolean }>()
defineProps<{ split?: boolean; compact?: boolean }>()
const host = ref<HTMLElement | null>(null)
provide(workspaceHostKey, host)
</script>
<template>
<section class="workspace-page">
<section ref="host" class="workspace-page" :class="{ 'content-first-workspace': compact }">
<NScrollbar v-if="$slots.header" class="workspace-heading-scroll"
><header class="workspace-heading"><slot name="header" /></header
></NScrollbar>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { inject } from 'vue'
import { NButton, NDrawer, NDrawerContent } from 'naive-ui'
import { Settings2, X } from '@lucide/vue'
import { workspaceHostKey } from './workspaceContext'
/** 低频配置、批量动作与回执在抽屉中查看,开关面板不影响页面查询和后台任务。 */
withDefaults(defineProps<{ title: string; label?: string; hasReceipt?: boolean }>(), { label: '操作与回执' })
const open = defineModel<boolean>('open', { default: false })
const host = inject(workspaceHostKey, undefined)
</script>
<template>
<NButton
class="workspace-tools-trigger"
data-workspace-tools
aria-haspopup="dialog"
:aria-expanded="open"
@click="open = true"
>
<template #icon><Settings2 :size="15" /></template>
{{ label }}<span v-if="hasReceipt" class="text-xs text-muted"> · 有回执</span>
</NButton>
<NDrawer
v-model:show="open"
:to="host ?? undefined"
width="min(960px, 100%)"
placement="right"
:block-scroll="false"
display-directive="show"
class="workspace-tools-drawer"
:aria-label="title"
>
<NDrawerContent :native-scrollbar="false" body-content-class="workspace-tools-body">
<template #header
><div class="workspace-tools-heading">
<span>{{ title }}</span
><NButton quaternary circle aria-label="关闭操作面板" @click="open = false"
><X :size="18"
/></NButton></div
></template>
<slot />
<template #footer><span class="text-xs text-muted">关闭面板不会取消已提交的后台任务</span></template>
</NDrawerContent>
</NDrawer>
</template>
+1
View File
@@ -4,3 +4,4 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as StatusBadge } from './StatusBadge.vue'
export { default as AssetImage } from './AssetImage.vue'
export { default as DirectoryItem } from './DirectoryItem.vue'
export { default as WorkspaceTools } from './WorkspaceTools.vue'
+104 -2
View File
@@ -1,11 +1,15 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { NConfigProvider, NModal, NScrollbar } from 'naive-ui'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar } from 'naive-ui'
import App from '../../App.vue'
import WorkspacePage from './WorkspacePage.vue'
import AppDialog from './AppDialog.vue'
import WorkspaceTools from './WorkspaceTools.vue'
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
import { projectsApi } from '../../features/projects/api'
import { readFileSync } from 'node:fs'
let wrapper: VueWrapper | undefined
@@ -14,6 +18,7 @@ afterEach(() => {
wrapper = undefined
localStorage.clear()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('管理后台组件边界', () => {
@@ -93,4 +98,101 @@ describe('管理后台组件边界', () => {
})
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
})
it('常用工具固定在列表外,批量抽屉挂载于工作区,开关保留列表与配置草稿', async () => {
wrapper = mount(WorkspacePage, {
attachTo: document.body,
props: { split: true, compact: true },
slots: {
header: () => h(WorkspaceTools, { title: '测试操作' }, () => h('input', { 'aria-label': '配置草稿' })),
default: '<article class="test-list">主体列表</article>'
}
})
const list = wrapper.get('.test-list').element
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
expect(wrapper.get('.workspace-heading').find('[data-workspace-tools]').exists()).toBe(true)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('to')).toBe(wrapper.get('.workspace-page').element)
expect(wrapper.getComponent(NDrawerContent).props('nativeScrollbar')).toBe(false)
expect(wrapper.get('.workspace-tools-drawer').element.closest('.workspace-split')).toBeNull()
expect(wrapper.find('.workspace-tools-drawer .n-scrollbar-container').exists()).toBe(true)
await wrapper.get('input[aria-label="配置草稿"]').setValue('保留配置')
await wrapper.get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
expect(wrapper.get('.test-list').element).toBe(list)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper.get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
})
it('移除内容区重复流程导航后,七条左侧链接、项目标题与刷新仍可用', async () => {
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
id: 'navigation-test',
title: '导航测试项目',
topic: '测试主题',
style: null,
status: 'completed',
createdAt: '',
updatedAt: '',
episodes: [],
characters: [],
world: null,
reviews: [],
tasks: []
})
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const paths = [
'create-drama',
'breakdown',
'visual-style',
'subject-identity',
'subject-images',
'storyboard',
'production'
]
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/projects/:projectId',
component: ProjectLayout,
children: paths.map(path => ({
path,
component: { render: () => h(WorkspacePage, {}, () => path) }
}))
}
]
})
await router.push('/projects/navigation-test/create-drama')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
for (const path of paths) {
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/projects/navigation-test/' + path)
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
}
expect(detail).toHaveBeenCalledOnce()
await wrapper.get('.project-header .n-button').trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
})
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
wrapper = mount(ConfirmAction, {
props: { label: '测试操作', description: '仅展示,不提交请求' },
attrs: { class: 'mt-4' }
})
expect(wrapper.element.tagName).toBe('SPAN')
expect(wrapper.classes()).toContain('confirm-action')
expect(wrapper.classes()).toContain('mt-4')
expect(wrapper.get('button').text()).toBe('测试操作')
expect(readFileSync('src/admin.css', 'utf8')).toMatch(
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
)
})
})
+4
View File
@@ -0,0 +1,4 @@
import type { InjectionKey, Ref } from 'vue'
/** 操作抽屉挂载到当前工作区,覆盖内容而不挤压列表或遮住整个应用导航。 */
export const workspaceHostKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('workspace-host')