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

98
docs/book-import-spec.md Normal file
View File

@@ -0,0 +1,98 @@
# 从书籍生成教案 — 功能规格Spec
## 背景与目标
当前 FakeTeachingDesignVue 3 + Hono/Bun + SQLiteDeepSeek `deepseek-v4-flash`)只能:
- **生成一篇**:手输一个主题 → `/api/generate` 生成单篇教案。
- **批量生成**:输入主题 → `/api/generate/outline` 生成标题列表 → 逐篇生成。
缺口:无法基于**真实教材内容**生成教案。本功能让用户提供一本教材的**目录**与**正文**,由 AI 据此划分课时,用户确认后批量生成,且生成每篇时把对应章节正文作为上下文喂给 AI产出更贴合教材的教案。
## 输入(用户提供)
1. **目录**:多行文本,用户**粘贴**到文本框。每行一个标题,给出课程的**顺序**与**完整标题清单**。
2. **正文压缩包**:用户**上传**一个 ZIP内含多份 `.md`**文件名 = 目录中的标题**,文件内容 = 该标题对应的正文。
## 核心流程
```
粘贴目录(多行) + 上传 ZIP
前端解压(JSZip) → {标题 → 正文} 映射 → 与目录每行按文件名匹配
│ 得到 entries: [{index, title, content, charCount, matched}]
POST /api/generate/lessons-from-book { entries: [{index,title,charCount}] }
│ AI 据目录与各条体量「再划分课时」(可合并/拆分)
返回 lessons: [{title, sourceIndexes:[...]}]
预览/编辑课时列表(改标题、删行)
│ 前端按 sourceIndexes 拼出每课时的正文上下文
批量生成: 每课时 POST /api/generate { topic, content }
│ content = 该课时所辖章节正文(拼接并截断 ~6000 字)
逐篇 parseTeachingDesign → 追加进 book.designs(复用现有有序追加/进度/取消)
```
## 关键决策
- **解压与匹配全在前端**`JSZip` 已是项目依赖(`src/services/zipExporter.ts` 在用)。浏览器内 `JSZip.loadAsync` 解压,无需 PDF 解析、OCR、服务端缓存、multipart 上传——发给后端只是 JSON。
- **AI 再划分课时**:目录条目(章/节)不一定 1:1 对应课时。AI 据标题 + 各条字数(`charCount`,廉价信号,不传全文)决定合并小节、拆分大章。
- **正文随生成请求直传**:每课时生成时把拼好的正文作为 `content` 字段直接 POST无服务端缓存、无 `parseId`
- **纯增量扩展 `/api/generate`**:新增可选 `content`,老调用方仍只传 `{topic}`,现有测试不变。
## 标题↔文件名 匹配规则
ZIP 内文件名可能带 `.md` 后缀、含被替换的非法字符(现有 `sanitizeFilename``[\\/:*?"<>|]` 换成 `_`)。匹配时对**两侧都归一化**后比对:
1. 去掉 `.md` 后缀;
2. `trim`,把连续空白折叠为单空格;
3.`[\\/:*?"<>|]` 替换为 `_`(与服务端 `sanitizeFilename` 一致)。
归一化后字符串相等即匹配。匹配不到的目录行 `matched=false``content=''`(仍可生成,但只靠标题)。
## 接口
### `POST /api/generate/lessons-from-book`(新增,挂 `/api/generate/*`,复用现有鉴权)
请求:
```json
{ "entries": [ { "index": 0, "title": "第1章 C# 入门", "charCount": 5821 } ] }
```
- 系统提示要求 AI 据目录把全书拆为单课时课题,每条对应一个或多个目录条目,难度由浅入深、覆盖全部条目,复杂条目可拆多课时;**仅输出 JSON**,形如 `{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}`
- 复用现有 `fenceMatch``generate.ts:86-87`)剥代码块围栏;`JSON.parse` 后校验每行 `title:string` + `sourceIndexes:number[]`(索引在 entries 范围内),丢弃非法行。
响应:
```json
{ "lessons": [ { "title": "C# 入门——搭建环境运行首个程序", "sourceIndexes": [0] } ] }
```
错误400 缺 `entries`500 无 key502 DeepSeek 失败/空。
### `POST /api/generate`(扩展,向后兼容)
- 读可选 `content:string`。有则把它作为编写依据拼进 user 消息(提炼要点、不照抄),系统提示不变,返回 `{filename, markdown}` 不变。
-`content` 时请求体与现状完全一致。
## 前端
- **菜单**`GenerateMenuButton.vue` 加「从书籍生成」(`data-testid="book-import"`),经 `WorkspaceToolbar``WorkspaceView`
- **对话框** `BookImportDialog.vue`(仿 `BatchGenerateDialog.vue` 多阶段):
- `input`:目录粘贴 `<textarea>` + `UploadDropzone`accept `.zip`,单文件)。校验:目录非空 + 已传 ZIP。点「解析」→ 前端解压匹配展示匹配概况如「20 行目录18 行匹配到正文2 行缺正文」)。
- `division-loading`:调 `divideLessonsFromBook(entries)`提示「AI 正在划分课时…」。
- `preview`可编辑课时列表改标题、删行显示每课时所辖章节标题。「开始生成」emit `start:[lessons]`(含拼好的 content
- `running / done / error`:与 batch 相同(进度条 + 停止)。
- **解压匹配工具** `src/services/bookImport.ts``parseZip(file)` 用 JSZip 读所有 `.md``Map<归一化标题, 正文>``matchToc(tocLines, map)``entries[]`
- **API** `src/services/booksApi.ts``generateLesson` 增加 `content` 选项(兼容旧 `AbortSignal` 第二参);新增 `divideLessonsFromBook(entries)`;新增类型 `BookEntry``ProposedLesson`
- **Store** `useTeachingBook.ts`:新增 `generateLessonsFromBook(lessons:{title,content}[], options)`,复用 `generateLessons``:199-265`)的 worker 池(并发 3、有序追加、可取消、进度回调每个 worker 调 `generateLesson(title,{content,signal})`
## 非目标
- 不支持 PDF / Word / 扫描件 / OCR仅 ZIP-of-md + 粘贴目录)。
- 不做章节↔课时映射的高级可视化编辑(预览仅改标题、删行)。
- 不持久化上传的原始 ZIP/目录(仅用于一次生成)。
## 验证
- **服务端 `bun test server`**`/lessons-from-book` 从 mock JSON 解出 `lessons`、畸形 JSON 优雅处理、400 缺 entries`POST /``content``content` 出现在捕获的 DeepSeek 请求体,且不带时请求体不变(向后兼容)。
- **前端 `vitest run`**`bookImport.ts` 解压+匹配(含归一化、缺正文)单测;`booksApi``generateLesson('x')` 仍发 `{topic:'x'}`、带 content 时附加;`divideLessonsFromBook``{entries}``BookImportDialog` 走 input→preview→start 流程,断言 `start` 载荷;`useTeachingBook.generateLessonsFromBook` 有序追加 + 取消。
- **类型检查**`npx vue-tsc -b`
- **手动 E2E**:粘贴一份目录 + 传一个 md 的 ZIP → 看匹配概况 → 确认 AI 课时 → 改标题/删行 → 开始生成 → 教案追加、内容贴合章节;负向:目录空 / 传非 ZIP / 全部不匹配 → 友好提示。

