Files
teaching-design/src/components/BookImportDialog.vue
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

208 lines
6.6 KiB
Vue
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.
<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>