Files
lexgo/learner/src/views/ReaderView.vue
T
ila 3fe9bcb058 feat: 章节增加可选作者字段,编辑对话框更紧凑 (#37)
- schema v10:lexgo_chapters 增加 author VARCHAR(120) NOT NULL DEFAULT '';由 Go 条件步骤
  addChapterAuthorColumn(先查 information_schema 再 ALTER)在语句列表之后执行,保持
  「部分迁移可重试 / 回退标记后可重新升级」,新建库的 v3 语句也带该列
- 章节编辑接受 author(可选、trim、≤120 字符、空串清空),仅改作者不重新处理章节;
  ChapterSummary 与 ChapterSource 都返回它,阅读页在标题下显示非空作者
- 对话框:标题与作者改成「标签在左、输入框在右」的同行排版,正文编辑框加高到 18 行,
  插图与音频压缩为各一两行(规格写在下方),时机说明合并为一行
- 测试:Go 89 项(新增作者往返与 v9→v10 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:标签同行偏差 <8px、正文高度 415px、保存作者后阅读页显示、清空后消失
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 22:49:56 +08:00

345 lines
16 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 { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
import { useTextSelection } from '../composables/useTextSelection'
import ReaderTokens from '../components/ReaderTokens.vue'
import AudioPlayer from '../components/AudioPlayer.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import LookupPanel from '../components/LookupPanel.vue'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const rangeNotice = ref('')
const readerBody = ref<HTMLElement | null>(null)
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.chapter)
const lookup = useReaderLookup(chapter)
// Retry uses the job id the chapter carries, no matter where it was loaded from.
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
const text = library.readerText
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
if (count < 2) return
if (count > MAX_PHRASE_WORDS) {
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
return
}
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element)
}
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
/** Shift-clicking a word extends the phrase from the word that was picked first. */
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
rangeNotice.value = ''
const anchor = lookup.selected.value
if (extend && anchor) {
const from = Number(element.dataset.tokenIndex)
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
if (from >= 0 && first >= 0) {
pickRange(Math.min(from, first), Math.max(from, first), element)
return
}
}
lookup.select(token, element)
}
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element, span.id)
}
const adjustOptions = computed(() => {
const current = lookup.range.value
if (!current) return null
const text = library.readerText
return {
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
}
})
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
function closePanel(): void {
lookup.close()
selection.clear()
}
let lastChapterId: number | null = null
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
// Switching chapters reports the position of the chapter that is being left.
if (lastChapterId !== null && lastChapterId !== chapterId.value) library.reportChapterPlayback(lastChapterId)
lastChapterId = chapterId.value
await library.loadChapter(chapterId.value)
restorePosition()
}
// The player belongs to the chapter that is open: the loaded audio is only shown when it belongs to
// this chapter, so switching chapters never reuses the previous file.
const audioSource = computed(() => (library.audioChapterId === chapter.value?.id ? library.audioUrl : ''))
const audioStart = computed(() => chapter.value?.playbackSeconds ?? 0)
const illustrationSource = computed(() => (chapter.value ? library.illustrationUrls[chapter.value.id] ?? '' : ''))
// The chapter shows a thumbnail; the original opens in a dialog so a large image never takes over
// the reading column.
const illustrationOpen = ref(false)
const illustrationTitle = computed(() => (chapter.value ? `${chapter.value.title} · 插图` : '插图'))
function rememberPlayback(seconds: number): void {
const chapterId = chapter.value?.id
if (chapterId === undefined) return
// The reported value is not awaited: leaving the page must not wait for the network.
void library.saveChapterPlayback(chapterId, seconds).catch(() => undefined)
}
// Reading position: remembered per account and chapter, and only while the text is the version
// the position was taken from. The restore waits for the chapter to be on screen and for the
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
const positionNotice = ref('')
let saveTimer: number | undefined
function storage(): Storage | null {
try { return window.localStorage } catch { return null }
}
function restorePosition(): void {
positionNotice.value = ''
const store = storage()
const sha = chapter.value?.contentSha256 ?? ''
const current = chapter.value
if (!store || !current || current.status !== 'ready' || !sha) return
const saved = readPosition(store, session.user?.id ?? null, current.id, sha)
if (!saved) return
// The chapter may still be laying out when the response arrives, so the restore waits for a
// scrollable page instead of trusting one timer; it gives up quietly after a short while.
let attempts = 0
const apply = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight
if (scrollable <= 0) {
attempts += 1
if (attempts <= 20) window.setTimeout(apply, 50)
return
}
window.scrollTo({ top: Math.round(saved.ratio * scrollable) })
positionNotice.value = '已回到上次阅读位置。'
}
window.setTimeout(apply, 40)
}
function savePosition(): void {
const store = storage()
const current = chapter.value
if (!store || !current || current.status !== 'ready') return
const ratio = currentRatio(window, document.documentElement.scrollHeight)
if (ratio <= 0) return
writePosition(store, session.user?.id ?? null, current.id, current.contentSha256, ratio)
}
function onScroll(): void {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
// Debounced: scrolling must stay smooth and only the resting position is worth storing.
saveTimer = window.setTimeout(() => { saveTimer = undefined; savePosition() }, 400)
}
async function retry() {
if (chapter.value === null) return
retryError.value = ''
try { await library.retryChapter(chapter.value.id) }
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
// Completing records only that this chapter was read. Word statuses and levels are untouched.
const completeNotice = ref('')
const readAt = computed(() => chapter.value?.readAt ?? null)
const readLabel = computed(() => (readAt.value ? `本章已读 · ${formatReadAt(readAt.value)}` : ''))
function formatReadAt(value: string): string {
const at = new Date(value)
if (Number.isNaN(at.getTime())) return '已记录'
const pad = (part: number) => String(part).padStart(2, '0')
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
}
async function complete() {
if (chapter.value === null) return
completeNotice.value = ''
try {
const result = await library.markChapterRead(chapter.value.id)
completeNotice.value = result.duplicate ? '本章已经标记过已读,没有重复计数。' : '已记为读完本章。'
} catch (reason) {
completeNotice.value = reason instanceof Error ? reason.message : '标记失败,请稍后重试。'
}
}
// Switching chapters reuses this component; only the route param changes.
function goTo(id: number | null) {
if (id === null) return
void router.push(`/chapters/${id}`)
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
void load()
})
watch(chapterId, () => { void load() })
// Leaving the page releases the chapter so polling stops and stores the last position.
// There is no separate unmount hook: this one already handles the page going away.
onUnmounted(() => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
savePosition()
window.removeEventListener('scroll', onScroll)
library.closeChapter()
})
</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> · <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="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
<div v-else-if="library.chapterError" class="notice">
<p role="alert">{{ library.chapterError }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<template v-else-if="chapter">
<p class="breadcrumb">
<RouterLink to="/">我的书库</RouterLink>
<span aria-hidden="true">/</span>
<RouterLink v-if="library.chapterBook" :to="`/books/${library.chapterBook.id}`">{{ library.chapterBook.title }}</RouterLink>
<span v-else>章节</span>
</p>
<AudioPlayer
v-if="audioSource"
:src="audioSource"
:initial-position="audioStart"
:on-position="rememberPlayback"
/>
<div class="page-title">
<h1>{{ chapter.title }}</h1>
<span class="status-chip" :class="`status-${chapter.status}`">{{ statusLabel(chapter.status) }}</span>
</div>
<p v-if="chapter.author" class="chapter-author" data-testid="chapter-author-line">{{ chapter.author }}</p>
<div v-if="chapter.status === 'failed'" class="notice">
<p role="alert">{{ chapter.errorMessage || '这一章处理失败。' }}</p>
<ElButton v-if="retryable" :loading="library.retryingChapterId === chapter.id" @click="retry">重试处理</ElButton>
<p v-else class="subtle">这一章暂时没有可重试的任务编号。</p>
</div>
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<button
v-if="chapter.status === 'ready' && illustrationSource"
type="button"
class="illustration-thumb"
data-testid="chapter-illustration"
:aria-label="`查看《${chapter.title}》的插图大图`"
@click="illustrationOpen = true"
>
<img :src="illustrationSource" alt="本章插图缩略图" />
<span class="illustration-hint">点击查看大图</span>
</button>
<ElDialog v-model="illustrationOpen" :title="illustrationTitle" width="auto" data-testid="illustration-dialog">
<img class="illustration-full" :src="illustrationSource" alt="本章插图" />
</ElDialog>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body" ref="readerBody">
<ReaderTokens
:tokens="lookup.tokens.value"
:phrases="lookup.phrases.value"
:original="library.readerText"
:selected-start="lookup.selected.value?.start"
:phrase-start="lookup.rangeTermId.value"
@select="selectToken"
@select-phrase="selectPhrase"
/>
<div v-if="lookup.tokensError.value" class="tokens-notice">
<p role="status">{{ lookup.tokensError.value }}</p>
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
</div>
</div>
<LookupPanel
v-if="lookup.selected.value || lookup.range.value"
:word="panelWord"
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
:adjust="adjustOptions"
:result="lookup.result.value"
:loading="lookup.loading.value"
:error="lookup.error.value"
:saving="lookup.saving.value"
:save-error="lookup.saveError.value"
:saved="lookup.saved.value"
:saved-term-id="lookup.savedTermId.value"
:prefilling="lookup.prefilling.value"
:prefill-error="lookup.prefillError.value"
:can-save="lookup.canSave.value"
v-model:definition="lookup.definition.value"
v-model:examples="lookup.examples.value"
v-model:status="lookup.status.value"
@close="closePanel"
@retry="lookup.lookup"
@save="lookup.save"
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
@resize="lookup.keepSelectionVisible"
/>
</div>
<nav class="reader-nav" aria-label="章节切换">
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
<ElButton :disabled="library.navigation.nextChapterId === null" @click="goTo(library.navigation.nextChapterId)">下一章</ElButton>
</nav>
<section v-if="chapter.status === 'ready'" class="chapter-complete" aria-label="完成本章">
<div>
<p v-if="readAt" class="read-state" data-testid="chapter-read-state">{{ readLabel }}</p>
<p v-else class="subtle">读完后标记已读;只记录已读,不改变词语状态。</p>
<p v-if="positionNotice" role="status" class="subtle" data-testid="position-notice">{{ positionNotice }}</p>
<p v-if="completeNotice" role="status" class="subtle" data-testid="complete-notice">{{ completeNotice }}</p>
</div>
<ElButton
type="primary"
:loading="library.completingChapterId === chapter.id"
data-testid="mark-read"
@click="complete"
>{{ readAt ? '再次标记已读' : '标记本章已读' }}</ElButton>
</section>
</template>
</main>
</div>
</template>