Files
teaching-design/docs/superpowers/plans/2026-06-15-bun-sqlite-backend-implementation.md
2026-06-15 23:14:16 -06:00

83 KiB
Raw Blame History

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


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 — 在 BookListPageWorkspaceView 间切换
  • 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 依赖

bun add hono

Expected: package.jsondependencies 中新增一行 "hono": "^4.x.x"(版本号以实际安装结果为准),bun.lock/package-lock.json 更新。

  • Step 2: 新增后端脚本

package.json"scripts" 中添加(保持已有脚本不变,仅新增以下三行):

    "server": "bun run server/index.ts",
    "server:dev": "bun --watch run server/index.ts",
    "test:server": "bun test server"
  • Step 3: 更新 .gitignore

在文件末尾追加:

# Backend data and secrets
data/teaching-books.db
.env
  • Step 4: 新增 Vite 开发代理

编辑 vite.config.ts,在 defineConfig 中新增 server.proxy

/// <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
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

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
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
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

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
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
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

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
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-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
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

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 退出)。再运行:

curl -s http://localhost:3001/api/books

Expected: 输出 [](数据库首次创建,data/teaching-books.db 文件已生成)。

  • Step 3: Commit
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

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
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
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 全部内容为:

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

替换全部内容为:

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
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

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
<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
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

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

替换全部内容为:

<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
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

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
<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 末尾追加:

/* 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
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

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
<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
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 全部内容为:

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

替换全部内容为:

<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
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: 确认无残留引用

grep -rn "bookStorage\|RestoreDraftDialog" src

Expected: 无输出Task 7 已重写 useTeachingBook.ts 不再引用 bookStorageTask 12 已重写 App.vue 不再引用 RestoreDraftDialog)。

  • Step 2: 删除文件
git rm src/services/bookStorage.ts src/services/bookStorage.test.ts src/components/RestoreDraftDialog.vue
  • Step 3: Commit
git commit -m "chore: remove localStorage-based persistence"

Task 14: 最终验证

Files: 无新增/修改文件,仅运行验证命令。

  • Step 1: 运行前端测试
npx vitest run

Expected: 全部测试通过,包括既有的 markdownParser/markdownWriter/zipExporter/PrintBook 等测试与本计划新增的测试。

  • Step 2: 运行后端测试
bun test server

Expected: server/db.test.tsserver/routes/books.test.tsserver/routes/generate.test.ts 全部通过(共 28 个测试)。

  • Step 3: 运行前端类型检查与构建
npm run build

Expected: vue-tsc -b && vite build 成功,无类型错误,产出 dist/

  • Step 4: 手动验证(按设计文档 10.3 节)
npm run server:dev

在另一个终端:

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 runbun test server 均通过
  • bun run server/index.ts 单进程同时提供 API 与前端静态资源

Self-Review

Spec coverage

  • §3 核心产品决策(列表入口、清空与删除分离、生成教案按钮、非破坏性错误提示)→ Task 7clearBook 语义说明、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/pendingDuplicateFilesgenerateLesson)→ 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

  • TeachingBookStoreTask 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/调用一致。

未发现不一致项。