From 7daa0e82501f9e21d15204b7c43f55ad3214ea36 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 15 Jun 2026 20:13:09 -0600 Subject: [PATCH] feat: load and autosave teaching books via booksApi --- src/composables/useTeachingBook.test.ts | 141 ++++++++++++++++++++++-- src/composables/useTeachingBook.ts | 101 ++++++++++++----- 2 files changed, 201 insertions(+), 41 deletions(-) diff --git a/src/composables/useTeachingBook.test.ts b/src/composables/useTeachingBook.test.ts index a7b0fcf..7d8c68a 100644 --- a/src/composables/useTeachingBook.test.ts +++ b/src/composables/useTeachingBook.test.ts @@ -1,14 +1,49 @@ +import { flushPromises } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../domain/teachingDesign' +import * as booksApi from '../services/booksApi' import { useTeachingBook } from './useTeachingBook' +vi.mock('../services/booksApi') + +function mockGetBook(data: TeachingBook, id = 'b1'): void { + vi.mocked(booksApi.getBook).mockResolvedValue({ id, name: '示例整本', updatedAt: data.updatedAt, data }) +} + describe('useTeachingBook', () => { beforeEach(() => { - localStorage.clear() + vi.clearAllMocks() vi.useFakeTimers() }) + it('loads the book from the API', async () => { + const data = createEmptyBook() + data.cover.courseName = 'Web 前端开发' + mockGetBook(data) + + const store = useTeachingBook('b1') + await flushPromises() + + expect(booksApi.getBook).toHaveBeenCalledWith('b1') + expect(store.loadStatus.value).toBe('loaded') + expect(store.book.value.cover.courseName).toBe('Web 前端开发') + }) + + it('sets loadStatus to error when loading fails', async () => { + vi.mocked(booksApi.getBook).mockRejectedValue(new Error('网络错误。')) + + const store = useTeachingBook('b1') + await flushPromises() + + expect(store.loadStatus.value).toBe('error') + expect(store.loadError.value).toBe('网络错误。') + }) + it('imports files in natural order and selects the first lesson', async () => { - const store = useTeachingBook() + mockGetBook(createEmptyBook()) + const store = useTeachingBook('b1') + await flushPromises() + const files = [ new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }), new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }), @@ -16,23 +51,107 @@ describe('useTeachingBook', () => { await store.importFiles(files, 'keep') - expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual([ - '2.md', - '10.md', - ]) + expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual(['2.md', '10.md']) expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id) }) it('reorders lessons without changing their identities', async () => { - const store = useTeachingBook() - await store.importFiles([ - new File(['# One 教学设计'], '1.md'), - new File(['# Two 教学设计'], '2.md'), - ], 'keep') + mockGetBook(createEmptyBook()) + const store = useTeachingBook('b1') + await flushPromises() + + await store.importFiles( + [new File(['# One 教学设计'], '1.md'), new File(['# Two 教学设计'], '2.md')], + 'keep', + ) const ids = store.book.value.designs.map((design) => design.id) store.moveDesign(0, 1) expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse()) }) + + it('does not autosave immediately after the initial load', async () => { + mockGetBook(createEmptyBook()) + useTeachingBook('b1') + await flushPromises() + + await vi.advanceTimersByTimeAsync(300) + + expect(booksApi.updateBook).not.toHaveBeenCalled() + }) + + it('autosaves the book via the API after the debounce delay', async () => { + mockGetBook(createEmptyBook()) + vi.mocked(booksApi.updateBook).mockResolvedValue({ id: 'b1', name: '示例整本', updatedAt: 'later' }) + + const store = useTeachingBook('b1') + await flushPromises() + + store.updateCover({ courseName: '新课程名' }) + await vi.advanceTimersByTimeAsync(300) + + expect(booksApi.updateBook).toHaveBeenCalledWith('b1', store.book.value) + expect(store.saveStatus.value).toBe('saved') + }) + + it('sets saveStatus to error when autosave fails', async () => { + mockGetBook(createEmptyBook()) + vi.mocked(booksApi.updateBook).mockRejectedValue(new Error('保存失败。')) + + const store = useTeachingBook('b1') + await flushPromises() + + store.updateCover({ courseName: '新课程名' }) + await vi.advanceTimersByTimeAsync(300) + + expect(store.saveStatus.value).toBe('error') + expect(store.lastError.value).toBe('保存失败。') + }) + + it('generateLesson appends a parsed design and selects it', async () => { + mockGetBook(createEmptyBook()) + vi.mocked(booksApi.generateLesson).mockResolvedValue({ + filename: 'css-flex.md', + markdown: '# CSS 弹性布局 教学设计', + }) + + const store = useTeachingBook('b1') + await flushPromises() + + const result = await store.generateLesson('CSS 弹性布局') + + expect(result).toEqual({ ok: true }) + expect(store.book.value.designs).toHaveLength(1) + expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id) + }) + + it('generateLesson returns an error when the API call fails', async () => { + mockGetBook(createEmptyBook()) + vi.mocked(booksApi.generateLesson).mockRejectedValue(new Error('Deepseek 请求失败。')) + + const store = useTeachingBook('b1') + await flushPromises() + + const result = await store.generateLesson('CSS 弹性布局') + + expect(result).toEqual({ ok: false, message: 'Deepseek 请求失败。' }) + expect(store.book.value.designs).toHaveLength(0) + }) + + it('clearBook empties designs but keeps the cover', async () => { + const data = createEmptyBook() + data.cover.courseName = 'Web 前端开发' + data.designs.push(createEmptyTeachingDesign('1.md')) + mockGetBook(data) + + const store = useTeachingBook('b1') + await flushPromises() + + store.clearBook() + + expect(store.book.value.designs).toEqual([]) + expect(store.book.value.cover.courseName).toBe('Web 前端开发') + expect(store.book.value.selectedId).toBe('cover') + }) }) diff --git a/src/composables/useTeachingBook.ts b/src/composables/useTeachingBook.ts index 8acceeb..9d9541c 100644 --- a/src/composables/useTeachingBook.ts +++ b/src/composables/useTeachingBook.ts @@ -1,4 +1,4 @@ -import { ref, watch, type Ref } from 'vue' +import { nextTick, ref, watch, type Ref } from 'vue' import { createEmptyBook, type BookCover, @@ -6,7 +6,7 @@ import { type TeachingBook, type TeachingDesign, } from '../domain/teachingDesign' -import { saveBook } from '../services/bookStorage' +import * as booksApi from '../services/booksApi' import { parseTeachingDesign } from '../services/markdownParser' import { sortFilesNaturally } from '../services/naturalSort' @@ -16,6 +16,10 @@ export type DuplicateStrategy = 'replace' | 'keep' export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' +export type LoadStatus = 'loading' | 'loaded' | 'error' + +export type GenerateLessonResult = { ok: true } | { ok: false; message: string } + export interface ImportResult { imported: number failed: Array<{ filename: string; message: string }> @@ -24,9 +28,10 @@ export interface ImportResult { export interface TeachingBookStore { book: Ref + loadStatus: Ref + loadError: Ref saveStatus: Ref lastError: Ref - pendingDuplicateFiles: Ref selectedDesign: Ref hasDesigns: Ref warningCount: Ref @@ -37,20 +42,24 @@ export interface TeachingBookStore { removeDesign: (id: DesignId) => void updateCover: (patch: Partial) => void updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void - restore: (book: TeachingBook) => void clearBook: () => void + generateLesson: (topic: string) => Promise } -export function useTeachingBook(): TeachingBookStore { +export function useTeachingBook(bookId: string): TeachingBookStore { const book = ref(createEmptyBook()) as Ref + const loadStatus = ref('loading') + const loadError = ref(null) const saveStatus = ref('idle') const lastError = ref(null) - const pendingDuplicateFiles = ref([]) const selectedDesign = ref(null) const hasDesigns = ref(false) const warningCount = ref(0) + let isLoading = true + let autosaveTimer: ReturnType | undefined + function syncDerived(): void { const current = book.value hasDesigns.value = current.designs.length > 0 @@ -66,36 +75,56 @@ export function useTeachingBook(): TeachingBookStore { syncDerived() - let autosaveTimer: ReturnType | undefined - function touch(): void { book.value.updatedAt = new Date().toISOString() } + function scheduleSave(): void { + if (autosaveTimer !== undefined) { + clearTimeout(autosaveTimer) + } + + autosaveTimer = setTimeout(() => { + saveStatus.value = 'saving' + booksApi + .updateBook(bookId, book.value) + .then(() => { + saveStatus.value = 'saved' + lastError.value = null + }) + .catch((error: unknown) => { + saveStatus.value = 'error' + lastError.value = error instanceof Error ? error.message : '保存失败。' + }) + }, AUTOSAVE_DELAY_MS) + } + watch( book, () => { syncDerived() - - if (autosaveTimer !== undefined) { - clearTimeout(autosaveTimer) - } - - autosaveTimer = setTimeout(() => { - saveStatus.value = 'saving' - const result = saveBook(book.value) - if (result.ok) { - saveStatus.value = 'saved' - lastError.value = null - } else { - saveStatus.value = 'error' - lastError.value = result.message - } - }, AUTOSAVE_DELAY_MS) + if (isLoading) return + scheduleSave() }, { deep: true }, ) + async function load(): Promise { + try { + const record = await booksApi.getBook(bookId) + book.value = record.data + await nextTick() + loadStatus.value = 'loaded' + } catch (error) { + loadStatus.value = 'error' + loadError.value = error instanceof Error ? error.message : '加载失败。' + } finally { + isLoading = false + } + } + + void load() + function detectDuplicates(files: readonly File[]): string[] { const existingNames = new Set(book.value.designs.map((design) => design.originalFilename)) return files.map((file) => file.name).filter((name) => existingNames.has(name)) @@ -197,19 +226,31 @@ export function useTeachingBook(): TeachingBookStore { touch() } - function restore(restored: TeachingBook): void { - book.value = restored + function clearBook(): void { + book.value.designs = [] + book.value.selectedId = 'cover' + touch() } - function clearBook(): void { - book.value = createEmptyBook() + async function generateLesson(topic: string): Promise { + try { + const result = await booksApi.generateLesson(topic) + const design = parseTeachingDesign(result.filename, result.markdown) + book.value.designs.push(design) + book.value.selectedId = design.id + touch() + return { ok: true } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : '生成失败。' } + } } return { book, + loadStatus, + loadError, saveStatus, lastError, - pendingDuplicateFiles, selectedDesign, hasDesigns, warningCount, @@ -220,7 +261,7 @@ export function useTeachingBook(): TeachingBookStore { removeDesign, updateCover, updateDesign, - restore, clearBook, + generateLesson, } }