update
This commit is contained in:
26
src/services/bookStorage.test.ts
Normal file
26
src/services/bookStorage.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
29
src/services/bookStorage.ts
Normal file
29
src/services/bookStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
35
src/services/markdownParser.corpus.test.ts
Normal file
35
src/services/markdownParser.corpus.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync, readdirSync } 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)
|
||||
})
|
||||
|
||||
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$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
82
src/services/markdownParser.test.ts
Normal file
82
src/services/markdownParser.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseTeachingDesign } from './markdownParser'
|
||||
|
||||
const standard = `# 个人主页——项目启动 教学设计
|
||||
|
||||
| **课题** | **个人主页——项目启动** |
|
||||
|:---|:---|
|
||||
| **课时** | 1课时(40分钟) |
|
||||
| **教学目标** | **知识目标**:认识 HTML。<br>**技能目标**:创建页面。<br>**素养目标**:规范操作。 |
|
||||
| **教学重难点** | **重点**:HTML。<br>**难点**:路径。 |
|
||||
| **教学资源准备** | 浏览器。 |
|
||||
|
||||
## 教学过程
|
||||
|
||||
| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| **1. 导入**<br>(6分钟) | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |
|
||||
|
||||
## 板书设计
|
||||
|
||||
\`\`\`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)
|
||||
})
|
||||
|
||||
it('parses process steps where the step number is outside the bold name', () => {
|
||||
const markdown = standard.replace(
|
||||
'| **1. 导入**<br>(6分钟) | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |',
|
||||
'| 1. **导入**<br>(6分钟) | 展示案例。 | **情境创设**<br>提问。 | **观察思考**<br>回答。 | 建立目标。 |',
|
||||
)
|
||||
|
||||
const design = parseTeachingDesign('1.md', markdown)
|
||||
|
||||
expect(design.processSteps[0]).toMatchObject({
|
||||
name: '1. 导入',
|
||||
duration: '6分钟',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a missing title when no level-one heading exists', () => {
|
||||
const markdown = standard.replace('# 个人主页——项目启动 教学设计\n\n', '')
|
||||
|
||||
const design = parseTeachingDesign('1.md', markdown)
|
||||
|
||||
expect(design.warnings.some((warning) => warning.code === 'missing-title')).toBe(true)
|
||||
})
|
||||
})
|
||||
282
src/services/markdownParser.ts
Normal file
282
src/services/markdownParser.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
createEmptyTeachingDesign,
|
||||
createTeachingStep,
|
||||
type ParseWarning,
|
||||
type TeachingDesign,
|
||||
type TeachingStep,
|
||||
} from '../domain/teachingDesign'
|
||||
import { extractMarkdownTable } from './markdownTable'
|
||||
|
||||
const BR = /<br\s*\/?>/gi
|
||||
const LABEL_MARKS = /[*_`]/g
|
||||
const COLON = /[::]\s*$/
|
||||
const PAREN_DURATION = /[((]([^()()]*)[))]/
|
||||
|
||||
const KNOWN_SECTION_HEADINGS = new Set(['教学过程', '板书设计', '教学成效与反思'])
|
||||
|
||||
function cleanLabel(value: string): string {
|
||||
return value.replace(LABEL_MARKS, '').trim()
|
||||
}
|
||||
|
||||
function stripOuterBold(value: string): string {
|
||||
return value.trim().replace(/^\*\*([\s\S]*)\*\*$/, '$1').trim()
|
||||
}
|
||||
|
||||
function normalizeMultiline(value: string): string {
|
||||
return value.replace(BR, '\n').trim()
|
||||
}
|
||||
|
||||
function isSectionHeading(line: string, heading: string): boolean {
|
||||
const trimmed = line.trim()
|
||||
return (
|
||||
new RegExp(`^##\\s+${heading}\\s*$`).test(trimmed) || trimmed === `**${heading}**`
|
||||
)
|
||||
}
|
||||
|
||||
function findSectionIndex(lines: readonly string[], heading: string, fromIndex = 0): number {
|
||||
for (let index = fromIndex; index < lines.length; index += 1) {
|
||||
if (isSectionHeading(lines[index] ?? '', heading)) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function isAnyHeading(line: string): boolean {
|
||||
const trimmed = line.trim()
|
||||
return /^##\s+\S/.test(trimmed) || /^\*\*[^*]+\*\*$/.test(trimmed)
|
||||
}
|
||||
|
||||
function findNextHeadingIndex(lines: readonly string[], fromIndex: number): number {
|
||||
for (let index = fromIndex; index < lines.length; index += 1) {
|
||||
if (isAnyHeading(lines[index] ?? '')) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function headingName(line: string): string {
|
||||
const trimmed = line.trim()
|
||||
const levelTwo = trimmed.match(/^##\s+(.+)$/)
|
||||
if (levelTwo) return levelTwo[1]!.trim()
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
function splitLabelledValue(value: string, labels: readonly string[]): Record<string, string> {
|
||||
const normalized = value.replace(BR, '\n')
|
||||
const alternation = labels.join('|')
|
||||
const pattern = new RegExp(`(?:\\*\\*(?:${alternation})\\*\\*|(?:${alternation}))\\s*[::]`, 'g')
|
||||
const matches = [...normalized.matchAll(pattern)]
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
matches.forEach((match, index) => {
|
||||
const label = cleanLabel(match[0].replace(COLON, ''))
|
||||
const start = match.index + match[0].length
|
||||
const end = index + 1 < matches.length ? matches[index + 1]!.index : normalized.length
|
||||
result[label] = normalized.slice(start, end).trim()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function parseStepNameCell(cell: string, fallbackIndex: number): { name: string; duration: string } {
|
||||
const normalized = cell.replace(BR, '\n')
|
||||
const parts = normalized
|
||||
.split('\n')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let namePart = parts[0] ?? ''
|
||||
let durationPart = parts[1] ?? ''
|
||||
let duration = ''
|
||||
|
||||
const durationMatch = (durationPart || namePart).match(PAREN_DURATION)
|
||||
if (durationMatch) {
|
||||
duration = durationMatch[1]!.trim()
|
||||
if (durationPart) {
|
||||
durationPart = ''
|
||||
} else {
|
||||
namePart = namePart.replace(durationMatch[0], '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
const name = cleanLabel(namePart) || createTeachingStep(fallbackIndex).name
|
||||
return { name, duration }
|
||||
}
|
||||
|
||||
function extractBoardContent(sectionLines: readonly string[]): string {
|
||||
const fenceStart = sectionLines.findIndex((line) => /^\s*(`{3,}|~{3,})/.test(line))
|
||||
if (fenceStart < 0) {
|
||||
return sectionLines.join('\n').trim()
|
||||
}
|
||||
|
||||
const fenceMatch = sectionLines[fenceStart]!.match(/^\s*(`{3,}|~{3,})/)!
|
||||
const fenceChar = fenceMatch[1]![0]!
|
||||
const fenceLength = fenceMatch[1]!.length
|
||||
let fenceEnd = sectionLines.length
|
||||
|
||||
for (let index = fenceStart + 1; index < sectionLines.length; index += 1) {
|
||||
const close = sectionLines[index]!.match(/^\s*(`+|~+)\s*$/)
|
||||
if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) {
|
||||
fenceEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sectionLines.slice(fenceStart + 1, fenceEnd).join('\n').trim()
|
||||
}
|
||||
|
||||
export function parseTeachingDesign(filename: string, markdown: string): TeachingDesign {
|
||||
const design = createEmptyTeachingDesign(filename)
|
||||
const warnings: ParseWarning[] = []
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
|
||||
const titleLineIndex = lines.findIndex((line) => /^#\s+\S/.test(line.trim()))
|
||||
let headingTitle = ''
|
||||
if (titleLineIndex >= 0) {
|
||||
headingTitle = lines[titleLineIndex]!.trim().replace(/^#\s+/, '').trim()
|
||||
} else {
|
||||
warnings.push({ code: 'missing-title', message: '未找到课程标题(一级标题)。' })
|
||||
}
|
||||
|
||||
const basicTable = extractMarkdownTable(lines, titleLineIndex + 1)
|
||||
const basicFieldsFound = new Set<string>()
|
||||
|
||||
if (basicTable) {
|
||||
for (const row of [basicTable.header, ...basicTable.rows]) {
|
||||
const label = cleanLabel(row[0] ?? '')
|
||||
const value = (row[1] ?? '').trim()
|
||||
|
||||
switch (label) {
|
||||
case '课题':
|
||||
design.topic = stripOuterBold(value)
|
||||
basicFieldsFound.add('topic')
|
||||
break
|
||||
case '课时':
|
||||
design.duration = value
|
||||
basicFieldsFound.add('duration')
|
||||
break
|
||||
case '教学目标': {
|
||||
const objectives = splitLabelledValue(value, ['知识目标', '技能目标', '素养目标'])
|
||||
design.knowledgeObjective = objectives['知识目标'] ?? ''
|
||||
design.skillObjective = objectives['技能目标'] ?? ''
|
||||
design.literacyObjective = objectives['素养目标'] ?? ''
|
||||
basicFieldsFound.add('objectives')
|
||||
break
|
||||
}
|
||||
case '教学重难点': {
|
||||
const points = splitLabelledValue(value, ['重点', '难点'])
|
||||
design.keyPoint = points['重点'] ?? ''
|
||||
design.difficultPoint = points['难点'] ?? ''
|
||||
basicFieldsFound.add('points')
|
||||
break
|
||||
}
|
||||
case '教学资源准备':
|
||||
design.resources = value
|
||||
basicFieldsFound.add('resources')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!basicTable) {
|
||||
warnings.push({ code: 'missing-basic-field', message: '未找到基本信息表格。' })
|
||||
} else {
|
||||
const requiredFields: Array<[string, string]> = [
|
||||
['topic', '课题'],
|
||||
['duration', '课时'],
|
||||
['objectives', '教学目标'],
|
||||
['points', '教学重难点'],
|
||||
['resources', '教学资源准备'],
|
||||
]
|
||||
for (const [key, label] of requiredFields) {
|
||||
if (!basicFieldsFound.has(key)) {
|
||||
warnings.push({ code: 'missing-basic-field', message: `缺少"${label}"信息。` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const titleWithoutSuffix = headingTitle.replace(/\s*教学设计\s*$/, '').trim()
|
||||
design.title = titleWithoutSuffix && titleWithoutSuffix !== design.topic ? headingTitle : ''
|
||||
|
||||
const processIndex = findSectionIndex(lines, '教学过程', titleLineIndex + 1)
|
||||
if (processIndex < 0) {
|
||||
warnings.push({ code: 'missing-process', message: '未找到教学过程章节。' })
|
||||
} else {
|
||||
const processTable = extractMarkdownTable(lines, processIndex + 1)
|
||||
if (!processTable || processTable.header.length < 5) {
|
||||
warnings.push({ code: 'invalid-process-table', message: '教学过程表格格式不正确。' })
|
||||
} else {
|
||||
const steps: TeachingStep[] = []
|
||||
processTable.rows.forEach((row, index) => {
|
||||
if (row.length < 5) return
|
||||
const [nameCell, content, teacherActivity, studentActivity, intention] = row
|
||||
const { name, duration } = parseStepNameCell(nameCell ?? '', index + 1)
|
||||
steps.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
duration,
|
||||
content: normalizeMultiline(content ?? ''),
|
||||
teacherActivity: normalizeMultiline(teacherActivity ?? ''),
|
||||
studentActivity: normalizeMultiline(studentActivity ?? ''),
|
||||
intention: normalizeMultiline(intention ?? ''),
|
||||
})
|
||||
})
|
||||
|
||||
if (steps.length > 0) {
|
||||
design.processSteps = steps
|
||||
} else {
|
||||
warnings.push({ code: 'invalid-process-table', message: '教学过程表格中没有有效的环节行。' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boardIndex = findSectionIndex(lines, '板书设计', titleLineIndex + 1)
|
||||
if (boardIndex < 0) {
|
||||
warnings.push({ code: 'missing-board', message: '未找到板书设计章节。' })
|
||||
} else {
|
||||
const nextHeadingIndex = findNextHeadingIndex(lines, boardIndex + 1)
|
||||
const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex
|
||||
design.boardDesign = extractBoardContent(lines.slice(boardIndex + 1, sectionEnd))
|
||||
}
|
||||
|
||||
const reflectionIndex = findSectionIndex(lines, '教学成效与反思', titleLineIndex + 1)
|
||||
if (reflectionIndex < 0) {
|
||||
warnings.push({ code: 'missing-reflection', message: '未找到教学成效与反思章节。' })
|
||||
} else {
|
||||
const reflectionTable = extractMarkdownTable(lines, reflectionIndex + 1)
|
||||
if (!reflectionTable) {
|
||||
warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' })
|
||||
} else {
|
||||
for (const row of reflectionTable.rows) {
|
||||
const label = cleanLabel(row[0] ?? '')
|
||||
const value = normalizeMultiline(row[1] ?? '')
|
||||
if (label === '教学成效') design.effectiveness = value
|
||||
if (label === '教学反思') design.reflection = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const additionalParts: string[] = []
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index]!
|
||||
if (!isAnyHeading(line)) continue
|
||||
const name = headingName(line)
|
||||
if (KNOWN_SECTION_HEADINGS.has(name)) continue
|
||||
|
||||
const nextHeadingIndex = findNextHeadingIndex(lines, index + 1)
|
||||
const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex
|
||||
const content = lines.slice(index + 1, sectionEnd).join('\n').trim()
|
||||
|
||||
if (content) {
|
||||
additionalParts.push(`## ${name}\n\n${content}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalParts.length > 0) {
|
||||
design.additionalContent = additionalParts.join('\n\n')
|
||||
warnings.push({ code: 'unclassified-content', message: '存在未识别的章节内容。' })
|
||||
}
|
||||
|
||||
design.warnings = warnings
|
||||
return design
|
||||
}
|
||||
12
src/services/markdownRenderer.ts
Normal file
12
src/services/markdownRenderer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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 || '')
|
||||
}
|
||||
@@ -101,23 +101,65 @@ export function splitMarkdownRow(row: string): string[] {
|
||||
|
||||
const dividerCellPattern = /^:?-{3,}:?$/
|
||||
|
||||
function startsWithPipe(line: string): boolean {
|
||||
const FENCE_OPEN_PATTERN = / {0,3}(`{3,}|~{3,})/
|
||||
const FENCE_CLOSE_PATTERN = / {0,3}(`+|~+)\s*$/
|
||||
|
||||
function isTableRow(line: string): boolean {
|
||||
const leading = line.match(/^[ \t]*/)?.[0] ?? ''
|
||||
if (leading.includes('\t') || leading.length >= 4) {
|
||||
return false
|
||||
}
|
||||
return line.trimStart().startsWith('|')
|
||||
}
|
||||
|
||||
function computeFenceMask(lines: readonly string[]): boolean[] {
|
||||
const mask = new Array<boolean>(lines.length).fill(false)
|
||||
let fenceChar: string | null = null
|
||||
let fenceLength = 0
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index]!
|
||||
|
||||
if (fenceChar === null) {
|
||||
const open = line.match(new RegExp(`^${FENCE_OPEN_PATTERN.source}`))
|
||||
if (open) {
|
||||
mask[index] = true
|
||||
fenceChar = open[1]![0]!
|
||||
fenceLength = open[1]!.length
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mask[index] = true
|
||||
const close = line.match(new RegExp(`^${FENCE_CLOSE_PATTERN.source}`))
|
||||
if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) {
|
||||
fenceChar = null
|
||||
fenceLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
return mask
|
||||
}
|
||||
|
||||
export function extractMarkdownTable(
|
||||
lines: readonly string[],
|
||||
fromIndex = 0,
|
||||
): MarkdownTable | null {
|
||||
const insideFence = computeFenceMask(lines)
|
||||
|
||||
for (
|
||||
let start = Math.max(0, fromIndex);
|
||||
start < lines.length - 1;
|
||||
start++
|
||||
) {
|
||||
if (insideFence[start] || insideFence[start + 1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const headerLine = lines[start]!
|
||||
const dividerLine = lines[start + 1]!
|
||||
|
||||
if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) {
|
||||
if (!isTableRow(headerLine) || !isTableRow(dividerLine)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -135,7 +177,11 @@ export function extractMarkdownTable(
|
||||
const rows: string[][] = []
|
||||
let end = start + 1
|
||||
|
||||
while (end + 1 < lines.length && startsWithPipe(lines[end + 1]!)) {
|
||||
while (
|
||||
end + 1 < lines.length &&
|
||||
!insideFence[end + 1] &&
|
||||
isTableRow(lines[end + 1]!)
|
||||
) {
|
||||
end++
|
||||
rows.push(splitMarkdownRow(lines[end]!))
|
||||
}
|
||||
|
||||
46
src/services/markdownWriter.test.ts
Normal file
46
src/services/markdownWriter.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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`',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits decorative parentheses for steps without a duration', () => {
|
||||
const source = parseTeachingDesign('1.md', readFileSync(
|
||||
resolve(process.cwd(), 'data/Web/1.md'),
|
||||
'utf8',
|
||||
))
|
||||
source.processSteps[0]!.duration = ''
|
||||
source.processSteps[0]!.name = '1. 导入'
|
||||
|
||||
const output = writeTeachingDesignMarkdown(source)
|
||||
|
||||
expect(output).toContain('**1. 导入**')
|
||||
expect(output).not.toContain('**1. 导入**<br>()')
|
||||
expect(output).not.toContain('**1. 导入**()')
|
||||
})
|
||||
})
|
||||
76
src/services/markdownWriter.ts
Normal file
76
src/services/markdownWriter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
|
||||
function escapeCell(value: string): string {
|
||||
return value
|
||||
.replace(/\r?\n/g, '<br>')
|
||||
.replace(/(?<!\\)\|/g, '\\|')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function objectiveCell(design: TeachingDesign): string {
|
||||
return [
|
||||
`**知识目标**:${design.knowledgeObjective}`,
|
||||
`**技能目标**:${design.skillObjective}`,
|
||||
`**素养目标**:${design.literacyObjective}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function keyPointCell(design: TeachingDesign): string {
|
||||
return [`**重点**:${design.keyPoint}`, `**难点**:${design.difficultPoint}`].join('\n')
|
||||
}
|
||||
|
||||
function processNameCell(step: TeachingDesign['processSteps'][number]): string {
|
||||
return step.duration ? `**${step.name}**\n(${step.duration})` : `**${step.name}**`
|
||||
}
|
||||
|
||||
export function writeTeachingDesignMarkdown(design: TeachingDesign): string {
|
||||
const title = design.title || `${design.topic} 教学设计`
|
||||
|
||||
const processRows = design.processSteps.map((step) =>
|
||||
[
|
||||
escapeCell(processNameCell(step)),
|
||||
escapeCell(step.content),
|
||||
escapeCell(step.teacherActivity),
|
||||
escapeCell(step.studentActivity),
|
||||
escapeCell(step.intention),
|
||||
]
|
||||
.map((cell) => `| ${cell}`)
|
||||
.join(' ') + ' |',
|
||||
)
|
||||
|
||||
const sections = [
|
||||
`# ${title}`,
|
||||
'',
|
||||
`| **课题** | **${escapeCell(design.topic)}** |`,
|
||||
'|:---|:---|',
|
||||
`| **课时** | ${escapeCell(design.duration)} |`,
|
||||
`| **教学目标** | ${escapeCell(objectiveCell(design))} |`,
|
||||
`| **教学重难点** | ${escapeCell(keyPointCell(design))} |`,
|
||||
`| **教学资源准备** | ${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`
|
||||
}
|
||||
35
src/services/zipExporter.test.ts
Normal file
35
src/services/zipExporter.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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')
|
||||
})
|
||||
|
||||
it('disambiguates duplicate filenames', async () => {
|
||||
const first = createEmptyTeachingDesign('1.md')
|
||||
first.topic = '第一课甲'
|
||||
const duplicate = createEmptyTeachingDesign('1.md')
|
||||
duplicate.topic = '第一课乙'
|
||||
|
||||
const blob = await createBookZip([first, duplicate])
|
||||
const zip = await JSZip.loadAsync(blob)
|
||||
|
||||
expect(Object.keys(zip.files)).toEqual(
|
||||
expect.arrayContaining(['1.md', '1-2.md', '课程顺序.txt']),
|
||||
)
|
||||
})
|
||||
})
|
||||
32
src/services/zipExporter.ts
Normal file
32
src/services/zipExporter.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
import { writeTeachingDesignMarkdown } from './markdownWriter'
|
||||
|
||||
export async function createBookZip(designs: readonly TeachingDesign[]): Promise<Blob> {
|
||||
const zip = new JSZip()
|
||||
const usedNames = new Set<string>()
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user