399 lines
22 KiB
Vue
399 lines
22 KiB
Vue
<script setup lang="ts">
|
||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||
import { NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
|
||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||
import { useProjectContext } from '../projects/context'
|
||
import { breakdownApi } from './api'
|
||
import type { BreakdownAction, BreakdownInput, BreakdownModule, BreakdownPreview } from './types'
|
||
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from '../workflows/selectors'
|
||
import { getOperation, runOperation } from '../workflows/operations'
|
||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
||
import SubjectList from './components/SubjectList.vue'
|
||
import StoryboardList from './components/StoryboardList.vue'
|
||
import { errorMessage } from '../../lib/http'
|
||
import { downloadText, nodeLabel } from '../../lib/format'
|
||
|
||
/** 拆解工作区:先预览真实分组,再启动;恢复操作根据 checkpoint 分阶段展示。 */
|
||
const { project, checkpoints, refresh, error } = useProjectContext()
|
||
const groupSize = ref(3)
|
||
const modules = ref<BreakdownModule[]>(['character', 'scene', 'prop'])
|
||
const preview = ref<BreakdownPreview | null>(null)
|
||
const previewBusy = ref(false)
|
||
const previewError = ref('')
|
||
const tab = ref('character')
|
||
const moduleOptions: { value: BreakdownModule; label: string; description: string }[] = [
|
||
{ value: 'character', label: '人物', description: '人物身份、外观与形态' },
|
||
{ value: 'scene', label: '场景', description: '故事空间与环境特征' },
|
||
{ value: 'prop', label: '道具', description: '关键物件与视觉描述' }
|
||
]
|
||
const records = computed(() => workflowCheckpoints(checkpoints.value, 'breakdown'))
|
||
const snapshot = computed(() => breakdownSnapshot(checkpoints.value))
|
||
const result = computed(() => snapshot.value?.breakdownResult ?? snapshot.value)
|
||
const subjects = computed(() => result.value?.subjectCandidates ?? [])
|
||
const forms = computed(() => result.value?.subjectForms ?? [])
|
||
const plans = computed(() => result.value?.storyboardPlans ?? [])
|
||
const shots = computed(() => result.value?.storyboardEpisodeShots ?? [])
|
||
const summary = computed(() => snapshot.value?.taskSummary)
|
||
const validation = computed(() => result.value?.storyboardShotValidation ?? snapshot.value?.storyboardShotValidation)
|
||
const operation = computed(() => getOperation(project.value!.id))
|
||
const recovery = computed(() => recoveryOptions(checkpoints.value))
|
||
const execution = computed(() => snapshot.value?.workflowExecution)
|
||
const taskStopped = computed(() => execution.value?.status === 'failed' || execution.value?.status === 'completed')
|
||
const showRecovery = computed(
|
||
() => records.value.length > 0 && (execution.value?.status !== 'completed' || validation.value?.valid === false)
|
||
)
|
||
const configValid = computed(
|
||
() => Number.isSafeInteger(groupSize.value) && groupSize.value > 0 && modules.value.length > 0
|
||
)
|
||
const input = computed<BreakdownInput>(() => ({ groupSize: groupSize.value, modules: [...modules.value] }))
|
||
const canStart = computed(
|
||
() =>
|
||
!!project.value?.episodes.length &&
|
||
project.value.status !== 'generating' &&
|
||
!!preview.value &&
|
||
configValid.value &&
|
||
!operation.value.pending &&
|
||
!error.value &&
|
||
(!records.value.length || taskStopped.value)
|
||
)
|
||
let previewController: AbortController | undefined
|
||
let previewVersion = 0
|
||
|
||
/** 输入变化立即废弃旧预览,防止按新配置启动旧的任务清单。 */
|
||
function invalidatePreview() {
|
||
previewVersion++
|
||
previewController?.abort()
|
||
preview.value = null
|
||
previewBusy.value = false
|
||
previewError.value = ''
|
||
}
|
||
watch(
|
||
[
|
||
groupSize,
|
||
() => modules.value.join(','),
|
||
() => project.value?.episodes.map(item => item.episode + ':' + item.content).join('|')
|
||
],
|
||
invalidatePreview
|
||
)
|
||
onScopeDispose(invalidatePreview)
|
||
|
||
/** 只接受明确勾选状态,不把不确定值作为模块选择。 */
|
||
function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate') {
|
||
if (checked === true && !modules.value.includes(module)) modules.value.push(module)
|
||
else modules.value = modules.value.filter(item => item !== module)
|
||
}
|
||
|
||
/** 获取服务器生成的任务分组,不在浏览器伪造任务 ID。 */
|
||
async function loadPreview() {
|
||
if (!configValid.value || !project.value || previewBusy.value) return
|
||
invalidatePreview()
|
||
const version = previewVersion
|
||
previewController = new AbortController()
|
||
previewBusy.value = true
|
||
try {
|
||
const data = await breakdownApi.preview(project.value.id, input.value, previewController.signal)
|
||
if (version === previewVersion) preview.value = data
|
||
} catch (cause) {
|
||
if (version === previewVersion) previewError.value = errorMessage(cause)
|
||
} finally {
|
||
if (version === previewVersion) previewBusy.value = false
|
||
}
|
||
}
|
||
|
||
/** 长请求不随路由切换取消,返回后刷新 checkpoint 与正式项目数据。 */
|
||
async function run(action: BreakdownAction) {
|
||
if (!project.value || operation.value.pending || error.value) return
|
||
if (action === 'start' && !canStart.value) return
|
||
const labels: Record<BreakdownAction, string> = {
|
||
start: '正在拆解剧本',
|
||
retry: '正在重试失败抽取',
|
||
'resume-shots': '正在补齐镜头',
|
||
'resume-storyboard': '正在修复分镜绑定'
|
||
}
|
||
const id = project.value.id
|
||
await runOperation(id, labels[action], () =>
|
||
breakdownApi.run(id, action, action === 'start' ? input.value : undefined)
|
||
)
|
||
await refresh()
|
||
}
|
||
|
||
/** 导出所见的后端结果;阶段性数据也明确标记为 snapshot。 */
|
||
function exportResult() {
|
||
if (!snapshot.value) return
|
||
downloadText(
|
||
`breakdown-${project.value?.id}-snapshot.json`,
|
||
JSON.stringify(snapshot.value, null, 2),
|
||
'application/json'
|
||
)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<WorkspacePage
|
||
><template #header
|
||
><h2 class="text-lg font-semibold">剧本拆解</h2>
|
||
<p class="text-xs text-muted">分组抽取、主体整理与分镜结果</p></template
|
||
>
|
||
<div v-if="!project?.episodes.length" class="panel">
|
||
<EmptyState
|
||
title="还没有可拆解的剧集"
|
||
description="先完成剧本创作。拆解会读取数据库中的正式剧集,不需要上传 checkpoint。"
|
||
><RouterLink class="button button-primary" :to="`/projects/${project?.id}/create-drama`"
|
||
>前往剧本创作<ArrowRight :size="14" /></RouterLink
|
||
></EmptyState>
|
||
</div>
|
||
<template v-else>
|
||
<section class="panel p-5 lg:p-6">
|
||
<div class="mb-5 flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h2 class="flex items-center gap-2 font-semibold"><Layers :size="17" />拆解设置</h2>
|
||
<p class="mt-2 text-xs leading-5 text-muted">
|
||
读取已保存的
|
||
{{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。
|
||
</p>
|
||
</div>
|
||
<NTag size="small" :bordered="false">来源:正式剧集</NTag>
|
||
</div>
|
||
<div class="breakdown-config">
|
||
<div>
|
||
<label class="field-label" for="group-size">每组集数</label>
|
||
<div class="flex items-center gap-3">
|
||
<NInputNumber
|
||
:disabled="operation.pending"
|
||
:input-props="{ id: 'group-size' }"
|
||
class="w-24"
|
||
:value="typeof groupSize === 'number' ? groupSize : null"
|
||
@update:value="groupSize = $event ?? 0"
|
||
:min="1"
|
||
:step="1"
|
||
></NInputNumber
|
||
><span class="text-sm text-muted">集 / 组</span>
|
||
</div>
|
||
</div>
|
||
<fieldset class="flex flex-wrap gap-5 border-0 p-0">
|
||
<legend class="field-label mb-3">抽取模块</legend>
|
||
<NCheckbox
|
||
:checked="modules.includes(option.value)"
|
||
:disabled="operation.pending"
|
||
:aria-label="option.label"
|
||
@update:checked="toggleModule(option.value, $event)"
|
||
v-for="option in moduleOptions"
|
||
:key="option.value"
|
||
class="flex cursor-pointer items-start gap-2.5"
|
||
><span
|
||
><span class="block text-sm font-medium">{{ option.label }}</span
|
||
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
|
||
></NCheckbox
|
||
>
|
||
</fieldset>
|
||
<NButton
|
||
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
||
@click="loadPreview"
|
||
class="self-end"
|
||
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||
</NButton>
|
||
</div>
|
||
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
||
每组集数必须是正整数,并至少选择一个抽取模块。
|
||
</p>
|
||
<NAlert v-if="previewError" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||
previewError
|
||
}}</NAlert>
|
||
<div v-if="preview" class="group-preview">
|
||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<p class="text-sm">
|
||
<strong>{{ preview.groupCount }}</strong> 个分组 <span class="mx-2 text-faint">/</span>
|
||
<strong>{{ preview.estimatedTaskCount }}</strong> 个抽取任务
|
||
</p>
|
||
<ConfirmAction
|
||
:label="records.length ? '重新拆解' : '开始拆解'"
|
||
:description="
|
||
records.length
|
||
? '这会重新运行整个 Breakdown,可能替换已保存的主体与分镜。只想恢复失败阶段时,请使用下方对应恢复操作。'
|
||
: '将按当前预览配置启动完整的 Breakdown 工作流。抽取结束后仍需完成主体整理、分镜生成与入库。'
|
||
"
|
||
:acknowledgement="records.length > 0"
|
||
primary
|
||
:disabled="!canStart"
|
||
@confirm="run('start')"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<div v-for="group in preview.groups" :key="group.groupId" class="group-chip">
|
||
<span class="text-muted">{{ String(group.groupNo).padStart(2, '0') }}</span
|
||
><span>第 {{ group.startEpisodeNo }}–{{ group.endEpisodeNo }} 集</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-if="project.status === 'generating'" class="mt-4 text-xs text-danger">
|
||
剧本仍在生成,暂不允许启动拆解,避免读取到不完整的剧集。
|
||
</p>
|
||
<p v-if="records.length && !taskStopped" class="mt-4 text-xs leading-5 text-muted">
|
||
后端尚无明确的完成或失败记录。为避免重复执行,已禁用整条重新拆解;如进程已中断,请先核实后台,再选择对应恢复操作。
|
||
</p>
|
||
</section>
|
||
<section v-if="snapshot" class="mt-5">
|
||
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||
<h2 class="text-sm font-medium">
|
||
最近可用拆解快照
|
||
<span class="ml-2 text-xs font-normal text-muted">{{
|
||
nodeLabel(records.at(-1)?.metadata?.nodeName)
|
||
}}</span>
|
||
</h2>
|
||
<StatusBadge
|
||
:status="execution?.status || 'unknown'"
|
||
:label="
|
||
execution?.status === 'completed'
|
||
? '工作流已完成'
|
||
: execution?.status === 'failed'
|
||
? '工作流失败'
|
||
: '阶段快照 · 执行状态待确认'
|
||
"
|
||
/>
|
||
</div>
|
||
<div v-if="summary" class="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-muted">
|
||
<span
|
||
>抽取完成 <strong class="text-ink">{{ summary.completed }} / {{ summary.total }}</strong></span
|
||
><span>失败 {{ summary.failed }}</span
|
||
><span>主体 {{ subjects.length }}</span
|
||
><span>分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
|
||
</div>
|
||
<NProgress
|
||
v-if="summary?.total"
|
||
aria-label="已完成抽取任务"
|
||
type="line"
|
||
:show-indicator="false"
|
||
:height="4"
|
||
:percentage="Math.min(100, Math.max(0, (summary.completed / Math.max(1, summary.total)) * 100))"
|
||
class="mt-3"
|
||
></NProgress>
|
||
<NAlert v-if="execution?.errorMessage" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||
execution.errorMessage
|
||
}}</NAlert>
|
||
<NAlert v-if="validation && !validation.valid" type="error" :show-icon="false" class="mt-4"
|
||
><p class="font-medium">分镜校验未通过</p>
|
||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||
<li v-for="(issue, index) in validation.issues" :key="index">
|
||
第 {{ issue.episodeNo }} 集<span v-if="issue.beatNo"> / Beat {{ issue.beatNo }}</span
|
||
><span v-if="issue.shotNo"> / Shot {{ issue.shotNo }}</span
|
||
>:{{ issue.message }}
|
||
</li>
|
||
</ul></NAlert
|
||
>
|
||
<div v-if="showRecovery" class="recovery-strip mt-4">
|
||
<div>
|
||
<p class="text-sm font-medium">从中断处继续</p>
|
||
<p class="mt-1 text-xs leading-5 text-muted">
|
||
抽取失败、镜头缺失、主体绑定异常分别处理。禁用表示近期 checkpoint 不具备所需数据。
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<ConfirmAction
|
||
label="重试失败抽取"
|
||
description="只重试 character / scene / prop 抽取任务中的失败项。不能用于镜头阶段失败。"
|
||
acknowledgement
|
||
:disabled="!recovery.retry || operation.pending || !!error"
|
||
@confirm="run('retry')"
|
||
/><ConfirmAction
|
||
label="补齐缺失镜头"
|
||
description="复用已完成剧集的镜头,仅生成尚未完成的 Episode Shot,再进行校验与入库。"
|
||
acknowledgement
|
||
:disabled="!recovery.shots || operation.pending || !!error"
|
||
@confirm="run('resume-shots')"
|
||
/><ConfirmAction
|
||
label="修复分镜绑定"
|
||
description="复用已有镜头,修复 SubjectRef 与视觉 Form 绑定,再校验并保存。"
|
||
acknowledgement
|
||
:disabled="!recovery.storyboard || operation.pending || !!error"
|
||
@confirm="run('resume-storyboard')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<div class="mt-7">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
|
||
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
|
||
>{{ option.label
|
||
}}<span>{{ subjects.filter(item => item.module === option.value).length }}</span></NTab
|
||
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab></NTabs
|
||
><NButton :disabled="!snapshot" @click="exportResult" text size="small"
|
||
><Download :size="14" />导出 JSON
|
||
</NButton>
|
||
</div>
|
||
<div class="content-with-history panel mt-4">
|
||
<main class="min-w-0">
|
||
<template v-for="option in moduleOptions" :key="option.value"
|
||
><div v-if="tab === option.value">
|
||
<SubjectList
|
||
:project-id="project?.id"
|
||
:subjects="subjects.filter(item => item.module === option.value)"
|
||
:forms="forms"
|
||
/></div
|
||
></template>
|
||
<div v-if="tab === 'storyboard'">
|
||
<div class="mb-4 flex justify-end">
|
||
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
||
>进入分镜设计<ArrowRight :size="14"
|
||
/></RouterLink>
|
||
</div>
|
||
<StoryboardList :plans="plans" :episodes="shots" />
|
||
</div>
|
||
<div v-if="tab === 'tasks'" class="p-5">
|
||
<div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||
<NTable :single-line="false" class="project-table"
|
||
><thead>
|
||
<tr>
|
||
<th>分组</th>
|
||
<th>模块</th>
|
||
<th>状态</th>
|
||
<th>尝试</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="task in snapshot.tasks" :key="task.taskId"
|
||
><tr>
|
||
<td>
|
||
第 {{ task.group.startEpisodeNo }}–{{ task.group.endEpisodeNo }} 集
|
||
</td>
|
||
<td>
|
||
{{ moduleOptions.find(item => item.value === task.module)?.label }}
|
||
</td>
|
||
<td>
|
||
<StatusBadge
|
||
:status="task.status"
|
||
:label="
|
||
{
|
||
pending: '等待中',
|
||
running: '执行中',
|
||
completed: '完成',
|
||
failed: '失败'
|
||
}[task.status]
|
||
"
|
||
/>
|
||
</td>
|
||
<td>{{ task.attempt }}</td>
|
||
</tr>
|
||
<tr v-if="task.errorMessage">
|
||
<td colspan="4" class="text-danger">{{ task.errorMessage }}</td>
|
||
</tr></template
|
||
>
|
||
</tbody></NTable
|
||
>
|
||
</div>
|
||
<EmptyState
|
||
v-else
|
||
title="尚无抽取任务记录"
|
||
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
|
||
/>
|
||
</div>
|
||
</main>
|
||
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
|
||
</div>
|
||
</div> </template
|
||
></WorkspacePage>
|
||
</template>
|