refactor: 精简导航并扩大工作区内容显示面积

This commit is contained in:
GouJ
2026-09-01 17:45:40 +08:00
parent 70ab4ba48f
commit 0279c701b4
15 changed files with 1256 additions and 821 deletions
+4 -2
View File
@@ -86,16 +86,18 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 后台布局与主题
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
- 工作流只保留左侧导航,项目标题下不再重复展示相同标签。主体身份、分镜设计、镜头生产和图库采用内容优先布局:常用筛选/剧集选择/刷新在固定的紧凑工具栏,列表占据剩余高度。
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`避免通过 `body``window.scrollTo` 管理滚动。
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`避免通过 `body``window.scrollTo` 管理滚动。
本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括过期形态图、主首帧和主视频的提示与更新入口。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。
+131 -22
View File
@@ -81,7 +81,7 @@ body {
align-items: center;
justify-content: space-between;
flex-shrink: 0;
height: 56px;
height: 44px;
padding: 0 20px;
gap: 12px;
}
@@ -104,7 +104,7 @@ body {
}
.project-header {
flex-shrink: 0;
padding: 14px 24px 0;
padding: 9px 20px;
background: var(--app-surface);
border-bottom: 1px solid var(--app-border);
}
@@ -117,12 +117,6 @@ body {
font-size: 16px;
font-weight: 600;
}
.project-tabs {
margin-top: 8px;
}
.project-tabs .n-tabs-nav {
border: 0;
}
.project-notices.n-scrollbar {
flex-shrink: 0;
height: auto;
@@ -144,6 +138,7 @@ body {
overflow: hidden;
}
.workspace-page {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
@@ -159,6 +154,116 @@ body {
.workspace-heading {
padding: 16px 24px 12px;
}
/* 常用工具固定在滚动区外;低频操作使用局部抽屉,不叠加占高面板。 */
.content-first-workspace .workspace-heading {
padding: 8px 20px;
}
.content-first-workspace .workspace-split {
padding: 0 20px 12px;
gap: 8px;
}
.content-first-workspace .workspace-scroll-content {
padding: 0 20px 16px;
}
.workspace-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
min-height: 34px;
}
.toolbar-episode {
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 340px;
min-width: 180px;
font-size: 12px;
}
.toolbar-episode > span {
flex-shrink: 0;
color: var(--app-muted);
}
.toolbar-episode .n-select {
min-width: 0;
}
.toolbar-metrics,
.toolbar-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
color: var(--app-muted);
font-size: 12px;
line-height: 1.6;
}
.toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-left: auto;
}
.toolbar-type-filter {
width: 152px;
}
.workspace-tools-drawer {
max-width: 100%;
}
.workspace-tools-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
font-size: 15px;
}
.workspace-tools-body {
padding: 20px;
}
.workspace-tools-body .storyboard-controls,
.workspace-tools-body .production-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-block: 20px;
}
.workspace-tools-body .storyboard-coverage {
margin-block: 20px;
}
.workspace-tools-body .generation-row {
display: block;
}
.workspace-tools-body .generation-row > div + div {
margin-top: 14px;
}
.workspace-tools-body .production-pipeline {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
}
.workspace-inline-status {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 16px;
padding: 7px 12px;
flex-shrink: 0;
border: 1px solid var(--app-border);
border-radius: 4px;
background: var(--app-subtle);
font-size: 12px;
}
.confirm-action {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.production-pipeline-card > .confirm-action {
margin-block: 16px 4px;
}
.gallery-workspace-page .form-image-filter-region {
margin-block: 10px 0;
}
.workspace-heading h2 {
font-size: 16px;
}
@@ -194,18 +299,6 @@ body {
margin: 0;
flex-shrink: 0;
}
.workspace-overview.n-scrollbar {
flex-shrink: 0;
height: auto;
max-height: min(36dvh, 420px);
overflow: hidden;
background: var(--app-surface);
border: 1px solid var(--app-border);
border-radius: 6px;
}
.overview-scroll-content {
padding: 10px 14px;
}
.workspace-feedback.n-scrollbar {
flex-shrink: 0;
height: auto;
@@ -845,13 +938,13 @@ body {
}
.admin-sider:not(.n-layout-sider--collapsed) {
position: absolute;
top: 56px;
top: 44px;
bottom: 0;
z-index: 20;
box-shadow: 6px 0 24px var(--app-shadow);
}
.project-header {
padding: 10px 12px 0;
padding: 8px 12px;
}
.project-title {
max-width: 37vw;
@@ -866,6 +959,22 @@ body {
.workspace-heading {
padding: 12px;
}
.content-first-workspace .workspace-heading {
padding: 8px 12px;
}
.content-first-workspace .workspace-split,
.content-first-workspace .workspace-scroll-content {
padding: 0 12px 12px;
}
.toolbar-episode {
flex: 1;
}
.toolbar-actions {
gap: 8px;
}
.workspace-tools-body {
padding: 14px;
}
.workspace-scroll-content,
.workspace-split {
padding: 0 12px 12px;
+6 -2
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { NScrollbar } from 'naive-ui'
import { provide, ref } from 'vue'
import { workspaceHostKey } from './workspaceContext'
/** 工作区固定标题和操作区;仅内容容器滚动,不向 document 传播滚动。 */
defineProps<{ split?: boolean }>()
defineProps<{ split?: boolean; compact?: boolean }>()
const host = ref<HTMLElement | null>(null)
provide(workspaceHostKey, host)
</script>
<template>
<section class="workspace-page">
<section ref="host" class="workspace-page" :class="{ 'content-first-workspace': compact }">
<NScrollbar v-if="$slots.header" class="workspace-heading-scroll"
><header class="workspace-heading"><slot name="header" /></header
></NScrollbar>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { inject } from 'vue'
import { NButton, NDrawer, NDrawerContent } from 'naive-ui'
import { Settings2, X } from '@lucide/vue'
import { workspaceHostKey } from './workspaceContext'
/** 低频配置、批量动作与回执在抽屉中查看,开关面板不影响页面查询和后台任务。 */
withDefaults(defineProps<{ title: string; label?: string; hasReceipt?: boolean }>(), { label: '操作与回执' })
const open = defineModel<boolean>('open', { default: false })
const host = inject(workspaceHostKey, undefined)
</script>
<template>
<NButton
class="workspace-tools-trigger"
data-workspace-tools
aria-haspopup="dialog"
:aria-expanded="open"
@click="open = true"
>
<template #icon><Settings2 :size="15" /></template>
{{ label }}<span v-if="hasReceipt" class="text-xs text-muted"> · 有回执</span>
</NButton>
<NDrawer
v-model:show="open"
:to="host ?? undefined"
width="min(960px, 100%)"
placement="right"
:block-scroll="false"
display-directive="show"
class="workspace-tools-drawer"
:aria-label="title"
>
<NDrawerContent :native-scrollbar="false" body-content-class="workspace-tools-body">
<template #header
><div class="workspace-tools-heading">
<span>{{ title }}</span
><NButton quaternary circle aria-label="关闭操作面板" @click="open = false"
><X :size="18"
/></NButton></div
></template>
<slot />
<template #footer><span class="text-xs text-muted">关闭面板不会取消已提交的后台任务</span></template>
</NDrawerContent>
</NDrawer>
</template>
+1
View File
@@ -4,3 +4,4 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as StatusBadge } from './StatusBadge.vue'
export { default as AssetImage } from './AssetImage.vue'
export { default as DirectoryItem } from './DirectoryItem.vue'
export { default as WorkspaceTools } from './WorkspaceTools.vue'
+104 -2
View File
@@ -1,11 +1,15 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { NConfigProvider, NModal, NScrollbar } from 'naive-ui'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NConfigProvider, NDrawer, NDrawerContent, NModal, NScrollbar } from 'naive-ui'
import App from '../../App.vue'
import WorkspacePage from './WorkspacePage.vue'
import AppDialog from './AppDialog.vue'
import WorkspaceTools from './WorkspaceTools.vue'
import ProjectLayout from '../../features/projects/ProjectLayout.vue'
import ConfirmAction from '../../features/workflows/ConfirmAction.vue'
import { projectsApi } from '../../features/projects/api'
import { readFileSync } from 'node:fs'
let wrapper: VueWrapper | undefined
@@ -14,6 +18,7 @@ afterEach(() => {
wrapper = undefined
localStorage.clear()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('管理后台组件边界', () => {
@@ -93,4 +98,101 @@ describe('管理后台组件边界', () => {
})
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
})
it('常用工具固定在列表外,批量抽屉挂载于工作区,开关保留列表与配置草稿', async () => {
wrapper = mount(WorkspacePage, {
attachTo: document.body,
props: { split: true, compact: true },
slots: {
header: () => h(WorkspaceTools, { title: '测试操作' }, () => h('input', { 'aria-label': '配置草稿' })),
default: '<article class="test-list">主体列表</article>'
}
})
const list = wrapper.get('.test-list').element
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
expect(wrapper.get('.workspace-heading').find('[data-workspace-tools]').exists()).toBe(true)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('to')).toBe(wrapper.get('.workspace-page').element)
expect(wrapper.getComponent(NDrawerContent).props('nativeScrollbar')).toBe(false)
expect(wrapper.get('.workspace-tools-drawer').element.closest('.workspace-split')).toBeNull()
expect(wrapper.find('.workspace-tools-drawer .n-scrollbar-container').exists()).toBe(true)
await wrapper.get('input[aria-label="配置草稿"]').setValue('保留配置')
await wrapper.get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
expect(wrapper.get('.test-list').element).toBe(list)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper.get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
})
it('移除内容区重复流程导航后,七条左侧链接、项目标题与刷新仍可用', async () => {
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
id: 'navigation-test',
title: '导航测试项目',
topic: '测试主题',
style: null,
status: 'completed',
createdAt: '',
updatedAt: '',
episodes: [],
characters: [],
world: null,
reviews: [],
tasks: []
})
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const paths = [
'create-drama',
'breakdown',
'visual-style',
'subject-identity',
'subject-images',
'storyboard',
'production'
]
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/projects/:projectId',
component: ProjectLayout,
children: paths.map(path => ({
path,
component: { render: () => h(WorkspacePage, {}, () => path) }
}))
}
]
})
await router.push('/projects/navigation-test/create-drama')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
for (const path of paths) {
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/projects/navigation-test/' + path)
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
}
expect(detail).toHaveBeenCalledOnce()
await wrapper.get('.project-header .n-button').trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
})
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
wrapper = mount(ConfirmAction, {
props: { label: '测试操作', description: '仅展示,不提交请求' },
attrs: { class: 'mt-4' }
})
expect(wrapper.element.tagName).toBe('SPAN')
expect(wrapper.classes()).toContain('confirm-action')
expect(wrapper.classes()).toContain('mt-4')
expect(wrapper.get('button').text()).toBe('测试操作')
expect(readFileSync('src/admin.css', 'utf8')).toMatch(
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
)
})
})
+4
View File
@@ -0,0 +1,4 @@
import type { InjectionKey, Ref } from 'vue'
/** 操作抽屉挂载到当前工作区,覆盖内容而不挤压列表或遮住整个应用导航。 */
export const workspaceHostKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('workspace-host')
+115 -58
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NScrollbar,
NCollapse,
@@ -104,33 +105,26 @@ function refreshProject() {
void query.refresh()
}
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
const overview = ref<string[]>([])
const toolsOpen = ref(false)
const overview = ref<string[]>(['overview'])
watch(
() => session.value.receipt,
receipt => {
if (receipt) overview.value = ['overview']
},
{ immediate: true }
if (receipt) {
overview.value = ['overview']
toolsOpen.value = true
}
}
)
</script>
<template>
<WorkspacePage split
<WorkspacePage split compact
><template #header
><div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧到异步视频任务与最终成片</p>
</div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</div>
<section class="mt-3" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">当前剧集</span>
<NSelect
><div class="workspace-toolbar">
<label class="toolbar-episode"
><span>剧集</span
><NSelect
:value="episodeNo"
aria-label="选择生产剧集"
@update:value="selectedEpisode = Number($event)"
@@ -142,8 +136,40 @@ watch(
}))
]"
class="select-control"
></NSelect>
</label>
></NSelect
></label>
<span class="toolbar-summary"
>本集 {{ shots.length }} <span v-if="videoRunning">
· {{ videoRunning }} 个视频任务进行中</span
></span
>
<div class="toolbar-actions">
<NButton
:disabled="query.loading.value"
@click="query.refresh"
text
size="small"
class="mb-2 ml-auto"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
</NButton>
<WorkspaceTools
v-model:open="toolsOpen"
title="项目生产总览与批量操作"
:has-receipt="!!session.receipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted">
从可生成的提示词和首帧到异步视频任务与最终成片
</p>
</div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</div>
<section class="mt-3" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">批量并发</span>
<NInputNumber
@@ -161,72 +187,87 @@ watch(
class="flex items-center gap-2 pb-3 text-xs"
>覆盖模式为已有结果新增候选
</NCheckbox>
<NButton
:disabled="query.loading.value"
@click="query.refresh"
text
size="small"
class="mb-2 ml-auto"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
</NButton>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式新增全项目候选Seedream
Seedance 均可能产生费用
</p>
</section></template
>
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
>{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
</section>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
每一步都按后端就绪检查执行覆盖只会新增首帧视频候选不会替换当前主资产上游参考资产变化后下游过期结果会被标记并阻止继续使用需要重新生成后才能恢复就绪
</NAlert>
<NAlert v-if="videoRunning" role="status" type="info" :show-icon="false" class="mt-4">
<NAlert
v-if="videoRunning"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
Provider同一镜头不会重复提交活动任务
</NAlert>
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
<RouterLink
:to="`/projects/${id}/subject-identity`"
class="text-button ml-2"
>前往角色选角 </RouterLink
></NAlert
>
<NAlert v-if="staleKeyframes" role="status" type="info" :show-icon="false" class="mt-4">
<NAlert
v-if="staleKeyframes"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleKeyframes }}
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
/ 更新主首帧批量处理
</NAlert>
<NAlert v-if="staleVideos" role="status" type="info" :show-icon="false" class="mt-4">
<NAlert
v-if="staleVideos"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleVideos }}
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</NAlert>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article v-for="stage in stages" :key="stage.key" class="panel production-pipeline-card">
<article
v-for="stage in stages"
:key="stage.key"
class="panel production-pipeline-card"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="production-step"><component :is="stage.icon" :size="17" /></span>
<span class="production-step"
><component :is="stage.icon" :size="17"
/></span>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">{{ stage.title }}</h3>
<h3 class="mt-2 text-sm font-semibold">
{{ stage.title }}
</h3>
</div>
</div>
<span class="font-mono text-xs text-muted"
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
>
</div>
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
{{ stage.description }}
</p>
<NProgress
:aria-label="`${stage.title}完成数量`"
type="line"
@@ -237,7 +278,9 @@ watch(
100,
Math.max(
0,
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
(stage.done /
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
100
)
)
"
@@ -263,15 +306,20 @@ watch(
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p v-if="stage.key === 'videos' && staleVideos" class="mt-3 text-[11px] leading-5">
<p
v-if="stage.key === 'videos' && staleVideos"
class="mt-3 text-[11px] leading-5"
>
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
</p>
<p
v-if="stage.key === 'keyframes' && identityBlocked"
class="mt-3 text-[11px] leading-5 text-danger"
>
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
@@ -303,7 +351,9 @@ watch(
</div>
<ConfirmAction
label="重试失败视频"
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
:disabled="
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
@@ -334,14 +384,21 @@ watch(
</div>
</div></NCollapseItem
></NCollapse
>
</div>
</WorkspaceTools>
</div>
</div></template
>
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
>{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 逐镜生产</h3>
<p class="mt-2 text-[11px] text-muted">选择镜头查看候选首帧视频任务成片和实际生成规格</p>
</div>
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
<div v-if="staleKeyframes || staleVideos || identityBlocked" class="workspace-inline-status">
<span>过期首帧 {{ staleKeyframes }} · 过期视频 {{ staleVideos }} · 身份前置问题 {{ identityBlocked }}</span>
<NButton text size="small" @click="toolsOpen = true">查看处理方式</NButton>
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list directory-panel" aria-label="本集生产镜头">
+2 -25
View File
@@ -1,32 +1,19 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
import { RefreshCw } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
/** 项目共享查询保持不变,标题与导航固定,子工作区负责内容滚动。 */
/** 项目仅保留紧凑标题与刷新;工作流统一由左侧导航切换,子工作区负责内容滚动。 */
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 tabs = [
['create-drama', '剧本创作'],
['breakdown', '剧本拆解'],
['visual-style', '视觉风格'],
['subject-identity', '主体身份'],
['subject-images', '形态图片'],
['storyboard', '分镜设计'],
['production', '镜头生产']
] as const
/** 路由控制当前工作流,不额外缓存一份可能失步的标签状态。 */
function switchWorkflow(path: string) {
void router.push('/projects/' + id.value + '/' + path)
}
</script>
<template>
<section class="project-frame">
@@ -48,16 +35,6 @@ function switchWorkflow(path: string) {
</div></template
>
</NPageHeader>
<NTabs
:value="String(route.path.split('/').at(-1))"
type="line"
size="small"
class="project-tabs"
aria-label="项目工作流"
@update:value="switchWorkflow"
>
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
</NTabs>
</header>
<NScrollbar
v-if="context.error.value || operation.pending || operation.error || operation.notice"
+91 -60
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NScrollbar,
NAlert,
@@ -81,32 +82,25 @@ function exportEpisode() {
)
}
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
const overview = ref<string[]>([])
const toolsOpen = ref(false)
const overview = ref<string[]>(['generation'])
watch(
() => session.value.receipt,
receipt => {
if (receipt) overview.value = ['generation']
},
{ immediate: true }
if (receipt) {
overview.value = ['generation']
toolsOpen.value = true
}
}
)
</script>
<template>
<WorkspacePage split class="storyboard-workspace-page"
<WorkspacePage split compact class="storyboard-workspace-page"
><template #header
><div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">分镜设计</h2>
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计再补齐每一镜的即时状态</p>
</div>
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
><ArrowLeft :size="14" />回到拆解</RouterLink
>
</div>
<section class="mt-3" aria-label="分镜生成配置">
<div class="storyboard-controls">
<label
><span class="field-label">当前剧集</span
><div class="workspace-toolbar">
<label class="toolbar-episode"
><span>剧集</span
><NSelect
:value="episodeNo"
aria-label="选择分镜剧集"
@@ -121,6 +115,36 @@ watch(
class="select-control"
></NSelect
></label>
<div class="toolbar-metrics">
<span v-for="item in coverage" :key="item.title"
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
>
</div>
<div class="toolbar-actions">
<div class="flex gap-3">
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
><Download :size="13" />导出本集
</NButton>
</div>
<WorkspaceTools
v-model:open="toolsOpen"
title="分镜生成配置与回执"
:has-receipt="!!session.receipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">分镜设计</h2>
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计再补齐每一镜的即时状态</p>
</div>
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
><ArrowLeft :size="14" />回到拆解</RouterLink
>
</div>
<section class="mt-3" aria-label="分镜生成配置">
<div class="storyboard-controls">
<label
><span class="field-label">批量并发</span
><NInputNumber
@@ -131,7 +155,9 @@ watch(
:min="1"
:step="1"
></NInputNumber
><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
>请输入正整数</span
></label
>
<label
><span class="field-label">状态自动修复次数</span
@@ -154,16 +180,37 @@ watch(
>批量覆盖已有结果</NCheckbox
>
</div>
</section></template
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
</section>
<div class="storyboard-coverage grid grid-cols-2 gap-3">
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
<div class="mb-3 flex items-center justify-between text-xs">
<span>{{ item.title }}</span
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
</div>
<NProgress
:aria-label="item.title + '已保存覆盖率'"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100)
)
"
class=""
></NProgress>
</div>
</div>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
><div class="overview-content">
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
<span class="mr-2 font-mono text-xs text-muted">01</span
>导演摄影设计
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集
@@ -192,7 +239,8 @@ watch(
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
<span class="mr-2 font-mono text-xs text-muted">02</span
>即时视觉状态
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错
@@ -228,7 +276,9 @@ watch(
<p class="my-3 text-xs leading-6 text-muted">
后端会先校验 GenerationSpec
与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1"
<RouterLink
:to="`/projects/${id}/production`"
class="text-button mx-1"
>镜头生产</RouterLink
>
</p>
@@ -246,9 +296,21 @@ watch(
<GenerationReceipt
v-if="session.receipt"
:receipt="session.receipt"
/></div></NCollapseItem></NCollapse
></NScrollbar>
<NScrollbar class="workspace-feedback">
/></div></NCollapseItem
></NCollapse>
</div>
</WorkspaceTools>
</div></div
></template>
<NScrollbar
v-if="
query.error.value ||
!prerequisites.directionEpisode ||
!prerequisites.visualEpisode ||
(data && !directionComplete && data.directions.shotCount)
"
class="workspace-feedback"
>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<NButton
@click="query.refresh"
@@ -275,37 +337,6 @@ watch(
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
</NAlert>
</NScrollbar>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 镜头检查</h3>
<p class="mt-2 text-[11px] text-muted">
当前剧集数据库结果每 6 秒刷新覆盖率不是批量任务进度批量诊断在请求返回后显示
</p>
</div>
<div class="flex gap-3">
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
><Download :size="13" />导出本集
</NButton>
</div>
</div>
<div class="storyboard-coverage grid grid-cols-2 gap-3">
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
<div class="mb-3 flex items-center justify-between text-xs">
<span>{{ item.title }}</span
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
</div>
<NProgress
:aria-label="item.title + '已保存覆盖率'"
type="line"
:show-indicator="false"
:height="4"
:percentage="Math.min(100, Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100))"
class=""
></NProgress>
</div>
</div>
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
正在读取正式分镜数据……
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
NScrollbar,
NAlert,
@@ -129,39 +130,55 @@ function chooseAnchor(imageId: string) {
else void setAnchor(imageId)
}
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
const overview = ref<string[]>([])
const toolsOpen = ref(false)
const overview = ref<string[]>(['casting'])
watch(
() => session.value.receipt,
receipt => {
if (receipt) overview.value = ['casting']
},
{ immediate: true }
if (receipt) {
overview.value = ['casting']
toolsOpen.value = true
}
}
)
</script>
<template>
<WorkspacePage split
<WorkspacePage split compact
><template #header
><div class="flex flex-wrap items-end justify-between gap-4">
><div class="workspace-toolbar">
<div class="toolbar-type-filter">
<NSelect
aria-label="身份主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
</div>
<span class="toolbar-summary"> {{ subjects.length }} 个主体</span>
<div class="toolbar-actions">
<WorkspaceTools
v-model:open="toolsOpen"
title="角色选角总览与批量操作"
:has-receipt="!!session.receipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">主体身份</h2>
<p class="mt-2 text-sm text-muted">为同一个人物场景或道具固定稳定特征再生成不同造型</p>
<p class="mt-2 text-sm text-muted">
为同一个人物场景或道具固定稳定特征再生成不同造型
</p>
</div>
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
>下一步形态图片 </RouterLink
>
</div></template
>
<NScrollbar
class="workspace-feedback"
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
><NAlert role="alert" type="error" :show-icon="false"
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</NAlert></NScrollbar
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
</div>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="casting" title="角色选角总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
@@ -169,7 +186,12 @@ watch(
Identity之后首帧才能就绪未明确人物背景时AI
默认以中国人物作为选角基线项目和角色的明确事实始终优先场景和道具仍使用普通身份母版流程
</NAlert>
<NAlert v-if="styleQuery.data.value && !hasStyle" type="info" :show-icon="false" class="mt-4">
<NAlert
v-if="styleQuery.data.value && !hasStyle"
type="info"
:show-icon="false"
class="mt-4"
>
先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink
:to="`/projects/${projectId}/visual-style`"
class="text-button ml-2"
@@ -229,7 +251,9 @@ watch(
<span class="field-label">本批上限</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof candidateLimit === 'number' ? candidateLimit : null"
:value="
typeof candidateLimit === 'number' ? candidateLimit : null
"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
@@ -243,7 +267,9 @@ watch(
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !casting.total
"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
@@ -262,7 +288,10 @@ watch(
@confirm="generateCastingCandidates"
/>
</div>
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
<p
v-if="!batchValid || !candidateLimitValid"
class="mt-3 text-xs text-danger"
>
并发数与本批上限都必须是正整数
</p>
<div class="casting-list mt-5">
@@ -273,11 +302,15 @@ watch(
class="casting-item"
:class="{ selected: selectedId === item.subjectId }"
><span class="min-w-0">
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
<span class="block truncate text-xs font-medium">{{
item.subjectName
}}</span>
<span class="mt-1 block font-mono text-[10px] text-muted">{{
item.subjectRef
}}</span> </span
><StatusBadge :status="item.status" :label="castingStatusLabel(item.status)"
><StatusBadge
:status="item.status"
:label="castingStatusLabel(item.status)"
/></NButton>
</div>
</section>
@@ -291,27 +324,41 @@ watch(
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !subjects.length
"
acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)" /></NCollapseItem
></NCollapse>
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
<section
v-if="session.receipt"
class="panel mb-5 p-5"
aria-label="身份文本批量回执"
>
<div class="flex justify-between gap-3">
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<h3 class="text-sm font-medium">
{{ session.receipt.title }} · 本次回执
</h3>
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
</div>
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
<p
v-if="session.receipt.kind === 'identities'"
class="mt-3 text-xs text-muted"
>
{{ session.receipt.result.total }} 个主体 · 目标
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
跳过 {{ session.receipt.result.skipped }}含锁定
{{ session.receipt.result.skippedLocked }}· 失败 {{ session.receipt.result.failed }}
{{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.result.generated }} · 跳过
{{ session.receipt.result.skipped }}含锁定
{{ session.receipt.result.skippedLocked }}· 失败
{{ session.receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
{{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }}
</p>
<NAlert
v-if="session.receipt.result.failed"
@@ -323,8 +370,13 @@ watch(
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
{{ failure.subjectRef }} · {{ failure.subjectId }}{{ failure.error }}
<li
v-for="failure in session.receipt.result.failures"
:key="failure.subjectId"
>
{{ failure.subjectRef }} · {{ failure.subjectId }}{{
failure.error
}}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">
@@ -333,7 +385,19 @@ watch(
</section>
</div></NCollapseItem
></NCollapse
></NScrollbar
>
</div>
</WorkspaceTools>
</div>
</div></template
>
<NScrollbar
class="workspace-feedback"
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
><NAlert role="alert" type="error" :show-icon="false"
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</NAlert></NScrollbar
>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel directory-panel identity-directory">
@@ -360,16 +424,6 @@ watch(
v-model:value="search"
clearable
></NInput>
<NSelect
aria-label="身份主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
</div>
<NScrollbar
class="identity-subject-list panel-scroll directory-scroll"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -31,6 +32,14 @@ const {
generateProject,
generateStale
} = useSubjectImages()
const toolsOpen = ref(false)
/** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */
watch(
() => session.value.receipt,
receipt => {
if (receipt) toolsOpen.value = true
}
)
const search = ref('')
const module = ref('all')
const onlyMissing = ref(false)
@@ -89,17 +98,32 @@ function resetFilters() {
</script>
<template>
<WorkspacePage
><template #header
<WorkspacePage compact class="gallery-workspace-page"
><template #header>
<div class="workspace-toolbar">
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
>{{ staleForms.length }} 个形态主图过期</NButton
>
<div class="toolbar-actions">
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton
><WorkspaceTools
v-model:open="toolsOpen"
title="形态生图配置与回执"
:has-receipt="!!session.receipt"
><div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">形态图片</h2>
<p class="mt-2 text-sm text-muted">为人物场景和道具准备造型参考图已有图片直接从数据库读取</p>
<p class="mt-2 text-sm text-muted">
为人物场景和道具准备造型参考图已有图片直接从数据库读取
</p>
</div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
>去检查分镜参考图<ArrowRight :size="14"
/></RouterLink></div></template
><NAlert type="info" :show-icon="false" class="mt-5 text-xs">
/></RouterLink>
</div>
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
Character 形态图会记录生成时使用的 Identity
Anchor更换演员母版后旧主图会标记为身份母版已变更且后端会阻止它继续进入分镜首帧请基于当前母版重新生成候选并确认新的主参考图
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
@@ -115,9 +139,6 @@ function resetFilters() {
</span>
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
</p>
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
</NButton>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
6
@@ -126,8 +147,8 @@ function resetFilters() {
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
><div class="flex flex-wrap items-center justify-between gap-3">
<span class="flex items-center gap-2 text-xs">
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} Character Form 仍在使用旧 Identity
Anchor
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} Character Form
仍在使用旧 Identity Anchor
</span>
<ConfirmAction
label="重新生成过期形态"
@@ -177,22 +198,26 @@ function resetFilters() {
></NCollapse
>
</div>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span></NAlert
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</NAlert>
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
<NButton @click="exportReceipt" text size="small"
><Download :size="13" />导出诊断</NButton
>
</div>
<p class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.total }} 个形态 · 本次目标
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
跳过 {{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.failed }}
</p>
<NAlert v-if="session.receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
@@ -209,8 +234,13 @@ function resetFilters() {
>{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准</p>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准
</p>
</section>
</WorkspaceTools>
</div>
</div>
<div class="form-image-filter-region">
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
@@ -237,6 +267,12 @@ function resetFilters() {
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
</div>
</div>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span></NAlert
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</NAlert>
</template>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片
</p>
+3
View File
@@ -30,6 +30,8 @@ function confirm() {
</script>
<template>
<span class="confirm-action">
<!-- 稳定的根元素承接调用方间距AppDialog 含多个根节点不能替按钮接收 class -->
<AppDialog v-model:open="open" :title="label" :description="description">
<template #trigger
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
@@ -52,4 +54,5 @@ function confirm() {
>
</div>
</AppDialog>
</span>
</template>
+11 -5
View File
@@ -94,7 +94,7 @@ function button(label: string): HTMLButtonElement {
async function confirmGeneration(label: string) {
button(label).click()
await flushPromises()
const acknowledgement = document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')
const acknowledgement = document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')
expect(button('确认' + label).disabled).toBe(true)
acknowledgement!.click()
await flushPromises()
@@ -193,9 +193,10 @@ describe('内容级滚动下的生产反馈', () => {
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(false)
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
const alert = wrapper.get('.workspace-split [role="alert"]')
expect(alert.element.closest('.workspace-overview')).toBeNull()
expect(alert.element.closest('.workspace-tools-drawer')).toBeNull()
expect(alert.text()).toContain('无法连接后端')
})
@@ -212,7 +213,8 @@ describe('内容级滚动下的生产反馈', () => {
result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
}
await flushPromises()
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(true)
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
expect(wrapper.find('.workspace-tools-drawer .n-collapse-item--active').exists()).toBe(true)
expect(wrapper.text()).toContain('供应商不可用')
})
})
@@ -313,7 +315,7 @@ describe('视觉风格与主体身份工作区', () => {
await mountAssets('style')
button('AI 重新生成风格').click()
await flushPromises()
document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')!.click()
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
await flushPromises()
expect(button('确认AI 重新生成风格').disabled).toBe(false)
button('取消').click()
@@ -872,6 +874,10 @@ describe('工作台页面交互', () => {
await flushPromises()
await confirmGeneration('生成本集导演设计')
expect(button('补齐项目导演设计').disabled).toBe(true)
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
expect(getOperation(fixture.id).pending).toBe(true)
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
await flushPromises()
wrapper!.unmount()
+3
View File
@@ -3,6 +3,9 @@ import { NSelect } from 'naive-ui'
/** 展开真实折叠面板后再测试内部操作,避免对不可见控件进行断言。 */
export async function expandSections() {
const tools = document.querySelector<HTMLElement>('[data-workspace-tools][aria-expanded="false"]')
tools?.click()
await flushPromises()
for (let depth = 0; depth < 4; depth++) {
const headers = document.querySelectorAll<HTMLElement>(
'.n-collapse-item:not(.n-collapse-item--active) > .n-collapse-item__header .n-collapse-item__header-main'