This commit is contained in:
2026-06-15 01:48:03 -06:00
parent 2bd1e0399a
commit 379ff41947
40 changed files with 2669 additions and 360 deletions

View 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()
})
})

View 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)
}

View 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$/)
}
})
})

View 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)
})
})

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

View 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 || '')
}

View File

@@ -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]!))
}

View 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. 导入**')
})
})

View 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`
}

View 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']),
)
})
})

View 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)
}