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

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

187 lines
8.6 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
})
// 手机上列表和工作区分两屏: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>