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,207 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import * as booksApi from '../services/booksApi'
import {
assembleContent,
matchToc,
parseZip,
type BookEntry,
type ProposedLesson,
} from '../services/bookImport'
import type { BookLessonInput } from '../composables/useTeachingBook'
import UploadDropzone from './UploadDropzone.vue'
type Phase = 'input' | 'division-loading' | 'preview' | 'running' | 'done' | 'error'
const props = defineProps<{
running: boolean
done: number
total: number
currentTopic: string
error: string | null
}>()
const emit = defineEmits<{
start: [lessons: BookLessonInput[]]
cancel: []
close: []
}>()
const phase = ref<Phase>('input')
const tocText = ref('')
const zipFile = ref<File | null>(null)
const inputError = ref<string | null>(null)
const entries = ref<BookEntry[]>([])
const lessons = ref<ProposedLesson[]>([])
const matchedCount = computed(() => entries.value.filter((e) => e.matched).length)
watch(
() => props.running,
(val) => {
if (!val && phase.value === 'running') {
phase.value = props.error ? 'error' : 'done'
}
},
)
function onFiles(files: File[]): void {
const file = files[0]
if (!file) return
if (!/\.zip$/i.test(file.name)) {
inputError.value = '请上传 .zip 压缩包。'
return
}
inputError.value = null
zipFile.value = file
}
function chapterTitlesOf(lesson: ProposedLesson): string {
return lesson.sourceIndexes
.map((i) => entries.value.find((e) => e.index === i)?.title ?? '')
.filter(Boolean)
.join('、')
}
async function handleAnalyze(): Promise<void> {
if (!tocText.value.trim() || !zipFile.value) return
inputError.value = null
phase.value = 'division-loading'
try {
const map = await parseZip(zipFile.value)
entries.value = matchToc(tocText.value, map)
} catch {
inputError.value = '压缩包解析失败,请确认是有效的 .zip 文件。'
phase.value = 'input'
return
}
if (entries.value.length === 0) {
inputError.value = '目录为空。'
phase.value = 'input'
return
}
try {
const result = await booksApi.divideLessonsFromBook(
entries.value.map((e) => ({ index: e.index, title: e.title, charCount: e.charCount })),
)
lessons.value = result.lessons
phase.value = 'preview'
} catch (error) {
inputError.value = error instanceof Error ? error.message : 'AI 划分课时失败。'
phase.value = 'input'
}
}
function removeLesson(index: number): void {
lessons.value.splice(index, 1)
}
function handleStart(): void {
const payload: BookLessonInput[] = lessons.value
.filter((lesson) => lesson.title.trim() !== '')
.map((lesson) => ({
title: lesson.title.trim(),
content: assembleContent(lesson, entries.value),
}))
if (payload.length === 0) return
phase.value = 'running'
emit('start', payload)
}
function handleClose(): void {
phase.value = 'input'
tocText.value = ''
zipFile.value = null
inputError.value = null
entries.value = []
lessons.value = []
emit('close')
}
</script>
<template>
<div class="dialog-overlay" role="dialog" aria-modal="true" aria-labelledby="book-import-title">
<div class="dialog batch-dialog">
<h2 id="book-import-title">从书籍生成教案</h2>
<!-- 第一步粘贴目录 + 上传 ZIP -->
<template v-if="phase === 'input'">
<p>粘贴教材目录每行一个标题并上传内含各章 .md ZIP文件名为目录标题AI 将据此划分课时</p>
<p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p>
<textarea
v-model="tocText"
class="batch-topics-input"
rows="12"
placeholder="第1章 C# 入门&#10;第2章 变量与数据类型&#10;第3章 流程控制"
/>
<UploadDropzone accept=".zip,application/zip" :multiple="false" @files="onFiles" />
<p v-if="zipFile" class="book-import-file">已选{{ zipFile.name }}</p>
<div class="dialog-actions">
<button type="button" :disabled="!tocText.trim() || !zipFile" @click="handleAnalyze">
解析并划分课时
</button>
<button type="button" @click="handleClose">取消</button>
</div>
</template>
<!-- AI 划分中 -->
<template v-else-if="phase === 'division-loading'">
<p>AI 正在划分课时</p>
</template>
<!-- 第二步预览/编辑课时 -->
<template v-else-if="phase === 'preview'">
<p>
目录共 {{ entries.length }} 匹配到正文 {{ matchedCount }} AI 划分出以下课时可编辑标题或删除后开始生成
</p>
<ul class="book-import-lessons">
<li v-for="(lesson, index) in lessons" :key="index" class="book-import-lesson">
<input v-model="lesson.title" type="text" class="book-import-lesson-title" />
<span class="book-import-lesson-source">{{ chapterTitlesOf(lesson) || '(无匹配正文)' }}</span>
<button type="button" class="book-import-lesson-remove" @click="removeLesson(index)">删除</button>
</li>
</ul>
<p class="batch-topics-count"> {{ lessons.length }} 个课时</p>
<div class="dialog-actions">
<button type="button" :disabled="lessons.length === 0" @click="handleStart">开始生成</button>
<button type="button" @click="phase = 'input'">重新选择</button>
</div>
</template>
<!-- 生成中 -->
<template v-else-if="phase === 'running'">
<p class="batch-progress-label">
正在生成第 <strong>{{ done + 1 }}</strong> / {{ total }}
</p>
<p class="batch-current-topic">{{ currentTopic }}</p>
<div class="batch-progress-bar">
<div class="batch-progress-fill" :style="{ width: `${(done / total) * 100}%` }" />
</div>
<div class="dialog-actions">
<button type="button" @click="emit('cancel')">停止</button>
</div>
</template>
<!-- 出错 -->
<template v-else-if="phase === 'error'">
<p class="app-notice app-notice--error" role="alert">{{ error }}</p>
<p>已生成 {{ done }} / {{ total }} 生成中止</p>
<div class="dialog-actions">
<button type="button" @click="handleClose">关闭</button>
</div>
</template>
<!-- 完成 -->
<template v-else-if="phase === 'done'">
<p>已生成 <strong>{{ done }}</strong> / {{ total }} 篇教案</p>
<div class="dialog-actions">
<button type="button" @click="handleClose">关闭</button>
</div>
</template>
</div>
</div>
</template>