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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user