This commit is contained in:
2026-06-24 06:36:25 -06:00
parent c44e8174c4
commit 7d9aeebdaf
6 changed files with 125 additions and 90 deletions

View File

@@ -169,11 +169,17 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
const entries = rawEntries const entries = rawEntries
.map((entry, index) => { .map((entry, index) => {
const e = entry as { index?: unknown; title?: unknown; charCount?: unknown } const e = entry as {
index?: unknown
title?: unknown
charCount?: unknown
depth?: unknown
}
const title = typeof e?.title === 'string' ? e.title.trim() : '' const title = typeof e?.title === 'string' ? e.title.trim() : ''
const entryIndex = typeof e?.index === 'number' ? e.index : index const entryIndex = typeof e?.index === 'number' ? e.index : index
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0 const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
return { index: entryIndex, title, charCount } const depth = typeof e?.depth === 'number' && e.depth > 0 ? e.depth : 0
return { index: entryIndex, title, charCount, depth }
}) })
.filter((entry) => entry.title !== '') .filter((entry) => entry.title !== '')
@@ -186,7 +192,10 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
} }
const tocText = entries const tocText = entries
.map((entry) => `${entry.index}\t${entry.title}\t约${entry.charCount}`) .map(
(entry) =>
`${entry.index}\t${' '.repeat(entry.depth)}${entry.title}\t约${entry.charCount}`,
)
.join('\n') .join('\n')
let response: Response let response: Response
@@ -203,7 +212,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
{ {
role: 'system', role: 'system',
content: content:
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」。' + '你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」,标题前的缩进表示层级(章 → 节 → 小节),同一上级标题下的条目应优先归到相邻课时。' +
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' + '请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
'每个课题对应一个或多个目录条目(用其序号表示)。' + '每个课题对应一个或多个目录条目(用其序号表示)。' +
'仅输出 JSON不要解释、不要代码块围栏形如' + '仅输出 JSON不要解释、不要代码块围栏形如' +

View File

@@ -1,5 +1,4 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import JSZip from 'jszip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as booksApi from '../services/booksApi' import * as booksApi from '../services/booksApi'
import BookImportDialog from './BookImportDialog.vue' import BookImportDialog from './BookImportDialog.vue'
@@ -14,18 +13,15 @@ const baseProps = {
error: null, error: null,
} }
async function makeZipFile(files: Record<string, string>): Promise<File> { function makeMdFiles(files: Record<string, string>): File[] {
const zip = new JSZip() return Object.entries(files).map(
for (const [name, content] of Object.entries(files)) { ([name, content]) => new File([content], name, { type: 'text/markdown' }),
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> { async function uploadFiles(wrapper: ReturnType<typeof mount>, files: File[]): Promise<void> {
const input = wrapper.get('input[type="file"]') const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) Object.defineProperty(input.element, 'files', { value: files, configurable: true })
await input.trigger('change') await input.trigger('change')
} }
@@ -38,7 +34,7 @@ describe('BookImportDialog', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it('parses toc + zip, divides lessons, and emits start with assembled content', async () => { it('parses toc + md files, divides lessons, and emits start with assembled content', async () => {
vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({ vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({
lessons: [ lessons: [
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] }, { title: 'C# 入门——搭建环境', sourceIndexes: [0] },
@@ -49,19 +45,19 @@ describe('BookImportDialog', () => {
const wrapper = mount(BookImportDialog, { props: baseProps }) const wrapper = mount(BookImportDialog, { props: baseProps })
await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量') await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量')
const zip = await makeZipFile({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' }) const files = makeMdFiles({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
await uploadFile(wrapper, zip) await uploadFiles(wrapper, files)
await flushPromises() await flushPromises()
// 解析并划分(parseZip 经由真实定时器解析,故用 waitFor 等待预览出现) // 解析并划分(File.text() 为异步,故用 waitFor 等待预览出现)
await wrapper.get('.dialog-actions button').trigger('click') await wrapper.get('.dialog-actions button').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2) expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2)
}) })
expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([ expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([
{ index: 0, title: '第1章 入门', charCount: 4 }, { index: 0, title: '第1章 入门', charCount: 4, depth: 0 },
{ index: 1, title: '第2章 变量', charCount: 4 }, { index: 1, title: '第2章 变量', charCount: 4, depth: 0 },
]) ])
// 预览阶段:编辑第一个课时标题 // 预览阶段:编辑第一个课时标题
@@ -79,12 +75,12 @@ describe('BookImportDialog', () => {
]) ])
}) })
it('rejects a non-zip upload', async () => { it('rejects an upload with no md files', async () => {
const wrapper = mount(BookImportDialog, { props: baseProps }) const wrapper = mount(BookImportDialog, { props: baseProps })
const txt = new File(['x'], 'notes.txt', { type: 'text/plain' }) const txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
await uploadFile(wrapper, txt) await uploadFiles(wrapper, [txt])
await flushPromises() await flushPromises()
expect(wrapper.get('.app-notice--error').text()).toContain('.zip') expect(wrapper.get('.app-notice--error').text()).toContain('.md')
}) })
}) })

