fix: 完善剧本完成状态限制并优化表单对齐与配色
This commit is contained in:
@@ -87,6 +87,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
|
|
||||||
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||||
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
|
||||||
|
- 项目标题栏只显示返回入口和名称,不再放置状态标签与刷新按钮;项目仍每 6 秒自动刷新,读取失败时在错误提示内提供重试入口。
|
||||||
|
- 只有当前项目正式状态为 `completed` 才解锁下游工作区;草稿、生成中、待审核、失败和读取期间默认禁用下游导航。直接访问下游链接只显示返回剧本创作的引导,不挂载操作组件;轮询确认完成后自动解锁,切换项目立即重新判定。页面内提交守卫同步检查完成状态;这是前端交互限制,不代替后端权限与业务校验。
|
||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
- 身份与形态图库采用限高预览及紧邻其下的横向历史栏;缩略图固定宽度,保留全部候选、辅助视图、失败及进行中记录。点击仅切换预览,不自动切换母版或主图;图片尺寸规则放在非分层组件覆盖样式中,避免被 Naive 按钮默认宽度覆盖。
|
||||||
@@ -101,6 +103,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
||||||
- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。
|
- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。
|
||||||
- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。
|
- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。
|
||||||
|
- 绿色选中复选框使用白色对勾;批量配置中的复选框与中号输入框共用 34px 控件行并垂直居中。浅色输入框按所在区域使用白底或浅灰底,与页面/抽屉或白色面板区分;聚焦与禁用反馈继续保留。
|
||||||
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
||||||
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
||||||
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
||||||
|
|||||||
+17
-4
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, ref } from 'vue'
|
import { computed, h, provide, ref, shallowRef } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { AppDialog } from './components/ui'
|
import { AppDialog } from './components/ui'
|
||||||
import { useTheme } from './composables/useTheme'
|
import { useTheme } from './composables/useTheme'
|
||||||
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||||
|
import { projectAccessKey, type ProjectAccess } from './features/projects/access'
|
||||||
|
|
||||||
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -38,6 +39,11 @@ const collapsed = ref(window.matchMedia('(max-width: 800px)').matches)
|
|||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const { preference, theme, overrides } = useTheme()
|
const { preference, theme, overrides } = useTheme()
|
||||||
const projectId = computed(() => String(route.params.projectId || ''))
|
const projectId = computed(() => String(route.params.projectId || ''))
|
||||||
|
const projectAccess = shallowRef<ProjectAccess | null>(null)
|
||||||
|
provide(projectAccessKey, projectAccess)
|
||||||
|
const workflowsUnlocked = computed(
|
||||||
|
() => projectAccess.value?.projectId === projectId.value && projectAccess.value.completed
|
||||||
|
)
|
||||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
const workflowItems = [
|
const workflowItems = [
|
||||||
['create-drama', '剧本创作', FileText],
|
['create-drama', '剧本创作', FileText],
|
||||||
@@ -58,11 +64,18 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
...(projectId.value
|
...(projectId.value
|
||||||
? [
|
? [
|
||||||
{ type: 'divider' as const, key: 'divider' },
|
{ type: 'divider' as const, key: 'divider' },
|
||||||
...workflowItems.map(([path, label, icon]) => ({
|
...workflowItems.map(([path, label, icon]) => {
|
||||||
|
const disabled = path !== 'create-drama' && !workflowsUnlocked.value
|
||||||
|
return {
|
||||||
key: '/projects/' + projectId.value + '/' + path,
|
key: '/projects/' + projectId.value + '/' + path,
|
||||||
label: () => h(RouterLink, { to: '/projects/' + projectId.value + '/' + path }, () => label),
|
disabled,
|
||||||
|
label: () =>
|
||||||
|
disabled
|
||||||
|
? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label)
|
||||||
|
: h(RouterLink, { to: '/projects/' + projectId.value + '/' + path }, () => label),
|
||||||
icon: () => h(icon, { size: 18 })
|
icon: () => h(icon, { size: 18 })
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
])
|
])
|
||||||
|
|||||||
+29
-11
@@ -7,6 +7,8 @@
|
|||||||
--app-subtle: #f7f7f7;
|
--app-subtle: #f7f7f7;
|
||||||
--app-control: #f0f0f0;
|
--app-control: #f0f0f0;
|
||||||
--app-control-hover: #e5e5e5;
|
--app-control-hover: #e5e5e5;
|
||||||
|
--app-field: #ffffff;
|
||||||
|
--app-field-hover: #ffffff;
|
||||||
--app-border: #e5e5e5;
|
--app-border: #e5e5e5;
|
||||||
--app-inverse: #ffffff;
|
--app-inverse: #ffffff;
|
||||||
--app-accent: #07c160;
|
--app-accent: #07c160;
|
||||||
@@ -31,6 +33,8 @@
|
|||||||
--app-subtle: #232323;
|
--app-subtle: #232323;
|
||||||
--app-control: #2b2b2b;
|
--app-control: #2b2b2b;
|
||||||
--app-control-hover: #353535;
|
--app-control-hover: #353535;
|
||||||
|
--app-field: #2b2b2b;
|
||||||
|
--app-field-hover: #353535;
|
||||||
--app-disabled-bg: #303030;
|
--app-disabled-bg: #303030;
|
||||||
--app-disabled-text: #808080;
|
--app-disabled-text: #808080;
|
||||||
--app-border: #333333;
|
--app-border: #333333;
|
||||||
@@ -186,9 +190,10 @@ body {
|
|||||||
.project-header .n-page-header__main,
|
.project-header .n-page-header__main,
|
||||||
.project-header .n-page-header__title {
|
.project-header .n-page-header__title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.project-title {
|
.project-title {
|
||||||
max-width: min(58vw, 980px);
|
max-width: 100%;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -212,6 +217,17 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* 标题移除额外按钮后让名称使用剩余空间;未完成项目的引导仍留在内容区。 */
|
||||||
|
.project-header .n-page-header-wrapper {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.project-access-gate {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.app-dialog {
|
||||||
|
--app-field: var(--app-control);
|
||||||
|
--app-field-hover: var(--app-control-hover);
|
||||||
|
}
|
||||||
.workspace-page {
|
.workspace-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -303,6 +319,16 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-block: 20px;
|
margin-block: 20px;
|
||||||
}
|
}
|
||||||
|
/* 与 34px 中号输入框共用一行;不再通过 pb-2 / pb-3 手动垫高复选框。 */
|
||||||
|
.n-checkbox.control-row-checkbox {
|
||||||
|
align-self: end;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
.control-row-checkbox .n-checkbox-box-wrapper {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
.workspace-tools-body .storyboard-coverage {
|
.workspace-tools-body .storyboard-coverage {
|
||||||
margin-block: 20px;
|
margin-block: 20px;
|
||||||
}
|
}
|
||||||
@@ -466,6 +492,8 @@ body {
|
|||||||
}
|
}
|
||||||
/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
|
/* 目录统一为固定标题/筛选区和独立滚动列表;不再依赖按钮的默认行高与内边距。 */
|
||||||
.directory-panel {
|
.directory-panel {
|
||||||
|
--app-field: var(--app-surface);
|
||||||
|
--app-field-hover: var(--app-surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1202,9 +1230,6 @@ body {
|
|||||||
z-index: 3000;
|
z-index: 3000;
|
||||||
}
|
}
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.project-title {
|
|
||||||
max-width: 44vw;
|
|
||||||
}
|
|
||||||
.production-controls {
|
.production-controls {
|
||||||
grid-template-columns: minmax(130px, 1fr) 90px;
|
grid-template-columns: minmax(130px, 1fr) 90px;
|
||||||
}
|
}
|
||||||
@@ -1228,15 +1253,8 @@ body {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
.project-title {
|
.project-title {
|
||||||
max-width: 37vw;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.project-header .n-page-header__extra {
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
.project-header .n-tag {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.workspace-heading {
|
.workspace-heading {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { NAlert, NButton, NCard, NConfigProvider, NInput, NSelect, NTable, NTag } from 'naive-ui'
|
import { NAlert, NButton, NCard, NCheckbox, NConfigProvider, NInput, NSelect, NTable, NTag } from 'naive-ui'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import { useTheme } from '../../composables/useTheme'
|
import { useTheme } from '../../composables/useTheme'
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ describe('全站平面主题', () => {
|
|||||||
h(NButton, {}, () => '生成'),
|
h(NButton, {}, () => '生成'),
|
||||||
h(NInput, { class: 'normal-input' }),
|
h(NInput, { class: 'normal-input' }),
|
||||||
h(NInput, { status: 'error', class: 'error-input' }),
|
h(NInput, { status: 'error', class: 'error-input' }),
|
||||||
|
h(NCheckbox, { checked: true }),
|
||||||
h(NSelect, { options: [{ label: '第一集', value: 1 }] }),
|
h(NSelect, { options: [{ label: '第一集', value: 1 }] }),
|
||||||
h(NAlert, { type: 'error' }, () => '生成失败'),
|
h(NAlert, { type: 'error' }, () => '生成失败'),
|
||||||
h(NCard, {}, () => '内容'),
|
h(NCard, {}, () => '内容'),
|
||||||
@@ -85,9 +86,12 @@ describe('全站平面主题', () => {
|
|||||||
}
|
}
|
||||||
for (const selector of ['.n-button', '.normal-input', '.n-base-selection']) {
|
for (const selector of ['.n-button', '.normal-input', '.n-base-selection']) {
|
||||||
expect(value(selector, '--n-border')).toBe('none')
|
expect(value(selector, '--n-border')).toBe('none')
|
||||||
expect(value(selector, '--n-color')).toBe(control)
|
|
||||||
expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42')
|
expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42')
|
||||||
}
|
}
|
||||||
|
expect(value('.n-button', '--n-color')).toBe(control)
|
||||||
|
expect(value('.normal-input', '--n-color')).toBe('var(--app-field)')
|
||||||
|
expect(value('.n-base-selection', '--n-color')).toBe('var(--app-field)')
|
||||||
|
expect(value('.n-checkbox', '--n-check-mark-color')).toBe('#ffffff')
|
||||||
expect(value('.n-alert', '--n-border')).toBe('none')
|
expect(value('.n-alert', '--n-border')).toBe('none')
|
||||||
expect(value('.n-tag', '--n-border')).toBe('none')
|
expect(value('.n-tag', '--n-border')).toBe('none')
|
||||||
expect(value('.n-card', '--n-border-color')).toBe('transparent')
|
expect(value('.n-card', '--n-border-color')).toBe('transparent')
|
||||||
@@ -107,7 +111,7 @@ describe('全站平面主题', () => {
|
|||||||
const css = readFileSync('src/styles.css', 'utf8')
|
const css = readFileSync('src/styles.css', 'utf8')
|
||||||
expect(admin + css).not.toMatch(/border-radius:\s*[1-9]/)
|
expect(admin + css).not.toMatch(/border-radius:\s*[1-9]/)
|
||||||
expect(admin + css).not.toMatch(/border(?:-[\w]+)?:\s*1px (?:solid|dashed) var\(--(?:app-border|color-line)\)/)
|
expect(admin + css).not.toMatch(/border(?:-[\w]+)?:\s*1px (?:solid|dashed) var\(--(?:app-border|color-line)\)/)
|
||||||
expect(css).toMatch(/\.surface-inset\s*\{\s*background:\s*var\(--app-subtle\)/)
|
expect(css).toMatch(/\.surface-inset\s*\{[^}]*background:\s*var\(--app-subtle\)/)
|
||||||
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:/)
|
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:/)
|
||||||
expect(admin).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
|
expect(admin).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||||
expect(admin).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
|
expect(admin).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe('管理后台组件边界', () => {
|
|||||||
await router.push('/projects/test/production')
|
await router.push('/projects/test/production')
|
||||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.findAll('.n-menu a')).toHaveLength(8)
|
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
|
||||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||||
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||||
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||||
@@ -142,7 +142,7 @@ describe('管理后台组件边界', () => {
|
|||||||
await settings.trigger('click')
|
await settings.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
|
||||||
expect(wrapper.findAll('.admin-nav-scroll .n-menu a')).toHaveLength(8)
|
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏不超出视口底部', () => {
|
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏不超出视口底部', () => {
|
||||||
@@ -203,7 +203,7 @@ describe('管理后台组件边界', () => {
|
|||||||
expect(wrapper.get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
|
expect(wrapper.get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('移除内容区重复流程导航后,七条左侧链接、项目标题与刷新仍可用', async () => {
|
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
|
||||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
|
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
|
||||||
id: 'navigation-test',
|
id: 'navigation-test',
|
||||||
title: '导航测试项目',
|
title: '导航测试项目',
|
||||||
@@ -253,9 +253,8 @@ describe('管理后台组件边界', () => {
|
|||||||
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
|
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
|
||||||
}
|
}
|
||||||
expect(detail).toHaveBeenCalledOnce()
|
expect(detail).toHaveBeenCalledOnce()
|
||||||
await wrapper.get('.project-header .n-button').trigger('click')
|
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
|
||||||
await flushPromises()
|
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
|
||||||
expect(detail).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
|
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
|
||||||
|
|||||||
@@ -122,10 +122,13 @@ describe('微信风格明暗主题', () => {
|
|||||||
// 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。
|
// 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。
|
||||||
expect(common?.borderRadius).toBe('0px')
|
expect(common?.borderRadius).toBe('0px')
|
||||||
expect(common?.borderRadiusSmall).toBe('0px')
|
expect(common?.borderRadiusSmall).toBe('0px')
|
||||||
expect(Input?.color).toBe(tokens.control)
|
expect(Input?.color).toBe('var(--app-field)')
|
||||||
expect(Button?.color).toBe(tokens.control)
|
expect(Button?.color).toBe(tokens.control)
|
||||||
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
||||||
expect(Select?.peers?.InternalSelection?.color).toBe(tokens.control)
|
expect(Select?.peers?.InternalSelection?.color).toBe('var(--app-field)')
|
||||||
|
expect(contrast(tokens.field!, tokens.body!)).toBeGreaterThan(1.1)
|
||||||
|
expect(contrast(tokens.control!, tokens.surface!)).toBeGreaterThan(1.1)
|
||||||
|
expect(state.overrides.value.Checkbox?.checkMarkColor).toBe('#ffffff')
|
||||||
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
||||||
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
|
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
|
||||||
expect(Button?.colorHoverPrimary).toBe(tokens['button-hover'])
|
expect(Button?.colorHoverPrimary).toBe(tokens['button-hover'])
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useTheme() {
|
|||||||
// 静态边界交给底色;聚焦与错误边框仍由控件的状态主题负责。
|
// 静态边界交给底色;聚焦与错误边框仍由控件的状态主题负责。
|
||||||
const focusBorder = `1px solid ${accentText}`
|
const focusBorder = `1px solid ${accentText}`
|
||||||
const selection = {
|
const selection = {
|
||||||
color: control,
|
color: 'var(--app-field)',
|
||||||
colorActive: control,
|
colorActive: 'var(--app-field)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderHover: 'none',
|
borderHover: 'none',
|
||||||
borderFocus: focusBorder,
|
borderFocus: focusBorder,
|
||||||
@@ -147,9 +147,9 @@ export function useTheme() {
|
|||||||
textColorGhostSuccess: accentText
|
textColorGhostSuccess: accentText
|
||||||
},
|
},
|
||||||
Input: {
|
Input: {
|
||||||
color: control,
|
color: 'var(--app-field)',
|
||||||
colorHover: controlHover,
|
colorHover: 'var(--app-field-hover)',
|
||||||
colorFocus: control,
|
colorFocus: 'var(--app-field)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderHover: 'none',
|
borderHover: 'none',
|
||||||
borderDisabled: 'none',
|
borderDisabled: 'none',
|
||||||
@@ -226,7 +226,7 @@ export function useTheme() {
|
|||||||
borderWarning: 'none',
|
borderWarning: 'none',
|
||||||
borderError: 'none'
|
borderError: 'none'
|
||||||
},
|
},
|
||||||
Checkbox: { checkMarkColor: '#082b17' },
|
Checkbox: { checkMarkColor: '#ffffff' },
|
||||||
Switch: { railColorActive: primary },
|
Switch: { railColorActive: primary },
|
||||||
Progress: { fillColor: primary }
|
Progress: { fillColor: primary }
|
||||||
} satisfies GlobalThemeOverrides
|
} satisfies GlobalThemeOverrides
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const input = computed<BreakdownInput>(() => ({ groupSize: groupSize.value, modu
|
|||||||
const canStart = computed(
|
const canStart = computed(
|
||||||
() =>
|
() =>
|
||||||
!!project.value?.episodes.length &&
|
!!project.value?.episodes.length &&
|
||||||
project.value.status !== 'generating' &&
|
project.value.status === 'completed' &&
|
||||||
!!preview.value &&
|
!!preview.value &&
|
||||||
configValid.value &&
|
configValid.value &&
|
||||||
!operation.value.pending &&
|
!operation.value.pending &&
|
||||||
@@ -89,7 +89,7 @@ function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate
|
|||||||
|
|
||||||
/** 获取服务器生成的任务分组,不在浏览器伪造任务 ID。 */
|
/** 获取服务器生成的任务分组,不在浏览器伪造任务 ID。 */
|
||||||
async function loadPreview() {
|
async function loadPreview() {
|
||||||
if (!configValid.value || !project.value || previewBusy.value) return
|
if (!configValid.value || project.value?.status !== 'completed' || previewBusy.value) return
|
||||||
invalidatePreview()
|
invalidatePreview()
|
||||||
const version = previewVersion
|
const version = previewVersion
|
||||||
previewController = new AbortController()
|
previewController = new AbortController()
|
||||||
@@ -106,7 +106,7 @@ async function loadPreview() {
|
|||||||
|
|
||||||
/** 长请求不随路由切换取消,返回后刷新 checkpoint 与正式项目数据。 */
|
/** 长请求不随路由切换取消,返回后刷新 checkpoint 与正式项目数据。 */
|
||||||
async function run(action: BreakdownAction) {
|
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
|
if (action === 'start' && !canStart.value) return
|
||||||
const labels: Record<BreakdownAction, string> = {
|
const labels: Record<BreakdownAction, string> = {
|
||||||
start: '正在拆解剧本',
|
start: '正在拆解剧本',
|
||||||
@@ -202,7 +202,13 @@ function exportResult() {
|
|||||||
>
|
>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<NButton
|
<NButton
|
||||||
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
:disabled="
|
||||||
|
project?.status !== 'completed' ||
|
||||||
|
!configValid ||
|
||||||
|
previewBusy ||
|
||||||
|
operation.pending ||
|
||||||
|
!!error
|
||||||
|
"
|
||||||
@click="loadPreview"
|
@click="loadPreview"
|
||||||
class="self-end"
|
class="self-end"
|
||||||
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||||
@@ -320,19 +326,34 @@ function exportResult() {
|
|||||||
label="重试失败抽取"
|
label="重试失败抽取"
|
||||||
description="只重试 character / scene / prop 抽取任务中的失败项。不能用于镜头阶段失败。"
|
description="只重试 character / scene / prop 抽取任务中的失败项。不能用于镜头阶段失败。"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
:disabled="!recovery.retry || operation.pending || !!error"
|
:disabled="
|
||||||
|
project?.status !== 'completed' ||
|
||||||
|
!recovery.retry ||
|
||||||
|
operation.pending ||
|
||||||
|
!!error
|
||||||
|
"
|
||||||
@confirm="run('retry')"
|
@confirm="run('retry')"
|
||||||
/><ConfirmAction
|
/><ConfirmAction
|
||||||
label="补齐缺失镜头"
|
label="补齐缺失镜头"
|
||||||
description="复用已完成剧集的镜头,仅生成尚未完成的 Episode Shot,再进行校验与入库。"
|
description="复用已完成剧集的镜头,仅生成尚未完成的 Episode Shot,再进行校验与入库。"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
:disabled="!recovery.shots || operation.pending || !!error"
|
:disabled="
|
||||||
|
project?.status !== 'completed' ||
|
||||||
|
!recovery.shots ||
|
||||||
|
operation.pending ||
|
||||||
|
!!error
|
||||||
|
"
|
||||||
@confirm="run('resume-shots')"
|
@confirm="run('resume-shots')"
|
||||||
/><ConfirmAction
|
/><ConfirmAction
|
||||||
label="修复分镜绑定"
|
label="修复分镜绑定"
|
||||||
description="复用已有镜头,修复 SubjectRef 与视觉 Form 绑定,再校验并保存。"
|
description="复用已有镜头,修复 SubjectRef 与视觉 Form 绑定,再校验并保存。"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
:disabled="!recovery.storyboard || operation.pending || !!error"
|
:disabled="
|
||||||
|
project?.status !== 'completed' ||
|
||||||
|
!recovery.storyboard ||
|
||||||
|
operation.pending ||
|
||||||
|
!!error
|
||||||
|
"
|
||||||
@confirm="run('resume-storyboard')"
|
@confirm="run('resume-storyboard')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ watch(
|
|||||||
<NCheckbox
|
<NCheckbox
|
||||||
v-model:checked="force"
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
class="flex items-center gap-2 pb-3 text-xs"
|
class="control-row-checkbox text-xs"
|
||||||
>覆盖模式:为已有结果新增候选
|
>覆盖模式:为已有结果新增候选
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function useProduction() {
|
|||||||
!!context.error.value ||
|
!!context.error.value ||
|
||||||
!!query.error.value ||
|
!!query.error.value ||
|
||||||
!query.data.value ||
|
!query.data.value ||
|
||||||
context.project.value?.status === 'generating'
|
context.project.value?.status !== 'completed'
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
|
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, provide } from 'vue'
|
import { computed, inject, onScopeDispose, provide, watchEffect } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
||||||
import { RefreshCw } from '@lucide/vue'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { StatusBadge } from '../../components/ui'
|
|
||||||
import { projectContextKey, useProjectData } from './context'
|
import { projectContextKey, useProjectData } from './context'
|
||||||
import { getOperation } from '../workflows/operations'
|
import { getOperation } from '../workflows/operations'
|
||||||
|
import { isProjectComplete, projectAccessKey, type ProjectAccess } from './access'
|
||||||
|
|
||||||
/** 项目仅保留紧凑标题与刷新;工作流统一由左侧导航切换,子工作区负责内容滚动。 */
|
/** 项目标题只保留名称;下游工作区在剧本完成前不挂载,直接链接也无法越过限制。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const id = computed(() => String(route.params.projectId))
|
const id = computed(() => String(route.params.projectId))
|
||||||
const context = useProjectData(id)
|
const context = useProjectData(id)
|
||||||
provide(projectContextKey, context)
|
provide(projectContextKey, context)
|
||||||
const operation = computed(() => getOperation(id.value))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="project-frame">
|
<section class="project-frame">
|
||||||
@@ -24,16 +35,6 @@ const operation = computed(() => getOperation(id.value))
|
|||||||
context.project.value?.title || context.project.value?.topic || '读取项目'
|
context.project.value?.title || context.project.value?.topic || '读取项目'
|
||||||
}}</NEllipsis></template
|
}}</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>
|
</NPageHeader>
|
||||||
</header>
|
</header>
|
||||||
<NScrollbar
|
<NScrollbar
|
||||||
@@ -42,8 +43,10 @@ const operation = computed(() => getOperation(id.value))
|
|||||||
content-class="project-notices-content"
|
content-class="project-notices-content"
|
||||||
>
|
>
|
||||||
<NAlert v-if="context.error.value" type="error" :show-icon="false"
|
<NAlert v-if="context.error.value" type="error" :show-icon="false"
|
||||||
>{{ context.error.value
|
>{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span>
|
||||||
}}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span></NAlert
|
<NButton text class="ml-3" :loading="context.loading.value" @click="context.refresh"
|
||||||
|
>重试读取</NButton
|
||||||
|
></NAlert
|
||||||
>
|
>
|
||||||
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||||
>{{ operation.label }}。可切换页面查看结果,请勿重复提交;关闭页面不会取消后端任务。</NAlert
|
>{{ 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>
|
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
<div class="project-view">
|
<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
|
<NSpin
|
||||||
v-else-if="context.loading.value"
|
v-else-if="context.loading.value"
|
||||||
class="workspace-loading"
|
class="workspace-loading"
|
||||||
|
|||||||
@@ -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]/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ watch(
|
|||||||
<NCheckbox
|
<NCheckbox
|
||||||
v-model:checked="force"
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
class="flex items-center gap-2 pb-3 text-xs"
|
class="control-row-checkbox text-xs"
|
||||||
>批量覆盖已有结果</NCheckbox
|
>批量覆盖已有结果</NCheckbox
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function useStoryboard() {
|
|||||||
!!context.error.value ||
|
!!context.error.value ||
|
||||||
!!query.error.value ||
|
!!query.error.value ||
|
||||||
!data.value ||
|
!data.value ||
|
||||||
context.project.value?.status === 'generating' ||
|
context.project.value?.status !== 'completed' ||
|
||||||
prerequisites.value.breakdownRunning
|
prerequisites.value.breakdownRunning
|
||||||
)
|
)
|
||||||
const directionComplete = computed(
|
const directionComplete = computed(
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ watch(
|
|||||||
<NCheckbox
|
<NCheckbox
|
||||||
v-model:checked="force"
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
class="flex gap-2 pb-2 text-xs"
|
class="control-row-checkbox text-xs"
|
||||||
>覆盖未锁定身份文本
|
>覆盖未锁定身份文本
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function resetFilters() {
|
|||||||
<NCheckbox
|
<NCheckbox
|
||||||
v-model:checked="force"
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
class="flex items-center gap-2 pb-2 text-xs"
|
class="control-row-checkbox text-xs"
|
||||||
>已有主图也新增候选图
|
>已有主图也新增候选图
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useProjectContext } from '../projects/context'
|
|||||||
import { getOperation } from './operations'
|
import { getOperation } from './operations'
|
||||||
import { workflowCheckpoints } from './selectors'
|
import { workflowCheckpoints } from './selectors'
|
||||||
|
|
||||||
/** 资产编辑沿用项目级互斥;剧本生成或拆解期间不修改其下游资产。 */
|
/** 资产编辑要求剧本正式完成,并沿用项目级互斥,拆解运行期间不修改其下游资产。 */
|
||||||
export function useProjectMutationGuard() {
|
export function useProjectMutationGuard() {
|
||||||
const context = useProjectContext()
|
const context = useProjectContext()
|
||||||
const projectId = computed(() => context.project.value?.id ?? '')
|
const projectId = computed(() => context.project.value?.id ?? '')
|
||||||
@@ -13,7 +13,7 @@ export function useProjectMutationGuard() {
|
|||||||
!projectId.value ||
|
!projectId.value ||
|
||||||
!!context.error.value ||
|
!!context.error.value ||
|
||||||
operation.value.pending ||
|
operation.value.pending ||
|
||||||
context.project.value?.status === 'generating' ||
|
context.project.value?.status !== 'completed' ||
|
||||||
workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status ===
|
workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status ===
|
||||||
'running'
|
'running'
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -186,12 +186,16 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
|
--app-field: var(--app-control);
|
||||||
|
--app-field-hover: var(--app-control-hover);
|
||||||
background: var(--app-surface);
|
background: var(--app-surface);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
|
/* 次级信息用低对比底色成组,避免用边框把工作台切成碎片。 */
|
||||||
.surface-inset {
|
.surface-inset {
|
||||||
|
--app-field: var(--app-surface);
|
||||||
|
--app-field-hover: var(--app-surface);
|
||||||
background: var(--app-subtle);
|
background: var(--app-subtle);
|
||||||
}
|
}
|
||||||
.record-list > article {
|
.record-list > article {
|
||||||
@@ -409,7 +413,7 @@
|
|||||||
}
|
}
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--app-control);
|
background: var(--app-field);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user