Files
teaching-design/src/services/bookImport.ts
yuetsh a427bba8c8 feat: 从书籍生成教案(粘贴目录 + 上传 md 压缩包)
新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.md 的 ZIP(文件名=目录标题),前端用 JSZip 解压匹配,AI 按章节
体量划分课时,预览/编辑后批量生成,每篇以对应章节正文作为上下文。

服务端
- generate.ts: 新增 POST /lessons-from-book(目录条目→AI 划分课时
  JSON,剥围栏/校验/丢非法行);扩展 POST / 支持可选 content 上下文,
  无 content 时请求体不变(向后兼容)。

前端
- bookImport.ts: normalizeTitle / parseZip / matchToc / assembleContent。
- booksApi.ts: generateLesson 兼容签名 + divideLessonsFromBook。
- useTeachingBook.ts: 抽出 runGenerationPool,新增 generateLessonsFromBook。
- BookImportDialog.vue 多阶段对话框;UploadDropzone 加 accept/multiple;
  菜单串接「从书籍生成」。

顺带修复 5 个遗留失败测试:import 功能已于 fb0b8d1 删除但测试漏删,
清理孤儿测试;批量生成对齐为单参 generateLesson(topic)。

docs/book-import-spec.md 记录规格。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:07:19 -06:00

76 lines
2.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import JSZip from 'jszip'
/** 目录中的一行,匹配到正文后携带内容与体量。 */
export interface BookEntry {
index: number
title: string
content: string
charCount: number
matched: boolean
}
/** AI 划分出的课时,引用其所辖的目录条目下标。 */
export interface ProposedLesson {
title: string
sourceIndexes: number[]
}
/**
* 归一化标题/文件名以便匹配:去 .md 后缀、trim、折叠空白、
* 把 sanitizeFilename 会替换的非法字符统一成下划线(与服务端一致)。
*/
export function normalizeTitle(value: string): string {
return value
.replace(/\.md$/i, '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
}
/** 解压 ZIP按归一化文件名建立 { 标题 → 正文 } 映射(仅取 .md 文件)。 */
export async function parseZip(file: File | Blob): Promise<Map<string, string>> {
const zip = await JSZip.loadAsync(file)
const map = new Map<string, string>()
const entries = Object.values(zip.files).filter(
(entry) => !entry.dir && /\.md$/i.test(entry.name),
)
for (const entry of entries) {
// 文件名可能带目录前缀,只取最后一段。
const base = entry.name.split('/').pop() ?? entry.name
const key = normalizeTitle(base)
if (key) {
map.set(key, await entry.async('text'))
}
}
return map
}
/** 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。 */
export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] {
return tocText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title, index) => {
const content = contentMap.get(normalizeTitle(title)) ?? ''
return {
index,
title,
content,
charCount: content.length,
matched: content !== '',
}
})
}
/** 按课时的 sourceIndexes 拼接其所辖目录条目的正文。 */
export function assembleContent(lesson: ProposedLesson, entries: readonly BookEntry[]): string {
return lesson.sourceIndexes
.map((i) => entries.find((entry) => entry.index === i)?.content ?? '')
.filter(Boolean)
.join('\n\n')
}