View File

@@ -4,7 +4,7 @@ import * as booksApi from '../services/booksApi'
import { import {
assembleContent, assembleContent,
matchToc, matchToc,
parseZip, parseMdFiles,
type BookEntry, type BookEntry,
type ProposedLesson, type ProposedLesson,
} from '../services/bookImport' } from '../services/bookImport'
@@ -29,7 +29,7 @@ const emit = defineEmits<{
const phase = ref<Phase>('input') const phase = ref<Phase>('input')
const tocText = ref('') const tocText = ref('')
const zipFile = ref<File | null>(null) const mdFiles = ref<File[]>([])
const inputError = ref<string | null>(null) const inputError = ref<string | null>(null)
const entries = ref<BookEntry[]>([]) const entries = ref<BookEntry[]>([])
@@ -47,14 +47,16 @@ watch(
) )
function onFiles(files: File[]): void { function onFiles(files: File[]): void {
const file = files[0] const mds = files.filter((file) => /\.md$/i.test(file.name))
if (!file) return if (mds.length === 0) {
if (!/\.zip$/i.test(file.name)) { inputError.value = '请上传 .md 文件。'
inputError.value = '请上传 .zip 压缩包。'
return return
} }
inputError.value = null inputError.value = null
zipFile.value = file // 去重(同名覆盖)并追加到已选列表。
const byName = new Map(mdFiles.value.map((f) => [f.name, f]))
for (const file of mds) byName.set(file.name, file)
mdFiles.value = [...byName.values()]
} }
function chapterTitlesOf(lesson: ProposedLesson): string { function chapterTitlesOf(lesson: ProposedLesson): string {
@@ -65,15 +67,15 @@ function chapterTitlesOf(lesson: ProposedLesson): string {
} }
async function handleAnalyze(): Promise<void> { async function handleAnalyze(): Promise<void> {
if (!tocText.value.trim() || !zipFile.value) return if (!tocText.value.trim() || mdFiles.value.length === 0) return
inputError.value = null inputError.value = null
phase.value = 'division-loading' phase.value = 'division-loading'
try { try {
const map = await parseZip(zipFile.value) const map = await parseMdFiles(mdFiles.value)
entries.value = matchToc(tocText.value, map) entries.value = matchToc(tocText.value, map)
} catch { } catch {
inputError.value = '压缩包解析失败,请确认是有效的 .zip 文件。' inputError.value = 'Markdown 文件读取失败,请重试。'
phase.value = 'input' phase.value = 'input'
return return
} }
@@ -86,7 +88,12 @@ async function handleAnalyze(): Promise<void> {
try { try {
const result = await booksApi.divideLessonsFromBook( const result = await booksApi.divideLessonsFromBook(
entries.value.map((e) => ({ index: e.index, title: e.title, charCount: e.charCount })), entries.value.map((e) => ({
index: e.index,
title: e.title,
charCount: e.charCount,
depth: e.depth,
})),
) )
lessons.value = result.lessons lessons.value = result.lessons
phase.value = 'preview' phase.value = 'preview'
@@ -115,7 +122,7 @@ function handleStart(): void {
function handleClose(): void { function handleClose(): void {
phase.value = 'input' phase.value = 'input'
tocText.value = '' tocText.value = ''
zipFile.value = null mdFiles.value = []
inputError.value = null inputError.value = null
entries.value = [] entries.value = []
lessons.value = [] lessons.value = []
@@ -130,7 +137,7 @@ function handleClose(): void {
<!-- 第一步粘贴目录 + 上传 ZIP --> <!-- 第一步粘贴目录 + 上传 ZIP -->
<template v-if="phase === 'input'"> <template v-if="phase === 'input'">
<p>粘贴教材目录每行一个标题并上传内含各章 .md ZIP文件名为目录标题AI 将据此划分课时</p> <p>粘贴教材目录每行一个标题可用缩进表示章/节层级并上传各章 .md 文件文件名为目录标题可多选AI 将据此划分课时</p>
<p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p> <p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p>
<textarea <textarea
v-model="tocText" v-model="tocText"
@@ -138,10 +145,14 @@ function handleClose(): void {
rows="12" rows="12"
placeholder="第1章 C# 入门&#10;第2章 变量与数据类型&#10;第3章 流程控制" placeholder="第1章 C# 入门&#10;第2章 变量与数据类型&#10;第3章 流程控制"
/> />
<UploadDropzone accept=".zip,application/zip" :multiple="false" @files="onFiles" /> <UploadDropzone accept=".md,text/markdown" :multiple="true" @files="onFiles" />
<p v-if="zipFile" class="book-import-file">已选{{ zipFile.name }}</p> <p v-if="mdFiles.length" class="book-import-file">已选 {{ mdFiles.length }} 个文件</p>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" :disabled="!tocText.trim() || !zipFile" @click="handleAnalyze"> <button
type="button"
:disabled="!tocText.trim() || mdFiles.length === 0"
@click="handleAnalyze"
>
解析并划分课时 解析并划分课时
</button> </button>
<button type="button" @click="handleClose">取消</button> <button type="button" @click="handleClose">取消</button>

View File

@@ -1,19 +1,14 @@
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
assembleContent, assembleContent,
matchToc, matchToc,
normalizeTitle, normalizeTitle,
parseZip, parseMdFiles,
type BookEntry, type BookEntry,
} from './bookImport' } from './bookImport'
async function makeZip(files: Record<string, string>): Promise<Blob> { function makeMdFiles(files: Record<string, string>): File[] {
const zip = new JSZip() return Object.entries(files).map(([name, content]) => new File([content], name))
for (const [name, content] of Object.entries(files)) {
zip.file(name, content)
}
return zip.generateAsync({ type: 'blob' })
} }
describe('normalizeTitle', () => { describe('normalizeTitle', () => {
@@ -23,15 +18,15 @@ describe('normalizeTitle', () => {
}) })
}) })
describe('parseZip + matchToc', () => { describe('parseMdFiles + matchToc', () => {
it('matches toc lines to md files by normalized filename', async () => { it('matches toc lines to md files by normalized filename', async () => {
const blob = await makeZip({ const map = await parseMdFiles(
makeMdFiles({
'第1章 入门.md': '入门正文', '第1章 入门.md': '入门正文',
'第2章 变量.md': '变量正文', '第2章 变量.md': '变量正文',
'readme.txt': '忽略非 md', 'readme.txt': '忽略非 md',
}) }),
)
const map = await parseZip(blob)
const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map) const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map)
expect(entries).toHaveLength(3) expect(entries).toHaveLength(3)
@@ -40,9 +35,27 @@ describe('parseZip + matchToc', () => {
expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 }) expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 })
}) })
it('ignores directory prefixes inside the zip', async () => { it('infers depth from leading indentation, ranking distinct widths', async () => {
const blob = await makeZip({ 'book/第1章 入门.md': '正文' }) const map = await parseMdFiles([])
const map = await parseZip(blob) const entries = matchToc(
['第1章 入门', ' 1.1 安装', ' 1.2 第一个程序', '第2章 变量'].join('\n'),
map,
)
expect(entries.map((e) => e.depth)).toEqual([0, 1, 1, 0])
expect(entries[1]?.title).toBe('1.1 安装')
})
it('treats tab and space indentation as the same depth unit per document', async () => {
const map = await parseMdFiles([])
const spaces = matchToc(['章', ' 节'].join('\n'), map)
const tabs = matchToc(['章', '\t节'].join('\n'), map)
expect(spaces.map((e) => e.depth)).toEqual([0, 1])
expect(tabs.map((e) => e.depth)).toEqual([0, 1])
})
it('ignores directory prefixes in file names', async () => {
const map = await parseMdFiles(makeMdFiles({ 'book/第1章 入门.md': '正文' }))
const entries = matchToc('第1章 入门', map) const entries = matchToc('第1章 入门', map)
expect(entries[0]?.matched).toBe(true) expect(entries[0]?.matched).toBe(true)
}) })
@@ -51,9 +64,9 @@ describe('parseZip + matchToc', () => {
describe('assembleContent', () => { describe('assembleContent', () => {
it('concatenates content of all source entries in order', () => { it('concatenates content of all source entries in order', () => {
const entries: BookEntry[] = [ const entries: BookEntry[] = [
{ index: 0, title: 'a', content: 'AAA', charCount: 3, matched: true }, { index: 0, title: 'a', depth: 0, content: 'AAA', charCount: 3, matched: true },
{ index: 1, title: 'b', content: 'BBB', charCount: 3, matched: true }, { index: 1, title: 'b', depth: 0, content: 'BBB', charCount: 3, matched: true },
{ index: 2, title: 'c', content: '', charCount: 0, matched: false }, { index: 2, title: 'c', depth: 0, content: '', charCount: 0, matched: false },
] ]
expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB') expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB')
}) })

View File

@@ -1,9 +1,9 @@
import JSZip from 'jszip'
/** 目录中的一行,匹配到正文后携带内容与体量。 */ /** 目录中的一行,匹配到正文后携带内容与体量。 */
export interface BookEntry { export interface BookEntry {
index: number index: number
title: string title: string
/** 由前导缩进推断的层级0 为顶层(章),数字越大越深(节)。 */
depth: number
content: string content: string
charCount: number charCount: number
matched: boolean matched: boolean
@@ -27,38 +27,44 @@ export function normalizeTitle(value: string): string {
.trim() .trim()
} }
/** 解压 ZIP,按归一化文件名建立 { 标题 → 正文 } 映射(仅取 .md 文件)。 */ /** 读取多个 .md 文件,按归一化文件名建立 { 标题 → 正文 } 映射(忽略非 .md。 */
export async function parseZip(file: File | Blob): Promise<Map<string, string>> { export async function parseMdFiles(files: readonly File[]): Promise<Map<string, string>> {
const zip = await JSZip.loadAsync(file)
const map = new Map<string, string>() const map = new Map<string, string>()
const entries = Object.values(zip.files).filter( for (const file of files) {
(entry) => !entry.dir && /\.md$/i.test(entry.name), if (!/\.md$/i.test(file.name)) continue
) // 文件名可能带目录前缀(拖拽文件夹时),只取最后一段。
const base = file.name.split('/').pop() ?? file.name
for (const entry of entries) {
// 文件名可能带目录前缀,只取最后一段。
const base = entry.name.split('/').pop() ?? entry.name
const key = normalizeTitle(base) const key = normalizeTitle(base)
if (key) { if (key) {
map.set(key, await entry.async('text')) map.set(key, await file.text())
} }
} }
return map return map
} }
/** 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。 */ /** 一行前导空白的宽度tab 记 4 格),用作缩进度量。 */
function indentWidth(line: string): number {
const lead = line.match(/^[ \t]*/)?.[0] ?? ''
return lead.replace(/\t/g, ' ').length
}
/**
* 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。
* 缩进层级按全文出现的不同缩进宽度排名得出,兼容空格 / tab、2 格 / 4 格等不同风格。
*/
export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] { export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] {
return tocText const lines = tocText.split('\n').filter((line) => line.trim() !== '')
.split('\n') const widths = [...new Set(lines.map(indentWidth))].sort((a, b) => a - b)
.map((line) => line.trim())
.filter(Boolean) return lines.map((line, index) => {
.map((title, index) => { const title = line.trim()
const content = contentMap.get(normalizeTitle(title)) ?? '' const content = contentMap.get(normalizeTitle(title)) ?? ''
return { return {
index, index,
title, title,
depth: widths.indexOf(indentWidth(line)),
content, content,
charCount: content.length, charCount: content.length,
matched: content !== '', matched: content !== '',

View File

@@ -86,7 +86,7 @@ export function generateOutline(theme: string, count = 18): Promise<{ titles: st
} }
export function divideLessonsFromBook( export function divideLessonsFromBook(
entries: readonly { index: number; title: string; charCount: number }[], entries: readonly { index: number; title: string; charCount: number; depth: number }[],
): Promise<{ lessons: ProposedLesson[] }> { ): Promise<{ lessons: ProposedLesson[] }> {
return authedFetch('/api/generate/lessons-from-book', { return authedFetch('/api/generate/lessons-from-book', {
method: 'POST', method: 'POST',