Files
short-drama-agent-front/src/features/breakdown/BreakdownPage.vue
T

399 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>