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:
90
src/components/BookImportDialog.test.ts
Normal file
90
src/components/BookImportDialog.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import JSZip from 'jszip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as booksApi from '../services/booksApi'
|
||||
import BookImportDialog from './BookImportDialog.vue'
|
||||
|
||||
vi.mock('../services/booksApi')
|
||||
|
||||
const baseProps = {
|
||||
running: false,
|
||||
done: 0,
|
||||
total: 0,
|
||||
currentTopic: '',
|
||||
error: null,
|
||||
}
|
||||
|
||||
async function makeZipFile(files: Record<string, string>): Promise<File> {
|
||||
const zip = new JSZip()
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
zip.file(name, content)
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
return new File([blob], 'book.zip', { type: 'application/zip' })
|
||||
}
|
||||
|
||||
async function uploadFile(wrapper: ReturnType<typeof mount>, file: File): Promise<void> {
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change')
|
||||
}
|
||||
|
||||
describe('BookImportDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('parses toc + zip, divides lessons, and emits start with assembled content', async () => {
|
||||
vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({
|
||||
lessons: [
|
||||
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
||||
{ title: '变量与类型', sourceIndexes: [1] },
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||
|
||||
await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量')
|
||||
const zip = await makeZipFile({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
|
||||
await uploadFile(wrapper, zip)
|
||||
await flushPromises()
|
||||
|
||||
// 解析并划分(parseZip 经由真实定时器解析,故用 waitFor 等待预览出现)
|
||||
await wrapper.get('.dialog-actions button').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([
|
||||
{ index: 0, title: '第1章 入门', charCount: 4 },
|
||||
{ index: 1, title: '第2章 变量', charCount: 4 },
|
||||
])
|
||||
|
||||
// 预览阶段:编辑第一个课时标题
|
||||
const titleInputs = wrapper.findAll('.book-import-lesson-title')
|
||||
await titleInputs[0]!.setValue('改后的标题')
|
||||
|
||||
// 开始生成
|
||||
await wrapper.get('.dialog-actions button').trigger('click')
|
||||
|
||||
const startEvents = wrapper.emitted('start')
|
||||
expect(startEvents).toHaveLength(1)
|
||||
expect(startEvents?.[0]?.[0]).toEqual([
|
||||
{ title: '改后的标题', content: '入门正文' },
|
||||
{ title: '变量与类型', content: '变量正文' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a non-zip upload', async () => {
|
||||
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||
const txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
|
||||
await uploadFile(wrapper, txt)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.app-notice--error').text()).toContain('.zip')
|
||||
})
|
||||
})
|
||||
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>
|
||||
@@ -37,6 +37,15 @@ describe('GenerateMenuButton', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits bookImport and closes the menu when "从书籍生成" is clicked', async () => {
|
||||
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
||||
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||
await wrapper.get('button[data-testid="book-import"]').trigger('click')
|
||||
expect(wrapper.emitted('bookImport')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-testid="book-import"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes the menu when clicking outside the component', async () => {
|
||||
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
||||
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||
|
||||
@@ -4,6 +4,7 @@ import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||
const emit = defineEmits<{
|
||||
generate: []
|
||||
batchGenerate: []
|
||||
bookImport: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -34,6 +35,18 @@ const emit = defineEmits<{
|
||||
生成一篇
|
||||
</button>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="book-import"
|
||||
@click="
|
||||
emit('bookImport');
|
||||
close()
|
||||
"
|
||||
>
|
||||
从书籍生成
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ToolbarMenuButton>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
withDefaults(defineProps<{ compact?: boolean }>(), {
|
||||
withDefaults(defineProps<{ compact?: boolean; accept?: string; multiple?: boolean }>(), {
|
||||
compact: false,
|
||||
accept: '.md,text/markdown,text/plain',
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ files: [files: File[]] }>()
|
||||
@@ -60,8 +62,8 @@ defineExpose({ openPicker })
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".md,text/markdown,text/plain"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
class="upload-dropzone-input"
|
||||
@change="onChange"
|
||||
@click.stop
|
||||
|
||||
@@ -28,6 +28,13 @@ describe('WorkspaceToolbar', () => {
|
||||
expect(wrapper.emitted('batchGenerate')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits bookImport when the book-import menu item is clicked', async () => {
|
||||
const wrapper = mountToolbar(3)
|
||||
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
||||
await wrapper.get('button[data-testid="book-import"]').trigger('click')
|
||||
expect(wrapper.emitted('bookImport')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits back when the back button is clicked', async () => {
|
||||
const wrapper = mountToolbar(0)
|
||||
await wrapper.get('button[data-testid="back"]').trigger('click')
|
||||
|
||||
@@ -15,6 +15,7 @@ defineEmits<{
|
||||
clear: []
|
||||
generate: []
|
||||
batchGenerate: []
|
||||
bookImport: []
|
||||
fixBroken: []
|
||||
back: []
|
||||
}>()
|
||||
@@ -30,7 +31,11 @@ const saveStatusLabel: Record<SaveStatus, string> = {
|
||||
<template>
|
||||
<header class="workspace-toolbar">
|
||||
<button type="button" data-testid="back" @click="$emit('back')">返回</button>
|
||||
<GenerateMenuButton @generate="$emit('generate')" @batch-generate="$emit('batchGenerate')" />
|
||||
<GenerateMenuButton
|
||||
@generate="$emit('generate')"
|
||||
@batch-generate="$emit('batchGenerate')"
|
||||
@book-import="$emit('bookImport')"
|
||||
/>
|
||||
<ExportMenuButton :disabled="lessonCount === 0" @print="$emit('print')" @export="$emit('export')" />
|
||||
<button type="button" data-testid="clear" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('WorkspaceView', () => {
|
||||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
||||
expect(wrapper.find('[data-testid="generate-menu-toggle"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-testid="back"]').trigger('click')
|
||||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||||
@@ -191,7 +191,8 @@ describe('WorkspaceView', () => {
|
||||
|
||||
await wrapper.get('[data-testid="clear"]').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('点击或拖拽上传')
|
||||
expect(wrapper.find('.workspace-layout').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('downloads the exported zip with the book name', async () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useTeachingBook } from '../composables/useTeachingBook'
|
||||
import { useTeachingBook, type BookLessonInput } from '../composables/useTeachingBook'
|
||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
||||
import A4Workspace from './A4Workspace.vue'
|
||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||
import BookImportDialog from './BookImportDialog.vue'
|
||||
import FixBrokenDialog from './FixBrokenDialog.vue'
|
||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||
import LessonSidebar from './LessonSidebar.vue'
|
||||
@@ -35,6 +36,7 @@ const {
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
generateLessonsFromBook,
|
||||
regenerateLesson,
|
||||
} = useTeachingBook(props.bookId)
|
||||
|
||||
@@ -52,6 +54,14 @@ const batchCurrentTopic = ref('')
|
||||
const batchError = ref<string | null>(null)
|
||||
const batchCancelled = ref(false)
|
||||
|
||||
const showBookImportDialog = ref(false)
|
||||
const bookImportRunning = ref(false)
|
||||
const bookImportDone = ref(0)
|
||||
const bookImportTotal = ref(0)
|
||||
const bookImportCurrentTopic = ref('')
|
||||
const bookImportError = ref<string | null>(null)
|
||||
const bookImportCancelled = ref(false)
|
||||
|
||||
const showFixDialog = ref(false)
|
||||
const fixRunning = ref(false)
|
||||
const fixDone = ref(0)
|
||||
@@ -153,6 +163,42 @@ function closeBatchDialog(): void {
|
||||
batchError.value = null
|
||||
}
|
||||
|
||||
async function handleBookImportStart(lessons: BookLessonInput[]): Promise<void> {
|
||||
bookImportRunning.value = true
|
||||
bookImportCancelled.value = false
|
||||
bookImportDone.value = 0
|
||||
bookImportTotal.value = lessons.length
|
||||
bookImportError.value = null
|
||||
|
||||
const result = await generateLessonsFromBook(lessons, {
|
||||
concurrency: BATCH_GENERATE_CONCURRENCY,
|
||||
isCancelled: () => bookImportCancelled.value,
|
||||
onTopicStart: (topic) => {
|
||||
bookImportCurrentTopic.value = topic
|
||||
},
|
||||
onLessonComplete: (count) => {
|
||||
bookImportDone.value += count
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
bookImportError.value = result.message
|
||||
}
|
||||
|
||||
bookImportRunning.value = false
|
||||
}
|
||||
|
||||
function handleBookImportCancel(): void {
|
||||
bookImportCancelled.value = true
|
||||
}
|
||||
|
||||
function closeBookImportDialog(): void {
|
||||
showBookImportDialog.value = false
|
||||
bookImportDone.value = 0
|
||||
bookImportTotal.value = 0
|
||||
bookImportError.value = null
|
||||
}
|
||||
|
||||
function openFixDialog(): void {
|
||||
fixTotal.value = book.value.designs.filter((d) => d.warnings.length > 0).length
|
||||
fixDone.value = 0
|
||||
@@ -220,6 +266,17 @@ function closeFixDialog(): void {
|
||||
@cancel="handleBatchCancel"
|
||||
@close="closeBatchDialog"
|
||||
/>
|
||||
<BookImportDialog
|
||||
v-if="showBookImportDialog"
|
||||
:running="bookImportRunning"
|
||||
:done="bookImportDone"
|
||||
:total="bookImportTotal"
|
||||
:current-topic="bookImportCurrentTopic"
|
||||
:error="bookImportError"
|
||||
@start="handleBookImportStart"
|
||||
@cancel="handleBookImportCancel"
|
||||
@close="closeBookImportDialog"
|
||||
/>
|
||||
<FixBrokenDialog
|
||||
v-if="showFixDialog"
|
||||
:running="fixRunning"
|
||||
@@ -247,6 +304,7 @@ function closeFixDialog(): void {
|
||||
@back="$emit('back')"
|
||||
@generate="openGenerateDialog"
|
||||
@batch-generate="showBatchDialog = true"
|
||||
@book-import="showBookImportDialog = true"
|
||||
@fix-broken="openFixDialog"
|
||||
@print="handlePrint"
|
||||
@export="handleExport"
|
||||
|
||||
@@ -83,36 +83,19 @@ describe('useTeachingBook', () => {
|
||||
expect(store.loadError.value).toBe('网络错误。')
|
||||
})
|
||||
|
||||
it('imports files in natural order and selects the first lesson', async () => {
|
||||
mockGetBook(createEmptyBook())
|
||||
const store = useTeachingBook('b1')
|
||||
await flushPromises()
|
||||
|
||||
const files = [
|
||||
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
|
||||
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
|
||||
]
|
||||
|
||||
await store.importFiles(files, 'keep')
|
||||
|
||||
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual(['2.md', '10.md'])
|
||||
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
||||
})
|
||||
|
||||
it('reorders lessons without changing their identities', async () => {
|
||||
mockGetBook(createEmptyBook())
|
||||
const data = createEmptyBook()
|
||||
data.designs.push(createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md'))
|
||||
data.selectedId = data.designs[0]!.id
|
||||
mockGetBook(data)
|
||||
|
||||
const store = useTeachingBook('b1')
|
||||
await flushPromises()
|
||||
|
||||
await store.importFiles(
|
||||
[new File(['# One 教学设计'], '1.md'), new File(['# Two 教学设计'], '2.md')],
|
||||
'keep',
|
||||
)
|
||||
|
||||
const ids = store.book.value.designs.map((design) => design.id)
|
||||
store.moveDesign(0, 1)
|
||||
|
||||
expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse())
|
||||
expect(store.book.value.designs.map((design) => design.id)).toEqual([ids[1], ids[0]])
|
||||
})
|
||||
|
||||
it('does not autosave immediately after the initial load', async () => {
|
||||
@@ -208,6 +191,51 @@ describe('useTeachingBook', () => {
|
||||
expect(store.book.value.designs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('generateLessonsFromBook appends lessons in order and feeds chapter content', async () => {
|
||||
mockGetBook(createEmptyBook())
|
||||
vi.mocked(booksApi.generateLesson).mockImplementation(async (topic: string) => ({
|
||||
filename: `${topic}.md`,
|
||||
markdown: `# ${topic} 教学设计`,
|
||||
}))
|
||||
|
||||
const store = useTeachingBook('b1')
|
||||
await flushPromises()
|
||||
|
||||
const result = await store.generateLessonsFromBook([
|
||||
{ title: '课时一', content: '第一章正文' },
|
||||
{ title: '课时二', content: '第二章正文' },
|
||||
])
|
||||
|
||||
expect(result).toEqual({ ok: true, completed: 2 })
|
||||
expect(store.book.value.designs).toHaveLength(2)
|
||||
const calls = vi.mocked(booksApi.generateLesson).mock.calls
|
||||
expect(calls.map((c) => c[0])).toEqual(['课时一', '课时二'])
|
||||
expect(calls[0]?.[1]).toEqual(expect.objectContaining({ content: '第一章正文' }))
|
||||
expect(calls[1]?.[1]).toEqual(expect.objectContaining({ content: '第二章正文' }))
|
||||
})
|
||||
|
||||
it('generateLessonsFromBook stops early when cancelled', async () => {
|
||||
mockGetBook(createEmptyBook())
|
||||
vi.mocked(booksApi.generateLesson).mockImplementation(async (topic: string) => ({
|
||||
filename: `${topic}.md`,
|
||||
markdown: `# ${topic} 教学设计`,
|
||||
}))
|
||||
|
||||
const store = useTeachingBook('b1')
|
||||
await flushPromises()
|
||||
|
||||
const result = await store.generateLessonsFromBook(
|
||||
[
|
||||
{ title: '课时一', content: 'a' },
|
||||
{ title: '课时二', content: 'b' },
|
||||
],
|
||||
{ concurrency: 1, isCancelled: () => true },
|
||||
)
|
||||
|
||||
expect(result.completed).toBe(0)
|
||||
expect(store.book.value.designs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clearBook empties designs and clears selection', async () => {
|
||||
const { data } = createBookWithDesign()
|
||||
mockGetBook(data)
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface BatchGenerateLessonOptions {
|
||||
onLessonComplete?: (count: number) => void
|
||||
}
|
||||
|
||||
export interface BookLessonInput {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface TeachingBookStore {
|
||||
book: Ref<TeachingBook>
|
||||
bookName: Ref<string>
|
||||
@@ -47,6 +52,10 @@ export interface TeachingBookStore {
|
||||
topics: readonly string[],
|
||||
options?: BatchGenerateLessonOptions,
|
||||
) => Promise<BatchGenerateLessonResult>
|
||||
generateLessonsFromBook: (
|
||||
lessons: readonly BookLessonInput[],
|
||||
options?: BatchGenerateLessonOptions,
|
||||
) => Promise<BatchGenerateLessonResult>
|
||||
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
||||
}
|
||||
|
||||
@@ -196,13 +205,15 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLessons(
|
||||
topics: readonly string[],
|
||||
async function runGenerationPool<T>(
|
||||
items: readonly T[],
|
||||
labelOf: (item: T) => string,
|
||||
requestOf: (item: T) => Promise<booksApi.GenerateResult>,
|
||||
options: BatchGenerateLessonOptions = {},
|
||||
): Promise<BatchGenerateLessonResult> {
|
||||
const concurrency = Math.max(1, options.concurrency ?? 3)
|
||||
const workerCount = Math.min(concurrency, topics.length)
|
||||
const results = new Array<TeachingDesign | undefined>(topics.length)
|
||||
const workerCount = Math.min(concurrency, items.length)
|
||||
const results = new Array<TeachingDesign | undefined>(items.length)
|
||||
let nextStartIndex = 0
|
||||
let nextAppendIndex = 0
|
||||
let appendedCount = 0
|
||||
@@ -227,24 +238,19 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (!firstError) {
|
||||
if (options.isCancelled?.()) {
|
||||
abortController.abort()
|
||||
return
|
||||
}
|
||||
if (options.isCancelled?.()) return
|
||||
|
||||
const index = nextStartIndex
|
||||
if (index >= topics.length) return
|
||||
if (index >= items.length) return
|
||||
|
||||
nextStartIndex++
|
||||
const topic = topics[index]!
|
||||
options.onTopicStart?.(topic)
|
||||
const item = items[index]!
|
||||
options.onTopicStart?.(labelOf(item))
|
||||
|
||||
try {
|
||||
const result = await booksApi.generateLesson(topic, abortController.signal)
|
||||
const result = await requestOf(item)
|
||||
results[index] = removeGeneratedAdditionalContent(
|
||||
parseTeachingDesign(result.filename, result.markdown),
|
||||
)
|
||||
@@ -264,6 +270,25 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
: { ok: true, completed: appendedCount }
|
||||
}
|
||||
|
||||
function generateLessons(
|
||||
topics: readonly string[],
|
||||
options: BatchGenerateLessonOptions = {},
|
||||
): Promise<BatchGenerateLessonResult> {
|
||||
return runGenerationPool(topics, (topic) => topic, (topic) => booksApi.generateLesson(topic), options)
|
||||
}
|
||||
|
||||
function generateLessonsFromBook(
|
||||
lessons: readonly BookLessonInput[],
|
||||
options: BatchGenerateLessonOptions = {},
|
||||
): Promise<BatchGenerateLessonResult> {
|
||||
return runGenerationPool(
|
||||
lessons,
|
||||
(lesson) => lesson.title,
|
||||
(lesson) => booksApi.generateLesson(lesson.title, { content: lesson.content }),
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
||||
const existing = book.value.designs.find((d) => d.id === id)
|
||||
if (!existing) return { ok: false, message: '找不到该教案。' }
|
||||
@@ -305,6 +330,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
generateLessonsFromBook,
|
||||
regenerateLesson,
|
||||
}
|
||||
}
|
||||
|
||||
60
src/services/bookImport.test.ts
Normal file
60
src/services/bookImport.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assembleContent,
|
||||
matchToc,
|
||||
normalizeTitle,
|
||||
parseZip,
|
||||
type BookEntry,
|
||||
} from './bookImport'
|
||||
|
||||
async function makeZip(files: Record<string, string>): Promise<Blob> {
|
||||
const zip = new JSZip()
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
zip.file(name, content)
|
||||
}
|
||||
return zip.generateAsync({ type: 'blob' })
|
||||
}
|
||||
|
||||
describe('normalizeTitle', () => {
|
||||
it('strips .md, illegal chars and collapses whitespace', () => {
|
||||
expect(normalizeTitle('第1章:入门.md')).toBe('第1章_入门')
|
||||
expect(normalizeTitle(' 变量 与 类型 ')).toBe('变量 与 类型')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseZip + matchToc', () => {
|
||||
it('matches toc lines to md files by normalized filename', async () => {
|
||||
const blob = await makeZip({
|
||||
'第1章 入门.md': '入门正文',
|
||||
'第2章 变量.md': '变量正文',
|
||||
'readme.txt': '忽略非 md',
|
||||
})
|
||||
|
||||
const map = await parseZip(blob)
|
||||
const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map)
|
||||
|
||||
expect(entries).toHaveLength(3)
|
||||
expect(entries[0]).toMatchObject({ index: 0, title: '第1章 入门', content: '入门正文', matched: true })
|
||||
expect(entries[1]).toMatchObject({ index: 1, content: '变量正文', matched: true })
|
||||
expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 })
|
||||
})
|
||||
|
||||
it('ignores directory prefixes inside the zip', async () => {
|
||||
const blob = await makeZip({ 'book/第1章 入门.md': '正文' })
|
||||
const map = await parseZip(blob)
|
||||
const entries = matchToc('第1章 入门', map)
|
||||
expect(entries[0]?.matched).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assembleContent', () => {
|
||||
it('concatenates content of all source entries in order', () => {
|
||||
const entries: BookEntry[] = [
|
||||
{ index: 0, title: 'a', content: 'AAA', charCount: 3, matched: true },
|
||||
{ index: 1, title: 'b', content: 'BBB', charCount: 3, matched: true },
|
||||
{ index: 2, title: 'c', content: '', charCount: 0, matched: false },
|
||||
]
|
||||
expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB')
|
||||
})
|
||||
})
|
||||
75
src/services/bookImport.ts
Normal file
75
src/services/bookImport.ts
Normal 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')
|
||||
}
|
||||
@@ -80,6 +80,43 @@ describe('booksApi', () => {
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' })
|
||||
})
|
||||
|
||||
it('generates a lesson with chapter content as context', async () => {
|
||||
const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' }
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||||
|
||||
await booksApi.generateLesson('CSS 弹性布局', { content: '弹性盒模型正文' })
|
||||
|
||||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
topic: 'CSS 弹性布局',
|
||||
content: '弹性盒模型正文',
|
||||
})
|
||||
})
|
||||
|
||||
it('still accepts an AbortSignal as the second argument', async () => {
|
||||
const result = { filename: 'x.md', markdown: '# x' }
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 }))
|
||||
|
||||
const controller = new AbortController()
|
||||
await booksApi.generateLesson('x', controller.signal)
|
||||
|
||||
const [, init] = vi.mocked(fetch).mock.calls[0]!
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ topic: 'x' })
|
||||
expect(init?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('divides lessons from a book table of contents', async () => {
|
||||
const lessons = [{ title: 'C# 入门——搭建环境', sourceIndexes: [0] }]
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ lessons }), { status: 200 }))
|
||||
|
||||
const entries = [{ index: 0, title: '第1章 C# 入门', charCount: 5000 }]
|
||||
await expect(booksApi.divideLessonsFromBook(entries)).resolves.toEqual({ lessons })
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0]!
|
||||
expect(url).toBe('/api/generate/lessons-from-book')
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ entries })
|
||||
})
|
||||
|
||||
it('throws the server error message on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 }))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TeachingBook } from '../../shared/domain/teachingDesign'
|
||||
import { authedFetch } from '../composables/useAuth'
|
||||
import type { ProposedLesson } from './bookImport'
|
||||
|
||||
export interface BookSummary {
|
||||
id: string
|
||||
@@ -51,8 +52,30 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
|
||||
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function generateLesson(topic: string, signal?: AbortSignal): Promise<GenerateResult> {
|
||||
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }), signal })
|
||||
export interface GenerateLessonOptions {
|
||||
content?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function generateLesson(
|
||||
topic: string,
|
||||
optionsOrSignal?: GenerateLessonOptions | AbortSignal,
|
||||
): Promise<GenerateResult> {
|
||||
const options: GenerateLessonOptions =
|
||||
optionsOrSignal instanceof AbortSignal
|
||||
? { signal: optionsOrSignal }
|
||||
: optionsOrSignal ?? {}
|
||||
|
||||
const payload: { topic: string; content?: string } = { topic }
|
||||
if (options.content && options.content.trim() !== '') {
|
||||
payload.content = options.content
|
||||
}
|
||||
|
||||
return authedFetch('/api/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function generateOutline(theme: string, count = 18): Promise<{ titles: string[] }> {
|
||||
@@ -61,3 +84,12 @@ export function generateOutline(theme: string, count = 18): Promise<{ titles: st
|
||||
body: JSON.stringify({ theme, count }),
|
||||
})
|
||||
}
|
||||
|
||||
export function divideLessonsFromBook(
|
||||
entries: readonly { index: number; title: string; charCount: number }[],
|
||||
): Promise<{ lessons: ProposedLesson[] }> {
|
||||
return authedFetch('/api/generate/lessons-from-book', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entries }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -897,3 +897,51 @@ table {
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.book-import-file {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.book-import-lessons {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.book-import-lesson {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.book-import-lesson-title {
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.book-import-lesson-title:focus {
|
||||
outline: none;
|
||||
border-color: var(--green-600);
|
||||
}
|
||||
|
||||
.book-import-lesson-source {
|
||||
flex: 0 0 30%;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.book-import-lesson-remove {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user