refactor: 精简导航并扩大工作区内容显示面积
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import {
|
||||
NScrollbar,
|
||||
NCollapse,
|
||||
@@ -104,63 +105,45 @@ function refreshProject() {
|
||||
void query.refresh()
|
||||
}
|
||||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||
const overview = ref<string[]>([])
|
||||
const toolsOpen = ref(false)
|
||||
const overview = ref<string[]>(['overview'])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['overview']
|
||||
},
|
||||
{ immediate: true }
|
||||
if (receipt) {
|
||||
overview.value = ['overview']
|
||||
toolsOpen.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage split
|
||||
<WorkspacePage split compact
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧,到异步视频任务与最终成片。</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<div class="production-controls">
|
||||
<label>
|
||||
<span class="field-label">当前剧集</span>
|
||||
<NSelect
|
||||
:value="episodeNo"
|
||||
aria-label="选择生产剧集"
|
||||
@update:value="selectedEpisode = Number($event)"
|
||||
:options="[
|
||||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||
...episodeOptions.map(episode => ({
|
||||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||
value: episode.episodeNo
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>覆盖模式:为已有结果新增候选
|
||||
</NCheckbox>
|
||||
><div class="workspace-toolbar">
|
||||
<label class="toolbar-episode"
|
||||
><span>剧集</span
|
||||
><NSelect
|
||||
:value="episodeNo"
|
||||
aria-label="选择生产剧集"
|
||||
@update:value="selectedEpisode = Number($event)"
|
||||
:options="[
|
||||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||
...episodeOptions.map(episode => ({
|
||||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||
value: episode.episodeNo
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<span class="toolbar-summary"
|
||||
>本集 {{ shots.length }} 镜<span v-if="videoRunning">
|
||||
· {{ videoRunning }} 个视频任务进行中</span
|
||||
></span
|
||||
>
|
||||
<div class="toolbar-actions">
|
||||
<NButton
|
||||
:disabled="query.loading.value"
|
||||
@click="query.refresh"
|
||||
@@ -169,12 +152,243 @@ watch(
|
||||
class="mb-2 ml-auto"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
||||
</NButton>
|
||||
<WorkspaceTools
|
||||
v-model:open="toolsOpen"
|
||||
title="项目生产总览与批量操作"
|
||||
:has-receipt="!!session.receipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
从可生成的提示词和首帧,到异步视频任务与最终成片。
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<div class="production-controls">
|
||||
<label>
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>覆盖模式:为已有结果新增候选
|
||||
</NCheckbox>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||||
与 Seedance 均可能产生费用。
|
||||
</p>
|
||||
</section>
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="videoRunning"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||||
<RouterLink
|
||||
:to="`/projects/${id}/subject-identity`"
|
||||
class="text-button ml-2"
|
||||
>前往角色选角 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<NAlert
|
||||
v-if="staleKeyframes"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleKeyframes }}
|
||||
个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐
|
||||
/ 更新主首帧”批量处理。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="staleVideos"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleVideos }}
|
||||
个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||||
</NAlert>
|
||||
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
<div v-if="query.data.value" class="production-pipeline mt-5">
|
||||
<article
|
||||
v-for="stage in stages"
|
||||
:key="stage.key"
|
||||
class="panel production-pipeline-card"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"
|
||||
><component :is="stage.icon" :size="17"
|
||||
/></span>
|
||||
<div>
|
||||
<p class="eyebrow">STEP {{ stage.code }}</p>
|
||||
<h3 class="mt-2 text-sm font-semibold">
|
||||
{{ stage.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
|
||||
{{ stage.description }}
|
||||
</p>
|
||||
<NProgress
|
||||
:aria-label="`${stage.title}完成数量`"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(stage.done /
|
||||
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
|
||||
100
|
||||
)
|
||||
)
|
||||
"
|
||||
class="mt-4"
|
||||
></NProgress>
|
||||
<dl class="production-stats mt-4">
|
||||
<div>
|
||||
<dt>可执行</dt>
|
||||
<dd>{{ stage.data?.ready ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>已有结果</dt>
|
||||
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>阻塞</dt>
|
||||
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && staleKeyframes"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'videos' && staleVideos"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||
class="mt-3 text-[11px] leading-5 text-danger"
|
||||
>
|
||||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||
母版缺失
|
||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
||||
未锁定
|
||||
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="stage.action"
|
||||
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos && !force
|
||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||
: stage.key === 'keyframes'
|
||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
状态统计取每个镜头最近一次任务,不等同于主视频数量。
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="
|
||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
||||
"
|
||||
acknowledgement
|
||||
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
</div>
|
||||
<dl class="production-status-grid mt-4">
|
||||
<div>
|
||||
<dt>完成</dt>
|
||||
<dd>{{ query.data.value.videoStatus.completed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>排队/生成</dt>
|
||||
<dd>{{ videoRunning }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>失败</dt>
|
||||
<dd>{{ query.data.value.videoStatus.failed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>取消</dt>
|
||||
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>未开始</dt>
|
||||
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
</WorkspaceTools>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||||
与 Seedance 均可能产生费用。
|
||||
</p>
|
||||
</section></template
|
||||
</div></template
|
||||
>
|
||||
<NScrollbar v-if="query.error.value" class="workspace-feedback"
|
||||
><NAlert role="alert" type="error" :show-icon="false"
|
||||
@@ -182,166 +396,9 @@ watch(
|
||||
}}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span></NAlert
|
||||
></NScrollbar
|
||||
>
|
||||
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
|
||||
><NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||
</NAlert>
|
||||
<NAlert v-if="videoRunning" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||
>前往角色选角 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<NAlert v-if="staleKeyframes" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
当前有
|
||||
{{ staleKeyframes }}
|
||||
个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐
|
||||
/ 更新主首帧”批量处理。
|
||||
</NAlert>
|
||||
<NAlert v-if="staleVideos" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
当前有
|
||||
{{ staleVideos }}
|
||||
个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||||
</NAlert>
|
||||
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
<div v-if="query.data.value" class="production-pipeline mt-5">
|
||||
<article v-for="stage in stages" :key="stage.key" class="panel production-pipeline-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"><component :is="stage.icon" :size="17" /></span>
|
||||
<div>
|
||||
<p class="eyebrow">STEP {{ stage.code }}</p>
|
||||
<h3 class="mt-2 text-sm font-semibold">{{ stage.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
|
||||
<NProgress
|
||||
:aria-label="`${stage.title}完成数量`"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
|
||||
)
|
||||
)
|
||||
"
|
||||
class="mt-4"
|
||||
></NProgress>
|
||||
<dl class="production-stats mt-4">
|
||||
<div>
|
||||
<dt>可执行</dt>
|
||||
<dd>{{ stage.data?.ready ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>已有结果</dt>
|
||||
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>阻塞</dt>
|
||||
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && staleKeyframes"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
|
||||
</p>
|
||||
<p v-if="stage.key === 'videos' && staleVideos" class="mt-3 text-[11px] leading-5">
|
||||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||
class="mt-3 text-[11px] leading-5 text-danger"
|
||||
>
|
||||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失
|
||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定
|
||||
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="stage.action"
|
||||
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos && !force
|
||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||
: stage.key === 'keyframes'
|
||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
状态统计取每个镜头最近一次任务,不等同于主视频数量。
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
|
||||
acknowledgement
|
||||
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
</div>
|
||||
<dl class="production-status-grid mt-4">
|
||||
<div>
|
||||
<dt>完成</dt>
|
||||
<dd>{{ query.data.value.videoStatus.completed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>排队/生成</dt>
|
||||
<dd>{{ videoRunning }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>失败</dt>
|
||||
<dd>{{ query.data.value.videoStatus.failed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>取消</dt>
|
||||
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>未开始</dt>
|
||||
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
></NScrollbar
|
||||
>
|
||||
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 逐镜生产</h3>
|
||||
<p class="mt-2 text-[11px] text-muted">选择镜头查看候选首帧、视频任务、成片和实际生成规格。</p>
|
||||
</div>
|
||||
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
|
||||
<div v-if="staleKeyframes || staleVideos || identityBlocked" class="workspace-inline-status">
|
||||
<span>过期首帧 {{ staleKeyframes }} · 过期视频 {{ staleVideos }} · 身份前置问题 {{ identityBlocked }}</span>
|
||||
<NButton text size="small" @click="toolsOpen = true">查看处理方式</NButton>
|
||||
</div>
|
||||
<div v-if="shot" class="panel production-workspace">
|
||||
<nav class="production-shot-list directory-panel" aria-label="本集生产镜头">
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
|
||||
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NEllipsis } from 'naive-ui'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
|
||||
/** 项目共享查询保持不变,标题与导航固定,子工作区负责内容滚动。 */
|
||||
/** 项目仅保留紧凑标题与刷新;工作流统一由左侧导航切换,子工作区负责内容滚动。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = computed(() => String(route.params.projectId))
|
||||
const context = useProjectData(id)
|
||||
provide(projectContextKey, context)
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const tabs = [
|
||||
['create-drama', '剧本创作'],
|
||||
['breakdown', '剧本拆解'],
|
||||
['visual-style', '视觉风格'],
|
||||
['subject-identity', '主体身份'],
|
||||
['subject-images', '形态图片'],
|
||||
['storyboard', '分镜设计'],
|
||||
['production', '镜头生产']
|
||||
] as const
|
||||
/** 路由控制当前工作流,不额外缓存一份可能失步的标签状态。 */
|
||||
function switchWorkflow(path: string) {
|
||||
void router.push('/projects/' + id.value + '/' + path)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="project-frame">
|
||||
@@ -48,16 +35,6 @@ function switchWorkflow(path: string) {
|
||||
</div></template
|
||||
>
|
||||
</NPageHeader>
|
||||
<NTabs
|
||||
:value="String(route.path.split('/').at(-1))"
|
||||
type="line"
|
||||
size="small"
|
||||
class="project-tabs"
|
||||
aria-label="项目工作流"
|
||||
@update:value="switchWorkflow"
|
||||
>
|
||||
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
|
||||
</NTabs>
|
||||
</header>
|
||||
<NScrollbar
|
||||
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import {
|
||||
NScrollbar,
|
||||
NAlert,
|
||||
@@ -81,174 +82,235 @@ function exportEpisode() {
|
||||
)
|
||||
}
|
||||
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
|
||||
const overview = ref<string[]>([])
|
||||
const toolsOpen = ref(false)
|
||||
const overview = ref<string[]>(['generation'])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['generation']
|
||||
},
|
||||
{ immediate: true }
|
||||
if (receipt) {
|
||||
overview.value = ['generation']
|
||||
toolsOpen.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage split class="storyboard-workspace-page"
|
||||
<WorkspacePage split compact class="storyboard-workspace-page"
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">分镜设计</h2>
|
||||
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计,再补齐每一镜的即时状态。</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
||||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||||
>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="分镜生成配置">
|
||||
<div class="storyboard-controls">
|
||||
<label
|
||||
><span class="field-label">当前剧集</span
|
||||
><NSelect
|
||||
:value="episodeNo"
|
||||
aria-label="选择分镜剧集"
|
||||
@update:value="selectedEpisode = Number($event)"
|
||||
:options="[
|
||||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||
...episodeOptions.map(episode => ({
|
||||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||
value: episode.episodeNo
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">批量并发</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
|
||||
>
|
||||
<label
|
||||
><span class="field-label">状态自动修复次数</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-repairs' }"
|
||||
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||||
@update:value="maxRepairAttempts = $event ?? 0"
|
||||
:min="0"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入非负整数,0 表示不修复</span
|
||||
></label
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>批量覆盖已有结果</NCheckbox
|
||||
><div class="workspace-toolbar">
|
||||
<label class="toolbar-episode"
|
||||
><span>剧集</span
|
||||
><NSelect
|
||||
:value="episodeNo"
|
||||
aria-label="选择分镜剧集"
|
||||
@update:value="selectedEpisode = Number($event)"
|
||||
:options="[
|
||||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||
...episodeOptions.map(episode => ({
|
||||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||
value: episode.episodeNo
|
||||
}))
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<div class="toolbar-metrics">
|
||||
<span v-for="item in coverage" :key="item.title"
|
||||
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
|
||||
>
|
||||
</div>
|
||||
</section></template
|
||||
<div class="toolbar-actions">
|
||||
<div class="flex gap-3">
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
|
||||
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
|
||||
><Download :size="13" />导出本集
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<WorkspaceTools
|
||||
v-model:open="toolsOpen"
|
||||
title="分镜生成配置与回执"
|
||||
:has-receipt="!!session.receipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">分镜设计</h2>
|
||||
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计,再补齐每一镜的即时状态。</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
||||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||||
>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="分镜生成配置">
|
||||
<div class="storyboard-controls">
|
||||
<label
|
||||
><span class="field-label">批量并发</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入正整数</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span class="field-label">状态自动修复次数</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-repairs' }"
|
||||
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||||
@update:value="maxRepairAttempts = $event ?? 0"
|
||||
:min="0"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入非负整数,0 表示不修复</span
|
||||
></label
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-3 text-xs"
|
||||
>批量覆盖已有结果</NCheckbox
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<div class="storyboard-coverage grid grid-cols-2 gap-3">
|
||||
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
|
||||
<div class="mb-3 flex items-center justify-between text-xs">
|
||||
<span>{{ item.title }}</span
|
||||
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
||||
</div>
|
||||
<NProgress
|
||||
:aria-label="item.title + '已保存覆盖率'"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100)
|
||||
)
|
||||
"
|
||||
class=""
|
||||
></NProgress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
|
||||
><div class="overview-content">
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">01</span
|
||||
>导演摄影设计
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||||
:disabled="!allowed['direction-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
||||
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
||||
"
|
||||
@confirm="run('direction-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">02</span
|
||||
>即时视觉状态
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||||
:disabled="!allowed['visual-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
||||
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
||||
"
|
||||
@confirm="run('visual-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-[11px] leading-6 text-muted">
|
||||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||||
VisualState。
|
||||
</p>
|
||||
<NCollapse class="mt-3 border-t border-line pt-3"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>视频提示词批量操作</template>
|
||||
<p class="my-3 text-xs leading-6 text-muted">
|
||||
后端会先校验 GenerationSpec
|
||||
与主体参考图。这里只生成提示词;首帧、视频任务、就绪诊断与失败重试已整理到
|
||||
<RouterLink
|
||||
:to="`/projects/${id}/production`"
|
||||
class="text-button mx-1"
|
||||
>镜头生产</RouterLink
|
||||
>。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
||||
:disabled="!allowed.prompts"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
||||
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
||||
"
|
||||
@confirm="run('prompts')" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<GenerationReceipt
|
||||
v-if="session.receipt"
|
||||
:receipt="session.receipt"
|
||||
/></div></NCollapseItem
|
||||
></NCollapse>
|
||||
</div>
|
||||
</WorkspaceTools>
|
||||
</div></div
|
||||
></template>
|
||||
<NScrollbar
|
||||
v-if="
|
||||
query.error.value ||
|
||||
!prerequisites.directionEpisode ||
|
||||
!prerequisites.visualEpisode ||
|
||||
(data && !directionComplete && data.directions.shotCount)
|
||||
"
|
||||
class="workspace-feedback"
|
||||
>
|
||||
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
|
||||
><NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
|
||||
><div class="overview-content">
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||||
:disabled="!allowed['direction-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
||||
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
||||
"
|
||||
@confirm="run('direction-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||||
:disabled="!allowed['visual-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
||||
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
||||
"
|
||||
@confirm="run('visual-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-[11px] leading-6 text-muted">
|
||||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||||
VisualState。
|
||||
</p>
|
||||
<NCollapse class="mt-3 border-t border-line pt-3"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>视频提示词批量操作</template>
|
||||
<p class="my-3 text-xs leading-6 text-muted">
|
||||
后端会先校验 GenerationSpec
|
||||
与主体参考图。这里只生成提示词;首帧、视频任务、就绪诊断与失败重试已整理到
|
||||
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1"
|
||||
>镜头生产</RouterLink
|
||||
>。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
||||
:disabled="!allowed.prompts"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
||||
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
||||
"
|
||||
@confirm="run('prompts')" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<GenerationReceipt
|
||||
v-if="session.receipt"
|
||||
:receipt="session.receipt"
|
||||
/></div></NCollapseItem></NCollapse
|
||||
></NScrollbar>
|
||||
<NScrollbar class="workspace-feedback">
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<NButton
|
||||
@click="query.refresh"
|
||||
@@ -275,37 +337,6 @@ watch(
|
||||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||||
</NAlert>
|
||||
</NScrollbar>
|
||||
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 镜头检查</h3>
|
||||
<p class="mt-2 text-[11px] text-muted">
|
||||
当前剧集数据库结果每 6 秒刷新。覆盖率不是批量任务进度;批量诊断在请求返回后显示。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
|
||||
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
|
||||
><Download :size="13" />导出本集
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="storyboard-coverage grid grid-cols-2 gap-3">
|
||||
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
|
||||
<div class="mb-3 flex items-center justify-between text-xs">
|
||||
<span>{{ item.title }}</span
|
||||
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
||||
</div>
|
||||
<NProgress
|
||||
:aria-label="item.title + '已保存覆盖率'"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="Math.min(100, Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100))"
|
||||
class=""
|
||||
></NProgress>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
||||
正在读取正式分镜数据……
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import {
|
||||
NScrollbar,
|
||||
NAlert,
|
||||
@@ -129,27 +130,265 @@ function chooseAnchor(imageId: string) {
|
||||
else void setAnchor(imageId)
|
||||
}
|
||||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||
const overview = ref<string[]>([])
|
||||
const toolsOpen = ref(false)
|
||||
const overview = ref<string[]>(['casting'])
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) overview.value = ['casting']
|
||||
},
|
||||
{ immediate: true }
|
||||
if (receipt) {
|
||||
overview.value = ['casting']
|
||||
toolsOpen.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage split
|
||||
<WorkspacePage split compact
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
<p class="mt-2 text-sm text-muted">为同一个人物、场景或道具固定稳定特征,再生成不同造型。</p>
|
||||
><div class="workspace-toolbar">
|
||||
<div class="toolbar-type-filter">
|
||||
<NSelect
|
||||
aria-label="身份主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
></NSelect>
|
||||
</div>
|
||||
<span class="toolbar-summary">共 {{ subjects.length }} 个主体</span>
|
||||
<div class="toolbar-actions">
|
||||
<WorkspaceTools
|
||||
v-model:open="toolsOpen"
|
||||
title="角色选角总览与批量操作"
|
||||
:has-receipt="!!session.receipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
为同一个人物、场景或道具固定稳定特征,再生成不同造型。
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
||||
>下一步:形态图片 →</RouterLink
|
||||
>
|
||||
</div>
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="casting" title="角色选角总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
||||
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
||||
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="styleQuery.data.value && !hasStyle"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||
:to="`/projects/${projectId}/visual-style`"
|
||||
class="text-button ml-2"
|
||||
>设置视觉风格 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"><UserCheck :size="17" /></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">角色选角</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
只处理 Character。每张候选都从 Identity 独立生成,不复用旧
|
||||
Anchor;确认选择时才会同时切换 Anchor 并锁定 Identity。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ casting.ready }} / {{ casting.total }} 已完成</span
|
||||
>
|
||||
</div>
|
||||
<dl class="casting-stats mt-4">
|
||||
<div>
|
||||
<dt>缺身份文本</dt>
|
||||
<dd>{{ casting.missingIdentity }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缺候选图</dt>
|
||||
<dd>{{ casting.missingAnchor }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>待确认演员</dt>
|
||||
<dd>{{ casting.candidatePending }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>母版未锁定</dt>
|
||||
<dd>{{ casting.unlocked }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>选角完成</dt>
|
||||
<dd>{{ casting.ready }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">并发数</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<label class="w-28">
|
||||
<span class="field-label">本批上限</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="
|
||||
typeof candidateLimit === 'number' ? candidateLimit : null
|
||||
"
|
||||
@update:value="candidateLimit = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex gap-2 pb-2 text-xs"
|
||||
>覆盖未锁定身份文本
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
||||
:disabled="
|
||||
blocked || !hasStyle || !batchValid || dirty || !casting.total
|
||||
"
|
||||
acknowledgement
|
||||
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
||||
@confirm="generateProject(true)"
|
||||
/>
|
||||
<ConfirmAction
|
||||
label="生成选角候选"
|
||||
:disabled="
|
||||
castingBlocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
!casting.missingAnchor
|
||||
"
|
||||
acknowledgement
|
||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="!batchValid || !candidateLimitValid"
|
||||
class="mt-3 text-xs text-danger"
|
||||
>
|
||||
并发数与本批上限都必须是正整数。
|
||||
</p>
|
||||
<div class="casting-list mt-5">
|
||||
<NButton
|
||||
v-for="item in casting.items"
|
||||
:key="item.subjectId"
|
||||
@click="select(item.subjectId)"
|
||||
class="casting-item"
|
||||
:class="{ selected: selectedId === item.subjectId }"
|
||||
><span class="min-w-0">
|
||||
<span class="block truncate text-xs font-medium">{{
|
||||
item.subjectName
|
||||
}}</span>
|
||||
<span class="mt-1 block font-mono text-[10px] text-muted">{{
|
||||
item.subjectRef
|
||||
}}</span> </span
|
||||
><StatusBadge
|
||||
:status="item.status"
|
||||
:label="castingStatusLabel(item.status)"
|
||||
/></NButton>
|
||||
</div>
|
||||
</section>
|
||||
<NCollapse class="panel mb-5 p-5"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>高级:全部主体身份文本</template>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
同时处理 Character、Scene 和
|
||||
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||
:disabled="
|
||||
blocked || !hasStyle || !batchValid || dirty || !subjects.length
|
||||
"
|
||||
acknowledgement
|
||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||
@confirm="generateProject(false)" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<section
|
||||
v-if="session.receipt"
|
||||
class="panel mb-5 p-5"
|
||||
aria-label="身份文本批量回执"
|
||||
>
|
||||
<div class="flex justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ session.receipt.title }} · 本次回执
|
||||
</h3>
|
||||
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
|
||||
</div>
|
||||
<p
|
||||
v-if="session.receipt.kind === 'identities'"
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
共 {{ session.receipt.result.total }} 个主体 · 目标
|
||||
{{ session.receipt.result.targetCount }} · 生成
|
||||
{{ session.receipt.result.generated }} · 跳过
|
||||
{{ session.receipt.result.skipped }}(含锁定
|
||||
{{ session.receipt.result.skippedLocked }})· 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||
{{ session.receipt.result.missingAnchor }} · 本批目标
|
||||
{{ session.receipt.result.targetCount }} · 已生成
|
||||
{{ session.receipt.result.generated }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="session.receipt.result.failed"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li
|
||||
v-for="failure in session.receipt.result.failures"
|
||||
:key="failure.subjectId"
|
||||
>
|
||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{
|
||||
failure.error
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">
|
||||
回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。
|
||||
</p>
|
||||
</section>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
</WorkspaceTools>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
||||
>下一步:形态图片 →</RouterLink
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
<NScrollbar
|
||||
@@ -160,181 +399,6 @@ watch(
|
||||
当前相关操作已暂停,请刷新核对后端。
|
||||
</NAlert></NScrollbar
|
||||
>
|
||||
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
|
||||
><NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="casting" title="角色选角总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
||||
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
||||
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
||||
</NAlert>
|
||||
<NAlert v-if="styleQuery.data.value && !hasStyle" type="info" :show-icon="false" class="mt-4">
|
||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||
:to="`/projects/${projectId}/visual-style`"
|
||||
class="text-button ml-2"
|
||||
>设置视觉风格 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"><UserCheck :size="17" /></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">角色选角</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
只处理 Character。每张候选都从 Identity 独立生成,不复用旧
|
||||
Anchor;确认选择时才会同时切换 Anchor 并锁定 Identity。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ casting.ready }} / {{ casting.total }} 已完成</span
|
||||
>
|
||||
</div>
|
||||
<dl class="casting-stats mt-4">
|
||||
<div>
|
||||
<dt>缺身份文本</dt>
|
||||
<dd>{{ casting.missingIdentity }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缺候选图</dt>
|
||||
<dd>{{ casting.missingAnchor }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>待确认演员</dt>
|
||||
<dd>{{ casting.candidatePending }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>母版未锁定</dt>
|
||||
<dd>{{ casting.unlocked }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>选角完成</dt>
|
||||
<dd>{{ casting.ready }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">并发数</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<label class="w-28">
|
||||
<span class="field-label">本批上限</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof candidateLimit === 'number' ? candidateLimit : null"
|
||||
@update:value="candidateLimit = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex gap-2 pb-2 text-xs"
|
||||
>覆盖未锁定身份文本
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
|
||||
acknowledgement
|
||||
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
||||
@confirm="generateProject(true)"
|
||||
/>
|
||||
<ConfirmAction
|
||||
label="生成选角候选"
|
||||
:disabled="
|
||||
castingBlocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
!casting.missingAnchor
|
||||
"
|
||||
acknowledgement
|
||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
|
||||
并发数与本批上限都必须是正整数。
|
||||
</p>
|
||||
<div class="casting-list mt-5">
|
||||
<NButton
|
||||
v-for="item in casting.items"
|
||||
:key="item.subjectId"
|
||||
@click="select(item.subjectId)"
|
||||
class="casting-item"
|
||||
:class="{ selected: selectedId === item.subjectId }"
|
||||
><span class="min-w-0">
|
||||
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
|
||||
<span class="mt-1 block font-mono text-[10px] text-muted">{{
|
||||
item.subjectRef
|
||||
}}</span> </span
|
||||
><StatusBadge :status="item.status" :label="castingStatusLabel(item.status)"
|
||||
/></NButton>
|
||||
</div>
|
||||
</section>
|
||||
<NCollapse class="panel mb-5 p-5"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>高级:全部主体身份文本</template>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
同时处理 Character、Scene 和
|
||||
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
||||
acknowledgement
|
||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||
@confirm="generateProject(false)" /></NCollapseItem
|
||||
></NCollapse>
|
||||
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
||||
<div class="flex justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
|
||||
</div>
|
||||
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个主体 · 目标
|
||||
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
|
||||
跳过 {{ session.receipt.result.skipped }}(含锁定
|
||||
{{ session.receipt.result.skippedLocked }})· 失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||
{{ session.receipt.result.missingAnchor }} · 本批目标
|
||||
{{ session.receipt.result.targetCount }} · 已生成
|
||||
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="session.receipt.result.failed"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
|
||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">
|
||||
回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。
|
||||
</p>
|
||||
</section>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
></NScrollbar
|
||||
>
|
||||
<div v-if="subjects.length" class="identity-workspace">
|
||||
<aside class="panel directory-panel identity-directory">
|
||||
<div class="directory-heading">
|
||||
@@ -360,16 +424,6 @@ watch(
|
||||
v-model:value="search"
|
||||
clearable
|
||||
></NInput>
|
||||
<NSelect
|
||||
aria-label="身份主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
></NSelect>
|
||||
</div>
|
||||
<NScrollbar
|
||||
class="identity-subject-list panel-scroll directory-scroll"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -31,6 +32,14 @@ const {
|
||||
generateProject,
|
||||
generateStale
|
||||
} = useSubjectImages()
|
||||
const toolsOpen = ref(false)
|
||||
/** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */
|
||||
watch(
|
||||
() => session.value.receipt,
|
||||
receipt => {
|
||||
if (receipt) toolsOpen.value = true
|
||||
}
|
||||
)
|
||||
const search = ref('')
|
||||
const module = ref('all')
|
||||
const onlyMissing = ref(false)
|
||||
@@ -89,154 +98,181 @@ function resetFilters() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage
|
||||
><template #header
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">形态图片</h2>
|
||||
<p class="mt-2 text-sm text-muted">为人物、场景和道具准备造型参考图。已有图片直接从数据库读取。</p>
|
||||
<WorkspacePage compact class="gallery-workspace-page"
|
||||
><template #header>
|
||||
<div class="workspace-toolbar">
|
||||
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
|
||||
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
|
||||
>{{ staleForms.length }} 个形态主图过期</NButton
|
||||
>
|
||||
<div class="toolbar-actions">
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton
|
||||
><WorkspaceTools
|
||||
v-model:open="toolsOpen"
|
||||
title="形态生图配置与回执"
|
||||
:has-receipt="!!session.receipt"
|
||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">形态图片</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
为人物、场景和道具准备造型参考图。已有图片直接从数据库读取。
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
|
||||
>去检查分镜参考图<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</div>
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 形态图会记录生成时使用的 Identity
|
||||
Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||
>管理主体身份 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<div class="panel mt-5 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm">
|
||||
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
|
||||
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
|
||||
{{ staleForms.length }} 个人物形态主图已过期
|
||||
</span>
|
||||
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||
每 6
|
||||
秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
||||
</p>
|
||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
||||
><div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个 Character Form
|
||||
仍在使用旧 Identity Anchor。
|
||||
</span>
|
||||
<ConfirmAction
|
||||
label="重新生成过期形态"
|
||||
:disabled="blocked || !batchValid || !staleForms.length"
|
||||
acknowledgement
|
||||
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||
@confirm="generateStale"
|
||||
/></div
|
||||
></NAlert>
|
||||
<NCollapse class="mt-4 border-t border-line pt-4"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>项目批量生图</template>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'image-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-2 text-xs"
|
||||
>已有主图也新增候选图
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
||||
</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<NButton @click="exportReceipt" text size="small"
|
||||
><Download :size="13" />导出诊断</NButton
|
||||
>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个形态 · 本次目标
|
||||
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
|
||||
跳过 {{ session.receipt.result.skipped }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="session.receipt.result.failed"
|
||||
role="alert"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="session.receipt.title === '刷新过期人物形态图'"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs"
|
||||
>
|
||||
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
|
||||
<code>{{ failure.subjectFormId }}</code
|
||||
>:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">
|
||||
回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。
|
||||
</p>
|
||||
</section>
|
||||
</WorkspaceTools>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
|
||||
>去检查分镜参考图<ArrowRight :size="14"
|
||||
/></RouterLink></div></template
|
||||
><NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
Character 形态图会记录生成时使用的 Identity
|
||||
Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||
>管理主体身份 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<div class="panel mt-5 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm">
|
||||
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
|
||||
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
|
||||
{{ staleForms.length }} 个人物形态主图已过期
|
||||
</span>
|
||||
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
||||
</p>
|
||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
|
||||
</NButton>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||
每 6
|
||||
秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
||||
</p>
|
||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
||||
><div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个 Character Form 仍在使用旧 Identity
|
||||
Anchor。
|
||||
</span>
|
||||
<ConfirmAction
|
||||
label="重新生成过期形态"
|
||||
:disabled="blocked || !batchValid || !staleForms.length"
|
||||
acknowledgement
|
||||
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||
@confirm="generateStale"
|
||||
/></div
|
||||
></NAlert>
|
||||
<NCollapse class="mt-4 border-t border-line pt-4"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>项目批量生图</template>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'image-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="flex items-center gap-2 pb-2 text-xs"
|
||||
>已有主图也新增候选图
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
<div class="form-image-filter-region">
|
||||
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
|
||||
<NInput
|
||||
placeholder="搜索主体、引用或形态"
|
||||
:input-props="{ 'aria-label': '搜索形态图片' }"
|
||||
v-model:value="search"
|
||||
clearable
|
||||
><template #prefix><Search :size="14" /></template
|
||||
></NInput>
|
||||
<NSelect
|
||||
aria-label="筛选主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
></NSelect>
|
||||
<div class="filter-toggles">
|
||||
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
|
||||
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
||||
</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span></NAlert
|
||||
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。
|
||||
</NAlert>
|
||||
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
|
||||
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
|
||||
{{ session.receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert v-if="session.receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="session.receipt.title === '刷新过期人物形态图'"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs"
|
||||
>
|
||||
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
|
||||
<code>{{ failure.subjectFormId }}</code
|
||||
>:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
||||
</section>
|
||||
<div class="form-image-filter-region">
|
||||
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
|
||||
<NInput
|
||||
placeholder="搜索主体、引用或形态"
|
||||
:input-props="{ 'aria-label': '搜索形态图片' }"
|
||||
v-model:value="search"
|
||||
clearable
|
||||
><template #prefix><Search :size="14" /></template
|
||||
></NInput>
|
||||
<NSelect
|
||||
aria-label="筛选主体类型"
|
||||
v-model:value="module"
|
||||
:options="[
|
||||
{ label: String('全部类型'), value: 'all' },
|
||||
{ label: String('人物'), value: 'character' },
|
||||
{ label: String('场景'), value: 'scene' },
|
||||
{ label: String('道具'), value: 'prop' }
|
||||
]"
|
||||
></NSelect>
|
||||
<div class="filter-toggles">
|
||||
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
|
||||
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
|
||||
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
|
||||
</div>
|
||||
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
|
||||
</div>
|
||||
</div>
|
||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span></NAlert
|
||||
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
|
||||
数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。
|
||||
</NAlert>
|
||||
</template>
|
||||
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
||||
正在读取形态和已有图片…
|
||||
</p>
|
||||
|
||||
@@ -30,26 +30,29 @@ function confirm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model:open="open" :title="label" :description="description">
|
||||
<template #trigger
|
||||
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
|
||||
label
|
||||
}}</NButton></template
|
||||
>
|
||||
<p class="mt-5 text-sm leading-6 text-muted">
|
||||
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
||||
</p>
|
||||
<NCheckbox
|
||||
v-model:checked="acknowledged"
|
||||
v-if="acknowledgement"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer mt-6">
|
||||
<NButton @click="open = false">取消</NButton
|
||||
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
|
||||
确认{{ label }}</NButton
|
||||
<span class="confirm-action">
|
||||
<!-- 稳定的根元素承接调用方间距;AppDialog 含多个根节点,不能替按钮接收 class。 -->
|
||||
<AppDialog v-model:open="open" :title="label" :description="description">
|
||||
<template #trigger
|
||||
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
|
||||
label
|
||||
}}</NButton></template
|
||||
>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<p class="mt-5 text-sm leading-6 text-muted">
|
||||
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
||||
</p>
|
||||
<NCheckbox
|
||||
v-model:checked="acknowledged"
|
||||
v-if="acknowledgement"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer mt-6">
|
||||
<NButton @click="open = false">取消</NButton
|
||||
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
|
||||
确认{{ label }}</NButton
|
||||
>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -94,7 +94,7 @@ function button(label: string): HTMLButtonElement {
|
||||
async function confirmGeneration(label: string) {
|
||||
button(label).click()
|
||||
await flushPromises()
|
||||
const acknowledgement = document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')
|
||||
const acknowledgement = document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')
|
||||
expect(button('确认' + label).disabled).toBe(true)
|
||||
acknowledgement!.click()
|
||||
await flushPromises()
|
||||
@@ -193,9 +193,10 @@ describe('内容级滚动下的生产反馈', () => {
|
||||
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
|
||||
const alert = wrapper.get('.workspace-split [role="alert"]')
|
||||
expect(alert.element.closest('.workspace-overview')).toBeNull()
|
||||
expect(alert.element.closest('.workspace-tools-drawer')).toBeNull()
|
||||
expect(alert.text()).toContain('无法连接后端')
|
||||
})
|
||||
|
||||
@@ -212,7 +213,8 @@ describe('内容级滚动下的生产反馈', () => {
|
||||
result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
|
||||
}
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
|
||||
expect(wrapper.find('.workspace-tools-drawer .n-collapse-item--active').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('供应商不可用')
|
||||
})
|
||||
})
|
||||
@@ -313,7 +315,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
await mountAssets('style')
|
||||
button('AI 重新生成风格').click()
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')!.click()
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
||||
button('取消').click()
|
||||
@@ -872,6 +874,10 @@ describe('工作台页面交互', () => {
|
||||
await flushPromises()
|
||||
await confirmGeneration('生成本集导演设计')
|
||||
expect(button('补齐项目导演设计').disabled).toBe(true)
|
||||
await wrapper!.get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
|
||||
expect(getOperation(fixture.id).pending).toBe(true)
|
||||
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
|
||||
Reference in New Issue
Block a user