332 lines
16 KiB
Vue
332 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
|
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
|
import { NScrollbar, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
|
import { computed, ref } from 'vue'
|
|
import { ArrowRight, 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 } from '../../lib/format'
|
|
import EpisodeReader from './EpisodeReader.vue'
|
|
|
|
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
|
|
const { project, checkpoints, refresh, error } = useProjectContext()
|
|
const tab = ref('episodes')
|
|
const showHistory = ref(false)
|
|
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
|
|
const state = computed(() => dramaRecords.value.at(-1)?.state)
|
|
const episodes = computed(() => project.value?.episodes ?? [])
|
|
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: state.value?.reviewPassed === 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 compact class="script-workspace-page"
|
|
><template #header
|
|
><div class="stage-strip">
|
|
<!-- 以下是创作阶段进度 -->
|
|
<div class="stage-strip-track">
|
|
<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
|
|
></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="tabs-toolbar">
|
|
<NTabs v-model:value="tab" class="workspace-tabs" 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>
|
|
<template #suffix>
|
|
<div class="tabs-toolbar-actions">
|
|
<ActionMenu
|
|
label="剧本更多操作"
|
|
:items="[
|
|
{ key: 'history', label: showHistory ? '收起执行记录' : '执行记录' },
|
|
{ key: 'export', label: '导出剧本', disabled: !episodes.length }
|
|
]"
|
|
@select="$event === 'history' ? (showHistory = !showHistory) : exportScript()"
|
|
/>
|
|
<RouterLink
|
|
v-if="complete"
|
|
class="text-button text-accent script-next-link"
|
|
:to="`/projects/${project?.id}/breakdown`"
|
|
>进入拆解<ArrowRight :size="14"
|
|
/></RouterLink>
|
|
</div>
|
|
</template>
|
|
</NTabs>
|
|
</div>
|
|
<div class="content-with-history panel mt-2" :class="{ 'history-hidden': !showHistory }">
|
|
<main class="min-w-0">
|
|
<div v-show="tab === 'episodes'" class="h-full">
|
|
<EpisodeReader v-if="episodes.length" :episodes="episodes" />
|
|
<EmptyState
|
|
v-else
|
|
title="剧本正在酝酿"
|
|
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可展开执行记录查看进度。"
|
|
/>
|
|
</div>
|
|
<NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
|
|
><div v-if="tab === 'characters'" class="p-6 lg:p-8">
|
|
<div v-if="project?.characters.length" class="record-list">
|
|
<article v-for="character in project.characters" :key="character.id">
|
|
<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>
|
|
<NScrollbar class="code-scroll mt-3" x-scrollable>
|
|
<pre class="json-view">{{
|
|
JSON.stringify(project.world.rules, null, 2)
|
|
}}</pre>
|
|
</NScrollbar>
|
|
</NCollapseItem></NCollapse
|
|
>
|
|
</div>
|
|
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
|
|
</div>
|
|
<div v-if="tab === 'review'" class="p-6 lg:p-8">
|
|
<p v-if="typeof state?.reviewPassed === 'boolean'" class="text-sm">
|
|
{{ state.reviewPassed ? '最近工作流审核通过' : '最近工作流审核未通过' }}
|
|
</p>
|
|
<EmptyState
|
|
v-else
|
|
title="暂无可用审核结果"
|
|
description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。"
|
|
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
|
|
><NCollapseItem name="details"
|
|
><template #header>查看改写建议原文</template>
|
|
<NScrollbar class="code-scroll mt-3" x-scrollable>
|
|
<pre class="json-view">{{
|
|
JSON.stringify(state.rewriteSuggestion, null, 2)
|
|
}}</pre>
|
|
</NScrollbar>
|
|
</NCollapseItem></NCollapse
|
|
>
|
|
</div>
|
|
</NScrollbar>
|
|
</main>
|
|
<HistoryPanel
|
|
v-if="showHistory"
|
|
:project-id="project?.id"
|
|
:checkpoints="checkpoints"
|
|
workflow="create-drama"
|
|
@close="showHistory = false"
|
|
/>
|
|
</div></div
|
|
></WorkspacePage>
|
|
</template>
|
|
|
|
<style>
|
|
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
|
@reference "../../styles/styles.css";
|
|
.stage-strip {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
.stage-strip-track {
|
|
@apply flex min-w-0 flex-1 flex-wrap items-center gap-3.5;
|
|
}
|
|
.stage-item {
|
|
@apply flex shrink-0 items-center gap-[7px] text-muted text-[11px];
|
|
}
|
|
.stage-item.done {
|
|
@apply text-ink;
|
|
}
|
|
.stage-arrow {
|
|
@apply ml-3 text-muted;
|
|
}
|
|
.json-view {
|
|
@apply whitespace-pre-wrap wrap-anywhere p-4 bg-(--app-subtle) rounded-none font-mono text-[11px] leading-[1.9];
|
|
}
|
|
@media (max-width: 800px) {
|
|
.script-workspace-page .workspace-heading {
|
|
@apply py-2.5 px-3;
|
|
}
|
|
.stage-strip {
|
|
@apply gap-2;
|
|
}
|
|
.stage-strip-track {
|
|
@apply min-w-0 flex-1 flex-nowrap overflow-x-auto overflow-y-hidden overscroll-x-contain;
|
|
scrollbar-width: none;
|
|
mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent);
|
|
}
|
|
.stage-strip-track::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
.stage-arrow {
|
|
@apply ml-[3px];
|
|
}
|
|
.script-section .tabs-toolbar {
|
|
container-type: normal;
|
|
}
|
|
.script-section .workspace-tabs > .n-tabs-nav {
|
|
@apply flex-nowrap;
|
|
}
|
|
.script-section .workspace-tabs .n-tabs-nav-scroll-wrapper {
|
|
@apply min-w-0 flex-1;
|
|
flex-basis: auto;
|
|
}
|
|
.script-section .workspace-tabs .n-tabs-nav__suffix {
|
|
@apply w-auto pl-2;
|
|
}
|
|
.script-section .tabs-toolbar-actions {
|
|
@apply min-h-0 flex-nowrap;
|
|
}
|
|
.script-section .tabs-toolbar-actions .script-next-link {
|
|
@apply hidden;
|
|
}
|
|
.script-section .content-with-history:not(.history-hidden) {
|
|
@apply relative;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
grid-template-rows: minmax(0, 1fr);
|
|
}
|
|
.script-section .content-with-history:not(.history-hidden) .history-panel {
|
|
@apply absolute inset-0 z-30;
|
|
}
|
|
}
|
|
.script-section {
|
|
@apply flex-1 flex flex-col min-h-0;
|
|
}
|
|
.script-section > :first-child {
|
|
@apply shrink-0;
|
|
}
|
|
.script-section .content-with-history {
|
|
@apply flex-1 h-auto min-h-0;
|
|
}
|
|
.script-workspace-page .workspace-split > :not(.script-section) {
|
|
@apply shrink-0;
|
|
}
|
|
.script-section .n-tabs-tab__label {
|
|
@apply inline-flex items-center gap-2;
|
|
}
|
|
.script-section .content-with-history.history-hidden {
|
|
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
|
|
}
|
|
</style>
|