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

View File

@@ -1,5 +1,4 @@
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'
@@ -14,18 +13,15 @@ const baseProps = {
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' })
function makeMdFiles(files: Record<string, string>): File[] {
return Object.entries(files).map(
([name, content]) => new File([content], name, { type: 'text/markdown' }),
)
}
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"]')
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
await input.trigger('change')
}
@@ -38,7 +34,7 @@ describe('BookImportDialog', () => {
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({
lessons: [
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
@@ -49,19 +45,19 @@ describe('BookImportDialog', () => {
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)
const files = makeMdFiles({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
await uploadFiles(wrapper, files)
await flushPromises()
// 解析并划分(parseZip 经由真实定时器解析,故用 waitFor 等待预览出现)
// 解析并划分(File.text() 为异步,故用 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 },
{ index: 0, title: '第1章 入门', charCount: 4, depth: 0 },
{ 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 txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
await uploadFile(wrapper, txt)
await uploadFiles(wrapper, [txt])
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 {
assembleContent,
matchToc,
parseZip,
parseMdFiles,
type BookEntry,
type ProposedLesson,
} from '../services/bookImport'
@@ -29,7 +29,7 @@ const emit = defineEmits<{
const phase = ref<Phase>('input')
const tocText = ref('')
const zipFile = ref<File | null>(null)
const mdFiles = ref<File[]>([])
const inputError = ref<string | null>(null)
const entries = ref<BookEntry[]>([])
@@ -47,14 +47,16 @@ watch(
)
function onFiles(files: File[]): void {
const file = files[0]
if (!file) return
if (!/\.zip$/i.test(file.name)) {
inputError.value = '请上传 .zip 压缩包。'
const mds = files.filter((file) => /\.md$/i.test(file.name))
if (mds.length === 0) {
inputError.value = '请上传 .md 文件。'
return
}
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 {
@@ -65,15 +67,15 @@ function chapterTitlesOf(lesson: ProposedLesson): string {
}
async function handleAnalyze(): Promise<void> {
if (!tocText.value.trim() || !zipFile.value) return
if (!tocText.value.trim() || mdFiles.value.length === 0) return
inputError.value = null
phase.value = 'division-loading'
try {
const map = await parseZip(zipFile.value)
const map = await parseMdFiles(mdFiles.value)
entries.value = matchToc(tocText.value, map)
} catch {
inputError.value = '压缩包解析失败,请确认是有效的 .zip 文件。'
inputError.value = 'Markdown 文件读取失败,请重试。'
phase.value = 'input'
return
}
@@ -86,7 +88,12 @@ async function handleAnalyze(): Promise<void> {
try {
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
phase.value = 'preview'
@@ -115,7 +122,7 @@ function handleStart(): void {
function handleClose(): void {
phase.value = 'input'
tocText.value = ''
zipFile.value = null
mdFiles.value = []
inputError.value = null
entries.value = []
lessons.value = []
@@ -130,7 +137,7 @@ function handleClose(): void {
<!-- 第一步粘贴目录 + 上传 ZIP -->
<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>
<textarea
v-model="tocText"
@@ -138,10 +145,14 @@ function handleClose(): void {
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>
<UploadDropzone accept=".md,text/markdown" :multiple="true" @files="onFiles" />
<p v-if="mdFiles.length" class="book-import-file">已选 {{ mdFiles.length }} 个文件</p>
<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 type="button" @click="handleClose">取消</button>

View File

@@ -1,19 +1,14 @@
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import {
assembleContent,
matchToc,
normalizeTitle,
parseZip,
parseMdFiles,
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' })
function makeMdFiles(files: Record<string, string>): File[] {
return Object.entries(files).map(([name, content]) => new File([content], name))
}
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 () => {
const blob = await makeZip({
const map = await parseMdFiles(
makeMdFiles({
'第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)
@@ -40,9 +35,27 @@ describe('parseZip + matchToc', () => {
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)
it('infers depth from leading indentation, ranking distinct widths', async () => {
const map = await parseMdFiles([])
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)
expect(entries[0]?.matched).toBe(true)
})
@@ -51,9 +64,9 @@ describe('parseZip + matchToc', () => {
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 },
{ index: 0, title: 'a', depth: 0, content: 'AAA', charCount: 3, matched: true },
{ index: 1, title: 'b', depth: 0, content: 'BBB', charCount: 3, matched: true },
{ index: 2, title: 'c', depth: 0, content: '', charCount: 0, matched: false },
]
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 {
index: number
title: string
/** 由前导缩进推断的层级0 为顶层(章),数字越大越深(节)。 */
depth: number
content: string
charCount: number
matched: boolean
@@ -27,38 +27,44 @@ export function normalizeTitle(value: string): string {
.trim()
}
/** 解压 ZIP,按归一化文件名建立 { 标题 → 正文 } 映射(仅取 .md 文件)。 */
export async function parseZip(file: File | Blob): Promise<Map<string, string>> {
const zip = await JSZip.loadAsync(file)
/** 读取多个 .md 文件,按归一化文件名建立 { 标题 → 正文 } 映射(忽略非 .md。 */
export async function parseMdFiles(files: readonly File[]): Promise<Map<string, string>> {
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
for (const file of files) {
if (!/\.md$/i.test(file.name)) continue
// 文件名可能带目录前缀(拖拽文件夹时),只取最后一段。
const base = file.name.split('/').pop() ?? file.name
const key = normalizeTitle(base)
if (key) {
map.set(key, await entry.async('text'))
map.set(key, await file.text())
}
}
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[] {
return tocText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title, index) => {
const lines = tocText.split('\n').filter((line) => line.trim() !== '')
const widths = [...new Set(lines.map(indentWidth))].sort((a, b) => a - b)
return lines.map((line, index) => {
const title = line.trim()
const content = contentMap.get(normalizeTitle(title)) ?? ''
return {
index,
title,
depth: widths.indexOf(indentWidth(line)),
content,
charCount: content.length,
matched: content !== '',

View File

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