View File

@@ -160,4 +160,149 @@ describe('generate route', () => {
expect(res.status).toBe(502)
})
it('does not add content to the prompt when topic is sent alone', async () => {
let sentBody = ''
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
sentBody = init.body ?? ''
return new Response(
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
{ status: 200 },
)
}) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
await app.request('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'CSS 弹性布局' }),
})
expect(sentBody).not.toContain('作为编写依据')
expect(sentBody).toContain('请围绕主题')
expect(sentBody).toContain('生成一份教案。')
})
it('injects chapter content into the prompt when content is provided', async () => {
let sentBody = ''
globalThis.fetch = mock(async (_url: unknown, init: { body?: string } = {}) => {
sentBody = init.body ?? ''
return new Response(
JSON.stringify({ choices: [{ message: { content: '# x 教学设计' } }] }),
{ status: 200 },
)
}) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'CSS 弹性布局', content: '弹性盒模型的正文内容片段' }),
})
expect(res.status).toBe(200)
expect(sentBody).toContain('作为编写依据')
expect(sentBody).toContain('弹性盒模型的正文内容片段')
})
it('returns 400 when entries are missing on lessons-from-book', async () => {
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('parses lessons with sourceIndexes from lessons-from-book', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content:
'{"lessons":[{"title":"C# 入门——搭建环境","sourceIndexes":[0]},{"title":"变量与类型","sourceIndexes":[1,2]}]}',
},
},
],
}),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: [
{ index: 0, title: '第1章 C# 入门', charCount: 5000 },
{ index: 1, title: '第2章 变量', charCount: 3000 },
{ index: 2, title: '第3章 类型', charCount: 2000 },
],
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
lessons: { title: string; sourceIndexes: number[] }[]
}
expect(body.lessons).toEqual([
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
{ title: '变量与类型', sourceIndexes: [1, 2] },
])
})
it('strips code fences and drops out-of-range sourceIndexes on lessons-from-book', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content:
'```json\n{"lessons":[{"title":"课时一","sourceIndexes":[0,9]}]}\n```',
},
},
],
}),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
lessons: { title: string; sourceIndexes: number[] }[]
}
expect(body.lessons).toEqual([{ title: '课时一', sourceIndexes: [0] }])
})
it('returns 502 when lessons-from-book JSON is malformed', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({ choices: [{ message: { content: '这不是 JSON' } }] }),
{ status: 200 },
),
) as unknown as typeof fetch
const app = new Hono().route('/api/generate', createGenerateRouter('test-key'))
const res = await app.request('/api/generate/lessons-from-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: [{ index: 0, title: '第1章', charCount: 100 }] }),
})
expect(res.status).toBe(502)
})
})

