diff --git a/README.md b/README.md index 6366b81..7348c6d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。 - 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 - 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。 +- 拆解页固定精简状态栏与结果标签,设置、预览及恢复操作收进“拆解设置与恢复”面板;人物、场景、道具、分镜和任务明细共用独立结果滚动区,执行记录单独滚动。结果区占满剩余高度,切换标签回到顶部,轮询刷新或开关设置不重置阅读位置;失败与校验提示在面板关闭时仍然可见。 - 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 diff --git a/src/admin.css b/src/admin.css index 2de9cbf..52ac800 100644 --- a/src/admin.css +++ b/src/admin.css @@ -588,6 +588,27 @@ body { overflow: hidden; overscroll-behavior: contain; } +/* 拆解结果使用剩余高度,不能再用视口比例定高后隐藏长列表。 */ +.breakdown-results-section { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; + gap: 8px; +} +.breakdown-results-section > .result-toolbar { + flex-shrink: 0; +} +.breakdown-results-section > .content-with-history { + flex: 1; + height: auto; + min-height: 0; + grid-template-rows: minmax(0, 1fr); +} +.breakdown-results-scroll .n-scrollbar-content { + overflow-wrap: anywhere; +} /* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */ .script-section .n-tabs-tab__label { display: inline-flex; @@ -1023,6 +1044,9 @@ body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 130px; } + .breakdown-results-section > .content-with-history { + grid-template-rows: minmax(0, 1fr) min(25%, 130px); + } .history-panel { overflow: hidden; min-height: 0; diff --git a/src/components/ui/layout.test.ts b/src/components/ui/layout.test.ts index 764da23..429f7a6 100644 --- a/src/components/ui/layout.test.ts +++ b/src/components/ui/layout.test.ts @@ -32,6 +32,17 @@ describe('管理后台组件边界', () => { const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8') expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/) }) + it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => { + // DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。 + const css = readFileSync('src/admin.css', 'utf8') + expect(css).toMatch(/\.breakdown-results-section\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/) + expect(css).toMatch(/\.breakdown-results-section > \.result-toolbar\s*\{[^}]*flex-shrink:\s*0/) + expect(css).toMatch( + /\.breakdown-results-section > \.content-with-history\s*\{[^}]*flex:\s*1;[^}]*height:\s*auto;[^}]*min-height:\s*0;[^}]*grid-template-rows:\s*minmax\(0, 1fr\)/ + ) + expect(css).toContain('grid-template-rows: minmax(0, 1fr) min(25%, 130px)') + expect(css).toMatch(/\.panel-scroll\.n-scrollbar\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0/) + }) it('标题与内容使用各自滚动容器,不随正文一起滚动', () => { wrapper = mount(WorkspacePage, { slots: { header: '

固定操作区

', default: '

正文

' } diff --git a/src/features/breakdown/BreakdownPage.test.ts b/src/features/breakdown/BreakdownPage.test.ts new file mode 100644 index 0000000..765ded2 --- /dev/null +++ b/src/features/breakdown/BreakdownPage.test.ts @@ -0,0 +1,214 @@ +import { computed, ref } from 'vue' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import BreakdownPage from './BreakdownPage.vue' +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' + +let wrapper: VueWrapper | undefined +afterEach(() => { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +/** 长主体、长分镜与长任务列表,用真实 Naive 滚动组件验证内容边界。 */ +function checkpoint(): Checkpoint { + const plan: EpisodePlan = { + episodeNo: 1, + episodeTitle: '长剧集', + storyGoal: '', + centralConflict: '', + emotionalArc: '', + pacing: '', + endingHook: '', + beats: Array.from({ length: 25 }, (_, i) => ({ + beatNo: i + 1, + title: `节拍 ${i + 1}`, + purpose: 'action', + description: '节拍内容'.repeat(100), + visualFocus: '', + narrativeGoal: '', + emotionalTone: '', + estimatedDurationSeconds: 5, + subjectRefs: [], + isKeyBeat: false + })) + } + return { + checkpointId: 'long-breakdown', + workflowName: 'breakdown', + createdAt: '2026-08-28T00:00:00Z', + state: { + workflowExecution: { executionId: 'run', status: 'completed', startedAt: '2026-08-28T00:00:00Z' }, + breakdownResult: { + subjectCandidates: (['character', 'scene', 'prop'] as BreakdownModule[]).flatMap(module => + Array.from({ length: 30 }, (_, i) => ({ + profileId: `${module}-${i}`, + name: `${module} 主体 ${i + 1}`, + ref: `@${module}${i}`, + description: '很长的主体描述。'.repeat(100), + module, + appearance_prompt: '外观描述' + })) + ), + subjectForms: [], + storyboardPlans: [plan], + storyboardEpisodeShots: [ + { + episodeNo: 1, + episodePlan: plan, + beatShots: plan.beats.map(beat => ({ + beatNo: beat.beatNo, + shots: [ + { + shotNo: 1, + title: `镜头 ${beat.beatNo}`, + description: '镜头内容', + visualFocus: '', + subjectRefs: [], + durationSeconds: 5 + } + ] + })) + } + ] + }, + tasks: Array.from({ length: 50 }, (_, i) => ({ + taskId: `task-${i}`, + module: 'character', + group: { + groupId: `group-${i}`, + groupNo: i + 1, + startEpisodeNo: i + 1, + endEpisodeNo: i + 1, + episodes: [] + }, + status: 'failed', + attempt: 1, + errorMessage: `任务错误 ${i + 1}` + })) + } + } +} + +/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */ +function mountPage(records = [checkpoint()], episodes = 1): ReturnType { + const project: ProjectDetail = { + id: 'breakdown-scroll-test', + title: '滚动回归', + topic: '', + style: '', + status: 'completed', + createdAt: '', + updatedAt: '', + characters: [], + world: null, + reviews: [], + tasks: [], + episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : [] + } + const data = ref({ project, checkpoints: records }) + const provided: ReturnType = { + data, + project: computed(() => data.value.project), + checkpoints: computed(() => data.value.checkpoints), + loading: ref(false), + error: ref(''), + updatedAt: ref(''), + refresh: vi.fn<() => Promise>().mockResolvedValue() + } + wrapper = mount(BreakdownPage, { + attachTo: document.body, + global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } } + }) + return provided +} + +/** 切换真实标签,不直接修改组件内部状态。 */ +async function selectTab(label: string) { + await wrapper! + .findAll('.result-toolbar .n-tabs-tab') + .find(tab => tab.text().startsWith(label))! + .trigger('click') + await flushPromises() +} + +describe('拆解页内容滚动', () => { + it.each([ + ['人物', 'character 主体 30'], + ['场景', 'scene 主体 30'], + ['道具', 'prop 主体 30'], + ['分镜', '镜头 25'], + ['任务明细', '任务错误 50'] + ])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => { + mountPage() + await selectTab(label) + expect(wrapper!.find('.workspace-scroll').exists()).toBe(false) + const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container') + expect(results.text()).toContain(lastItem) + expect(results.find('.result-toolbar').exists()).toBe(false) + expect(results.find('.history-panel').exists()).toBe(false) + expect(wrapper!.get('.history-panel .n-scrollbar-container').text()).toContain('执行记录') + expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false') + expect(results.find('.table-scroll').exists()).toBe(label === '任务明细') + }) + + it('刷新和开关配置保留阅读位置,切换标签只重置结果区,不改变执行记录位置', async () => { + const fetcher = vi.fn() + vi.stubGlobal('fetch', fetcher) + const provided = mountPage() + const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element + const history = wrapper!.get('.history-panel .n-scrollbar-container').element + results.scrollTop = 900 + 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') + provided.data.value!.checkpoints = [checkpoint()] + await flushPromises() + expect(wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element).toBe(results) + expect(results.scrollTop).toBe(900) + await selectTab('场景') + const next = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element + expect(next).not.toBe(results) + expect(next.scrollTop).toBe(0) + expect(wrapper!.get('.history-panel .n-scrollbar-container').element).toBe(history) + 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(fetcher).not.toHaveBeenCalled() + }) + + it('关闭设置时错误仍可见,详细校验与恢复在面板内,不挤占结果高度', async () => { + const record = checkpoint() + record.state.workflowExecution!.status = 'failed' + record.state.workflowExecution!.errorMessage = '工作流中断' + record.state.breakdownResult!.storyboardShotValidation = { + valid: false, + issues: [{ episodeNo: 1, message: '缺少形态绑定' }] + } + mountPage([record]) + const feedback = wrapper!.get('.workspace-feedback') + expect(feedback.text()).toContain('工作流中断') + expect(feedback.text()).toContain('分镜校验未通过,共 1 项问题') + 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('重试失败抽取') + }) + + it('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => { + mountPage([], 0) + expect(wrapper!.get('.panel-scroll .n-scrollbar-container').text()).toContain('还没有可拆解的剧集') + expect(wrapper!.find('.breakdown-results-section').exists()).toBe(false) + expect(wrapper!.find('[data-workspace-tools]').exists()).toBe(false) + }) +}) diff --git a/src/features/breakdown/BreakdownPage.vue b/src/features/breakdown/BreakdownPage.vue index 0bc5cb9..2dde3fa 100644 --- a/src/features/breakdown/BreakdownPage.vue +++ b/src/features/breakdown/BreakdownPage.vue @@ -3,7 +3,7 @@ import WorkspacePage from '../../components/ui/WorkspacePage.vue' import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui' import { computed, onScopeDispose, ref, watch } from 'vue' import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue' -import { EmptyState, StatusBadge } from '../../components/ui' +import { EmptyState, StatusBadge, WorkspaceTools } from '../../components/ui' import { useProjectContext } from '../projects/context' import { breakdownApi } from './api' import type { BreakdownAction, BreakdownInput, BreakdownModule, BreakdownPreview } from './types' @@ -24,6 +24,7 @@ const preview = ref(null) const previewBusy = ref(false) const previewError = ref('') const tab = ref('character') +const toolsOpen = ref(false) const moduleOptions: { value: BreakdownModule; label: string; description: string }[] = [ { value: 'character', label: '人物', description: '人物身份、外观与形态' }, { value: 'scene', label: '场景', description: '故事空间与环境特征' }, @@ -132,117 +133,15 @@ function exportResult() { diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts index edcc573..f44b6c1 100644 --- a/src/features/workflows/pages.test.ts +++ b/src/features/workflows/pages.test.ts @@ -1171,6 +1171,7 @@ describe('工作台页面交互', () => { attachTo: document.body, global: { provide: { [projectContextKey as symbol]: provided } } }) + await expandSections() button('预览分组').click() await flushPromises() expect(button('开始拆解').disabled).toBe(false)