From aa91632f8ecfb6daa91b0fcc7c02dae5250c7dbb Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 15 Jun 2026 20:17:30 -0600 Subject: [PATCH] feat: add book list entry page --- src/components/BookListPage.test.ts | 124 ++++++++++++++++++++++++++++ src/components/BookListPage.vue | 124 ++++++++++++++++++++++++++++ src/style.css | 62 ++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 src/components/BookListPage.test.ts create mode 100644 src/components/BookListPage.vue diff --git a/src/components/BookListPage.test.ts b/src/components/BookListPage.test.ts new file mode 100644 index 0000000..40514c9 --- /dev/null +++ b/src/components/BookListPage.test.ts @@ -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 前端开发') + }) +}) diff --git a/src/components/BookListPage.vue b/src/components/BookListPage.vue new file mode 100644 index 0000000..e71b881 --- /dev/null +++ b/src/components/BookListPage.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/style.css b/src/style.css index 5acb8f3..82c73e7 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +}