# Printable Teaching Design Generator Implementation Plan
> **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:** Build a browser-only Vue application that imports multiple teaching-design Markdown files, edits them as an ordered A4 book, prints the full book, autosaves locally, and exports the edited Markdown files as a ZIP.
**Architecture:** Parse each Markdown file into a typed `TeachingDesign` model, keep the full book in a single composable state store, and render the selected cover or lesson through focused A4 editor components. Pure services handle parsing, Markdown generation, natural sorting, local persistence, and ZIP creation so they can be tested independently from the Vue UI.
**Tech Stack:** Vue 3, TypeScript, Vite 8, Vitest, Vue Test Utils, jsdom, markdown-it, JSZip, browser File/Print/Storage APIs
---
## Repository Note
The workspace contains an empty `.git` directory rather than valid Git metadata. Each task retains the intended commit command, but execution must first run:
```bash
rtk git rev-parse --is-inside-work-tree
```
If that command fails, skip only the commit step and continue implementation without initializing or rewriting repository history.
## File Map
### Domain and services
- `src/domain/teachingDesign.ts`: shared types, empty-value factories, schema version.
- `src/services/naturalSort.ts`: numeric-aware filename ordering.
- `src/services/markdownTable.ts`: Markdown table row scanner and table extraction helpers.
- `src/services/markdownParser.ts`: tolerant Markdown-to-model parser and warnings.
- `src/services/markdownWriter.ts`: canonical model-to-Markdown generator.
- `src/services/bookStorage.ts`: versioned local autosave and restore.
- `src/services/zipExporter.ts`: ZIP generation and browser download.
- `src/services/markdownRenderer.ts`: safe markdown-it instance for editable previews.
### State and UI
- `src/composables/useTeachingBook.ts`: book state, import, selection, reorder, edit, autosave, clear.
- `src/components/UploadDropzone.vue`: initial and append-file upload surface.
- `src/components/WorkspaceToolbar.vue`: upload, print, export, clear actions and save status.
- `src/components/LessonSidebar.vue`: cover entry, lesson selection, warnings, native drag reorder.
- `src/components/ImportConflictDialog.vue`: explicit replace/keep resolution for duplicate filenames.
- `src/components/RestoreDraftDialog.vue`: restore/discard choice for stored work.
- `src/components/EditableText.vue`: auto-growing plain-text field.
- `src/components/EditableMarkdown.vue`: click-to-edit Markdown field with rendered blur state.
- `src/components/CoverPage.vue`: editable course name and teacher cover.
- `src/components/TeachingDesignPage.vue`: editable information, process, board, and reflection tables.
- `src/components/A4Workspace.vue`: screen-only selected page.
- `src/components/PrintBook.vue`: print-only complete book.
- `src/App.vue`: application composition and modal coordination.
### Styling and tests
- `src/style.css`: application shell, upload screen, workspace, A4 visual styles, responsive rules.
- `src/print.css`: A4 print pages, page breaks, repeated headers, hidden controls.
- `src/test/setup.ts`: Vue/jsdom test setup.
- `src/services/*.test.ts`: unit and corpus regression tests.
- `src/components/*.test.ts`: interaction tests.
- `src/composables/useTeachingBook.test.ts`: state workflow tests.
## Task 1: Establish Test and Runtime Dependencies
**Files:**
- Modify: `package.json`
- Modify: `package-lock.json`
- Modify: `vite.config.ts`
- Modify: `tsconfig.app.json`
- Create: `src/test/setup.ts`
- Create: `src/domain/teachingDesign.ts`
- Test: `src/domain/teachingDesign.test.ts`
- [ ] **Step 1: Install runtime and test dependencies**
Run:
```bash
rtk npm install jszip@3.10.1 markdown-it@14.2.0
rtk npm install -D vitest@4.1.8 @vue/test-utils@2.4.11 jsdom@29.1.1 @types/markdown-it@14.1.2 @testing-library/jest-dom@6.9.1
```
Expected: `package.json` and `package-lock.json` include the named packages without dependency-resolution errors.
- [ ] **Step 2: Add test scripts and Vitest configuration**
Update `package.json` scripts to:
```json
{
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
```
Update `vite.config.ts`:
```ts
///
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
},
})
```
Add `"vitest/globals"` to `compilerOptions.types` in `tsconfig.app.json`.
Create `src/test/setup.ts`:
```ts
import '@testing-library/jest-dom/vitest'
```
- [ ] **Step 3: Write the failing domain factory test**
Create `src/domain/teachingDesign.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from './teachingDesign'
describe('createEmptyTeachingDesign', () => {
it('creates editable defaults for missing lesson sections', () => {
const design = createEmptyTeachingDesign('8.md')
expect(design.originalFilename).toBe('8.md')
expect(design.processSteps).toHaveLength(1)
expect(design.boardDesign).toBe('')
expect(design.warnings).toEqual([])
})
})
```
- [ ] **Step 4: Run the test to verify it fails**
Run:
```bash
rtk npm test -- src/domain/teachingDesign.test.ts
```
Expected: FAIL because `src/domain/teachingDesign.ts` does not exist.
- [ ] **Step 5: Implement the domain types and factories**
Create `src/domain/teachingDesign.ts` with:
```ts
export const BOOK_SCHEMA_VERSION = 1
export type ParseWarningCode =
| 'missing-title'
| 'missing-basic-field'
| 'missing-process'
| 'invalid-process-table'
| 'missing-board'
| 'missing-reflection'
| 'unclassified-content'
export interface ParseWarning {
code: ParseWarningCode
message: string
}
export interface TeachingStep {
id: string
name: string
duration: string
content: string
teacherActivity: string
studentActivity: string
intention: string
}
export interface TeachingDesign {
id: string
originalFilename: string
title: string
topic: string
duration: string
knowledgeObjective: string
skillObjective: string
literacyObjective: string
keyPoint: string
difficultPoint: string
resources: string
processSteps: TeachingStep[]
boardDesign: string
effectiveness: string
reflection: string
additionalContent: string
warnings: ParseWarning[]
}
export interface BookCover {
courseName: string
teacherName: string
}
export interface TeachingBook {
schemaVersion: number
cover: BookCover
designs: TeachingDesign[]
selectedId: 'cover' | string
updatedAt: string
}
export function createTeachingStep(index = 1): TeachingStep {
return {
id: crypto.randomUUID(),
name: `${index}. 教学环节`,
duration: '',
content: '',
teacherActivity: '',
studentActivity: '',
intention: '',
}
}
export function createEmptyTeachingDesign(filename: string): TeachingDesign {
return {
id: crypto.randomUUID(),
originalFilename: filename,
title: '',
topic: '',
duration: '',
knowledgeObjective: '',
skillObjective: '',
literacyObjective: '',
keyPoint: '',
difficultPoint: '',
resources: '',
processSteps: [createTeachingStep()],
boardDesign: '',
effectiveness: '',
reflection: '',
additionalContent: '',
warnings: [],
}
}
export function createEmptyBook(): TeachingBook {
return {
schemaVersion: BOOK_SCHEMA_VERSION,
cover: { courseName: '', teacherName: '' },
designs: [],
selectedId: 'cover',
updatedAt: new Date().toISOString(),
}
}
```
- [ ] **Step 6: Run tests and type checking**
Run:
```bash
rtk npm test -- src/domain/teachingDesign.test.ts
rtk npm run build
```
Expected: the domain test passes and the production build completes.
- [ ] **Step 7: Commit**
```bash
rtk git add package.json package-lock.json vite.config.ts tsconfig.app.json src/test/setup.ts src/domain
rtk git commit -m "test: establish teaching book domain"
```
## Task 2: Implement Natural Sorting and Markdown Table Scanning
**Files:**
- Create: `src/services/naturalSort.ts`
- Create: `src/services/naturalSort.test.ts`
- Create: `src/services/markdownTable.ts`
- Create: `src/services/markdownTable.test.ts`
- [ ] **Step 1: Write failing natural-sort tests**
Create `src/services/naturalSort.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { sortFilesNaturally } from './naturalSort'
describe('sortFilesNaturally', () => {
it('sorts numbered filenames numerically', () => {
const files = [{ name: '10.md' }, { name: '2.md' }, { name: '1.md' }]
expect(sortFilesNaturally(files).map((file) => file.name)).toEqual([
'1.md',
'2.md',
'10.md',
])
})
it('does not mutate the supplied array', () => {
const files = [{ name: '2.md' }, { name: '1.md' }]
sortFilesNaturally(files)
expect(files.map((file) => file.name)).toEqual(['2.md', '1.md'])
})
})
```
- [ ] **Step 2: Write failing Markdown table tests**
Create `src/services/markdownTable.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { extractMarkdownTable, splitMarkdownRow } from './markdownTable'
describe('splitMarkdownRow', () => {
it('preserves escaped pipes and inline code', () => {
expect(splitMarkdownRow('| A | `x | y` | left \\| right |')).toEqual([
'A',
'`x | y`',
'left \\| right',
])
})
})
describe('extractMarkdownTable', () => {
it('returns header and body rows after the requested start line', () => {
const lines = [
'intro',
'| A | B |',
'|:--|--:|',
'| 1 | 2 |',
'',
]
expect(extractMarkdownTable(lines, 0)).toEqual({
start: 1,
end: 3,
header: ['A', 'B'],
rows: [['1', '2']],
})
})
})
```
- [ ] **Step 3: Run tests to verify they fail**
Run:
```bash
rtk npm test -- src/services/naturalSort.test.ts src/services/markdownTable.test.ts
```
Expected: FAIL because both service modules are missing.
- [ ] **Step 4: Implement natural sorting**
Create `src/services/naturalSort.ts`:
```ts
const filenameCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base',
})
export function sortFilesNaturally(files: readonly T[]): T[] {
return [...files].sort((left, right) => filenameCollator.compare(left.name, right.name))
}
```
- [ ] **Step 5: Implement the table scanner**
Create `src/services/markdownTable.ts` with a character scanner that ignores pipes inside backtick spans:
```ts
export interface MarkdownTable {
start: number
end: number
header: string[]
rows: string[][]
}
export function splitMarkdownRow(line: string): string[] {
const source = line.trim().replace(/^\|/, '').replace(/\|$/, '')
const cells: string[] = []
let current = ''
let escaped = false
let tickRun = 0
for (const character of source) {
if (escaped) {
current += character
escaped = false
continue
}
if (character === '\\') {
current += character
escaped = true
continue
}
if (character === '`') {
tickRun = tickRun === 0 ? 1 : 0
current += character
continue
}
if (character === '|' && tickRun === 0) {
cells.push(current.trim())
current = ''
continue
}
current += character
}
cells.push(current.trim())
return cells
}
function isDivider(line: string): boolean {
const cells = splitMarkdownRow(line)
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
export function extractMarkdownTable(
lines: readonly string[],
fromIndex: number,
): MarkdownTable | null {
for (let index = fromIndex; index < lines.length - 1; index += 1) {
if (!lines[index]?.trim().startsWith('|') || !isDivider(lines[index + 1] ?? '')) {
continue
}
const header = splitMarkdownRow(lines[index] ?? '')
const rows: string[][] = []
let end = index + 1
while (end + 1 < lines.length && lines[end + 1]?.trim().startsWith('|')) {
end += 1
rows.push(splitMarkdownRow(lines[end] ?? ''))
}
return { start: index, end, header, rows }
}
return null
}
```
- [ ] **Step 6: Run focused tests**
Run:
```bash
rtk npm test -- src/services/naturalSort.test.ts src/services/markdownTable.test.ts
```
Expected: all sorting and table tests pass.
- [ ] **Step 7: Commit**
```bash
rtk git add src/services/naturalSort.ts src/services/naturalSort.test.ts src/services/markdownTable.ts src/services/markdownTable.test.ts
rtk git commit -m "feat: add natural sorting and markdown table scanning"
```
## Task 3: Build the Tolerant Teaching-Design Parser
**Files:**
- Create: `src/services/markdownParser.ts`
- Create: `src/services/markdownParser.test.ts`
- Create: `src/services/markdownParser.corpus.test.ts`
- Modify: `src/services/markdownTable.ts`
- [ ] **Step 1: Write failing parser tests for standard and variant input**
Create `src/services/markdownParser.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
const standard = `# 个人主页——项目启动 教学设计
| **课题** | **个人主页——项目启动** |
|:---|:---|
| **课时** | 1课时(40分钟) |
| **教学目标** | **知识目标**:认识 HTML。
**技能目标**:创建页面。
**素养目标**:规范操作。 |
| **教学重难点** | **重点**:HTML。
**难点**:路径。 |
| **教学资源准备** | 浏览器。 |
## 教学过程
| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |
|:---|:---|:---|:---|:---|
| **1. 导入**
(6分钟) | 展示案例。 | **情境创设**
提问。 | **观察思考**
回答。 | 建立目标。 |
## 板书设计
\`\`\`text
HTML → 浏览器
\`\`\`
## 教学成效与反思
| | |
|:---|:---|
| **教学成效** | 完成页面。 |
| **教学反思** | 加强路径讲解。 |
`
describe('parseTeachingDesign', () => {
it('parses the complete teaching-design structure', () => {
const design = parseTeachingDesign('1.md', standard)
expect(design.topic).toBe('个人主页——项目启动')
expect(design.knowledgeObjective).toBe('认识 HTML。')
expect(design.processSteps[0]).toMatchObject({
name: '1. 导入',
duration: '6分钟',
content: '展示案例。',
})
expect(design.boardDesign).toContain('HTML → 浏览器')
expect(design.reflection).toBe('加强路径讲解。')
expect(design.warnings).toEqual([])
})
it('accepts half-width punctuation and reports missing sections', () => {
const markdown = standard
.replaceAll(':', ':')
.replace(/## 板书设计[\\s\\S]*?(?=## 教学成效与反思)/, '')
const design = parseTeachingDesign('8.md', markdown)
expect(design.knowledgeObjective).toBe('认识 HTML。')
expect(design.boardDesign).toBe('')
expect(design.warnings.some((warning) => warning.code === 'missing-board')).toBe(true)
})
})
```
- [ ] **Step 2: Write the corpus regression test**
Create `src/services/markdownParser.corpus.test.ts`:
```ts
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
const fixture = (path: string) =>
readFileSync(resolve(process.cwd(), path), 'utf8')
describe('teaching-design corpus', () => {
it.each([
['data/Web/1.md', '个人主页——项目启动与开发环境搭建'],
['data/Python/1.md', '智能学生选课推荐系统——项目启动与Python开发环境搭建'],
['data/C#/8.md', '智能仓储管理系统——异常处理与调试确保系统稳定运行'],
['data/C#/19.md', '智能教室环境监测系统——数据可视化与历史曲线绘制'],
])('parses %s without losing its topic', (path, topic) => {
const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path))
expect(design.topic).toBe(topic)
expect(design.processSteps.length).toBeGreaterThan(0)
})
})
```
- [ ] **Step 3: Run parser tests to verify they fail**
Run:
```bash
rtk npm test -- src/services/markdownParser.test.ts src/services/markdownParser.corpus.test.ts
```
Expected: FAIL because `parseTeachingDesign` is missing.
- [ ] **Step 4: Implement parser helpers**
Create `src/services/markdownParser.ts` with these public and private contracts:
```ts
import {
createEmptyTeachingDesign,
createTeachingStep,
type ParseWarning,
type TeachingDesign,
} from '../domain/teachingDesign'
import { extractMarkdownTable } from './markdownTable'
const BR = /
/gi
const LABEL_MARKS = /[*_`]/g
function cleanLabel(value: string): string {
return value.replace(LABEL_MARKS, '').trim()
}
function stripOuterBold(value: string): string {
return value.trim().replace(/^\\*\\*(.*?)\\*\\*$/s, '$1').trim()
}
function sectionIndex(lines: readonly string[], heading: string): number {
return lines.findIndex((line) => new RegExp(`^##\\\\s+${heading}\\\\s*$`).test(line.trim()))
}
function splitLabelledValue(
value: string,
labels: readonly string[],
): Record {
const normalized = value.replace(BR, '\\n')
const result: Record = {}
for (let index = 0; index < labels.length; index += 1) {
const label = labels[index] ?? ''
const nextLabel = labels[index + 1]
const start = normalized.search(new RegExp(`(?:\\\\*\\\\*)?${label}(?:\\\\*\\\\*)?\\\\s*[::]`))
if (start < 0) continue
const afterLabel = normalized.slice(start).replace(
new RegExp(`^(?:\\\\*\\\\*)?${label}(?:\\\\*\\\\*)?\\\\s*[::]\\\\s*`),
'',
)
const end = nextLabel
? afterLabel.search(new RegExp(`(?:\\\\*\\\\*)?${nextLabel}(?:\\\\*\\\\*)?\\\\s*[::]`))
: -1
result[label] = (end >= 0 ? afterLabel.slice(0, end) : afterLabel).trim()
}
return result
}
```
Implement `parseTeachingDesign(filename, markdown)` with this exact parsing flow:
1. normalizes CRLF to LF;
2. reads the first `#` heading and removes a trailing `教学设计`;
3. parses the first two-column table as basic information;
4. splits objective and key-point labels with full-width or half-width colons;
5. locates the teaching-process section and parses its five-column table;
6. extracts name and duration from the first process cell while preserving Markdown in other cells;
7. extracts board content between `## 板书设计` and the next level-two heading, removing only one outer fence;
8. parses the reflection table;
9. stores non-empty unclassified section text in `additionalContent`;
10. creates empty editable values and warnings for missing fields.
Use `createTeachingStep(index + 1)` for stable complete process objects, and replace the default placeholder step when at least one process row parses successfully.
- [ ] **Step 5: Run focused parser tests**
Run:
```bash
rtk npm test -- src/services/markdownParser.test.ts src/services/markdownParser.corpus.test.ts
```
Expected: all parser and selected corpus tests pass.
- [ ] **Step 6: Add all-file corpus assertions**
Extend `src/services/markdownParser.corpus.test.ts`:
```ts
import { readdirSync } from 'node:fs'
it('imports every numbered corpus file without throwing', () => {
const directories = ['data/Web', 'data/Python', 'data/C#']
const paths = directories.flatMap((directory) =>
readdirSync(resolve(process.cwd(), directory))
.filter((name) => /^\\d+\\.md$/.test(name))
.map((name) => `${directory}/${name}`),
)
expect(paths).toHaveLength(55)
for (const path of paths) {
const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path))
expect(design.topic || design.title).not.toBe('')
expect(design.originalFilename).toMatch(/\\.md$/)
}
})
```
- [ ] **Step 7: Run full parser regression**
Run:
```bash
rtk npm test -- src/services/markdownParser
```
Expected: all 55 files import without exceptions.
- [ ] **Step 8: Commit**
```bash
rtk git add src/services/markdownParser.ts src/services/markdownParser.test.ts src/services/markdownParser.corpus.test.ts src/services/markdownTable.ts
rtk git commit -m "feat: parse teaching design markdown"
```
## Task 4: Generate Markdown and Export ZIP Files
**Files:**
- Create: `src/services/markdownWriter.ts`
- Create: `src/services/markdownWriter.test.ts`
- Create: `src/services/zipExporter.ts`
- Create: `src/services/zipExporter.test.ts`
- [ ] **Step 1: Write failing Markdown round-trip tests**
Create `src/services/markdownWriter.test.ts`:
```ts
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseTeachingDesign } from './markdownParser'
import { writeTeachingDesignMarkdown } from './markdownWriter'
describe('writeTeachingDesignMarkdown', () => {
it('writes canonical sections that can be parsed again', () => {
const source = readFileSync(resolve(process.cwd(), 'data/Web/1.md'), 'utf8')
const parsed = parseTeachingDesign('1.md', source)
const output = writeTeachingDesignMarkdown(parsed)
const reparsed = parseTeachingDesign('1.md', output)
expect(output).toContain('## 板书设计')
expect(reparsed.topic).toBe(parsed.topic)
expect(reparsed.processSteps).toHaveLength(parsed.processSteps.length)
expect(reparsed.reflection).toBe(parsed.reflection)
})
it('escapes table-breaking pipes but preserves inline markdown', () => {
const source = parseTeachingDesign('1.md', readFileSync(
resolve(process.cwd(), 'data/Web/1.md'),
'utf8',
))
source.resources = '终端 | 浏览器与 `index.html`'
expect(writeTeachingDesignMarkdown(source)).toContain(
'终端 \\| 浏览器与 `index.html`',
)
})
})
```
- [ ] **Step 2: Write failing ZIP tests**
Create `src/services/zipExporter.test.ts`:
```ts
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import { createBookZip } from './zipExporter'
describe('createBookZip', () => {
it('keeps original lesson filenames and adds an order manifest', async () => {
const second = createEmptyTeachingDesign('2.md')
second.topic = '第二课'
const first = createEmptyTeachingDesign('1.md')
first.topic = '第一课'
const blob = await createBookZip([second, first])
const zip = await JSZip.loadAsync(blob)
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining(['2.md', '1.md', '课程顺序.txt']),
)
await expect(zip.file('课程顺序.txt')?.async('text')).resolves.toContain('1. 2.md')
})
})
```
- [ ] **Step 3: Run tests to verify they fail**
Run:
```bash
rtk npm test -- src/services/markdownWriter.test.ts src/services/zipExporter.test.ts
```
Expected: FAIL because writer and ZIP services are missing.
- [ ] **Step 4: Implement canonical Markdown generation**
Create `src/services/markdownWriter.ts` with:
```ts
import type { TeachingDesign } from '../domain/teachingDesign'
function escapeCell(value: string): string {
return value
.replace(/\\r?\\n/g, '
')
.replace(/(?')
}
export function writeTeachingDesignMarkdown(design: TeachingDesign): string {
const title = design.title || `${design.topic} 教学设计`
const processRows = design.processSteps.map((step) =>
`| ${escapeCell(`${step.name}
(${step.duration})`)} | ${escapeCell(step.content)} | ${escapeCell(step.teacherActivity)} | ${escapeCell(step.studentActivity)} | ${escapeCell(step.intention)} |`,
)
const sections = [
`# ${title}`,
'',
`| **课题** | **${escapeCell(design.topic)}** |`,
'|:---|:---|',
`| **课时** | ${escapeCell(design.duration)} |`,
`| **教学目标** | ${escapeCell(objectiveCell(design))} |`,
`| **教学重难点** | ${escapeCell(`**重点**:${design.keyPoint}
**难点**:${design.difficultPoint}`)} |`,
`| **教学资源准备** | ${escapeCell(design.resources)} |`,
'',
'## 教学过程',
'',
'| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |',
'|:---|:---|:---|:---|:---|',
...processRows,
'',
'## 板书设计',
'',
'```text',
design.boardDesign.trim(),
'```',
'',
'## 教学成效与反思',
'',
'| | |',
'|:---|:---|',
`| **教学成效** | ${escapeCell(design.effectiveness)} |`,
`| **教学反思** | ${escapeCell(design.reflection)} |`,
]
if (design.additionalContent.trim()) {
sections.push('', '## 附加内容', '', design.additionalContent.trim())
}
return `${sections.join('\\n')}\\n`
}
```
Adjust `escapeCell` so generated `
` tags are not double-escaped and empty durations do not render decorative parentheses.
- [ ] **Step 5: Implement ZIP creation and browser download**
Create `src/services/zipExporter.ts`:
```ts
import JSZip from 'jszip'
import type { TeachingDesign } from '../domain/teachingDesign'
import { writeTeachingDesignMarkdown } from './markdownWriter'
export async function createBookZip(designs: readonly TeachingDesign[]): Promise {
const zip = new JSZip()
const usedNames = new Set()
const order: string[] = []
designs.forEach((design, index) => {
let filename = design.originalFilename || `${index + 1}.md`
if (usedNames.has(filename)) {
const stem = filename.replace(/\\.md$/i, '')
filename = `${stem}-${index + 1}.md`
}
usedNames.add(filename)
order.push(`${index + 1}. ${filename} — ${design.topic}`)
zip.file(filename, writeTeachingDesignMarkdown(design))
})
zip.file('课程顺序.txt', `${order.join('\\n')}\\n`)
return zip.generateAsync({ type: 'blob' })
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
```
- [ ] **Step 6: Run writer and ZIP tests**
Run:
```bash
rtk npm test -- src/services/markdownWriter.test.ts src/services/zipExporter.test.ts
```
Expected: canonical round-trip and ZIP tests pass.
- [ ] **Step 7: Commit**
```bash
rtk git add src/services/markdownWriter.ts src/services/markdownWriter.test.ts src/services/zipExporter.ts src/services/zipExporter.test.ts
rtk git commit -m "feat: export teaching designs as markdown zip"
```
## Task 5: Add Versioned Autosave and Book State
**Files:**
- Create: `src/services/bookStorage.ts`
- Create: `src/services/bookStorage.test.ts`
- Create: `src/composables/useTeachingBook.ts`
- Create: `src/composables/useTeachingBook.test.ts`
- [ ] **Step 1: Write failing storage tests**
Create `src/services/bookStorage.test.ts`:
```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { createEmptyBook } from '../domain/teachingDesign'
import { clearStoredBook, loadStoredBook, saveBook } from './bookStorage'
describe('bookStorage', () => {
beforeEach(() => localStorage.clear())
it('round-trips a versioned book', () => {
const book = createEmptyBook()
book.cover.courseName = 'Web 前端开发'
expect(saveBook(book)).toEqual({ ok: true })
expect(loadStoredBook()?.cover.courseName).toBe('Web 前端开发')
})
it('returns null for malformed storage', () => {
localStorage.setItem('teaching-design-book', '{bad json')
expect(loadStoredBook()).toBeNull()
})
it('clears saved work', () => {
saveBook(createEmptyBook())
clearStoredBook()
expect(loadStoredBook()).toBeNull()
})
})
```
- [ ] **Step 2: Write failing composable workflow tests**
Create `src/composables/useTeachingBook.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTeachingBook } from './useTeachingBook'
describe('useTeachingBook', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
})
it('imports files in natural order and selects the first lesson', async () => {
const store = useTeachingBook()
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 () => {
const store = useTeachingBook()
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())
})
})
```
- [ ] **Step 3: Run tests to verify they fail**
Run:
```bash
rtk npm test -- src/services/bookStorage.test.ts src/composables/useTeachingBook.test.ts
```
Expected: FAIL because storage and composable modules are missing.
- [ ] **Step 4: Implement versioned storage**
Create `src/services/bookStorage.ts`:
```ts
import {
BOOK_SCHEMA_VERSION,
type TeachingBook,
} from '../domain/teachingDesign'
const STORAGE_KEY = 'teaching-design-book'
export type SaveResult =
| { ok: true }
| { ok: false; message: string }
export function saveBook(book: TeachingBook): SaveResult {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(book))
return { ok: true }
} catch {
return { ok: false, message: '浏览器存储空间不足,当前修改尚未暂存。' }
}
}
export function loadStoredBook(): TeachingBook | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as TeachingBook
return parsed.schemaVersion === BOOK_SCHEMA_VERSION ? parsed : null
} catch {
return null
}
}
export function clearStoredBook(): void {
localStorage.removeItem(STORAGE_KEY)
}
```
- [ ] **Step 5: Implement the book composable**
Create `src/composables/useTeachingBook.ts` exposing:
```ts
export type DuplicateStrategy = 'replace' | 'keep'
export interface ImportResult {
imported: number
failed: Array<{ filename: string; message: string }>
duplicates: string[]
}
export function useTeachingBook() {
// refs: book, saveStatus, lastError, pendingDuplicateFiles
// computed: selectedDesign, hasDesigns, warningCount
// methods:
// importFiles(files, strategy)
// detectDuplicates(files)
// selectPage(id)
// moveDesign(from, to)
// removeDesign(id)
// updateCover(patch)
// updateDesign(id, updater)
// restore(book)
// clearBook()
}
```
Implement the composable with these exact behaviors:
- read files with `await file.text()`;
- reject non-`.md` files individually;
- sort newly read files with `sortFilesNaturally`;
- call `parseTeachingDesign`;
- with `replace`, replace the existing design at the same list position;
- with `keep`, retain both records and let ZIP export disambiguate duplicate names;
- debounce `saveBook` by 300 ms after reactive book changes;
- expose save status as `'idle' | 'saving' | 'saved' | 'error'`;
- never discard successful imports when another file fails;
- update `updatedAt` on meaningful edits.
- [ ] **Step 6: Run storage and composable tests**
Run:
```bash
rtk npm test -- src/services/bookStorage.test.ts src/composables/useTeachingBook.test.ts
```
Expected: autosave service and core import/reorder workflows pass.
- [ ] **Step 7: Commit**
```bash
rtk git add src/services/bookStorage.ts src/services/bookStorage.test.ts src/composables/useTeachingBook.ts src/composables/useTeachingBook.test.ts
rtk git commit -m "feat: manage and autosave teaching books"
```
## Task 6: Create Editable A4 Page Components
**Files:**
- Create: `src/services/markdownRenderer.ts`
- Create: `src/components/EditableText.vue`
- Create: `src/components/EditableText.test.ts`
- Create: `src/components/EditableMarkdown.vue`
- Create: `src/components/EditableMarkdown.test.ts`
- Create: `src/components/CoverPage.vue`
- Create: `src/components/TeachingDesignPage.vue`
- Create: `src/components/TeachingDesignPage.test.ts`
- [ ] **Step 1: Write failing editable-field tests**
Create `src/components/EditableText.test.ts`:
```ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
it('emits updates while keeping an accessible label', async () => {
const wrapper = mount(EditableText, {
props: { modelValue: '旧内容', label: '课题' },
})
await wrapper.get('textarea').setValue('新内容')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['新内容'])
expect(wrapper.get('textarea').attributes('aria-label')).toBe('课题')
})
})
```
Create `src/components/EditableMarkdown.test.ts`:
```ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import EditableMarkdown from './EditableMarkdown.vue'
describe('EditableMarkdown', () => {
it('renders markdown when blurred and edits raw markdown when activated', async () => {
const wrapper = mount(EditableMarkdown, {
props: { modelValue: '**重点**内容', label: '教师活动' },
})
expect(wrapper.get('.markdown-preview strong').text()).toBe('重点')
await wrapper.get('.markdown-preview').trigger('click')
expect(wrapper.get('textarea').element.value).toBe('**重点**内容')
})
})
```
- [ ] **Step 2: Write the failing teaching-page edit test**
Create `src/components/TeachingDesignPage.test.ts`:
```ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import TeachingDesignPage from './TeachingDesignPage.vue'
describe('TeachingDesignPage', () => {
it('adds and removes teaching process rows', async () => {
const design = createEmptyTeachingDesign('1.md')
const wrapper = mount(TeachingDesignPage, {
props: { design, editable: true },
})
await wrapper.get('[data-testid="add-step"]').trigger('click')
expect(wrapper.emitted('update:design')?.at(-1)?.[0].processSteps).toHaveLength(2)
})
})
```
- [ ] **Step 3: Run component tests to verify they fail**
Run:
```bash
rtk npm test -- src/components/EditableText.test.ts src/components/EditableMarkdown.test.ts src/components/TeachingDesignPage.test.ts
```
Expected: FAIL because the components are missing.
- [ ] **Step 4: Implement safe Markdown rendering**
Create `src/services/markdownRenderer.ts`:
```ts
import MarkdownIt from 'markdown-it'
const renderer = new MarkdownIt({
html: false,
breaks: true,
linkify: false,
typographer: false,
})
export function renderMarkdown(value: string): string {
return renderer.render(value || '')
}
```
- [ ] **Step 5: Implement editable fields**
`EditableText.vue` must:
- render an auto-growing `