diff --git a/src/components/WorkspaceView.test.ts b/src/components/WorkspaceView.test.ts index da1880d..bc3668e 100644 --- a/src/components/WorkspaceView.test.ts +++ b/src/components/WorkspaceView.test.ts @@ -2,6 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign' import * as booksApi from '../services/booksApi' +import BatchGenerateDialog from './BatchGenerateDialog.vue' import GenerateLessonDialog from './GenerateLessonDialog.vue' import WorkspaceView from './WorkspaceView.vue' @@ -16,6 +17,32 @@ function mockBook(data = createEmptyBook()): void { }) } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +function generatedLesson(topic: string): booksApi.GenerateResult { + return { + filename: `${topic}.md`, + markdown: [ + `# ${topic} 教学设计`, + '|:---|:---|', + `| **课题** | **${topic}** |`, + '| **课时** | 1课时(40分钟) |', + ].join('\n'), + } +} + describe('WorkspaceView', () => { beforeEach(() => { vi.clearAllMocks() @@ -87,6 +114,68 @@ describe('WorkspaceView', () => { expect(wrapper.text()).not.toContain('封面') }) + it('batch generates up to three lessons concurrently and keeps outline order', async () => { + mockBook() + const requests = new Map>>() + vi.mocked(booksApi.generateLesson).mockImplementation((topic) => { + const request = deferred() + requests.set(topic, request) + return request.promise + }) + + const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } }) + await flushPromises() + + await wrapper.get('[data-testid="batch-generate"]').trigger('click') + wrapper.getComponent(BatchGenerateDialog).vm.$emit('start', [ + '第一课', + '第二课', + '第三课', + '第四课', + '第五课', + ]) + await flushPromises() + + expect(booksApi.generateLesson).toHaveBeenCalledTimes(3) + expect(booksApi.generateLesson).toHaveBeenNthCalledWith(1, '第一课') + expect(booksApi.generateLesson).toHaveBeenNthCalledWith(2, '第二课') + expect(booksApi.generateLesson).toHaveBeenNthCalledWith(3, '第三课') + + requests.get('第三课')!.resolve(generatedLesson('第三课')) + await flushPromises() + expect(booksApi.generateLesson).toHaveBeenCalledTimes(4) + expect(booksApi.generateLesson).toHaveBeenNthCalledWith(4, '第四课') + expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([]) + + requests.get('第四课')!.resolve(generatedLesson('第四课')) + await flushPromises() + expect(booksApi.generateLesson).toHaveBeenCalledTimes(5) + expect(booksApi.generateLesson).toHaveBeenNthCalledWith(5, '第五课') + + requests.get('第一课')!.resolve(generatedLesson('第一课')) + await flushPromises() + expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual(['第一课']) + + requests.get('第二课')!.resolve(generatedLesson('第二课')) + await flushPromises() + expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([ + '第一课', + '第二课', + '第三课', + '第四课', + ]) + + requests.get('第五课')!.resolve(generatedLesson('第五课')) + await flushPromises() + expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([ + '第一课', + '第二课', + '第三课', + '第四课', + '第五课', + ]) + }) + it('clears the lessons after confirmation', async () => { const data = createEmptyBook() data.designs.push(createEmptyTeachingDesign('1.md')) diff --git a/src/components/WorkspaceView.vue b/src/components/WorkspaceView.vue index a3b7b01..400bc40 100644 --- a/src/components/WorkspaceView.vue +++ b/src/components/WorkspaceView.vue @@ -13,6 +13,8 @@ import PrintBook from './PrintBook.vue' import UploadDropzone from './UploadDropzone.vue' import WorkspaceToolbar from './WorkspaceToolbar.vue' +const BATCH_GENERATE_CONCURRENCY = 3 + const props = defineProps<{ bookId: string }>() defineEmits<{ back: [] }>() @@ -35,6 +37,7 @@ const { updateDesign, clearBook, generateLesson, + generateLessons, regenerateLesson, } = useTeachingBook(props.bookId) @@ -153,15 +156,19 @@ async function handleBatchStart(topics: string[]): Promise { batchTotal.value = topics.length batchError.value = null - for (const topic of topics) { - if (batchCancelled.value) break - batchCurrentTopic.value = topic - const result = await generateLesson(topic) - if (!result.ok) { - batchError.value = result.message - break - } - batchDone.value++ + const result = await generateLessons(topics, { + concurrency: BATCH_GENERATE_CONCURRENCY, + isCancelled: () => batchCancelled.value, + onTopicStart: (topic) => { + batchCurrentTopic.value = topic + }, + onLessonComplete: (count) => { + batchDone.value += count + }, + }) + + if (!result.ok) { + batchError.value = result.message } batchRunning.value = false diff --git a/src/composables/useTeachingBook.ts b/src/composables/useTeachingBook.ts index a8ed5ba..b7e2c46 100644 --- a/src/composables/useTeachingBook.ts +++ b/src/composables/useTeachingBook.ts @@ -19,6 +19,17 @@ export type LoadStatus = 'loading' | 'loaded' | 'error' export type GenerateLessonResult = { ok: true } | { ok: false; message: string } +export type BatchGenerateLessonResult = + | { ok: true; completed: number } + | { ok: false; completed: number; message: string } + +export interface BatchGenerateLessonOptions { + concurrency?: number + isCancelled?: () => boolean + onTopicStart?: (topic: string) => void + onLessonComplete?: (count: number) => void +} + export interface ImportResult { imported: number failed: Array<{ filename: string; message: string }> @@ -43,6 +54,10 @@ export interface TeachingBookStore { updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void clearBook: () => void generateLesson: (topic: string) => Promise + generateLessons: ( + topics: readonly string[], + options?: BatchGenerateLessonOptions, + ) => Promise regenerateLesson: (id: DesignId) => Promise } @@ -242,6 +257,64 @@ export function useTeachingBook(bookId: string): TeachingBookStore { } } + async function generateLessons( + topics: readonly string[], + options: BatchGenerateLessonOptions = {}, + ): Promise { + const concurrency = Math.max(1, options.concurrency ?? 3) + const workerCount = Math.min(concurrency, topics.length) + const results = new Array(topics.length) + let nextStartIndex = 0 + let nextAppendIndex = 0 + let appendedCount = 0 + let firstError: string | null = null + + function appendReadyLessons(): void { + let readyCount = 0 + + while (nextAppendIndex < results.length) { + const design = results[nextAppendIndex] + if (!design) break + book.value.designs.push(design) + book.value.selectedId = design.id + nextAppendIndex++ + readyCount++ + } + + if (readyCount > 0) { + appendedCount += readyCount + touch() + options.onLessonComplete?.(readyCount) + } + } + + async function runWorker(): Promise { + while (!firstError && !options.isCancelled?.()) { + const index = nextStartIndex + if (index >= topics.length) return + + nextStartIndex++ + const topic = topics[index]! + options.onTopicStart?.(topic) + + try { + const result = await booksApi.generateLesson(topic) + results[index] = parseTeachingDesign(result.filename, result.markdown) + appendReadyLessons() + } catch (error) { + firstError = error instanceof Error ? error.message : '生成失败。' + } + } + } + + await Promise.all(Array.from({ length: workerCount }, () => runWorker())) + appendReadyLessons() + + return firstError + ? { ok: false, completed: appendedCount, message: firstError } + : { ok: true, completed: appendedCount } + } + async function regenerateLesson(id: DesignId): Promise { const existing = book.value.designs.find((d) => d.id === id) if (!existing) return { ok: false, message: '找不到该教案。' } @@ -282,6 +355,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore { updateDesign, clearBook, generateLesson, + generateLessons, regenerateLesson, } }