Files
short-drama-agent-front/src/features/create-drama/CreateDramaPage.vue
T

265 lines
15 KiB
Vue

<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
import { computed, ref } from 'vue'
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>
<WorkspacePage split class="script-workspace-page"
><template #header
><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
></template>
<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>
<NProgress
v-if="total && !complete"
aria-label="已写入剧集数量"
type="line"
:show-indicator="false"
:height="4"
:percentage="Math.min(100, Math.max(0, (Math.min(episodes.length, total) / Math.max(1, total)) * 100))"
class="mt-4"
></NProgress>
<div class="script-section">
<div class="flex flex-wrap items-center justify-between gap-3">
<NTabs v-model:value="tab" type="line" size="small" aria-label="剧本内容"
><NTab name="episodes"
>剧集正文 <span>{{ episodes.length }}</span></NTab
><NTab name="characters">角色设定</NTab><NTab name="world">世界观</NTab
><NTab name="review">审核记录</NTab></NTabs
>
<div class="flex items-center gap-3">
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
><Download :size="14" />导出剧本</NButton
><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">
<div v-if="tab === '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>
<NButton
v-for="item in episodes"
:key="item.episode"
:aria-pressed="episode.episode === item.episode"
@click="selected = item.episode"
class="episode-link"
:class="{ active: episode.episode === item.episode }"
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
><span class="truncate">{{ item.title }}</span></NButton
>
</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="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
/>
</div>
<div v-if="tab === '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>
<NTag size="small" :bordered="false">{{ character.role || '角色' }}</NTag
><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="角色生成结束后会在这里显示。" />
</div>
<div v-if="tab === '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>
<NCollapse v-if="project.world.rules" class="mt-6 text-sm"
><NCollapseItem name="details"
><template #header>世界规则</template>
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
</NCollapseItem></NCollapse
>
</div>
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
</div>
<div v-if="tab === '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="剧集生成完成后,工作流会进行内容审核与必要的改写。"
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
><NCollapseItem name="details"
><template #header>查看改写建议原文</template>
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
</NCollapseItem></NCollapse
>
</div>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
</div></div
></WorkspacePage>
</template>