- 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 同步
345 lines
16 KiB
Vue
345 lines
16 KiB
Vue
<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>
|