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>
This commit is contained in:
2026-06-24 05:07:19 -06:00
parent b18bde8d2a
commit a427bba8c8
19 changed files with 1101 additions and 48 deletions

View File

@@ -0,0 +1,75 @@
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')
}