feat: load and autosave teaching books via booksApi
This commit is contained in:
@@ -1,14 +1,49 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
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', () => {
|
describe('useTeachingBook', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
vi.clearAllMocks()
|
||||||
vi.useFakeTimers()
|
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 () => {
|
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 = [
|
const files = [
|
||||||
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
|
new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }),
|
||||||
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
|
new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }),
|
||||||
@@ -16,23 +51,107 @@ describe('useTeachingBook', () => {
|
|||||||
|
|
||||||
await store.importFiles(files, 'keep')
|
await store.importFiles(files, 'keep')
|
||||||
|
|
||||||
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual([
|
expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual(['2.md', '10.md'])
|
||||||
'2.md',
|
|
||||||
'10.md',
|
|
||||||
])
|
|
||||||
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reorders lessons without changing their identities', async () => {
|
it('reorders lessons without changing their identities', async () => {
|
||||||
const store = useTeachingBook()
|
mockGetBook(createEmptyBook())
|
||||||
await store.importFiles([
|
const store = useTeachingBook('b1')
|
||||||
new File(['# One 教学设计'], '1.md'),
|
await flushPromises()
|
||||||
new File(['# Two 教学设计'], '2.md'),
|
|
||||||
], 'keep')
|
await store.importFiles(
|
||||||
|
[new File(['# One 教学设计'], '1.md'), new File(['# Two 教学设计'], '2.md')],
|
||||||
|
'keep',
|
||||||
|
)
|
||||||
|
|
||||||
const ids = store.book.value.designs.map((design) => design.id)
|
const ids = store.book.value.designs.map((design) => design.id)
|
||||||
store.moveDesign(0, 1)
|
store.moveDesign(0, 1)
|
||||||
|
|
||||||
expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse())
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, watch, type Ref } from 'vue'
|
import { nextTick, ref, watch, type Ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
createEmptyBook,
|
createEmptyBook,
|
||||||
type BookCover,
|
type BookCover,
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type TeachingBook,
|
type TeachingBook,
|
||||||
type TeachingDesign,
|
type TeachingDesign,
|
||||||
} from '../domain/teachingDesign'
|
} from '../domain/teachingDesign'
|
||||||
import { saveBook } from '../services/bookStorage'
|
import * as booksApi from '../services/booksApi'
|
||||||
import { parseTeachingDesign } from '../services/markdownParser'
|
import { parseTeachingDesign } from '../services/markdownParser'
|
||||||
import { sortFilesNaturally } from '../services/naturalSort'
|
import { sortFilesNaturally } from '../services/naturalSort'
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ export type DuplicateStrategy = 'replace' | 'keep'
|
|||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
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 {
|
export interface ImportResult {
|
||||||
imported: number
|
imported: number
|
||||||
failed: Array<{ filename: string; message: string }>
|
failed: Array<{ filename: string; message: string }>
|
||||||
@@ -24,9 +28,10 @@ export interface ImportResult {
|
|||||||
|
|
||||||
export interface TeachingBookStore {
|
export interface TeachingBookStore {
|
||||||
book: Ref<TeachingBook>
|
book: Ref<TeachingBook>
|
||||||
|
loadStatus: Ref<LoadStatus>
|
||||||
|
loadError: Ref<string | null>
|
||||||
saveStatus: Ref<SaveStatus>
|
saveStatus: Ref<SaveStatus>
|
||||||
lastError: Ref<string | null>
|
lastError: Ref<string | null>
|
||||||
pendingDuplicateFiles: Ref<File[]>
|
|
||||||
selectedDesign: Ref<TeachingDesign | null>
|
selectedDesign: Ref<TeachingDesign | null>
|
||||||
hasDesigns: Ref<boolean>
|
hasDesigns: Ref<boolean>
|
||||||
warningCount: Ref<number>
|
warningCount: Ref<number>
|
||||||
@@ -37,20 +42,24 @@ export interface TeachingBookStore {
|
|||||||
removeDesign: (id: DesignId) => void
|
removeDesign: (id: DesignId) => void
|
||||||
updateCover: (patch: Partial<BookCover>) => void
|
updateCover: (patch: Partial<BookCover>) => void
|
||||||
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
||||||
restore: (book: TeachingBook) => void
|
|
||||||
clearBook: () => void
|
clearBook: () => void
|
||||||
|
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTeachingBook(): TeachingBookStore {
|
export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||||
const book = ref<TeachingBook>(createEmptyBook()) as Ref<TeachingBook>
|
const book = ref<TeachingBook>(createEmptyBook()) as Ref<TeachingBook>
|
||||||
|
const loadStatus = ref<LoadStatus>('loading')
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
const saveStatus = ref<SaveStatus>('idle')
|
const saveStatus = ref<SaveStatus>('idle')
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null)
|
||||||
const pendingDuplicateFiles = ref<File[]>([])
|
|
||||||
|
|
||||||
const selectedDesign = ref<TeachingDesign | null>(null)
|
const selectedDesign = ref<TeachingDesign | null>(null)
|
||||||
const hasDesigns = ref(false)
|
const hasDesigns = ref(false)
|
||||||
const warningCount = ref(0)
|
const warningCount = ref(0)
|
||||||
|
|
||||||
|
let isLoading = true
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
function syncDerived(): void {
|
function syncDerived(): void {
|
||||||
const current = book.value
|
const current = book.value
|
||||||
hasDesigns.value = current.designs.length > 0
|
hasDesigns.value = current.designs.length > 0
|
||||||
@@ -66,36 +75,56 @@ export function useTeachingBook(): TeachingBookStore {
|
|||||||
|
|
||||||
syncDerived()
|
syncDerived()
|
||||||
|
|
||||||
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
function touch(): void {
|
function touch(): void {
|
||||||
book.value.updatedAt = new Date().toISOString()
|
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(
|
watch(
|
||||||
book,
|
book,
|
||||||
() => {
|
() => {
|
||||||
syncDerived()
|
syncDerived()
|
||||||
|
if (isLoading) return
|
||||||
if (autosaveTimer !== undefined) {
|
scheduleSave()
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
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[] {
|
function detectDuplicates(files: readonly File[]): string[] {
|
||||||
const existingNames = new Set(book.value.designs.map((design) => design.originalFilename))
|
const existingNames = new Set(book.value.designs.map((design) => design.originalFilename))
|
||||||
return files.map((file) => file.name).filter((name) => existingNames.has(name))
|
return files.map((file) => file.name).filter((name) => existingNames.has(name))
|
||||||
@@ -197,19 +226,31 @@ export function useTeachingBook(): TeachingBookStore {
|
|||||||
touch()
|
touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore(restored: TeachingBook): void {
|
function clearBook(): void {
|
||||||
book.value = restored
|
book.value.designs = []
|
||||||
|
book.value.selectedId = 'cover'
|
||||||
|
touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBook(): void {
|
async function generateLesson(topic: string): Promise<GenerateLessonResult> {
|
||||||
book.value = createEmptyBook()
|
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 {
|
return {
|
||||||
book,
|
book,
|
||||||
|
loadStatus,
|
||||||
|
loadError,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
lastError,
|
lastError,
|
||||||
pendingDuplicateFiles,
|
|
||||||
selectedDesign,
|
selectedDesign,
|
||||||
hasDesigns,
|
hasDesigns,
|
||||||
warningCount,
|
warningCount,
|
||||||
@@ -220,7 +261,7 @@ export function useTeachingBook(): TeachingBookStore {
|
|||||||
removeDesign,
|
removeDesign,
|
||||||
updateCover,
|
updateCover,
|
||||||
updateDesign,
|
updateDesign,
|
||||||
restore,
|
|
||||||
clearBook,
|
clearBook,
|
||||||
|
generateLesson,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user