fix: 完善剧本完成状态限制并优化表单对齐与配色

This commit is contained in:
GouJ
2026-09-01 22:37:51 +08:00
parent a24326b85d
commit 370e7bb4a6
19 changed files with 340 additions and 67 deletions
+28 -7
View File
@@ -53,7 +53,7 @@ const input = computed<BreakdownInput>(() => ({ groupSize: groupSize.value, modu
const canStart = computed(
() =>
!!project.value?.episodes.length &&
project.value.status !== 'generating' &&
project.value.status === 'completed' &&
!!preview.value &&
configValid.value &&
!operation.value.pending &&
@@ -89,7 +89,7 @@ function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate
/** 获取服务器生成的任务分组,不在浏览器伪造任务 ID。 */
async function loadPreview() {
if (!configValid.value || !project.value || previewBusy.value) return
if (!configValid.value || project.value?.status !== 'completed' || previewBusy.value) return
invalidatePreview()
const version = previewVersion
previewController = new AbortController()
@@ -106,7 +106,7 @@ async function loadPreview() {
/** 长请求不随路由切换取消,返回后刷新 checkpoint 与正式项目数据。 */
async function run(action: BreakdownAction) {
if (!project.value || operation.value.pending || error.value) return
if (project.value?.status !== 'completed' || operation.value.pending || error.value) return
if (action === 'start' && !canStart.value) return
const labels: Record<BreakdownAction, string> = {
start: '正在拆解剧本',
@@ -202,7 +202,13 @@ function exportResult() {
>
</fieldset>
<NButton
:disabled="!configValid || previewBusy || operation.pending || !!error"
:disabled="
project?.status !== 'completed' ||
!configValid ||
previewBusy ||
operation.pending ||
!!error
"
@click="loadPreview"
class="self-end"
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
@@ -320,19 +326,34 @@ function exportResult() {
label="重试失败抽取"
description="只重试 character / scene / prop 抽取任务中的失败项。不能用于镜头阶段失败。"
acknowledgement
:disabled="!recovery.retry || operation.pending || !!error"
:disabled="
project?.status !== 'completed' ||
!recovery.retry ||
operation.pending ||
!!error
"
@confirm="run('retry')"
/><ConfirmAction
label="补齐缺失镜头"
description="复用已完成剧集的镜头,仅生成尚未完成的 Episode Shot,再进行校验与入库。"
acknowledgement
:disabled="!recovery.shots || operation.pending || !!error"
:disabled="
project?.status !== 'completed' ||
!recovery.shots ||
operation.pending ||
!!error
"
@confirm="run('resume-shots')"
/><ConfirmAction
label="修复分镜绑定"
description="复用已有镜头,修复 SubjectRef 与视觉 Form 绑定,再校验并保存。"
acknowledgement
:disabled="!recovery.storyboard || operation.pending || !!error"
:disabled="
project?.status !== 'completed' ||
!recovery.storyboard ||
operation.pending ||
!!error
"
@confirm="run('resume-storyboard')"
/>
</div>
+1 -1
View File
@@ -184,7 +184,7 @@ watch(
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
class="control-row-checkbox text-xs"
>覆盖模式为已有结果新增候选
</NCheckbox>
</div>
+1 -1
View File
@@ -73,7 +73,7 @@ export function useProduction() {
!!context.error.value ||
!!query.error.value ||
!query.data.value ||
context.project.value?.status === 'generating'
context.project.value?.status !== 'completed'
)
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
+28 -17
View File
@@ -1,19 +1,30 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { computed, inject, onScopeDispose, provide, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
import { RefreshCw } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { EmptyState } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
/** 项目仅保留紧凑标题与刷新;工作流统一由左侧导航切换,子工作区负责内容滚动。 */
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
const route = useRoute()
const router = useRouter()
const id = computed(() => String(route.params.projectId))
const context = useProjectData(id)
provide(projectContextKey, context)
const operation = computed(() => getOperation(id.value))
const complete = computed(() => isProjectComplete(context.project.value, id.value))
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
const access = inject(projectAccessKey, undefined)
let published: ProjectAccess | null = null
watchEffect(() => {
published = { projectId: id.value, completed: complete.value }
if (access) access.value = published
})
onScopeDispose(() => {
if (access?.value === published) access.value = null
})
</script>
<template>
<section class="project-frame">
@@ -24,16 +35,6 @@ const operation = computed(() => getOperation(id.value))
context.project.value?.title || context.project.value?.topic || '读取项目'
}}</NEllipsis></template
>
<template #extra
><div class="flex items-center gap-3">
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><NButton
size="small"
:loading="context.loading.value"
@click="context.refresh"
><template #icon><RefreshCw :size="14" /></template>刷新</NButton
>
</div></template
>
</NPageHeader>
</header>
<NScrollbar
@@ -42,8 +43,10 @@ const operation = computed(() => getOperation(id.value))
content-class="project-notices-content"
>
<NAlert v-if="context.error.value" type="error" :show-icon="false"
>{{ context.error.value
}}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span></NAlert
>{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span>
<NButton text class="ml-3" :loading="context.loading.value" @click="context.refresh"
>重试读取</NButton
></NAlert
>
<NAlert v-if="operation.pending" :show-icon="false" role="status"
>{{ operation.label }}可切换页面查看结果请勿重复提交关闭页面不会取消后端任务</NAlert
@@ -52,7 +55,15 @@ const operation = computed(() => getOperation(id.value))
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
</NScrollbar>
<div class="project-view">
<RouterView v-if="context.project.value" :key="id" />
<RouterView v-if="context.project.value?.id === id && (isCreation || complete)" :key="id" />
<EmptyState
v-else-if="context.project.value?.id === id"
class="project-access-gate"
title="请先完成剧本创作"
description="剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。状态会自动更新。"
>
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
</EmptyState>
<NSpin
v-else-if="context.loading.value"
class="workspace-loading"
+181
View File
@@ -0,0 +1,181 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import App from '../../App.vue'
import ProjectLayout from './ProjectLayout.vue'
import { isProjectComplete } from './access'
import { projectsApi } from './api'
import type { ProjectDetail, ProjectStatus } from './types'
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
let wrapper: VueWrapper | undefined
afterEach(() => {
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<ProjectDetail>(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(/<NCheckbox\b[^>]*class="[^"]*pb-[23]/)
}
})
})
+16
View File
@@ -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<ShallowRef<ProjectAccess | null>> = Symbol('project-access')
/** 仅正式项目状态 completed 解锁下游;已有部分剧集或旧 checkpoint 不代表剧本完成。 */
export function isProjectComplete(project: Pick<Project, 'id' | 'status'> | null, projectId: string): boolean {
return !!projectId && project?.id === projectId && project.status === 'completed'
}
+1 -1
View File
@@ -176,7 +176,7 @@ watch(
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
class="control-row-checkbox text-xs"
>批量覆盖已有结果</NCheckbox
>
</div>
+1 -1
View File
@@ -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(
@@ -276,7 +276,7 @@ watch(
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex gap-2 pb-2 text-xs"
class="control-row-checkbox text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
@@ -176,7 +176,7 @@ function resetFilters() {
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-2 text-xs"
class="control-row-checkbox text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
@@ -3,7 +3,7 @@ import { useProjectContext } from '../projects/context'
import { getOperation } from './operations'
import { workflowCheckpoints } from './selectors'
/** 资产编辑沿用项目级互斥;剧本生成或拆解期间不修改其下游资产。 */
/** 资产编辑要求剧本正式完成,并沿用项目级互斥,拆解运行期间不修改其下游资产。 */
export function useProjectMutationGuard() {
const context = useProjectContext()
const projectId = computed(() => 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'
)