feat: 书籍封面与音频附件、播放器与播放位置 (#21)

- 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(备份范围含附件与体积提示)
This commit was merged in pull request #36.
This commit is contained in:
ila
2026-09-15 18:39:56 +08:00
parent dde2ac127a
commit ab23ff60e5
30 changed files with 2339 additions and 54 deletions
+249
View File
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AudioPlayer from '../components/AudioPlayer.vue'
import BookView from '../views/BookView.vue'
import LibraryView from '../views/LibraryView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const }
const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
const audio = { kind: 'audio' as const, mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }
const book = { id: 7, title: 'Fictional reader', language: 'en' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '',
jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n',
}
let wrapper: VueWrapper | undefined
let createdUrls: string[]
/** jsdom has no object URLs, so the two functions are added to the existing URL object. */
function stubObjectUrls(): void {
createdUrls = []
let counter = 0
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => {
const url = `blob:fictional-${++counter}`
createdUrls.push(url)
return url
})
;(URL as unknown as Record<string, unknown>).revokeObjectURL = vi.fn()
}
function putBackObjectUrls(): void {
delete (URL as unknown as Record<string, unknown>).createObjectURL
delete (URL as unknown as Record<string, unknown>).revokeObjectURL
}
/** Sets the files of a hidden input the way a file picker would, then fires the change event. */
async function pickFile(view: VueWrapper, testid: string, file: File): Promise<void> {
const input = view.get(`[data-testid="${testid}"]`).element as HTMLInputElement
Object.defineProperty(input, 'files', { value: [file], configurable: true })
await view.get(`[data-testid="${testid}"]`).trigger('change')
await flushPromises()
}
function stubMedia(): { play: MockInstance; pause: MockInstance } {
const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined)
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
return { play, pause }
}
/** An API mock whose book and chapter carry attachments. */
function mockApi(options: { attachments?: { cover: unknown; audio: unknown; playbackSeconds: number } } = {}): MockInstance {
const attachments = options.attachments ?? { cover, audio, playbackSeconds: 90 }
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String(init?.method ?? 'GET')
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books') && method === 'GET') {
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: attachments.cover ? 'cover-sha' : '', hasAudio: Boolean(attachments.audio), createdAt: '', updatedAt: '' }] })
}
if (url.includes('/books/7/cover') || url.includes('/books/7/audio')) {
// A body of bytes rather than a jsdom Blob: the Response constructor cannot stream that one.
if (method === 'GET') return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
return ok({ attachment: url.includes('cover') ? cover : audio })
}
if (url.includes('/books/7/playback')) {
const body = typeof init?.body === 'string' ? JSON.parse(init.body) : { positionSeconds: 0 }
return ok({ playbackSeconds: Number(body.positionSeconds) })
}
if (url.includes('/books/7')) return ok({ book: { ...book, attachments }, chapters: [chapter] })
if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] })
if (url.includes('/chapters/3')) return ok({ book: { ...book, attachments }, chapter, navigation: { previousChapterId: null, nextChapterId: null } })
if (url.includes('/terms')) return ok({ items: [], total: 0, page: 1, limit: 20 })
if (url.includes('/progress')) return ok({})
return ok({})
})
}
async function mountWith(component: unknown, path: string): Promise<{ view: VueWrapper; router: Router }> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: LibraryView },
{ path: '/books/:id', component: BookView },
{ path: '/chapters/:id', component: ReaderView },
{ path: '/vocab', component: { template: '<div />' } },
{ path: '/review', component: { template: '<div />' } },
{ path: '/progress', component: { template: '<div />' } },
{ path: '/import', component: { template: '<div />' } },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('book covers in the library', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the stored cover and falls back to a default block', async () => {
mockApi()
const { view } = await mountWith(LibraryView, '/')
// The cover is fetched after the list answers, so the test waits for the blob instead of
// assuming that two microtask flushes are enough.
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
const image = view.get('[data-testid="book-cover"]')
expect(image.attributes('src')).toMatch(/^blob:fictional-/)
// The cover repeats the title link, so it is decorative and hidden from the accessibility tree.
expect(image.attributes('alt')).toBe('')
expect(view.get('.book-cover').attributes('aria-hidden')).toBe('true')
expect(useLibraryStore().coverUrls[7]).toMatch(/^blob:fictional-/)
})
it('keeps the default cover when the book has none', async () => {
mockApi({ attachments: { cover: null, audio: null, playbackSeconds: 0 } })
const { view } = await mountWith(LibraryView, '/')
expect(view.find('[data-testid="book-cover"]').exists()).toBe(false)
expect(view.get('.book-cover-default').text()).toBe('F')
})
})
describe('attachments on the book page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('describes what is stored and where the audio stopped', async () => {
mockApi()
const { view } = await mountWith(BookView, '/books/7')
expect(view.get('[data-testid="cover-state"]').text()).toContain('PNG')
expect(view.get('[data-testid="audio-state"]').text()).toContain('MP3')
expect(view.get('[data-testid="audio-state"]').text()).toContain('1:30')
expect(view.find('[data-testid="cover-remove"]').exists()).toBe(true)
expect(view.find('[data-testid="audio-remove"]').exists()).toBe(true)
})
it('refuses an oversized or wrong-typed file before uploading', async () => {
const fetchMock = mockApi()
const { view } = await mountWith(BookView, '/books/7')
const big = new File([new Uint8Array(3 << 20)], 'big.png', { type: 'image/png' })
await pickFile(view, 'cover-input', big)
expect(view.get('.field-error').text()).toContain('2 MiB')
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover') && String(url).includes('books/7'))).toBe(true) // only the initial read
expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0)
const wrong = new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' })
await pickFile(view, 'audio-input', wrong)
expect(view.text()).toContain('音频只支持 MP3')
})
it('uploads a replacement and reports the new state', async () => {
const fetchMock = mockApi()
const { view } = await mountWith(BookView, '/books/7')
const file = new File([new Uint8Array(2048)], 'cover.png', { type: 'image/png' })
await pickFile(view, 'cover-input', file)
const posted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'POST')
expect(String(posted?.[0])).toContain('/api/v1/books/7/cover')
expect(posted?.[1]?.body).toBeInstanceOf(FormData)
expect(view.get('[data-testid="book-notice"]').text()).toContain('封面已更新')
})
it('removes an attachment after confirmation', async () => {
const fetchMock = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="audio-remove"]').trigger('click')
await flushPromises()
const deleted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'DELETE')
expect(String(deleted?.[0])).toContain('/api/v1/books/7/audio')
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已移除')
// The audio state falls back to "not set" once the file is gone.
expect(view.get('[data-testid="audio-state"]').text()).toContain('未设置')
})
})
describe('audio player', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('formats the clock, changes the rate and reports an error', async () => {
stubMedia()
const positions: number[] = []
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 65, onPosition: (value: number) => positions.push(value) } })
await flushPromises()
expect(wrapper.get('[data-testid="audio-clock"]').text()).toContain('1:05')
await wrapper.get('[data-testid="audio-rate-1.5"]').trigger('click')
expect(wrapper.get('[data-testid="audio-rate-1.5"]').attributes('aria-pressed')).toBe('true')
const element = wrapper.get('[data-testid="audio-element"]').element as HTMLAudioElement
expect(element.playbackRate).toBe(1.5)
element.currentTime = 42
await wrapper.get('[data-testid="audio-element"]').trigger('pause')
expect(positions).toEqual([42])
await wrapper.get('[data-testid="audio-element"]').trigger('error')
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('音频加载失败')
})
it('never starts on its own and tolerates a rejected play', async () => {
const { play } = stubMedia()
play.mockRejectedValueOnce(new Error('blocked'))
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 0, onPosition: () => undefined } })
await flushPromises()
expect(play).not.toHaveBeenCalled()
await wrapper.get('[data-testid="audio-toggle"]').trigger('click')
await flushPromises()
expect(play).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('无法播放')
})
})
describe('player inside the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); stubMedia(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('appears for a book with audio and stays away otherwise', async () => {
mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
expect(view.find('.reader-text').exists()).toBe(true)
await vi.waitFor(() => expect(useLibraryStore().audioUrl).not.toBe(''))
await flushPromises()
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
wrapper?.unmount()
mockApi({ attachments: { cover, audio: null, playbackSeconds: 0 } })
const second = await mountWith(ReaderView, '/chapters/3')
expect(second.view.find('[data-testid="audio-player"]').exists()).toBe(false)
})
it('sends the position to the book instead of only keeping it locally', async () => {
const fetchMock = mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
const element = view.get('[data-testid="audio-element"]').element as HTMLAudioElement
element.currentTime = 77
await view.get('[data-testid="audio-element"]').trigger('pause')
await flushPromises()
const reported = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'PUT')
expect(String(reported?.[0])).toContain('/api/v1/books/7/playback')
expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 })
})
})
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
/**
* The audio player for one book. It only appears when the book has audio, never starts on its own,
* and remembers the position per account so reading can continue where it stopped. Nothing here
* aligns the audio with the text: the two are independent, as the ticket requires.
*/
const props = defineProps<{
src: string
/** Where this account stopped last time, in seconds. */
initialPosition: number
/** Called with the position while playing, when pausing and when leaving. */
onPosition: (seconds: number) => void
}>()
const audio = ref<HTMLAudioElement | null>(null)
const playing = ref(false)
const position = ref(props.initialPosition)
const duration = ref(0)
const rate = ref(1)
const error = ref('')
const rates = [0.75, 1, 1.25, 1.5]
const positionLabel = computed(() => formatClock(position.value))
const durationLabel = computed(() => (duration.value > 0 ? formatClock(duration.value) : '--:--'))
function formatClock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
async function toggle(): Promise<void> {
const element = audio.value
if (!element) return
error.value = ''
if (element.paused) {
try {
await element.play()
} catch {
// A rejected play() is usually a blocked autoplay attempt or a missing file.
error.value = '音频无法播放,请重试。'
playing.value = false
}
return
}
element.pause()
}
function onLoaded(): void {
const element = audio.value
if (!element) return
duration.value = Number.isFinite(element.duration) ? element.duration : 0
// The stored position is applied once the metadata is known, so seeking actually lands.
if (props.initialPosition > 0 && props.initialPosition < duration.value) {
element.currentTime = props.initialPosition
}
}
function onSeek(event: Event): void {
const element = audio.value
if (!element) return
element.currentTime = Number((event.target as HTMLInputElement).value)
position.value = element.currentTime
}
function setRate(value: number): void {
rate.value = value
if (audio.value) audio.value.playbackRate = value
}
/** Reports the position on a fixed cadence while playing, so a long listen is not lost. */
let reportTimer: number | undefined
function onTimeUpdate(): void {
const element = audio.value
if (!element) return
position.value = element.currentTime
if (reportTimer !== undefined) return
reportTimer = window.setInterval(() => {
reportTimer = undefined
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
startReporting()
}, 5000)
}
function startReporting(): void {
if (reportTimer !== undefined) return
reportTimer = window.setInterval(() => {
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
}, 5000)
}
function stopReporting(): void {
if (reportTimer !== undefined) {
window.clearInterval(reportTimer)
reportTimer = undefined
}
}
function onPause(): void {
playing.value = false
stopReporting()
if (audio.value) props.onPosition(audio.value.currentTime)
}
function onPlay(): void {
playing.value = true
startReporting()
}
function retry(): void {
error.value = ''
const element = audio.value
if (element) {
element.load()
void toggle()
}
}
watch(() => props.src, () => {
error.value = ''
position.value = props.initialPosition
playing.value = false
})
// Leaving the page stores the last position, so the same book continues where it stopped.
onScopeDispose(() => {
stopReporting()
if (audio.value) props.onPosition(audio.value.currentTime)
})
</script>
<template>
<section class="audio-player" aria-label="音频播放" data-testid="audio-player">
<ElButton
circle
type="primary"
data-testid="audio-toggle"
:aria-label="playing ? '暂停音频' : '播放音频'"
@click="toggle"
>{{ playing ? '❚❚' : '▶' }}</ElButton>
<span class="audio-clock" data-testid="audio-clock">{{ positionLabel }} / {{ durationLabel }}</span>
<input
class="audio-range"
type="range"
min="0"
:max="duration > 0 ? duration : 0"
step="1"
:value="position"
aria-label="播放进度"
data-testid="audio-seek"
:disabled="duration === 0"
@input="onSeek"
/>
<div class="audio-rates" role="group" aria-label="播放速度">
<button
v-for="value in rates"
:key="value"
type="button"
class="audio-rate"
:class="{ 'is-active': rate === value }"
:aria-pressed="rate === value"
:data-testid="`audio-rate-${value}`"
@click="setRate(value)"
>{{ value }}×</button>
</div>
<span v-if="error" role="alert" class="audio-error" data-testid="audio-error">{{ error }}
<ElButton text size="small" data-testid="audio-retry" @click="retry">重试</ElButton>
</span>
<audio
ref="audio"
:src="src"
preload="metadata"
data-testid="audio-element"
@loadedmetadata="onLoaded"
@timeupdate="onTimeUpdate"
@play="onPlay"
@pause="onPause"
@ended="onPause"
@error="error = '音频加载失败。'"
/>
</section>
</template>
+179 -1
View File
@@ -4,9 +4,35 @@ import { ApiError, useSessionStore } from './session'
export type ChapterStatus = 'pending' | 'processing' | 'ready' | 'failed'
export interface BookRef { id: number; title: string; language: string }
export type AttachmentKind = 'audio' | 'cover'
/** One stored attachment: metadata only, the bytes are fetched from the attachment endpoint. */
export interface BookAttachment {
kind: AttachmentKind
mime: string
byteSize: number
/** Content digest, used as the cache key so a replaced file is fetched again. */
version: string
updatedAt: string
}
export interface BookAttachments {
cover: BookAttachment | null
audio: BookAttachment | null
/** Where this account stopped in this book, in seconds. */
playbackSeconds: number
}
export interface BookRef {
id: number
title: string
language: string
attachments?: BookAttachments | null
}
export interface BookSummary extends BookRef {
coverVersion?: string
hasAudio?: boolean
chapterCount: number
pendingCount: number
processingCount: number
@@ -188,6 +214,7 @@ export const useLibraryStore = defineStore('library', () => {
const submitError = ref('')
const retryingChapterId = ref<number | null>(null)
const completingChapterId = ref<number | null>(null)
const uploadingKind = ref<AttachmentKind | null>(null)
/** Reader text exists only for ready chapters and is never taken from a cache. */
const readerText = computed(() => (chapter.value?.status === 'ready' ? chapter.value.originalText ?? '' : ''))
@@ -278,6 +305,9 @@ export const useLibraryStore = defineStore('library', () => {
if (seq !== booksSeq || isStale(version, owner)) return
const items = result?.items
books.value = Array.isArray(items) ? items : []
for (const book of books.value) {
if (book.coverVersion) void loadCover(book.id, book.coverVersion)
}
} catch (reason) {
if (seq !== booksSeq || isStale(version, owner)) return
booksError.value = failureMessage(reason, '书库暂时无法加载,请稍后重试。')
@@ -300,6 +330,9 @@ export const useLibraryStore = defineStore('library', () => {
book.value = result.book
chapters.value = Array.isArray(result.chapters) ? result.chapters : []
bookError.value = ''
// The book is always present in this response; the guard also covers a stale server reply.
const cover = result.book?.attachments?.cover
if (cover) void loadCover(result.book.id, cover.version)
schedulePolling()
} catch (reason) {
if (seq !== bookSeq || isStale(version, owner)) return
@@ -341,6 +374,13 @@ export const useLibraryStore = defineStore('library', () => {
chapter.value = result.chapter
navigation.value = result.navigation ?? emptyNavigation()
chapterError.value = ''
const audio = result.book?.attachments?.audio
if (audio) {
// A failure here is shown by the player itself; the chapter stays readable without audio.
void loadAudio(result.book.id, audio.version).catch(() => undefined)
} else {
audioUrl.value = ''
}
schedulePolling()
} catch (reason) {
if (seq !== chapterSeq || isStale(version, owner)) return
@@ -575,6 +615,140 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/**
* Uploads or replaces one attachment of an owned book. The reply is the stored metadata, which is
* written back into the open book and into the library list so a cover appears without a reload.
*/
async function uploadAttachment(bookId: number, kind: AttachmentKind, file: File): Promise<BookAttachment> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
try {
const form = new FormData()
form.append('file', file, file.name)
const result = await session.request<{ attachment: BookAttachment }>(`books/${bookId}/${kind}`, 'POST', form)
if (!isStale(version, owner)) applyAttachment(bookId, kind, result.attachment)
return result.attachment
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) uploadingKind.value = null
}
}
/** Removes one attachment of an owned book; removing the audio also clears the position. */
async function deleteAttachment(bookId: number, kind: AttachmentKind): Promise<void> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
try {
await session.request<{ deleted: boolean }>(`books/${bookId}/${kind}`, 'DELETE')
if (!isStale(version, owner)) applyAttachment(bookId, kind, null)
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) uploadingKind.value = null
}
}
/** Writes the attachment change into every view that shows this book. */
function applyAttachment(bookId: number, kind: AttachmentKind, attachment: BookAttachment | null): void {
const current = book.value?.id === bookId
? book.value.attachments ?? { cover: null, audio: null, playbackSeconds: 0 }
: null
if (current) {
const next: BookAttachments = { ...current, [kind]: attachment }
// A new audio file has nothing to do with the old position.
if (kind === 'audio') next.playbackSeconds = 0
book.value = { ...book.value!, attachments: next }
}
const listed = books.value.find(item => item.id === bookId)
if (listed) {
if (kind === 'cover') listed.coverVersion = attachment?.version ?? ''
else listed.hasAudio = attachment !== null
}
if (kind === 'cover') {
// The cached image belongs to the previous file, so it is dropped and loaded again.
forgetCover(bookId)
if (attachment) void loadCover(bookId, attachment.version)
}
if (kind === 'audio') {
loadedVersions.delete(`audio:${bookId}`)
audioUrl.value = ''
}
}
/** Remembers where this account stopped in one book, so reading can continue later. */
async function savePlaybackPosition(bookId: number, seconds: number): Promise<void> {
const version = generation
const owner = ownerId()
const result = await session.request<{ playbackSeconds: number }>(`books/${bookId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) })
if (!isStale(version, owner) && book.value?.id === bookId && book.value.attachments) {
book.value = { ...book.value, attachments: { ...book.value.attachments, playbackSeconds: result.playbackSeconds } }
}
}
/** Object URLs handed to <img>/<audio>; the token never appears in a URL the browser sees. */
const coverUrls = ref<Record<number, string>>({})
const audioUrl = ref('')
const liveBlobUrls = new Set<string>()
const loadedVersions = new Map<string, string>()
function rememberBlob(blob: Blob): string {
const url = URL.createObjectURL(blob)
liveBlobUrls.add(url)
return url
}
function releaseBlobs(): void {
for (const url of liveBlobUrls) URL.revokeObjectURL(url)
liveBlobUrls.clear()
loadedVersions.clear()
coverUrls.value = {}
audioUrl.value = ''
}
/** Loads a cover for the library list, once per version. */
async function loadCover(bookId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`cover:${bookId}`) === version) return
try {
const blob = await session.requestBlob(`books/${bookId}/cover`)
if (!session.user) return
loadedVersions.set(`cover:${bookId}`, version)
coverUrls.value = { ...coverUrls.value, [bookId]: rememberBlob(blob) }
} catch {
// A cover that cannot be read simply keeps the default styling.
}
}
/** Loads the audio of the open book, once per version, for the player in the reader. */
async function loadAudio(bookId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`audio:${bookId}`) === version) return
try {
const blob = await session.requestBlob(`books/${bookId}/audio`)
if (!session.user) return
loadedVersions.set(`audio:${bookId}`, version)
audioUrl.value = rememberBlob(blob)
} catch (reason) {
throw reason instanceof Error ? reason : new Error('音频暂时无法加载。')
}
}
/** Drops one cached cover so a replaced file is fetched again. */
function forgetCover(bookId: number): void {
loadedVersions.delete(`cover:${bookId}`)
const url = coverUrls.value[bookId]
if (url) {
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...coverUrls.value }
delete next[bookId]
coverUrls.value = next
}
}
/**
* Marks one owned chapter as read. Completing only records the reading fact: word statuses and
* levels are left alone. A repeated request answers with the same mark instead of counting
@@ -646,6 +820,8 @@ export const useLibraryStore = defineStore('library', () => {
submitError.value = ''
retryingChapterId.value = null
completingChapterId.value = null
uploadingKind.value = null
releaseBlobs()
submissionKey = ''
submissionRequestId = ''
}
@@ -656,6 +832,8 @@ export const useLibraryStore = defineStore('library', () => {
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, completingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead,
uploadAttachment, deleteAttachment, savePlaybackPosition, uploadingKind,
coverUrls, audioUrl, loadCover, loadAudio,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
+24 -1
View File
@@ -53,6 +53,29 @@ export const useSessionStore = defineStore('session', () => {
return result.data as T
}
/**
* Fetches binary content with the same session as every other request. A media element cannot
* carry an Authorization header, so the bytes are fetched here and handed to the page as a blob
* URL instead of putting the token into a URL where it could be logged or shared.
*/
async function requestBlob(path: string): Promise<Blob> {
const response = await fetch(`/api/v1/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
})
if (!response.ok) {
let message = '附件暂时无法加载。'
try {
const result = await response.json()
if (result?.msg) message = result.msg
} catch {
// A non-JSON error body keeps the default message.
}
throw new ApiError(message, response.status)
}
return response.blob()
}
async function login(username: string, password: string) {
clear()
initialized = true
@@ -104,5 +127,5 @@ export const useSessionStore = defineStore('session', () => {
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
}
return { user, space, notice, login, restore, logout, loadSpace, request }
return { user, space, notice, login, restore, logout, loadSpace, request, requestBlob }
})
+28
View File
@@ -345,3 +345,31 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible,
/* Discoverable keyboard operations on the review page. */
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
/* Book covers: the card shows the stored image or a default block built from the title. */
.book-cover { display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); text-decoration: none; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.book-cover-default { display: grid; place-items: center; width: 100%; height: 100%; font-family: Georgia, serif; font-size: 40px; color: var(--accent); }
/* Book attachments: metadata, preview and the upload controls. */
.attachments { margin-top: 26px; padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.attachments h2 { margin: 0 0 16px; font-size: 19px; }
.attachment-row { display: flex; gap: 18px; align-items: flex-start; padding: 14px 0; border-top: 1px solid var(--border); }
.attachment-row:first-of-type { border-top: 0; padding-top: 0; }
.attachment-preview { flex: 0 0 160px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
.attachment-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-info { min-width: 0; flex: 1; }
.attachment-label { margin: 0 0 4px; font-weight: 600; }
.attachment-info .subtle { margin: 4px 0 0; }
.attachment-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 10px 0 0; }
.attachment-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.attachment-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
.attachment-actions label.is-busy { opacity: 0.6; cursor: progress; }
/* Audio player: sticky so the controls stay reachable while the chapter is read. */
.audio-player { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 2px 10px #233d3114; }
.audio-clock { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.audio-range { flex: 1 1 160px; min-width: 120px; accent-color: var(--accent); }
.audio-rates { display: flex; gap: 4px; }
.audio-rate { min-width: 44px; min-height: 32px; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface-raised); color: var(--text); font-size: 12px; cursor: pointer; }
.audio-rate.is-active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.audio-error { display: inline-flex; align-items: center; gap: 6px; color: var(--notice-text); font-size: 13px; }
.audio-player audio { display: none; }
+118 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
@@ -12,6 +12,85 @@ const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
const attachmentError = ref<{ cover: string; audio: string }>({ cover: '', audio: '' })
const ATTACHMENT_RULES = {
cover: { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' },
audio: { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' },
} as const
const coverState = computed(() => {
const cover = library.book?.attachments?.cover
if (!cover) return '未设置(显示默认封面)'
return `${cover.mime === 'image/png' ? 'PNG' : cover.mime === 'image/webp' ? 'WebP' : 'JPG'} · ${fileSizeLabel(cover.byteSize)}`
})
const audioState = computed(() => {
const audio = library.book?.attachments?.audio
if (!audio) return '未设置(阅读时可正常使用)'
const position = library.book?.attachments?.playbackSeconds ?? 0
const detail = `${fileSizeLabel(audio.byteSize)}${position > 0 ? ` · 上次播放位置 ${Math.floor(position / 60)}:${String(position % 60).padStart(2, '0')}` : ''}`
return `MP3 · ${detail}`
})
/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */
function reject(kind: 'cover' | 'audio', file: File): string {
const rules = ATTACHMENT_RULES[kind]
if (file.size === 0) return '文件为空,请重新选择。'
if (file.size > rules.maxBytes) return rules.label
if (file.type && !(rules.types as readonly string[]).includes(file.type)) return rules.label
return ''
}
async function upload(kind: 'cover' | 'audio', file: File): Promise<void> {
if (library.book === null) return
attachmentError.value = { ...attachmentError.value, [kind]: '' }
const problem = reject(kind, file)
if (problem) {
attachmentError.value = { ...attachmentError.value, [kind]: problem }
return
}
try {
await library.uploadAttachment(library.book.id, kind, file)
notice.value = kind === 'cover' ? '封面已更新。' : '音频已更新,播放位置已重置。'
} catch (reason) {
attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '上传失败,请稍后重试。' }
}
}
function onCoverPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void upload('cover', file)
}
function onAudioPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void upload('audio', file)
}
async function removeAttachment(kind: 'cover' | 'audio'): Promise<void> {
if (library.book === null) return
const question = kind === 'cover' ? '移除这本书的封面?' : '移除这本书的音频?播放位置会一起清除。'
try {
await ElMessageBox.confirm(question, kind === 'cover' ? '移除封面' : '移除音频', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
attachmentError.value = { ...attachmentError.value, [kind]: '' }
try {
await library.deleteAttachment(library.book.id, kind)
notice.value = kind === 'cover' ? '封面已移除。' : '音频已移除。'
} catch (reason) {
attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '移除失败,请稍后重试。' }
}
}
function removeCover(): void { void removeAttachment('cover') }
function removeAudio(): void { void removeAttachment('audio') }
// Renaming the book.
const bookDialog = ref(false)
@@ -163,7 +242,7 @@ onUnmounted(() => library.closeBook())
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语 · 封面使用系统默认样式</p>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
</div>
<div class="page-actions">
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书名</ElButton>
@@ -172,6 +251,43 @@ onUnmounted(() => library.closeBook())
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<section class="attachments" aria-label="音频与封面" data-testid="attachments">
<h2>音频与封面</h2>
<div class="attachment-row">
<div class="attachment-preview">
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
</div>
<div class="attachment-info">
<p class="attachment-label">封面</p>
<p class="subtle" data-testid="cover-state">{{ coverState }}</p>
<p v-if="attachmentError.cover" role="alert" class="field-error">{{ attachmentError.cover }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
{{ library.coverUrls[library.book.id] ? '替换封面' : '上传封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
</label>
<ElButton v-if="library.coverUrls[library.book.id] || library.book.attachments?.cover" size="small" :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
</div>
<p class="subtle">JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。</p>
</div>
</div>
<div class="attachment-row">
<div class="attachment-info">
<p class="attachment-label">音频</p>
<p class="subtle" data-testid="audio-state">{{ audioState }}</p>
<p v-if="attachmentError.audio" role="alert" class="field-error">{{ attachmentError.audio }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' }">
{{ library.book.attachments?.audio ? '替换音频' : '上传音频' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="audio-input" :disabled="library.uploadingKind !== null" @change="onAudioPicked" />
</label>
<ElButton v-if="library.book.attachments?.audio" size="small" :disabled="library.uploadingKind !== null" data-testid="audio-remove" @click="removeAudio">移除</ElButton>
</div>
<p class="subtle">MP3,最大 20 MiB;播放不与文本同步。替换或移除后播放位置会重置。</p>
</div>
</div>
</section>
<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">
+11
View File
@@ -62,6 +62,17 @@ onMounted(load)
</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>
+18
View File
@@ -9,6 +9,7 @@ import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, ran
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'
@@ -93,6 +94,17 @@ async function load() {
restorePosition()
}
// The player only exists for a book that has audio, and it reports the position for this account.
const audioSource = computed(() => library.audioUrl)
const audioStart = computed(() => library.chapterBook?.attachments?.playbackSeconds ?? library.book?.attachments?.playbackSeconds ?? 0)
function rememberPlayback(seconds: number): void {
const bookId = library.chapterBook?.id
if (bookId === undefined) return
// The reported value is not awaited: leaving the page must not wait for the network.
void library.savePlaybackPosition(bookId, 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.
@@ -223,6 +235,12 @@ onUnmounted(() => {
<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>