fix: 使用 NScrollbar 统一内容区滚动条

This commit is contained in:
GouJ
2026-09-01 16:44:30 +08:00
parent db8c176b55
commit 3fe1b3f5c3
17 changed files with 967 additions and 737 deletions
+1
View File
@@ -87,6 +87,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。 - 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。 - 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。 - 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 - 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。 - 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
+134 -28
View File
@@ -123,10 +123,13 @@ body {
.project-tabs .n-tabs-nav { .project-tabs .n-tabs-nav {
border: 0; border: 0;
} }
.project-notices { .project-notices.n-scrollbar {
flex-shrink: 0; flex-shrink: 0;
height: auto;
max-height: 100px; max-height: 100px;
overflow: auto; overflow: hidden;
}
.project-notices-content {
padding: 8px 24px; padding: 8px 24px;
display: grid; display: grid;
gap: 5px; gap: 5px;
@@ -147,12 +150,14 @@ body {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.workspace-heading { .workspace-heading-scroll.n-scrollbar {
flex-shrink: 0; flex-shrink: 0;
height: auto;
max-height: min(35dvh, 260px);
overflow: hidden;
}
.workspace-heading {
padding: 16px 24px 12px; padding: 16px 24px 12px;
max-height: 35%;
overflow: auto;
overscroll-behavior: contain;
} }
.workspace-heading h2 { .workspace-heading h2 {
font-size: 16px; font-size: 16px;
@@ -189,28 +194,27 @@ body {
margin: 0; margin: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.workspace-overview { .workspace-overview.n-scrollbar {
flex-shrink: 0; flex-shrink: 0;
max-height: 42%; height: auto;
overflow: auto; max-height: min(36dvh, 420px);
padding: 10px 14px; overflow: hidden;
background: var(--app-surface); background: var(--app-surface);
border: 1px solid var(--app-border); border: 1px solid var(--app-border);
border-radius: 6px; border-radius: 6px;
} }
.workspace-feedback { .overview-scroll-content {
padding: 10px 14px;
}
.workspace-feedback.n-scrollbar {
flex-shrink: 0; flex-shrink: 0;
max-height: 24%; height: auto;
overflow: auto; max-height: min(24dvh, 160px);
overflow: hidden;
} }
.workspace-feedback .n-alert + .n-alert { .workspace-feedback .n-alert + .n-alert {
margin-top: 8px; margin-top: 8px;
} }
.workspace-split > .n-alert {
flex-shrink: 0;
max-height: 24%;
overflow: auto;
}
.script-section { .script-section {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -259,11 +263,11 @@ body {
} }
.production-shot-list { .production-shot-list {
max-height: none; max-height: none;
overflow: auto; overflow: hidden;
min-height: 0; min-height: 0;
} }
.production-workspace > article { .production-workspace > article {
overflow: auto; overflow: hidden;
min-height: 0; min-height: 0;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
@@ -278,7 +282,7 @@ body {
} }
.identity-workspace > div { .identity-workspace > div {
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.identity-subject-list { .identity-subject-list {
@@ -292,7 +296,7 @@ body {
} }
.storyboard-workspace > article, .storyboard-workspace > article,
.storyboard-workspace .shot-list { .storyboard-workspace .shot-list {
overflow: auto; overflow: hidden;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
max-height: none; max-height: none;
@@ -306,7 +310,7 @@ body {
.content-with-history > aside, .content-with-history > aside,
.script-page { .script-page {
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.script-workspace { .script-workspace {
@@ -315,6 +319,84 @@ body {
.episode-list { .episode-list {
max-height: none; max-height: none;
min-height: 0; min-height: 0;
overflow: hidden;
}
/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
.panel-scroll.n-scrollbar {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.n-scrollbar > .n-scrollbar-container {
overscroll-behavior: contain;
}
.production-shot-list,
.production-detail,
.storyboard-detail,
.shot-list,
.episode-list,
.content-with-history > main {
min-width: 0;
min-height: 0;
}
.script-page,
.history-panel,
.production-shot-list,
.shot-list,
.episode-list {
padding: 0;
}
.production-shot-list-content {
padding-block: 8px;
}
.shot-list-content {
padding: 0 8px 20px;
}
.episode-list-content {
padding: 0 7px 20px;
}
.script-page-content {
padding: 36px 40px;
}
.history-content {
padding: 23px 21px;
}
.production-shot-list-content,
.shot-list-content,
.episode-list-content,
.identity-subject-list-content {
display: flex;
flex-direction: column;
}
.n-button.production-shot-link,
.n-button.shot-link,
.n-button.episode-link,
.n-button.identity-subject-item {
display: flex;
width: 100%;
flex-shrink: 0;
}
.image-history.n-scrollbar,
.table-scroll.n-scrollbar {
height: auto;
overflow: hidden;
}
.image-history-content {
display: flex;
gap: 12px;
padding: 4px 4px 8px;
width: max-content;
}
.code-scroll.n-scrollbar {
height: auto;
max-height: 420px;
overflow: hidden;
}
.code-scroll .json-view,
.code-scroll .storyboard-json {
max-height: none;
overflow: visible;
} }
.project-table-area { .project-table-area {
flex: 1; flex: 1;
@@ -522,18 +604,39 @@ body {
grid-template-rows: 110px minmax(0, 1fr); grid-template-rows: 110px minmax(0, 1fr);
} }
.production-shot-list { .production-shot-list {
display: flex; display: block;
overflow-x: auto; overflow: hidden;
height: 110px; height: 110px;
} }
.production-shot-link { .production-shot-list-content {
flex-shrink: 0; flex-direction: row;
width: max-content;
}
.n-button.production-shot-link {
width: 180px;
min-height: 98px;
} }
.identity-workspace { .identity-workspace {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: 180px minmax(0, 1fr); grid-template-rows: 180px minmax(0, 1fr);
gap: 10px; gap: 10px;
} }
.identity-workspace > aside {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 6px 8px;
padding: 10px;
}
.identity-workspace > aside > * {
margin-top: 0;
min-width: 0;
}
.identity-workspace > aside > h3,
.identity-workspace > aside > .identity-subject-list,
.identity-workspace > aside > .n-button {
grid-column: 1 / -1;
}
.storyboard-workspace { .storyboard-workspace {
grid-template-columns: 120px minmax(0, 1fr); grid-template-columns: 120px minmax(0, 1fr);
} }
@@ -545,7 +648,7 @@ body {
grid-template-rows: minmax(0, 1fr) 130px; grid-template-rows: minmax(0, 1fr) 130px;
} }
.history-panel { .history-panel {
overflow: auto; overflow: hidden;
min-height: 0; min-height: 0;
} }
.script-workspace { .script-workspace {
@@ -560,6 +663,9 @@ body {
width: 100%; width: 100%;
} }
.script-page { .script-page {
padding: 0;
}
.script-page-content {
padding: 18px; padding: 18px;
} }
.project-search { .project-search {
+3 -1
View File
@@ -5,7 +5,9 @@ defineProps<{ split?: boolean }>()
</script> </script>
<template> <template>
<section class="workspace-page"> <section class="workspace-page">
<header v-if="$slots.header" class="workspace-heading"><slot name="header" /></header> <NScrollbar v-if="$slots.header" class="workspace-heading-scroll"
><header class="workspace-heading"><slot name="header" /></header
></NScrollbar>
<div v-if="split" class="workspace-split"><slot /></div> <div v-if="split" class="workspace-split"><slot /></div>
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar> <NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
</section> </section>
+10 -3
View File
@@ -23,13 +23,20 @@ describe('管理后台组件边界', () => {
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/) expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/) expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
}) })
it('固定标题在滚动容器之外,普通工作区只有内容使用滚动组件', () => { it('业务样式不再直接开启浏览器原生滚动条', () => {
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
})
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
wrapper = mount(WorkspacePage, { wrapper = mount(WorkspacePage, {
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' } slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
}) })
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区') expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
expect(wrapper.getComponent(NScrollbar).text()).toBe('正文') const content = wrapper.get('.workspace-scroll')
expect(wrapper.getComponent(NScrollbar).find('h2').exists()).toBe(false) expect(content.classes()).toContain('n-scrollbar')
expect(content.text()).toBe('正文')
expect(content.find('h2').exists()).toBe(false)
expect(wrapper.get('.workspace-heading-scroll .n-scrollbar-content').text()).toBe('固定操作区')
}) })
it('分栏工作区不再包一层整页滚动', () => { it('分栏工作区不再包一层整页滚动', () => {
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui' import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
import { computed, onScopeDispose, ref, watch } from 'vue' import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue' import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui' import { EmptyState, StatusBadge } from '../../components/ui'
@@ -343,7 +343,7 @@ function exportResult() {
<StoryboardList :plans="plans" :episodes="shots" /> <StoryboardList :plans="plans" :episodes="shots" />
</div> </div>
<div v-if="tab === 'tasks'" class="p-5"> <div v-if="tab === 'tasks'" class="p-5">
<div v-if="snapshot?.tasks?.length" class="table-scroll"> <NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
<NTable :single-line="false" class="project-table" <NTable :single-line="false" class="project-table"
><thead> ><thead>
<tr> <tr>
@@ -383,7 +383,7 @@ function exportResult() {
> >
</tbody></NTable </tbody></NTable
> >
</div> </NScrollbar>
<EmptyState <EmptyState
v-else v-else
title="尚无抽取任务记录" title="尚无抽取任务记录"
+113 -97
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui' import { NScrollbar, NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ArrowRight, Download, Check, Circle } from '@lucide/vue' import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
import { EmptyState } from '../../components/ui' import { EmptyState } from '../../components/ui'
@@ -138,32 +138,38 @@ function exportScript() {
<div v-if="tab === 'episodes'" class="h-full"> <div v-if="tab === 'episodes'" class="h-full">
<div v-if="episode" class="script-workspace"> <div v-if="episode" class="script-workspace">
<aside class="episode-list"> <aside class="episode-list">
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p> <NScrollbar class="panel-scroll" content-class="episode-list-content">
<NButton <p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">
v-for="item in episodes" 剧集目录
:key="item.episode" </p>
:aria-pressed="episode.episode === item.episode" <NButton
@click="selected = item.episode" v-for="item in episodes"
class="episode-link" :key="item.episode"
:class="{ active: episode.episode === item.episode }" :aria-pressed="episode.episode === item.episode"
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span @click="selected = item.episode"
><span class="truncate">{{ item.title }}</span></NButton class="episode-link"
> :class="{ active: episode.episode === item.episode }"
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
><span class="truncate">{{ item.title }}</span></NButton
>
</NScrollbar>
</aside> </aside>
<article class="script-page"> <article class="script-page">
<p class="eyebrow"> {{ String(episode.episode).padStart(2, '0') }} </p> <NScrollbar class="panel-scroll" content-class="script-page-content">
<h2 class="mt-3 text-2xl font-semibold">{{ episode.title }}</h2> <p class="eyebrow"> {{ String(episode.episode).padStart(2, '0') }} </p>
<p v-if="episode.summary" class="script-summary">{{ episode.summary }}</p> <h2 class="mt-3 text-2xl font-semibold">{{ episode.title }}</h2>
<div class="script-body">{{ episode.content }}</div> <p v-if="episode.summary" class="script-summary">{{ episode.summary }}</p>
<dl v-if="episode.conflict || episode.hook" class="script-notes"> <div class="script-body">{{ episode.content }}</div>
<template v-if="episode.conflict" <dl v-if="episode.conflict || episode.hook" class="script-notes">
><dt>核心冲突</dt> <template v-if="episode.conflict"
<dd>{{ episode.conflict }}</dd></template ><dt>核心冲突</dt>
><template v-if="episode.hook" <dd>{{ episode.conflict }}</dd></template
><dt>结尾钩子</dt> ><template v-if="episode.hook"
<dd>{{ episode.hook }}</dd></template ><dt>结尾钩子</dt>
> <dd>{{ episode.hook }}</dd></template
</dl> >
</dl>
</NScrollbar>
</article> </article>
</div> </div>
<EmptyState <EmptyState
@@ -172,91 +178,101 @@ function exportScript() {
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。" description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
/> />
</div> </div>
<div v-if="tab === 'characters'" class="p-6 lg:p-8"> <NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
<div v-if="project?.characters.length" class="divide-y divide-line"> ><div v-if="tab === 'characters'" class="p-6 lg:p-8">
<article <div v-if="project?.characters.length" class="divide-y divide-line">
v-for="character in project.characters" <article
:key="character.id" v-for="character in project.characters"
class="py-5 first:pt-0" :key="character.id"
> class="py-5 first:pt-0"
<div class="mb-3 flex items-center gap-3"> >
<h3 class="text-lg font-semibold">{{ character.name }}</h3> <div class="mb-3 flex items-center gap-3">
<NTag size="small" :bordered="false">{{ character.role || '角色' }}</NTag <h3 class="text-lg font-semibold">{{ character.name }}</h3>
><span class="text-xs text-muted">{{ character.occupation }}</span> <NTag size="small" :bordered="false">{{ character.role || '角色' }}</NTag
</div> ><span class="text-xs text-muted">{{ character.occupation }}</span>
</div>
<dl class="detail-grid">
<template
v-for="field in [
{ key: 'personality', label: '性格' },
{ key: 'goal', label: '目标' },
{ key: 'secret', label: '秘密' }
] as const"
:key="field.key"
><dt>{{ field.label }}</dt>
<dd>{{ character[field.key] || '未提供' }}</dd></template
>
</dl>
</article>
</div>
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。" />
</div>
<div v-if="tab === 'world'" class="p-6 lg:p-8">
<div v-if="project?.world">
<p class="eyebrow">故事的发生之地</p>
<h2 class="mb-7 mt-3 text-xl font-semibold">
{{ project.world.era }} · {{ project.world.location }}
</h2>
<dl class="detail-grid"> <dl class="detail-grid">
<template <template
v-for="field in [ v-for="field in [
{ key: 'personality', label: '性格' }, { key: 'background', label: '背景' },
{ key: 'goal', label: '目标' }, { key: 'coreConflict', label: '核心冲突' },
{ key: 'secret', label: '秘密' } { key: 'tone', label: '基调' }
] as const" ] as const"
:key="field.key" :key="field.key"
><dt>{{ field.label }}</dt> ><dt>{{ field.label }}</dt>
<dd>{{ character[field.key] || '未提供' }}</dd></template <dd>{{ project.world[field.key] || '未提供' }}</dd></template
> >
</dl> </dl>
</article> <NCollapse v-if="project.world.rules" class="mt-6 text-sm"
</div> ><NCollapseItem name="details"
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。" /> ><template #header>世界规则</template>
</div> <NScrollbar class="code-scroll mt-3" x-scrollable>
<div v-if="tab === 'world'" class="p-6 lg:p-8"> <pre class="json-view">{{
<div v-if="project?.world"> JSON.stringify(project.world.rules, null, 2)
<p class="eyebrow">故事的发生之地</p> }}</pre>
<h2 class="mb-7 mt-3 text-xl font-semibold"> </NScrollbar>
{{ project.world.era }} · {{ project.world.location }} </NCollapseItem></NCollapse
</h2>
<dl class="detail-grid">
<template
v-for="field in [
{ key: 'background', label: '背景' },
{ key: 'coreConflict', label: '核心冲突' },
{ key: 'tone', label: '基调' }
] as const"
:key="field.key"
><dt>{{ field.label }}</dt>
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
> >
</dl> </div>
<NCollapse v-if="project.world.rules" class="mt-6 text-sm" <EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
</div>
<div v-if="tab === 'review'" class="p-6 lg:p-8">
<div v-if="project?.reviews.length" class="space-y-5">
<article
v-for="review in project.reviews"
:key="review.id"
class="border-b border-line pb-5"
>
<div class="mb-3 flex justify-between gap-3">
<span
class="text-sm font-medium"
:class="review.passed ? 'text-success' : 'text-danger'"
>{{ review.passed ? '审核通过' : '需要修改' }}</span
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
</div>
<p class="whitespace-pre-wrap text-sm leading-7">
{{ review.message || '本次审核未附加说明。' }}
</p>
</article>
</div>
<EmptyState
v-else
title="还没有审核记录"
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>世界规则</template> ><template #header>查看改写建议原文</template>
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre> <NScrollbar class="code-scroll mt-3" x-scrollable>
<pre class="json-view">{{
JSON.stringify(state.rewriteSuggestion, null, 2)
}}</pre>
</NScrollbar>
</NCollapseItem></NCollapse </NCollapseItem></NCollapse
> >
</div> </div>
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" /> </NScrollbar>
</div>
<div v-if="tab === 'review'" class="p-6 lg:p-8">
<div v-if="project?.reviews.length" class="space-y-5">
<article
v-for="review in project.reviews"
:key="review.id"
class="border-b border-line pb-5"
>
<div class="mb-3 flex justify-between gap-3">
<span
class="text-sm font-medium"
:class="review.passed ? 'text-success' : 'text-danger'"
>{{ review.passed ? '审核通过' : '需要修改' }}</span
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
</div>
<p class="whitespace-pre-wrap text-sm leading-7">
{{ review.message || '本次审核未附加说明。' }}
</p>
</article>
</div>
<EmptyState
v-else
title="还没有审核记录"
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
><NCollapseItem name="details"
><template #header>查看改写建议原文</template>
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
</NCollapseItem></NCollapse
>
</div>
</main> </main>
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" /> <HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
</div></div </div></div
+254 -232
View File
@@ -1,6 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NCollapse, NCollapseItem, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NSelect } from 'naive-ui' import {
NScrollbar,
NCollapse,
NCollapseItem,
NAlert,
NButton,
NCheckbox,
NInputNumber,
NProgress,
NSelect
} from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue' import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui' import { EmptyState, StatusBadge } from '../../components/ui'
@@ -166,158 +176,165 @@ watch(
</p> </p>
</section></template </section></template
> >
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4" <NScrollbar v-if="query.error.value" class="workspace-feedback"
>{{ query.error.value ><NAlert role="alert" type="error" :show-icon="false"
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert >{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
> >
<NCollapse v-model:expanded-names="overview" class="workspace-overview" <NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapseItem name="overview" title="项目生产总览与批量操作" ><NCollapse v-model:expanded-names="overview"
><div class="overview-content"> ><NCollapseItem name="overview" title="项目生产总览与批量操作"
<NAlert type="info" :show-icon="false" class="mt-5 text-xs"> ><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>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 <NAlert v-if="videoRunning" role="status" type="info" :show-icon="false" class="mt-4">
Provider同一镜头不会重复提交活动任务 当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
</NAlert> Provider同一镜头不会重复提交活动任务
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4"> </NAlert>
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成 <NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
Identity确认演员母版并锁定后首帧才能就绪 当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2" Identity确认演员母版并锁定后首帧才能就绪
>前往角色选角 </RouterLink <RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
></NAlert >前往角色选角 </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 }} 当前有
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐 {{ staleKeyframes }}
/ 更新主首帧批量处理 个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
</NAlert> / 更新主首帧批量处理
<NAlert v-if="staleVideos" role="status" type="info" :show-icon="false" class="mt-4"> </NAlert>
当前有 <NAlert v-if="staleVideos" role="status" type="info" :show-icon="false" class="mt-4">
{{ staleVideos }} 当前有
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频 {{ staleVideos }}
</NAlert> 个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" /> </NAlert>
<div v-if="query.data.value" class="production-pipeline mt-5"> <ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<article v-for="stage in stages" :key="stage.key" class="panel production-pipeline-card"> <div v-if="query.data.value" class="production-pipeline mt-5">
<div class="flex items-start justify-between gap-3"> <article v-for="stage in stages" :key="stage.key" class="panel production-pipeline-card">
<div class="flex items-start gap-3"> <div class="flex items-start justify-between gap-3">
<span class="production-step"><component :is="stage.icon" :size="17" /></span> <div class="flex items-start gap-3">
<div> <span class="production-step"><component :is="stage.icon" :size="17" /></span>
<p class="eyebrow">STEP {{ stage.code }}</p> <div>
<h3 class="mt-2 text-sm font-semibold">{{ stage.title }}</h3> <p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">{{ stage.title }}</h3>
</div>
</div> </div>
<span class="font-mono text-xs text-muted"
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
>
</div> </div>
<span class="font-mono text-xs text-muted" <p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span <NProgress
> :aria-label="`${stage.title}完成数量`"
</div> type="line"
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p> :show-indicator="false"
<NProgress :height="4"
:aria-label="`${stage.title}完成数量`" :percentage="
type="line" Math.min(
:show-indicator="false" 100,
:height="4" Math.max(
:percentage=" 0,
Math.min( (stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
100, )
Math.max(
0,
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
) )
) "
" class="mt-4"
class="mt-4" ></NProgress>
></NProgress> <dl class="production-stats mt-4">
<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> <div>
<dt>可执行</dt> <h3 class="text-sm font-semibold">视频任务状态</h3>
<dd>{{ stage.data?.ready ?? 0 }}</dd> <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>
<div> <div>
<dt>已有结果</dt> <dt>排队生成</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd> <dd>{{ videoRunning }}</dd>
</div> </div>
<div> <div>
<dt>阻塞</dt> <dt>失败</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd> <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> </div>
</dl> </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> </div>
<dl class="production-status-grid mt-4"> </div></NCollapseItem
<div> ></NCollapse
<dt>完成</dt> ></NScrollbar
<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 class="my-6 flex flex-wrap items-center justify-between gap-4"> <div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
@@ -328,103 +345,108 @@ watch(
</div> </div>
<div v-if="shot" class="panel production-workspace"> <div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头"> <nav class="production-shot-list" aria-label="本集生产镜头">
<NButton <NScrollbar class="panel-scroll" content-class="production-shot-list-content" x-scrollable>
v-for="item in shots" <NButton
:key="item.shotId" v-for="item in shots"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined" :key="item.shotId"
@click="selectedShot = item.shotId" :aria-current="item.shotId === shot.shotId ? 'true' : undefined"
class="production-shot-link" @click="selectedShot = item.shotId"
:class="{ selected: item.shotId === shot.shotId }" class="production-shot-link"
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span :class="{ selected: item.shotId === shot.shotId }"
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span ><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
><span ><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
v-if=" ><span
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
?.primaryKeyframeStale
"
class="mt-2 flex items-center gap-2 text-[10px]"
>
<CircleAlert :size="12" />主首帧已过期 </span
><span
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale
"
class="mt-2 flex items-center gap-2 text-[10px]"
>
<CircleAlert :size="12" />主视频已过期 </span
><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
<CheckCircle2
v-if=" v-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
'skipped' ?.primaryKeyframeStale
" "
:size="12" class="mt-2 flex items-center gap-2 text-[10px]"
/> >
<CircleAlert <CircleAlert :size="12" />主首帧已过期 </span
><span
v-else-if=" v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === query.data.value?.videos.items.find(row => row.shotId === item.shotId)
'blocked' ?.primaryVideoStale
" "
:size="12" class="mt-2 flex items-center gap-2 text-[10px]"
/> >
{{ <CircleAlert :size="12" />主视频已过期 </span
productionStatusLabel( ><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)?.status || <CheckCircle2
'not_started' v-if="
) query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
}} 'skipped'
</span></NButton "
> :size="12"
/>
<CircleAlert
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'blocked'
"
:size="12"
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
?.status || 'not_started'
)
}}
</span></NButton
>
</NScrollbar>
</nav> </nav>
<article class="min-w-0 p-5 lg:p-7"> <article class="production-detail min-w-0">
<header class="mb-5 border-b border-line pb-5"> <NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<div class="flex flex-wrap items-start justify-between gap-3"> <header class="mb-5 border-b border-line pb-5">
<div> <div class="flex flex-wrap items-start justify-between gap-3">
<p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p> <div>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3> <p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
</div>
<StatusBadge
v-if="videoStatusItem"
:status="videoStatusItem.status"
:label="productionStatusLabel(videoStatusItem.status)"
/>
</div> </div>
<StatusBadge <p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
v-if="videoStatusItem" <p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
:status="videoStatusItem.status" <NAlert
:label="productionStatusLabel(videoStatusItem.status)" v-if="keyframeItem?.primaryKeyframeStale"
/> role="status"
</div> type="info"
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p> :show-icon="false"
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p> class="mt-4 text-xs"
<NAlert >
v-if="keyframeItem?.primaryKeyframeStale" 主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
role="status" </NAlert>
type="info" <NAlert
:show-icon="false" v-if="videoItem?.primaryVideoStale"
class="mt-4 text-xs" role="status"
> type="info"
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。 :show-icon="false"
</NAlert> class="mt-4 text-xs"
<NAlert >
v-if="videoItem?.primaryVideoStale" 主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
role="status" </NAlert>
type="info" <ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
:show-icon="false" <li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
class="mt-4 text-xs" {{ issueLabel(issue.code) }}{{ issue.reason }}
> </li>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。 </ul>
</NAlert> </header>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert"> <ShotProductionAssets
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`"> :key="shot.shotId"
{{ issueLabel(issue.code) }}{{ issue.reason }} :project-id="id"
</li> :shot="shot"
</ul> :prompt-readiness="promptItem"
</header> :keyframe-readiness="keyframeItem"
<ShotProductionAssets :video-readiness="videoItem"
:key="shot.shotId" :disabled="blocked"
:project-id="id" @changed="refreshProject"
:shot="shot" />
:prompt-readiness="promptItem" </NScrollbar>
:keyframe-readiness="keyframeItem"
:video-readiness="videoItem"
:disabled="blocked"
@changed="refreshProject"
/>
</article> </article>
</div> </div>
<EmptyState <EmptyState
+4 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui' import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
import { RefreshCw } from '@lucide/vue' import { RefreshCw } from '@lucide/vue'
import { StatusBadge } from '../../components/ui' import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context' import { projectContextKey, useProjectData } from './context'
@@ -59,9 +59,10 @@ function switchWorkflow(path: string) {
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab> <NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
</NTabs> </NTabs>
</header> </header>
<div <NScrollbar
v-if="context.error.value || operation.pending || operation.error || operation.notice" v-if="context.error.value || operation.pending || operation.error || operation.notice"
class="project-notices" class="project-notices"
content-class="project-notices-content"
> >
<NAlert v-if="context.error.value" type="error" :show-icon="false" <NAlert v-if="context.error.value" type="error" :show-icon="false"
>{{ context.error.value >{{ context.error.value
@@ -72,7 +73,7 @@ function switchWorkflow(path: string) {
> >
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert> <NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert> <NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
</div> </NScrollbar>
<div class="project-view"> <div class="project-view">
<RouterView v-if="context.project.value" :key="id" /> <RouterView v-if="context.project.value" :key="id" />
<NSpin <NSpin
+147 -136
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { import {
NScrollbar,
NAlert, NAlert,
NButton, NButton,
NCheckbox, NCheckbox,
@@ -155,95 +156,99 @@ watch(
</div> </div>
</section></template </section></template
> >
<NCollapse v-model:expanded-names="overview" class="workspace-overview" <NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapseItem name="generation" title="生成配置、批量操作与回执" ><NCollapse v-model:expanded-names="overview"
><div class="overview-content"> ><NCollapseItem name="generation" title="生成配置、批量操作与回执"
<div class="generation-row"> ><div class="overview-content">
<div> <div class="generation-row">
<h3 class="text-sm font-semibold"> <div>
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计 <h3 class="text-sm font-semibold">
</h3> <span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
<p class="mt-2 text-xs leading-6 text-muted"> </h3>
景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集 <p class="mt-2 text-xs leading-6 text-muted">
</p> 景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集
</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>
<div class="flex flex-wrap gap-2"> <div class="generation-row">
<ConfirmAction <div>
label="生成本集导演设计" <h3 class="text-sm font-semibold">
:disabled="!allowed['direction-one']" <span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
acknowledgement </h3>
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。" <p class="mt-2 text-xs leading-6 text-muted">
@confirm="run('direction-one')" 表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错
/><ConfirmAction </p>
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'" </div>
:disabled="!allowed['direction-all']" <div class="flex flex-wrap gap-2">
acknowledgement <ConfirmAction
:description=" label="生成本集即时状态"
force :disabled="!allowed['visual-one']"
? '重新生成项目全部剧集的导演设计,覆盖已有结果。' acknowledgement
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。' description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
" @confirm="run('visual-one')"
@confirm="run('direction-all')" /><ConfirmAction
/> :label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
:disabled="!allowed['visual-all']"
acknowledgement
:description="
force
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
"
@confirm="run('visual-all')"
/>
</div>
</div> </div>
</div> <p class="mt-4 text-[11px] leading-6 text-muted">
<div class="generation-row"> 修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
<div> VisualState
<h3 class="text-sm font-semibold"> </p>
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态 <NCollapse class="mt-3 border-t border-line pt-3"
</h3> ><NCollapseItem name="details"
<p class="mt-2 text-xs leading-6 text-muted"> ><template #header>视频提示词批量操作</template>
表情姿态位置环境与连续性每集需要完整导演设计批量中缺少前置设计的剧集会单独报错 <p class="my-3 text-xs leading-6 text-muted">
</p> 后端会先校验 GenerationSpec
</div> 与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<div class="flex flex-wrap gap-2"> <RouterLink :to="`/projects/${id}/production`" class="text-button mx-1"
<ConfirmAction >镜头生产</RouterLink
label="生成本集即时状态" >
:disabled="!allowed['visual-one']" </p>
acknowledgement <ConfirmAction
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。" :label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
@confirm="run('visual-one')" :disabled="!allowed.prompts"
/><ConfirmAction acknowledgement
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'" :description="
:disabled="!allowed['visual-all']" force
acknowledgement ? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
:description=" : '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
force "
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。' @confirm="run('prompts')" /></NCollapseItem
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。' ></NCollapse>
" <GenerationReceipt
@confirm="run('visual-all')" v-if="session.receipt"
/> :receipt="session.receipt"
</div> /></div></NCollapseItem></NCollapse
</div> ></NScrollbar>
<p class="mt-4 text-[11px] leading-6 text-muted"> <NScrollbar class="workspace-feedback">
修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
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 class="workspace-feedback">
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4" <NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的数据暂停生成操作<NButton >{{ query.error.value }} 当前保留上次成功读取的数据暂停生成操作<NButton
@click="query.refresh" @click="query.refresh"
@@ -269,7 +274,7 @@ watch(
> >
本集导演设计尚未补齐请先完成第 01 再生成本集即时状态 本集导演设计尚未补齐请先完成第 01 再生成本集即时状态
</NAlert> </NAlert>
</div> </NScrollbar>
<div class="my-6 flex flex-wrap items-center justify-between gap-4"> <div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 镜头检查</h3> <h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 镜头检查</h3>
@@ -306,57 +311,63 @@ watch(
</div> </div>
<div v-else-if="shot" class="panel storyboard-workspace"> <div v-else-if="shot" class="panel storyboard-workspace">
<nav class="shot-list" aria-label="本集镜头"> <nav class="shot-list" aria-label="本集镜头">
<p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · Beat 排列</p> <NScrollbar class="panel-scroll" content-class="shot-list-content">
<template v-for="(item, index) in shots" :key="item.shotId" <p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · Beat 排列</p>
><h4 <template v-for="(item, index) in shots" :key="item.shotId"
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo" ><h4
class="px-4 pb-2 pt-4 font-mono text-[10px] text-muted" v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo"
class="px-4 pb-2 pt-4 font-mono text-[10px] text-muted"
>
BEAT {{ String(item.beatNo).padStart(2, '0') }}
</h4>
<NButton
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
@click="selectedShot = item.shotId"
class="shot-link"
:class="{ selected: shot.shotId === item.shotId }"
><span class="font-mono text-[10px] text-muted">{{
String(item.shotNo).padStart(2, '0')
}}</span
><span class="min-w-0"
><span class="block truncate text-xs font-medium">{{ item.title }}</span
><span class="mt-1 block text-[10px] text-muted"
>{{ item.direction ? '设计已保存' : '待设计' }} ·
{{ item.visualState ? '状态已保存' : '待状态' }}</span
></span
></NButton
></template
> >
BEAT {{ String(item.beatNo).padStart(2, '0') }} </NScrollbar>
</h4>
<NButton
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
@click="selectedShot = item.shotId"
class="shot-link"
:class="{ selected: shot.shotId === item.shotId }"
><span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
><span class="min-w-0"
><span class="block truncate text-xs font-medium">{{ item.title }}</span
><span class="mt-1 block text-[10px] text-muted"
>{{ item.direction ? '设计已保存' : '待设计' }} ·
{{ item.visualState ? '状态已保存' : '待状态' }}</span
></span
></NButton
></template
>
</nav> </nav>
<article class="min-w-0 p-5 lg:p-7"> <article class="storyboard-detail min-w-0">
<header class="mb-6"> <NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<p class="eyebrow"> <header class="mb-6">
BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }} <p class="eyebrow">
<span v-if="shot.durationSeconds" class="ml-3">{{ shot.durationSeconds }}s</span> BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}
</p> <span v-if="shot.durationSeconds" class="ml-3">{{ shot.durationSeconds }}s</span>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3> </p>
<p v-if="shot.description" class="mt-3 whitespace-pre-wrap text-sm leading-7"> <h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
{{ shot.description }} <p v-if="shot.description" class="mt-3 whitespace-pre-wrap text-sm leading-7">
</p> {{ shot.description }}
<p v-if="shot.visualFocus" class="mt-2 text-xs leading-6 text-muted"> </p>
视觉重点{{ shot.visualFocus }} <p v-if="shot.visualFocus" class="mt-2 text-xs leading-6 text-muted">
</p> 视觉重点{{ shot.visualFocus }}
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p> </p>
<p class="mt-1 text-[10px] text-muted"> <p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
标题与剧情描述取自最近可用 Breakdown 快照以下设计与状态取自数据库 <p class="mt-1 text-[10px] text-muted">
</p> 标题与剧情描述取自最近可用 Breakdown 快照以下设计与状态取自数据库
</header> </p>
<div> </header>
<NTabs v-model:value="detailTab" type="line" size="small" aria-label="镜头详情" <div>
><NTab name="design">设计与状态</NTab><NTab name="tools">参考图与生成规格</NTab></NTabs <NTabs v-model:value="detailTab" type="line" size="small" aria-label="镜头详情"
> ><NTab name="design">设计与状态</NTab><NTab name="tools">参考图与生成规格</NTab></NTabs
<div v-if="detailTab === 'design'" class="pt-6"><ShotDesign :shot="shot" /></div> >
<div v-if="detailTab === 'tools'" class="pt-6"> <div v-if="detailTab === 'design'" class="pt-6"><ShotDesign :shot="shot" /></div>
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" /> <div v-if="detailTab === 'tools'" class="pt-6">
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" />
</div>
</div> </div>
</div> </NScrollbar>
</article> </article>
</div> </div>
<EmptyState <EmptyState
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NTable } from 'naive-ui' import { NScrollbar, NAlert, NButton, NTable } from 'naive-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { Download } from '@lucide/vue' import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format' import { downloadText } from '../../../lib/format'
@@ -42,7 +42,7 @@ function exportReceipt() {
{{ receipt.result.episodeCount }} · 完成 {{ receipt.result.completedEpisodes }} · 跳过 {{ receipt.result.episodeCount }} · 完成 {{ receipt.result.completedEpisodes }} · 跳过
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }} {{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
</p> </p>
<div class="table-scroll"> <NScrollbar x-scrollable class="table-scroll">
<NTable :single-line="false" class="project-table" <NTable :single-line="false" class="project-table"
><thead> ><thead>
<tr> <tr>
@@ -74,7 +74,7 @@ function exportReceipt() {
</tr> </tr>
</tbody></NTable </tbody></NTable
> >
</div> </NScrollbar>
</template> </template>
<template v-else-if="receipt.kind === 'prompts'"> <template v-else-if="receipt.kind === 'prompts'">
<p class="mt-4 text-sm"> <p class="mt-4 text-sm">
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCheckbox } from 'naive-ui' import { NScrollbar, NAlert, NButton, NCheckbox } from 'naive-ui'
import { computed, onScopeDispose, ref, watch } from 'vue' import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, ExternalLink, RefreshCw } from '@lucide/vue' import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
import { storyboardApi } from '../api' import { storyboardApi } from '../api'
@@ -183,7 +183,9 @@ function exportResult(kind: 'spec' | 'prompt') {
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} </span >{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} </span
><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton> ><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton>
</div> </div>
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre> <NScrollbar class="code-scroll mt-3" x-scrollable>
<pre class="storyboard-json">{{ JSON.stringify(spec, null, 2) }}</pre>
</NScrollbar>
</template> </template>
</section> </section>
<section class="border-t border-line pt-6"> <section class="border-t border-line pt-6">
@@ -1,6 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui' import {
NScrollbar,
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NSelect,
NTag
} from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { UserCheck } from '@lucide/vue' import { UserCheck } from '@lucide/vue'
@@ -141,187 +152,188 @@ watch(
> >
</div></template </div></template
> >
<NAlert <NScrollbar
class="workspace-feedback"
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value" v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
role="alert" ><NAlert role="alert" type="error" :show-icon="false"
type="error" >{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
:show-icon="false" 当前相关操作已暂停请刷新核对后端
class="mt-4" </NAlert></NScrollbar
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }} >
当前相关操作已暂停请刷新核对后端 <NScrollbar class="workspace-overview" content-class="overview-scroll-content"
</NAlert> ><NCollapse v-model:expanded-names="overview"
<NCollapse v-model:expanded-names="overview" class="workspace-overview" ><NCollapseItem name="casting" title="角色选角总览与批量操作"
><NCollapseItem name="casting" title="角色选角总览与批量操作" ><div class="overview-content">
><div class="overview-content"> <NAlert type="info" :show-icon="false" class="mt-5 text-xs">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs"> Character 现在需要依次完成身份文本选角候选确认演员并锁定
Character 现在需要依次完成身份文本选角候选确认演员并锁定 Identity之后首帧才能就绪未明确人物背景时AI
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> </NAlert>
<ul class="mt-3 space-y-2 text-xs text-danger"> <NAlert v-if="styleQuery.data.value && !hasStyle" type="info" :show-icon="false" class="mt-4">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId"> 先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink
{{ failure.subjectRef }} · {{ failure.subjectId }}{{ failure.error }} :to="`/projects/${projectId}/visual-style`"
</li> class="text-button ml-2"
</ul> >设置视觉风格 </RouterLink
<p class="mt-3 text-[11px] text-muted"> ></NAlert
回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准 >
</p> <section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
</section> <div class="flex flex-wrap items-start justify-between gap-4">
</div></NCollapseItem <div class="flex items-start gap-3">
></NCollapse <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"> <div v-if="subjects.length" class="identity-workspace">
<aside class="panel min-w-0 p-4"> <aside class="panel min-w-0 p-4">
@@ -346,7 +358,10 @@ watch(
]" ]"
class="mt-3" class="mt-3"
></NSelect> ></NSelect>
<div class="identity-subject-list mt-4"> <NScrollbar
class="identity-subject-list panel-scroll mt-4"
content-class="identity-subject-list-content"
>
<NButton <NButton
v-for="item in filtered" v-for="item in filtered"
:key="item.id" :key="item.id"
@@ -367,7 +382,7 @@ watch(
></NButton ></NButton
> >
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p> <p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</div> </NScrollbar>
<NButton <NButton
:disabled="catalog.loading.value || castingQuery.loading.value" :disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory" @click="refreshDirectory"
@@ -378,7 +393,7 @@ watch(
刷新主体目录 刷新主体目录
</NButton> </NButton>
</aside> </aside>
<div class="min-w-0"> <NScrollbar class="identity-detail panel-scroll min-w-0">
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4" <NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
><p class="text-xs">当前主体有未保存修改切换会丢弃草稿</p> ><p class="text-xs">当前主体有未保存修改切换会丢弃草稿</p>
<div class="mt-3 flex gap-3"> <div class="mt-3 flex gap-3">
@@ -495,7 +510,7 @@ watch(
> >
</div> </div>
</section> </section>
</div> </NScrollbar>
</div> </div>
<p v-else-if="catalog.loading.value" class="py-8 text-sm text-muted" role="status">正在读取正式主体目录</p> <p v-else-if="catalog.loading.value" class="py-8 text-sm text-muted" role="status">正在读取正式主体目录</p>
<EmptyState <EmptyState
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui' import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { AssetImage, StatusBadge } from '../../../components/ui' import { AssetImage, StatusBadge } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets' import { referenceImageUrl } from '../../../lib/assets'
@@ -132,7 +132,12 @@ function chooseAnchor() {
本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }} 本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }}
</p> </p>
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p> <p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
<div class="image-history mt-4" aria-label="身份图片历史"> <NScrollbar
x-scrollable
content-class="image-history-content"
class="image-history mt-4"
aria-label="身份图片历史"
>
<NButton <NButton
v-for="image in images" v-for="image in images"
:key="image.id" :key="image.id"
@@ -154,7 +159,7 @@ function chooseAnchor() {
: identityViewLabels[image.viewType] : identityViewLabels[image.viewType]
}}</span></NButton }}</span></NButton
> >
</div> </NScrollbar>
</template> </template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p> <p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section> </section>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui' import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue' import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui' import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
@@ -159,7 +159,12 @@ async function choosePrimary() {
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。' : '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
}} }}
</p> </p>
<div class="image-history mt-5" aria-label="图片历史"> <NScrollbar
x-scrollable
content-class="image-history-content"
class="image-history mt-5"
aria-label="图片历史"
>
<NButton <NButton
v-for="image in images" v-for="image in images"
:key="image.id" :key="image.id"
@@ -185,7 +190,7 @@ async function choosePrimary() {
] ]
}}</span></NButton }}</span></NButton
> >
</div> </NScrollbar>
</div> </div>
<p v-else class="py-10 text-center text-sm text-muted"> <p v-else class="py-10 text-center text-sm text-muted">
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }} {{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
+28 -24
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { NEmpty, NTimeline, NTimelineItem } from 'naive-ui' import { NScrollbar, NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
import { Clock3 } from '@lucide/vue' import { Clock3 } from '@lucide/vue'
import { formatDate, nodeLabel } from '../../lib/format' import { formatDate, nodeLabel } from '../../lib/format'
import { workflowCheckpoints } from './selectors' import { workflowCheckpoints } from './selectors'
@@ -13,29 +13,33 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
<template> <template>
<aside class="history-panel"> <aside class="history-panel">
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3> <NScrollbar class="panel-scroll" content-class="history-content">
<p class="mb-6 text-xs leading-5 text-muted">最近 18 checkpoint · 自动刷新</p> <h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
<NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" /> <p class="mb-6 text-xs leading-5 text-muted">最近 18 checkpoint · 自动刷新</p>
<NTimeline v-else> <NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" />
<NTimelineItem <NTimeline v-else>
v-for="item in history" <NTimelineItem
:key="item.checkpointId" v-for="item in history"
:type="item.state.workflowExecution?.status === 'failed' ? 'error' : 'default'" :key="item.checkpointId"
> :type="item.state.workflowExecution?.status === 'failed' ? 'error' : 'default'"
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
<p class="mt-1 text-[11px] text-muted">
{{ formatDate(item.createdAt)
}}<span v-if="item.metadata?.nodeDurationMs">
· {{ (item.metadata.nodeDurationMs / 1000).toFixed(1) }}s</span
>
</p>
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
>运行失败</span
> >
</NTimelineItem> <p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
</NTimeline> <p class="mt-1 text-[11px] text-muted">
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted"> {{ formatDate(item.createdAt)
记录在节点或批次结束后更新长时间无新记录不一定意味着任务失败 }}<span v-if="item.metadata?.nodeDurationMs">
</p> · {{ (item.metadata.nodeDurationMs / 1000).toFixed(1) }}s</span
>
</p>
<span
v-if="item.state.workflowExecution?.status === 'failed'"
class="mt-1 block text-xs text-danger"
>运行失败</span
>
</NTimelineItem>
</NTimeline>
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
记录在节点或批次结束后更新长时间无新记录不一定意味着任务失败
</p>
</NScrollbar>
</aside> </aside>
</template> </template>
+40
View File
@@ -1,4 +1,5 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { NScrollbar } from 'naive-ui'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -142,6 +143,45 @@ afterEach(() => {
}) })
describe('内容级滚动下的生产反馈', () => { describe('内容级滚动下的生产反馈', () => {
it('生产目录和资产详情由两个独立 NScrollbar 接管,切镜头不会新增生产请求', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/storyboard-directions')) return jsonResponse(directionsResult(fixture.id))
if (path.includes('/readiness') || path.endsWith('/videos/status'))
return jsonResponse({
total: 2,
ready: 0,
skipped: 0,
blocked: 0,
completed: 0,
queued: 0,
pending: 0,
running: 0,
failed: 0,
cancelled: 0,
notStarted: 2,
items: []
})
return jsonResponse([])
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ProductionPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
const directory = wrapper.get('.production-shot-list')
const detail = wrapper.get('.production-detail')
expect(directory.getComponent(NScrollbar).props('xScrollable')).toBe(true)
expect(detail.get('.panel-scroll').classes()).toContain('n-scrollbar')
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
const items = directory.findAll('.production-shot-link')
expect(items).toHaveLength(2)
await items[1]!.trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-current')).toBe('true')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('默认折叠总览时查询错误仍显示在内容区外层', async () => { it('默认折叠总览时查询错误仍显示在内容区外层', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断'))) vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断')))
wrapper = mount(ProductionPage, { wrapper = mount(ProductionPage, {
+8 -15
View File
@@ -70,7 +70,7 @@
} }
.identity-subject-list { .identity-subject-list {
max-height: 560px; max-height: 560px;
overflow-y: auto; overflow: hidden;
} }
.identity-subject-item { .identity-subject-item {
display: block; display: block;
@@ -248,7 +248,7 @@
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.table-scroll { .table-scroll {
overflow-x: auto; overflow: hidden;
} }
.project-table { .project-table {
width: 100%; width: 100%;
@@ -336,7 +336,7 @@
} }
.production-shot-list { .production-shot-list {
max-height: 760px; max-height: 760px;
overflow-y: auto; overflow: hidden;
border-right: 1px solid var(--color-line); border-right: 1px solid var(--color-line);
background: var(--app-surface); background: var(--app-surface);
padding-block: 8px; padding-block: 8px;
@@ -413,7 +413,7 @@
.production-shot-list { .production-shot-list {
display: flex; display: flex;
max-height: none; max-height: none;
overflow-x: auto; overflow: hidden;
border-right: none; border-right: none;
border-bottom: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line);
} }
@@ -565,10 +565,7 @@
height: min(48vh, 480px); height: min(48vh, 480px);
} }
.image-history { .image-history {
display: flex; overflow: hidden;
gap: 12px;
overflow-x: auto;
padding: 4px;
} }
.image-history-item { .image-history-item {
width: 110px; width: 110px;
@@ -615,7 +612,7 @@
} }
.shot-list { .shot-list {
max-height: 1000px; max-height: 1000px;
overflow-y: auto; overflow: hidden;
border-right: 1px solid var(--color-line); border-right: 1px solid var(--color-line);
background: var(--app-surface); background: var(--app-surface);
padding: 0 8px 20px; padding: 0 8px 20px;
@@ -661,8 +658,6 @@
background: var(--app-subtle); background: var(--app-subtle);
} }
.storyboard-json { .storyboard-json {
overflow: auto;
max-height: 440px;
padding: 16px; padding: 16px;
border-radius: 4px; border-radius: 4px;
background: var(--app-subtle); background: var(--app-subtle);
@@ -680,7 +675,7 @@
padding: 0 7px 20px; padding: 0 7px 20px;
background: var(--app-surface); background: var(--app-surface);
max-height: 850px; max-height: 850px;
overflow-y: auto; overflow: hidden;
} }
.episode-link { .episode-link {
display: flex; display: flex;
@@ -757,8 +752,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.json-view { .json-view {
max-height: 420px;
overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
padding: 16px; padding: 16px;
@@ -930,7 +923,7 @@
} }
.episode-list { .episode-list {
display: flex; display: flex;
overflow-x: auto; overflow: hidden;
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line);
padding: 8px; padding: 8px;