feat: 粘贴英语文本、章节处理与本人阅读 (#5)

schema v3 新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;一次粘贴生成一个章节,
原文按收到的字符串逐字保存;处理任务具备持久状态机、启动恢复与 requestId 幂等。
新增书籍/章节/任务 API 一律按认证身份过滤归属,他人编号返回 404,后台任务不使用
客户端用户编号;学习端补齐粘贴导入、书库、章节状态与失败重试、原文阅读与章节切换。

测试:MySQL 集成测试覆盖导入→处理中→就绪→阅读完整路径、失败重试、幂等、越权与
Unicode 原文保真;学习端 Vitest 31 项、Playwright 3 项与构建通过;lexgo_dev 已显式
迁移到 v3,迁移前后既有数据指纹不变。

文档:Wiki 先写后回读(架构、业务规则、本地验证、需求总览、Home),导出核心镜像。
This commit is contained in:
ila
2026-09-11 00:36:23 +08:00
parent ed5da31888
commit a55708cd37
25 changed files with 3442 additions and 63 deletions
+343
View File
@@ -0,0 +1,343 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import {
NOT_FOUND_MESSAGE,
POLL_INTERVAL_MS,
TEXT_MAX_CODE_POINTS,
canRetry,
statusSummary,
useLibraryStore,
type ChapterDetail,
} from '../stores/library'
import { useSessionStore } from '../stores/session'
// All accounts, books and texts in these tests are deliberately fictitious.
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const navigation = { previousChapterId: null, nextChapterId: null }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
const httpError = (status: number, msg: string) => new Response(JSON.stringify({ code: status, msg }), { status })
const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
id: 55,
bookId: 1,
ordinal: 1,
title: '第一篇',
status: 'pending',
charCount: 120,
errorReason: '',
errorMessage: '',
contentSha256: 'fictional-sha256',
jobId: 7,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
})
const summary = (overrides: Partial<Record<string, number | string>> = {}) => ({
...book,
chapterCount: 0,
pendingCount: 0,
processingCount: 0,
readyCount: 0,
failedCount: 0,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
})
const job = { id: 7, bookId: 1, chapterId: 55, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const fetchMock = () => vi.mocked(globalThis.fetch)
const paths = () => fetchMock().mock.calls.map(([input]) => String(input))
const bodyOf = (index: number): Record<string, unknown> => JSON.parse(String(fetchMock().mock.calls[index]?.[1]?.body)) as Record<string, unknown>
async function signIn() {
fetchMock().mockResolvedValueOnce(ok({ token: 'fictional-token', expiresAt: '2030-01-01', user }))
const session = useSessionStore()
await session.login(user.username, 'fictional-password')
return session
}
describe('learner library store', () => {
beforeEach(() => {
sessionStorage.clear()
setActivePinia(createPinia())
vi.restoreAllMocks()
// Any request a test did not expect fails loudly instead of hanging.
vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
throw new Error(`unexpected request: ${String(input)}`)
})
})
afterEach(() => {
// Never let a polling timer outlive its test.
useLibraryStore().stopPolling()
vi.useRealTimers()
})
it('loads the library and summarises the count fields the API reports', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ chapterCount: 5, readyCount: 2, processingCount: 1, pendingCount: 1, failedCount: 1 })] }))
await library.loadBooks()
expect(library.books).toHaveLength(1)
expect(library.booksLoading).toBe(false)
expect(library.booksError).toBe('')
expect(statusSummary(library.books[0]!)).toBe('已就绪 2 · 处理中 1 · 待处理 1 · 失败 1')
expect(paths()).toEqual(['/api/v1/login', '/api/v1/books'])
expect(fetchMock().mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer fictional-token' })
})
it('shows pending and processing separately instead of deriving one from a total', async () => {
// processingCount is strictly "processing" now, so pendingCount must be read as given.
const queued = summary({ chapterCount: 3, readyCount: 1, processingCount: 0, pendingCount: 2, failedCount: 0 })
expect(statusSummary(queued)).toBe('已就绪 1 · 待处理 2')
expect(statusSummary(queued)).not.toContain('处理中')
const done = summary({ chapterCount: 1, readyCount: 1 })
expect(statusSummary(done)).toBe('已就绪 1')
expect(statusSummary(summary({ chapterCount: 0 }))).toBe('')
})
it('loads a book detail whose chapters carry the job id used for retry', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', jobId: 7 }), chapter({ id: 56, status: 'failed', jobId: null })] }))
await library.loadBook(1)
expect(library.book?.title).toBe('虚构样例书')
expect(library.chapters).toHaveLength(2)
expect(paths()[1]).toBe('/api/v1/books/1')
// The chapter itself carries the job id, even for a freshly loaded book.
expect(library.chapters[0]?.jobId).toBe(7)
expect(canRetry(library.chapters[0]!)).toBe(true)
// A null job id means the chapter has nothing to retry yet.
expect(canRetry(library.chapters[1]!)).toBe(false)
})
it('retries a failed chapter loaded fresh from the book detail, without any submit in this session', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
// No submit() call: this is a plain reload, the old workaround would hide retry here.
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorMessage: '无法解析正文。', jobId: 7 })] }))
await library.loadBook(1)
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
.mockResolvedValueOnce(ok({ book, chapters: [chapter({ jobId: 7 })] }))
await library.retryChapter(55)
expect(paths()).toContain('/api/v1/jobs/7/retry')
expect(library.retryingChapterId).toBeNull()
expect(library.chapters[0]?.status).toBe('pending')
})
it('pastes a new book and reads the job id from the created chapter', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
const bookId = await library.submit({ title: ' 第一篇 ', text: 'Hello world.\nSecond line.', target: { mode: 'new' } })
expect(bookId).toBe(1)
expect(library.submitting).toBe(false)
expect(library.submitError).toBe('')
expect(paths()[1]).toBe('/api/v1/books')
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '第一篇', text: 'Hello world.\nSecond line.', language: 'en' })
})
it('appends to an existing book through the chapter endpoint', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(created({ chapter: chapter(), job, duplicate: false }))
const bookId = await library.submit({ title: '第二篇', text: 'Another text.', target: { mode: 'append', bookId: 1 } })
expect(bookId).toBe(1)
expect(paths()[1]).toBe('/api/v1/books/1/chapters')
})
it('reuses one requestId while the same unsent content keeps failing', async () => {
await signIn()
const library = useLibraryStore()
const input = { title: '第一篇', text: 'Hello world.', target: { mode: 'new' as const } }
fetchMock()
.mockResolvedValueOnce(httpError(500, '服务器开小差了'))
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
await expect(library.submit(input)).rejects.toThrow('服务器开小差了')
expect(library.submitError).toBe('服务器开小差了')
await library.submit(input)
// One chapter, not two: the retry of unchanged content reuses the requestId.
expect(bodyOf(2).requestId).toBe(bodyOf(1).requestId)
})
it('uses a fresh requestId after a successful submit and after the content changes', async () => {
await signIn()
const library = useLibraryStore()
fetchMock()
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 56 }), job, duplicate: false }))
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 57 }), job, duplicate: false }))
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
await library.submit({ title: '第一篇', text: 'Hello world changed.', target: { mode: 'new' } })
expect(bodyOf(2).requestId).not.toBe(bodyOf(1).requestId)
expect(bodyOf(3).requestId).not.toBe(bodyOf(2).requestId)
})
it('rejects invalid input before sending anything', async () => {
await signIn()
const library = useLibraryStore()
const sent = paths().length
await expect(library.submit({ title: ' ', text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('请填写标题。')
await expect(library.submit({ title: '标题', text: ' \n\t ', target: { mode: 'new' } })).rejects.toThrow('请粘贴要导入的英文正文。')
await expect(library.submit({ title: 'x'.repeat(121), text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('标题不能超过 120 个字符。')
await expect(library.submit({ title: '标题', text: 'a'.repeat(TEXT_MAX_CODE_POINTS + 1), target: { mode: 'new' } })).rejects.toThrow(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
expect(paths().length).toBe(sent)
expect(library.submitError).toBe(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
})
it('measures the text limit in Unicode code points', async () => {
await signIn()
const library = useLibraryStore()
// 100000 astral characters are 200000 UTF-16 units but still within the limit.
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
await expect(library.submit({ title: '标题', text: '😀'.repeat(TEXT_MAX_CODE_POINTS), target: { mode: 'new' } })).resolves.toBe(1)
expect([...('😀'.repeat(TEXT_MAX_CODE_POINTS))].length).toBe(TEXT_MAX_CODE_POINTS)
})
it('polls a pending chapter until it is ready and then stops', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.loadChapter(55)
expect(library.chapter?.status).toBe('pending')
expect(library.readerText).toBe('')
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello\nworld.' }), navigation }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(library.chapter?.status).toBe('ready')
expect(library.readerText).toBe('Hello\nworld.')
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
expect(paths().length).toBe(settled)
})
it('retries a failed chapter through the chapter job id and resumes polling', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'decode_error', errorMessage: '无法解析正文。', jobId: 7 }), navigation }))
await library.loadChapter(55)
expect(library.chapter?.status).toBe('failed')
expect(canRetry(library.chapter!)).toBe(true)
fetchMock()
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
.mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.retryChapter(55)
expect(paths()).toContain('/api/v1/jobs/7/retry')
expect(library.retryingChapterId).toBeNull()
expect(library.chapter?.status).toBe('pending')
// The retry restarts polling for the chapter it re-queued.
const before = paths().length
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello world.' }), navigation }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(paths().length).toBe(before + 1)
expect(library.readerText).toBe('Hello world.')
})
it('refuses to retry a chapter whose job id is null', async () => {
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', jobId: null }), navigation }))
await library.loadChapter(55)
expect(canRetry(library.chapter!)).toBe(false)
await expect(library.retryChapter(55)).rejects.toThrow('这一章暂时没有可重试的任务编号。')
expect(paths()).toEqual(['/api/v1/login', '/api/v1/chapters/55'])
})
it('reports another account id as 内容不存在 and stops polling for it', async () => {
vi.useFakeTimers()
await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(httpError(404, 'chapter not found'))
await library.loadChapter(99)
expect(library.chapter).toBeNull()
expect(library.chapterError).toBe(NOT_FOUND_MESSAGE)
fetchMock().mockResolvedValueOnce(httpError(404, 'book not found'))
await library.loadBook(99)
expect(library.book).toBeNull()
expect(library.bookError).toBe(NOT_FOUND_MESSAGE)
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(paths().length).toBe(settled)
})
it('stops polling and ignores a late response once the session is cleared', async () => {
vi.useFakeTimers()
const session = await signIn()
const library = useLibraryStore()
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
await library.loadChapter(55)
expect(library.chapter).not.toBeNull()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
fetchMock().mockResolvedValueOnce(ok(null))
const late = library.loadChapter(55, { silent: true })
const logout = session.logout()
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
await late
await logout
expect(session.user).toBeNull()
expect(library.chapter).toBeNull()
expect(library.readerText).toBe('')
const settled = paths().length
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(paths().length).toBe(settled)
})
it('keeps newer state when an older request answers later', async () => {
await signIn()
const library = useLibraryStore()
let finish!: (response: Response) => void
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ title: '较新的标题' })] }))
const stale = library.loadBooks()
await library.loadBooks()
finish(ok({ items: [summary({ title: '过期的标题' })] }))
await stale
expect(library.books).toHaveLength(1)
expect(library.books[0]?.title).toBe('较新的标题')
})
})
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { defineComponent, h } from 'vue'
import BookView from '../views/BookView.vue'
import ImportView from '../views/ImportView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore, type ChapterStatus } from '../stores/library'
import { useSessionStore } from '../stores/session'
// All accounts, books and texts in these tests are deliberately fictitious.
const user = { id: 42, username: 'fictional-reader', role: 'learner' as const }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const navigation = { previousChapterId: null, nextChapterId: null }
const pasted = 'First line.\n\tIndented line.\nTwo spaces kept.\n\nLast line.\n'
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
function chapter(status: ChapterStatus, extra: Record<string, unknown> = {}) {
return {
id: 55,
bookId: 1,
ordinal: 1,
title: '第一篇',
status,
charCount: 120,
errorReason: '',
errorMessage: '',
jobId: 7,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...extra,
}
}
const stub = (name: string) => defineComponent({ name, render: () => h('div') })
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/import', component: stub('ImportStub') },
{ path: '/books/:id', component: stub('BookStub') },
{ path: '/chapters/:id', component: stub('ChapterStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
function signIn() {
useSessionStore().user = { ...user }
}
describe('learner reading views', () => {
beforeEach(() => {
sessionStorage.clear()
setActivePinia(createPinia())
vi.restoreAllMocks()
})
afterEach(() => {
useLibraryStore().stopPolling()
})
it('shows inline validation and sends nothing for an empty import form', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
signIn()
const router = await viewAt('/import')
const wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await wrapper.find('form').trigger('submit')
expect(wrapper.text()).toContain('请填写标题。')
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock).not.toHaveBeenCalled()
await wrapper.find('input#title').setValue(' ')
await wrapper.find('textarea#text').setValue(' \n\t ')
await wrapper.find('form').trigger('submit')
expect(wrapper.text()).toContain('请填写标题。')
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
await wrapper.find('input#title').setValue('虚构样例第一章')
await wrapper.find('form').trigger('submit')
expect(wrapper.text()).not.toContain('请填写标题。')
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock).not.toHaveBeenCalled()
wrapper.unmount()
})
it('submits valid pasted text and opens the created book', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(created({ book, chapter: chapter('pending'), job: { id: 7 }, duplicate: false }))
signIn()
const router = await viewAt('/import')
const wrapper = mount(ImportView, { global: { plugins: [router] } })
// Element Plus assigns the input ids on mount, so wait for the first update.
await flushPromises()
await wrapper.find('input#title').setValue('虚构样例第一章')
await wrapper.find('textarea#text').setValue(pasted)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ title: '虚构样例第一章', text: pasted, language: 'en' })
expect(router.currentRoute.value.path).toBe('/books/1')
wrapper.unmount()
})
it('renders a ready chapter verbatim, keeping line breaks, tabs and repeated spaces', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('ready', { originalText: pasted, contentSha256: 'fictional' }), navigation }))
signIn()
const router = await viewAt('/chapters/55')
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
await flushPromises()
const article = wrapper.find('.reader-text')
expect(article.exists()).toBe(true)
expect(article.element.textContent).toBe(pasted)
expect(wrapper.text()).toContain('已就绪')
wrapper.unmount()
})
it('shows the failure message of a failed chapter and no text', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorReason: 'decode_error', errorMessage: '无法解析正文,请检查编码。', jobId: null }), navigation }))
signIn()
const router = await viewAt('/chapters/55')
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.text()).toContain('处理失败')
expect(wrapper.text()).toContain('无法解析正文,请检查编码。')
expect(wrapper.find('.reader-text').exists()).toBe(false)
// A null job id means there is nothing to retry yet.
expect(wrapper.text()).toContain('这一章暂时没有可重试的任务编号。')
wrapper.unmount()
})
it('offers retry on a failed chapter through its own job id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorMessage: '解析失败。', jobId: 7 }), navigation }))
signIn()
const router = await viewAt('/chapters/55')
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.text()).toContain('解析失败。')
const retryButton = wrapper.get('.notice .el-button')
expect(retryButton.text()).toContain('重试处理')
await retryButton.trigger('click')
await flushPromises()
// No prior submit in this session: the job id comes from the chapter payload.
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/7/retry'))
expect(retryCall?.[1]?.method).toBe('POST')
wrapper.unmount()
})
it('lists chapters with their status labels and links only ready chapters', async () => {
const chapters = [
chapter('pending', { id: 55, ordinal: 1, title: '第一篇', jobId: 7 }),
chapter('processing', { id: 56, ordinal: 2, title: '第二篇', jobId: 8 }),
chapter('ready', { id: 57, ordinal: 3, title: '第三篇', jobId: 9 }),
chapter('failed', { id: 58, ordinal: 4, title: '第四篇', errorMessage: '解析失败。', jobId: null }),
chapter('failed', { id: 59, ordinal: 5, title: '第五篇', errorMessage: '编码错误。', jobId: 10 }),
]
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
if (String(input).endsWith('/jobs/10/retry')) {
return Promise.resolve(ok({ job: { id: 10, status: 'pending', attempts: 1 }, chapter: { id: 59, bookId: 1, jobId: 10 } }))
}
return Promise.resolve(ok({ book, chapters }))
})
signIn()
const router = await viewAt('/books/1')
const wrapper = mount(BookView, { global: { plugins: [router] } })
await flushPromises()
const text = wrapper.text()
expect(text).toContain('待处理')
expect(text).toContain('处理中')
expect(text).toContain('已就绪')
expect(text).toContain('处理失败')
expect(text).toContain('解析失败。')
expect(text).toContain('编码错误。')
expect(wrapper.find('a[href="/chapters/57"]').exists()).toBe(true)
expect(wrapper.find('a[href="/chapters/55"]').exists()).toBe(false)
// Retry is offered only for the failed chapter that carries a job id.
const retryButtons = wrapper.findAll('.chapter-row .el-button').filter(button => button.text().includes('重试'))
expect(retryButtons).toHaveLength(1)
await retryButtons[0]!.trigger('click')
await flushPromises()
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/10/retry'))
expect(retryCall?.[1]?.method).toBe('POST')
wrapper.unmount()
})
})
+4
View File
@@ -4,6 +4,10 @@ import App from './App.vue'
import router from './router'
import 'element-plus/es/components/button/style/css'
import 'element-plus/es/components/input/style/css'
import 'element-plus/es/components/radio/style/css'
import 'element-plus/es/components/radio-group/style/css'
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/option/style/css'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+3
View File
@@ -6,6 +6,9 @@ const router = createRouter({
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
+438
View File
@@ -0,0 +1,438 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { ApiError, useSessionStore } from './session'
export type ChapterStatus = 'pending' | 'processing' | 'ready' | 'failed'
export interface BookRef { id: number; title: string; language: string }
export interface BookSummary extends BookRef {
chapterCount: number
pendingCount: number
processingCount: number
readyCount: number
failedCount: number
createdAt: string
updatedAt: string
}
export interface ChapterSummary {
id: number
bookId: number
ordinal: number
title: string
status: ChapterStatus
charCount: number
errorReason: string
errorMessage: string
// Present wherever a chapter appears; null while the job id is unknown.
jobId: number | null
createdAt: string
updatedAt: string
}
export interface ChapterDetail extends ChapterSummary {
contentSha256: string
// Present only for ready chapters; never cached or faked for other statuses.
originalText?: string
}
export interface Job {
id: number
bookId: number
chapterId: number
status: ChapterStatus
attempts: number
errorReason: string
errorMessage: string
createdAt: string
updatedAt: string
}
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
export const POLL_INTERVAL_MS = 1500
export const TITLE_MAX_LENGTH = 120
export const TEXT_MAX_CODE_POINTS = 100000
export const NOT_FOUND_MESSAGE = '内容不存在。'
export const LANGUAGE_LABEL = '英语'
export const LANGUAGE_CODE = 'en'
const CHAPTER_STATUS_LABELS: Record<ChapterStatus, string> = {
pending: '待处理',
processing: '处理中',
ready: '已就绪',
failed: '处理失败',
}
export function statusLabel(status: ChapterStatus): string {
return CHAPTER_STATUS_LABELS[status]
}
function isUnsettled(status: ChapterStatus): boolean {
return status === 'pending' || status === 'processing'
}
/** Mirrors the server rule: non-empty after trim and at most 120 characters. */
export function titleProblem(title: string): string {
const trimmed = title.trim()
if (!trimmed) return '请填写标题。'
if ([...trimmed].length > TITLE_MAX_LENGTH) return `标题不能超过 ${TITLE_MAX_LENGTH} 个字符。`
return ''
}
/** Mirrors the server rule: at least one non-whitespace character, at most 100000 code points. */
export function textProblem(text: string): string {
if (!text.trim()) return '请粘贴要导入的英文正文。'
if ([...text].length > TEXT_MAX_CODE_POINTS) return `正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`
return ''
}
/** A failed chapter can be retried as soon as the API told us its job id. */
export function canRetry(chapter: Pick<ChapterSummary, 'status' | 'jobId'>): boolean {
return chapter.status === 'failed' && chapter.jobId !== null
}
/** Compact one-line status summary for a book card, e.g. `已就绪 2 · 处理中 1 · 待处理 1 · 失败 1`. */
export function statusSummary(book: BookSummary): string {
const parts: string[] = []
if (book.readyCount > 0) parts.push(`已就绪 ${book.readyCount}`)
if (book.processingCount > 0) parts.push(`处理中 ${book.processingCount}`)
if (book.pendingCount > 0) parts.push(`待处理 ${book.pendingCount}`)
if (book.failedCount > 0) parts.push(`失败 ${book.failedCount}`)
return parts.join(' · ')
}
interface LoadOptions { silent?: boolean }
interface SubmitBody { requestId: string; title: string; text: string; language: 'en' }
interface Created { bookId: number; chapter: ChapterSummary }
function emptyNavigation(): ChapterNavigation {
return { previousChapterId: null, nextChapterId: null }
}
export const useLibraryStore = defineStore('library', () => {
const session = useSessionStore()
const books = ref<BookSummary[]>([])
const booksLoading = ref(false)
const booksError = ref('')
const book = ref<BookRef | null>(null)
const chapters = ref<ChapterSummary[]>([])
const bookLoading = ref(false)
const bookError = ref('')
const chapter = ref<ChapterDetail | null>(null)
const chapterBook = ref<BookRef | null>(null)
const navigation = ref<ChapterNavigation>(emptyNavigation())
const chapterLoading = ref(false)
const chapterError = ref('')
const submitting = ref(false)
const submitError = ref('')
const retryingChapterId = ref<number | 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 ?? '' : ''))
// Every request is tagged with a generation and the owning account so that a
// late response can never repopulate the view after logout or an account switch.
let generation = 0
let booksSeq = 0
let bookSeq = 0
let chapterSeq = 0
let pollTimer: number | undefined
// One requestId per unsent form content: a double click or a repeat submit of
// unchanged content must create one chapter, not two.
let submissionKey = ''
let submissionRequestId = ''
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function failureMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback
}
function isNotFound(reason: unknown): boolean {
return reason instanceof ApiError && reason.status === 404
}
function stopPolling(): void {
if (pollTimer !== undefined) {
window.clearTimeout(pollTimer)
pollTimer = undefined
}
}
function needsPolling(): boolean {
return (book.value !== null && chapters.value.some(item => isUnsettled(item.status)))
|| (chapter.value !== null && isUnsettled(chapter.value.status))
}
/** Schedules the next refresh, or stops polling when nothing is pending anymore. */
function schedulePolling(): void {
if (!needsPolling()) {
stopPolling()
return
}
if (pollTimer !== undefined || !session.user) return
pollTimer = window.setTimeout(() => {
pollTimer = undefined
void poll()
}, POLL_INTERVAL_MS)
}
async function poll(): Promise<void> {
if (!session.user) {
stopPolling()
return
}
const version = generation
const owner = ownerId()
const bookId = book.value !== null && chapters.value.some(item => isUnsettled(item.status)) ? book.value.id : null
const chapterId = chapter.value !== null && isUnsettled(chapter.value.status) ? chapter.value.id : null
if (bookId !== null) await loadBook(bookId, { silent: true })
if (chapterId !== null) await loadChapter(chapterId, { silent: true })
if (isStale(version, owner)) {
stopPolling()
return
}
schedulePolling()
}
async function loadBooks(): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++booksSeq
booksLoading.value = true
booksError.value = ''
try {
const result = await session.request<{ items?: BookSummary[] } | null>('books')
if (seq !== booksSeq || isStale(version, owner)) return
const items = result?.items
books.value = Array.isArray(items) ? items : []
} catch (reason) {
if (seq !== booksSeq || isStale(version, owner)) return
booksError.value = failureMessage(reason, '书库暂时无法加载,请稍后重试。')
} finally {
if (seq === booksSeq && !isStale(version, owner)) booksLoading.value = false
}
}
async function loadBook(id: number, options: LoadOptions = {}): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++bookSeq
if (!options.silent) {
bookLoading.value = true
bookError.value = ''
}
try {
const result = await session.request<{ book: BookRef; chapters?: ChapterSummary[] }>(`books/${id}`)
if (seq !== bookSeq || isStale(version, owner)) return
book.value = result.book
chapters.value = Array.isArray(result.chapters) ? result.chapters : []
bookError.value = ''
schedulePolling()
} catch (reason) {
if (seq !== bookSeq || isStale(version, owner)) return
if (isNotFound(reason)) {
// Another account's id never resolves for this caller: report it and stop
// instead of polling a resource that will not appear.
book.value = null
chapters.value = []
bookError.value = NOT_FOUND_MESSAGE
stopPolling()
return
}
// A failed background refresh keeps the data already on screen; the next
// tick tries again and the user still sees the last known state.
if (!options.silent) bookError.value = failureMessage(reason, '书籍暂时无法加载,请稍后重试。')
} finally {
if (seq === bookSeq && !isStale(version, owner)) bookLoading.value = false
}
}
async function loadChapter(id: number, options: LoadOptions = {}): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++chapterSeq
if (!options.silent) {
chapterLoading.value = true
chapterError.value = ''
// Never keep the previous chapter's text under a new chapter id.
if (chapter.value !== null && chapter.value.id !== id) {
chapter.value = null
chapterBook.value = null
navigation.value = emptyNavigation()
}
}
try {
const result = await session.request<{ book: BookRef; chapter: ChapterDetail; navigation?: ChapterNavigation }>(`chapters/${id}`)
if (seq !== chapterSeq || isStale(version, owner)) return
chapterBook.value = result.book
chapter.value = result.chapter
navigation.value = result.navigation ?? emptyNavigation()
chapterError.value = ''
schedulePolling()
} catch (reason) {
if (seq !== chapterSeq || isStale(version, owner)) return
if (isNotFound(reason)) {
chapter.value = null
chapterBook.value = null
navigation.value = emptyNavigation()
chapterError.value = NOT_FOUND_MESSAGE
stopPolling()
return
}
if (!options.silent) chapterError.value = failureMessage(reason, '章节暂时无法加载,请稍后重试。')
} finally {
if (seq === chapterSeq && !isStale(version, owner)) chapterLoading.value = false
}
}
function submissionKeyOf(target: SubmitTarget, title: string, text: string): string {
return target.mode === 'new' ? `new\n${title}\n${text}` : `append:${target.bookId}\n${title}\n${text}`
}
async function createBook(body: SubmitBody): Promise<Created> {
const result = await session.request<{ book: BookRef; chapter: ChapterSummary }>('books', 'POST', body)
return { bookId: result.book.id, chapter: result.chapter }
}
async function appendChapter(bookId: number, body: SubmitBody): Promise<Created> {
const result = await session.request<{ chapter: ChapterSummary }>(`books/${bookId}/chapters`, 'POST', body)
return { bookId: result.chapter.bookId, chapter: result.chapter }
}
/**
* Submits pasted text. Returns the book id to open on success and throws on
* failure; `submitError` always carries the message shown to the user.
*/
async function submit(input: SubmitInput): Promise<number> {
const title = input.title.trim()
const text = input.text
const problem = titleProblem(title) || textProblem(text)
if (problem) {
submitError.value = problem
throw new Error(problem)
}
const key = submissionKeyOf(input.target, title, text)
if (key !== submissionKey || submissionRequestId === '') {
submissionKey = key
submissionRequestId = crypto.randomUUID()
}
const body: SubmitBody = { requestId: submissionRequestId, title, text, language: LANGUAGE_CODE }
const version = generation
const owner = ownerId()
submitting.value = true
submitError.value = ''
try {
const created = input.target.mode === 'new'
? await createBook(body)
: await appendChapter(input.target.bookId, body)
if (isStale(version, owner)) throw new Error('登录状态已变化,请重新提交。')
// The content was accepted; a later submit must use a fresh requestId.
submissionKey = ''
submissionRequestId = ''
return created.bookId
} catch (reason) {
if (!isStale(version, owner)) submitError.value = failureMessage(reason, '提交失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('提交失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) submitting.value = false
}
}
/** The job id comes from the chapter itself, wherever that chapter was loaded from. */
function jobIdOf(chapterId: number): number | null {
const target = chapters.value.find(item => item.id === chapterId)
?? (chapter.value?.id === chapterId ? chapter.value : null)
return target?.jobId ?? null
}
async function retryChapter(chapterId: number): Promise<void> {
const jobId = jobIdOf(chapterId)
if (jobId === null) throw new Error('这一章暂时没有可重试的任务编号。')
const version = generation
const owner = ownerId()
retryingChapterId.value = chapterId
try {
const result = await session.request<{ job: Job; chapter: Pick<ChapterSummary, 'id' | 'bookId'> }>(`jobs/${jobId}/retry`, 'POST')
if (isStale(version, owner)) return
// The retry answer only carries a summary, so refresh whatever is on screen.
if (book.value !== null && book.value.id === result.chapter.bookId) await loadBook(result.chapter.bookId, { silent: true })
if (chapter.value !== null && chapter.value.id === result.chapter.id) await loadChapter(result.chapter.id, { silent: true })
} catch (reason) {
if (isStale(version, owner)) return
throw reason instanceof Error ? reason : new Error('重试失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) retryingChapterId.value = null
}
}
/** Releases the book view so polling stops when the page is left. */
function closeBook(): void {
book.value = null
chapters.value = []
bookError.value = ''
schedulePolling()
}
/** Releases the reader view so polling stops when the page is left. */
function closeChapter(): void {
chapter.value = null
chapterBook.value = null
navigation.value = emptyNavigation()
chapterError.value = ''
schedulePolling()
}
function reset(): void {
generation++
stopPolling()
books.value = []
booksLoading.value = false
booksError.value = ''
book.value = null
chapters.value = []
bookLoading.value = false
bookError.value = ''
chapter.value = null
chapterBook.value = null
navigation.value = emptyNavigation()
chapterLoading.value = false
chapterError.value = ''
submitting.value = false
submitError.value = ''
retryingChapterId.value = null
submissionKey = ''
submissionRequestId = ''
}
return {
books, booksLoading, booksError,
book, chapters, bookLoading, bookError,
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, retryChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+13 -2
View File
@@ -2,6 +2,17 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
export const TOKEN_KEY = 'lexgo-learner-token'
// Carries the real HTTP status alongside the server message so callers can tell
// "this id does not exist for me" (404) from a transient failure without
// re-parsing the envelope. The message itself is unchanged.
export class ApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
interface User { id: number; username: string; role: 'admin' | 'learner' }
interface Space { ownerId: number; language: 'en' }
interface Login { token: string; expiresAt: string; user: User }
@@ -36,7 +47,7 @@ export const useSessionStore = defineStore('session', () => {
notice.value = '登录已失效,请重新登录。'
}
const result = await response.json()
if (!response.ok || result.code !== 200) throw new Error(result.msg || '请求失败,请稍后重试。')
if (!response.ok || result.code !== 200) throw new ApiError(result.msg || '请求失败,请稍后重试。', response.status)
return result.data as T
}
@@ -91,5 +102,5 @@ export const useSessionStore = defineStore('session', () => {
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
}
return { user, space, notice, login, restore, logout, loadSpace }
return { user, space, notice, login, restore, logout, loadSpace, request }
})
+55
View File
@@ -51,6 +51,50 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
.loading { padding: 80px 24px; text-align: center; color: #748073; }
/* Import / book / reader pages share one shell. */
.page { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.page-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-bottom: 28px; }
.page-title h1 { margin: 14px 0 6px; overflow-wrap: anywhere; }
.page-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.library-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: #315c43; }
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: #748073; }
.breadcrumb a { color: #315c43; }
/* Library book cards. */
.book-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
.book-card { display: flex; flex-direction: column; gap: 10px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 22px; }
.book-card .subtle { margin: 0; }
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
.book-title:hover { color: #315c43; text-decoration: underline; }
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: #eef2eb; color: #3d5b48; font-size: 13px; }
/* Chapter and job status, identical vocabulary for both. */
.status-chip { display: inline-flex; align-items: center; white-space: nowrap; padding: 5px 13px; border: 1px solid transparent; border-radius: 20px; font-size: 13px; }
.status-pending { background: #f5f2e4; border-color: #e2dcc2; color: #7a6a35; }
.status-processing { background: #eaf1f7; border-color: #c9dcea; color: #35566e; }
.status-ready { background: #eef2eb; border-color: #cbd9c9; color: #315c43; }
.status-failed { background: #fff0e7; border-color: #ebc3a8; color: #8b4324; }
/* Import form. */
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; font-size: 15px; }
.field-error { margin: 8px 0 0; color: #8b4324; font-size: 13px; line-height: 1.6; }
.counter { margin: 8px 0 0; color: #748073; font-size: 13px; }
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
.import-form .el-textarea__inner { min-height: 220px; line-height: 1.9; }
.book-select { width: 100%; }
/* Book chapters. */
.chapter-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: #8b9a8b; text-align: center; }
.chapter-info { min-width: 0; }
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: #748073; font-size: 13px; overflow-wrap: anywhere; }
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
.reader-page { max-width: 820px; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: 17px; line-height: 2; }
.processing-hint { padding: 36px 0; color: #748073; }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid #e0e3d8; }
@media (max-width: 760px) {
.login-page { grid-template-columns: 1fr; }
.welcome { padding: 28px; }
@@ -62,4 +106,15 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.site-header nav { order: 3; flex-basis: 100%; padding-top: 8px; }
.library { margin-top: 32px; padding: 0 20px; }
h1 { font-size: 26px; }
/* Single column, tap-friendly controls and no horizontal overflow. */
.page { margin-top: 32px; padding: 0 20px; }
.page-title { align-items: flex-start; }
.library-title { align-items: flex-start; }
.library-actions { width: 100%; justify-content: space-between; }
.book-grid { grid-template-columns: 1fr; }
.import-form { padding: 20px; }
.chapter-row { grid-template-columns: 30px minmax(0, 1fr); align-items: start; row-gap: 10px; padding: 15px 16px; }
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
.reader-text { font-size: 16px; line-height: 1.95; }
.reader-nav .el-button { flex: 1; }
}
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const bookId = computed(() => Number(route.params.id))
async function load() {
retryError.value = ''
await library.loadBook(bookId.value)
}
async function retry(chapterId: number) {
retryError.value = ''
try { await library.retryChapter(chapterId) }
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(load)
watch(bookId, () => { void load() })
// Leaving the page releases the book so polling stops.
onUnmounted(() => library.closeBook())
</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></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page">
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>{{ library.book?.title ?? '书籍' }}</span></p>
<p v-if="library.bookLoading && !library.book" role="status" class="loading">正在加载…</p>
<div v-else-if="library.bookError" class="notice">
<p role="alert">{{ library.bookError }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<template v-else-if="library.book">
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
</div>
<div class="page-actions">
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
</div>
</div>
<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">
<span class="chapter-ordinal">{{ item.ordinal }}</span>
<div class="chapter-info">
<RouterLink v-if="item.status === 'ready'" :to="`/chapters/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
<span v-else class="chapter-name">{{ item.title }}</span>
<p class="chapter-meta">
{{ item.charCount }} 字符
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
</p>
</div>
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
<ElButton
v-if="canRetry(item)"
size="small"
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
</li>
</ul>
<section v-else class="empty-library" aria-label="章节列表">
<h2>这一本书还没有章节</h2>
<p class="subtle">粘贴一段英文即可生成第一章。</p>
</section>
</template>
</main>
</div>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const title = ref('')
const text = ref('')
const mode = ref<'new' | 'append'>('new')
const bookId = ref<number | undefined>(undefined)
const titleError = ref('')
const textError = ref('')
const bookError = ref('')
const length = computed(() => [...text.value].length)
const busy = computed(() => library.submitting)
function requestedBookId(): number | undefined {
const raw = Array.isArray(route.query.book) ? route.query.book[0] : route.query.book
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return undefined
const value = Number(raw)
return Number.isSafeInteger(value) && value > 0 ? value : undefined
}
onMounted(() => {
// `?book=<id>` preselects "append to an existing book".
const preselect = requestedBookId()
if (preselect === undefined) return
mode.value = 'append'
bookId.value = preselect
})
watch(mode, value => {
// The select can only list the caller's own books.
if (value === 'append') void library.loadBooks()
})
watch(title, () => { titleError.value = '' })
watch(text, () => { textError.value = '' })
async function submit() {
if (busy.value) return
titleError.value = titleProblem(title.value)
textError.value = textProblem(text.value)
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
if (titleError.value || textError.value || bookError.value) return
const target: SubmitTarget = mode.value === 'append' && bookId.value !== undefined
? { mode: 'append', bookId: bookId.value }
: { mode: 'new' }
try {
const createdBookId = await library.submit({ title: title.value, text: text.value, target })
// The requestId was consumed by this submission, so the form starts clean.
title.value = ''
text.value = ''
await router.replace(`/books/${createdBookId}`)
} catch {
// library.submitError already carries the server message for the template.
}
}
onUnmounted(() => { library.submitError = '' })
</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></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
</div>
</header>
<main class="page">
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>导入内容</span></p>
<div class="page-title">
<div>
<h1>导入英文内容</h1>
<p class="subtle">粘贴英文正文,提交后系统会自动切分并处理章节。</p>
</div>
</div>
<form class="import-form" novalidate @submit.prevent="submit">
<div class="field">
<span class="field-label">语言</span>
<p class="fixed-value">{{ LANGUAGE_LABEL }}</p>
</div>
<div class="field">
<label for="title">标题</label>
<ElInput id="title" v-model="title" type="text" maxlength="200" placeholder="例如:虚构样例第一章" :disabled="busy" />
<p v-if="titleError" role="alert" class="field-error">{{ titleError }}</p>
</div>
<div class="field">
<span class="field-label">导入到</span>
<ElRadioGroup v-model="mode" :disabled="busy" aria-label="导入目标">
<ElRadio value="new">新建书籍</ElRadio>
<ElRadio value="append">追加到已有书籍</ElRadio>
</ElRadioGroup>
</div>
<div v-if="mode === 'append'" class="field">
<label for="book">选择书籍</label>
<ElSelect id="book" v-model="bookId" placeholder="请选择要追加的书籍" :disabled="busy" class="book-select">
<ElOption v-for="item in library.books" :key="item.id" :label="item.title" :value="item.id" />
</ElSelect>
<p v-if="library.booksError" role="alert" class="field-error">{{ library.booksError }}</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
</div>
<div class="field">
<label for="text">正文</label>
<ElInput id="text" v-model="text" type="textarea" :rows="12" placeholder="在此粘贴英文正文…" :disabled="busy" />
<p class="counter">{{ length }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p v-if="textError" role="alert" class="field-error">{{ textError }}</p>
</div>
<p v-if="library.submitError" role="alert" class="notice">{{ library.submitError }}</p>
<div class="form-actions">
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">开始处理</ElButton>
<RouterLink to="/" class="subtle">返回书库</RouterLink>
</div>
</form>
</main>
</div>
</template>
+26 -5
View File
@@ -3,8 +3,10 @@ import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const router = useRouter()
const loading = ref(true)
const error = ref('')
@@ -13,7 +15,9 @@ async function load() {
error.value = ''
try { await session.loadSpace() }
catch (reason) { error.value = reason instanceof Error ? reason.message : '暂时无法加载,请重试。' }
finally { loading.value = false }
// The book list keeps its own error so one failing call still shows a retry.
if (!error.value) await library.loadBooks()
loading.value = false
}
async function logout() {
try { await session.logout() }
@@ -34,11 +38,28 @@ onMounted(load)
</div>
</header>
<main class="library">
<div class="library-title"><div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div><span class="language">英语</span></div>
<div class="library-title">
<div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div>
<div class="library-actions">
<span class="language">英语</span>
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
</div>
</div>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error" class="notice"><p role="alert">{{ error }}</p><ElButton @click="load">重试</ElButton></div>
<section v-else-if="session.space" class="empty-library" aria-label="书库内容">
<BookMark /><h2>书库还是空的</h2><p class="subtle">这里将收纳你的阅读内容。</p>
<div v-else-if="error || library.booksError" class="notice">
<p role="alert">{{ error || library.booksError }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<ul v-else-if="library.books.length" class="book-grid" aria-label="书籍列表">
<li v-for="item in library.books" :key="item.id" class="book-card">
<RouterLink :to="`/books/${item.id}`" class="book-title">{{ item.title }}</RouterLink>
<p class="subtle">{{ item.chapterCount }} 个章节</p>
<p v-if="statusSummary(item)" class="status-summary">{{ statusSummary(item) }}</p>
<p v-else class="subtle">尚未导入章节</p>
</li>
</ul>
<section v-else class="empty-library" aria-label="书库内容">
<BookMark /><h2>书库还是空的</h2><p class="subtle">粘贴一段英文,开始你的第一篇阅读。</p>
</section>
</main>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.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))
async function load() {
retryError.value = ''
await library.loadChapter(chapterId.value)
}
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 : '重试失败,请稍后重试。' }
}
// 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(load)
watch(chapterId, () => { void load() })
// Leaving the page releases the chapter so polling stops.
onUnmounted(() => 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></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page reader-page">
<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>
<div class="page-title">
<h1>{{ chapter.title }}</h1>
<span class="status-chip" :class="`status-${chapter.status}`">{{ statusLabel(chapter.status) }}</span>
</div>
<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>
<article v-if="chapter.status === 'ready'" class="reader-text">{{ library.readerText }}</article>
<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>
</template>
</main>
</div>
</template>