Files
ai-novel-studio/app/pages/projects/[id]/index.vue
T
jocay 3c9b65d982 AI 小说工坊:主编 + 作家多模型协作写作应用
- 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>
2026-10-18 02:04:40 +08:00

152 lines
6.8 KiB
Vue
Raw 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 { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
const store = useProjectStore()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { chapters, running, live } = store
const selectedId = computed<string | null>({
get: () => (route.query.c as string) || chapters.value.find(c => c.status !== 'approved')?.id || chapters.value.at(-1)?.id || null,
set: v => router.replace({ query: { ...route.query, c: v ?? undefined } })
})
// 写作开始时自动切换到正在写的章节
watch(() => chapters.value.find(c => c.status === 'writing')?.id, (id) => {
if (id) selectedId.value = id
})
const totalWords = computed(() => chapters.value.reduce((s, c) => s + c.wordCount, 0))
const approvedCount = computed(() => chapters.value.filter(c => c.status === 'approved').length)
/* 规划大纲 */
const planOpen = ref(false)
const planForm = reactive({ count: 10, fromIndex: null as number | null })
async function plan() {
await api.post(`/api/projects/${store.projectId}/plan`, { count: planForm.count, ...(planForm.fromIndex ? { fromIndex: planForm.fromIndex } : {}) })
planOpen.value = false
}
/* 新增章节 */
async function addChapter() {
const row = await api.post<{ id: string }>(`/api/projects/${store.projectId}/chapters`, { title: '' })
await store.refreshChapters()
selectedId.value = row.id
}
const exportUrl = (format: string, scope: string) => `/api/projects/${store.projectId}/export?format=${format}&scope=${scope}`
const exportItems = [
[{ label: '导出全部章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'all'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'all'), external: true, target: '_blank' }],
[{ label: '仅导出已确认章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'approved'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'approved'), external: true, target: '_blank' }]
]
function liveWords(chapterId: string) {
const t = live[chapterId]?.text
return t ? t.replace(/\s/g, '').length : 0
}
</script>
<template>
<UDashboardPanel id="chapter-list" :default-size="22" :min-size="16" :max-size="35" resizable>
<template #header>
<UDashboardNavbar title="章节">
<template #right>
<UTooltip text="让主编规划章节大纲">
<UButton icon="i-lucide-list-tree" size="sm" color="neutral" variant="ghost" :disabled="running" @click="planOpen = true" />
</UTooltip>
<UTooltip text="手动新增章节">
<UButton icon="i-lucide-plus" size="sm" color="neutral" variant="ghost" @click="addChapter" />
</UTooltip>
<UDropdownMenu :items="exportItems">
<UButton icon="i-lucide-download" size="sm" color="neutral" variant="ghost" />
</UDropdownMenu>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="text-xs text-muted flex gap-3 px-1 -mt-1 mb-2">
<span>{{ approvedCount }}/{{ chapters.length }} 章已确认</span>
<span>{{ formatWords(totalWords) }}</span>
</div>
<div v-if="!chapters.length" class="text-sm text-muted text-center py-10 space-y-3">
<p>还没有章节</p>
<UButton label="让主编规划大纲" icon="i-lucide-list-tree" size="sm" :disabled="running" @click="planOpen = true" />
<p class="text-xs">
或直接点击右上角「继续下一章」,主编会自动规划
</p>
</div>
<div class="flex flex-col gap-1">
<button
v-for="c in chapters"
:key="c.id"
class="text-left rounded-md px-2.5 py-2 transition border border-transparent"
:class="c.id === selectedId ? 'bg-elevated border-default' : 'hover:bg-elevated/50'"
@click="selectedId = c.id"
>
<div class="flex items-center gap-2">
<span class="text-xs text-muted tabular-nums w-10 shrink-0">第{{ c.index }}章</span>
<span class="truncate text-sm font-medium flex-1">{{ c.title || '(未命名)' }}</span>
<UIcon v-if="c.status === 'writing'" name="i-lucide-loader-circle" class="size-3.5 animate-spin text-warning" />
</div>
<div class="flex items-center gap-2 mt-1 ps-12">
<UBadge :color="CHAPTER_STATUS_COLOR[c.status]" variant="subtle" size="sm">
{{ CHAPTER_STATUS_LABEL[c.status] }}
</UBadge>
<span v-if="c.status === 'writing'" class="text-xs text-muted">{{ liveWords(c.id) }} 字…</span>
<span v-else-if="c.wordCount" class="text-xs text-muted">{{ c.wordCount }} 字</span>
</div>
</button>
</div>
</template>
</UDashboardPanel>
<UDashboardPanel id="chapter-main">
<template #header>
<UDashboardNavbar :title="store.project.value?.title">
<template #right>
<RunControls />
</template>
</UDashboardNavbar>
</template>
<template #body>
<ChapterWorkspace v-if="selectedId" :key="selectedId" :chapter-id="selectedId" />
<div v-else class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<UIcon name="i-lucide-feather" class="size-12 text-muted" />
<div class="space-y-1">
<p class="font-medium">
准备开始
</p>
<p class="text-sm text-muted max-w-md">
确认已经填写「故事设定」、添加了角色,并在「AI 团队」中为主编和作家分配了模型。然后点击右上角「继续下一章」,主编会先规划章节,再为第一章写简报并交给作家。
</p>
</div>
</div>
</template>
</UDashboardPanel>
<UModal v-model:open="planOpen" title="主编规划章节大纲">
<template #body>
<div class="space-y-4">
<p class="text-sm text-muted">
主编会根据故事设定、大纲和已完成章节的摘要,规划后续章节的标题和梗概。已开写的章节不会被改动。
</p>
<div class="grid grid-cols-2 gap-3">
<UFormField label="规划章节数">
<UInputNumber v-model="planForm.count" :min="1" :max="100" />
</UFormField>
<UFormField label="起始章节" hint="留空=接在已写章节之后">
<UInputNumber v-model="planForm.fromIndex" :min="1" placeholder="自动" />
</UFormField>
</div>
</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-sparkles" @click="plan" />
</div>
</template>
</UModal>
</template>