diff --git a/README.md b/README.md
index 52ee9cd..06de6d1 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,12 @@
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。
-## 最新同步(2026-09-03)
+## 最新同步(2026-09-04)
- 对齐后端 `dev@05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`:图片/视频模型由后端统一配置,前端不再发送固定 Provider。
- 新增首帧视觉校验、有限自动修复、当前集小批量质量生成、视频抽样帧质量检查、视频修复候选与复检晋升、只读模型能力查询。默认不调用模型,付费操作需确认,结果可定位和导出。
- 保留主要布局和业务能力,导出/执行记录/提示词等次要入口收进菜单,长介绍和高级规则按需展开;关键错误、过期、未保存与费用提示继续直显。
+- 全部业务抽屉统一挂载到 `body`,侧栏及遮罩覆盖完整视口,不受 `main/content` 裁切;保持原有宽度、正文内部滚动和关闭后配置草稿。
入口和边界见 [前端简化与质量功能同步](docs/frontend-simplification.md),接口覆盖见 [后端功能覆盖说明](docs/backend-coverage.md)。
diff --git a/src/components/ui/WorkspacePage.vue b/src/components/ui/WorkspacePage.vue
index 192dd6f..7f2be9f 100644
--- a/src/components/ui/WorkspacePage.vue
+++ b/src/components/ui/WorkspacePage.vue
@@ -1,14 +1,10 @@
-
+
diff --git a/src/components/ui/WorkspaceTools.vue b/src/components/ui/WorkspaceTools.vue
index 33988dc..8456664 100644
--- a/src/components/ui/WorkspaceTools.vue
+++ b/src/components/ui/WorkspaceTools.vue
@@ -1,13 +1,10 @@
@@ -23,10 +20,10 @@ const host = inject(workspaceHostKey, undefined)
{
@@ -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('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
+ expect(drawerPanel().get('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
+ container.querySelector('.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 () => {
diff --git a/src/components/ui/workspaceContext.ts b/src/components/ui/workspaceContext.ts
deleted file mode 100644
index c5a0a42..0000000
--- a/src/components/ui/workspaceContext.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import type { InjectionKey, Ref } from 'vue'
-
-/** 操作抽屉挂载到当前工作区,覆盖内容而不挤压列表或遮住整个应用导航。 */
-export const workspaceHostKey: InjectionKey[> = Symbol('workspace-host')
diff --git a/src/features/breakdown/BreakdownPage.test.ts b/src/features/breakdown/BreakdownPage.test.ts
index eaae85b..52d78f2 100644
--- a/src/features/breakdown/BreakdownPage.test.ts
+++ b/src/features/breakdown/BreakdownPage.test.ts
@@ -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('#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('#group-size').element.value).toBe('2')
+ expect(drawerPanel().get('#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('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => {
diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts
index 9a4bce9..7806141 100644
--- a/src/features/workflows/pages.test.ts
+++ b/src/features/workflows/pages.test.ts
@@ -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()
diff --git a/src/testing/naive.ts b/src/testing/naive.ts
index 9f8ffd0..392cfc4 100644
--- a/src/testing/naive.ts
+++ b/src/testing/naive.ts
@@ -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('.workspace-tools-drawer')
+}
+
/** 通过真实菜单触发器访问低频功能,不直接调用组件内部方法绕过可见性。 */
export async function selectMenu(label: string, option: string) {
const trigger = [...document.querySelectorAll('button')].find(
]