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
+254 -232
View File
@@ -1,6 +1,16 @@
<script setup lang="ts">
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 { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui'
@@ -166,158 +176,165 @@ watch(
</p>
</section></template
>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
>{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
>
<NCollapse v-model:expanded-names="overview" class="workspace-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>
<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>
<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
<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">
"
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>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
<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>{{ stage.data?.skipped ?? 0 }}</dd>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
<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>
<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></NCollapseItem
></NCollapse
></NScrollbar
>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
@@ -328,103 +345,108 @@ watch(
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头">
<NButton
v-for="item in shots"
:key="item.shotId"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
@click="selectedShot = item.shotId"
class="production-shot-link"
:class="{ selected: item.shotId === shot.shotId }"
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
><span
v-if="
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
<NScrollbar class="panel-scroll" content-class="production-shot-list-content" x-scrollable>
<NButton
v-for="item in shots"
:key="item.shotId"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
@click="selectedShot = item.shotId"
class="production-shot-link"
:class="{ selected: item.shotId === shot.shotId }"
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
><span
v-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'skipped'
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
?.primaryKeyframeStale
"
:size="12"
/>
<CircleAlert
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)?.status ===
'blocked'
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.primaryVideoStale
"
:size="12"
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)?.status ||
'not_started'
)
}}
</span></NButton
>
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="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'skipped'
"
: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>
<article class="min-w-0 p-5 lg:p-7">
<header class="mb-5 border-b border-line pb-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
<article class="production-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-5 border-b border-line pb-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<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>
<StatusBadge
v-if="videoStatusItem"
:status="videoStatusItem.status"
:label="productionStatusLabel(videoStatusItem.status)"
/>
</div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert
v-if="keyframeItem?.primaryKeyframeStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
</NAlert>
<NAlert
v-if="videoItem?.primaryVideoStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</NAlert>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
</header>
<ShotProductionAssets
:key="shot.shotId"
:project-id="id"
:shot="shot"
:prompt-readiness="promptItem"
:keyframe-readiness="keyframeItem"
:video-readiness="videoItem"
:disabled="blocked"
@changed="refreshProject"
/>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert
v-if="keyframeItem?.primaryKeyframeStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
</NAlert>
<NAlert
v-if="videoItem?.primaryVideoStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</NAlert>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
</header>
<ShotProductionAssets
:key="shot.shotId"
:project-id="id"
:shot="shot"
:prompt-readiness="promptItem"
:keyframe-readiness="keyframeItem"
:video-readiness="videoItem"
:disabled="blocked"
@changed="refreshProject"
/>
</NScrollbar>
</article>
</div>
<EmptyState