2733 lines
83 KiB
Markdown
2733 lines
83 KiB
Markdown
# Bun + SQLite 后端实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 为教学设计生成器新增 Bun + Hono + `bun:sqlite` 后端,支持多本「整本」的增删改查与持久化,并新增「输入主题生成教案」(Deepseek)功能;前端从 `localStorage` 切换为服务器 API,新增整本列表页与生成教案对话框。
|
||
|
||
**Architecture:** `server/` 目录下用 Hono 路由 + `bun:sqlite` 提供 `/api/books*` 与 `/api/generate` REST API,数据以 JSON 形式存入单表 `books`。前端新增 `booksApi.ts` 封装 fetch 调用,`useTeachingBook` 改为按 `bookId` 加载/保存;`App.vue` 拆分为 `BookListPage.vue`(入口列表)与 `WorkspaceView.vue`(原工作区,新增「生成教案」「返回列表」)。
|
||
|
||
**Tech Stack:** Bun 1.3、Hono 4、`bun:sqlite`、Vue 3 + TypeScript + Vite(不变)、Vitest、`bun:test`
|
||
|
||
参考设计文档:[2026-06-15-bun-sqlite-backend-design.md](../specs/2026-06-15-bun-sqlite-backend-design.md)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
新增文件:
|
||
|
||
- `server/db.ts` — SQLite 初始化与整本 CRUD 数据访问函数
|
||
- `server/db.test.ts`
|
||
- `server/routes/books.ts` — `/api/books*` Hono 路由
|
||
- `server/routes/books.test.ts`
|
||
- `server/routes/generate.ts` — `/api/generate` Hono 路由(Deepseek)
|
||
- `server/routes/generate.test.ts`
|
||
- `server/index.ts` — Hono 应用入口(API + 静态文件回退)
|
||
- `src/services/booksApi.ts` — 前端 API 客户端
|
||
- `src/services/booksApi.test.ts`
|
||
- `src/components/GenerateLessonDialog.vue` — 生成教案对话框
|
||
- `src/components/GenerateLessonDialog.test.ts`
|
||
- `src/components/BookListPage.vue` — 整本列表入口页
|
||
- `src/components/BookListPage.test.ts`
|
||
- `src/components/WorkspaceToolbar.test.ts`
|
||
- `src/components/WorkspaceView.vue` — 原工作区(从 `App.vue` 拆出)
|
||
- `src/components/WorkspaceView.test.ts`
|
||
|
||
修改文件:
|
||
|
||
- `package.json` — 新增 `hono` 依赖与 `server`/`server:dev`/`test:server` 脚本
|
||
- `.gitignore` — 忽略 `data/teaching-books.db` 与 `.env`
|
||
- `vite.config.ts` — 新增 `/api` 开发代理
|
||
- `src/composables/useTeachingBook.ts` — 改为按 `bookId` 加载/保存,新增 `generateLesson`
|
||
- `src/composables/useTeachingBook.test.ts` — 改为 mock `booksApi`
|
||
- `src/components/WorkspaceToolbar.vue` — 新增「生成教案」「返回列表」按钮
|
||
- `src/App.vue` — 在 `BookListPage` 与 `WorkspaceView` 间切换
|
||
- `src/App.test.ts` — 改为校验整本列表入口
|
||
- `src/style.css` — 新增整本列表页样式
|
||
|
||
删除文件:
|
||
|
||
- `src/services/bookStorage.ts`
|
||
- `src/services/bookStorage.test.ts`
|
||
- `src/components/RestoreDraftDialog.vue`
|
||
|
||
---
|
||
|
||
## Task 1: 后端基础设施(依赖、脚本、代理)
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
- Modify: `.gitignore`
|
||
- Modify: `vite.config.ts`
|
||
|
||
- [ ] **Step 1: 添加 Hono 依赖**
|
||
|
||
```bash
|
||
bun add hono
|
||
```
|
||
|
||
Expected: `package.json` 的 `dependencies` 中新增一行 `"hono": "^4.x.x"`(版本号以实际安装结果为准),`bun.lock`/`package-lock.json` 更新。
|
||
|
||
- [ ] **Step 2: 新增后端脚本**
|
||
|
||
在 `package.json` 的 `"scripts"` 中添加(保持已有脚本不变,仅新增以下三行):
|
||
|
||
```json
|
||
"server": "bun run server/index.ts",
|
||
"server:dev": "bun --watch run server/index.ts",
|
||
"test:server": "bun test server"
|
||
```
|
||
|
||
- [ ] **Step 3: 更新 `.gitignore`**
|
||
|
||
在文件末尾追加:
|
||
|
||
```gitignore
|
||
# Backend data and secrets
|
||
data/teaching-books.db
|
||
.env
|
||
```
|
||
|
||
- [ ] **Step 4: 新增 Vite 开发代理**
|
||
|
||
编辑 `vite.config.ts`,在 `defineConfig` 中新增 `server.proxy`:
|
||
|
||
```ts
|
||
/// <reference types="vitest/config" />
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
server: {
|
||
proxy: {
|
||
'/api': 'http://localhost:3001',
|
||
},
|
||
},
|
||
test: {
|
||
environment: 'jsdom',
|
||
setupFiles: ['./src/test/setup.ts'],
|
||
css: true,
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json bun.lock .gitignore vite.config.ts
|
||
git commit -m "chore: add Hono dependency, server scripts, and dev proxy"
|
||
```
|
||
|
||
(若 `bun add` 未生成 `bun.lock`,则省略该文件;只提交实际变更的锁文件。)
|
||
|
||
---
|
||
|
||
## Task 2: 数据库访问层 `server/db.ts`
|
||
|
||
**Files:**
|
||
- Create: `server/db.ts`
|
||
- Test: `server/db.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `server/db.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
||
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
||
import { createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData } from './db'
|
||
|
||
afterEach(() => {
|
||
setSystemTime()
|
||
})
|
||
|
||
describe('db', () => {
|
||
it('creates a book with empty data', () => {
|
||
const db = openDb(':memory:')
|
||
const created = createBook(db, '示例整本')
|
||
|
||
expect(created.name).toBe('示例整本')
|
||
expect(created.data.designs).toEqual([])
|
||
expect(created.data.schemaVersion).toBe(1)
|
||
})
|
||
|
||
it('retrieves a created book by id', () => {
|
||
const db = openDb(':memory:')
|
||
const created = createBook(db, '示例整本')
|
||
|
||
expect(getBook(db, created.id)).toEqual(created)
|
||
})
|
||
|
||
it('returns null for a missing book', () => {
|
||
const db = openDb(':memory:')
|
||
expect(getBook(db, 'missing')).toBeNull()
|
||
})
|
||
|
||
it('lists books ordered by most recently updated, with lesson counts', () => {
|
||
const db = openDb(':memory:')
|
||
setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
|
||
const first = createBook(db, '第一本')
|
||
setSystemTime(new Date('2026-01-02T00:00:00.000Z'))
|
||
const second = createBook(db, '第二本')
|
||
|
||
const data = createEmptyBook()
|
||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||
setSystemTime(new Date('2026-01-03T00:00:00.000Z'))
|
||
saveBookData(db, first.id, data)
|
||
|
||
const books = listBooks(db)
|
||
|
||
expect(books.map((book) => book.id)).toEqual([first.id, second.id])
|
||
expect(books[0]?.lessonCount).toBe(1)
|
||
expect(books[1]?.lessonCount).toBe(0)
|
||
})
|
||
|
||
it('saves book data and updates updated_at', () => {
|
||
const db = openDb(':memory:')
|
||
const created = createBook(db, '示例整本')
|
||
const data = createEmptyBook()
|
||
data.cover.courseName = 'Web 前端开发'
|
||
|
||
setSystemTime(new Date('2026-02-01T00:00:00.000Z'))
|
||
const result = saveBookData(db, created.id, data)
|
||
|
||
expect(result).toEqual({ id: created.id, name: '示例整本', updatedAt: '2026-02-01T00:00:00.000Z' })
|
||
expect(getBook(db, created.id)?.data.cover.courseName).toBe('Web 前端开发')
|
||
})
|
||
|
||
it('returns null when saving data for a missing book', () => {
|
||
const db = openDb(':memory:')
|
||
expect(saveBookData(db, 'missing', createEmptyBook())).toBeNull()
|
||
})
|
||
|
||
it('renames a book without changing updated_at', () => {
|
||
const db = openDb(':memory:')
|
||
const created = createBook(db, '旧名称')
|
||
|
||
const result = renameBook(db, created.id, '新名称')
|
||
|
||
expect(result).toEqual({ id: created.id, name: '新名称', updatedAt: created.updatedAt })
|
||
expect(getBook(db, created.id)?.name).toBe('新名称')
|
||
})
|
||
|
||
it('returns null when renaming a missing book', () => {
|
||
const db = openDb(':memory:')
|
||
expect(renameBook(db, 'missing', '新名称')).toBeNull()
|
||
})
|
||
|
||
it('deletes a book', () => {
|
||
const db = openDb(':memory:')
|
||
const created = createBook(db, '示例整本')
|
||
|
||
expect(deleteBook(db, created.id)).toBe(true)
|
||
expect(getBook(db, created.id)).toBeNull()
|
||
})
|
||
|
||
it('returns false when deleting a missing book', () => {
|
||
const db = openDb(':memory:')
|
||
expect(deleteBook(db, 'missing')).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `bun test server/db.test.ts`
|
||
Expected: FAIL — `Cannot find module './db'`(`server/db.ts` 尚不存在)
|
||
|
||
- [ ] **Step 3: 实现 `server/db.ts`**
|
||
|
||
```ts
|
||
import { Database } from 'bun:sqlite'
|
||
import { createEmptyBook, type TeachingBook } from '../src/domain/teachingDesign'
|
||
|
||
export interface BookSummary {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
lessonCount: number
|
||
}
|
||
|
||
export interface BookRecord {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
data: TeachingBook
|
||
}
|
||
|
||
export interface BookMeta {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
}
|
||
|
||
interface BookRow {
|
||
id: string
|
||
name: string
|
||
data: string
|
||
updated_at: string
|
||
}
|
||
|
||
const SCHEMA = `
|
||
CREATE TABLE IF NOT EXISTS books (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
data TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)
|
||
`
|
||
|
||
export function openDb(path: string): Database {
|
||
const db = new Database(path)
|
||
db.run(SCHEMA)
|
||
return db
|
||
}
|
||
|
||
export function listBooks(db: Database): BookSummary[] {
|
||
const rows = db
|
||
.query<BookRow, []>('SELECT id, name, data, updated_at FROM books ORDER BY updated_at DESC')
|
||
.all()
|
||
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
updatedAt: row.updated_at,
|
||
lessonCount: (JSON.parse(row.data) as TeachingBook).designs.length,
|
||
}))
|
||
}
|
||
|
||
export function createBook(db: Database, name: string): BookRecord {
|
||
const id = crypto.randomUUID()
|
||
const now = new Date().toISOString()
|
||
const data = createEmptyBook()
|
||
data.updatedAt = now
|
||
|
||
db.run('INSERT INTO books (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [
|
||
id,
|
||
name,
|
||
JSON.stringify(data),
|
||
now,
|
||
now,
|
||
])
|
||
|
||
return { id, name, updatedAt: now, data }
|
||
}
|
||
|
||
export function getBook(db: Database, id: string): BookRecord | null {
|
||
const row = db
|
||
.query<BookRow, [string]>('SELECT id, name, data, updated_at FROM books WHERE id = ?')
|
||
.get(id)
|
||
if (!row) return null
|
||
|
||
return {
|
||
id: row.id,
|
||
name: row.name,
|
||
updatedAt: row.updated_at,
|
||
data: JSON.parse(row.data) as TeachingBook,
|
||
}
|
||
}
|
||
|
||
export function saveBookData(db: Database, id: string, data: TeachingBook): BookMeta | null {
|
||
const existing = db
|
||
.query<{ name: string }, [string]>('SELECT name FROM books WHERE id = ?')
|
||
.get(id)
|
||
if (!existing) return null
|
||
|
||
const now = new Date().toISOString()
|
||
db.run('UPDATE books SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(data), now, id])
|
||
|
||
return { id, name: existing.name, updatedAt: now }
|
||
}
|
||
|
||
export function renameBook(db: Database, id: string, name: string): BookMeta | null {
|
||
const existing = db
|
||
.query<{ updated_at: string }, [string]>('SELECT updated_at FROM books WHERE id = ?')
|
||
.get(id)
|
||
if (!existing) return null
|
||
|
||
db.run('UPDATE books SET name = ? WHERE id = ?', [name, id])
|
||
|
||
return { id, name, updatedAt: existing.updated_at }
|
||
}
|
||
|
||
export function deleteBook(db: Database, id: string): boolean {
|
||
const result = db.run('DELETE FROM books WHERE id = ?', [id])
|
||
return result.changes > 0
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `bun test server/db.test.ts`
|
||
Expected: PASS — 10 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add server/db.ts server/db.test.ts
|
||
git commit -m "feat: add SQLite-backed book data access layer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 整本 CRUD 路由 `server/routes/books.ts`
|
||
|
||
**Files:**
|
||
- Create: `server/routes/books.ts`
|
||
- Test: `server/routes/books.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `server/routes/books.test.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it } from 'bun:test'
|
||
import type { Database } from 'bun:sqlite'
|
||
import { Hono } from 'hono'
|
||
import { createEmptyBook } from '../../src/domain/teachingDesign'
|
||
import { openDb } from '../db'
|
||
import { createBooksRouter } from './books'
|
||
|
||
describe('books routes', () => {
|
||
let app: Hono
|
||
let db: Database
|
||
|
||
beforeEach(() => {
|
||
db = openDb(':memory:')
|
||
app = new Hono().route('/api/books', createBooksRouter(db))
|
||
})
|
||
|
||
async function createViaApi(name: string): Promise<{ id: string }> {
|
||
const res = await app.request('/api/books', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
})
|
||
return (await res.json()) as { id: string }
|
||
}
|
||
|
||
it('lists no books initially', async () => {
|
||
const res = await app.request('/api/books')
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual([])
|
||
})
|
||
|
||
it('creates a book', async () => {
|
||
const res = await app.request('/api/books', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: '示例整本' }),
|
||
})
|
||
expect(res.status).toBe(200)
|
||
|
||
const body = (await res.json()) as { name: string; data: { designs: unknown[] } }
|
||
expect(body.name).toBe('示例整本')
|
||
expect(body.data.designs).toEqual([])
|
||
})
|
||
|
||
it('returns 400 when creating without a name', async () => {
|
||
const res = await app.request('/api/books', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
})
|
||
expect(res.status).toBe(400)
|
||
})
|
||
|
||
it('gets a created book', async () => {
|
||
const created = await createViaApi('示例整本')
|
||
|
||
const res = await app.request(`/api/books/${created.id}`)
|
||
expect(res.status).toBe(200)
|
||
expect(((await res.json()) as { id: string }).id).toBe(created.id)
|
||
})
|
||
|
||
it('returns 404 for a missing book', async () => {
|
||
const res = await app.request('/api/books/missing')
|
||
expect(res.status).toBe(404)
|
||
})
|
||
|
||
it('saves book data', async () => {
|
||
const created = await createViaApi('示例整本')
|
||
|
||
const data = createEmptyBook()
|
||
data.cover.courseName = 'Web 前端开发'
|
||
|
||
const res = await app.request(`/api/books/${created.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ data }),
|
||
})
|
||
expect(res.status).toBe(200)
|
||
|
||
const fetched = await app.request(`/api/books/${created.id}`)
|
||
const body = (await fetched.json()) as { data: { cover: { courseName: string } } }
|
||
expect(body.data.cover.courseName).toBe('Web 前端开发')
|
||
})
|
||
|
||
it('returns 404 when saving data for a missing book', async () => {
|
||
const res = await app.request('/api/books/missing', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ data: createEmptyBook() }),
|
||
})
|
||
expect(res.status).toBe(404)
|
||
})
|
||
|
||
it('returns 400 when saving without data', async () => {
|
||
const created = await createViaApi('示例整本')
|
||
|
||
const res = await app.request(`/api/books/${created.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
})
|
||
expect(res.status).toBe(400)
|
||
})
|
||
|
||
it('renames a book', async () => {
|
||
const created = await createViaApi('旧名称')
|
||
|
||
const res = await app.request(`/api/books/${created.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: '新名称' }),
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(((await res.json()) as { name: string }).name).toBe('新名称')
|
||
})
|
||
|
||
it('returns 404 when renaming a missing book', async () => {
|
||
const res = await app.request('/api/books/missing', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: '新名称' }),
|
||
})
|
||
expect(res.status).toBe(404)
|
||
})
|
||
|
||
it('deletes a book', async () => {
|
||
const created = await createViaApi('示例整本')
|
||
|
||
const res = await app.request(`/api/books/${created.id}`, { method: 'DELETE' })
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ ok: true })
|
||
|
||
expect((await app.request(`/api/books/${created.id}`)).status).toBe(404)
|
||
})
|
||
|
||
it('returns 404 when deleting a missing book', async () => {
|
||
const res = await app.request('/api/books/missing', { method: 'DELETE' })
|
||
expect(res.status).toBe(404)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `bun test server/routes/books.test.ts`
|
||
Expected: FAIL — `Cannot find module './books'`(`server/routes/books.ts` 尚不存在)
|
||
|
||
- [ ] **Step 3: 实现 `server/routes/books.ts`**
|
||
|
||
```ts
|
||
import type { Database } from 'bun:sqlite'
|
||
import { Hono } from 'hono'
|
||
import type { TeachingBook } from '../../src/domain/teachingDesign'
|
||
import { createBook, deleteBook, getBook, listBooks, renameBook, saveBookData } from '../db'
|
||
|
||
export function createBooksRouter(db: Database): Hono {
|
||
const app = new Hono()
|
||
|
||
app.get('/', (c) => {
|
||
return c.json(listBooks(db))
|
||
})
|
||
|
||
app.post('/', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { name?: unknown } | null
|
||
const name = body?.name
|
||
|
||
if (typeof name !== 'string' || name.trim() === '') {
|
||
return c.json({ error: '请提供整本名称。' }, 400)
|
||
}
|
||
|
||
return c.json(createBook(db, name.trim()))
|
||
})
|
||
|
||
app.get('/:id', (c) => {
|
||
const book = getBook(db, c.req.param('id'))
|
||
if (!book) return c.json({ error: '整本不存在。' }, 404)
|
||
return c.json(book)
|
||
})
|
||
|
||
app.put('/:id', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { data?: TeachingBook } | null
|
||
if (!body?.data) {
|
||
return c.json({ error: '请提供整本数据。' }, 400)
|
||
}
|
||
|
||
const result = saveBookData(db, c.req.param('id'), body.data)
|
||
if (!result) return c.json({ error: '整本不存在。' }, 404)
|
||
return c.json(result)
|
||
})
|
||
|
||
app.patch('/:id', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { name?: unknown } | null
|
||
const name = body?.name
|
||
|
||
if (typeof name !== 'string' || name.trim() === '') {
|
||
return c.json({ error: '请提供整本名称。' }, 400)
|
||
}
|
||
|
||
const result = renameBook(db, c.req.param('id'), name.trim())
|
||
if (!result) return c.json({ error: '整本不存在。' }, 404)
|
||
return c.json(result)
|
||
})
|
||
|
||
app.delete('/:id', (c) => {
|
||
if (!deleteBook(db, c.req.param('id'))) {
|
||
return c.json({ error: '整本不存在。' }, 404)
|
||
}
|
||
return c.json({ ok: true })
|
||
})
|
||
|
||
return app
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `bun test server/routes/books.test.ts`
|
||
Expected: PASS — 12 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add server/routes/books.ts server/routes/books.test.ts
|
||
git commit -m "feat: add CRUD routes for teaching design books"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: AI 生成路由 `server/routes/generate.ts`
|
||
|
||
**Files:**
|
||
- Create: `server/routes/generate.ts`
|
||
- Test: `server/routes/generate.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `server/routes/generate.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||
import { Hono } from 'hono'
|
||
import { createGenerateRouter } from './generate'
|
||
|
||
describe('generate route', () => {
|
||
let originalFetch: typeof fetch
|
||
|
||
beforeEach(() => {
|
||
originalFetch = globalThis.fetch
|
||
})
|
||
|
||
afterEach(() => {
|
||
globalThis.fetch = originalFetch
|
||
})
|
||
|
||
it('returns 400 when topic is missing', async () => {
|
||
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
})
|
||
|
||
expect(res.status).toBe(400)
|
||
})
|
||
|
||
it('returns 500 when DEEPSEEK_API_KEY is not configured', async () => {
|
||
const app = new Hono().route('/api/generate', createGenerateRouter(undefined))
|
||
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||
})
|
||
|
||
expect(res.status).toBe(500)
|
||
})
|
||
|
||
it('returns parsed markdown on success', async () => {
|
||
globalThis.fetch = mock(async () =>
|
||
new Response(
|
||
JSON.stringify({ choices: [{ message: { content: '# CSS 弹性布局 教学设计' } }] }),
|
||
{ status: 200 },
|
||
),
|
||
) as unknown as typeof fetch
|
||
|
||
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||
})
|
||
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { filename: string; markdown: string }
|
||
expect(body.filename).toBe('CSS 弹性布局.md')
|
||
expect(body.markdown).toContain('# CSS 弹性布局 教学设计')
|
||
})
|
||
|
||
it('returns 502 when Deepseek responds with an error status', async () => {
|
||
globalThis.fetch = mock(async () => new Response('', { status: 401 })) as unknown as typeof fetch
|
||
|
||
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||
})
|
||
|
||
expect(res.status).toBe(502)
|
||
})
|
||
|
||
it('returns 502 when fetch throws', async () => {
|
||
globalThis.fetch = mock(async () => {
|
||
throw new Error('network error')
|
||
}) as unknown as typeof fetch
|
||
|
||
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||
})
|
||
|
||
expect(res.status).toBe(502)
|
||
})
|
||
|
||
it('returns 502 when Deepseek response has no content', async () => {
|
||
globalThis.fetch = mock(async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })) as unknown as typeof fetch
|
||
|
||
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
|
||
const res = await app.request('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
|
||
})
|
||
|
||
expect(res.status).toBe(502)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `bun test server/routes/generate.test.ts`
|
||
Expected: FAIL — `Cannot find module './generate'`(`server/routes/generate.ts` 尚不存在)
|
||
|
||
- [ ] **Step 3: 实现 `server/routes/generate.ts`**
|
||
|
||
```ts
|
||
import { Hono } from 'hono'
|
||
|
||
const SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。
|
||
请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明:
|
||
|
||
1. 第一行是一级标题:\`# <课程标题> 教学设计\`
|
||
2. 紧接着是一个两列表格(表头使用 \`|:---|:---|\`),依次包含以下行:
|
||
- \`| **课题** | **<课题名称>** |\`
|
||
- \`| **课时** | <课时说明,例如 1课时(40分钟)> |\`
|
||
- \`| **教学目标** | **知识目标**:...<br>**技能目标**:...<br>**素养目标**:... |\`
|
||
- \`| **教学重难点** | **重点**:...<br>**难点**:... |\`
|
||
- \`| **教学资源准备** | ... |\`
|
||
3. 二级标题 \`## 教学过程\`,后接一个 5 列表格,表头固定为:
|
||
\`| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |\`,分隔行 \`|:---|:---|:---|:---|:---|\`,
|
||
包含 4-6 个教学环节行,每个环节名称写作 \`**N. 环节名称**<br>(时长)\` 的格式。
|
||
4. 二级标题 \`## 板书设计\`,内容放在 \`\`\`text 代码块中。
|
||
5. 二级标题 \`## 教学成效与反思\`,后接一个两列表格:
|
||
- \`| **教学成效** | ... |\`
|
||
- \`| **教学反思** | ... |\`
|
||
`
|
||
|
||
function sanitizeFilename(topic: string): string {
|
||
const sanitized = topic.trim().replace(/[\\/:*?"<>|]/g, '_')
|
||
return sanitized || 'lesson'
|
||
}
|
||
|
||
export function createGenerateRouter(apiKey: string | undefined): Hono {
|
||
const app = new Hono()
|
||
|
||
app.post('/', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { topic?: unknown } | null
|
||
const topic = body?.topic
|
||
|
||
if (typeof topic !== 'string' || topic.trim() === '') {
|
||
return c.json({ error: '请提供教案主题。' }, 400)
|
||
}
|
||
|
||
if (!apiKey) {
|
||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||
}
|
||
|
||
let response: Response
|
||
try {
|
||
response = await fetch('https://api.deepseek.com/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'deepseek-chat',
|
||
messages: [
|
||
{ role: 'system', content: SYSTEM_PROMPT },
|
||
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },
|
||
],
|
||
}),
|
||
})
|
||
} catch {
|
||
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
|
||
}
|
||
|
||
const payload = (await response.json().catch(() => null)) as
|
||
| { choices?: Array<{ message?: { content?: string } }> }
|
||
| null
|
||
const markdown = payload?.choices?.[0]?.message?.content
|
||
|
||
if (!markdown) {
|
||
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
|
||
}
|
||
|
||
return c.json({ filename: `${sanitizeFilename(topic)}.md`, markdown })
|
||
})
|
||
|
||
return app
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `bun test server/routes/generate.test.ts`
|
||
Expected: PASS — 6 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add server/routes/generate.ts server/routes/generate.test.ts
|
||
git commit -m "feat: add Deepseek-backed lesson generation route"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 服务器入口 `server/index.ts`
|
||
|
||
**Files:**
|
||
- Create: `server/index.ts`
|
||
|
||
- [ ] **Step 1: 实现 `server/index.ts`**
|
||
|
||
```ts
|
||
import { Hono } from 'hono'
|
||
import { serveStatic } from 'hono/bun'
|
||
import { openDb } from './db'
|
||
import { createBooksRouter } from './routes/books'
|
||
import { createGenerateRouter } from './routes/generate'
|
||
|
||
const db = openDb(process.env.TEACHING_BOOKS_DB ?? 'data/teaching-books.db')
|
||
|
||
const app = new Hono()
|
||
|
||
app.route('/api/books', createBooksRouter(db))
|
||
app.route('/api/generate', createGenerateRouter(process.env.DEEPSEEK_API_KEY))
|
||
|
||
app.use('/*', serveStatic({ root: './dist' }))
|
||
app.get('*', serveStatic({ path: './dist/index.html' }))
|
||
|
||
export default {
|
||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||
fetch: app.fetch,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 手动验证服务器可启动**
|
||
|
||
Run: `bun run server/index.ts`
|
||
Expected: 进程启动且无报错(终端无输出即表示监听成功;可按 Ctrl+C 退出)。再运行:
|
||
|
||
```bash
|
||
curl -s http://localhost:3001/api/books
|
||
```
|
||
|
||
Expected: 输出 `[]`(数据库首次创建,`data/teaching-books.db` 文件已生成)。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add server/index.ts
|
||
git commit -m "feat: add Hono server entry point with static fallback"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 前端 API 客户端 `src/services/booksApi.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/services/booksApi.ts`
|
||
- Test: `src/services/booksApi.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `src/services/booksApi.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createEmptyBook } from '../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('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)')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/services/booksApi.test.ts`
|
||
Expected: FAIL — `Failed to resolve import "./booksApi"`(`src/services/booksApi.ts` 尚不存在)
|
||
|
||
- [ ] **Step 3: 实现 `src/services/booksApi.ts`**
|
||
|
||
```ts
|
||
import type { TeachingBook } from '../domain/teachingDesign'
|
||
|
||
export interface BookSummary {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
lessonCount: number
|
||
}
|
||
|
||
export interface BookRecord {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
data: TeachingBook
|
||
}
|
||
|
||
export interface BookMeta {
|
||
id: string
|
||
name: string
|
||
updatedAt: string
|
||
}
|
||
|
||
export interface GenerateResult {
|
||
filename: string
|
||
markdown: string
|
||
}
|
||
|
||
interface ErrorBody {
|
||
error?: string
|
||
}
|
||
|
||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const response = await fetch(path, {
|
||
...init,
|
||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const body = (await response.json().catch(() => null)) as ErrorBody | null
|
||
throw new Error(body?.error ?? `请求失败(${response.status})。`)
|
||
}
|
||
|
||
return response.json() as Promise<T>
|
||
}
|
||
|
||
export function listBooks(): Promise<BookSummary[]> {
|
||
return request('/api/books')
|
||
}
|
||
|
||
export function createBook(name: string): Promise<BookRecord> {
|
||
return request('/api/books', { method: 'POST', body: JSON.stringify({ name }) })
|
||
}
|
||
|
||
export function getBook(id: string): Promise<BookRecord> {
|
||
return request(`/api/books/${id}`)
|
||
}
|
||
|
||
export function updateBook(id: string, data: TeachingBook): Promise<BookMeta> {
|
||
return request(`/api/books/${id}`, { method: 'PUT', body: JSON.stringify({ data }) })
|
||
}
|
||
|
||
export function renameBook(id: string, name: string): Promise<BookMeta> {
|
||
return request(`/api/books/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) })
|
||
}
|
||
|
||
export function deleteBook(id: string): Promise<{ ok: true }> {
|
||
return request(`/api/books/${id}`, { method: 'DELETE' })
|
||
}
|
||
|
||
export function generateLesson(topic: string): Promise<GenerateResult> {
|
||
return request('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/services/booksApi.test.ts`
|
||
Expected: PASS — 9 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/services/booksApi.ts src/services/booksApi.test.ts
|
||
git commit -m "feat: add booksApi client for the books backend"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 重写 `useTeachingBook`(按 `bookId` 加载/保存 + 生成教案)
|
||
|
||
**Files:**
|
||
- Modify: `src/composables/useTeachingBook.ts`
|
||
- Test: `src/composables/useTeachingBook.test.ts`
|
||
|
||
- [ ] **Step 1: 重写测试**
|
||
|
||
替换 `src/composables/useTeachingBook.test.ts` 全部内容为:
|
||
|
||
```ts
|
||
import { flushPromises } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../domain/teachingDesign'
|
||
import * as booksApi from '../services/booksApi'
|
||
import { useTeachingBook } from './useTeachingBook'
|
||
|
||
vi.mock('../services/booksApi')
|
||
|
||
function mockGetBook(data: TeachingBook, id = 'b1'): void {
|
||
vi.mocked(booksApi.getBook).mockResolvedValue({ id, name: '示例整本', updatedAt: data.updatedAt, data })
|
||
}
|
||
|
||
describe('useTeachingBook', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
vi.useFakeTimers()
|
||
})
|
||
|
||
it('loads the book from the API', async () => {
|
||
const data = createEmptyBook()
|
||
data.cover.courseName = 'Web 前端开发'
|
||
mockGetBook(data)
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
expect(booksApi.getBook).toHaveBeenCalledWith('b1')
|
||
expect(store.loadStatus.value).toBe('loaded')
|
||
expect(store.book.value.cover.courseName).toBe('Web 前端开发')
|
||
})
|
||
|
||
it('sets loadStatus to error when loading fails', async () => {
|
||
vi.mocked(booksApi.getBook).mockRejectedValue(new Error('网络错误。'))
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
expect(store.loadStatus.value).toBe('error')
|
||
expect(store.loadError.value).toBe('网络错误。')
|
||
})
|
||
|
||
it('imports files in natural order and selects the first lesson', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
const files = [
|
||
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
|
||
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
|
||
]
|
||
|
||
await store.importFiles(files, 'keep')
|
||
|
||
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual(['2.md', '10.md'])
|
||
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
||
})
|
||
|
||
it('reorders lessons without changing their identities', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
await store.importFiles(
|
||
[new File(['# One 教学设计'], '1.md'), new File(['# Two 教学设计'], '2.md')],
|
||
'keep',
|
||
)
|
||
|
||
const ids = store.book.value.designs.map((design) => design.id)
|
||
store.moveDesign(0, 1)
|
||
|
||
expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse())
|
||
})
|
||
|
||
it('does not autosave immediately after the initial load', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
await vi.advanceTimersByTimeAsync(300)
|
||
|
||
expect(booksApi.updateBook).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('autosaves the book via the API after the debounce delay', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
vi.mocked(booksApi.updateBook).mockResolvedValue({ id: 'b1', name: '示例整本', updatedAt: 'later' })
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
store.updateCover({ courseName: '新课程名' })
|
||
await vi.advanceTimersByTimeAsync(300)
|
||
|
||
expect(booksApi.updateBook).toHaveBeenCalledWith('b1', store.book.value)
|
||
expect(store.saveStatus.value).toBe('saved')
|
||
})
|
||
|
||
it('sets saveStatus to error when autosave fails', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
vi.mocked(booksApi.updateBook).mockRejectedValue(new Error('保存失败。'))
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
store.updateCover({ courseName: '新课程名' })
|
||
await vi.advanceTimersByTimeAsync(300)
|
||
|
||
expect(store.saveStatus.value).toBe('error')
|
||
expect(store.lastError.value).toBe('保存失败。')
|
||
})
|
||
|
||
it('generateLesson appends a parsed design and selects it', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
vi.mocked(booksApi.generateLesson).mockResolvedValue({
|
||
filename: 'css-flex.md',
|
||
markdown: '# CSS 弹性布局 教学设计',
|
||
})
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
const result = await store.generateLesson('CSS 弹性布局')
|
||
|
||
expect(result).toEqual({ ok: true })
|
||
expect(store.book.value.designs).toHaveLength(1)
|
||
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
||
})
|
||
|
||
it('generateLesson returns an error when the API call fails', async () => {
|
||
mockGetBook(createEmptyBook())
|
||
vi.mocked(booksApi.generateLesson).mockRejectedValue(new Error('Deepseek 请求失败。'))
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
const result = await store.generateLesson('CSS 弹性布局')
|
||
|
||
expect(result).toEqual({ ok: false, message: 'Deepseek 请求失败。' })
|
||
expect(store.book.value.designs).toHaveLength(0)
|
||
})
|
||
|
||
it('clearBook empties designs but keeps the cover', async () => {
|
||
const data = createEmptyBook()
|
||
data.cover.courseName = 'Web 前端开发'
|
||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||
mockGetBook(data)
|
||
|
||
const store = useTeachingBook('b1')
|
||
await flushPromises()
|
||
|
||
store.clearBook()
|
||
|
||
expect(store.book.value.designs).toEqual([])
|
||
expect(store.book.value.cover.courseName).toBe('Web 前端开发')
|
||
expect(store.book.value.selectedId).toBe('cover')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/composables/useTeachingBook.test.ts`
|
||
Expected: FAIL — 现有 `useTeachingBook` 不接受参数、没有 `loadStatus`/`generateLesson` 等字段,多个断言失败。
|
||
|
||
- [ ] **Step 3: 重写 `src/composables/useTeachingBook.ts`**
|
||
|
||
替换全部内容为:
|
||
|
||
```ts
|
||
import { nextTick, ref, watch, type Ref } from 'vue'
|
||
import {
|
||
createEmptyBook,
|
||
type BookCover,
|
||
type DesignId,
|
||
type TeachingBook,
|
||
type TeachingDesign,
|
||
} from '../domain/teachingDesign'
|
||
import * as booksApi from '../services/booksApi'
|
||
import { parseTeachingDesign } from '../services/markdownParser'
|
||
import { sortFilesNaturally } from '../services/naturalSort'
|
||
|
||
const AUTOSAVE_DELAY_MS = 300
|
||
|
||
export type DuplicateStrategy = 'replace' | 'keep'
|
||
|
||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||
|
||
export type LoadStatus = 'loading' | 'loaded' | 'error'
|
||
|
||
export type GenerateLessonResult = { ok: true } | { ok: false; message: string }
|
||
|
||
export interface ImportResult {
|
||
imported: number
|
||
failed: Array<{ filename: string; message: string }>
|
||
duplicates: string[]
|
||
}
|
||
|
||
export interface TeachingBookStore {
|
||
book: Ref<TeachingBook>
|
||
loadStatus: Ref<LoadStatus>
|
||
loadError: Ref<string | null>
|
||
saveStatus: Ref<SaveStatus>
|
||
lastError: Ref<string | null>
|
||
selectedDesign: Ref<TeachingDesign | null>
|
||
hasDesigns: Ref<boolean>
|
||
warningCount: Ref<number>
|
||
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
|
||
detectDuplicates: (files: readonly File[]) => string[]
|
||
selectPage: (id: 'cover' | DesignId) => void
|
||
moveDesign: (from: number, to: number) => void
|
||
removeDesign: (id: DesignId) => void
|
||
updateCover: (patch: Partial<BookCover>) => void
|
||
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
||
clearBook: () => void
|
||
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
||
}
|
||
|
||
export function useTeachingBook(bookId: string): TeachingBookStore {
|
||
const book = ref<TeachingBook>(createEmptyBook()) as Ref<TeachingBook>
|
||
const loadStatus = ref<LoadStatus>('loading')
|
||
const loadError = ref<string | null>(null)
|
||
const saveStatus = ref<SaveStatus>('idle')
|
||
const lastError = ref<string | null>(null)
|
||
|
||
const selectedDesign = ref<TeachingDesign | null>(null)
|
||
const hasDesigns = ref(false)
|
||
const warningCount = ref(0)
|
||
|
||
let isLoading = true
|
||
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||
|
||
function syncDerived(): void {
|
||
const current = book.value
|
||
hasDesigns.value = current.designs.length > 0
|
||
selectedDesign.value =
|
||
current.selectedId === 'cover'
|
||
? null
|
||
: current.designs.find((design) => design.id === current.selectedId) ?? null
|
||
warningCount.value = current.designs.reduce(
|
||
(total, design) => total + design.warnings.length,
|
||
0,
|
||
)
|
||
}
|
||
|
||
syncDerived()
|
||
|
||
function touch(): void {
|
||
book.value.updatedAt = new Date().toISOString()
|
||
}
|
||
|
||
function scheduleSave(): void {
|
||
if (autosaveTimer !== undefined) {
|
||
clearTimeout(autosaveTimer)
|
||
}
|
||
|
||
autosaveTimer = setTimeout(() => {
|
||
saveStatus.value = 'saving'
|
||
booksApi
|
||
.updateBook(bookId, book.value)
|
||
.then(() => {
|
||
saveStatus.value = 'saved'
|
||
lastError.value = null
|
||
})
|
||
.catch((error: unknown) => {
|
||
saveStatus.value = 'error'
|
||
lastError.value = error instanceof Error ? error.message : '保存失败。'
|
||
})
|
||
}, AUTOSAVE_DELAY_MS)
|
||
}
|
||
|
||
watch(
|
||
book,
|
||
() => {
|
||
syncDerived()
|
||
if (isLoading) return
|
||
scheduleSave()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
async function load(): Promise<void> {
|
||
try {
|
||
const record = await booksApi.getBook(bookId)
|
||
book.value = record.data
|
||
await nextTick()
|
||
loadStatus.value = 'loaded'
|
||
} catch (error) {
|
||
loadStatus.value = 'error'
|
||
loadError.value = error instanceof Error ? error.message : '加载失败。'
|
||
} finally {
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
void load()
|
||
|
||
function detectDuplicates(files: readonly File[]): string[] {
|
||
const existingNames = new Set(book.value.designs.map((design) => design.originalFilename))
|
||
return files.map((file) => file.name).filter((name) => existingNames.has(name))
|
||
}
|
||
|
||
async function importFiles(
|
||
files: readonly File[],
|
||
strategy: DuplicateStrategy,
|
||
): Promise<ImportResult> {
|
||
const markdownFiles = files.filter((file) => /\.md$/i.test(file.name))
|
||
const failed: ImportResult['failed'] = files
|
||
.filter((file) => !/\.md$/i.test(file.name))
|
||
.map((file) => ({ filename: file.name, message: '仅支持 .md 文件。' }))
|
||
|
||
const sortedFiles = sortFilesNaturally([...markdownFiles])
|
||
const duplicates: string[] = []
|
||
let imported = 0
|
||
|
||
for (const file of sortedFiles) {
|
||
try {
|
||
const text = await file.text()
|
||
const design = parseTeachingDesign(file.name, text)
|
||
|
||
const existingIndex = book.value.designs.findIndex(
|
||
(existing) => existing.originalFilename === file.name,
|
||
)
|
||
|
||
if (existingIndex !== -1) {
|
||
duplicates.push(file.name)
|
||
if (strategy === 'replace') {
|
||
book.value.designs.splice(existingIndex, 1, design)
|
||
} else {
|
||
book.value.designs.push(design)
|
||
}
|
||
} else {
|
||
book.value.designs.push(design)
|
||
}
|
||
|
||
imported++
|
||
} catch (error) {
|
||
failed.push({
|
||
filename: file.name,
|
||
message: error instanceof Error ? error.message : '解析失败。',
|
||
})
|
||
}
|
||
}
|
||
|
||
if (imported > 0 && book.value.selectedId === 'cover' && book.value.designs.length > 0) {
|
||
book.value.selectedId = book.value.designs[0]!.id
|
||
}
|
||
|
||
if (imported > 0) {
|
||
touch()
|
||
}
|
||
|
||
return { imported, failed, duplicates }
|
||
}
|
||
|
||
function selectPage(id: 'cover' | DesignId): void {
|
||
book.value.selectedId = id
|
||
}
|
||
|
||
function moveDesign(from: number, to: number): void {
|
||
const designs = book.value.designs
|
||
if (from < 0 || from >= designs.length || to < 0 || to >= designs.length) {
|
||
return
|
||
}
|
||
const [moved] = designs.splice(from, 1)
|
||
designs.splice(to, 0, moved!)
|
||
touch()
|
||
}
|
||
|
||
function removeDesign(id: DesignId): void {
|
||
const designs = book.value.designs
|
||
const index = designs.findIndex((design) => design.id === id)
|
||
if (index === -1) {
|
||
return
|
||
}
|
||
designs.splice(index, 1)
|
||
|
||
if (book.value.selectedId === id) {
|
||
book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? 'cover'
|
||
}
|
||
|
||
touch()
|
||
}
|
||
|
||
function updateCover(patch: Partial<BookCover>): void {
|
||
Object.assign(book.value.cover, patch)
|
||
touch()
|
||
}
|
||
|
||
function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void {
|
||
const design = book.value.designs.find((candidate) => candidate.id === id)
|
||
if (!design) {
|
||
return
|
||
}
|
||
updater(design)
|
||
touch()
|
||
}
|
||
|
||
function clearBook(): void {
|
||
book.value.designs = []
|
||
book.value.selectedId = 'cover'
|
||
touch()
|
||
}
|
||
|
||
async function generateLesson(topic: string): Promise<GenerateLessonResult> {
|
||
try {
|
||
const result = await booksApi.generateLesson(topic)
|
||
const design = parseTeachingDesign(result.filename, result.markdown)
|
||
book.value.designs.push(design)
|
||
book.value.selectedId = design.id
|
||
touch()
|
||
return { ok: true }
|
||
} catch (error) {
|
||
return { ok: false, message: error instanceof Error ? error.message : '生成失败。' }
|
||
}
|
||
}
|
||
|
||
return {
|
||
book,
|
||
loadStatus,
|
||
loadError,
|
||
saveStatus,
|
||
lastError,
|
||
selectedDesign,
|
||
hasDesigns,
|
||
warningCount,
|
||
importFiles,
|
||
detectDuplicates,
|
||
selectPage,
|
||
moveDesign,
|
||
removeDesign,
|
||
updateCover,
|
||
updateDesign,
|
||
clearBook,
|
||
generateLesson,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/composables/useTeachingBook.test.ts`
|
||
Expected: PASS — 11 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/composables/useTeachingBook.ts src/composables/useTeachingBook.test.ts
|
||
git commit -m "feat: load and autosave teaching books via booksApi"
|
||
```
|
||
|
||
> 注意:本步骤的 `clearBook()` 仅清空 `designs` 并将 `selectedId` 重置为 `'cover'`,**保留** `cover`(课程名称/教师姓名)。这与旧版 `clearBook()`(整体重置为 `createEmptyBook()`)行为不同,但更符合设计文档中「清空教案列表但保留整本记录」的描述。
|
||
|
||
---
|
||
|
||
## Task 8: 生成教案对话框 `GenerateLessonDialog.vue`
|
||
|
||
**Files:**
|
||
- Create: `src/components/GenerateLessonDialog.vue`
|
||
- Test: `src/components/GenerateLessonDialog.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `src/components/GenerateLessonDialog.test.ts`:
|
||
|
||
```ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||
|
||
describe('GenerateLessonDialog', () => {
|
||
it('disables submit until a topic is entered', async () => {
|
||
const wrapper = mount(GenerateLessonDialog, { props: { loading: false, error: null } })
|
||
|
||
const submit = wrapper.findAll('button')[0]!
|
||
expect(submit.attributes('disabled')).toBeDefined()
|
||
|
||
await wrapper.get('input').setValue('CSS 弹性布局')
|
||
expect(submit.attributes('disabled')).toBeUndefined()
|
||
})
|
||
|
||
it('emits submit with the trimmed topic', async () => {
|
||
const wrapper = mount(GenerateLessonDialog, { props: { loading: false, error: null } })
|
||
|
||
await wrapper.get('input').setValue(' CSS 弹性布局 ')
|
||
await wrapper.findAll('button')[0]!.trigger('click')
|
||
|
||
expect(wrapper.emitted('submit')).toEqual([['CSS 弹性布局']])
|
||
})
|
||
|
||
it('shows a loading state and disables interaction', () => {
|
||
const wrapper = mount(GenerateLessonDialog, { props: { loading: true, error: null } })
|
||
|
||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||
expect(wrapper.findAll('button')[0]!.text()).toContain('生成中')
|
||
expect(wrapper.findAll('button')[0]!.attributes('disabled')).toBeDefined()
|
||
})
|
||
|
||
it('shows an error message and allows retry without closing', async () => {
|
||
const wrapper = mount(GenerateLessonDialog, { props: { loading: false, error: 'Deepseek 请求失败。' } })
|
||
|
||
expect(wrapper.text()).toContain('Deepseek 请求失败。')
|
||
expect(wrapper.findAll('button')[0]!.attributes('disabled')).toBeDefined()
|
||
|
||
await wrapper.get('input').setValue('CSS 弹性布局')
|
||
expect(wrapper.findAll('button')[0]!.attributes('disabled')).toBeUndefined()
|
||
})
|
||
|
||
it('emits cancel', async () => {
|
||
const wrapper = mount(GenerateLessonDialog, { props: { loading: false, error: null } })
|
||
|
||
await wrapper.findAll('button')[1]!.trigger('click')
|
||
|
||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/components/GenerateLessonDialog.test.ts`
|
||
Expected: FAIL — `Failed to resolve import "./GenerateLessonDialog.vue"`
|
||
|
||
- [ ] **Step 3: 实现 `src/components/GenerateLessonDialog.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
|
||
const props = defineProps<{
|
||
loading: boolean
|
||
error: string | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
submit: [topic: string]
|
||
cancel: []
|
||
}>()
|
||
|
||
const topic = ref('')
|
||
|
||
function submit(): void {
|
||
const value = topic.value.trim()
|
||
if (!value || props.loading) return
|
||
emit('submit', value)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="generate-lesson-title">
|
||
<div class="dialog">
|
||
<h2 id="generate-lesson-title">生成教案</h2>
|
||
<p>输入主题,AI 将生成一份符合模板结构的教案,加入当前整本末尾。</p>
|
||
<input
|
||
v-model="topic"
|
||
type="text"
|
||
placeholder="例如:CSS 弹性布局入门"
|
||
:disabled="loading"
|
||
@keydown.enter="submit"
|
||
/>
|
||
<p v-if="error" class="app-notice app-notice--error" role="alert">{{ error }}</p>
|
||
<div class="dialog-actions">
|
||
<button type="button" :disabled="loading || !topic.trim()" @click="submit">
|
||
{{ loading ? '生成中…' : '生成' }}
|
||
</button>
|
||
<button type="button" :disabled="loading" @click="emit('cancel')">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/components/GenerateLessonDialog.test.ts`
|
||
Expected: PASS — 5 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/GenerateLessonDialog.vue src/components/GenerateLessonDialog.test.ts
|
||
git commit -m "feat: add generate lesson dialog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 更新 `WorkspaceToolbar.vue`(新增「生成教案」「返回列表」)
|
||
|
||
**Files:**
|
||
- Modify: `src/components/WorkspaceToolbar.vue`
|
||
- Test: `src/components/WorkspaceToolbar.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `src/components/WorkspaceToolbar.test.ts`:
|
||
|
||
```ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it } from 'vitest'
|
||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||
|
||
function mountToolbar(lessonCount: number): ReturnType<typeof mount> {
|
||
return mount(WorkspaceToolbar, {
|
||
props: { lessonCount, warningCount: 0, saveStatus: 'idle' },
|
||
})
|
||
}
|
||
|
||
describe('WorkspaceToolbar', () => {
|
||
it('renders the lesson count', () => {
|
||
const wrapper = mountToolbar(3)
|
||
expect(wrapper.text()).toContain('共 3 课')
|
||
})
|
||
|
||
it('emits generate when the generate button is clicked', async () => {
|
||
const wrapper = mountToolbar(3)
|
||
await wrapper.get('button[data-testid="generate"]').trigger('click')
|
||
expect(wrapper.emitted('generate')).toHaveLength(1)
|
||
})
|
||
|
||
it('emits back when the back button is clicked', async () => {
|
||
const wrapper = mountToolbar(0)
|
||
await wrapper.get('button[data-testid="back"]').trigger('click')
|
||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||
})
|
||
|
||
it('keeps generate and back enabled even with no lessons', () => {
|
||
const wrapper = mountToolbar(0)
|
||
expect(wrapper.get('button[data-testid="generate"]').attributes('disabled')).toBeUndefined()
|
||
expect(wrapper.get('button[data-testid="back"]').attributes('disabled')).toBeUndefined()
|
||
})
|
||
|
||
it('disables print, export and clear when there are no lessons', () => {
|
||
const wrapper = mountToolbar(0)
|
||
expect(wrapper.get('button[data-testid="print"]').attributes('disabled')).toBeDefined()
|
||
expect(wrapper.get('button[data-testid="export"]').attributes('disabled')).toBeDefined()
|
||
expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts`
|
||
Expected: FAIL — 找不到 `[data-testid="generate"]` / `[data-testid="back"]`(按钮尚未添加)
|
||
|
||
- [ ] **Step 3: 更新 `src/components/WorkspaceToolbar.vue`**
|
||
|
||
替换全部内容为:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import type { SaveStatus } from '../composables/useTeachingBook'
|
||
|
||
const props = defineProps<{
|
||
lessonCount: number
|
||
warningCount: number
|
||
saveStatus: SaveStatus
|
||
}>()
|
||
|
||
defineEmits<{
|
||
upload: []
|
||
print: []
|
||
export: []
|
||
clear: []
|
||
generate: []
|
||
back: []
|
||
}>()
|
||
|
||
const saveStatusLabel: Record<SaveStatus, string> = {
|
||
idle: '',
|
||
saving: '保存中…',
|
||
saved: '已保存',
|
||
error: '保存失败',
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<header class="workspace-toolbar">
|
||
<button type="button" data-testid="back" @click="$emit('back')">返回列表</button>
|
||
<button type="button" data-testid="upload" @click="$emit('upload')">导入教案</button>
|
||
<button type="button" data-testid="generate" @click="$emit('generate')">生成教案</button>
|
||
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
||
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 Markdown</button>
|
||
<button type="button" data-testid="clear" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
|
||
|
||
<span class="workspace-toolbar-count">共 {{ lessonCount }} 课</span>
|
||
<span v-if="warningCount > 0" class="workspace-toolbar-warning">
|
||
{{ warningCount }} 处提示
|
||
</span>
|
||
<span class="workspace-toolbar-status" :class="`workspace-toolbar-status--${saveStatus}`">
|
||
{{ saveStatusLabel[props.saveStatus] }}
|
||
</span>
|
||
</header>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts`
|
||
Expected: PASS — 5 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/WorkspaceToolbar.vue src/components/WorkspaceToolbar.test.ts
|
||
git commit -m "feat: add generate and back actions to workspace toolbar"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 整本列表入口页 `BookListPage.vue`
|
||
|
||
**Files:**
|
||
- Create: `src/components/BookListPage.vue`
|
||
- Test: `src/components/BookListPage.test.ts`
|
||
- Modify: `src/style.css`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `src/components/BookListPage.test.ts`:
|
||
|
||
```ts
|
||
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 前端开发')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/components/BookListPage.test.ts`
|
||
Expected: FAIL — `Failed to resolve import "./BookListPage.vue"`
|
||
|
||
- [ ] **Step 3: 实现 `src/components/BookListPage.vue`**
|
||
|
||
```vue
|
||
<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>
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/components/BookListPage.test.ts`
|
||
Expected: PASS — 7 个测试全部通过
|
||
|
||
- [ ] **Step 5: 新增列表页样式**
|
||
|
||
在 `src/style.css` 末尾追加:
|
||
|
||
```css
|
||
/* 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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/BookListPage.vue src/components/BookListPage.test.ts src/style.css
|
||
git commit -m "feat: add book list entry page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 提取工作区 `WorkspaceView.vue`
|
||
|
||
**Files:**
|
||
- Create: `src/components/WorkspaceView.vue`
|
||
- Test: `src/components/WorkspaceView.test.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
创建 `src/components/WorkspaceView.test.ts`:
|
||
|
||
```ts
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||
import * as booksApi from '../services/booksApi'
|
||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||
import WorkspaceView from './WorkspaceView.vue'
|
||
|
||
vi.mock('../services/booksApi')
|
||
|
||
function mockBook(data = createEmptyBook()): void {
|
||
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||
id: 'b1',
|
||
name: '示例整本',
|
||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||
data,
|
||
})
|
||
}
|
||
|
||
describe('WorkspaceView', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
it('shows a loading state while the book loads', () => {
|
||
vi.mocked(booksApi.getBook).mockReturnValue(new Promise(() => {}))
|
||
|
||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||
|
||
expect(wrapper.text()).toContain('加载中')
|
||
})
|
||
|
||
it('shows an error and emits back when loading fails', async () => {
|
||
vi.mocked(booksApi.getBook).mockRejectedValue(new Error('整本不存在。'))
|
||
|
||
const wrapper = mount(WorkspaceView, { props: { bookId: 'missing' } })
|
||
await flushPromises()
|
||
|
||
expect(wrapper.text()).toContain('整本不存在。')
|
||
|
||
await wrapper.get('button').trigger('click')
|
||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||
})
|
||
|
||
it('renders the toolbar and emits back when loaded', async () => {
|
||
mockBook()
|
||
|
||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||
await flushPromises()
|
||
|
||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
||
|
||
await wrapper.get('[data-testid="back"]').trigger('click')
|
||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||
})
|
||
|
||
it('opens the generate dialog and adds a generated lesson on submit', async () => {
|
||
mockBook()
|
||
vi.mocked(booksApi.generateLesson).mockResolvedValue({
|
||
filename: 'css-flex.md',
|
||
markdown: '# CSS 弹性布局 教学设计',
|
||
})
|
||
|
||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||
await flushPromises()
|
||
|
||
await wrapper.get('[data-testid="generate"]').trigger('click')
|
||
const dialog = wrapper.getComponent(GenerateLessonDialog)
|
||
|
||
dialog.vm.$emit('submit', 'CSS 弹性布局')
|
||
await flushPromises()
|
||
|
||
expect(booksApi.generateLesson).toHaveBeenCalledWith('CSS 弹性布局')
|
||
expect(wrapper.findComponent(GenerateLessonDialog).exists()).toBe(false)
|
||
expect(wrapper.text()).toContain('CSS 弹性布局')
|
||
})
|
||
|
||
it('clears the lessons after confirmation', async () => {
|
||
const data = createEmptyBook()
|
||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||
mockBook(data)
|
||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||
|
||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||
await flushPromises()
|
||
|
||
await wrapper.get('[data-testid="clear"]').trigger('click')
|
||
|
||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/components/WorkspaceView.test.ts`
|
||
Expected: FAIL — `Failed to resolve import "./WorkspaceView.vue"`
|
||
|
||
- [ ] **Step 3: 实现 `src/components/WorkspaceView.vue`**
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { type DuplicateStrategy, useTeachingBook } from '../composables/useTeachingBook'
|
||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
||
import A4Workspace from './A4Workspace.vue'
|
||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||
import ImportConflictDialog from './ImportConflictDialog.vue'
|
||
import LessonSidebar from './LessonSidebar.vue'
|
||
import PrintBook from './PrintBook.vue'
|
||
import UploadDropzone from './UploadDropzone.vue'
|
||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||
|
||
const props = defineProps<{ bookId: string }>()
|
||
|
||
defineEmits<{ back: [] }>()
|
||
|
||
const {
|
||
book,
|
||
loadStatus,
|
||
loadError,
|
||
saveStatus,
|
||
lastError,
|
||
selectedDesign,
|
||
hasDesigns,
|
||
warningCount,
|
||
importFiles,
|
||
detectDuplicates,
|
||
selectPage,
|
||
moveDesign,
|
||
removeDesign,
|
||
updateCover,
|
||
updateDesign,
|
||
clearBook,
|
||
generateLesson,
|
||
} = useTeachingBook(props.bookId)
|
||
|
||
const pendingFiles = ref<File[]>([])
|
||
const duplicateNames = ref<string[]>([])
|
||
const errorMessage = ref<string | null>(null)
|
||
const uploadRef = ref<InstanceType<typeof UploadDropzone> | null>(null)
|
||
|
||
const showGenerateDialog = ref(false)
|
||
const generateLoading = ref(false)
|
||
const generateError = ref<string | null>(null)
|
||
|
||
async function runImport(files: File[], strategy: DuplicateStrategy): Promise<void> {
|
||
const result = await importFiles(files, strategy)
|
||
if (result.failed.length > 0) {
|
||
errorMessage.value = `${result.failed.length} 个文件导入失败:${result.failed
|
||
.map((entry) => `${entry.filename}(${entry.message})`)
|
||
.join('、')}`
|
||
}
|
||
}
|
||
|
||
async function handleFiles(files: File[]): Promise<void> {
|
||
const duplicates = detectDuplicates(files)
|
||
if (duplicates.length > 0) {
|
||
pendingFiles.value = files
|
||
duplicateNames.value = duplicates
|
||
return
|
||
}
|
||
await runImport(files, 'keep')
|
||
}
|
||
|
||
async function resolveConflict(strategy: DuplicateStrategy | 'cancel'): Promise<void> {
|
||
const files = pendingFiles.value
|
||
pendingFiles.value = []
|
||
duplicateNames.value = []
|
||
if (strategy === 'cancel') return
|
||
await runImport(files, strategy)
|
||
}
|
||
|
||
function triggerUpload(): void {
|
||
uploadRef.value?.openPicker()
|
||
}
|
||
|
||
function handlePrint(): void {
|
||
window.print()
|
||
}
|
||
|
||
async function handleExport(): Promise<void> {
|
||
try {
|
||
const blob = await createBookZip(book.value.designs)
|
||
downloadBlob(blob, 'teaching-design-book.zip')
|
||
} catch {
|
||
errorMessage.value = '导出失败,请重试。'
|
||
}
|
||
}
|
||
|
||
function handleClear(): void {
|
||
if (book.value.designs.length === 0) {
|
||
return
|
||
}
|
||
if (window.confirm('确定要清空当前所有教案吗?此操作无法撤销。')) {
|
||
clearBook()
|
||
}
|
||
}
|
||
|
||
function handleDesignUpdate(design: TeachingDesign): void {
|
||
updateDesign(design.id, (target) => Object.assign(target, design))
|
||
}
|
||
|
||
function openGenerateDialog(): void {
|
||
generateError.value = null
|
||
showGenerateDialog.value = true
|
||
}
|
||
|
||
async function handleGenerateSubmit(topic: string): Promise<void> {
|
||
generateLoading.value = true
|
||
generateError.value = null
|
||
const result = await generateLesson(topic)
|
||
generateLoading.value = false
|
||
|
||
if (result.ok) {
|
||
showGenerateDialog.value = false
|
||
} else {
|
||
generateError.value = result.message
|
||
}
|
||
}
|
||
|
||
function cancelGenerate(): void {
|
||
showGenerateDialog.value = false
|
||
generateError.value = null
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="app-shell">
|
||
<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" @click="$emit('back')">返回列表</button>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<ImportConflictDialog
|
||
v-if="duplicateNames.length > 0"
|
||
:duplicates="duplicateNames"
|
||
@replace="resolveConflict('replace')"
|
||
@keep="resolveConflict('keep')"
|
||
@cancel="resolveConflict('cancel')"
|
||
/>
|
||
<GenerateLessonDialog
|
||
v-if="showGenerateDialog"
|
||
:loading="generateLoading"
|
||
:error="generateError"
|
||
@submit="handleGenerateSubmit"
|
||
@cancel="cancelGenerate"
|
||
/>
|
||
|
||
<p v-if="errorMessage" class="app-notice app-notice--error" role="alert">
|
||
{{ errorMessage }}
|
||
<button type="button" @click="errorMessage = null">关闭</button>
|
||
</p>
|
||
<p v-if="saveStatus === 'error' && lastError" class="app-notice app-notice--error" role="alert">
|
||
{{ lastError }}
|
||
</p>
|
||
|
||
<WorkspaceToolbar
|
||
:lesson-count="book.designs.length"
|
||
:warning-count="warningCount"
|
||
:save-status="saveStatus"
|
||
@back="$emit('back')"
|
||
@upload="triggerUpload"
|
||
@generate="openGenerateDialog"
|
||
@print="handlePrint"
|
||
@export="handleExport"
|
||
@clear="handleClear"
|
||
/>
|
||
|
||
<UploadDropzone v-if="!hasDesigns" @files="handleFiles" />
|
||
|
||
<template v-else>
|
||
<div class="workspace-layout">
|
||
<LessonSidebar
|
||
:designs="book.designs"
|
||
:selected-id="book.selectedId"
|
||
@select="selectPage"
|
||
@remove="removeDesign"
|
||
@move="moveDesign"
|
||
/>
|
||
<A4Workspace
|
||
:cover="book.cover"
|
||
:selected-id="book.selectedId"
|
||
:selected-design="selectedDesign"
|
||
@update:cover="updateCover"
|
||
@update:design="handleDesignUpdate"
|
||
/>
|
||
</div>
|
||
<UploadDropzone ref="uploadRef" compact class="visually-hidden" @files="handleFiles" />
|
||
</template>
|
||
|
||
<PrintBook :cover="book.cover" :designs="book.designs" />
|
||
</template>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/components/WorkspaceView.test.ts`
|
||
Expected: PASS — 5 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/WorkspaceView.vue src/components/WorkspaceView.test.ts
|
||
git commit -m "feat: extract workspace view with generate and back actions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 重写 `App.vue`(整本列表 <-> 工作区切换)
|
||
|
||
**Files:**
|
||
- Modify: `src/App.vue`
|
||
- Modify: `src/App.test.ts`
|
||
|
||
- [ ] **Step 1: 重写测试**
|
||
|
||
替换 `src/App.test.ts` 全部内容为:
|
||
|
||
```ts
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import App from './App.vue'
|
||
import { createEmptyBook } from './domain/teachingDesign'
|
||
import * as booksApi from './services/booksApi'
|
||
|
||
vi.mock('./services/booksApi')
|
||
|
||
describe('App', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
it('starts with the book list entry page', async () => {
|
||
vi.mocked(booksApi.listBooks).mockResolvedValue([])
|
||
|
||
const wrapper = mount(App)
|
||
await flushPromises()
|
||
|
||
expect(wrapper.text()).toContain('教学设计整本')
|
||
expect(wrapper.text()).toContain('新建整本')
|
||
})
|
||
|
||
it('switches to the workspace view when a book is opened', async () => {
|
||
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
||
])
|
||
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||
id: 'b1',
|
||
name: '示例整本',
|
||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||
data: createEmptyBook(),
|
||
})
|
||
|
||
const wrapper = mount(App)
|
||
await flushPromises()
|
||
|
||
await wrapper.get('[data-testid="open-b1"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
|
||
})
|
||
|
||
it('returns to the book list when back is emitted', async () => {
|
||
vi.mocked(booksApi.listBooks).mockResolvedValue([
|
||
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
|
||
])
|
||
vi.mocked(booksApi.getBook).mockResolvedValue({
|
||
id: 'b1',
|
||
name: '示例整本',
|
||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||
data: createEmptyBook(),
|
||
})
|
||
|
||
const wrapper = mount(App)
|
||
await flushPromises()
|
||
|
||
await wrapper.get('[data-testid="open-b1"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
await wrapper.get('[data-testid="back"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(wrapper.text()).toContain('教学设计整本')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试,确认失败**
|
||
|
||
Run: `npx vitest run src/App.test.ts`
|
||
Expected: FAIL — 当前 `App.vue` 直接渲染工作区(无 `教学设计整本`/`新建整本` 文本),且不存在 `[data-testid="open-b1"]`。
|
||
|
||
- [ ] **Step 3: 重写 `src/App.vue`**
|
||
|
||
替换全部内容为:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import BookListPage from './components/BookListPage.vue'
|
||
import WorkspaceView from './components/WorkspaceView.vue'
|
||
|
||
const currentBookId = ref<string | null>(null)
|
||
|
||
function openBook(id: string): void {
|
||
currentBookId.value = id
|
||
}
|
||
|
||
function backToList(): void {
|
||
currentBookId.value = null
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<BookListPage v-if="!currentBookId" @open="openBook" />
|
||
<WorkspaceView v-else :key="currentBookId" :book-id="currentBookId" @back="backToList" />
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试,确认通过**
|
||
|
||
Run: `npx vitest run src/App.test.ts`
|
||
Expected: PASS — 3 个测试全部通过
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/App.vue src/App.test.ts
|
||
git commit -m "feat: switch between book list and workspace view in App"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: 移除 localStorage 相关代码
|
||
|
||
**Files:**
|
||
- Delete: `src/services/bookStorage.ts`
|
||
- Delete: `src/services/bookStorage.test.ts`
|
||
- Delete: `src/components/RestoreDraftDialog.vue`
|
||
|
||
- [ ] **Step 1: 确认无残留引用**
|
||
|
||
```bash
|
||
grep -rn "bookStorage\|RestoreDraftDialog" src
|
||
```
|
||
|
||
Expected: 无输出(Task 7 已重写 `useTeachingBook.ts` 不再引用 `bookStorage`,Task 12 已重写 `App.vue` 不再引用 `RestoreDraftDialog`)。
|
||
|
||
- [ ] **Step 2: 删除文件**
|
||
|
||
```bash
|
||
git rm src/services/bookStorage.ts src/services/bookStorage.test.ts src/components/RestoreDraftDialog.vue
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git commit -m "chore: remove localStorage-based persistence"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: 最终验证
|
||
|
||
**Files:** 无新增/修改文件,仅运行验证命令。
|
||
|
||
- [ ] **Step 1: 运行前端测试**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
|
||
Expected: 全部测试通过,包括既有的 `markdownParser`/`markdownWriter`/`zipExporter`/`PrintBook` 等测试与本计划新增的测试。
|
||
|
||
- [ ] **Step 2: 运行后端测试**
|
||
|
||
```bash
|
||
bun test server
|
||
```
|
||
|
||
Expected: `server/db.test.ts`、`server/routes/books.test.ts`、`server/routes/generate.test.ts` 全部通过(共 28 个测试)。
|
||
|
||
- [ ] **Step 3: 运行前端类型检查与构建**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: `vue-tsc -b && vite build` 成功,无类型错误,产出 `dist/`。
|
||
|
||
- [ ] **Step 4: 手动验证(按设计文档 10.3 节)**
|
||
|
||
```bash
|
||
npm run server:dev
|
||
```
|
||
|
||
在另一个终端:
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
在浏览器中打开 Vite dev server 地址,依次验证:
|
||
|
||
1. 首次进入显示「教学设计整本」列表(应为空),点击「新建整本」创建一本,自动进入工作区。
|
||
2. 上传 `data/Web` 目录下的教案 `.md` 文件,确认解析与编辑正常。
|
||
3. 编辑封面或某课内容,等待约 300ms,工具栏显示「已保存」;刷新页面后内容仍存在(从服务器 `GET /api/books/:id` 恢复)。
|
||
4. 点击「生成教案」,输入主题,确认生成成功后新增一课,结构符合模板(含警告提示,如有)。
|
||
5. 点击「返回列表」,确认列表中该整本的更新时间和课时数已更新。
|
||
6. 删除该整本,确认从列表移除;删除前需确认弹窗。
|
||
|
||
若 `.env` 未配置 `DEEPSEEK_API_KEY`,「生成教案」应显示后端返回的 500 错误提示("未配置 DEEPSEEK_API_KEY。"),不影响其他功能。
|
||
|
||
- [ ] **Step 5: 最终确认**
|
||
|
||
确认以下验收标准均已满足(对照设计文档第 11 节):
|
||
|
||
- [ ] 应用启动后先显示整本列表,可创建、打开、重命名、删除整本
|
||
- [ ] 进入整本后,原有上传、编辑、拖拽排序、打印、ZIP 导出功能行为不变
|
||
- [ ] 编辑内容 300ms 防抖后通过 API 保存到 SQLite,刷新后从服务器恢复
|
||
- [ ] 工具栏「生成教案」可生成并加入新课时,应用既有警告机制
|
||
- [ ] `npx vitest run` 与 `bun test server` 均通过
|
||
- [ ] `bun run server/index.ts` 单进程同时提供 API 与前端静态资源
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
|
||
- §3 核心产品决策(列表入口、清空与删除分离、生成教案按钮、非破坏性错误提示)→ Task 7(`clearBook` 语义说明)、Task 9-12。
|
||
- §5 数据库设计(`books` 表结构、`data` JSON 复用、`updated_at`)→ Task 2。
|
||
- §6 API 表(`/api/books*`、`/api/generate`)→ Task 3、Task 4。
|
||
- §7.1 `booksApi.ts` → Task 6。
|
||
- §7.2 `BookListPage.vue`(列表/新建/重命名/删除/错误重试)→ Task 10。
|
||
- §7.3 `useTeachingBook` 重写(按 `bookId` 加载/保存、移除 `restore`/`pendingDuplicateFiles`、`generateLesson`)→ Task 7。
|
||
- §7.4 `GenerateLessonDialog.vue` → Task 8。
|
||
- §7.5 `WorkspaceToolbar.vue`(「生成教案」「返回列表」)→ Task 9。
|
||
- §7.6 `App.vue`(列表/工作区切换)→ Task 12(通过 Task 11 拆出的 `WorkspaceView.vue`)。
|
||
- §7.7 移除内容 → Task 13。
|
||
- §8 开发与构建流程(依赖、脚本、代理、`.env`)→ Task 1、Task 5。
|
||
- §9 错误处理与状态反馈(加载失败显示返回列表入口、保存失败提示、生成失败对话框内提示)→ Task 7、Task 11。
|
||
- §10 测试策略 → 每个任务均含对应测试;后端 `server/*.test.ts` 覆盖 §10.2;手动验证覆盖 §10.3 → Task 14 Step 4。
|
||
- §11 验收标准 → Task 14 Step 5。
|
||
|
||
无遗漏章节。
|
||
|
||
**Placeholder scan:** 全文搜索 "TBD"、"TODO"、"待实现"、"类似 Task" 均无匹配;所有代码步骤均含完整代码。
|
||
|
||
**Type consistency:**
|
||
|
||
- `TeachingBookStore`(Task 7 定义)字段:`book, loadStatus, loadError, saveStatus, lastError, selectedDesign, hasDesigns, warningCount, importFiles, detectDuplicates, selectPage, moveDesign, removeDesign, updateCover, updateDesign, clearBook, generateLesson` — Task 11 的 `WorkspaceView.vue` 解构使用了全部字段且名称一致。
|
||
- `GenerateLessonDialog` props `{ loading, error }` / emits `{ submit, cancel }`(Task 8)与 Task 11 中的绑定一致。
|
||
- `WorkspaceToolbar` emits `{ upload, print, export, clear, generate, back }`(Task 9)与 Task 11 中 `@upload/@print/@export/@clear/@generate/@back` 一一对应。
|
||
- `BookListPage` emits `{ open: [id: string] }`(Task 10)与 Task 12 `@open="openBook"` 一致。
|
||
- `booksApi` 导出的 `BookSummary/BookRecord/BookMeta/GenerateResult` 及函数签名(Task 6)与 Task 7、10、11、12 中的 mock/调用一致。
|
||
|
||
未发现不一致项。
|