3c9b65d982
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
20 KiB
Vue
405 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import type { TabsItem } from '@nuxt/ui'
|
||
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
|
||
|
||
const props = defineProps<{ chapterId: string }>()
|
||
const store = useProjectStore()
|
||
const api = useApi()
|
||
const { running, live, chapterRev, agents } = store
|
||
|
||
const chapter = ref<ChapterDetail | null>(null)
|
||
const loading = ref(true)
|
||
|
||
async function load() {
|
||
try {
|
||
chapter.value = await api.get<ChapterDetail>(`/api/chapters/${props.chapterId}`)
|
||
}
|
||
finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
load()
|
||
watch(() => chapterRev[props.chapterId], () => { if (!editing.value && !editingBrief.value) load() })
|
||
|
||
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
|
||
const writerItems = computed(() => writers.value.map(w => ({ label: w.name + (w.createdBy === 'editor' ? '(主编创建)' : ''), value: w.id })))
|
||
const currentWriter = computed(() => agents.value.find(a => a.id === chapter.value?.writerAgentId))
|
||
|
||
const isWriting = computed(() => chapter.value?.status === 'writing')
|
||
const liveText = computed(() => live[props.chapterId]?.text ?? '')
|
||
const displayText = computed(() => (isWriting.value ? liveText.value : chapter.value?.content ?? ''))
|
||
const activeVersion = computed(() => chapter.value?.versions.find(v => v.id === chapter.value?.activeVersionId))
|
||
|
||
/* ---------- 标题 ---------- */
|
||
const titleDraft = ref('')
|
||
watch(() => chapter.value?.title, v => (titleDraft.value = v ?? ''), { immediate: true })
|
||
async function saveTitle() {
|
||
if (!chapter.value || titleDraft.value === chapter.value.title) return
|
||
await api.put(`/api/chapters/${props.chapterId}`, { title: titleDraft.value })
|
||
}
|
||
|
||
/* ---------- 作家指派 ---------- */
|
||
async function assignWriter(id: string) {
|
||
await api.put(`/api/chapters/${props.chapterId}`, { writerAgentId: id, writerLocked: true })
|
||
}
|
||
async function unlockWriter() {
|
||
await api.put(`/api/chapters/${props.chapterId}`, { writerLocked: false })
|
||
}
|
||
|
||
/* ---------- 正文:实时滚动 ---------- */
|
||
const textBox = ref<HTMLElement | null>(null)
|
||
const followTail = ref(true)
|
||
function onScroll() {
|
||
const el = textBox.value
|
||
if (!el) return
|
||
followTail.value = el.scrollHeight - el.scrollTop - el.clientHeight < 80
|
||
}
|
||
watch(liveText, async () => {
|
||
if (!isWriting.value || !followTail.value) return
|
||
await nextTick()
|
||
textBox.value?.scrollTo({ top: textBox.value.scrollHeight })
|
||
})
|
||
|
||
/* ---------- 正文:手动编辑 ---------- */
|
||
const editing = ref(false)
|
||
const contentDraft = ref('')
|
||
function startEdit() {
|
||
contentDraft.value = chapter.value?.content ?? ''
|
||
editing.value = true
|
||
}
|
||
async function saveContent() {
|
||
if (!chapter.value?.activeVersionId) return
|
||
await api.put(`/api/versions/${chapter.value.activeVersionId}`, { content: contentDraft.value })
|
||
editing.value = false
|
||
await load()
|
||
api.toast.add({ title: '已保存到文件', color: 'success', icon: 'i-lucide-save' })
|
||
}
|
||
|
||
/* ---------- 简报 ---------- */
|
||
const editingBrief = ref(false)
|
||
const briefDraft = ref('')
|
||
function startEditBrief() {
|
||
briefDraft.value = chapter.value?.brief || chapter.value?.synopsis || ''
|
||
editingBrief.value = true
|
||
}
|
||
async function saveBrief() {
|
||
await api.put(`/api/chapters/${props.chapterId}`, { brief: briefDraft.value })
|
||
editingBrief.value = false
|
||
await load()
|
||
}
|
||
const synopsisDraft = ref('')
|
||
watch(() => chapter.value?.synopsis, v => (synopsisDraft.value = v ?? ''), { immediate: true })
|
||
async function saveSynopsis() {
|
||
if (synopsisDraft.value === chapter.value?.synopsis) return
|
||
await api.put(`/api/chapters/${props.chapterId}`, { synopsis: synopsisDraft.value })
|
||
}
|
||
|
||
/* ---------- 动作 ---------- */
|
||
const act = (path: string, body?: unknown) => api.post(`/api/chapters/${props.chapterId}/${path}`, body)
|
||
const genBrief = () => act('brief')
|
||
const write = () => act('write', {})
|
||
const approve = (next: boolean) => act('approve', { continue: next })
|
||
const review = async () => { await act('review'); tab.value = 'review' }
|
||
const reopen = () => act('reopen')
|
||
|
||
const reviseOpen = ref(false)
|
||
const reviseForm = reactive({ feedback: '', mode: 'revise' as 'revise' | 'rewrite', writerAgentId: '' })
|
||
function openRevise(useReview = false) {
|
||
reviseForm.feedback = useReview ? (chapter.value?.reviewNotes ?? '') : ''
|
||
reviseForm.mode = chapter.value?.activeVersionId ? 'revise' : 'rewrite'
|
||
reviseForm.writerAgentId = chapter.value?.writerAgentId ?? writers.value[0]?.id ?? ''
|
||
reviseOpen.value = true
|
||
}
|
||
async function submitRevise() {
|
||
await act('write', { feedback: reviseForm.feedback, revise: reviseForm.mode === 'revise', writerAgentId: reviseForm.writerAgentId || null })
|
||
reviseOpen.value = false
|
||
tab.value = 'text'
|
||
}
|
||
|
||
async function activate(versionId: string) {
|
||
await act('activate', { versionId })
|
||
tab.value = 'text'
|
||
}
|
||
const viewing = ref<{ version: number, content: string } | null>(null)
|
||
async function viewVersion(id: string) {
|
||
const v = await api.get<{ version: number, content: string }>(`/api/versions/${id}`)
|
||
viewing.value = v
|
||
}
|
||
|
||
const deleteOpen = ref(false)
|
||
async function deleteChapter() {
|
||
await api.del(`/api/chapters/${props.chapterId}`)
|
||
deleteOpen.value = false
|
||
await store.refreshChapters()
|
||
}
|
||
|
||
/* ---------- 摘要 ---------- */
|
||
const summaryDraft = ref('')
|
||
watch(() => chapter.value?.summary, v => (summaryDraft.value = v ?? ''), { immediate: true })
|
||
async function saveSummary() {
|
||
await api.put(`/api/chapters/${props.chapterId}`, { summary: summaryDraft.value })
|
||
api.toast.add({ title: '摘要已保存', color: 'success' })
|
||
}
|
||
|
||
const tab = ref('text')
|
||
const tabs = computed<TabsItem[]>(() => [
|
||
{ label: '正文', value: 'text', icon: 'i-lucide-file-text' },
|
||
{ label: '简报', value: 'brief', icon: 'i-lucide-clipboard-list', badge: chapter.value?.brief ? undefined : { label: '空', color: 'neutral', variant: 'subtle' } },
|
||
{ label: `版本 (${chapter.value?.versions.length ?? 0})`, value: 'versions', icon: 'i-lucide-history' },
|
||
{ label: '审稿与摘要', value: 'review', icon: 'i-lucide-message-square-text' }
|
||
])
|
||
|
||
// 没有正文时默认显示简报
|
||
watch(() => chapter.value?.id, () => {
|
||
if (chapter.value && !chapter.value.activeVersionId && chapter.value.status !== 'writing') tab.value = 'brief'
|
||
}, { immediate: true })
|
||
watch(isWriting, (v) => { if (v) tab.value = 'text' })
|
||
|
||
const VERSION_STATUS: Record<string, { label: string, color: 'success' | 'warning' | 'error' | 'neutral' }> = {
|
||
done: { label: '完成', color: 'success' },
|
||
writing: { label: '写作中', color: 'warning' },
|
||
error: { label: '出错', color: 'error' },
|
||
cancelled: { label: '已取消', color: 'neutral' }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="loading" class="flex justify-center py-20">
|
||
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin text-muted" />
|
||
</div>
|
||
<div v-else-if="chapter" class="flex flex-col gap-4 h-full">
|
||
<!-- 头部 -->
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<span class="text-muted font-medium shrink-0">第{{ chapter.index }}章</span>
|
||
<UInput
|
||
v-model="titleDraft"
|
||
variant="ghost"
|
||
placeholder="章节标题"
|
||
class="flex-1 min-w-48"
|
||
:ui="{ base: 'text-lg font-semibold' }"
|
||
@blur="saveTitle"
|
||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||
/>
|
||
<UBadge :color="CHAPTER_STATUS_COLOR[chapter.status]" variant="subtle">
|
||
{{ CHAPTER_STATUS_LABEL[chapter.status] }}
|
||
</UBadge>
|
||
<div class="flex items-center gap-1">
|
||
<USelect
|
||
:model-value="chapter.writerAgentId ?? undefined"
|
||
:items="writerItems"
|
||
placeholder="由主编指派作家"
|
||
size="sm"
|
||
class="w-48"
|
||
icon="i-lucide-pen-tool"
|
||
:disabled="isWriting"
|
||
@update:model-value="assignWriter"
|
||
/>
|
||
<UTooltip v-if="chapter.writerLocked" text="已锁定作家(主编不会更换)。点击交给主编决定">
|
||
<UButton icon="i-lucide-lock" size="sm" color="neutral" variant="ghost" @click="unlockWriter" />
|
||
</UTooltip>
|
||
</div>
|
||
<UDropdownMenu :items="[[{ label: '删除本章', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleteOpen = true) }]]">
|
||
<UButton icon="i-lucide-ellipsis-vertical" size="sm" color="neutral" variant="ghost" />
|
||
</UDropdownMenu>
|
||
</div>
|
||
|
||
<!-- 动作条 -->
|
||
<div class="flex flex-wrap items-center gap-2 rounded-lg bg-elevated/50 border border-default px-3 py-2">
|
||
<template v-if="isWriting">
|
||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin text-warning" />
|
||
<span class="text-sm">
|
||
{{ currentWriter?.name || '作家' }} 正在写作,已写 {{ liveText.replace(/\s/g, '').length }} 字,同步写入
|
||
<code class="text-xs">{{ chapter.versions[0]?.filePath }}</code>
|
||
</span>
|
||
<UButton class="ms-auto" icon="i-lucide-square" label="停止" size="sm" color="error" variant="soft" @click="store.stop()" />
|
||
</template>
|
||
|
||
<template v-else-if="chapter.status === 'written'">
|
||
<span class="text-sm text-muted me-auto">本章已写完,请阅读后确认。确认后会生成摘要、更新故事记忆。</span>
|
||
<UButton icon="i-lucide-check-check" label="确认并继续下一章" size="sm" :disabled="running" @click="approve(true)" />
|
||
<UButton icon="i-lucide-check" label="仅确认" size="sm" variant="soft" :disabled="running" @click="approve(false)" />
|
||
<UButton icon="i-lucide-scan-search" label="主编审稿" size="sm" color="neutral" variant="outline" :disabled="running" @click="review" />
|
||
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
|
||
</template>
|
||
|
||
<template v-else-if="chapter.status === 'approved'">
|
||
<UIcon name="i-lucide-circle-check" class="size-4 text-success" />
|
||
<span class="text-sm text-muted me-auto">本章已确认</span>
|
||
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
|
||
<UButton icon="i-lucide-undo-2" label="撤销确认" size="sm" color="neutral" variant="ghost" :disabled="running" @click="reopen" />
|
||
</template>
|
||
|
||
<template v-else>
|
||
<span class="text-sm text-muted me-auto">
|
||
{{ chapter.brief ? '简报已就绪,可以开始写作,也可以先修改简报。' : '还没有简报:可以让主编生成,也可以手动填写。' }}
|
||
</span>
|
||
<UButton icon="i-lucide-sparkles" :label="chapter.brief ? '主编重写简报' : '主编生成简报'" size="sm" :variant="chapter.brief ? 'soft' : 'solid'" :disabled="running" @click="genBrief" />
|
||
<UButton icon="i-lucide-pen-line" label="开始写作" size="sm" :variant="chapter.brief ? 'solid' : 'soft'" :disabled="running || (!chapter.brief && !chapter.synopsis)" @click="write" />
|
||
</template>
|
||
</div>
|
||
|
||
<UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
|
||
|
||
<!-- 正文 -->
|
||
<div v-if="tab === 'text'" class="flex-1 min-h-0 flex flex-col gap-2">
|
||
<div v-if="activeVersion && !isWriting" class="flex items-center gap-2 text-xs text-muted">
|
||
<span>v{{ activeVersion.version }} · {{ activeVersion.writerName }} · {{ activeVersion.modelLabel }} · {{ activeVersion.wordCount }} 字</span>
|
||
<code class="truncate">{{ activeVersion.filePath }}</code>
|
||
<div class="ms-auto flex gap-1">
|
||
<template v-if="editing">
|
||
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editing = false" />
|
||
<UButton label="保存" icon="i-lucide-save" size="xs" @click="saveContent" />
|
||
</template>
|
||
<UButton v-else label="编辑正文" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="running" @click="startEdit" />
|
||
</div>
|
||
</div>
|
||
<UTextarea v-if="editing" v-model="contentDraft" autoresize :rows="20" :maxrows="0" class="w-full" :ui="{ base: 'novel-text' }" />
|
||
<div
|
||
v-else-if="displayText"
|
||
ref="textBox"
|
||
class="overflow-y-auto rounded-lg border border-default bg-default px-8 py-6 max-h-[calc(100vh-18rem)]"
|
||
@scroll="onScroll"
|
||
>
|
||
<NovelText :text="displayText" :writing="isWriting" class="max-w-3xl mx-auto" />
|
||
</div>
|
||
<div v-else class="text-center text-muted py-16 text-sm">
|
||
{{ isWriting ? '等待模型输出…' : '还没有正文' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 简报 -->
|
||
<div v-else-if="tab === 'brief'" class="space-y-4">
|
||
<UFormField label="章节梗概" hint="来自大纲规划,可修改">
|
||
<UTextarea v-model="synopsisDraft" autoresize :rows="2" class="w-full" @blur="saveSynopsis" />
|
||
</UFormField>
|
||
<div class="flex items-center gap-2">
|
||
<h3 class="font-medium">
|
||
章节简报
|
||
</h3>
|
||
<span class="text-xs text-muted">主编写给作家的执行指令,作家会严格按此写作</span>
|
||
<div class="ms-auto flex gap-1">
|
||
<template v-if="editingBrief">
|
||
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editingBrief = false" />
|
||
<UButton label="保存简报" icon="i-lucide-save" size="xs" @click="saveBrief" />
|
||
</template>
|
||
<template v-else>
|
||
<UButton label="编辑" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="isWriting" @click="startEditBrief" />
|
||
<UButton :label="chapter.brief ? '主编重写' : '主编生成'" icon="i-lucide-sparkles" size="xs" variant="soft" :disabled="running" @click="genBrief" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<UTextarea v-if="editingBrief" v-model="briefDraft" autoresize :rows="14" :maxrows="0" class="w-full font-mono text-sm" placeholder="支持 Markdown。写清楚本章目标、情节要点、出场人物、场景、基调、结尾钩子等。" />
|
||
<div v-else-if="chapter.brief" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.brief)" />
|
||
<div v-else class="text-sm text-muted rounded-lg border border-dashed border-default px-5 py-8 text-center">
|
||
暂无简报
|
||
</div>
|
||
<UAlert v-if="chapter.editorNotes" icon="i-lucide-message-circle" color="neutral" variant="subtle" title="主编说明" :description="chapter.editorNotes" :ui="{ description: 'whitespace-pre-wrap' }" />
|
||
</div>
|
||
|
||
<!-- 版本 -->
|
||
<div v-else-if="tab === 'versions'" class="space-y-2">
|
||
<div v-if="!chapter.versions.length" class="text-sm text-muted py-8 text-center">
|
||
还没有版本
|
||
</div>
|
||
<div v-for="v in chapter.versions" :key="v.id" class="flex flex-wrap items-center gap-3 rounded-lg border border-default px-4 py-3" :class="v.id === chapter.activeVersionId ? 'ring-1 ring-primary' : ''">
|
||
<span class="font-medium">v{{ v.version }}</span>
|
||
<UBadge :color="VERSION_STATUS[v.status]?.color" variant="subtle" size="sm">
|
||
{{ VERSION_STATUS[v.status]?.label }}
|
||
</UBadge>
|
||
<UBadge v-if="v.id === chapter.activeVersionId" color="primary" variant="soft" size="sm">
|
||
当前
|
||
</UBadge>
|
||
<span class="text-sm">{{ v.writerName }}</span>
|
||
<span class="text-xs text-muted">{{ v.modelLabel }} · {{ v.wordCount }} 字 · {{ formatTime(v.createdAt) }}</span>
|
||
<div class="ms-auto flex gap-1">
|
||
<UButton label="查看" size="xs" color="neutral" variant="ghost" @click="viewVersion(v.id)" />
|
||
<UButton v-if="v.id !== chapter.activeVersionId && v.status !== 'writing'" label="设为当前" size="xs" variant="soft" :disabled="running" @click="activate(v.id)" />
|
||
</div>
|
||
<p v-if="v.feedback" class="w-full text-xs text-muted whitespace-pre-wrap line-clamp-3">
|
||
修改意见:{{ v.feedback }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 审稿与摘要 -->
|
||
<div v-else class="space-y-6">
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-2">
|
||
<h3 class="font-medium">
|
||
主编审稿意见
|
||
</h3>
|
||
<div class="ms-auto flex gap-1">
|
||
<UButton v-if="chapter.reviewNotes" label="按审稿意见修改" icon="i-lucide-wand-sparkles" size="xs" variant="soft" :disabled="running" @click="openRevise(true)" />
|
||
<UButton label="主编审稿" icon="i-lucide-scan-search" size="xs" color="neutral" variant="outline" :disabled="running || !chapter.activeVersionId" @click="review" />
|
||
</div>
|
||
</div>
|
||
<div v-if="chapter.reviewNotes" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.reviewNotes)" />
|
||
<p v-else class="text-sm text-muted">
|
||
暂无审稿意见
|
||
</p>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-2">
|
||
<h3 class="font-medium">
|
||
本章摘要
|
||
</h3>
|
||
<span class="text-xs text-muted">确认章节时自动生成;主编和后续作家只会看到摘要而不是全文</span>
|
||
<UButton class="ms-auto" label="保存" icon="i-lucide-save" size="xs" variant="soft" :disabled="summaryDraft === chapter.summary" @click="saveSummary" />
|
||
</div>
|
||
<UTextarea v-model="summaryDraft" autoresize :rows="6" class="w-full" placeholder="确认章节后自动生成" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 修改 / 重写 -->
|
||
<UModal v-model:open="reviseOpen" title="修改 / 重写本章" :ui="{ content: 'max-w-2xl' }">
|
||
<template #body>
|
||
<div class="space-y-4">
|
||
<UFormField label="方式">
|
||
<URadioGroup
|
||
v-model="reviseForm.mode"
|
||
orientation="horizontal"
|
||
:items="[
|
||
{ label: '在当前版本上修改', value: 'revise', disabled: !chapter?.activeVersionId },
|
||
{ label: '按简报重新写一版', value: 'rewrite' }
|
||
]"
|
||
/>
|
||
</UFormField>
|
||
<UFormField label="执笔作家">
|
||
<USelect v-model="reviseForm.writerAgentId" :items="writerItems" class="w-full" />
|
||
</UFormField>
|
||
<UFormField label="修改意见" :hint="reviseForm.mode === 'revise' ? '必填' : '可选'">
|
||
<UTextarea v-model="reviseForm.feedback" autoresize :rows="6" :maxrows="16" class="w-full" placeholder="例如:节奏太快,第二场对话要更有张力;结尾不要点破悬念……" />
|
||
</UFormField>
|
||
<p class="text-xs text-muted">
|
||
新内容会保存为新版本,旧版本仍可在「版本」中查看和恢复。
|
||
</p>
|
||
</div>
|
||
</template>
|
||
<template #footer="{ close }">
|
||
<div class="flex justify-end gap-2 w-full">
|
||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||
<UButton label="开始" icon="i-lucide-play" :disabled="running || (reviseForm.mode === 'revise' && !reviseForm.feedback.trim())" @click="submitRevise" />
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<USlideover :open="!!viewing" :title="viewing ? `版本 v${viewing.version}` : ''" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (viewing = null)">
|
||
<template #body>
|
||
<NovelText :text="viewing?.content ?? ''" />
|
||
</template>
|
||
</USlideover>
|
||
|
||
<UModal v-model:open="deleteOpen" title="删除章节">
|
||
<template #body>
|
||
<p>确定删除第{{ chapter?.index }}章吗?之后的章节序号会前移。已生成的文件会保留在磁盘上。</p>
|
||
</template>
|
||
<template #footer="{ close }">
|
||
<div class="flex justify-end gap-2 w-full">
|
||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||
<UButton label="删除" color="error" @click="deleteChapter" />
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
</template>
|