View File

@@ -37,7 +37,9 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
const app = new Hono()
app.post('/', async (c) => {
const body = (await c.req.json().catch(() => null)) as { topic?: unknown } | null
const body = (await c.req.json().catch(() => null)) as
| { topic?: unknown; content?: unknown }
| null
const topic = body?.topic
if (typeof topic !== 'string' || topic.trim() === '') {
@@ -48,6 +50,11 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
const content = typeof body?.content === 'string' ? body.content.trim() : ''
const userContent = content
? `请围绕主题"${topic.trim()}"生成一份教案。\n\n以下是教材相关章节内容作为编写依据请提炼要点不要照抄\n"""\n${content.slice(0, 6000)}\n"""`
: `请围绕主题"${topic.trim()}"生成一份教案。`
const systemPrompt = loadSystemPrompt()
let response: Response
@@ -62,7 +69,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `请围绕主题"${topic.trim()}"生成一份教案。` },
{ role: 'user', content: userContent },
],
}),
})
@@ -152,5 +159,110 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
return c.json({ titles })
})
app.post('/lessons-from-book', async (c) => {
const body = (await c.req.json().catch(() => null)) as { entries?: unknown } | null
const rawEntries = Array.isArray(body?.entries) ? body!.entries : null
if (!rawEntries || rawEntries.length === 0) {
return c.json({ error: '请提供教材目录。' }, 400)
}
const entries = rawEntries
.map((entry, index) => {
const e = entry as { index?: unknown; title?: unknown; charCount?: unknown }
const title = typeof e?.title === 'string' ? e.title.trim() : ''
const entryIndex = typeof e?.index === 'number' ? e.index : index
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
return { index: entryIndex, title, charCount }
})
.filter((entry) => entry.title !== '')
if (entries.length === 0) {
return c.json({ error: '目录为空或格式无效。' }, 400)
}
if (!apiKey) {
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
}
const tocText = entries
.map((entry) => `${entry.index}\t${entry.title}\t约${entry.charCount}`)
.join('\n')
let response: Response
try {
response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content:
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」。' +
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
'每个课题对应一个或多个目录条目(用其序号表示)。' +
'仅输出 JSON不要解释、不要代码块围栏形如' +
'{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}。',
},
{ role: 'user', content: tocText },
],
}),
})
} catch {
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
}
if (!response.ok) {
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
}
const payload = (await response.json().catch(() => null)) as
| { choices?: Array<{ message?: { content?: string } }> }
| null
const raw = payload?.choices?.[0]?.message?.content
if (!raw) {
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
}
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
const jsonText = fenceMatch ? fenceMatch[1]! : raw
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch {
return c.json({ error: 'Deepseek 返回的课时格式无法解析。' }, 502)
}
const maxIndex = entries.reduce((max, entry) => Math.max(max, entry.index), 0)
const rawLessons = (parsed as { lessons?: unknown })?.lessons
const lessons = Array.isArray(rawLessons)
? rawLessons
.map((lesson) => {
const l = lesson as { title?: unknown; sourceIndexes?: unknown }
const title = typeof l?.title === 'string' ? l.title.trim() : ''
const sourceIndexes = Array.isArray(l?.sourceIndexes)
? l.sourceIndexes.filter(
(n): n is number => typeof n === 'number' && n >= 0 && n <= maxIndex,
)
: []
return { title, sourceIndexes }
})
.filter((lesson) => lesson.title !== '')
: []
if (lessons.length === 0) {
return c.json({ error: 'Deepseek 未返回有效课时。' }, 502)
}
return c.json({ lessons })
})
return app
}

View 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')
})
})

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>

View File

@@ -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')

View File

@@ -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>

View File

@@ -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

View File

@@ -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')

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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"

View File

@@ -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)

View File

@@ -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,
}
}

View 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')
})
})

View 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')
}

View File

@@ -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 }))

View File

@@ -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 }),
})
}

View File

@@ -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;
}