feat: add book list entry page
This commit is contained in:
124
src/components/BookListPage.test.ts
Normal file
124
src/components/BookListPage.test.ts
Normal 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 前端开发')
|
||||
})
|
||||
})
|
||||
124
src/components/BookListPage.vue
Normal file
124
src/components/BookListPage.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user