feat: 编辑和删除本人书籍与章节 (#10)

- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、
  PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id
- 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务
  被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409
- 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态
- 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留
- 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化
- 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
ila
2026-09-13 23:20:52 +08:00
parent 65b50d5038
commit 35ed692c4b
16 changed files with 1565 additions and 29 deletions
+237
View File
@@ -0,0 +1,237 @@
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 BookView from '../views/BookView.vue'
import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-editor', role: 'learner' as const }
const book = { id: 1, title: 'A small step', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = (overrides: Partial<ChapterSummary> = {}): ChapterSummary => ({
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 12,
errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides,
})
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status })
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/books/:id', component: stub('BookStub') },
{ path: '/import', component: stub('ImportStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** Routes the book page's own calls; a test can override any of them. */
function mockApi(overrides: Record<string, (init?: RequestInit) => Response | Promise<Response>> = {}): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String((init as RequestInit | undefined)?.method ?? 'GET')
for (const [key, handler] of Object.entries(overrides)) {
if (url.includes(key)) return handler(init as RequestInit)
}
if (url.endsWith('/books/1') && method === 'GET') return ok({ book, chapters: [chapter()] })
if (url.includes('/chapters/9/source')) return ok({ source })
// A rename answers with the stored book, and a delete reports what it removed.
if (url.endsWith('/books/1') && method === 'PATCH') {
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string }
return ok({ book: { ...book, title: body.title ?? book.title } })
}
if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } })
if (url.endsWith('/books/1')) return ok({ book })
if (url.includes('/chapters/9')) return ok({ chapter: chapter(), job: null, versionChanged: false })
return ok({})
})
}
async function openBook() {
useSessionStore().user = { ...user }
const router = await viewAt('/books/1')
wrapper = mount(BookView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router }
}
describe('book editing store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('renames a book and keeps the list entry in step', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }]
library.book = { ...book }
const renamed = await library.renameBook(1, ' New name ')
expect(renamed.title).toBe('New name')
expect(library.book?.title).toBe('New name')
expect(library.books[0]?.title).toBe('New name')
const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(String(call[0])).toBe('/api/v1/books/1')
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name' })
})
it('refuses an invalid title before calling the API', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题')
expect(fetchMock).not.toHaveBeenCalled()
})
it('applies a new chapter version and drops the stale reader text', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ status: 'pending', title: 'Edited' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'old text' }
const result = await library.updateChapter(9, { title: 'Edited', text: 'new text' })
expect(result.versionChanged).toBe(true)
expect(library.chapters[0]).toMatchObject({ status: 'pending', title: 'Edited', jobId: 6 })
// The reader must not keep showing text that is no longer the stored version.
expect(library.chapter?.originalText).toBeUndefined()
})
it('keeps the reader text when only the title changed', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ title: 'Renamed' }), job: null, versionChanged: false }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'kept text' }
await library.updateChapter(9, { title: 'Renamed' })
expect(library.chapter?.originalText).toBe('kept text')
expect(library.chapter?.title).toBe('Renamed')
})
it('deletes a chapter, reloads the book and removes a deleted book from the list', async () => {
mockApi({
'/chapters/9': () => ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }),
'/books/1': (init) => (init?.method === 'DELETE'
? ok({ deleted: { bookId: 1, chapters: 3 } })
: ok({ book, chapters: [] })),
})
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 3, pendingCount: 0, processingCount: 0, readyCount: 3, failedCount: 0, ...timestamps }]
library.book = { ...book }
library.chapters = [chapter(), chapter({ id: 10, ordinal: 2 }), chapter({ id: 11, ordinal: 3 })]
const deleted = await library.deleteChapter(9)
expect(deleted.remaining).toBe(2)
expect(library.chapters.map(item => item.id)).toEqual([])
expect(library.book?.id).toBe(1)
const removed = await library.deleteBook(1)
expect(removed.chapters).toBe(3)
expect(library.books).toHaveLength(0)
expect(library.book).toBeNull()
})
it('reads the editable source of any chapter state', async () => {
mockApi({ '/chapters/9/source': () => ok({ source: { ...source, status: 'failed' } }) })
const library = useLibraryStore()
const loaded = await library.loadChapterSource(9)
expect(loaded.text).toBe(source.text)
expect(loaded.status).toBe('failed')
})
})
describe('book editing view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; useLibraryStore().stopPolling() })
it('renames the book through the dialog and reports it', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
const input = view.get('#book-title')
expect((input.element as HTMLInputElement).value).toBe('A small step')
await input.setValue(' Edited name ')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('书名已更新')
})
it('keeps the dialog open with the server message when renaming fails', async () => {
mockApi({ '/books/1': (init) => (init?.method === 'PATCH' ? fail('书名已存在') : ok({ book, chapters: [chapter()] })) })
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
await view.get('#book-title').setValue('Rejected')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('书名已存在')
expect(view.get('#book-title')).toBeTruthy()
})
it('asks before deleting the book and does nothing when the learner cancels', async () => {
const fetchMock = mockApi()
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const { view } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('已保存的生词和短语将保留'), '删除书籍', expect.anything())
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
})
it('deletes the book after confirmation and returns to the library', async () => {
const fetchMock = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view, router } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/books/1') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(router.currentRoute.value.path).toBe('/')
expect(router.currentRoute.value.query.deleted).toBe('3')
})
it('edits a chapter: loads the source, saves title and text and reports the new version', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'PATCH'
? ok({ chapter: chapter({ status: 'pending', title: 'Edited chapter' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true })
: ok({ source })) })
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
expect((view.get('#chapter-title').element as HTMLInputElement).value).toBe('First chapter')
expect((view.get('#chapter-text').element as HTMLTextAreaElement).value).toBe(source.text)
await view.get('#chapter-title').setValue('Edited chapter')
await view.get('#chapter-text').setValue('A new body.\n')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', text: 'A new body.\n' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理')
})
it('refuses an empty chapter body locally and keeps the dialog', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('#chapter-text').setValue(' \n\t ')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')).toBe(false)
})
it('deletes a chapter after confirmation and reports the remaining count', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'DELETE' ? ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }) : ok({ source })) })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('[data-testid="delete-chapter"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('章节已删除 · 剩余 2 章')
// Element Plus keeps a closed dialog in the DOM, so the closed state is what matters.
expect(view.get('[data-testid="chapter-dialog"]').isVisible()).toBe(false)
})
it('shows an empty chapter list with the import hint', async () => {
mockApi({ '/books/1': () => ok({ book, chapters: [] }) })
const { view } = await openBook()
expect(view.get('[data-testid="empty-chapters"]').text()).toContain('这一本书还没有章节')
expect(view.get('[data-testid="delete-book"]')).toBeTruthy()
})
})
+90
View File
@@ -51,6 +51,21 @@ export interface Job {
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
/** The editable text of one owned chapter; separate from the ready-only reader payload. */
export interface ChapterSource {
id: number
bookId: number
ordinal: number
title: string
text: string
status: ChapterStatus
contentSha256: string
charCount: number
}
export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean }
export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number }
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
@@ -429,6 +444,80 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/** Renames one owned book; the reply is the stored book. */
async function renameBook(id: number, title: string): Promise<BookRef> {
const version = generation
const owner = ownerId()
const problem = titleProblem(title)
if (problem) throw new Error(problem)
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() })
if (!isStale(version, owner)) {
book.value = result.book
const listed = books.value.find(item => item.id === id)
if (listed) listed.title = result.book.title
}
return result.book
}
/**
* Saves a chapter title and/or text. A changed text becomes a new version and re-processes;
* the same text answered again changes nothing.
*/
async function updateChapter(id: number, input: { title?: string; text?: string }): Promise<ChapterEdit> {
const version = generation
const owner = ownerId()
if (input.title !== undefined) {
const problem = titleProblem(input.title)
if (problem) throw new Error(problem)
}
if (input.text !== undefined) {
const problem = textProblem(input.text)
if (problem) throw new Error(problem)
}
const body: { title?: string; text?: string } = {}
if (input.title !== undefined) body.title = input.title.trim()
if (input.text !== undefined) body.text = input.text
const result = await session.request<ChapterEdit>(`chapters/${id}`, 'PATCH', body)
if (isStale(version, owner)) return result
const merged = { ...result.chapter, jobId: result.job?.id ?? result.chapter.jobId }
applyChapterSummary(merged)
if (chapter.value !== null && chapter.value.id === id && result.versionChanged) {
// The new version is not readable yet, so the reader must drop the previous text.
chapter.value = { ...chapter.value, ...merged, originalText: undefined }
}
schedulePolling()
return result
}
/** Reads the editable text of one owned chapter, in any processing state. */
async function loadChapterSource(id: number): Promise<ChapterSource> {
const result = await session.request<{ source: ChapterSource }>(`chapters/${id}/source`)
return result.source
}
/** Deletes one owned book with its chapters and jobs; personal records stay. */
async function deleteBook(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`books/${id}`, 'DELETE')
if (!isStale(version, owner)) {
books.value = books.value.filter(item => item.id !== id)
if (book.value?.id === id) closeBook()
}
return result.deleted
}
/** Deletes one owned chapter and closes the gap in the chapter order. */
async function deleteChapter(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`chapters/${id}`, 'DELETE')
if (isStale(version, owner)) return result.deleted
chapters.value = chapters.value.filter(item => item.id !== id)
if (book.value !== null) await loadBook(book.value.id, { silent: true })
return result.deleted
}
/** 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)
@@ -526,6 +615,7 @@ export const useLibraryStore = defineStore('library', () => {
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+2
View File
@@ -128,6 +128,8 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
+148 -4
View File
@@ -1,8 +1,8 @@
<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 { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
@@ -10,8 +10,23 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
// Renaming the book.
const bookDialog = ref(false)
const bookTitle = ref('')
const bookError = ref('')
// Editing one chapter: title and text, loaded from the source endpoint.
const chapterDialog = ref(false)
const chapterSource = ref<ChapterSource | null>(null)
const chapterTitle = ref('')
const chapterText = ref('')
const chapterError = ref('')
const chapterLoading = ref(false)
const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
async function load() {
retryError.value = ''
@@ -24,6 +39,95 @@ async function retry(chapterId: number) {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
function openBookDialog() {
bookTitle.value = library.book?.title ?? ''
bookError.value = ''
bookDialog.value = true
}
async function saveBookTitle() {
if (saving.value || library.book === null) return
bookError.value = titleProblem(bookTitle.value)
if (bookError.value) return
saving.value = true
try {
await library.renameBook(library.book.id, bookTitle.value)
bookDialog.value = false
notice.value = '书名已更新。'
} catch (reason) {
bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
chapterSource.value = source
chapterTitle.value = source.title
chapterText.value = source.text
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。'
} finally {
chapterLoading.value = false
}
}
async function saveChapter() {
const source = chapterSource.value
if (saving.value || source === null) return
chapterError.value = titleProblem(chapterTitle.value) || textProblem(chapterText.value)
if (chapterError.value) return
saving.value = true
try {
const edited = await library.updateChapter(source.id, { title: chapterTitle.value, text: chapterText.value })
chapterDialog.value = false
notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。'
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function confirmDeleteBook() {
if (library.book === null) return
const id = library.book.id
try {
await ElMessageBox.confirm('删除这本书及其章节?已保存的生词和短语将保留。', '删除书籍', {
confirmButtonText: '确认删除', cancelButtonText: '取消,保留书籍', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteBook(id)
await router.replace(`/?deleted=${deleted.chapters ?? 0}`)
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function confirmDeleteChapter(chapterId: number, ordinal: number) {
try {
await ElMessageBox.confirm(`删除第 ${ordinal} 章?本章正文将被删除,已保存词条保留。`, '删除章节', {
confirmButtonText: '确认删除章节', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteChapter(chapterId)
// The chapter no longer exists, so the dialog closes and the list reports the remainder.
chapterDialog.value = false
chapterSource.value = null
notice.value = `章节已删除 · 剩余 ${deleted.remaining} 章`
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
@@ -57,12 +161,15 @@ 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>
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<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">
@@ -82,13 +189,50 @@ onUnmounted(() => library.closeBook())
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
<ElButton size="small" :data-testid="`edit-chapter-${item.id}`" @click="openChapterDialog(item.id)">编辑</ElButton>
</li>
</ul>
<section v-else class="empty-library" aria-label="章节列表">
<section v-else class="empty-library" aria-label="章节列表" data-testid="empty-chapters">
<h2>这一本书还没有章节</h2>
<p class="subtle">粘贴一段英文即可生成第一章。</p>
</section>
</template>
<ElDialog v-model="bookDialog" title="编辑书名" width="420" data-testid="book-dialog">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
<p class="subtle">封面使用系统默认样式。</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
<template #footer>
<ElButton @click="bookDialog = false">取消</ElButton>
<ElButton type="primary" :loading="saving" data-testid="save-book" @click="saveBookTitle">保存修改</ElButton>
</template>
</ElDialog>
<ElDialog v-model="chapterDialog" title="编辑章节" width="620" data-testid="chapter-dialog">
<p v-if="chapterLoading" role="status" class="loading">正在加载…</p>
<template v-else-if="chapterSource">
<label for="chapter-title">章节标题</label>
<ElInput id="chapter-title" v-model="chapterTitle" type="text" maxlength="200" :disabled="saving" />
<label for="chapter-text">正文</label>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="12" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p class="subtle">保存后会重新处理这一章;只改标题不会重新处理。</p>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>
<ElButton data-testid="cancel-chapter" @click="chapterDialog = false">取消</ElButton>
<ElButton
v-if="chapterSource"
type="danger"
plain
:disabled="saving"
data-testid="delete-chapter"
@click="confirmDeleteChapter(chapterSource.id, chapterSource.ordinal)"
>删除章节</ElButton>
<ElButton type="primary" :loading="saving" :disabled="chapterLoading || !chapterSource" data-testid="save-chapter" @click="saveChapter">保存章节</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+10 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
@@ -8,8 +8,15 @@ import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const router = useRouter()
const route = useRoute()
const loading = ref(true)
const error = ref('')
// A deletion returns here with the count it removed, so the learner sees what happened.
const deletedNotice = computed(() => {
const raw = Array.isArray(route.query.deleted) ? route.query.deleted[0] : route.query.deleted
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return ''
return '书籍已删除 · 已保存的生词和短语仍保留在生词本。'
})
async function load() {
loading.value = true
error.value = ''
@@ -45,6 +52,7 @@ onMounted(load)
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
</div>
</div>
<p v-if="deletedNotice" role="status" class="chapter-notice" data-testid="library-notice">{{ deletedNotice }}</p>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error || library.booksError" class="notice">
<p role="alert">{{ error || library.booksError }}</p>