# 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 /// 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('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('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分钟)> |\` - \`| **教学目标** | **知识目标**:...**技能目标**:...**素养目标**:... |\` - \`| **教学重难点** | **重点**:...**难点**:... |\` - \`| **教学资源准备** | ... |\` 3. 二级标题 \`## 教学过程\`,后接一个 5 列表格,表头固定为: \`| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |\`,分隔行 \`|:---|:---|:---|:---|:---|\`, 包含 4-6 个教学环节行,每个环节名称写作 \`**N. 环节名称**(时长)\` 的格式。 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-v4-flash', 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(path: string, init?: RequestInit): Promise { 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 } export function listBooks(): Promise { return request('/api/books') } export function createBook(name: string): Promise { return request('/api/books', { method: 'POST', body: JSON.stringify({ name }) }) } export function getBook(id: string): Promise { return request(`/api/books/${id}`) } export function updateBook(id: string, data: TeachingBook): Promise { return request(`/api/books/${id}`, { method: 'PUT', body: JSON.stringify({ data }) }) } export function renameBook(id: string, name: string): Promise { 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 { 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 loadStatus: Ref loadError: Ref saveStatus: Ref lastError: Ref selectedDesign: Ref hasDesigns: Ref warningCount: Ref importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise detectDuplicates: (files: readonly File[]) => string[] selectPage: (id: 'cover' | DesignId) => void moveDesign: (from: number, to: number) => void removeDesign: (id: DesignId) => void updateCover: (patch: Partial) => void updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void clearBook: () => void generateLesson: (topic: string) => Promise } export function useTeachingBook(bookId: string): TeachingBookStore { const book = ref(createEmptyBook()) as Ref const loadStatus = ref('loading') const loadError = ref(null) const saveStatus = ref('idle') const lastError = ref(null) const selectedDesign = ref(null) const hasDesigns = ref(false) const warningCount = ref(0) let isLoading = true let autosaveTimer: ReturnType | 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 { 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 { 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): 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 { 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 生成教案 输入主题,AI 将生成一份符合模板结构的教案,加入当前整本末尾。 {{ error }} {{ loading ? '生成中…' : '生成' }} 取消 ``` - [ ] **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 { 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 返回列表 导入教案 生成教案 打印整册 导出 Markdown 清空 共 {{ lessonCount }} 课 {{ warningCount }} 处提示 {{ saveStatusLabel[props.saveStatus] }} ``` - [ ] **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 教学设计整本 新建整本 {{ actionError }} 关闭 加载中… {{ loadError }} 重试 还没有整本,创建一个开始使用。 保存 取消 {{ book.name }} 更新于 {{ book.updatedAt }} · {{ book.lessonCount }} 课 打开 重命名 删除 ``` - [ ] **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 加载中… {{ loadError }} 返回列表 {{ errorMessage }} 关闭 {{ lastError }} ``` - [ ] **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 ``` - [ ] **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/调用一致。 未发现不一致项。
输入主题,AI 将生成一份符合模板结构的教案,加入当前整本末尾。
{{ error }}
{{ actionError }} 关闭
加载中…
还没有整本,创建一个开始使用。
{{ errorMessage }} 关闭
{{ lastError }}