新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.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>
76 lines
2.2 KiB
TypeScript
76 lines
2.2 KiB
TypeScript
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')
|
||
}
|