fix: 将全部业务抽屉提升为全局全高侧栏

This commit is contained in:
GouJ
2026-09-04 10:23:00 +08:00
parent 13bb6dc83b
commit f3657ea0a6
8 changed files with 68 additions and 54 deletions
+1 -5
View File
@@ -1,14 +1,10 @@
<script setup lang="ts">
import { NScrollbar } from 'naive-ui'
import { provide, ref } from 'vue'
import { workspaceHostKey } from './workspaceContext'
/** 工作区固定标题和操作区;仅内容容器滚动,不向 document 传播滚动。 */
defineProps<{ split?: boolean; compact?: boolean }>()
const host = ref<HTMLElement | null>(null)
provide(workspaceHostKey, host)
</script>
<template>
<section ref="host" class="workspace-page" :class="{ 'content-first-workspace': compact }">
<section 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>
+3 -6
View File
@@ -1,13 +1,10 @@
<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'
/** 低频配置、批量动作与回执在抽屉中查看,开关面板不影响页面查询和后台任务。 */
/** 所有业务抽屉挂到 body,以完整视口为边界;开关面板不影响草稿、页面查询和后台任务。 */
withDefaults(defineProps<{ title: string; label?: string; hasReceipt?: boolean }>(), { label: '操作与回执' })
const open = defineModel<boolean>('open', { default: false })
const host = inject(workspaceHostKey, undefined)
</script>
<template>
@@ -23,10 +20,10 @@ const host = inject(workspaceHostKey, undefined)
</NButton>
<NDrawer
v-model:show="open"
:to="host ?? undefined"
to="body"
width="min(960px, 100%)"
placement="right"
:block-scroll="false"
:block-scroll="true"
display-directive="show"
class="workspace-tools-drawer"
:aria-label="title"
+26 -7
View File
@@ -12,6 +12,7 @@ import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
import ThemeToggle from './ThemeToggle.vue'
import { projectsApi } from '../../features/projects/api'
import { readFileSync } from 'node:fs'
import { drawerPanel } from '../../testing/naive'
let wrapper: VueWrapper | undefined
afterEach(() => {
@@ -188,7 +189,7 @@ describe('管理后台组件边界', () => {
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
})
it('常用工具固定在列表外,批量抽屉挂载于工作区,开关保留列表与配置草稿', async () => {
it('抽屉挂载 body 覆盖完整视口,开关保留列表与配置草稿', async () => {
wrapper = mount(WorkspacePage, {
attachTo: document.body,
props: { split: true, compact: true },
@@ -202,18 +203,36 @@ describe('管理后台组件边界', () => {
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(NDrawer).props()).toMatchObject({
to: 'body',
blockScroll: true,
placement: 'right',
width: 'min(960px, 100%)',
trapFocus: true,
closeOnEsc: true
})
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')
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
const container = drawerPanel().element.closest('.n-drawer-container')!
expect(container.parentElement).toBe(document.body)
expect(container.closest('main, .admin-content, .workspace-page')).toBeNull()
expect(container.querySelector('.n-drawer-mask')).not.toBeNull()
expect(drawerPanel().find('.n-scrollbar-container').exists()).toBe(true)
await drawerPanel().get('input[aria-label="配置草稿"]').setValue('保留配置')
await drawerPanel().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('保留配置')
expect(drawerPanel().get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
container.querySelector<HTMLElement>('.n-drawer-mask')!.click()
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
expect(wrapper.get('.test-list').element).toBe(list)
wrapper.unmount()
wrapper = undefined
expect(document.querySelector('.n-drawer-container')).toBeNull()
})
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
-4
View File
@@ -1,4 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
/** 操作抽屉挂载到当前工作区,覆盖内容而不挤压列表或遮住整个应用导航。 */
export const workspaceHostKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('workspace-host')
+9 -9
View File
@@ -8,7 +8,7 @@ import { projectContextKey, type useProjectData } from '../projects/context'
import type { ProjectDetail } from '../projects/types'
import type { Checkpoint } from '../workflows/types'
import type { BreakdownModule, EpisodePlan } from './types'
import { selectMenu } from '../../testing/naive'
import { drawerPanel, selectMenu } from '../../testing/naive'
let wrapper: VueWrapper | undefined
afterEach(() => {
@@ -147,7 +147,7 @@ describe('拆解页内容滚动', () => {
const provided = mountPage()
await wrapper!.get('[data-workspace-tools]').trigger('click')
await flushPromises()
const config = wrapper!.get('.breakdown-config')
const config = drawerPanel().get('.breakdown-config')
expect(config.get('label[for="group-size"]').text()).toBe('每组集数')
expect(config.get<HTMLInputElement>('#group-size').element.value).toBe('3')
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
@@ -165,7 +165,7 @@ describe('拆解页内容滚动', () => {
await checkbox.trigger('click')
}
expect(preview.element.disabled).toBe(true)
expect(wrapper!.text()).toContain('至少选择一个抽取模块')
expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
await options[1]!.get('[role="checkbox"]').trigger('click')
expect(preview.element.disabled).toBe(false)
provided.data.value!.project.status = 'generating'
@@ -389,9 +389,9 @@ describe('拆解页内容滚动', () => {
history.scrollTop = 120
await wrapper!.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper!.get('.workspace-tools-drawer').element.closest('.workspace-split')).toBeNull()
await wrapper!.get('#group-size').setValue('2')
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
expect(drawerPanel().element.closest('.workspace-split')).toBeNull()
await drawerPanel().get('#group-size').setValue('2')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
provided.data.value!.checkpoints = [checkpoint()]
await flushPromises()
expect(wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element).toBe(results)
@@ -404,7 +404,7 @@ describe('拆解页内容滚动', () => {
expect(history.scrollTop).toBe(120)
await wrapper!.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper!.get<HTMLInputElement>('#group-size').element.value).toBe('2')
expect(drawerPanel().get<HTMLInputElement>('#group-size').element.value).toBe('2')
expect(fetcher).not.toHaveBeenCalled()
})
@@ -423,8 +423,8 @@ describe('拆解页内容滚动', () => {
expect(feedback.find('.breakdown-results-section').exists()).toBe(false)
await feedback.get('button').trigger('click')
await flushPromises()
expect(wrapper!.get('.workspace-tools-drawer').text()).toContain('缺少形态绑定')
expect(wrapper!.get('.workspace-tools-drawer').text()).toContain('重试失败抽取')
expect(drawerPanel().text()).toContain('缺少形态绑定')
expect(drawerPanel().text()).toContain('重试失败抽取')
})
it('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => {
+21 -21
View File
@@ -28,7 +28,7 @@ import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
import { getIdentitySession } from '../subject-identity/model'
import { expandSections, selectControl, selectMenu } from '../../testing/naive'
import { drawerPanel, expandSections, selectControl, selectMenu } from '../../testing/naive'
import ProductionPage from '../production/ProductionPage.vue'
import { getProductionSession } from '../production/model'
@@ -221,8 +221,8 @@ describe('内容级滚动下的生产反馈', () => {
}
await flushPromises()
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
expect(wrapper.find('.workspace-tools-drawer .n-collapse-item--active').exists()).toBe(true)
expect(wrapper.text()).toContain('供应商不可用')
expect(drawerPanel().find('.n-collapse-item--active').exists()).toBe(true)
expect(drawerPanel().text()).toContain('供应商不可用')
})
})
@@ -302,7 +302,7 @@ describe('视觉风格与主体身份工作区', () => {
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
const items = wrapper!.findAll('.identity-subject-item')
expect(items).toHaveLength(3)
@@ -592,9 +592,9 @@ describe('视觉风格与主体身份工作区', () => {
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
expect(wrapper!.text()).toContain('本次存在部分失败')
expect(wrapper!.text()).toContain('含锁定 1')
expect(wrapper!.text()).toContain('文本模型超时')
expect(drawerPanel().text()).toContain('本次存在部分失败')
expect(drawerPanel().text()).toContain('含锁定 1')
expect(drawerPanel().text()).toContain('文本模型超时')
})
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
@@ -673,8 +673,8 @@ describe('视觉风格与主体身份工作区', () => {
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(wrapper!.get('.identity-tools-intro').text()).toContain('为同一个人物、场景或道具固定稳定特征')
const cards = wrapper!.get('[aria-label="角色选角进度"]').findAll('.casting-item')
expect(drawerPanel().get('.identity-tools-intro').text()).toContain('为同一个人物、场景或道具固定稳定特征')
const cards = drawerPanel().get('[aria-label="角色选角进度"]').findAll('.casting-item')
expect(cards).toHaveLength(2)
expect(cards[0]!.get('.casting-item-name').text()).toBe(longName)
expect(cards[0]!.get('.casting-item-ref').text()).toBe('@CH0001')
@@ -883,7 +883,7 @@ describe('工作台页面交互', () => {
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
const card = wrapper!.get('.form-image-card')
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
await card.get('img').trigger('click')
@@ -951,11 +951,11 @@ describe('工作台页面交互', () => {
await mountSubjectImages()
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('@CH0001')
await confirmGeneration('补齐项目主参考图')
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
expect(wrapper!.text()).toContain('服务暂不可用')
expect(drawerPanel().get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
expect(drawerPanel().text()).toContain('服务暂不可用')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ concurrency: 2, force: false })
await wrapper!.get('.n-collapse [role="checkbox"]').trigger('click')
await drawerPanel().get('.n-collapse [role="checkbox"]').trigger('click')
await confirmGeneration('为全项目新增候选图')
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
@@ -1109,7 +1109,7 @@ describe('工作台页面交互', () => {
await flushPromises()
await confirmGeneration('生成本集导演设计')
expect(button('补齐项目导演设计').disabled).toBe(true)
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
expect(getOperation(fixture.id).pending).toBe(true)
@@ -1169,12 +1169,12 @@ describe('工作台页面交互', () => {
button('刷新分镜').click()
await flushPromises()
expect(button('生成本集即时状态').disabled).toBe(false)
await wrapper!.get('#storyboard-repairs').setValue('0')
await drawerPanel().get('#storyboard-repairs').setValue('0')
await confirmGeneration('生成本集即时状态')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1, persist: true, maxRepairAttempts: 0 })
expect(wrapper!.text()).toContain('校验失败,未保存本次生成结果')
expect(wrapper!.text()).toContain('缺少主体状态')
expect(drawerPanel().text()).toContain('校验失败,未保存本次生成结果')
expect(drawerPanel().text()).toContain('缺少主体状态')
})
it('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => {
@@ -1216,9 +1216,9 @@ describe('工作台页面交互', () => {
await mountStoryboard()
await flushPromises()
await confirmGeneration('补齐项目导演设计')
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
expect(wrapper!.text()).toContain('模型校验失败')
await wrapper!.get('[role="checkbox"]').trigger('click')
expect(drawerPanel().get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
expect(drawerPanel().text()).toContain('模型校验失败')
await drawerPanel().get('[role="checkbox"]').trigger('click')
await confirmGeneration('重生成全部导演设计')
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
@@ -1415,7 +1415,7 @@ describe('工作台页面交互', () => {
button('预览分组').click()
await flushPromises()
expect(button('开始拆解').disabled).toBe(false)
await wrapper.get('#group-size').setValue('2')
await drawerPanel().get('#group-size').setValue('2')
expect(document.body.textContent).not.toContain('开始拆解')
button('预览分组').click()
await flushPromises()
+6 -1
View File
@@ -1,6 +1,11 @@
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { DOMWrapper, flushPromises, type VueWrapper } from '@vue/test-utils'
import { NSelect } from 'naive-ui'
/** 抽屉通过真实 Teleport 挂到 body,测试也从外层读取,不禁用 Teleport 掩盖挂载问题。 */
export function drawerPanel() {
return new DOMWrapper(document.body).get<HTMLElement>('.workspace-tools-drawer')
}
/** 通过真实菜单触发器访问低频功能,不直接调用组件内部方法绕过可见性。 */
export async function selectMenu(label: string, option: string) {
const trigger = [...document.querySelectorAll<HTMLButtonElement>('button')].find(