update
This commit is contained in:
@@ -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,不要解释、不要代码块围栏,形如:' +
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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# 入门 第2章 变量与数据类型 第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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 !== '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user