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:
207
src/components/BookImportDialog.vue
Normal file
207
src/components/BookImportDialog.vue
Normal 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# 入门 第2章 变量与数据类型 第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>
|
||||
Reference in New Issue
Block a user