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
+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')
+271 -214
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,63 +105,45 @@ 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
:value="episodeNo"
aria-label="选择生产剧集"
@update:value="selectedEpisode = Number($event)"
:options="[
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
...episodeOptions.map(episode => ({
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
value: episode.episodeNo
}))
]"
class="select-control"
></NSelect>
</label>
<label>
<span class="field-label">批量并发</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
>覆盖模式为已有结果新增候选
</NCheckbox>
><div class="workspace-toolbar">
<label class="toolbar-episode"
><span>剧集</span
><NSelect
:value="episodeNo"
aria-label="选择生产剧集"
@update:value="selectedEpisode = Number($event)"
:options="[
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
...episodeOptions.map(episode => ({
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
value: episode.episodeNo
}))
]"
class="select-control"
></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"
@@ -169,12 +152,243 @@ watch(
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
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
>覆盖模式为已有结果新增候选
</NCheckbox>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式新增全项目候选Seedream
Seedance 均可能产生费用
</p>
</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"
>
当前有 {{ 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
></NAlert
>
<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"
>
当前有
{{ 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"
>
<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>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<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>
<NProgress
:aria-label="`${stage.title}完成数量`"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(
0,
(stage.done /
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
100
)
)
"
class="mt-4"
></NProgress>
<dl class="production-stats mt-4">
<div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<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.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
: stage.key === 'keyframes'
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div>
<div>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
</div>
</div></NCollapseItem
></NCollapse
>
</div>
</WorkspaceTools>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式新增全项目候选Seedream
Seedance 均可能产生费用
</p>
</section></template
</div></template
>
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
@@ -182,166 +396,9 @@ watch(
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><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">
当前有 {{ 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
></NAlert
>
<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">
当前有
{{ 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">
<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>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<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>
<NProgress
:aria-label="`${stage.title}完成数量`"
type="line"
:show-indicator="false"
:height="4"
:percentage="
Math.min(
100,
Math.max(
0,
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
)
)
"
class="mt-4"
></NProgress>
<dl class="production-stats mt-4">
<div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<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.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
: stage.key === 'keyframes'
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div>
<div>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
</div>
</div></NCollapseItem
></NCollapse
></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"
+219 -188
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,174 +82,235 @@ 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
><NSelect
:value="episodeNo"
aria-label="选择分镜剧集"
@update:value="selectedEpisode = Number($event)"
:options="[
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
...episodeOptions.map(episode => ({
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
value: episode.episodeNo
}))
]"
class="select-control"
></NSelect
></label>
<label
><span class="field-label">批量并发</span
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
>
<label
><span class="field-label">状态自动修复次数</span
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-repairs' }"
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
@update:value="maxRepairAttempts = $event ?? 0"
:min="0"
:step="1"
></NInputNumber
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
>请输入非负整数0 表示不修复</span
></label
>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
>批量覆盖已有结果</NCheckbox
><div class="workspace-toolbar">
<label class="toolbar-episode"
><span>剧集</span
><NSelect
:value="episodeNo"
aria-label="选择分镜剧集"
@update:value="selectedEpisode = Number($event)"
:options="[
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
...episodeOptions.map(episode => ({
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
value: episode.episodeNo
}))
]"
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>
</section></template
<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
:disabled="operation.pending"
:input-props="{ id: 'storyboard-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
>请输入正整数</span
></label
>
<label
><span class="field-label">状态自动修复次数</span
><NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'storyboard-repairs' }"
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
@update:value="maxRepairAttempts = $event ?? 0"
:min="0"
:step="1"
></NInputNumber
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
>请输入非负整数0 表示不修复</span
></label
>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-3 text-xs"
>批量覆盖已有结果</NCheckbox
>
</div>
</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
>导演摄影设计
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
label="生成本集导演设计"
:disabled="!allowed['direction-one']"
acknowledgement
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
@confirm="run('direction-one')"
/><ConfirmAction
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
:disabled="!allowed['direction-all']"
acknowledgement
:description="
force
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
"
@confirm="run('direction-all')"
/>
</div>
</div>
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">02</span
>即时视觉状态
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
label="生成本集即时状态"
:disabled="!allowed['visual-one']"
acknowledgement
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
@confirm="run('visual-one')"
/><ConfirmAction
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
:disabled="!allowed['visual-all']"
acknowledgement
:description="
force
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
"
@confirm="run('visual-all')"
/>
</div>
</div>
<p class="mt-4 text-[11px] leading-6 text-muted">
修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
VisualState
</p>
<NCollapse class="mt-3 border-t border-line pt-3"
><NCollapseItem name="details"
><template #header>视频提示词批量操作</template>
<p class="my-3 text-xs leading-6 text-muted">
后端会先校验 GenerationSpec
与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<RouterLink
:to="`/projects/${id}/production`"
class="text-button mx-1"
>镜头生产</RouterLink
>
</p>
<ConfirmAction
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
:disabled="!allowed.prompts"
acknowledgement
:description="
force
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
"
@confirm="run('prompts')" /></NCollapseItem
></NCollapse>
<GenerationReceipt
v-if="session.receipt"
:receipt="session.receipt"
/></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"
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><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>导演摄影设计
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
label="生成本集导演设计"
:disabled="!allowed['direction-one']"
acknowledgement
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
@confirm="run('direction-one')"
/><ConfirmAction
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
:disabled="!allowed['direction-all']"
acknowledgement
:description="
force
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
"
@confirm="run('direction-all')"
/>
</div>
</div>
<div class="generation-row">
<div>
<h3 class="text-sm font-semibold">
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错
</p>
</div>
<div class="flex flex-wrap gap-2">
<ConfirmAction
label="生成本集即时状态"
:disabled="!allowed['visual-one']"
acknowledgement
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
@confirm="run('visual-one')"
/><ConfirmAction
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
:disabled="!allowed['visual-all']"
acknowledgement
:description="
force
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
"
@confirm="run('visual-all')"
/>
</div>
</div>
<p class="mt-4 text-[11px] leading-6 text-muted">
修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
VisualState
</p>
<NCollapse class="mt-3 border-t border-line pt-3"
><NCollapseItem name="details"
><template #header>视频提示词批量操作</template>
<p class="my-3 text-xs leading-6 text-muted">
后端会先校验 GenerationSpec
与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1"
>镜头生产</RouterLink
>
</p>
<ConfirmAction
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
:disabled="!allowed.prompts"
acknowledgement
:description="
force
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
"
@confirm="run('prompts')" /></NCollapseItem
></NCollapse>
<GenerationReceipt
v-if="session.receipt"
:receipt="session.receipt"
/></div></NCollapseItem></NCollapse
></NScrollbar>
<NScrollbar 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,27 +130,265 @@ 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>
<h2 class="text-lg font-semibold">主体身份</h2>
<p class="mt-2 text-sm text-muted">为同一个人物场景或道具固定稳定特征再生成不同造型</p>
><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>
</div>
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
>下一步形态图片 </RouterLink
>
</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">
Character 现在需要依次完成身份文本选角候选确认演员并锁定
Identity之后首帧才能就绪未明确人物背景时AI
默认以中国人物作为选角基线项目和角色的明确事实始终优先场景和道具仍使用普通身份母版流程
</NAlert>
<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"
>设置视觉风格 </RouterLink
></NAlert
>
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="production-step"><UserCheck :size="17" /></span>
<div>
<h3 class="text-sm font-semibold">角色选角</h3>
<p class="mt-2 text-xs leading-6 text-muted">
只处理 Character每张候选都从 Identity 独立生成不复用旧
Anchor确认选择时才会同时切换 Anchor 并锁定 Identity
</p>
</div>
</div>
<span class="font-mono text-xs text-muted"
>{{ casting.ready }} / {{ casting.total }} 已完成</span
>
</div>
<dl class="casting-stats mt-4">
<div>
<dt>缺身份文本</dt>
<dd>{{ casting.missingIdentity }}</dd>
</div>
<div>
<dt>缺候选图</dt>
<dd>{{ casting.missingAnchor }}</dd>
</div>
<div>
<dt>待确认演员</dt>
<dd>{{ casting.candidatePending }}</dd>
</div>
<div>
<dt>母版未锁定</dt>
<dd>{{ casting.unlocked }}</dd>
</div>
<div>
<dt>选角完成</dt>
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-28">
<span class="field-label">本批上限</span>
<NInputNumber
:disabled="operation.pending"
:value="
typeof candidateLimit === 'number' ? candidateLimit : null
"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex gap-2 pb-2 text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="
blocked || !hasStyle || !batchValid || dirty || !casting.total
"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
!casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
<p
v-if="!batchValid || !candidateLimitValid"
class="mt-3 text-xs text-danger"
>
并发数与本批上限都必须是正整数
</p>
<div class="casting-list mt-5">
<NButton
v-for="item in casting.items"
:key="item.subjectId"
@click="select(item.subjectId)"
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="mt-1 block font-mono text-[10px] text-muted">{{
item.subjectRef
}}</span> </span
><StatusBadge
:status="item.status"
:label="castingStatusLabel(item.status)"
/></NButton>
</div>
</section>
<NCollapse class="panel mb-5 p-5"
><NCollapseItem name="details"
><template #header>高级全部主体身份文本</template>
<p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene
Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
: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="身份文本批量回执"
>
<div class="flex justify-between gap-3">
<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"
>
{{ session.receipt.result.total }} 个主体 · 目标
{{ 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 }}
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</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>
</ul>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准
</p>
</section>
</div></NCollapseItem
></NCollapse
>
</div>
</WorkspaceTools>
</div>
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
>下一步形态图片 </RouterLink
>
</div></template
>
<NScrollbar
@@ -160,181 +399,6 @@ watch(
当前相关操作已暂停请刷新核对后端
</NAlert></NScrollbar
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><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">
Character 现在需要依次完成身份文本选角候选确认演员并锁定
Identity之后首帧才能就绪未明确人物背景时AI
默认以中国人物作为选角基线项目和角色的明确事实始终优先场景和道具仍使用普通身份母版流程
</NAlert>
<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"
>设置视觉风格 </RouterLink
></NAlert
>
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="production-step"><UserCheck :size="17" /></span>
<div>
<h3 class="text-sm font-semibold">角色选角</h3>
<p class="mt-2 text-xs leading-6 text-muted">
只处理 Character每张候选都从 Identity 独立生成不复用旧
Anchor确认选择时才会同时切换 Anchor 并锁定 Identity
</p>
</div>
</div>
<span class="font-mono text-xs text-muted"
>{{ casting.ready }} / {{ casting.total }} 已完成</span
>
</div>
<dl class="casting-stats mt-4">
<div>
<dt>缺身份文本</dt>
<dd>{{ casting.missingIdentity }}</dd>
</div>
<div>
<dt>缺候选图</dt>
<dd>{{ casting.missingAnchor }}</dd>
</div>
<div>
<dt>待确认演员</dt>
<dd>{{ casting.candidatePending }}</dd>
</div>
<div>
<dt>母版未锁定</dt>
<dd>{{ casting.unlocked }}</dd>
</div>
<div>
<dt>选角完成</dt>
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<label class="w-28">
<span class="field-label">本批上限</span>
<NInputNumber
:disabled="operation.pending"
:value="typeof candidateLimit === 'number' ? candidateLimit : null"
@update:value="candidateLimit = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex gap-2 pb-2 text-xs"
>覆盖未锁定身份文本
</NCheckbox>
<ConfirmAction
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
acknowledgement
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
!casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
并发数与本批上限都必须是正整数
</p>
<div class="casting-list mt-5">
<NButton
v-for="item in casting.items"
:key="item.subjectId"
@click="select(item.subjectId)"
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="mt-1 block font-mono text-[10px] text-muted">{{
item.subjectRef
}}</span> </span
><StatusBadge :status="item.status" :label="castingStatusLabel(item.status)"
/></NButton>
</div>
</section>
<NCollapse class="panel mb-5 p-5"
><NCollapseItem name="details"
><template #header>高级全部主体身份文本</template>
<p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene
Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
: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="身份文本批量回执">
<div class="flex justify-between gap-3">
<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">
{{ session.receipt.result.total }} 个主体 · 目标
{{ 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 }}
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</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>
</ul>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准
</p>
</section>
</div></NCollapseItem
></NCollapse
></NScrollbar
>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel directory-panel identity-directory">
<div class="directory-heading">
@@ -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"
+179 -143
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 { 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,154 +98,181 @@ function resetFilters() {
</script>
<template>
<WorkspacePage
><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>
<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>
</div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
>去检查分镜参考图<ArrowRight :size="14"
/></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"
>管理主体身份 </RouterLink
></NAlert
>
<div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
{{ staleForms.length }} 个人物形态主图已过期
</span>
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
</p>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
6
秒更新图片记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除也不会被批量任务静默替换
</p>
<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
</span>
<ConfirmAction
label="重新生成过期形态"
:disabled="blocked || !batchValid || !staleForms.length"
acknowledgement
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
@confirm="generateStale"
/></div
></NAlert>
<NCollapse class="mt-4 border-t border-line pt-4"
><NCollapseItem name="details"
><template #header>项目批量生图</template>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28">
<span class="field-label">批量并发</span>
<NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'image-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-2 text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响批量使用后端默认尺寸与形态提示词不会启动视频生成
</p></NCollapseItem
></NCollapse
>
</div>
<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
>
</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.failed }}
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
v-else-if="session.receipt.title === '刷新过期人物形态图'"
type="info"
:show-icon="false"
class="mt-3 text-xs"
>
新图当前仍是候选图请进入对应形态的查看图片与记录确认人物身份和造型后再设为主参考图旧主图在此之前仍保持过期状态
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
>{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">
回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准
</p>
</section>
</WorkspaceTools>
</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">
Character 形态图会记录生成时使用的 Identity
Anchor更换演员母版后旧主图会标记为身份母版已变更且后端会阻止它继续进入分镜首帧请基于当前母版重新生成候选并确认新的主参考图
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
>管理主体身份 </RouterLink
></NAlert
>
<div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
{{ staleForms.length }} 个人物形态主图已过期
</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
秒更新图片记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除也不会被批量任务静默替换
</p>
<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
</span>
<ConfirmAction
label="重新生成过期形态"
:disabled="blocked || !batchValid || !staleForms.length"
acknowledgement
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
@confirm="generateStale"
/></div
></NAlert>
<NCollapse class="mt-4 border-t border-line pt-4"
><NCollapseItem name="details"
><template #header>项目批量生</template>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28">
<span class="field-label">批量并发</span>
<NInputNumber
:disabled="operation.pending"
:input-props="{ id: 'image-concurrency' }"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="flex items-center gap-2 pb-2 text-xs"
>已有主图也新增候选图
</NCheckbox>
<ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
<div class="form-image-filter-region">
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></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 class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响批量使用后端默认尺寸与形态提示词不会启动视频生成
</p></NCollapseItem
></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>
</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.failed }}
</p>
<NAlert v-if="session.receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
v-else-if="session.receipt.title === '刷新过期人物形态图'"
type="info"
:show-icon="false"
class="mt-3 text-xs"
>
新图当前仍是候选图请进入对应形态的查看图片与记录确认人物身份和造型后再设为主参考图旧主图在此之前仍保持过期状态
</NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
>{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准</p>
</section>
<div class="form-image-filter-region">
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></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 class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
</div>
<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>
+24 -21
View File
@@ -30,26 +30,29 @@ function confirm() {
</script>
<template>
<AppDialog v-model:open="open" :title="label" :description="description">
<template #trigger
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
label
}}</NButton></template
>
<p class="mt-5 text-sm leading-6 text-muted">
此操作会提交到真实后端可能调用模型并产生费用请勿在其他页面或终端同时启动相同工作流
</p>
<NCheckbox
v-model:checked="acknowledged"
v-if="acknowledgement"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认后台任务停止当前没有同项目的生成或拆解任务在运行</NCheckbox
>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
确认{{ label }}</NButton
<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">{{
label
}}</NButton></template
>
</div>
</AppDialog>
<p class="mt-5 text-sm leading-6 text-muted">
此操作会提交到真实后端可能调用模型并产生费用请勿在其他页面或终端同时启动相同工作流
</p>
<NCheckbox
v-model:checked="acknowledged"
v-if="acknowledgement"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认后台任务停止当前没有同项目的生成或拆解任务在运行</NCheckbox
>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
确认{{ label }}</NButton
>
</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'