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: '
正文
' } 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分组抽取、主体整理与分镜结果
-- 读取已保存的 - {{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。 -
-- 每组集数必须是正整数,并至少选择一个抽取模块。 -
-- {{ preview.groupCount }} 个分组 / - {{ preview.estimatedTaskCount }} 个抽取任务 -
-- 剧本仍在生成,暂不允许启动拆解,避免读取到不完整的剧集。 -
-- 后端尚无明确的完成或失败记录。为避免重复执行,已禁用整条重新拆解;如进程已中断,请先核实后台,再选择对应恢复操作。 -
-