Files
jocay cc9048b799 写作台适配手机:列表与章节工作区分屏切换
- 手机上章节列表与工作区分两屏,点章节进入工作区,左上角返回
- 列表页顶部提供「继续下一章」与流程说明;工作区顶栏使用紧凑版控制按钮
- 在列表页启动写作后自动进入即将处理的章节
- 精简标签页文字、正文留白与版本信息,避免小屏截断

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:47:18 +08:00

405 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import 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' },
{ label: '简报', value: 'brief', badge: chapter.value?.brief ? undefined : { label: '空', color: 'neutral', variant: 'subtle' } },
{ label: `版本 ${chapter.value?.versions.length ?? 0}`, value: 'versions' },
{ label: '审稿·摘要', value: 'review' }
])
// 没有正文时默认显示简报
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 }}<span class="hidden sm:inline"> · {{ activeVersion.modelLabel }}</span> · {{ activeVersion.wordCount }} 字</span>
<code class="truncate hidden sm:inline">{{ 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-4 py-4 sm:px-8 sm: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 hidden sm:inline">主编写给作家的执行指令,作家会严格按此写作</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 hidden sm:inline">确认章节时自动生成;主编和后续作家只会看到摘要而不是全文</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>