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 @@
@@ -24,16 +35,6 @@ const operation = computed(() => getOperation(id.value))
context.project.value?.title || context.project.value?.topic || '读取项目'
}}
-
- 刷新
-
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;