新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.md 的 ZIP(文件名=目录标题),前端用 JSZip 解压匹配,AI 按章节
体量划分课时,预览/编辑后批量生成,每篇以对应章节正文作为上下文。
服务端
- generate.ts: 新增 POST /lessons-from-book(目录条目→AI 划分课时
JSON,剥围栏/校验/丢非法行);扩展 POST / 支持可选 content 上下文,
无 content 时请求体不变(向后兼容)。
前端
- bookImport.ts: normalizeTitle / parseZip / matchToc / assembleContent。
- booksApi.ts: generateLesson 兼容签名 + divideLessonsFromBook。
- useTeachingBook.ts: 抽出 runGenerationPool,新增 generateLessonsFromBook。
- BookImportDialog.vue 多阶段对话框;UploadDropzone 加 accept/multiple;
菜单串接「从书籍生成」。
顺带修复 5 个遗留失败测试:import 功能已于 fb0b8d1 删除但测试漏删,
清理孤儿测试;批量生成对齐为单参 generateLesson(topic)。
docs/book-import-spec.md 记录规格。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
5.4 KiB
TypeScript
132 lines
5.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createEmptyBook } from '../../shared/domain/teachingDesign'
|
||
import * as booksApi from './booksApi'
|
||
|
||
describe('booksApi', () => {
|
||
beforeEach(() => {
|
||
vi.stubGlobal('fetch', vi.fn())
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
it('lists books', async () => {
|
||
const summaries = [{ id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 2 }]
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(summaries), { status: 200 }))
|
||
|
||
await expect(booksApi.listBooks()).resolves.toEqual(summaries)
|
||
expect(fetch).toHaveBeenCalledWith('/api/books', expect.objectContaining({ headers: expect.any(Object) }))
|
||
})
|
||
|
||
it('creates a book', async () => {
|
||
const created = { id: 'b1', name: '新整本', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(created), { status: 200 }))
|
||
|
||
await expect(booksApi.createBook('新整本')).resolves.toEqual(created)
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(init?.method).toBe('POST')
|
||
expect(JSON.parse(init?.body as string)).toEqual({ name: '新整本' })
|
||
})
|
||
|
||
it('gets a book', async () => {
|
||
const record = { id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(record), { status: 200 }))
|
||
|
||
await expect(booksApi.getBook('b1')).resolves.toEqual(record)
|
||
expect(fetch).toHaveBeenCalledWith('/api/books/b1', expect.objectContaining({ headers: expect.any(Object) }))
|
||
})
|
||
|
||
it('updates a book', async () => {
|
||
const meta = { id: 'b1', name: 'Web', updatedAt: '2026-01-02T00:00:00.000Z' }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 }))
|
||
|
||
const data = createEmptyBook()
|
||
await expect(booksApi.updateBook('b1', data)).resolves.toEqual(meta)
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(init?.method).toBe('PUT')
|
||
expect(JSON.parse(init?.body as string)).toEqual({ data })
|
||
})
|
||
|
||
it('renames a book', async () => {
|
||
const meta = { id: 'b1', name: '新名称', updatedAt: '2026-01-01T00:00:00.000Z' }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 }))
|
||
|
||
await expect(booksApi.renameBook('b1', '新名称')).resolves.toEqual(meta)
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(init?.method).toBe('PATCH')
|
||
expect(JSON.parse(init?.body as string)).toEqual({ name: '新名称' })
|
||
})
|
||
|
||
it('deletes a book', async () => {
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
|
||
|
||
await expect(booksApi.deleteBook('b1')).resolves.toEqual({ ok: true })
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(init?.method).toBe('DELETE')
|
||
})
|
||
|
||
it('generates a lesson', async () => {
|
||
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||
|
||
await expect(booksApi.generateLesson('CSS 弹性布局')).resolves.toEqual(result)
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' })
|
||
})
|
||
|
||
it('generates a lesson with chapter content as context', async () => {
|
||
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||
|
||
await booksApi.generateLesson('CSS 弹性布局', { content: '弹性盒模型正文' })
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(JSON.parse(init?.body as string)).toEqual({
|
||
topic: 'CSS 弹性布局',
|
||
content: '弹性盒模型正文',
|
||
})
|
||
})
|
||
|
||
it('still accepts an AbortSignal as the second argument', async () => {
|
||
const result = { filename: 'x.md', markdown: '# x' }
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||
|
||
const controller = new AbortController()
|
||
await booksApi.generateLesson('x', controller.signal)
|
||
|
||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'x' })
|
||
expect(init?.signal).toBe(controller.signal)
|
||
})
|
||
|
||
it('divides lessons from a book table of contents', async () => {
|
||
const lessons = [{ title: 'C# 入门——搭建环境', sourceIndexes: [0] }]
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ lessons }), { status: 200 }))
|
||
|
||
const entries = [{ index: 0, title: '第1章 C# 入门', charCount: 5000 }]
|
||
await expect(booksApi.divideLessonsFromBook(entries)).resolves.toEqual({ lessons })
|
||
|
||
const [url, init] = vi.mocked(fetch).mock.calls[0]!
|
||
expect(url).toBe('/api/generate/lessons-from-book')
|
||
expect(JSON.parse(init?.body as string)).toEqual({ entries })
|
||
})
|
||
|
||
it('throws the server error message on failure', async () => {
|
||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 }))
|
||
|
||
await expect(booksApi.getBook('missing')).rejects.toThrow('整本不存在。')
|
||
})
|
||
|
||
it('throws a generic error when the response has no error message', async () => {
|
||
vi.mocked(fetch).mockResolvedValue(new Response('', { status: 500 }))
|
||
|
||
await expect(booksApi.getBook('b1')).rejects.toThrow('请求失败(500)')
|
||
})
|
||
})
|