feat: add book list entry page

This commit is contained in:
2026-06-15 20:17:30 -06:00
parent efb46d9df0
commit aa91632f8e
3 changed files with 310 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyBook } from '../domain/teachingDesign'
import * as booksApi from '../services/booksApi'
import BookListPage from './BookListPage.vue'
vi.mock('../services/booksApi')
describe('BookListPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the list of books', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: 'Web 前端开发', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 3 },
])
const wrapper = mount(BookListPage)
await flushPromises()
expect(wrapper.text()).toContain('Web 前端开发')
expect(wrapper.text()).toContain('3 课')
})
it('shows an empty state when there are no books', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([])
const wrapper = mount(BookListPage)
await flushPromises()
expect(wrapper.text()).toContain('还没有整本')
})
it('shows an error and allows retry when loading fails', async () => {
vi.mocked(booksApi.listBooks).mockRejectedValueOnce(new Error('网络错误。'))
vi.mocked(booksApi.listBooks).mockResolvedValueOnce([])
const wrapper = mount(BookListPage)
await flushPromises()
expect(wrapper.text()).toContain('网络错误。')
await wrapper.get('button[data-testid="retry"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('还没有整本')
})
it('creates a book and emits open with the new id', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([])
vi.mocked(booksApi.createBook).mockResolvedValue({
id: 'new-id',
name: '新整本',
updatedAt: '2026-01-01T00:00:00.000Z',
data: createEmptyBook(),
})
const wrapper = mount(BookListPage)
await flushPromises()
await wrapper.get('input[aria-label="新整本名称"]').setValue('新整本')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(booksApi.createBook).toHaveBeenCalledWith('新整本')
expect(wrapper.emitted('open')).toEqual([['new-id']])
})
it('renames a book', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '旧名称', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
vi.mocked(booksApi.renameBook).mockResolvedValue({ id: 'b1', name: '新名称', updatedAt: '2026-01-02T00:00:00.000Z' })
const wrapper = mount(BookListPage)
await flushPromises()
await wrapper.get('button[data-testid="rename-b1"]').trigger('click')
await wrapper.get('input[aria-label="整本名称"]').setValue('新名称')
await wrapper.get('button[data-testid="confirm-rename-b1"]').trigger('click')
await flushPromises()
expect(booksApi.renameBook).toHaveBeenCalledWith('b1', '新名称')
expect(wrapper.text()).toContain('新名称')
})
it('deletes a book after confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: 'Web 前端开发', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
vi.mocked(booksApi.deleteBook).mockResolvedValue({ ok: true })
const wrapper = mount(BookListPage)
await flushPromises()
await wrapper.get('button[data-testid="delete-b1"]').trigger('click')
await flushPromises()
expect(booksApi.deleteBook).toHaveBeenCalledWith('b1')
expect(wrapper.text()).toContain('还没有整本')
})
it('does not delete a book when confirmation is declined', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: 'Web 前端开发', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
const wrapper = mount(BookListPage)
await flushPromises()
await wrapper.get('button[data-testid="delete-b1"]').trigger('click')
await flushPromises()
expect(booksApi.deleteBook).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Web 前端开发')
})
})

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import * as booksApi from '../services/booksApi'
import type { BookSummary } from '../services/booksApi'
type LoadStatus = 'loading' | 'loaded' | 'error'
const emit = defineEmits<{ open: [id: string] }>()
const books = ref<BookSummary[]>([])
const loadStatus = ref<LoadStatus>('loading')
const loadError = ref<string | null>(null)
const newBookName = ref('')
const actionError = ref<string | null>(null)
const renamingId = ref<string | null>(null)
const renameValue = ref('')
async function loadBooks(): Promise<void> {
loadStatus.value = 'loading'
try {
books.value = await booksApi.listBooks()
loadStatus.value = 'loaded'
} catch (error) {
loadStatus.value = 'error'
loadError.value = error instanceof Error ? error.message : '加载失败。'
}
}
onMounted(loadBooks)
async function createBook(): Promise<void> {
const name = newBookName.value.trim()
if (!name) return
try {
const created = await booksApi.createBook(name)
newBookName.value = ''
emit('open', created.id)
} catch (error) {
actionError.value = error instanceof Error ? error.message : '创建失败。'
}
}
function startRename(book: BookSummary): void {
renamingId.value = book.id
renameValue.value = book.name
}
function cancelRename(): void {
renamingId.value = null
}
async function confirmRename(): Promise<void> {
const id = renamingId.value
const name = renameValue.value.trim()
if (!id || !name) return
try {
const updated = await booksApi.renameBook(id, name)
const target = books.value.find((book) => book.id === id)
if (target) target.name = updated.name
renamingId.value = null
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重命名失败。'
}
}
async function removeBook(book: BookSummary): Promise<void> {
if (!window.confirm(`确定要删除「${book.name}」吗?此操作无法撤销。`)) return
try {
await booksApi.deleteBook(book.id)
books.value = books.value.filter((entry) => entry.id !== book.id)
} catch (error) {
actionError.value = error instanceof Error ? error.message : '删除失败。'
}
}
</script>
<template>
<div class="book-list-page">
<h1>教学设计整本</h1>
<form class="book-list-create" @submit.prevent="createBook">
<input v-model="newBookName" type="text" placeholder="新整本名称" aria-label="新整本名称" />
<button type="submit">新建整本</button>
</form>
<p v-if="actionError" class="app-notice app-notice--error" role="alert">
{{ actionError }}
<button type="button" @click="actionError = null">关闭</button>
</p>
<p v-if="loadStatus === 'loading'">加载中</p>
<div v-else-if="loadStatus === 'error'" class="app-notice app-notice--error" role="alert">
<span>{{ loadError }}</span>
<button type="button" data-testid="retry" @click="loadBooks">重试</button>
</div>
<template v-else>
<p v-if="books.length === 0">还没有整本创建一个开始使用</p>
<ul v-else class="book-list">
<li v-for="book in books" :key="book.id" class="book-list-item">
<template v-if="renamingId === book.id">
<input v-model="renameValue" type="text" aria-label="整本名称" />
<button type="button" :data-testid="`confirm-rename-${book.id}`" @click="confirmRename">保存</button>
<button type="button" @click="cancelRename">取消</button>
</template>
<template v-else>
<span class="book-list-name">{{ book.name }}</span>
<span class="book-list-meta">更新于 {{ book.updatedAt }} · {{ book.lessonCount }} </span>
<button type="button" :data-testid="`open-${book.id}`" @click="emit('open', book.id)">打开</button>
<button type="button" :data-testid="`rename-${book.id}`" @click="startRename(book)">重命名</button>
<button type="button" :data-testid="`delete-${book.id}`" @click="removeBook(book)">删除</button>
</template>
</li>
</ul>
</template>
</div>
</template>

View File

@@ -590,3 +590,65 @@ table {
min-height: auto;
}
}
/* Book list */
.book-list-page {
max-width: 720px;
margin: 0 auto;
padding: 32px 16px;
}
.book-list-create {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.book-list-create input,
.book-list-item input {
flex: 1 1 auto;
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
}
.book-list-create button,
.book-list-item button {
border: 1px solid var(--line);
background: #fff;
border-radius: 6px;
padding: 6px 14px;
color: var(--green-700);
cursor: pointer;
white-space: nowrap;
}
.book-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.book-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
}
.book-list-name {
font-weight: 600;
flex: 0 0 auto;
}
.book-list-meta {
flex: 1 1 auto;
color: var(--muted);
font-size: 14px;
}