feat: 章节附件并入章节编辑对话框 (#37)

- 章节列表每行只保留「编辑」;移除「附件」按钮与独立的附件对话框
- 章节编辑对话框同时编辑标题、正文、本章插图与本章音频,并明确写出生效时机:
  标题与正文点「保存章节」后生效,插图与音频在选中文件后立即上传、取消不撤销
- 无插图/音频的章节仍显示「未设置」,后端口与 schema 无变化(仍 v9)
- 测试:157 单测与 26 项 E2E 通过(新断言:章节行只有一个按钮、对话框含四类控件与时机说明、
  对话框内上传后仍可用保存按钮提交文本);真实链路确认按钮数为 1、上传即时生效、取消后阅读页正常
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
This commit is contained in:
ila
2026-09-15 22:14:21 +08:00
parent 3ab26afe4a
commit 771c3df976
9 changed files with 181 additions and 80 deletions
+12 -6
View File
@@ -80,6 +80,10 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean }
})
return json({ textSha256: 'sha', tokens })
}
if (path === '/api/v1/chapters/3/source') {
return json({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: chapterText, status: 'ready', contentSha256: 'sha', charCount: chapterText.length } })
}
if (path === '/api/v1/chapters/3' && method === 'PATCH') return json({ chapter: chapterFiles(), job: null, versionChanged: false })
if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapterFiles(), contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } })
return json({})
})
@@ -105,11 +109,14 @@ test('the library shows the cover and the chapter dialog manages its files', asy
// The chapter row carries no image: it only has the audio meta and the attachment entry point.
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
await expect(page.locator('.chapter-thumb')).toHaveCount(0)
await expect(page.getByTestId('chapter-attachments-3')).toHaveText('附件')
// One entry point per row: the editor holds the title, the text and both files.
await expect(page.getByTestId('chapter-attachments-3')).toHaveCount(0)
await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' })
await page.getByTestId('chapter-attachments-3').click()
await expect(page.getByTestId('attachment-dialog')).toBeVisible()
await page.getByTestId('edit-chapter-3').click()
await expect(page.getByTestId('chapter-dialog')).toBeVisible()
await expect(page.locator('#chapter-title')).toBeVisible()
await expect(page.getByTestId('attachment-timing')).toContainText('立即上传')
await expect(page.getByTestId('illustration-state')).toContainText('已设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('已设置')
await page.screenshot({ path: '../.local/evidence/issue37-attachment-dialog.png' })
@@ -123,7 +130,7 @@ test('the library shows the cover and the chapter dialog manages its files', asy
// A file of the wrong type is refused in the page, before any request.
await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') })
await expect(page.locator('[data-testid="attachment-dialog"] .field-error')).toContainText('音频只支持 MP3')
await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3')
expect(calls.posted).toEqual(['illustration', 'audio'])
// Removing the audio asks first and then reports the empty state.
@@ -215,8 +222,7 @@ test('a book without attachments shows placeholders and no player', async ({ pag
await expect(page.getByTestId('attachments')).toBeVisible()
await expect(page.getByTestId('cover-state')).toContainText('未设置')
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
await expect(page.getByTestId('chapter-attachments-3')).toHaveText('附件')
await page.getByTestId('chapter-attachments-3').click()
await page.getByTestId('edit-chapter-3').click()
await expect(page.getByTestId('illustration-state')).toContainText('未设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置')
await page.goto('/chapters/3')
+24 -6
View File
@@ -70,13 +70,18 @@ function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSec
if (url.endsWith('/books') && method === 'GET') {
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] })
}
if (url.endsWith('/books/7/cover') || url.includes('/chapters/3/')) {
if (url.endsWith('/books/7/cover') || url.endsWith('/chapters/3/audio') || url.endsWith('/chapters/3/illustration')
|| url.endsWith('/chapters/3/playback')) {
if (method === 'GET') {
return new Response(pngBytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
}
return ok({ attachment: { kind: url.includes('illustration') ? 'illustration' : 'audio', mime: 'application/octet-stream', byteSize: 2048, version: 'new-sha', updatedAt: '' }, deleted: true, playbackSeconds: 12 })
}
if (url.endsWith('/books/7')) return ok({ book: { ...book, attachments: { cover } }, chapters: [chapter] })
if (url.endsWith('/chapters/3/source')) {
return ok({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: 'Curiosity opens the first door.' + String.fromCharCode(10), status: 'ready', contentSha256: 'sha', charCount: 31 } })
}
if (url.endsWith('/chapters/3') && method === 'PATCH') return ok({ chapter: baseChapter, job: null, versionChanged: false })
if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] })
if (url.endsWith('/chapters/3')) {
return ok({ book: { ...book, attachments: { cover } }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } })
@@ -152,16 +157,23 @@ describe('chapter attachments on the book page', () => {
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/illustration'))).toBe(false)
expect(view.get('.chapter-meta').text()).toContain('有音频')
expect(view.get('.chapter-meta').text()).toContain('上次 1:05')
// The book-level audio row is gone: audio lives in the chapter dialog now.
// The book-level audio row is gone: audio lives in the chapter editor now, and the row has
// exactly one entry point.
expect(view.text()).not.toContain('音频 · 3.4 MB')
expect(view.get('[data-testid="chapter-attachments-3"]').text()).toBe('附件')
expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false)
expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true)
})
it('uploads an illustration and an audio file from the chapter dialog', async () => {
it('edits the title, the text and both files in one chapter editor', async () => {
const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 })
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="chapter-attachments-3"]').trigger('click')
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
// One dialog holds the title, the text and both files.
expect(view.find('[data-testid="chapter-dialog"]').exists()).toBe(true)
expect(view.find('#chapter-title').exists()).toBe(true)
expect(view.find('#chapter-text').exists()).toBe(true)
expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置')
@@ -170,13 +182,19 @@ describe('chapter attachments on the book page', () => {
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(64)], 'track.mp3', { type: 'audio/mpeg' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/audio') && String(init?.method) === 'POST')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已更新')
// The text is still saved by its own button, inside the same dialog.
await view.get('#chapter-title').setValue('Fictional chapter renamed')
await view.get('[data-testid="save-chapter"]').trigger('click')
await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/chapters/3') && String(init?.method) === 'PATCH')).toBe(true)
})
it('refuses a wrong type before uploading and removes 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="chapter-attachments-3"]').trigger('click')
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' }))
+1
View File
@@ -367,6 +367,7 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible,
.attachment-info { min-width: 0; flex: 1; }
.attachment-label { margin: 0 0 4px; font-weight: 600; }
.attachment-info .subtle { margin: 4px 0 0; }
.attachment-heading { margin: 22px 0 4px; font-size: 15px; font-weight: 600; color: var(--text-strong); }
.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); }
+51 -58
View File
@@ -29,9 +29,7 @@ const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
const coverError = ref('')
// The chapter attachment dialog: one chapter at a time, with its own errors per file.
const attachmentDialog = ref(false)
const attachmentChapter = ref<ChapterSummary | null>(null)
// Errors for the chapter's files, shown inside the chapter editor next to the file they belong to.
const illustrationError = ref('')
const audioError = ref('')
@@ -134,18 +132,15 @@ async function removeCover(): Promise<void> {
}
}
function openAttachmentDialog(item: ChapterSummary): void {
attachmentChapter.value = item
illustrationError.value = ''
audioError.value = ''
attachmentDialog.value = true
}
/** The list entry is replaced by the store after an upload, so the dialog reads the fresh copy. */
/**
* The chapter being edited, read from the list so an upload is reflected immediately. The editor
* holds the title and the text, and the same dialog also manages the chapter's illustration and
* audio: one place to edit a chapter, with one entry point per row.
*/
const attachmentLive = computed(() => {
const current = attachmentChapter.value
if (!current) return null
return library.chapters.find(item => item.id === current.id) ?? current
const id = chapterSource.value?.id
if (id === undefined) return null
return library.chapters.find(item => item.id === id) ?? null
})
async function uploadChapterFile(kind: 'illustration' | 'audio', file: File): Promise<void> {
@@ -242,6 +237,8 @@ async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
illustrationError.value = ''
audioError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
@@ -392,7 +389,6 @@ onUnmounted(() => library.closeBook())
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
<ElButton size="small" :data-testid="`chapter-attachments-${item.id}`" @click="openAttachmentDialog(item)">附件</ElButton>
<ElButton size="small" :data-testid="`edit-chapter-${item.id}`" @click="openChapterDialog(item.id)">编辑</ElButton>
</li>
</ul>
@@ -413,57 +409,54 @@ onUnmounted(() => library.closeBook())
</template>
</ElDialog>
<ElDialog v-model="attachmentDialog" :title="`章节附件:${attachmentLive?.title ?? ''}`" width="620" data-testid="attachment-dialog">
<template v-if="attachmentLive">
<div class="attachment-row">
<div class="attachment-preview">
<img v-if="library.illustrationUrls[attachmentLive.id]" :src="library.illustrationUrls[attachmentLive.id]" alt="当前插图" data-testid="illustration-preview" />
<span v-else class="book-cover-default" aria-hidden="true">图</span>
</div>
<div class="attachment-info">
<p class="attachment-label">插图</p>
<p class="subtle" data-testid="illustration-state">{{ fileState(attachmentLive, 'illustration') }}</p>
<p v-if="illustrationError" role="alert" class="field-error">{{ illustrationError }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'illustration' && library.uploadingId === attachmentLive.id }">
{{ attachmentLive.illustrationVersion ? '替换插图' : '上传插图' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="illustration-input" :disabled="library.uploadingKind !== null" @change="onIllustrationPicked" />
</label>
<ElButton v-if="attachmentLive.illustrationVersion" size="small" :disabled="library.uploadingKind !== null" data-testid="illustration-remove" @click="removeChapterFile('illustration')">移除</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="chapter-audio-state">{{ fileState(attachmentLive, 'audio') }}</p>
<p v-if="audioError" role="alert" class="field-error">{{ audioError }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' && library.uploadingId === attachmentLive.id }">
{{ attachmentLive.audioVersion ? '替换音频' : '上传音频' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="chapter-audio-input" :disabled="library.uploadingKind !== null" @change="onChapterAudioPicked" />
</label>
<ElButton v-if="attachmentLive.audioVersion" size="small" :disabled="library.uploadingKind !== null" data-testid="chapter-audio-remove" @click="removeChapterFile('audio')">移除</ElButton>
</div>
<p class="subtle">MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章位置。</p>
</div>
</div>
</template>
<template #footer>
<ElButton data-testid="close-attachment-dialog" @click="attachmentDialog = false">关闭</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" />
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="10" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p class="subtle">保存后会重新处理这一章;只改标题不会重新处理。</p>
<h3 class="attachment-heading">本章插图</h3>
<template v-if="attachmentLive">
<div class="attachment-row">
<div class="attachment-preview">
<img v-if="library.illustrationUrls[attachmentLive.id]" :src="library.illustrationUrls[attachmentLive.id]" alt="当前插图" data-testid="illustration-preview" />
<span v-else class="book-cover-default" aria-hidden="true">图</span>
</div>
<div class="attachment-info">
<p class="subtle" data-testid="illustration-state">{{ fileState(attachmentLive, 'illustration') }}</p>
<p v-if="illustrationError" role="alert" class="field-error">{{ illustrationError }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'illustration' && library.uploadingId === attachmentLive.id }">
{{ attachmentLive.illustrationVersion ? '替换插图' : '上传插图' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="illustration-input" :disabled="library.uploadingKind !== null" @change="onIllustrationPicked" />
</label>
<ElButton v-if="attachmentLive.illustrationVersion" size="small" :disabled="library.uploadingKind !== null" data-testid="illustration-remove" @click="removeChapterFile('illustration')">移除</ElButton>
</div>
<p class="subtle">JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096;在阅读页显示为缩略图,点击可看原图。</p>
</div>
</div>
<h3 class="attachment-heading">本章音频</h3>
<div class="attachment-row">
<div class="attachment-info">
<p class="subtle" data-testid="chapter-audio-state">{{ fileState(attachmentLive, 'audio') }}</p>
<p v-if="audioError" role="alert" class="field-error">{{ audioError }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' && library.uploadingId === attachmentLive.id }">
{{ attachmentLive.audioVersion ? '替换音频' : '上传音频' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="chapter-audio-input" :disabled="library.uploadingKind !== null" @change="onChapterAudioPicked" />
</label>
<ElButton v-if="attachmentLive.audioVersion" size="small" :disabled="library.uploadingKind !== null" data-testid="chapter-audio-remove" @click="removeChapterFile('audio')">移除</ElButton>
</div>
<p class="subtle">MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章播放位置。</p>
</div>
</div>
<p class="subtle" data-testid="attachment-timing">标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传,取消不会撤销已上传的文件。</p>
</template>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>