Merge branch 'remove-cover-page'
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
import { existsSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +13,12 @@ afterEach(() => {
|
|||||||
setSystemTime()
|
setSystemTime()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function tempDbPath(name: string): string {
|
||||||
|
const path = join(tmpdir(), `fake-teaching-design-${name}-${crypto.randomUUID()}.db`)
|
||||||
|
if (existsSync(path)) rmSync(path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
describe('db', () => {
|
describe('db', () => {
|
||||||
it('creates a book with empty data', () => {
|
it('creates a book with empty data', () => {
|
||||||
const db = openDb(':memory:')
|
const db = openDb(':memory:')
|
||||||
@@ -55,13 +64,85 @@ describe('db', () => {
|
|||||||
const db = openDb(':memory:')
|
const db = openDb(':memory:')
|
||||||
const created = createBook(db, '示例整本')
|
const created = createBook(db, '示例整本')
|
||||||
const data = createEmptyBook()
|
const data = createEmptyBook()
|
||||||
data.cover.courseName = 'Web 前端开发'
|
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||||||
|
|
||||||
setSystemTime(new Date('2026-02-01T00:00:00.000Z'))
|
setSystemTime(new Date('2026-02-01T00:00:00.000Z'))
|
||||||
const result = saveBookData(db, created.id, data)
|
const result = saveBookData(db, created.id, data)
|
||||||
|
|
||||||
expect(result).toEqual({ id: created.id, name: '示例整本', updatedAt: '2026-02-01T00:00:00.000Z' })
|
expect(result).toEqual({ id: created.id, name: '示例整本', updatedAt: '2026-02-01T00:00:00.000Z' })
|
||||||
expect(getBook(db, created.id)?.data.cover.courseName).toBe('Web 前端开发')
|
expect(getBook(db, created.id)?.data).not.toHaveProperty('cover')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates legacy cover data and cover selection on open', () => {
|
||||||
|
const path = tempDbPath('cover-migration')
|
||||||
|
const db = openDb(path)
|
||||||
|
const design = createEmptyTeachingDesign('1.md')
|
||||||
|
const legacy = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
cover: { courseName: '旧课程', teacherName: '旧教师' },
|
||||||
|
designs: [design],
|
||||||
|
selectedId: 'cover',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
db.run('INSERT INTO books (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
|
'legacy-1',
|
||||||
|
'旧整本',
|
||||||
|
JSON.stringify(legacy),
|
||||||
|
'2026-01-01T00:00:00.000Z',
|
||||||
|
'2026-01-01T00:00:00.000Z',
|
||||||
|
])
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
const reopened = openDb(path)
|
||||||
|
const migrated = getBook(reopened, 'legacy-1')!.data
|
||||||
|
const raw = reopened.query<{ data: string }, [string]>('SELECT data FROM books WHERE id = ?').get('legacy-1')!.data
|
||||||
|
reopened.close()
|
||||||
|
rmSync(path)
|
||||||
|
|
||||||
|
expect(migrated).not.toHaveProperty('cover')
|
||||||
|
expect(migrated.selectedId).toBe(design.id)
|
||||||
|
expect(JSON.parse(raw)).not.toHaveProperty('cover')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates legacy cover selection to null when no lessons exist', () => {
|
||||||
|
const path = tempDbPath('empty-cover-migration')
|
||||||
|
const db = openDb(path)
|
||||||
|
db.run('INSERT INTO books (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
|
'legacy-empty',
|
||||||
|
'空整本',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
cover: { courseName: '旧课程', teacherName: '旧教师' },
|
||||||
|
designs: [],
|
||||||
|
selectedId: 'cover',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'2026-01-01T00:00:00.000Z',
|
||||||
|
'2026-01-01T00:00:00.000Z',
|
||||||
|
])
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
const reopened = openDb(path)
|
||||||
|
const migrated = getBook(reopened, 'legacy-empty')!.data
|
||||||
|
reopened.close()
|
||||||
|
rmSync(path)
|
||||||
|
|
||||||
|
expect(migrated).not.toHaveProperty('cover')
|
||||||
|
expect(migrated.selectedId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes invalid selected ids to the first lesson', () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
const created = createBook(db, '示例整本')
|
||||||
|
const data = createEmptyBook()
|
||||||
|
const design = createEmptyTeachingDesign('1.md')
|
||||||
|
data.designs.push(design)
|
||||||
|
db.run('UPDATE books SET data = ? WHERE id = ?', [
|
||||||
|
JSON.stringify({ ...data, selectedId: 'missing-id' }),
|
||||||
|
created.id,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(getBook(db, created.id)?.data.selectedId).toBe(design.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when saving data for a missing book', () => {
|
it('returns null when saving data for a missing book', () => {
|
||||||
|
|||||||
66
server/db.ts
66
server/db.ts
@@ -28,6 +28,16 @@ interface BookRow {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
|
||||||
|
cover?: unknown
|
||||||
|
selectedId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedBookData {
|
||||||
|
data: TeachingBook
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserRecord {
|
export interface UserRecord {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -77,10 +87,59 @@ const SCHEMA = `
|
|||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
function normalizeBookData(raw: StoredTeachingBook): NormalizedBookData {
|
||||||
|
const data = { ...raw, designs: Array.isArray(raw.designs) ? raw.designs : [] }
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
if ('cover' in data) {
|
||||||
|
delete data.cover
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedId = data.selectedId ?? null
|
||||||
|
const firstDesignId = data.designs[0]?.id ?? null
|
||||||
|
const selectedExists =
|
||||||
|
selectedId !== null && data.designs.some((design) => design.id === selectedId)
|
||||||
|
|
||||||
|
let normalizedSelectedId: TeachingBook['selectedId']
|
||||||
|
if (selectedId === 'cover' || (selectedId !== null && !selectedExists)) {
|
||||||
|
normalizedSelectedId = firstDesignId
|
||||||
|
changed = selectedId !== normalizedSelectedId || changed
|
||||||
|
} else {
|
||||||
|
normalizedSelectedId = selectedId as TeachingBook['selectedId']
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
schemaVersion: data.schemaVersion,
|
||||||
|
designs: data.designs,
|
||||||
|
selectedId: normalizedSelectedId,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
},
|
||||||
|
changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBookData(data: string): TeachingBook {
|
||||||
|
return normalizeBookData(JSON.parse(data) as StoredTeachingBook).data
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStoredBooks(db: Database): void {
|
||||||
|
const rows = db.query<{ id: string; data: string }, []>('SELECT id, data FROM books').all()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const normalized = normalizeBookData(JSON.parse(row.data) as StoredTeachingBook)
|
||||||
|
if (normalized.changed) {
|
||||||
|
db.run('UPDATE books SET data = ? WHERE id = ?', [JSON.stringify(normalized.data), row.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openDb(path: string): Database {
|
export function openDb(path: string): Database {
|
||||||
const db = new Database(path)
|
const db = new Database(path)
|
||||||
db.run('PRAGMA foreign_keys = ON')
|
db.run('PRAGMA foreign_keys = ON')
|
||||||
db.run(SCHEMA)
|
db.run(SCHEMA)
|
||||||
|
migrateStoredBooks(db)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +152,7 @@ export function listBooks(db: Database): BookSummary[] {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
lessonCount: (JSON.parse(row.data) as TeachingBook).designs.length,
|
lessonCount: parseBookData(row.data).designs.length,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +183,7 @@ export function getBook(db: Database, id: string): BookRecord | null {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
data: JSON.parse(row.data) as TeachingBook,
|
data: parseBookData(row.data),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +194,8 @@ export function saveBookData(db: Database, id: string, data: TeachingBook): Book
|
|||||||
if (!existing) return null
|
if (!existing) return null
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
db.run('UPDATE books SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(data), now, id])
|
const normalized = normalizeBookData(data as unknown as StoredTeachingBook).data
|
||||||
|
db.run('UPDATE books SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(normalized), now, id])
|
||||||
|
|
||||||
return { id, name: existing.name, updatedAt: now }
|
return { id, name: existing.name, updatedAt: now }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'bun:test'
|
import { beforeEach, describe, expect, it } from 'bun:test'
|
||||||
import type { Database } from 'bun:sqlite'
|
import type { Database } from 'bun:sqlite'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { createEmptyBook } from '../../src/domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../../src/domain/teachingDesign'
|
||||||
import { openDb } from '../db'
|
import { openDb } from '../db'
|
||||||
import { createBooksRouter } from './books'
|
import { createBooksRouter } from './books'
|
||||||
|
|
||||||
@@ -64,22 +64,22 @@ describe('books routes', () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves book data', async () => {
|
it('saves book data without cover state', async () => {
|
||||||
const created = await createViaApi('示例整本')
|
const created = await createViaApi('示例整本')
|
||||||
|
|
||||||
const data = createEmptyBook()
|
const data = createEmptyBook()
|
||||||
data.cover.courseName = 'Web 前端开发'
|
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||||||
|
|
||||||
const res = await app.request(`/api/books/${created.id}`, {
|
const res = await app.request(`/api/books/${created.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ data }),
|
body: JSON.stringify({ data: { ...data, cover: { courseName: '旧课程', teacherName: '旧教师' } } }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
const fetched = await app.request(`/api/books/${created.id}`)
|
const fetched = await app.request(`/api/books/${created.id}`)
|
||||||
const body = (await fetched.json()) as { data: { cover: { courseName: string } } }
|
const body = (await fetched.json()) as { data: Record<string, unknown> }
|
||||||
expect(body.data.cover.courseName).toBe('Web 前端开发')
|
expect(body.data).not.toHaveProperty('cover')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 404 when saving data for a missing book', async () => {
|
it('returns 404 when saving data for a missing book', async () => {
|
||||||
|
|||||||
27
src/components/A4Workspace.test.ts
Normal file
27
src/components/A4Workspace.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||||
|
import A4Workspace from './A4Workspace.vue'
|
||||||
|
|
||||||
|
describe('A4Workspace', () => {
|
||||||
|
it('renders a selected lesson without cover state', () => {
|
||||||
|
const design = createEmptyTeachingDesign('1.md')
|
||||||
|
design.topic = 'CSS 弹性布局'
|
||||||
|
|
||||||
|
const wrapper = mount(A4Workspace, {
|
||||||
|
props: { selectedDesign: design },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Object.keys(wrapper.props()).sort()).toEqual(['selectedDesign'])
|
||||||
|
expect(wrapper.find('.cover-page').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.teaching-design-page').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no page when no lesson is selected', () => {
|
||||||
|
const wrapper = mount(A4Workspace, {
|
||||||
|
props: { selectedDesign: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.page').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
|
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||||
import CoverPage from './CoverPage.vue'
|
|
||||||
import TeachingDesignPage from './TeachingDesignPage.vue'
|
import TeachingDesignPage from './TeachingDesignPage.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
cover: BookCover
|
|
||||||
selectedId: string
|
|
||||||
selectedDesign: TeachingDesign | null
|
selectedDesign: TeachingDesign | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:cover': [patch: Partial<BookCover>]
|
|
||||||
'update:design': [design: TeachingDesign]
|
'update:design': [design: TeachingDesign]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -18,16 +14,8 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="a4-workspace">
|
<div class="a4-workspace">
|
||||||
<div class="a4-paper">
|
<div class="a4-paper">
|
||||||
<CoverPage
|
|
||||||
v-if="selectedId === 'cover'"
|
|
||||||
:course-name="cover.courseName"
|
|
||||||
:teacher-name="cover.teacherName"
|
|
||||||
:editable="true"
|
|
||||||
@update:course-name="emit('update:cover', { courseName: $event })"
|
|
||||||
@update:teacher-name="emit('update:cover', { teacherName: $event })"
|
|
||||||
/>
|
|
||||||
<TeachingDesignPage
|
<TeachingDesignPage
|
||||||
v-else-if="selectedDesign"
|
v-if="selectedDesign"
|
||||||
:design="selectedDesign"
|
:design="selectedDesign"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
@update:design="emit('update:design', $event)"
|
@update:design="emit('update:design', $event)"
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import EditableText from './EditableText.vue'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
courseName: string
|
|
||||||
teacherName: string
|
|
||||||
editable: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'update:courseName': [value: string]
|
|
||||||
'update:teacherName': [value: string]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="page cover-page">
|
|
||||||
<h1 class="cover-title">教学设计</h1>
|
|
||||||
<div class="cover-field">
|
|
||||||
<span class="cover-field-label">课程名称</span>
|
|
||||||
<EditableText
|
|
||||||
class="cover-field-value"
|
|
||||||
:model-value="courseName"
|
|
||||||
label="课程名称"
|
|
||||||
:editable="editable"
|
|
||||||
@update:model-value="$emit('update:courseName', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="cover-field">
|
|
||||||
<span class="cover-field-label">教师姓名</span>
|
|
||||||
<EditableText
|
|
||||||
class="cover-field-value"
|
|
||||||
:model-value="teacherName"
|
|
||||||
label="教师姓名"
|
|
||||||
:editable="editable"
|
|
||||||
@update:model-value="$emit('update:teacherName', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -7,7 +7,7 @@ describe('LessonSidebar', () => {
|
|||||||
it('emits a move when one lesson is dropped on another', async () => {
|
it('emits a move when one lesson is dropped on another', async () => {
|
||||||
const designs = [createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md')]
|
const designs = [createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md')]
|
||||||
const wrapper = mount(LessonSidebar, {
|
const wrapper = mount(LessonSidebar, {
|
||||||
props: { designs, selectedId: designs[0]?.id ?? 'cover' },
|
props: { designs, selectedId: designs[0]?.id ?? null },
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('[data-index="0"]').trigger('dragstart')
|
await wrapper.get('[data-index="0"]').trigger('dragstart')
|
||||||
@@ -15,4 +15,14 @@ describe('LessonSidebar', () => {
|
|||||||
|
|
||||||
expect(wrapper.emitted('move')?.[0]).toEqual([0, 1])
|
expect(wrapper.emitted('move')?.[0]).toEqual([0, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not render a cover navigation item', () => {
|
||||||
|
const designs = [createEmptyTeachingDesign('1.md')]
|
||||||
|
const wrapper = mount(LessonSidebar, {
|
||||||
|
props: { designs, selectedId: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('封面')
|
||||||
|
expect(wrapper.find('.lesson-sidebar-cover').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import type { DesignId, TeachingDesign } from '../domain/teachingDesign'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
designs: TeachingDesign[]
|
designs: TeachingDesign[]
|
||||||
selectedId: 'cover' | DesignId
|
selectedId: DesignId | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [id: 'cover' | DesignId]
|
select: [id: DesignId]
|
||||||
remove: [id: DesignId]
|
remove: [id: DesignId]
|
||||||
move: [from: number, to: number]
|
move: [from: number, to: number]
|
||||||
}>()
|
}>()
|
||||||
@@ -29,15 +29,6 @@ function onDrop(targetIndex: number): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="lesson-sidebar" aria-label="教案目录">
|
<nav class="lesson-sidebar" aria-label="教案目录">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="lesson-sidebar-item lesson-sidebar-cover"
|
|
||||||
:class="{ 'lesson-sidebar-item--active': selectedId === 'cover' }"
|
|
||||||
@click="emit('select', 'cover')"
|
|
||||||
>
|
|
||||||
封面
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul class="lesson-sidebar-list">
|
<ul class="lesson-sidebar-list">
|
||||||
<li
|
<li
|
||||||
v-for="(design, index) in designs"
|
v-for="(design, index) in designs"
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ describe('WorkspaceView', () => {
|
|||||||
expect(wrapper.text()).toContain('CSS 弹性布局')
|
expect(wrapper.text()).toContain('CSS 弹性布局')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not render cover navigation when lessons exist', async () => {
|
||||||
|
const data = createEmptyBook()
|
||||||
|
const design = createEmptyTeachingDesign('1.md')
|
||||||
|
data.designs.push(design)
|
||||||
|
data.selectedId = design.id
|
||||||
|
mockBook(data)
|
||||||
|
|
||||||
|
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('封面')
|
||||||
|
})
|
||||||
|
|
||||||
it('clears the lessons after confirmation', async () => {
|
it('clears the lessons after confirmation', async () => {
|
||||||
const data = createEmptyBook()
|
const data = createEmptyBook()
|
||||||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const {
|
|||||||
selectPage,
|
selectPage,
|
||||||
moveDesign,
|
moveDesign,
|
||||||
removeDesign,
|
removeDesign,
|
||||||
updateCover,
|
|
||||||
updateDesign,
|
updateDesign,
|
||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
@@ -299,10 +298,7 @@ function closeFixDialog(): void {
|
|||||||
@move="moveDesign"
|
@move="moveDesign"
|
||||||
/>
|
/>
|
||||||
<A4Workspace
|
<A4Workspace
|
||||||
:cover="book.cover"
|
|
||||||
:selected-id="book.selectedId"
|
|
||||||
:selected-design="selectedDesign"
|
:selected-design="selectedDesign"
|
||||||
@update:cover="updateCover"
|
|
||||||
@update:design="handleDesignUpdate"
|
@update:design="handleDesignUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ function mockGetBook(data: TeachingBook, id = 'b1'): void {
|
|||||||
vi.mocked(booksApi.getBook).mockResolvedValue({ id, name: '示例整本', updatedAt: data.updatedAt, data })
|
vi.mocked(booksApi.getBook).mockResolvedValue({ id, name: '示例整本', updatedAt: data.updatedAt, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBookWithDesign(filename = '1.md'): { data: TeachingBook; design: ReturnType<typeof createEmptyTeachingDesign> } {
|
||||||
|
const data = createEmptyBook()
|
||||||
|
const design = createEmptyTeachingDesign(filename)
|
||||||
|
data.designs.push(design)
|
||||||
|
data.selectedId = design.id
|
||||||
|
return { data, design }
|
||||||
|
}
|
||||||
|
|
||||||
describe('useTeachingBook', () => {
|
describe('useTeachingBook', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads the book from the API', async () => {
|
it('loads the book from the API without cover state', async () => {
|
||||||
const data = createEmptyBook()
|
const { data } = createBookWithDesign()
|
||||||
data.cover.courseName = 'Web 前端开发'
|
|
||||||
mockGetBook(data)
|
mockGetBook(data)
|
||||||
|
|
||||||
const store = useTeachingBook('b1')
|
const store = useTeachingBook('b1')
|
||||||
@@ -26,7 +33,8 @@ describe('useTeachingBook', () => {
|
|||||||
|
|
||||||
expect(booksApi.getBook).toHaveBeenCalledWith('b1')
|
expect(booksApi.getBook).toHaveBeenCalledWith('b1')
|
||||||
expect(store.loadStatus.value).toBe('loaded')
|
expect(store.loadStatus.value).toBe('loaded')
|
||||||
expect(store.book.value.cover.courseName).toBe('Web 前端开发')
|
expect(store.book.value).not.toHaveProperty('cover')
|
||||||
|
expect(store.book.value.selectedId).toBe(data.selectedId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets loadStatus to error when loading fails', async () => {
|
it('sets loadStatus to error when loading fails', async () => {
|
||||||
@@ -82,13 +90,16 @@ describe('useTeachingBook', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('autosaves the book via the API after the debounce delay', async () => {
|
it('autosaves the book via the API after the debounce delay', async () => {
|
||||||
mockGetBook(createEmptyBook())
|
const { data, design } = createBookWithDesign()
|
||||||
|
mockGetBook(data)
|
||||||
vi.mocked(booksApi.updateBook).mockResolvedValue({ id: 'b1', name: '示例整本', updatedAt: 'later' })
|
vi.mocked(booksApi.updateBook).mockResolvedValue({ id: 'b1', name: '示例整本', updatedAt: 'later' })
|
||||||
|
|
||||||
const store = useTeachingBook('b1')
|
const store = useTeachingBook('b1')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
store.updateCover({ courseName: '新课程名' })
|
store.updateDesign(design.id, (current) => {
|
||||||
|
current.title = '新课程名'
|
||||||
|
})
|
||||||
await vi.advanceTimersByTimeAsync(300)
|
await vi.advanceTimersByTimeAsync(300)
|
||||||
|
|
||||||
expect(booksApi.updateBook).toHaveBeenCalledWith('b1', store.book.value)
|
expect(booksApi.updateBook).toHaveBeenCalledWith('b1', store.book.value)
|
||||||
@@ -96,13 +107,16 @@ describe('useTeachingBook', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('sets saveStatus to error when autosave fails', async () => {
|
it('sets saveStatus to error when autosave fails', async () => {
|
||||||
mockGetBook(createEmptyBook())
|
const { data, design } = createBookWithDesign()
|
||||||
|
mockGetBook(data)
|
||||||
vi.mocked(booksApi.updateBook).mockRejectedValue(new Error('保存失败。'))
|
vi.mocked(booksApi.updateBook).mockRejectedValue(new Error('保存失败。'))
|
||||||
|
|
||||||
const store = useTeachingBook('b1')
|
const store = useTeachingBook('b1')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
store.updateCover({ courseName: '新课程名' })
|
store.updateDesign(design.id, (current) => {
|
||||||
|
current.title = '新课程名'
|
||||||
|
})
|
||||||
await vi.advanceTimersByTimeAsync(300)
|
await vi.advanceTimersByTimeAsync(300)
|
||||||
|
|
||||||
expect(store.saveStatus.value).toBe('error')
|
expect(store.saveStatus.value).toBe('error')
|
||||||
@@ -139,10 +153,8 @@ describe('useTeachingBook', () => {
|
|||||||
expect(store.book.value.designs).toHaveLength(0)
|
expect(store.book.value.designs).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clearBook empties designs but keeps the cover', async () => {
|
it('clearBook empties designs and clears selection', async () => {
|
||||||
const data = createEmptyBook()
|
const { data } = createBookWithDesign()
|
||||||
data.cover.courseName = 'Web 前端开发'
|
|
||||||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
|
||||||
mockGetBook(data)
|
mockGetBook(data)
|
||||||
|
|
||||||
const store = useTeachingBook('b1')
|
const store = useTeachingBook('b1')
|
||||||
@@ -151,7 +163,22 @@ describe('useTeachingBook', () => {
|
|||||||
store.clearBook()
|
store.clearBook()
|
||||||
|
|
||||||
expect(store.book.value.designs).toEqual([])
|
expect(store.book.value.designs).toEqual([])
|
||||||
expect(store.book.value.cover.courseName).toBe('Web 前端开发')
|
expect(store.book.value).not.toHaveProperty('cover')
|
||||||
expect(store.book.value.selectedId).toBe('cover')
|
expect(store.book.value.selectedId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects null after removing the last selected lesson', async () => {
|
||||||
|
const { data, design } = createBookWithDesign()
|
||||||
|
mockGetBook(data)
|
||||||
|
|
||||||
|
const store = useTeachingBook('b1')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
store.removeDesign(design.id)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.book.value.designs).toEqual([])
|
||||||
|
expect(store.book.value.selectedId).toBeNull()
|
||||||
|
expect(store.selectedDesign.value).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { nextTick, ref, watch, type Ref } from 'vue'
|
import { nextTick, ref, watch, type Ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
createEmptyBook,
|
createEmptyBook,
|
||||||
type BookCover,
|
|
||||||
type DesignId,
|
type DesignId,
|
||||||
type TeachingBook,
|
type TeachingBook,
|
||||||
type TeachingDesign,
|
type TeachingDesign,
|
||||||
@@ -38,10 +37,9 @@ export interface TeachingBookStore {
|
|||||||
warningCount: Ref<number>
|
warningCount: Ref<number>
|
||||||
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
|
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
|
||||||
detectDuplicates: (files: readonly File[]) => string[]
|
detectDuplicates: (files: readonly File[]) => string[]
|
||||||
selectPage: (id: 'cover' | DesignId) => void
|
selectPage: (id: DesignId) => void
|
||||||
moveDesign: (from: number, to: number) => void
|
moveDesign: (from: number, to: number) => void
|
||||||
removeDesign: (id: DesignId) => void
|
removeDesign: (id: DesignId) => void
|
||||||
updateCover: (patch: Partial<BookCover>) => void
|
|
||||||
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
||||||
clearBook: () => void
|
clearBook: () => void
|
||||||
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
||||||
@@ -67,7 +65,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
const current = book.value
|
const current = book.value
|
||||||
hasDesigns.value = current.designs.length > 0
|
hasDesigns.value = current.designs.length > 0
|
||||||
selectedDesign.value =
|
selectedDesign.value =
|
||||||
current.selectedId === 'cover'
|
current.selectedId === null
|
||||||
? null
|
? null
|
||||||
: current.designs.find((design) => design.id === current.selectedId) ?? null
|
: current.designs.find((design) => design.id === current.selectedId) ?? null
|
||||||
warningCount.value = current.designs.reduce(
|
warningCount.value = current.designs.reduce(
|
||||||
@@ -176,7 +174,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imported > 0 && book.value.selectedId === 'cover' && book.value.designs.length > 0) {
|
if (imported > 0 && book.value.selectedId === null && book.value.designs.length > 0) {
|
||||||
book.value.selectedId = book.value.designs[0]!.id
|
book.value.selectedId = book.value.designs[0]!.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +185,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
return { imported, failed, duplicates }
|
return { imported, failed, duplicates }
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPage(id: 'cover' | DesignId): void {
|
function selectPage(id: DesignId): void {
|
||||||
book.value.selectedId = id
|
book.value.selectedId = id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +208,12 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
designs.splice(index, 1)
|
designs.splice(index, 1)
|
||||||
|
|
||||||
if (book.value.selectedId === id) {
|
if (book.value.selectedId === id) {
|
||||||
book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? 'cover'
|
book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
touch()
|
touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCover(patch: Partial<BookCover>): void {
|
|
||||||
Object.assign(book.value.cover, patch)
|
|
||||||
touch()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void {
|
function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void {
|
||||||
const design = book.value.designs.find((candidate) => candidate.id === id)
|
const design = book.value.designs.find((candidate) => candidate.id === id)
|
||||||
if (!design) {
|
if (!design) {
|
||||||
@@ -232,7 +225,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
|
|
||||||
function clearBook(): void {
|
function clearBook(): void {
|
||||||
book.value.designs = []
|
book.value.designs = []
|
||||||
book.value.selectedId = 'cover'
|
book.value.selectedId = null
|
||||||
touch()
|
touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +279,6 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
selectPage,
|
selectPage,
|
||||||
moveDesign,
|
moveDesign,
|
||||||
removeDesign,
|
removeDesign,
|
||||||
updateCover,
|
|
||||||
updateDesign,
|
updateDesign,
|
||||||
clearBook,
|
clearBook,
|
||||||
generateLesson,
|
generateLesson,
|
||||||
|
|||||||
@@ -53,24 +53,22 @@ describe('createEmptyTeachingDesign', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('createEmptyBook', () => {
|
describe('createEmptyBook', () => {
|
||||||
it('creates the schema defaults with cover selected and an ISO timestamp', () => {
|
it('creates the schema defaults with no selected page and an ISO timestamp', () => {
|
||||||
const book = createEmptyBook()
|
const book = createEmptyBook()
|
||||||
|
|
||||||
expect(book.schemaVersion).toBe(BOOK_SCHEMA_VERSION)
|
expect(book.schemaVersion).toBe(BOOK_SCHEMA_VERSION)
|
||||||
expect(book.selectedId).toBe('cover')
|
expect(book.selectedId).toBeNull()
|
||||||
|
expect(book).not.toHaveProperty('cover')
|
||||||
expect(new Date(book.updatedAt).toISOString()).toBe(book.updatedAt)
|
expect(new Date(book.updatedAt).toISOString()).toBe(book.updatedAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates independent cover and design collections', () => {
|
it('creates independent design collections', () => {
|
||||||
const first = createEmptyBook()
|
const first = createEmptyBook()
|
||||||
const second = createEmptyBook()
|
const second = createEmptyBook()
|
||||||
|
|
||||||
first.cover.courseName = 'Changed'
|
|
||||||
first.designs.push(createEmptyTeachingDesign('1.md'))
|
first.designs.push(createEmptyTeachingDesign('1.md'))
|
||||||
|
|
||||||
expect(first.cover).not.toBe(second.cover)
|
|
||||||
expect(first.designs).not.toBe(second.designs)
|
expect(first.designs).not.toBe(second.designs)
|
||||||
expect(second.cover.courseName).toBe('')
|
|
||||||
expect(second.designs).toEqual([])
|
expect(second.designs).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -79,7 +77,7 @@ describe('domain types', () => {
|
|||||||
it('uses branded string design IDs and literal schema versions', () => {
|
it('uses branded string design IDs and literal schema versions', () => {
|
||||||
expectTypeOf<DesignId>().toExtend<string>()
|
expectTypeOf<DesignId>().toExtend<string>()
|
||||||
expectTypeOf<TeachingDesign['id']>().toEqualTypeOf<DesignId>()
|
expectTypeOf<TeachingDesign['id']>().toEqualTypeOf<DesignId>()
|
||||||
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<'cover' | DesignId>()
|
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<DesignId | null>()
|
||||||
expectTypeOf<TeachingBook['schemaVersion']>().toEqualTypeOf<
|
expectTypeOf<TeachingBook['schemaVersion']>().toEqualTypeOf<
|
||||||
typeof BOOK_SCHEMA_VERSION
|
typeof BOOK_SCHEMA_VERSION
|
||||||
>()
|
>()
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ export interface TeachingDesign {
|
|||||||
warnings: ParseWarning[]
|
warnings: ParseWarning[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookCover {
|
|
||||||
courseName: string
|
|
||||||
teacherName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeachingBook {
|
export interface TeachingBook {
|
||||||
schemaVersion: typeof BOOK_SCHEMA_VERSION
|
schemaVersion: typeof BOOK_SCHEMA_VERSION
|
||||||
cover: BookCover
|
|
||||||
designs: TeachingDesign[]
|
designs: TeachingDesign[]
|
||||||
selectedId: 'cover' | DesignId
|
selectedId: DesignId | null
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +98,8 @@ export function createEmptyTeachingDesign(filename: string): TeachingDesign {
|
|||||||
export function createEmptyBook(): TeachingBook {
|
export function createEmptyBook(): TeachingBook {
|
||||||
return {
|
return {
|
||||||
schemaVersion: BOOK_SCHEMA_VERSION,
|
schemaVersion: BOOK_SCHEMA_VERSION,
|
||||||
cover: { courseName: '', teacherName: '' },
|
|
||||||
designs: [],
|
designs: [],
|
||||||
selectedId: 'cover',
|
selectedId: null,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,17 +238,6 @@ input {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-sidebar-cover {
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--green-700);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lesson-sidebar-list {
|
.lesson-sidebar-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -346,41 +335,6 @@ input {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cover page */
|
|
||||||
.cover-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-title {
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2em;
|
|
||||||
color: var(--green-700);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-field-label {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-field-value {
|
|
||||||
min-width: 12em;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Teaching design page */
|
/* Teaching design page */
|
||||||
.teaching-design-page {
|
.teaching-design-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user