fix: 将全部业务抽屉提升为全局全高侧栏
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
/** 操作抽屉挂载到当前工作区,覆盖内容而不挤压列表或遮住整个应用导航。 */
|
||||
export const workspaceHostKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('workspace-host')
|
||||
@@ -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('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user