- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与 lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书 - 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析), 音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给 http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置 - 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」 区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报) - 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E - 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试 - 文档:Architecture / Business-Rules / Local-Development / Requirements / Home / Deployment-and-Operations(备份范围含附件与体积提示)
88 lines
4.2 KiB
Vue
88 lines
4.2 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||
import { ElButton } from 'element-plus'
|
||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||
import BookMark from '../components/BookMark.vue'
|
||
import { statusSummary, useLibraryStore } from '../stores/library'
|
||
import { useSessionStore } from '../stores/session'
|
||
const session = useSessionStore()
|
||
const library = useLibraryStore()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const loading = ref(true)
|
||
const error = ref('')
|
||
// A deletion returns here with the count it removed, so the learner sees what happened.
|
||
const deletedNotice = computed(() => {
|
||
const raw = Array.isArray(route.query.deleted) ? route.query.deleted[0] : route.query.deleted
|
||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return ''
|
||
return '书籍已删除 · 已保存的生词和短语仍保留在生词本。'
|
||
})
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try { await session.loadSpace() }
|
||
catch (reason) { error.value = reason instanceof Error ? reason.message : '暂时无法加载,请重试。' }
|
||
// The book list keeps its own error so one failing call still shows a retry.
|
||
if (!error.value) await library.loadBooks()
|
||
loading.value = false
|
||
}
|
||
async function logout() {
|
||
try { await session.logout() }
|
||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||
finally { await router.replace('/login') }
|
||
}
|
||
onMounted(load)
|
||
</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="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||
<div class="account">
|
||
<DisplaySettings />
|
||
<span class="account-name">{{ session.user.username }}</span>
|
||
<ElButton text @click="logout">退出登录</ElButton>
|
||
</div>
|
||
</header>
|
||
<main class="library">
|
||
<div class="library-title">
|
||
<div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div>
|
||
<div class="library-actions">
|
||
<span class="language">英语</span>
|
||
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
|
||
</div>
|
||
</div>
|
||
<p v-if="deletedNotice" role="status" class="chapter-notice" data-testid="library-notice">{{ deletedNotice }}</p>
|
||
<p v-if="loading" role="status" class="loading">正在加载…</p>
|
||
<div v-else-if="error || library.booksError" class="notice">
|
||
<p role="alert">{{ error || library.booksError }}</p>
|
||
<ElButton @click="load">重试</ElButton>
|
||
</div>
|
||
<ul v-else-if="library.books.length" class="book-grid" aria-label="书籍列表">
|
||
<li v-for="item in library.books" :key="item.id" class="book-card">
|
||
<!-- The cover repeats the title link, so it is hidden from assistive technology and from
|
||
keyboard order: the title link next to it is the accessible way into the book. -->
|
||
<RouterLink
|
||
:to="`/books/${item.id}`"
|
||
class="book-cover"
|
||
aria-hidden="true"
|
||
tabindex="-1"
|
||
>
|
||
<img v-if="library.coverUrls[item.id]" :src="library.coverUrls[item.id]" alt="" data-testid="book-cover" />
|
||
<span v-else class="book-cover-default">{{ item.title.slice(0, 1) }}</span>
|
||
</RouterLink>
|
||
<RouterLink :to="`/books/${item.id}`" class="book-title">{{ item.title }}</RouterLink>
|
||
<p class="subtle">{{ item.chapterCount }} 个章节</p>
|
||
<p v-if="statusSummary(item)" class="status-summary">{{ statusSummary(item) }}</p>
|
||
<p v-else class="subtle">尚未导入章节</p>
|
||
</li>
|
||
</ul>
|
||
<section v-else class="empty-library" aria-label="书库内容">
|
||
<BookMark /><h2>书库还是空的</h2><p class="subtle">粘贴一段英文,开始你的第一篇阅读。</p>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</template>
|