schema v3 新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;一次粘贴生成一个章节, 原文按收到的字符串逐字保存;处理任务具备持久状态机、启动恢复与 requestId 幂等。 新增书籍/章节/任务 API 一律按认证身份过滤归属,他人编号返回 404,后台任务不使用 客户端用户编号;学习端补齐粘贴导入、书库、章节状态与失败重试、原文阅读与章节切换。 测试:MySQL 集成测试覆盖导入→处理中→就绪→阅读完整路径、失败重试、幂等、越权与 Unicode 原文保真;学习端 Vitest 31 项、Playwright 3 项与构建通过;lexgo_dev 已显式 迁移到 v3,迁移前后既有数据指纹不变。 文档:Wiki 先写后回读(架构、业务规则、本地验证、需求总览、Home),导出核心镜像。
95 lines
3.9 KiB
Vue
95 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
import { ElButton } from 'element-plus'
|
|
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
|
|
import { useSessionStore } from '../stores/session'
|
|
|
|
const session = useSessionStore()
|
|
const library = useLibraryStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const retryError = ref('')
|
|
|
|
const bookId = computed(() => Number(route.params.id))
|
|
|
|
async function load() {
|
|
retryError.value = ''
|
|
await library.loadBook(bookId.value)
|
|
}
|
|
|
|
async function retry(chapterId: number) {
|
|
retryError.value = ''
|
|
try { await library.retryChapter(chapterId) }
|
|
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
|
|
}
|
|
|
|
async function logout() {
|
|
try { await session.logout() }
|
|
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
|
finally { await router.replace('/login') }
|
|
}
|
|
|
|
onMounted(load)
|
|
watch(bookId, () => { void load() })
|
|
// Leaving the page releases the book so polling stops.
|
|
onUnmounted(() => library.closeBook())
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="session.user">
|
|
<header class="site-header">
|
|
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
|
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
|
|
<div class="account">
|
|
<span class="account-name">{{ session.user.username }}</span>
|
|
<ElButton text @click="logout">退出登录</ElButton>
|
|
</div>
|
|
</header>
|
|
<main class="page">
|
|
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>{{ library.book?.title ?? '书籍' }}</span></p>
|
|
<p v-if="library.bookLoading && !library.book" role="status" class="loading">正在加载…</p>
|
|
<div v-else-if="library.bookError" class="notice">
|
|
<p role="alert">{{ library.bookError }}</p>
|
|
<ElButton @click="load">重试</ElButton>
|
|
</div>
|
|
<template v-else-if="library.book">
|
|
<div class="page-title">
|
|
<div>
|
|
<h1>{{ library.book.title }}</h1>
|
|
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
|
|
</div>
|
|
</div>
|
|
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
|
|
<ul v-if="library.chapters.length" class="chapter-list" aria-label="章节列表">
|
|
<li v-for="item in library.chapters" :key="item.id" class="chapter-row">
|
|
<span class="chapter-ordinal">{{ item.ordinal }}</span>
|
|
<div class="chapter-info">
|
|
<RouterLink v-if="item.status === 'ready'" :to="`/chapters/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
|
|
<span v-else class="chapter-name">{{ item.title }}</span>
|
|
<p class="chapter-meta">
|
|
{{ item.charCount }} 字符
|
|
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
|
|
</p>
|
|
</div>
|
|
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
|
|
<ElButton
|
|
v-if="canRetry(item)"
|
|
size="small"
|
|
:loading="library.retryingChapterId === item.id"
|
|
@click="retry(item.id)"
|
|
>重试</ElButton>
|
|
</li>
|
|
</ul>
|
|
<section v-else class="empty-library" aria-label="章节列表">
|
|
<h2>这一本书还没有章节</h2>
|
|
<p class="subtle">粘贴一段英文即可生成第一章。</p>
|
|
</section>
|
|
</template>
|
|
</main>
|
|
</div>
|
|
</template>
|