cc9048b799
- 手机上章节列表与工作区分两屏,点章节进入工作区,左上角返回 - 列表页顶部提供「继续下一章」与流程说明;工作区顶栏使用紧凑版控制按钮 - 在列表页启动写作后自动进入即将处理的章节 - 精简标签页文字、正文留白与版本信息,避免小屏截断 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
8.6 KiB
Vue
187 lines
8.6 KiB
Vue
<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
|
||
})
|
||
|
||
// 手机上列表和工作区分两屏:URL 带 ?c= 时显示章节工作区
|
||
const showDetail = computed(() => Boolean(route.query.c))
|
||
function backToList() {
|
||
const { c: _c, ...rest } = route.query
|
||
router.replace({ query: rest })
|
||
}
|
||
// 在列表页点「继续下一章」后,直接进入即将处理的章节
|
||
watch(running, (v) => {
|
||
if (!v || route.query.c) return
|
||
const next = chapters.value.find(c => c.status !== 'approved')
|
||
if (next) selectedId.value = next.id
|
||
})
|
||
const nextChapter = computed(() => chapters.value.find(c => c.status !== 'approved'))
|
||
|
||
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 :class="showDetail ? 'hidden lg:flex' : ''">
|
||
<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="lg:hidden rounded-lg border border-default bg-elevated/40 p-3 mb-3 space-y-2">
|
||
<RunControls />
|
||
<p class="text-xs text-muted leading-relaxed">
|
||
<template v-if="nextChapter">
|
||
点「继续下一章」:主编为第{{ nextChapter.index }}章写简报 → 作家写正文 → 写完暂停等你确认。也可以点下面任意一章,手动生成简报、指定作家后再写。
|
||
</template>
|
||
<template v-else>
|
||
点「继续下一章」,主编会先规划后续章节。
|
||
</template>
|
||
</p>
|
||
</div>
|
||
<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" />
|
||
<UIcon name="i-lucide-chevron-right" class="size-4 text-muted lg:hidden" />
|
||
</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" :class="showDetail ? '' : 'hidden lg:flex'">
|
||
<template #header>
|
||
<UDashboardNavbar :title="store.project.value?.title" :ui="{ title: 'truncate' }">
|
||
<template #leading>
|
||
<UButton icon="i-lucide-arrow-left" label="章节" color="neutral" variant="ghost" size="sm" class="lg:hidden -ms-1.5" @click="backToList" />
|
||
</template>
|
||
<template #right>
|
||
<div class="hidden lg:block">
|
||
<RunControls />
|
||
</div>
|
||
<div class="lg:hidden">
|
||
<RunControls compact />
|
||
</div>
|
||
</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>
|