From 7d9aeebdaf5481160d67f26c2c7582e653788b57 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 24 Jun 2026 06:36:25 -0600 Subject: [PATCH] update --- server/routes/generate.ts | 17 +++++-- src/components/BookImportDialog.test.ts | 34 ++++++-------- src/components/BookImportDialog.vue | 43 ++++++++++------- src/services/bookImport.test.ts | 57 ++++++++++++++--------- src/services/bookImport.ts | 62 ++++++++++++++----------- src/services/booksApi.ts | 2 +- 6 files changed, 125 insertions(+), 90 deletions(-) diff --git a/server/routes/generate.ts b/server/routes/generate.ts index 7f136b9..3c4fedf 100644 --- a/server/routes/generate.ts +++ b/server/routes/generate.ts @@ -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: - '你是教学设计专家。下面是一本教材的目录,每行为「序号标题正文体量」。' + + '你是教学设计专家。下面是一本教材的目录,每行为「序号标题正文体量」,标题前的缩进表示层级(章 → 节 → 小节),同一上级标题下的条目应优先归到相邻课时。' + '请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' + '每个课题对应一个或多个目录条目(用其序号表示)。' + '仅输出 JSON,不要解释、不要代码块围栏,形如:' + diff --git a/src/components/BookImportDialog.test.ts b/src/components/BookImportDialog.test.ts index 013ddf6..401e494 100644 --- a/src/components/BookImportDialog.test.ts +++ b/src/components/BookImportDialog.test.ts @@ -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): Promise { - 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): File[] { + return Object.entries(files).map( + ([name, content]) => new File([content], name, { type: 'text/markdown' }), + ) } -async function uploadFile(wrapper: ReturnType, file: File): Promise { +async function uploadFiles(wrapper: ReturnType, files: File[]): Promise { 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') }) }) diff --git a/src/components/BookImportDialog.vue b/src/components/BookImportDialog.vue index f3c85f3..54b0551 100644 --- a/src/components/BookImportDialog.vue +++ b/src/components/BookImportDialog.vue @@ -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('input') const tocText = ref('') -const zipFile = ref(null) +const mdFiles = ref([]) const inputError = ref(null) const entries = ref([]) @@ -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 { - 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 { 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 {