feat: 实现剧本创作与拆解前端工作台

This commit is contained in:
GouJ
2026-08-27 19:33:51 +08:00
parent edc003443f
commit 2ee6f049f6
56 changed files with 7597 additions and 2 deletions
+401
View File
@@ -0,0 +1,401 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent,
CheckboxRoot,
CheckboxIndicator,
ProgressRoot,
ProgressIndicator
} from 'reka-ui'
import { Check, 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)
/** Reka Checkbox 的不确定值不视为选择。 */
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>
<div class="mt-6">
<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>
<span class="tag">来源正式剧集</span>
</div>
<div class="breakdown-config">
<div>
<label class="field-label" for="group-size">每组集数</label>
<div class="flex items-center gap-3">
<input
id="group-size"
v-model.number="groupSize"
type="number"
class="input w-24"
min="1"
step="1"
:disabled="operation.pending"
/><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>
<label
v-for="option in moduleOptions"
:key="option.value"
class="flex cursor-pointer items-start gap-2.5"
><CheckboxRoot
:model-value="modules.includes(option.value)"
:disabled="operation.pending"
class="checkbox mt-0.5"
:aria-label="option.label"
@update:model-value="toggleModule(option.value, $event)"
><CheckboxIndicator><Check :size="12" /></CheckboxIndicator></CheckboxRoot
><span
><span class="block text-sm font-medium">{{ option.label }}</span
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
></label
>
</fieldset>
<button
class="button button-secondary self-end"
:disabled="!configValid || previewBusy || operation.pending || !!error"
@click="loadPreview"
>
<LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
</button>
</div>
<p v-if="!configValid" class="mt-4 text-xs text-danger">
每组集数必须是正整数并至少选择一个抽取模块
</p>
<p v-if="previewError" class="alert alert-error mt-4" role="alert">{{ previewError }}</p>
<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>
<ProgressRoot
v-if="summary?.total"
class="progress-track mt-3"
:model-value="summary.completed"
:max="summary.total"
aria-label="已完成抽取任务"
><ProgressIndicator
class="progress-fill"
:style="{ width: Math.min(100, (summary.completed / summary.total) * 100) + '%' }"
/></ProgressRoot>
<p v-if="execution?.errorMessage" class="alert alert-error mt-4" role="alert">
{{ execution.errorMessage }}
</p>
<div v-if="validation && !validation.valid" class="alert alert-error 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>
</div>
<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>
<TabsRoot v-model="tab" class="mt-7">
<div class="flex flex-wrap items-center justify-between gap-3">
<TabsList class="tabs-list" aria-label="拆解结果"
><TabsTrigger
v-for="option in moduleOptions"
:key="option.value"
:value="option.value"
class="tab-trigger"
>{{ option.label
}}<span>{{
subjects.filter(item => item.module === option.value).length
}}</span></TabsTrigger
><TabsTrigger value="storyboard" class="tab-trigger">分镜</TabsTrigger
><TabsTrigger value="tasks" class="tab-trigger">任务明细</TabsTrigger></TabsList
><button class="text-button" :disabled="!snapshot" @click="exportResult">
<Download :size="14" />导出 JSON
</button>
</div>
<div class="content-with-history panel mt-4">
<main class="min-w-0">
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
><SubjectList
:subjects="subjects.filter(item => item.module === option.value)"
:forms="forms" /></TabsContent
><TabsContent value="storyboard"
><StoryboardList :plans="plans" :episodes="shots" /></TabsContent
><TabsContent value="tasks" class="p-5"
><div v-if="snapshot?.tasks?.length" class="table-scroll">
<table 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>
</table>
</div>
<EmptyState
v-else
title="尚无抽取任务记录"
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
/></TabsContent>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
</div>
</TabsRoot>
</template>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
import { optionalResource, request } from '../../lib/http'
import type { BreakdownAction, BreakdownInput, BreakdownPreview, BreakdownState } from './types'
/** Breakdown API;预览为 GET,启动和恢复为等待完成的长 POST。 */
export const breakdownApi = {
preview: (id: string, input: BreakdownInput, signal?: AbortSignal) => {
const query = new URLSearchParams({ groupSize: String(input.groupSize), modules: input.modules.join(',') })
return request<BreakdownPreview>(`/projects/${encodeURIComponent(id)}/breakdown-preview?${query}`, { signal })
},
latest: (id: string, signal?: AbortSignal) =>
optionalResource(request<BreakdownState>(`/projects/${encodeURIComponent(id)}/breakdown/latest`, { signal })),
run: (id: string, action: BreakdownAction, input?: BreakdownInput) =>
request<BreakdownState>(`/projects/${encodeURIComponent(id)}/breakdown/${action}`, {
method: 'POST',
body: input,
timeoutMs: 0
})
}
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Clock3 } from '@lucide/vue'
import { EmptyState } from '../../../components/ui'
import type { EpisodePlan, EpisodeShots } from '../types'
/** Episode → Beat → Shot 的分层查看,不提前加入下一条 graph 的导演字段。 */
const props = defineProps<{ plans: EpisodePlan[]; episodes: EpisodeShots[] }>()
const selected = ref<number>()
const plans = computed(() => (props.plans.length ? props.plans : props.episodes.map(item => item.episodePlan)))
const plan = computed(() => plans.value.find(item => item.episodeNo === selected.value) ?? plans.value[0])
const episode = computed(() => props.episodes.find(item => item.episodeNo === plan.value?.episodeNo))
const purposeLabels: Record<string, string> = {
establish: '建立场景',
introduce: '引入',
action: '行动',
dialogue: '对白',
reaction: '反应',
reveal: '揭示',
transition: '过渡',
climax: '高潮',
resolution: '收束',
hook: '钩子'
}
</script>
<template>
<div v-if="plan" class="p-5 lg:p-7">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-3 text-sm"
>选择剧集<select v-model="selected" class="input w-auto" aria-label="选择分镜剧集">
<option v-if="selected === undefined" :value="undefined">
{{ plan.episodeNo }} · {{ plan.episodeTitle }}
</option>
<option v-for="item in plans" :key="item.episodeNo" :value="item.episodeNo">
{{ item.episodeNo }} · {{ item.episodeTitle }}
</option>
</select></label
><span class="text-xs text-muted"
>{{ plan.beats.length }} 个节拍 ·
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
>
</div>
<div class="mb-7 border-l-2 border-accent pl-4">
<p class="font-medium">{{ plan.storyGoal }}</p>
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
</div>
<p v-if="!episode" class="alert mb-5">
本集已完成节拍规划镜头尚未生成请等待进度更新或在确认中断后使用补齐缺失镜头
</p>
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
<div class="beat-heading">
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
<span class="tag ml-auto">{{ purposeLabels[beat.purpose] || beat.purpose }}</span>
</div>
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
<div class="space-y-3">
<article
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
:key="shot.shotNo"
class="shot-row"
>
<div class="shot-label">
镜头 {{ String(shot.shotNo).padStart(2, '0')
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
>
</div>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
</div>
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
{{
shot.subjectBindings
.map(binding => binding.subjectRef + ' · ' + binding.formName)
.join(' / ')
}}
</p>
</div>
</article>
</div>
</section>
</div>
<EmptyState
v-else
title="分镜规划尚未生成"
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
/>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search, ChevronDown } from '@lucide/vue'
import { EmptyState } from '../../../components/ui'
import type { SubjectCandidate, SubjectForm } from '../types'
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[] }>()
const search = ref('')
const filtered = computed(() =>
props.subjects.filter(item =>
`${item.name} ${item.ref} ${item.aliases?.join(' ') ?? ''}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
)
)
</script>
<template>
<div class="p-5 lg:p-7">
<div class="mb-5 flex items-center justify-between gap-3">
<p class="text-sm text-muted">{{ subjects.length }} 个主体</p>
<div class="search-field">
<Search :size="14" /><input v-model="search" placeholder="搜索名称或引用" aria-label="搜索主体" />
</div>
</div>
<div v-if="filtered.length" class="divide-y divide-line">
<article v-for="subject in filtered" :key="subject.profileId" class="py-5 first:pt-0">
<div class="mb-3 flex flex-wrap items-center gap-3">
<h3 class="font-semibold">{{ subject.name }}</h3>
<code class="subject-ref">{{ subject.ref }}</code
><span v-if="subject.aliases?.length" class="text-xs text-muted"
>别名{{ subject.aliases.join('、') }}</span
>
</div>
<p class="text-sm leading-7">{{ subject.description }}</p>
<details class="subject-details mt-4">
<summary>
<ChevronDown :size="14" />外观描述与形态
<span class="ml-1 text-muted">{{
forms.filter(form => form.profileId === subject.profileId).length
}}</span>
</summary>
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
{{ subject.appearance_prompt || '暂无外观描述' }}
</p>
<div
v-for="form in forms.filter(item => item.profileId === subject.profileId)"
:key="form.formId"
class="mt-3 border-l-2 border-line pl-4"
>
<p class="text-sm font-medium">
{{ form.formName || form.name }}<span v-if="form.isDefault" class="tag ml-2">默认</span>
</p>
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
</div>
</details>
</article>
</div>
<EmptyState
v-else
:title="subjects.length ? '没有匹配的主体' : '尚无主体结果'"
description="主体会在抽取、合并与档案整理完成后出现在这里。若未启用该模块,则不会生成此类主体。"
/>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
/** Breakdown 模块公共入口。 */
export { breakdownApi } from './api'
export type { BreakdownInput, BreakdownState, BreakdownModule } from './types'
+154
View File
@@ -0,0 +1,154 @@
/** 三种可配置的抽取模块,分镜并不是第四个模块。 */
export type BreakdownModule = 'character' | 'scene' | 'prop'
/** 拆解分组,来自后端预览接口。 */
export interface EpisodeGroup {
groupId: string
groupNo: number
startEpisodeNo: number
endEpisodeNo: number
episodes: { episodeId: string; episodeNo: number; title: string }[]
}
/** 抽取任务统计,不代表整个 Breakdown 工作流已完成。 */
export interface TaskSummary {
total: number
pending: number
running: number
completed: number
failed: number
finished: number
allFinished: boolean
hasFailed: boolean
}
/** 单个模块 × 分组的抽取任务。 */
export interface BreakdownTask {
taskId: string
module: BreakdownModule
group: EpisodeGroup
status: 'pending' | 'running' | 'completed' | 'failed'
attempt: number
errorMessage?: string
}
/** 传给预览与启动接口的相同配置。 */
export interface BreakdownInput {
groupSize: number
modules: BreakdownModule[]
}
/** 拆解启动前的服务器预览结果。 */
export interface BreakdownPreview {
episodeCount: number
groupCount: number
estimatedTaskCount: number
groups: EpisodeGroup[]
tasks: BreakdownTask[]
modules: BreakdownModule[]
groupSize: number
}
/** 归一化主体;appearance_prompt 保留后端字段名。 */
export interface SubjectCandidate {
profileId: string
name: string
ref: string
description: string
module: BreakdownModule
appearance_prompt: string
aliases?: string[]
}
/** 同一主体的不同视觉形态。 */
export interface SubjectForm {
formId: string
profileId: string
type: BreakdownModule
name: string
formName?: string
isDefault: boolean
description: string
appearancePrompt: string
}
/** 分镜规划中的叙事节拍。 */
export interface StoryboardBeat {
beatNo: number
title: string
purpose: string
description: string
visualFocus: string
narrativeGoal: string
emotionalTone: string
estimatedDurationSeconds: number
subjectRefs: string[]
isKeyBeat: boolean
}
/** 单集分镜规划。 */
export interface EpisodePlan {
episodeNo: number
episodeTitle: string
storyGoal: string
centralConflict: string
emotionalArc: string
pacing: string
endingHook: string
beats: StoryboardBeat[]
}
/** 最小连续镜头及其主体形态绑定。 */
export interface StoryboardShot {
shotNo: number
title: string
description: string
visualFocus: string
subjectRefs: string[]
durationSeconds: number
subjectBindings?: { subjectRef: string; profileId: string; formId: string; formName: string }[]
}
/** 单集按 Beat 组织的镜头结果。 */
export interface EpisodeShots {
episodeNo: number
episodePlan: EpisodePlan
beatShots: { beatNo: number; shots: StoryboardShot[] }[]
}
/** 分镜校验报告。 */
export interface ShotValidation {
valid: boolean
issues: { episodeNo: number; beatNo?: number | null; shotNo?: number | null; message: string }[]
}
/** Breakdown 的最终成果及阶段 checkpoint 共有的可展示字段。 */
export interface BreakdownResult {
subjectCandidates?: SubjectCandidate[]
subjectForms?: SubjectForm[]
storyboardPlans?: EpisodePlan[]
storyboardEpisodeShots?: EpisodeShots[]
storyboardShotValidation?: ShotValidation
storyboardNeedsManualReview?: boolean
}
/** 工作流执行结果与抽取任务状态独立。 */
export interface WorkflowExecution {
executionId: string
status: 'running' | 'completed' | 'failed'
errorMessage?: string
startedAt: string
completedAt?: string
}
/** latest 和 checkpoint 的恢复状态;失败 checkpoint 可能只有 execution。 */
export interface BreakdownState extends BreakdownResult {
workflowExecution?: WorkflowExecution
runConfig?: BreakdownInput & { episodeGroups: EpisodeGroup[] }
taskSummary?: TaskSummary
tasks?: BreakdownTask[]
breakdownResult?: BreakdownResult
}
/** 不同失败阶段的恢复端点,不能用 retry 替代所有恢复。 */
export type BreakdownAction = 'start' | 'retry' | 'resume-shots' | 'resume-storyboard'
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
import { EmptyState } from '../../components/ui'
import { useProjectContext } from '../projects/context'
import { projectsApi } from '../projects/api'
import { workflowCheckpoints } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import HistoryPanel from '../workflows/HistoryPanel.vue'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { downloadText, formatDate } from '../../lib/format'
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
const { project, checkpoints, refresh, error } = useProjectContext()
const selected = ref<number>()
const tab = ref('episodes')
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
const state = computed(() => dramaRecords.value.at(-1)?.state)
const episodes = computed(() => project.value?.episodes ?? [])
const episode = computed(() => episodes.value.find(item => item.episode === selected.value) ?? episodes.value[0])
const total = computed(() => state.value?.episodeCount)
const operation = computed(() => getOperation(project.value!.id))
const complete = computed(() => project.value?.status === 'completed')
const hasCheckpoint = computed(() => dramaRecords.value.length > 0)
const canGenerate = computed(
() =>
!complete.value &&
hasCheckpoint.value &&
!!project.value?.characters.length &&
!!project.value?.world &&
(!total.value || episodes.value.length < total.value)
)
const canRewrite = computed(
() =>
!complete.value &&
hasCheckpoint.value &&
!!episodes.value.length &&
(!total.value || episodes.value.length >= total.value)
)
const stages = computed(() => [
{ label: '角色设定', done: !!project.value?.characters.length },
{ label: '世界观', done: !!project.value?.world },
{ label: '编写剧集', done: !!total.value && episodes.value.length >= total.value },
{ label: '审核与改写', done: project.value?.reviews[0]?.passed === true },
{ label: '完成', done: complete.value }
])
/** 恢复操作不能以“查看状态”的名义发送,始终在确认后调用。 */
async function resume(action: 'resume-generation' | 'resume-rewrite') {
if (!project.value || operation.value.pending || error.value) return
const id = project.value.id
await runOperation(id, action === 'resume-generation' ? '恢复剧集生成' : '恢复剧本改写', () =>
projectsApi.resume(id, action)
)
await refresh()
}
/** 导出当前数据库中的完整正文,方便本地校对。 */
function exportScript() {
if (!project.value) return
const content = episodes.value
.map(item => `${item.episode}${item.title}\n\n${item.content}`)
.join('\n\n————————————\n\n')
downloadText(`${project.value.title || '剧本'}.txt`, content)
}
</script>
<template>
<div class="mt-6">
<div class="stage-strip">
<div v-for="(stage, index) in stages" :key="stage.label" class="stage-item" :class="{ done: stage.done }">
<Check v-if="stage.done" :size="15" /><Circle v-else :size="13" /><span>{{ stage.label }}</span
><ArrowRight v-if="index < stages.length - 1" :size="13" class="stage-arrow" />
</div>
</div>
<div v-if="!complete" class="mt-5 flex flex-wrap items-center justify-between gap-4">
<p class="text-sm text-muted">
已写入 {{ episodes.length }}<span v-if="total"> / {{ total }}</span> 集<span v-if="!total">
· 等待计划集数</span
>{{
project?.status === 'generating' ? '生成状态来自后端,请等待下一次更新。' : '可按中断阶段继续处理。'
}}
</p>
<div class="flex flex-wrap gap-2">
<ConfirmAction
label="恢复生成"
description="用于剧集尚未写完的情况。后端会检查角色、世界观与 checkpoint,并继续处理剧集。"
acknowledgement
:disabled="!canGenerate || operation.pending || !!error"
@confirm="resume('resume-generation')"
/><ConfirmAction
label="恢复改写"
description="用于剧集已齐全但审核未通过的情况。将读取 checkpoint 的改写上下文并重新审核。"
acknowledgement
:disabled="!canRewrite || operation.pending || !!error"
@confirm="resume('resume-rewrite')"
/>
</div>
</div>
<ProgressRoot
v-if="total && !complete"
class="progress-track mt-4"
:model-value="Math.min(episodes.length, total)"
:max="total"
aria-label="已写入剧集数量"
><ProgressIndicator
class="progress-fill"
:style="{ width: Math.min(100, (episodes.length / total) * 100) + '%' }"
/></ProgressRoot>
<TabsRoot v-model="tab" class="mt-7">
<div class="flex flex-wrap items-center justify-between gap-3">
<TabsList class="tabs-list" aria-label="剧本内容"
><TabsTrigger class="tab-trigger" value="episodes"
>剧集正文 <span>{{ episodes.length }}</span></TabsTrigger
><TabsTrigger class="tab-trigger" value="characters">角色设定</TabsTrigger
><TabsTrigger class="tab-trigger" value="world">世界观</TabsTrigger
><TabsTrigger class="tab-trigger" value="review">审核记录</TabsTrigger></TabsList
>
<div class="flex items-center gap-3">
<button class="text-button" :disabled="!episodes.length" @click="exportScript">
<Download :size="14" />导出剧本</button
><RouterLink
v-if="complete"
class="text-button text-accent"
:to="`/projects/${project?.id}/breakdown`"
>进入拆解<ArrowRight :size="14"
/></RouterLink>
</div>
</div>
<div class="content-with-history panel mt-4">
<main class="min-w-0">
<TabsContent value="episodes" class="h-full">
<div v-if="episode" class="script-workspace">
<aside class="episode-list">
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
<button
v-for="item in episodes"
:key="item.episode"
class="episode-link"
:class="{ active: episode.episode === item.episode }"
:aria-pressed="episode.episode === item.episode"
@click="selected = item.episode"
>
<span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
><span class="truncate">{{ item.title }}</span>
</button>
</aside>
<article class="script-page">
<p class="eyebrow"> {{ String(episode.episode).padStart(2, '0') }} </p>
<h2 class="mt-3 text-2xl font-semibold">{{ episode.title }}</h2>
<p v-if="episode.summary" class="script-summary">{{ episode.summary }}</p>
<div class="script-body">{{ episode.content }}</div>
<dl v-if="episode.conflict || episode.hook" class="script-notes">
<template v-if="episode.conflict"
><dt>核心冲突</dt>
<dd>{{ episode.conflict }}</dd></template
><template v-if="episode.hook"
><dt>结尾钩子</dt>
<dd>{{ episode.hook }}</dd></template
>
</dl>
</article>
</div>
<EmptyState
v-else
title="剧本正在酝酿"
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
/>
</TabsContent>
<TabsContent value="characters" class="p-6 lg:p-8"
><div v-if="project?.characters.length" class="divide-y divide-line">
<article
v-for="character in project.characters"
:key="character.id"
class="py-5 first:pt-0"
>
<div class="mb-3 flex items-center gap-3">
<h3 class="text-lg font-semibold">{{ character.name }}</h3>
<span class="tag">{{ character.role || '角色' }}</span
><span class="text-xs text-muted">{{ character.occupation }}</span>
</div>
<dl class="detail-grid">
<template
v-for="field in [
{ key: 'personality', label: '性格' },
{ key: 'goal', label: '目标' },
{ key: 'secret', label: '秘密' }
] as const"
:key="field.key"
><dt>{{ field.label }}</dt>
<dd>{{ character[field.key] || '未提供' }}</dd></template
>
</dl>
</article>
</div>
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。"
/></TabsContent>
<TabsContent value="world" class="p-6 lg:p-8"
><div v-if="project?.world">
<p class="eyebrow">故事的发生之地</p>
<h2 class="mb-7 mt-3 text-xl font-semibold">
{{ project.world.era }} · {{ project.world.location }}
</h2>
<dl class="detail-grid">
<template
v-for="field in [
{ key: 'background', label: '背景' },
{ key: 'coreConflict', label: '核心冲突' },
{ key: 'tone', label: '基调' }
] as const"
:key="field.key"
><dt>{{ field.label }}</dt>
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
>
</dl>
<details v-if="project.world.rules" class="mt-6 text-sm">
<summary class="cursor-pointer text-muted">世界规则</summary>
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
</details>
</div>
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。"
/></TabsContent>
<TabsContent value="review" class="p-6 lg:p-8"
><div v-if="project?.reviews.length" class="space-y-5">
<article
v-for="review in project.reviews"
:key="review.id"
class="border-b border-line pb-5"
>
<div class="mb-3 flex justify-between gap-3">
<span
class="text-sm font-medium"
:class="review.passed ? 'text-success' : 'text-danger'"
>{{ review.passed ? '审核通过' : '需要修改' }}</span
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
</div>
<p class="whitespace-pre-wrap text-sm leading-7">
{{ review.message || '本次审核未附加说明。' }}
</p>
</article>
</div>
<EmptyState
v-else
title="还没有审核记录"
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
/>
<details v-if="state?.rewriteSuggestion" class="mt-5 text-sm">
<summary class="cursor-pointer text-muted">查看改写建议原文</summary>
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
</details></TabsContent
>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
</div>
</TabsRoot>
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
/** Create Drama 页面入口,路由通过动态导入进行分包。 */
export { default as CreateDramaPage } from './CreateDramaPage.vue'
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, FileText, Layers, LoaderCircle } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
/** 项目级数据与操作状态跨 graph 页面共享。 */
const route = useRoute()
const id = computed(() => String(route.params.projectId))
const context = useProjectData(id)
provide(projectContextKey, context)
const operation = computed(() => getOperation(id.value))
</script>
<template>
<section class="page-container">
<RouterLink to="/projects" class="back-link"><ArrowLeft :size="14" />全部剧本</RouterLink>
<div class="page-heading mt-5">
<div class="min-w-0">
<p class="eyebrow">项目工作台</p>
<h1 class="break-words">
{{ context.project.value?.title || context.project.value?.topic || '读取项目' }}
</h1>
<p class="page-description">{{ context.project.value?.style || '剧本与拆解结果' }}</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><button
class="button button-secondary"
:disabled="context.loading.value"
@click="context.refresh"
>
<RefreshCw :size="14" :class="{ 'animate-spin': context.loading.value }" /><span
class="hidden sm:inline"
>刷新</span
>
</button>
</div>
</div>
<nav class="workflow-nav" aria-label="项目工作流">
<RouterLink :to="`/projects/${id}/create-drama`"
><FileText :size="17" />剧本创作<span class="nav-code">create-drama</span></RouterLink
>
<RouterLink :to="`/projects/${id}/breakdown`"
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></RouterLink
>
</nav>
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span>
</p>
<p v-if="operation.pending" class="alert mt-4 flex items-center gap-2" role="status">
<LoaderCircle :size="16" class="shrink-0 animate-spin" />{{
operation.label
}}可切换页面查看结果请勿重复提交或关闭浏览器关闭页面不会取消后端任务
</p>
<p v-if="operation.error" class="alert alert-error mt-4" role="alert">{{ operation.error }}</p>
<p v-if="operation.notice" class="alert mt-4" role="status">{{ operation.notice }}</p>
<RouterView v-if="context.project.value" :key="id" />
<div v-else-if="context.loading.value" class="py-12 text-sm text-muted" role="status">
正在读取项目和工作流记录
</div>
</section>
</template>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
import { usePolling } from '../../composables/usePolling'
import { EmptyState, StatusBadge } from '../../components/ui'
import { formatDate } from '../../lib/format'
import { projectsApi } from './api'
import CreateProjectDialog from './components/CreateProjectDialog.vue'
/** 项目索引:真实查询、客户端筛选,以及进入两条 graph 的入口。 */
const router = useRouter()
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
const search = ref('')
const filter = ref('all')
const projects = computed(() => query.data.value ?? [])
const filtered = computed(() =>
projects.value.filter(item => {
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
.toLowerCase()
.includes(search.value.toLowerCase().trim())
return matches && (filter.value === 'all' || item.status === filter.value)
})
)
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
function openProject(id: string) {
void router.push(`/projects/${encodeURIComponent(id)}/create-drama`)
}
/** 多项筛选的重置放在函数中,避免格式化后产生无效模板表达式。 */
function clearFilters() {
search.value = ''
filter.value = 'all'
}
</script>
<template>
<section class="page-container">
<div class="page-heading">
<div>
<p class="eyebrow">工作空间 / 项目</p>
<h1>我的剧本</h1>
<p class="page-description">从故事到镜头在这里继续你的创作</p>
</div>
<CreateProjectDialog @created="openProject" />
</div>
<div class="workspace-note">
<Clapperboard :size="20" :stroke-width="1.5" /><span
>剧本创作 <span class="mx-3 text-faint">/</span> 主体拆解
<span class="mx-3 text-faint">/</span> 分镜规划</span
><span class="ml-auto hidden text-xs text-muted sm:block">两个工作流一个项目</span>
</div>
<div class="toolbar mt-7">
<div class="flex flex-wrap gap-1">
<button
v-for="item in [
{ value: 'all', label: '全部项目' },
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'need_review', label: '待审核' },
{ value: 'failed', label: '失败' }
]"
:key="item.value"
class="filter-button"
:class="{ active: filter === item.value }"
:aria-pressed="filter === item.value"
@click="filter = item.value"
>
{{ item.label
}}<span v-if="item.value === 'all'" class="ml-2 text-muted">{{ projects.length }}</span>
</button>
</div>
<div class="flex items-center gap-2">
<div class="search-field">
<Search :size="15" /><input v-model="search" aria-label="搜索项目" placeholder="搜索剧本" />
</div>
<button
class="icon-button"
aria-label="刷新项目"
:disabled="query.loading.value"
@click="query.refresh"
>
<RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
</button>
</div>
</div>
<div v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }}<button class="ml-3 underline" @click="query.refresh">重新连接</button>
</div>
<div class="panel mt-4 overflow-hidden" :aria-busy="query.loading.value">
<div v-if="!query.data.value && query.loading.value" class="p-10 text-sm text-muted" role="status">
正在读取项目……
</div>
<div v-else-if="filtered.length" class="table-scroll">
<table class="project-table">
<thead>
<tr>
<th>剧本名称</th>
<th>风格</th>
<th>创作状态</th>
<th>最近更新</th>
<th><span class="sr-only">打开项目</span></th>
</tr>
</thead>
<tbody>
<tr v-for="project in filtered" :key="project.id">
<td>
<RouterLink :to="`/projects/${project.id}/create-drama`" class="project-name"
><span class="project-monogram">{{
(project.title || project.topic).slice(0, 1)
}}</span
><span class="min-w-0"
><strong class="block truncate font-medium">{{
project.title || project.topic
}}</strong
><span class="mt-1 block max-w-md truncate text-xs text-muted">{{
project.topic
}}</span></span
></RouterLink
>
</td>
<td class="text-muted">{{ project.style || '未设置' }}</td>
<td><StatusBadge :status="project.status" /></td>
<td class="whitespace-nowrap text-xs text-muted">{{ formatDate(project.updatedAt) }}</td>
<td>
<RouterLink
:to="`/projects/${project.id}/create-drama`"
class="icon-button"
:aria-label="`打开 ${project.title || project.topic}`"
><ArrowUpRight :size="17"
/></RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
<EmptyState
v-else-if="query.data.value"
:title="projects.length ? '没有匹配的剧本' : '第一部故事,从这里开始'"
:description="
projects.length
? '试试其他关键词,或切换项目状态。'
: '新建一个剧本,生成角色与剧集;完成后,再将故事拆解为主体和分镜。'
"
><button v-if="projects.length" class="button button-secondary" @click="clearFilters">
清除筛选
</button></EmptyState
>
<EmptyState
v-else
title="等待连接后端"
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
/>
</div>
<p class="mt-4 text-xs text-muted">
项目数据来自后端数据库<span v-if="query.updatedAt.value">
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
>
</p>
</section>
</template>
+18
View File
@@ -0,0 +1,18 @@
import { request, optionalResource } from '../../lib/http'
import type { Checkpoint } from '../workflows/types'
import type { CreateProjectInput, DramaState, Project, ProjectDetail } from './types'
/** 项目与 Create Drama API;路径严格对应后端 dev。 */
export const projectsApi = {
list: (signal?: AbortSignal) => request<Project[]>('/projects', { signal }),
detail: (id: string, signal?: AbortSignal) =>
request<ProjectDetail>(`/projects/${encodeURIComponent(id)}`, { signal }),
create: (input: CreateProjectInput) =>
request<{ projectId: string; status: string }>('/projects', { method: 'POST', body: input }),
state: (id: string, signal?: AbortSignal) =>
optionalResource(request<DramaState>(`/projects/${encodeURIComponent(id)}/state`, { signal })),
checkpoints: (id: string, signal?: AbortSignal) =>
request<Checkpoint[]>(`/projects/${encodeURIComponent(id)}/checkpoints`, { signal }),
resume: (id: string, action: 'resume-generation' | 'resume-rewrite') =>
request<unknown>(`/projects/${encodeURIComponent(id)}/${action}`, { method: 'POST', timeoutMs: 0 })
}
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { DialogTrigger } from 'reka-ui'
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
import { AppDialog } from '../../../components/ui'
import { projectsApi } from '../api'
import { errorMessage } from '../../../lib/http'
/** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */
const emit = defineEmits<{ created: [projectId: string] }>()
const open = ref(false)
const busy = ref(false)
const error = ref('')
const form = reactive({ topic: '', style: '爽文反转', episodeCount: 3 })
/** 校验正整数集数和主题,禁止双击产生重复项目。 */
async function submit() {
if (busy.value) return
error.value = ''
if (!form.topic.trim() || !Number.isSafeInteger(form.episodeCount) || form.episodeCount <= 0) {
error.value = '请填写故事主题,并输入大于 0 的整数集数。'
return
}
busy.value = true
try {
const result = await projectsApi.create({
...form,
topic: form.topic.trim(),
style: form.style.trim() || '爽文反转'
})
open.value = false
form.topic = ''
emit('created', result.projectId)
} catch (cause) {
error.value = errorMessage(cause)
} finally {
busy.value = false
}
}
</script>
<template>
<AppDialog
v-model:open="open"
title="新建剧本"
description="从一个故事想法开始。提交后,工作流将依次生成角色、世界观和剧集,并进行审核。"
:busy="busy"
>
<template #trigger
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></template
>
<form class="mt-7 space-y-5" @submit.prevent="submit">
<div>
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
><textarea
id="topic"
v-model="form.topic"
class="input min-h-32 resize-y"
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
required
:disabled="busy"
></textarea>
</div>
<div class="grid grid-cols-[1fr_110px] gap-4">
<div>
<label class="field-label" for="style">剧本风格</label
><input
id="style"
v-model="form.style"
class="input"
placeholder="如:都市悬疑、爽文反转"
:disabled="busy"
/>
</div>
<div>
<label class="field-label" for="episode-count">计划集数</label
><input
id="episode-count"
v-model.number="form.episodeCount"
class="input"
type="number"
min="1"
step="1"
required
:disabled="busy"
/>
</div>
</div>
<p class="text-xs leading-5 text-muted">
建议先用 3 集验证生成效果生成会实际调用后端模型产生相应费用
</p>
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
<div class="dialog-footer">
<button type="button" class="button button-secondary" :disabled="busy" @click="open = false">
取消</button
><button type="submit" class="button button-primary" :disabled="busy">
<LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
v-if="!busy"
:size="16"
/>
</button>
</div>
</form>
</AppDialog>
</template>
+30
View File
@@ -0,0 +1,30 @@
import { computed, inject, type InjectionKey } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { projectsApi } from './api'
import type { ProjectDetail } from './types'
import type { Checkpoint } from '../workflows/types'
import type { Ref } from 'vue'
/** 同一项目的两个 graph 共用一份查询,避免每个面板重复请求。 */
export function useProjectData(id: Ref<string>) {
const query = usePolling(id, async (projectId, signal) => {
const [project, checkpoints] = await Promise.all([
projectsApi.detail(projectId, signal),
projectsApi.checkpoints(projectId, signal)
])
return { project, checkpoints }
})
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
return { ...query, project, checkpoints }
}
/** 项目布局向子页面提供的类型安全上下文。 */
export const projectContextKey: InjectionKey<ReturnType<typeof useProjectData>> = Symbol('project-context')
/** 读取项目上下文,错误布局在开发阶段立即暴露。 */
export function useProjectContext() {
const context = inject(projectContextKey)
if (!context) throw new Error('项目页面必须位于 ProjectLayout 中')
return context
}
+3
View File
@@ -0,0 +1,3 @@
/** 项目模块公共入口。 */
export { projectsApi } from './api'
export type { Project, ProjectDetail, CreateProjectInput } from './types'
+80
View File
@@ -0,0 +1,80 @@
/** 后端 DramaProject.status 的原始取值。 */
export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed'
/** 创建请求不包含标题,标题由后端在工作流收尾时更新。 */
export interface CreateProjectInput {
topic: string
style: string
episodeCount: number
}
/** 项目列表记录;列表接口不包含剧集数量,不能据此推断生成进度。 */
export interface Project {
id: string
title: string | null
topic: string
style: string | null
status: ProjectStatus
createdAt: string
updatedAt: string
}
/** 正式数据库剧集;episode 是编号,与 Breakdown 的 episodeNo 区分。 */
export interface Episode {
id?: string
episode: number
title: string
summary?: string | null
content: string
conflict?: string | null
hook?: string | null
}
/** 剧本阶段的角色设定,不等同于拆解后的主体资产。 */
export interface Character {
id: string
name: string
role?: string | null
age?: number | null
occupation?: string | null
personality?: string | null
goal?: string | null
secret?: string | null
}
/** 正式数据库保存的世界观。 */
export interface World {
background?: string | null
era?: string | null
location?: string | null
coreConflict?: string | null
tone?: string | null
rules?: unknown
}
/** 后端审核记录,列表按最新在前返回。 */
export interface Review {
id: string
passed: boolean
message?: string | null
createdAt: string
}
/** 项目详情接口包含的关联数据。 */
export interface ProjectDetail extends Project {
episodes: Episode[]
characters: Character[]
world: World | null
reviews: Review[]
tasks: { id: string; type: string; status: string; error?: string | null }[]
}
/** Create Drama checkpoint 中页面使用的状态切片。 */
export interface DramaState {
episodeCount?: number
episodes?: Episode[]
retryCount?: number
reviewPassed?: boolean
rewriteSuggestion?: unknown
rewritePlan?: unknown
}
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue'
import { DialogTrigger } from 'reka-ui'
import { AppDialog } from '../../components/ui'
/** 耗费模型额度或覆盖结果的动作必须经过明确确认。 */
const props = defineProps<{
label: string
description: string
disabled?: boolean
acknowledgement?: boolean
primary?: boolean
}>()
const emit = defineEmits<{ confirm: [] }>()
const open = ref(false)
const acknowledged = ref(false)
/** 发出事件后关闭弹窗,操作状态由项目级锁负责。 */
function confirm() {
if (props.disabled || (props.acknowledgement && !acknowledged.value)) return
emit('confirm')
open.value = false
acknowledged.value = false
}
</script>
<template>
<AppDialog v-model:open="open" :title="label" :description="description">
<template #trigger
><DialogTrigger
class="button"
:class="primary ? 'button-primary' : 'button-secondary'"
:disabled="disabled"
>{{ label }}</DialogTrigger
></template
>
<p class="mt-5 text-sm leading-6 text-muted">
此操作会提交到真实后端可能调用模型并产生费用请勿在其他页面或终端同时启动相同工作流
</p>
<label v-if="acknowledgement" class="mt-5 flex items-start gap-3 text-sm leading-6"
><input
v-model="acknowledged"
class="mt-1 accent-accent"
type="checkbox"
/>我已确认后台任务停止当前没有同项目的生成或拆解任务在运行</label
>
<div class="dialog-footer mt-6">
<button class="button button-secondary" @click="open = false">取消</button
><button
class="button button-primary"
:disabled="disabled || (acknowledgement && !acknowledged)"
@click="confirm"
>
确认{{ label }}
</button>
</div>
</AppDialog>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock3 } from '@lucide/vue'
import { formatDate, nodeLabel } from '../../lib/format'
import { workflowCheckpoints } from './selectors'
import type { Checkpoint } from './types'
/** 时间线只说明 checkpoint 已保存,不把每条记录误标为工作流成功。 */
const props = defineProps<{ checkpoints: Checkpoint[]; workflow: string }>()
const history = computed(() => workflowCheckpoints(props.checkpoints, props.workflow).slice(-18).toReversed())
</script>
<template>
<aside class="history-panel">
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
<p class="mb-6 text-xs leading-5 text-muted">最近 18 checkpoint · 自动刷新</p>
<p v-if="!history.length" class="text-xs leading-6 text-muted">工作流尚未保存执行记录</p>
<ol v-else class="timeline">
<li v-for="(item, index) in history" :key="item.checkpointId" :class="{ 'timeline-latest': index === 0 }">
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
<p class="mt-1 text-[11px] text-muted">
{{ formatDate(item.createdAt)
}}<span v-if="item.metadata?.nodeDurationMs">
· {{ (item.metadata.nodeDurationMs / 1000).toFixed(1) }}s</span
>
</p>
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
>运行失败</span
>
</li>
</ol>
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
记录在节点或批次结束后更新长时间无新记录不一定意味着任务失败
</p>
</aside>
</template>
+4
View File
@@ -0,0 +1,4 @@
/** 跨 graph 的观测与操作接口。 */
export { getOperation, runOperation } from './operations'
export { workflowCheckpoints, breakdownSnapshot, recoveryOptions } from './selectors'
export type { Checkpoint } from './types'
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { getOperation, runOperation } from './operations'
describe('项目级长请求互斥', () => {
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
let fail!: (reason: Error) => void
const action = vi.fn<() => Promise<unknown>>(
() =>
new Promise((_resolve, reject) => {
fail = reject
})
)
const first = runOperation('operation-test', '拆解', action)
expect(await runOperation('operation-test', '改写', action)).toBe(false)
expect(action).toHaveBeenCalledTimes(1)
fail(new Error('连接中断'))
expect(await first).toBe(false)
expect(getOperation('operation-test')).toMatchObject({ pending: false, error: '连接中断', notice: '' })
})
})
+35
View File
@@ -0,0 +1,35 @@
import { reactive } from 'vue'
import { errorMessage } from '../../lib/http'
/** 当前浏览器会话中的操作状态;切换路由不会丢失长请求。 */
export interface Operation {
pending: boolean
label: string
error: string
notice: string
}
/** 按项目而非 graph 上锁,防止改写剧本与拆解同时提交。 */
const operations = reactive<Record<string, Operation>>({})
/** 取得项目操作状态,不向浏览器持久化虚假的后台运行状态。 */
export function getOperation(projectId: string): Operation {
return (operations[projectId] ??= { pending: false, label: '', error: '', notice: '' })
}
/** 只提交一次;断网不自动重试,后台是否已执行由刷新后的 checkpoint 确认。 */
export async function runOperation(projectId: string, label: string, action: () => Promise<unknown>) {
const operation = getOperation(projectId)
if (operation.pending) return false
Object.assign(operation, { pending: true, label, error: '', notice: '' })
try {
await action()
operation.notice = '请求已返回,正在重新读取后端状态;最终结果以下方记录为准。'
return true
} catch (error) {
operation.error = errorMessage(error)
return false
} finally {
operation.pending = false
}
}
+142
View File
@@ -0,0 +1,142 @@
import { computed, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CreateProjectDialog from '../projects/components/CreateProjectDialog.vue'
import BreakdownPage from '../breakdown/BreakdownPage.vue'
import CreateDramaPage from '../create-drama/CreateDramaPage.vue'
import ProjectsPage from '../projects/ProjectsPage.vue'
import { projectContextKey, type useProjectData } from '../projects/context'
import type { ProjectDetail } from '../projects/types'
import type { Checkpoint } from './types'
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = {
id: 'page-test-project',
title: '雨夜来信',
topic: '一封信改变了两个人的命运',
style: '都市悬疑',
status: 'completed',
createdAt: '2026-08-27T00:00:00Z',
updatedAt: '2026-08-27T00:00:00Z',
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\n第一场:旧书店。' }],
characters: [{ id: 'character', name: '林知夏' }],
world: { era: '当代' },
reviews: [],
tasks: []
}
let wrapper: VueWrapper | undefined
/** 使用真实上下文形状,不绕过页面内的异步操作与按钮守卫。 */
function context(): ReturnType<typeof useProjectData> {
const data = ref({ project: fixture, checkpoints: [] as Checkpoint[] })
return {
data,
project: computed(() => data.value.project),
checkpoints: computed(() => data.value.checkpoints),
loading: ref(false),
error: ref(''),
updatedAt: ref(''),
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
}
}
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
function button(label: string): HTMLButtonElement {
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
if (!element) throw new Error('找不到按钮:' + label)
return element
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('工作台页面交互', () => {
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementation(async () => new Response(JSON.stringify({ data: [fixture] })))
)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: ProjectsPage }]
})
await router.push('/')
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.text()).toContain('雨夜来信')
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
expect(wrapper.text()).toContain('没有匹配的剧本')
button('清除筛选').click()
await flushPromises()
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
})
it('新建弹窗提交真实参数并返回 202 的项目 ID', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
button('新建剧本').click()
await flushPromises()
const topic = document.querySelector<HTMLTextAreaElement>('#topic')!
topic.value = ' 雨夜来信 '
topic.dispatchEvent(new Event('input', { bubbles: true }))
const count = document.querySelector<HTMLInputElement>('#episode-count')!
count.value = '6'
count.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('created')).toEqual([['created']])
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({
topic: '雨夜来信',
style: '爽文反转',
episodeCount: 6
})
})
it('修改每组集数会废弃预览,重新预览并确认后才能启动', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
if (String(url).includes('breakdown-preview'))
return new Response(
'{"data":{"episodeCount":1,"groupCount":1,"estimatedTaskCount":3,"groups":[],"tasks":[],"modules":["character","scene","prop"],"groupSize":2}}'
)
return new Response('{"data":{"workflowExecution":{"status":"completed"}}}')
})
vi.stubGlobal('fetch', fetcher)
const provided = context()
wrapper = mount(BreakdownPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: provided } }
})
button('预览分组').click()
await flushPromises()
expect(button('开始拆解').disabled).toBe(false)
await wrapper.get('#group-size').setValue('2')
expect(document.body.textContent).not.toContain('开始拆解')
button('预览分组').click()
await flushPromises()
button('开始拆解').click()
await flushPromises()
button('确认开始拆解').click()
await flushPromises()
const post = fetcher.mock.calls.find(call => call[1]?.method === 'POST')
expect(post?.[0]).toBe('/api/projects/page-test-project/breakdown/start')
expect(JSON.parse(post![1]!.body as string)).toEqual({ groupSize: 2, modules: ['character', 'scene', 'prop'] })
expect(provided.refresh).toHaveBeenCalledOnce()
})
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
wrapper = mount(CreateDramaPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
expect(wrapper.find('.script-body').text()).toContain('<script>不要执行模型内容</script>')
expect(wrapper.find('.script-body script').exists()).toBe(false)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from './selectors'
import type { Checkpoint } from './types'
import type { BreakdownState } from '../breakdown/types'
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
return { checkpointId: String(index), workflowName, createdAt: new Date(index * 1000).toISOString(), state }
}
describe('Checkpoint 选择与恢复', () => {
it('按 graph 隔离并保持输入不可变', () => {
const records = [checkpoint(2, {}), checkpoint(1, {}, 'create-drama')]
expect(workflowCheckpoints(records, 'breakdown').map(item => item.checkpointId)).toEqual(['2'])
expect(records[0]?.checkpointId).toBe('2')
})
it('只有错误的最新 checkpoint 仍能展示上一份成果,同时保留最新失败状态', () => {
const result = breakdownSnapshot([
checkpoint(1, {
breakdownResult: { subjectCandidates: [] },
runConfig: { groupSize: 3, modules: ['character'], episodeGroups: [] }
}),
checkpoint(2, {
workflowExecution: {
executionId: 'e',
status: 'failed',
startedAt: '',
errorMessage: '模型未返回 JSON'
}
})
])
expect(result?.runConfig?.groupSize).toBe(3)
expect(result?.workflowExecution?.status).toBe('failed')
})
it('新阶段不能继承上一轮 completed 状态', () => {
const result = breakdownSnapshot([
checkpoint(1, {
workflowExecution: { executionId: 'e', status: 'completed', startedAt: '' },
breakdownResult: {}
}),
checkpoint(2, { runConfig: { groupSize: 2, modules: ['scene'], episodeGroups: [] } })
])
expect(result?.workflowExecution).toBeUndefined()
expect(result?.runConfig?.groupSize).toBe(2)
})
it('恢复窗口和后端一致,过旧的失败任务不启用重试', () => {
const records = Array.from({ length: 11 }, (_, index) =>
checkpoint(
index,
index === 0
? {
tasks: [
{
taskId: 't',
module: 'prop',
status: 'failed',
attempt: 1,
group: { groupId: 'g', groupNo: 1, startEpisodeNo: 1, endEpisodeNo: 1, episodes: [] }
}
]
}
: {}
)
)
expect(recoveryOptions(records).retry).toBe(false)
expect(recoveryOptions(records.slice(0, 10)).retry).toBe(true)
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { BreakdownState } from '../breakdown/types'
import type { Checkpoint } from './types'
/** 按工作流隔离并排序,后端数组顺序变化不会影响恢复判断。 */
export function workflowCheckpoints(checkpoints: Checkpoint[], name: string): Checkpoint[] {
return checkpoints
.filter(item => item.workflowName === name)
.toSorted((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
}
/** 失败快照可能只有错误;回看最近有效状态,同时只使用最新记录的执行状态。 */
export function breakdownSnapshot(checkpoints: Checkpoint[]): BreakdownState | null {
const records = workflowCheckpoints(checkpoints, 'breakdown')
const latest = records.at(-1)
if (!latest) return null
const data =
records
.slice(-30)
.toReversed()
.find(item => item.state.runConfig || item.state.breakdownResult || item.state.storyboardPlans?.length)
?.state ?? {}
return { ...data, workflowExecution: latest.state.workflowExecution }
}
/** 根据最近后端可恢复窗口推导操作;页面仍需先确认任务已经结束。 */
export function recoveryOptions(checkpoints: Checkpoint[]) {
const records = workflowCheckpoints(checkpoints, 'breakdown').toReversed()
const retry = records.slice(0, 10).some(item => item.state.tasks?.some(task => task.status === 'failed'))
const shots = records
.slice(0, 30)
.some(({ state }) =>
Boolean(
state.storyboardPlans?.length &&
state.subjectCandidates?.length &&
state.subjectForms &&
(state.storyboardEpisodeShots?.length ?? 0) < state.storyboardPlans.length
)
)
const storyboard = records
.slice(0, 10)
.some(({ state }) =>
Boolean(state.storyboardEpisodeShots?.length && state.subjectCandidates?.length && state.subjectForms)
)
return { retry, shots, storyboard }
}
+11
View File
@@ -0,0 +1,11 @@
import type { DramaState } from '../projects/types'
import type { BreakdownState } from '../breakdown/types'
/** 观测接口返回的 checkpoint,保留工作流归属以防混淆两条 graph。 */
export interface Checkpoint {
checkpointId: string
workflowName: string
createdAt: string
metadata?: { nodeName?: string; nodeDurationMs?: number; [key: string]: unknown } | null
state: DramaState & BreakdownState
}