From 370e7bb4a635ab984f795c6f312d21eeac8273b7 Mon Sep 17 00:00:00 2001 From: GouJ Date: Tue, 1 Sep 2026 22:37:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E5=89=A7=E6=9C=AC?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=E9=99=90=E5=88=B6=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A1=A8=E5=8D=95=E5=AF=B9=E9=BD=90=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + src/App.vue | 25 ++- src/admin.css | 40 ++-- src/components/ui/flatTheme.test.ts | 10 +- src/components/ui/layout.test.ts | 11 +- src/composables/useTheme.test.ts | 7 +- src/composables/useTheme.ts | 12 +- src/features/breakdown/BreakdownPage.vue | 35 +++- src/features/production/ProductionPage.vue | 2 +- src/features/production/useProduction.ts | 2 +- src/features/projects/ProjectLayout.vue | 45 +++-- src/features/projects/access.test.ts | 181 ++++++++++++++++++ src/features/projects/access.ts | 16 ++ src/features/storyboard/StoryboardPage.vue | 2 +- src/features/storyboard/useStoryboard.ts | 2 +- .../subject-identity/SubjectIdentityPage.vue | 2 +- .../subject-images/SubjectImagesPage.vue | 2 +- .../workflows/useProjectMutationGuard.ts | 4 +- src/styles.css | 6 +- 19 files changed, 340 insertions(+), 67 deletions(-) create mode 100644 src/features/projects/access.test.ts create mode 100644 src/features/projects/access.ts diff --git a/README.md b/README.md index da432fd..dddf678 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。 - 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。 +- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。 +- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。 - 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 - 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。 - 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。 @@ -101,6 +103,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。 - 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。 - 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。 +- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。 - 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。 - 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。 - 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 diff --git a/src/App.vue b/src/App.vue index 8b0a6e1..dbdd636 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ - getOperation(id.value)) content-class="project-notices-content" > {{ context.error.value - }} 当前保留上次成功读取的数据。{{ context.error.value }} 当前保留上次成功读取的数据。 + 重试读取 {{ operation.label }}。可切换页面查看结果,请勿重复提交;关闭页面不会取消后端任务。 getOperation(id.value)) {{ operation.notice }}
- + + + 返回剧本创作 + { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + localStorage.clear() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +/** 用已有剧集模拟中途生成的项目,完成与否必须取 status 而非数组长度。 */ +function project(id: string, status: ProjectStatus): ProjectDetail { + return { + id, + status, + title: '访问限制测试', + topic: '', + style: null, + createdAt: '', + updatedAt: '', + episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }], + characters: [], + world: null, + reviews: [], + tasks: [] + } +} + +/** 真实项目布局与侧栏,子工作区以挂载探针代替,防止测试发起实际生成请求。 */ +async function openProject(initialPath: string) { + const mounted = vi.fn<(path: string) => void>() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/projects', component: { render: () => h('div', '项目列表') } }, + { + path: '/projects/:projectId', + component: ProjectLayout, + children: ['create-drama', ...downstream].map(path => ({ + path, + component: { + setup() { + mounted(path) + return () => h('div', { class: 'workspace-probe' }, path) + } + } + })) + } + ] + }) + await router.push(initialPath) + wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } }) + await flushPromises() + return { router, mounted } +} + +describe('剧本完成前的下游访问限制', () => { + it.each(['draft', 'generating', 'need_review', 'failed'] as const)( + '%s 不解锁导航,也不挂载直接链接对应的工作区', + async status => { + vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('unfinished', status)) + vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) + const { router, mounted } = await openProject('/projects/unfinished/production') + for (const path of downstream) { + await router.push(`/projects/unfinished/${path}`) + await flushPromises() + expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作') + expect(wrapper!.find('.workspace-probe').exists()).toBe(false) + expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false) + } + expect(mounted).not.toHaveBeenCalled() + expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6) + await wrapper!.get('.project-access-gate button').trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama') + expect(wrapper!.get('.workspace-probe').text()).toBe('create-drama') + expect(mounted).toHaveBeenCalledExactlyOnceWith('create-drama') + } + ) + + it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => { + vi.useFakeTimers() + let status: ProjectStatus = 'generating' + vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status)) + vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) + const { mounted } = await openProject('/projects/polling/production') + expect(mounted).not.toHaveBeenCalled() + status = 'completed' + await vi.advanceTimersByTimeAsync(6000) + await flushPromises() + expect(wrapper!.get('.workspace-probe').text()).toBe('production') + expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) + status = 'need_review' + await vi.advanceTimersByTimeAsync(6000) + await flushPromises() + expect(wrapper!.find('.workspace-probe').exists()).toBe(false) + expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作') + expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) + expect(mounted).toHaveBeenCalledExactlyOnceWith('production') + }) + + it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => { + let resolveSecond!: (value: ProjectDetail) => void + const pending = new Promise(resolve => { + resolveSecond = resolve + }) + vi.spyOn(projectsApi, 'detail').mockImplementation(async id => + id === 'first' ? project(id, 'completed') : pending + ) + vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) + const { router, mounted } = await openProject('/projects/first/production') + expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) + await router.push('/projects/second/production') + await flushPromises() + expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) + expect(wrapper!.find('.workspace-probe').exists()).toBe(false) + resolveSecond(project('second', 'draft')) + await flushPromises() + expect(wrapper!.find('.workspace-probe').exists()).toBe(false) + expect(mounted).toHaveBeenCalledExactlyOnceWith('production') + await router.push('/projects') + await flushPromises() + expect(wrapper!.findAll('.n-menu a')).toHaveLength(1) + }) + + it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => { + const detail = vi + .spyOn(projectsApi, 'detail') + .mockRejectedValueOnce(new Error('项目读取失败')) + .mockResolvedValue(project('retry', 'completed')) + vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) + const { mounted } = await openProject('/projects/retry/storyboard') + expect(mounted).not.toHaveBeenCalled() + expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) + expect(wrapper!.find('.project-header .n-button').exists()).toBe(false) + expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false) + await wrapper!.get('.project-notices button').trigger('click') + await flushPromises() + expect(detail).toHaveBeenCalledTimes(2) + expect(mounted).toHaveBeenCalledExactlyOnceWith('storyboard') + }) + + it('不存在、未知状态或项目 ID 不匹配时默认锁定', () => { + expect(isProjectComplete(null, 'p')).toBe(false) + expect(isProjectComplete({ id: 'other', status: 'completed' }, 'p')).toBe(false) + expect(isProjectComplete({ id: 'p', status: 'unknown' as ProjectStatus }, 'p')).toBe(false) + expect(isProjectComplete({ id: 'p', status: 'completed' }, 'p')).toBe(true) + }) + + it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => { + // 保护布局契约;实际像素对齐仍需浏览器视觉验收。 + const css = readFileSync('src/admin.css', 'utf8') + expect(css).toMatch( + /\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/ + ) + expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/) + expect(readFileSync('src/styles.css', 'utf8')).toMatch(/\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/) + for (const path of [ + 'production/ProductionPage.vue', + 'storyboard/StoryboardPage.vue', + 'subject-identity/SubjectIdentityPage.vue', + 'subject-images/SubjectImagesPage.vue' + ]) { + const source = readFileSync(`src/features/${path}`, 'utf8') + expect(source).toContain('control-row-checkbox') + expect(source).not.toMatch(/]*class="[^"]*pb-[23]/) + } + }) +}) diff --git a/src/features/projects/access.ts b/src/features/projects/access.ts new file mode 100644 index 0000000..98a6401 --- /dev/null +++ b/src/features/projects/access.ts @@ -0,0 +1,16 @@ +import type { InjectionKey, ShallowRef } from 'vue' +import type { Project } from './types' + +/** 由项目布局发布给应用侧栏的访问状态;不额外请求或持久化项目数据。 */ +export interface ProjectAccess { + projectId: string + completed: boolean +} + +/** 侧栏与项目布局共享同一份判定,初次读取和切换项目时默认锁定。 */ +export const projectAccessKey: InjectionKey> = Symbol('project-access') + +/** 仅正式项目状态 completed 解锁下游;已有部分剧集或旧 checkpoint 不代表剧本完成。 */ +export function isProjectComplete(project: Pick | null, projectId: string): boolean { + return !!projectId && project?.id === projectId && project.status === 'completed' +} diff --git a/src/features/storyboard/StoryboardPage.vue b/src/features/storyboard/StoryboardPage.vue index f1ad541..7a323eb 100644 --- a/src/features/storyboard/StoryboardPage.vue +++ b/src/features/storyboard/StoryboardPage.vue @@ -176,7 +176,7 @@ watch( 批量覆盖已有结果
diff --git a/src/features/storyboard/useStoryboard.ts b/src/features/storyboard/useStoryboard.ts index f01f432..c630083 100644 --- a/src/features/storyboard/useStoryboard.ts +++ b/src/features/storyboard/useStoryboard.ts @@ -72,7 +72,7 @@ export function useStoryboard() { !!context.error.value || !!query.error.value || !data.value || - context.project.value?.status === 'generating' || + context.project.value?.status !== 'completed' || prerequisites.value.breakdownRunning ) const directionComplete = computed( diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue index 4e04496..868fb42 100644 --- a/src/features/subject-identity/SubjectIdentityPage.vue +++ b/src/features/subject-identity/SubjectIdentityPage.vue @@ -276,7 +276,7 @@ watch( 覆盖未锁定身份文本 已有主图也新增候选图 context.project.value?.id ?? '') @@ -13,7 +13,7 @@ export function useProjectMutationGuard() { !projectId.value || !!context.error.value || operation.value.pending || - context.project.value?.status === 'generating' || + context.project.value?.status !== 'completed' || workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status === 'running' ) diff --git a/src/styles.css b/src/styles.css index 938cbde..10234b4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -186,12 +186,16 @@ color: var(--color-accent); } .panel { + --app-field: var(--app-control); + --app-field-hover: var(--app-control-hover); background: var(--app-surface); border-radius: 0; } /* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */ .surface-inset { + --app-field: var(--app-surface); + --app-field-hover: var(--app-surface); background: var(--app-subtle); } .record-list > article { @@ -409,7 +413,7 @@ } .input { width: 100%; - background: var(--app-control); + background: var(--app-field); color: var(--color-ink); border-radius: 0; padding: 9px 11px;