refactor: 使用 Naive UI 重构后台布局与黑白双主题

This commit is contained in:
GouJ
2026-09-01 16:31:21 +08:00
parent 55516537bf
commit db8c176b55
51 changed files with 3277 additions and 2442 deletions
+270 -198
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { 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 { ProgressRoot, ProgressIndicator } from 'reka-ui'
import { EmptyState, StatusBadge } from '../../components/ui'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { issueLabel, productionStatusLabel } from './model'
@@ -31,7 +32,6 @@ const {
blocked,
run
} = useProduction()
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
const stages = computed(() => {
const data = query.data.value
@@ -87,186 +87,238 @@ const identityBlocked = computed(
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
(query.data.value?.keyframes.identityUnlocked ?? 0)
)
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
function refreshProject() {
void query.refresh()
}
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
const overview = ref<string[]>([])
watch(
() => session.value.receipt,
receipt => {
if (receipt) overview.value = ['overview']
},
{ immediate: true }
)
</script>
<template>
<section class="mt-7">
<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>
<p class="alert mt-5 text-xs">
每一步都按后端就绪检查执行覆盖只会新增首帧视频候选不会替换当前主资产上游参考资产变化后下游过期结果会被标记并阻止继续使用需要重新生成后才能恢复就绪
</p>
<section class="panel mt-5 p-5" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">当前剧集</span>
<select
:value="episodeNo"
class="input"
aria-label="选择生产剧集"
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
>
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
{{ episode.episodeNo }} · {{ episode.title }}
</option>
</select>
</label>
<label>
<span class="field-label">批量并发</span>
<input
v-model.number="concurrency"
class="input"
type="number"
min="1"
step="1"
:disabled="operation.pending"
/>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<label class="flex items-center gap-2 pb-3 text-xs">
<input
v-model="force"
type="checkbox"
class="accent-accent"
:disabled="operation.pending"
/>覆盖模式为已有结果新增候选
</label>
<button class="text-button mb-2 ml-auto" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
</button>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
批量操作始终面向整个项目不受当前剧集选择影响当前剧集只控制下方逐镜检查Seedream Seedance
均可能产生费用
</p>
</section>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span>
</p>
<p v-if="videoRunning" class="alert mt-4" role="status">
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 Provider同一镜头不会重复提交活动任务
</p>
<p v-if="identityBlocked" class="alert mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">前往角色选角 </RouterLink>
</p>
<p v-if="staleKeyframes" class="alert mt-4" role="status">
当前有 {{ staleKeyframes }} 个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐 / 更新主首帧批量处理
</p>
<p v-if="staleVideos" class="alert mt-4" role="status">
当前有 {{ staleVideos }} 个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</p>
<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>
<ProgressRoot
:model-value="stage.done"
:max="Math.max(stage.data?.total ?? 0, 1)"
:aria-label="`${stage.title}完成数量`"
class="progress-track mt-4"
>
<ProgressIndicator
class="progress-fill"
:style="{
width: `${stage.data?.total ? Math.min(100, (stage.done / stage.data.total) * 100) : 0}%`
}"
/>
</ProgressRoot>
<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
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 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">
<WorkspacePage split
><template #header
><div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">状态统计取每个镜头最近一次任务不等同于主视频数量</p>
<h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧到异步视频任务与最终成片</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
/>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</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>
<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>
<NButton
:disabled="query.loading.value"
@click="query.refresh"
text
size="small"
class="mb-2 ml-auto"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
</NButton>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式新增全项目候选Seedream
Seedance 均可能产生费用
</p>
</section></template
>
<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
>
<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>
</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 class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 逐镜生产</h3>
@@ -276,35 +328,43 @@ function refreshProject() {
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头">
<button
<NButton
v-for="item in shots"
:key="item.shotId"
class="production-shot-link"
:class="{ selected: item.shotId === shot.shotId }"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
@click="selectedShot = item.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="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"
<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">
<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'"
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'"
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'blocked'
"
:size="12"
/>
{{
@@ -313,8 +373,8 @@ function refreshProject() {
'not_started'
)
}}
</span>
</button>
</span></NButton
>
</nav>
<article class="min-w-0 p-5 lg:p-7">
<header class="mb-5 border-b border-line pb-5">
@@ -331,12 +391,24 @@ function refreshProject() {
</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>
<p v-if="keyframeItem?.primaryKeyframeStale" class="alert mt-4 text-xs" role="status">
<NAlert
v-if="keyframeItem?.primaryKeyframeStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
</p>
<p v-if="videoItem?.primaryVideoStale" class="alert mt-4 text-xs" role="status">
</NAlert>
<NAlert
v-if="videoItem?.primaryVideoStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</p>
</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 }}
@@ -364,6 +436,6 @@ function refreshProject() {
</EmptyState>
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
正在检查全项目生产就绪状态
</p>
</section>
</template>
</p></WorkspacePage
>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validOptionalSize } from '../model'
@@ -50,37 +51,43 @@ function submit() {
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
>
<p v-if="replacePrimary" class="alert mt-5 text-xs">
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</p>
</NAlert>
<div class="mt-5 grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度可选</span
><input v-model.number="width" class="input" type="number" min="1"
/></label>
><NInputNumber
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></label>
<label
><span class="field-label">高度可选</span
><input v-model.number="height" class="input" type="number" min="1"
/></label>
><NInputNumber
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></label>
</div>
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
<input v-model="setPrimary" class="mt-1 accent-accent" type="checkbox" />生成成功后设为当前主首帧
</label>
<p v-if="hasPrimary && setPrimary" class="alert mt-3 text-xs">
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
>生成成功后设为当前主首帧
</NCheckbox>
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</p>
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
<input
id="confirm-keyframe-cost"
v-model="confirmed"
class="mt-1 accent-accent"
type="checkbox"
/>我已确认调用 Seedream 可能产生费用且当前没有相同镜头的生图任务
</label>
</NAlert>
<NCheckbox
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用 Seedream 可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<button class="button button-secondary" @click="open = false">取消</button>
<button class="button button-primary" :disabled="!canSubmit" @click="submit">确认生成首帧</button>
<NButton @click="open = false">取消</NButton>
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
</div>
</AppDialog>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton } from 'naive-ui'
import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format'
import type { ProductionReceipt } from '../types'
@@ -16,7 +17,7 @@ function exportReceipt() {
<section class="panel mt-4 p-5" aria-label="镜头生产回执">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
</div>
<p v-if="receipt.kind === 'prompts'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已生成
@@ -36,12 +37,18 @@ function exportReceipt() {
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
{{ receipt.result.failed }}
</p>
<p v-if="receipt.kind === 'videos' && receipt.result.created" class="alert mt-3" role="status">
<NAlert
v-if="receipt.kind === 'videos' && receipt.result.created"
role="status"
type="info"
:show-icon="false"
class="mt-3"
>
视频任务已经创建但尚未代表成片完成后台会继续查询 Seedance以下方任务状态和视频资产为准
</p>
<p v-if="receipt.result.failed" class="alert alert-error mt-3" role="alert">
</NAlert>
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试
</p>
</NAlert>
<ul v-if="receipt.result.failures.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in receipt.result.failures" :key="failure.shotId">
<code>{{ failure.shotId }}</code
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
@@ -189,18 +190,18 @@ async function inspectSpecs() {
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败主资产决定下一阶段实际输入</p>
</div>
<div class="flex gap-3">
<button class="text-button" :disabled="inspecting" @click="inspectSpecs">
<FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}
</button>
<button class="text-button" :disabled="query.loading.value" @click="refreshAll">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
</button>
<NButton :disabled="inspecting" @click="inspectSpecs" text size="small"
><FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}</NButton
>
<NButton :disabled="query.loading.value" @click="refreshAll" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
</NButton>
</div>
</div>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }} 当前保留上次成功读取的资产操作已暂停
</p>
<p v-if="specError" class="alert alert-error mt-4" role="alert">{{ specError }}</p>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的资产操作已暂停
</NAlert>
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
<div class="production-stage mt-5">
<div class="production-stage-heading">
@@ -246,9 +247,9 @@ async function inspectSpecs() {
:label="keyframeStale ? '主首帧已过期' : productionStatusLabel(keyframeReadiness.status)"
/>
</div>
<p v-if="keyframeStale" class="alert mt-3 text-xs" role="status">
<NAlert v-if="keyframeStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
当前主首帧使用的主体参考资产已经变化旧图仍保留但不会继续进入视频生成请重新生成主首帧
</p>
</NAlert>
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
@@ -264,34 +265,44 @@ async function inspectSpecs() {
<div class="p-3">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<span v-if="item.isPrimary" class="tag">主首帧</span>
<span v-if="item.isPrimary && keyframeStale" class="tag">已过期</span>
<span class="text-[10px] text-muted">{{ item.width || '—' }} × {{ item.height || '—' }}</span>
<NTag v-if="item.isPrimary" size="small" :bordered="false">主首帧</NTag>
<NTag v-if="item.isPrimary && keyframeStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
>
</div>
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
<button
<NButton
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
class="text-button mt-2"
:disabled="assetBlocked"
@click="confirmingKeyframe = item.id"
text
size="small"
class="mt-2"
>
设为主首帧
</button>
<div v-if="confirmingKeyframe === item.id" class="alert mt-2 text-xs">
<p>确认让后续视频使用此首帧已有视频不会自动重生成</p>
</NButton>
<NAlert
v-if="confirmingKeyframe === item.id"
type="info"
:show-icon="false"
class="mt-2 text-xs"
><p>确认让后续视频使用此首帧已有视频不会自动重生成</p>
<div class="mt-2 flex gap-3">
<button class="button button-primary" @click="setPrimaryKeyframe">确认切换</button>
<button class="text-button" @click="confirmingKeyframe = ''">取消</button>
</div>
</div>
<NButton @click="setPrimaryKeyframe" type="primary">确认切换</NButton>
<NButton @click="confirmingKeyframe = ''" text size="small">取消</NButton>
</div></NAlert
>
</div>
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录</p>
<button class="button button-secondary mt-4" :disabled="!canGenerateKeyframe" @click="keyframeOpen = true">
<ImagePlus :size="14" />{{ keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧' }}
</button>
<NButton :disabled="!canGenerateKeyframe" @click="keyframeOpen = true" class="mt-4"
><ImagePlus :size="14" />{{
keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧'
}}</NButton
>
</div>
<div class="production-stage">
@@ -306,9 +317,9 @@ async function inspectSpecs() {
:label="videoStale ? '主视频已过期' : productionStatusLabel(videoReadiness.status)"
/>
</div>
<p v-if="videoStale" class="alert mt-3 text-xs" role="status">
<NAlert v-if="videoStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
当前主视频使用的主首帧或主体参考图已经变化旧视频仍保留重新生成完成后新视频会自动接替过期主视频
</p>
</NAlert>
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in videoReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
@@ -326,11 +337,15 @@ async function inspectSpecs() {
<div class="min-w-0 flex-1 p-4">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<span v-if="item.isPrimary" class="tag">主视频</span>
<span v-if="item.isPrimary && videoStale" class="tag">已过期</span>
<span class="text-[10px] text-muted">{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span>
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
>
</div>
<p v-if="item.error" class="alert alert-error mt-3" role="alert">{{ item.error }}</p>
<NAlert v-if="item.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
item.error
}}</NAlert>
<p class="mt-3 break-all text-[10px] text-muted">
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
</p>
@@ -341,24 +356,25 @@ async function inspectSpecs() {
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开视频<ExternalLink :size="12" /></a
>
<button
>打开视频<ExternalLink :size="12"
/></a>
<NButton
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
class="text-button"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
text
size="small"
>
设为主视频
</button>
</NButton>
</div>
<div v-if="confirmingVideo === item.id" class="alert mt-3 text-xs">
<p>确认把此候选设为当前镜头主视频旧成片仍会保留</p>
<NAlert v-if="confirmingVideo === item.id" type="info" :show-icon="false" class="mt-3 text-xs"
><p>确认把此候选设为当前镜头主视频旧成片仍会保留</p>
<div class="mt-2 flex gap-3">
<button class="button button-primary" @click="setPrimaryVideo">确认切换</button>
<button class="text-button" @click="confirmingVideo = ''">取消</button>
</div>
</div>
<NButton @click="setPrimaryVideo" type="primary">确认切换</NButton>
<NButton @click="confirmingVideo = ''" text size="small">取消</NButton>
</div></NAlert
>
</div>
</article>
</div>
@@ -378,46 +394,43 @@ async function inspectSpecs() {
primary
@confirm="generateVideo"
/>
<button
v-if="activeVideo"
class="button button-secondary"
:disabled="assetBlocked"
@click="refreshActiveVideo"
>
<RefreshCw :size="14" />刷新任务状态
</button>
<NButton v-if="activeVideo" :disabled="assetBlocked" @click="refreshActiveVideo"
><RefreshCw :size="14" />刷新任务状态
</NButton>
</div>
</div>
<details v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs">
<summary class="cursor-pointer font-medium">本镜确定性生成规格</summary>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
</p>
</div>
</div>
</details>
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs"
><NCollapseItem name="details"
><template #header>本镜确定性生成规格</template>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
</p>
</div>
</div></NCollapseItem
></NCollapse
>
<KeyframeDialog
v-model:open="keyframeOpen"
:shot-id="shot.shotId"
@@ -428,4 +441,4 @@ async function inspectSpecs() {
@generate="generateKeyframe"
/>
</section>
</template>
</template>
+1 -1
View File
@@ -21,7 +21,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
/** 从 Reka Portal 中查找精确按钮。 */
/** 从 Naive 弹窗中查找精确按钮。 */
function button(label: string): HTMLButtonElement {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)