Compare commits

...

10 Commits

Author SHA1 Message Date
804224fc3c update 2026-06-16 09:01:02 -06:00
494fbef7b4 update 2026-06-16 08:58:06 -06:00
660039b3cf update 2026-06-16 08:18:10 -06:00
19cc1ffdfa docs: add frontend routing design 2026-06-16 07:57:22 -06:00
dcec78d4b7 fix 2026-06-16 07:51:38 -06:00
8b0e4df43d feat: add concurrent batch lesson generation 2026-06-16 07:33:29 -06:00
2321b5f68e Merge branch 'remove-cover-page' 2026-06-16 07:17:25 -06:00
7b95324649 feat: remove cover page 2026-06-16 07:14:39 -06:00
a2534459d0 fix 2026-06-16 07:08:13 -06:00
085f70bd64 docs: add remove cover page plan 2026-06-16 07:02:10 -06:00
29 changed files with 2043 additions and 296 deletions

View File

@@ -2,6 +2,9 @@
FROM oven/bun:1 AS builder
WORKDIR /app
ARG BUN_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=${BUN_REGISTRY}
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
@@ -12,6 +15,9 @@ RUN bun run build
FROM oven/bun:1-slim AS runner
WORKDIR /app
ARG BUN_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=${BUN_REGISTRY}
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

View File

@@ -1,14 +1,20 @@
services:
app:
build: .
ports:
- "3001:3001"
expose:
- 3001
environment:
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- TEACHING_BOOKS_DB=/app/data/teaching-books.db
volumes:
- db_data:/app/data
restart: unless-stopped
networks:
- npm_proxy
networks:
npm_proxy:
external: true
volumes:
db_data:

View File

@@ -0,0 +1,340 @@
# Frontend Routing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add URL-backed frontend routes for login, book list, book workspace, and admin.
**Architecture:** Keep route ownership in `src/App.vue` and leave page components event-driven. Use `window.history.pushState`, `window.history.replaceState`, and `popstate` to maintain one small local route state without adding dependencies.
**Tech Stack:** Vue 3 Composition API, Vite, Vitest, Vue Test Utils, browser History API.
---
## File Structure
- Modify `src/App.test.ts` for TDD coverage of URL-backed navigation and logged-out redirects.
- Modify `src/App.vue` to replace local `currentBookId` / `showAdmin` view state with parsed route state.
- Do not modify `package.json` or `package-lock.json`; existing user changes there are unrelated.
## Task 1: Add failing route tests
**Files:**
- Modify: `src/App.test.ts`
- Test: `src/App.test.ts`
- [ ] **Step 1: Replace the auth mock with mutable test state**
Use a hoisted auth state so each test can switch between logged-in and logged-out behavior:
```ts
const authState = vi.hoisted(() => {
const { computed, ref } = require('vue') as typeof import('vue')
return {
loggedIn: ref(true),
user: ref(null),
fetchMe: vi.fn(),
}
})
vi.mock('./composables/useAuth', () => ({
useAuth: () => ({
isLoggedIn: computed(() => authState.loggedIn.value),
fetchMe: authState.fetchMe,
user: authState.user,
}),
}))
```
- [ ] **Step 2: Reset URL and auth state before each test**
Add this to the existing `beforeEach`:
```ts
authState.loggedIn.value = true
authState.user.value = null
authState.fetchMe.mockReset()
window.history.replaceState(null, '', '/books')
```
- [ ] **Step 3: Add tests for the route behaviors**
Add these tests:
```ts
it('opens a book route when a book is selected', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
vi.mocked(booksApi.getBook).mockResolvedValue({
id: 'b1',
name: '示例整本',
updatedAt: '2026-01-01T00:00:00.000Z',
data: createEmptyBook(),
})
const wrapper = mount(App)
await flushPromises()
await wrapper.get('[data-testid="open-b1"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books/b1')
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
})
it('returns to the books route from the workspace', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
vi.mocked(booksApi.getBook).mockResolvedValue({
id: 'b1',
name: '示例整本',
updatedAt: '2026-01-01T00:00:00.000Z',
data: createEmptyBook(),
})
const wrapper = mount(App)
await flushPromises()
await wrapper.get('[data-testid="open-b1"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="back"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books')
expect(wrapper.text()).toContain('教学设计')
})
it('opens the admin route from the book list', async () => {
authState.user.value = { id: 'u1', username: 'admin', role: 'admin' }
vi.mocked(booksApi.listBooks).mockResolvedValue([])
const wrapper = mount(App)
await flushPromises()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/admin')
expect(wrapper.text()).toContain('用户管理')
})
it('routes logged-out users to login', async () => {
authState.loggedIn.value = false
window.history.replaceState(null, '', '/books/b1')
const wrapper = mount(App)
await flushPromises()
expect(window.location.pathname).toBe('/login')
expect(wrapper.text()).toContain('登录')
})
```
- [ ] **Step 4: Run test to verify RED**
Run:
```bash
rtk npm test -- src/App.test.ts
```
Expected: FAIL because `App.vue` does not update `window.location.pathname` for book/admin navigation and does not redirect logged-out users.
## Task 2: Implement route state in App.vue
**Files:**
- Modify: `src/App.vue`
- Test: `src/App.test.ts`
- [ ] **Step 1: Replace local page flags with route state**
In `src/App.vue`, replace `currentBookId` and `showAdmin` with this route model:
```ts
type AppRoute =
| { name: 'login' }
| { name: 'books' }
| { name: 'book'; bookId: string }
| { name: 'admin' }
const route = ref<AppRoute>(parseRoute(window.location.pathname))
```
- [ ] **Step 2: Add route parsing and navigation helpers**
Add helpers in `src/App.vue`:
```ts
function parseRoute(pathname: string): AppRoute {
if (pathname === '/login') return { name: 'login' }
if (pathname === '/admin') return { name: 'admin' }
if (pathname === '/books') return { name: 'books' }
const bookMatch = pathname.match(/^\/books\/([^/]+)$/)
if (bookMatch?.[1]) {
return { name: 'book', bookId: decodeURIComponent(bookMatch[1]) }
}
return { name: 'books' }
}
function routeToPath(nextRoute: AppRoute): string {
if (nextRoute.name === 'login') return '/login'
if (nextRoute.name === 'admin') return '/admin'
if (nextRoute.name === 'book') return `/books/${encodeURIComponent(nextRoute.bookId)}`
return '/books'
}
function replaceRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.replaceState(null, '', path)
}
}
function pushRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.pushState(null, '', path)
}
}
```
- [ ] **Step 3: Wire mount, popstate, and auth redirects**
Update imports and lifecycle:
```ts
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
```
Use:
```ts
function syncRouteForAuth(): void {
if (!isLoggedIn.value) {
replaceRoute({ name: 'login' })
return
}
if (route.value.name === 'login') {
replaceRoute({ name: 'books' })
}
}
function handlePopState(): void {
route.value = parseRoute(window.location.pathname)
syncRouteForAuth()
}
onMounted(async () => {
window.addEventListener('popstate', handlePopState)
await fetchMe()
syncRouteForAuth()
})
onBeforeUnmount(() => {
window.removeEventListener('popstate', handlePopState)
})
watch(isLoggedIn, syncRouteForAuth)
```
- [ ] **Step 4: Map existing component events to route navigation**
Use:
```ts
async function handleLoginSuccess(): Promise<void> {
await fetchMe()
pushRoute({ name: 'books' })
}
function openBook(id: string): void {
pushRoute({ name: 'book', bookId: id })
}
function backToList(): void {
pushRoute({ name: 'books' })
}
function openAdmin(): void {
pushRoute({ name: 'admin' })
}
```
- [ ] **Step 5: Update template route conditions**
Use:
```vue
<LoginPage v-if="route.name === 'login'" @success="handleLoginSuccess" />
<template v-else>
<AdminPage v-if="route.name === 'admin'" @back="backToList" />
<WorkspaceView
v-else-if="route.name === 'book'"
:key="route.bookId"
:book-id="route.bookId"
@back="backToList"
/>
<BookListPage v-else @open="openBook" @admin="openAdmin" />
</template>
```
- [ ] **Step 6: Run test to verify GREEN**
Run:
```bash
rtk npm test -- src/App.test.ts
```
Expected: PASS for all `App.test.ts` cases.
## Task 3: Final verification
**Files:**
- Verify: `src/App.vue`
- Verify: `src/App.test.ts`
- [ ] **Step 1: Run focused tests**
Run:
```bash
rtk npm test -- src/App.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run full frontend test suite**
Run:
```bash
rtk npm test
```
Expected: PASS.
- [ ] **Step 3: Run production build**
Run:
```bash
rtk npm run build
```
Expected: PASS.
- [ ] **Step 4: Review git diff**
Run:
```bash
rtk git diff -- src/App.vue src/App.test.ts docs/superpowers/plans/2026-06-16-frontend-routing.md
```
Expected: diff only contains routing implementation, routing tests, and this plan.

View File

@@ -0,0 +1,779 @@
# Remove Cover Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the cover page from the teaching-book data model, stored JSON, and workspace UI.
**Architecture:** First make the domain model lesson-only, then add server-side normalization so old book JSON loses `cover` and invalid `selectedId` values. Finally update the Vue store and components so users can only select and edit lesson pages.
**Tech Stack:** Vue 3, Vitest, Vue Test Utils, Bun, bun:sqlite, Hono, TypeScript.
---
## File Structure
- Modify `src/domain/teachingDesign.ts`: remove `BookCover`, remove `cover`, and change `selectedId` to `DesignId | null`.
- Modify `src/domain/teachingDesign.test.ts`: update defaults and type assertions.
- Modify `server/db.ts`: normalize stored book JSON on `openDb()` and before saving.
- Modify `server/db.test.ts`: cover migration of legacy `cover`, legacy `'cover'` selection, invalid selected ids, and route-safe persistence.
- Modify `server/routes/books.test.ts`: update save/get expectations so `cover` is not persisted.
- Modify `src/composables/useTeachingBook.ts`: remove `updateCover`, use `null` selection, and update import/delete/clear behavior.
- Modify `src/composables/useTeachingBook.test.ts`: update tests away from cover and add null-selection coverage.
- Modify `src/components/LessonSidebar.vue`: remove cover props/events/UI.
- Modify `src/components/LessonSidebar.test.ts`: assert no cover button and keep drag/drop coverage.
- Modify `src/components/A4Workspace.vue`: remove cover props/events/import and render only selected lessons.
- Create `src/components/A4Workspace.test.ts`: assert no cover page is rendered.
- Modify `src/components/WorkspaceView.vue`: stop passing cover/update-cover.
- Modify `src/components/WorkspaceView.test.ts`: assert the loaded workspace does not show a cover entry.
- Delete `src/components/CoverPage.vue`.
- Modify `src/style.css`: remove cover-specific CSS.
### Task 1: Domain Model
**Files:**
- Modify: `src/domain/teachingDesign.test.ts`
- Modify: `src/domain/teachingDesign.ts`
- [ ] **Step 1: Write failing domain tests**
In `src/domain/teachingDesign.test.ts`, replace the `createEmptyBook` describe block with:
```ts
describe('createEmptyBook', () => {
it('creates the schema defaults with no selected page and an ISO timestamp', () => {
const book = createEmptyBook()
expect(book.schemaVersion).toBe(BOOK_SCHEMA_VERSION)
expect(book.selectedId).toBeNull()
expect(book).not.toHaveProperty('cover')
expect(new Date(book.updatedAt).toISOString()).toBe(book.updatedAt)
})
it('creates independent design collections', () => {
const first = createEmptyBook()
const second = createEmptyBook()
first.designs.push(createEmptyTeachingDesign('1.md'))
expect(first.designs).not.toBe(second.designs)
expect(second.designs).toEqual([])
})
})
```
In the `domain types` test, replace the selected id assertion with:
```ts
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<DesignId | null>()
```
- [ ] **Step 2: Run domain tests to verify failure**
Run:
```bash
rtk npm run test -- src/domain/teachingDesign.test.ts
```
Expected: FAIL because `createEmptyBook()` still returns `cover` and `selectedId: 'cover'`.
- [ ] **Step 3: Update domain model**
In `src/domain/teachingDesign.ts`, delete:
```ts
export interface BookCover {
courseName: string
teacherName: string
}
```
Replace `TeachingBook` with:
```ts
export interface TeachingBook {
schemaVersion: typeof BOOK_SCHEMA_VERSION
designs: TeachingDesign[]
selectedId: DesignId | null
updatedAt: string
}
```
Replace `createEmptyBook()` with:
```ts
export function createEmptyBook(): TeachingBook {
return {
schemaVersion: BOOK_SCHEMA_VERSION,
designs: [],
selectedId: null,
updatedAt: new Date().toISOString(),
}
}
```
- [ ] **Step 4: Run domain tests to verify pass**
Run:
```bash
rtk npm run test -- src/domain/teachingDesign.test.ts
```
Expected: PASS.
### Task 2: Server Data Normalization
**Files:**
- Modify: `server/db.test.ts`
- Modify: `server/routes/books.test.ts`
- Modify: `server/db.ts`
- [ ] **Step 1: Write failing DB migration tests**
In `server/db.test.ts`, add these imports:
```ts
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
```
Add this helper above `describe('db', () => {`:
```ts
function tempDbPath(name: string): string {
const path = join(tmpdir(), `fake-teaching-design-${name}-${crypto.randomUUID()}.db`)
if (existsSync(path)) rmSync(path)
return path
}
```
Append these tests inside `describe('db', () => { ... })`:
```ts
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)
})
```
Update the existing `saves book data and updates updated_at` test to stop writing `data.cover` and assert `cover` is absent:
```ts
it('saves book data and updates updated_at', () => {
const db = openDb(':memory:')
const created = createBook(db, '示例整本')
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
setSystemTime(new Date('2026-02-01T00:00:00.000Z'))
const result = saveBookData(db, created.id, data)
expect(result).toEqual({ id: created.id, name: '示例整本', updatedAt: '2026-02-01T00:00:00.000Z' })
expect(getBook(db, created.id)?.data).not.toHaveProperty('cover')
})
```
- [ ] **Step 2: Update route test expectations**
In `server/routes/books.test.ts`, replace the `saves book data` test body with:
```ts
it('saves book data without cover state', async () => {
const created = await createViaApi('示例整本')
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
const res = await app.request(`/api/books/${created.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { ...data, cover: { courseName: '旧课程', teacherName: '旧教师' } } }),
})
expect(res.status).toBe(200)
const fetched = await app.request(`/api/books/${created.id}`)
const body = (await fetched.json()) as { data: Record<string, unknown> }
expect(body.data).not.toHaveProperty('cover')
})
```
Also update the import in `server/routes/books.test.ts`:
```ts
import { createEmptyBook, createEmptyTeachingDesign } from '../../src/domain/teachingDesign'
```
- [ ] **Step 3: Run DB and books route tests to verify failure**
Run:
```bash
rtk bun test server/db.test.ts server/routes/books.test.ts
```
Expected: FAIL because stored JSON still preserves `cover` and `'cover'` selected ids.
- [ ] **Step 4: Implement book normalization**
In `server/db.ts`, add these internal types after `interface BookRow`:
```ts
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
cover?: unknown
selectedId?: string | null
}
interface NormalizedBookData {
data: TeachingBook
changed: boolean
}
```
Add this helper before `openDb()`:
```ts
function normalizeBookData(raw: StoredTeachingBook): NormalizedBookData {
const data = { ...raw, designs: Array.isArray(raw.designs) ? raw.designs : [] } as StoredTeachingBook
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
if (selectedId !== normalizedSelectedId) changed = true
} else {
normalizedSelectedId = selectedId as TeachingBook['selectedId']
}
return {
data: {
schemaVersion: data.schemaVersion,
designs: data.designs,
selectedId: normalizedSelectedId,
updatedAt: data.updatedAt,
},
changed,
}
}
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])
}
}
}
function parseBookData(data: string): TeachingBook {
return normalizeBookData(JSON.parse(data) as StoredTeachingBook).data
}
```
Update `openDb()` to run migration:
```ts
export function openDb(path: string): Database {
const db = new Database(path)
db.run('PRAGMA foreign_keys = ON')
db.run(SCHEMA)
migrateStoredBooks(db)
return db
}
```
Replace all direct `JSON.parse(row.data) as TeachingBook` uses in `listBooks()` and `getBook()` with `parseBookData(row.data)`.
In `saveBookData()`, normalize before storing:
```ts
const normalized = normalizeBookData(data as StoredTeachingBook).data
db.run('UPDATE books SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(normalized), now, id])
```
- [ ] **Step 5: Run DB and books route tests to verify pass**
Run:
```bash
rtk bun test server/db.test.ts server/routes/books.test.ts
```
Expected: PASS.
### Task 3: Store Selection Without Cover
**Files:**
- Modify: `src/composables/useTeachingBook.test.ts`
- Modify: `src/composables/useTeachingBook.ts`
- [ ] **Step 1: Write failing store tests**
In `src/composables/useTeachingBook.test.ts`:
Replace the `loads the book from the API` test with:
```ts
it('loads the book from the API without cover state', async () => {
const data = createEmptyBook()
mockGetBook(data)
const store = useTeachingBook('b1')
await flushPromises()
expect(booksApi.getBook).toHaveBeenCalledWith('b1')
expect(store.loadStatus.value).toBe('loaded')
expect(store.book.value).not.toHaveProperty('cover')
})
```
Replace autosave test mutation:
```ts
const design = createEmptyTeachingDesign('1.md')
store.book.value.designs.push(design)
store.updateDesign(design.id, (target) => {
target.topic = '新课程名'
})
```
Replace the save-error test mutation with the same `updateDesign()` pattern.
Replace the clear test with:
```ts
it('clearBook empties designs and clears selection', async () => {
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
data.selectedId = data.designs[0]!.id
mockGetBook(data)
const store = useTeachingBook('b1')
await flushPromises()
store.clearBook()
expect(store.book.value.designs).toEqual([])
expect(store.book.value.selectedId).toBeNull()
})
```
Add this test inside the describe block:
```ts
it('selects null after removing the last selected lesson', async () => {
const data = createEmptyBook()
const design = createEmptyTeachingDesign('1.md')
data.designs.push(design)
data.selectedId = design.id
mockGetBook(data)
const store = useTeachingBook('b1')
await flushPromises()
store.removeDesign(design.id)
expect(store.book.value.designs).toEqual([])
expect(store.book.value.selectedId).toBeNull()
expect(store.selectedDesign.value).toBeNull()
})
```
- [ ] **Step 2: Run store tests to verify failure**
Run:
```bash
rtk npm run test -- src/composables/useTeachingBook.test.ts
```
Expected: FAIL because `updateCover` still exists, `clearBook()` selects `'cover'`, and types still mention cover.
- [ ] **Step 3: Update useTeachingBook types and behavior**
In `src/composables/useTeachingBook.ts`:
Remove `type BookCover` from imports.
Change `TeachingBookStore` methods:
```ts
selectPage: (id: DesignId) => void
moveDesign: (from: number, to: number) => void
removeDesign: (id: DesignId) => void
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
```
Delete `updateCover` from the interface and returned object.
Replace `syncDerived()` selected-design logic with:
```ts
selectedDesign.value = current.selectedId === null
? null
: current.designs.find((design) => design.id === current.selectedId) ?? null
```
Replace the import selection block with:
```ts
if (imported > 0 && book.value.selectedId === null && book.value.designs.length > 0) {
book.value.selectedId = book.value.designs[0]!.id
}
```
Change `selectPage()` signature:
```ts
function selectPage(id: DesignId): void {
book.value.selectedId = id
}
```
In `removeDesign()`, replace fallback selection with:
```ts
book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? null
```
Delete `updateCover()`.
In `clearBook()`, set:
```ts
book.value.selectedId = null
```
- [ ] **Step 4: Run store tests to verify pass**
Run:
```bash
rtk npm run test -- src/composables/useTeachingBook.test.ts
```
Expected: PASS.
### Task 4: Components Without Cover
**Files:**
- Modify: `src/components/LessonSidebar.test.ts`
- Create: `src/components/A4Workspace.test.ts`
- Modify: `src/components/WorkspaceView.test.ts`
- Modify: `src/components/LessonSidebar.vue`
- Modify: `src/components/A4Workspace.vue`
- Modify: `src/components/WorkspaceView.vue`
- Delete: `src/components/CoverPage.vue`
- Modify: `src/style.css`
- [ ] **Step 1: Write failing component tests**
In `src/components/LessonSidebar.test.ts`, use `selectedId: designs[0]?.id ?? null` in the existing mount. Add:
```ts
it('does not render a cover navigation item', () => {
const designs = [createEmptyTeachingDesign('1.md')]
const wrapper = mount(LessonSidebar, {
props: { designs, selectedId: designs[0]?.id ?? null },
})
expect(wrapper.text()).not.toContain('封面')
})
```
Create `src/components/A4Workspace.test.ts`:
```ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
import A4Workspace from './A4Workspace.vue'
import TeachingDesignPage from './TeachingDesignPage.vue'
describe('A4Workspace', () => {
it('renders the selected lesson without a cover page', () => {
const design = createEmptyTeachingDesign('1.md')
design.topic = '第一课'
const wrapper = mount(A4Workspace, {
props: { selectedDesign: design },
})
expect(wrapper.find('.cover-page').exists()).toBe(false)
expect(wrapper.findComponent(TeachingDesignPage).exists()).toBe(true)
expect(wrapper.text()).toContain('第一课')
})
it('renders no page when no lesson is selected', () => {
const wrapper = mount(A4Workspace, {
props: { selectedDesign: null },
})
expect(wrapper.find('.page').exists()).toBe(false)
})
})
```
In `src/components/WorkspaceView.test.ts`, add this test:
```ts
it('does not render a cover entry when lessons exist', async () => {
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
data.selectedId = data.designs[0]!.id
mockBook(data)
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
await flushPromises()
expect(wrapper.text()).not.toContain('封面')
})
```
- [ ] **Step 2: Run component tests to verify failure**
Run:
```bash
rtk npm run test -- src/components/LessonSidebar.test.ts src/components/A4Workspace.test.ts src/components/WorkspaceView.test.ts
```
Expected: FAIL because `LessonSidebar` still renders cover and `A4Workspace` still requires cover props/imports `CoverPage`.
- [ ] **Step 3: Update LessonSidebar**
In `src/components/LessonSidebar.vue`:
Change props:
```ts
defineProps<{
designs: TeachingDesign[]
selectedId: DesignId | null
}>()
```
Change emits:
```ts
const emit = defineEmits<{
select: [id: DesignId]
remove: [id: DesignId]
move: [from: number, to: number]
}>()
```
Delete the `<button class="lesson-sidebar-item lesson-sidebar-cover">...</button>` block.
- [ ] **Step 4: Update A4Workspace**
Replace `src/components/A4Workspace.vue` with:
```vue
<script setup lang="ts">
import type { TeachingDesign } from '../domain/teachingDesign'
import TeachingDesignPage from './TeachingDesignPage.vue'
defineProps<{
selectedDesign: TeachingDesign | null
}>()
const emit = defineEmits<{
'update:design': [design: TeachingDesign]
}>()
</script>
<template>
<div class="a4-workspace">
<div class="a4-paper">
<TeachingDesignPage
v-if="selectedDesign"
:design="selectedDesign"
:editable="true"
@update:design="emit('update:design', $event)"
/>
</div>
</div>
</template>
```
- [ ] **Step 5: Update WorkspaceView**
In `src/components/WorkspaceView.vue`:
Remove `updateCover` from the `useTeachingBook()` destructuring.
Replace the `A4Workspace` usage with:
```vue
<A4Workspace
:selected-design="selectedDesign"
@update:design="handleDesignUpdate"
/>
```
- [ ] **Step 6: Delete cover component and CSS**
Delete `src/components/CoverPage.vue`.
In `src/style.css`, delete the whole section from:
```css
/* Cover page */
```
through the `.cover-field-value` rule.
Also delete `.lesson-sidebar-cover` rules because no element uses that class anymore.
- [ ] **Step 7: Run component tests to verify pass**
Run:
```bash
rtk npm run test -- src/components/LessonSidebar.test.ts src/components/A4Workspace.test.ts src/components/WorkspaceView.test.ts
```
Expected: PASS.
### Task 5: Cleanup References and Verify
- [ ] **Step 1: Search for remaining cover references**
Run:
```bash
rtk rg -n "CoverPage|BookCover|cover|selectedId: 'cover'|'cover'|封面|lesson-sidebar-cover|cover-page" src server
```
Expected: Output contains only migration compatibility references in `server/db.ts`, `server/db.test.ts`, and `server/routes/books.test.ts`. It must not contain `src/components/CoverPage.vue`, `BookCover`, UI text `封面`, `lesson-sidebar-cover`, or `.cover-page`.
- [ ] **Step 2: Run full frontend tests**
Run:
```bash
rtk npm run test
```
Expected: PASS.
- [ ] **Step 3: Run backend tests**
Run:
```bash
rtk npm run test:server
```
Expected: PASS.
- [ ] **Step 4: Run production build**
Run:
```bash
rtk npm run build
```
Expected: PASS.
- [ ] **Step 5: Review diff**
Run:
```bash
rtk git diff -- src/domain/teachingDesign.ts src/domain/teachingDesign.test.ts server/db.ts server/db.test.ts server/routes/books.test.ts src/composables/useTeachingBook.ts src/composables/useTeachingBook.test.ts src/components/LessonSidebar.vue src/components/LessonSidebar.test.ts src/components/A4Workspace.vue src/components/A4Workspace.test.ts src/components/WorkspaceView.vue src/components/WorkspaceView.test.ts src/components/CoverPage.vue src/style.css
```
Expected: Diff removes cover data/UI, adds migration, updates tests, and does not include unrelated `index.html`.
- [ ] **Step 6: Commit implementation**
Run:
```bash
rtk git add src/domain/teachingDesign.ts src/domain/teachingDesign.test.ts server/db.ts server/db.test.ts server/routes/books.test.ts src/composables/useTeachingBook.ts src/composables/useTeachingBook.test.ts src/components/LessonSidebar.vue src/components/LessonSidebar.test.ts src/components/A4Workspace.vue src/components/A4Workspace.test.ts src/components/WorkspaceView.vue src/components/WorkspaceView.test.ts src/components/CoverPage.vue src/style.css
rtk git commit -m "feat: remove cover page"
```
Expected: Commit succeeds.

View File

@@ -0,0 +1,80 @@
# Frontend Routing Design
## Goal
Add URL-backed frontend routing so users can refresh or directly open the main app views without losing their place.
## Scope
In scope:
- Add routes for login, book list, book workspace, and admin user management.
- Keep the existing component structure: `LoginPage`, `BookListPage`, `WorkspaceView`, and `AdminPage`.
- Preserve the existing auth behavior and API calls.
- Preserve the existing backend API routes and static SPA fallback.
- Add focused tests for routing behavior.
Out of scope:
- Adding nested lesson-level URLs.
- Adding a new navigation layout.
- Replacing the current auth model.
- Adding a third-party router package unless the existing code makes a local router impractical.
## Approaches Considered
The recommended approach is a small local router built around `window.history` and `popstate`. The project does not currently use `vue-router`, and the app only needs four top-level route states. A local router keeps the change small and avoids a new dependency.
A second option is adding `vue-router`. It would be more conventional for a growing Vue app, but it adds dependency and setup overhead for a narrow routing surface.
A third option is hash routing, such as `/#/books/b1`. It avoids server fallback concerns, but the server already serves `dist/index.html` for unknown paths, so clean history URLs are a better fit.
## Routes
The frontend will support these clean URLs:
- `/login` shows `LoginPage`.
- `/books` shows `BookListPage`.
- `/books/:bookId` shows `WorkspaceView` for the selected book.
- `/admin` shows `AdminPage`.
Unknown paths redirect to the best available default: `/books` when logged in and `/login` when logged out.
## Auth Behavior
`App.vue` still calls `fetchMe()` on mount. While logged out, any route except `/login` resolves to the login page and updates the URL to `/login`.
After login succeeds, the app routes to `/books`. Logout continues to clear tokens through `useAuth`; when the app observes the logged-out state, it routes to `/login`.
The admin page remains visible only through the existing admin entry point in `BookListPage`. If a non-admin user reaches `/admin` directly, the backend admin API will still return authorization errors. The frontend may render the page shell, but protected data will not load.
## Component Behavior
`BookListPage` keeps emitting `open` and `admin`. `App.vue` will translate those events into route changes:
- `open(id)` navigates to `/books/{id}`.
- `admin` navigates to `/admin`.
`WorkspaceView` keeps emitting `back`; `App.vue` maps it to `/books`.
`AdminPage` keeps emitting `back`; `App.vue` maps it to `/books`.
This preserves component contracts and confines route ownership to the app shell.
## Error Handling
Book load errors remain handled by `WorkspaceView`. Its existing "返回列表" action navigates back to `/books`.
Route parsing should be strict enough to avoid invalid view state. Empty or malformed book IDs fall back to `/books`.
## Testing
Add or update `App.test.ts` coverage for:
- Starting at `/books` renders the book list.
- Opening a book updates the URL to `/books/:bookId` and renders the workspace.
- Pressing workspace back updates the URL to `/books`.
- Opening admin updates the URL to `/admin`.
- Logged-out users are routed to `/login`.
Run the focused app tests and the project build after implementation.

152
package-lock.json generated
View File

@@ -11,21 +11,22 @@
"hono": "^4.12.25",
"jszip": "^3.10.1",
"markdown-it": "^14.2.0",
"vue": "^3.5.34"
"vue": "^3.5.38"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@types/bun": "^1.3.14",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.8",
"@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vitest": "^4.1.8",
"vue-tsc": "^3.2.8"
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.9",
"vue-tsc": "^3.3.5"
}
},
"node_modules/@adobe/css-tools": {
@@ -754,6 +755,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bun": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz",
"integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.14"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -805,14 +816,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@vitejs/plugin-vue": {
@@ -833,15 +843,15 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -855,8 +865,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -865,16 +875,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -883,13 +893,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.8",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -920,9 +930,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -933,13 +943,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.8",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -947,14 +957,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -963,9 +973,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -973,13 +983,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -1292,6 +1302,16 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/bun-types": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz",
"integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2918,9 +2938,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
@@ -3010,20 +3030,20 @@
}
},
"node_modules/vitest": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -3051,12 +3071,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View File

@@ -18,21 +18,21 @@
"hono": "^4.12.25",
"jszip": "^3.10.1",
"markdown-it": "^14.2.0",
"vue": "^3.5.34"
"vue": "^3.5.38"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@types/bun": "^1.3.14",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.8",
"@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vitest": "^4.1.8",
"vue-tsc": "^3.2.8"
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.9",
"vue-tsc": "^3.3.5"
}
}

View File

@@ -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 { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
import {
@@ -10,6 +13,12 @@ afterEach(() => {
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', () => {
it('creates a book with empty data', () => {
const db = openDb(':memory:')
@@ -55,13 +64,85 @@ describe('db', () => {
const db = openDb(':memory:')
const created = createBook(db, '示例整本')
const data = createEmptyBook()
data.cover.courseName = 'Web 前端开发'
data.designs.push(createEmptyTeachingDesign('1.md'))
setSystemTime(new Date('2026-02-01T00:00:00.000Z'))
const result = saveBookData(db, created.id, data)
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', () => {

View File

@@ -28,6 +28,16 @@ interface BookRow {
updated_at: string
}
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
cover?: unknown
selectedId?: string | null
}
interface NormalizedBookData {
data: TeachingBook
changed: boolean
}
export interface UserRecord {
id: 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 {
const db = new Database(path)
db.run('PRAGMA foreign_keys = ON')
db.run(SCHEMA)
migrateStoredBooks(db)
return db
}
@@ -93,7 +152,7 @@ export function listBooks(db: Database): BookSummary[] {
id: row.id,
name: row.name,
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,
name: row.name,
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
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 }
}

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import { createEmptyBook } from '../../src/domain/teachingDesign'
import { createEmptyBook, createEmptyTeachingDesign } from '../../src/domain/teachingDesign'
import { openDb } from '../db'
import { createBooksRouter } from './books'
@@ -64,22 +64,22 @@ describe('books routes', () => {
expect(res.status).toBe(404)
})
it('saves book data', async () => {
it('saves book data without cover state', async () => {
const created = await createViaApi('示例整本')
const data = createEmptyBook()
data.cover.courseName = 'Web 前端开发'
data.designs.push(createEmptyTeachingDesign('1.md'))
const res = await app.request(`/api/books/${created.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
body: JSON.stringify({ data: { ...data, cover: { courseName: '旧课程', teacherName: '旧教师' } } }),
})
expect(res.status).toBe(200)
const fetched = await app.request(`/api/books/${created.id}`)
const body = (await fetched.json()) as { data: { cover: { courseName: string } } }
expect(body.data.cover.courseName).toBe('Web 前端开发')
const body = (await fetched.json()) as { data: Record<string, unknown> }
expect(body.data).not.toHaveProperty('cover')
})
it('returns 404 when saving data for a missing book', async () => {

View File

@@ -1,22 +1,39 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
import { createEmptyBook } from './domain/teachingDesign'
import * as booksApi from './services/booksApi'
vi.mock('./services/booksApi')
const authState = vi.hoisted(() => ({
authedFetch: vi.fn(),
fetchMe: vi.fn(),
loggedIn: true,
login: vi.fn(),
logout: vi.fn(),
user: null as { id: string; username: string; role: 'admin' | 'user' } | null,
}))
vi.mock('./composables/useAuth', () => ({
authedFetch: authState.authedFetch,
useAuth: () => ({
isLoggedIn: computed(() => true),
fetchMe: vi.fn(),
user: ref(null),
fetchMe: authState.fetchMe,
isLoggedIn: computed(() => authState.loggedIn),
login: authState.login,
logout: authState.logout,
user: computed(() => authState.user),
}),
}))
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.authedFetch.mockResolvedValue([])
authState.loggedIn = true
authState.user = null
window.history.replaceState(null, '', '/books')
})
it('starts with the book list entry page', async () => {
@@ -29,7 +46,7 @@ describe('App', () => {
expect(wrapper.text()).toContain('新建整本')
})
it('switches to the workspace view when a book is opened', async () => {
it('opens a book route when a book is selected', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
@@ -46,10 +63,11 @@ describe('App', () => {
await wrapper.get('[data-testid="open-b1"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books/b1')
expect(wrapper.find('[data-testid="back"]').exists()).toBe(true)
})
it('returns to the book list when back is emitted', async () => {
it('returns to the books route from the workspace', async () => {
vi.mocked(booksApi.listBooks).mockResolvedValue([
{ id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 },
])
@@ -69,6 +87,35 @@ describe('App', () => {
await wrapper.get('[data-testid="back"]').trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/books')
expect(wrapper.text()).toContain('教学设计')
})
it('opens the admin route from the book list', async () => {
authState.user = { id: 'u1', username: 'admin', role: 'admin' }
vi.mocked(booksApi.listBooks).mockResolvedValue([])
const wrapper = mount(App)
await flushPromises()
const adminButton = wrapper.findAll('button').find((button) => button.text() === '用户管理')
expect(adminButton).toBeDefined()
await adminButton!.trigger('click')
await flushPromises()
expect(window.location.pathname).toBe('/admin')
expect(wrapper.text()).toContain('用户管理')
})
it('routes logged-out users to login', async () => {
authState.loggedIn = false
window.history.replaceState(null, '', '/books/b1')
const wrapper = mount(App)
await flushPromises()
expect(window.location.pathname).toBe('/login')
expect(wrapper.text()).toContain('登录')
})
})

View File

@@ -1,52 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AdminPage from './components/AdminPage.vue'
import BookListPage from './components/BookListPage.vue'
import LoginPage from './components/LoginPage.vue'
import WorkspaceView from './components/WorkspaceView.vue'
import { useAuth } from './composables/useAuth'
type AppRoute =
| { name: 'login' }
| { name: 'books' }
| { name: 'book'; bookId: string }
| { name: 'admin' }
const { isLoggedIn, fetchMe } = useAuth()
const currentBookId = ref<string | null>(null)
const showAdmin = ref(false)
const route = ref<AppRoute>(getInitialRoute())
function parseRoute(pathname: string): AppRoute {
if (pathname === '/login') return { name: 'login' }
if (pathname === '/admin') return { name: 'admin' }
if (pathname === '/books') return { name: 'books' }
const bookMatch = pathname.match(/^\/books\/([^/]+)$/)
if (bookMatch?.[1]) {
try {
return { name: 'book', bookId: decodeURIComponent(bookMatch[1]) }
} catch {
return { name: 'books' }
}
}
return { name: 'books' }
}
function getInitialRoute(): AppRoute {
const parsed = parseRoute(window.location.pathname)
return isLoggedIn.value ? parsed : { name: 'login' }
}
function routeToPath(nextRoute: AppRoute): string {
if (nextRoute.name === 'login') return '/login'
if (nextRoute.name === 'admin') return '/admin'
if (nextRoute.name === 'book') return `/books/${encodeURIComponent(nextRoute.bookId)}`
return '/books'
}
function replaceRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.replaceState(null, '', path)
}
}
function pushRoute(nextRoute: AppRoute): void {
const path = routeToPath(nextRoute)
route.value = nextRoute
if (window.location.pathname !== path) {
window.history.pushState(null, '', path)
}
}
function syncRouteForAuth(): void {
if (!isLoggedIn.value) {
replaceRoute({ name: 'login' })
return
}
if (route.value.name === 'login') {
replaceRoute({ name: 'books' })
return
}
replaceRoute(route.value)
}
function handlePopState(): void {
route.value = parseRoute(window.location.pathname)
syncRouteForAuth()
}
onMounted(async () => {
window.addEventListener('popstate', handlePopState)
await fetchMe()
syncRouteForAuth()
})
onBeforeUnmount(() => {
window.removeEventListener('popstate', handlePopState)
})
async function handleLoginSuccess(): Promise<void> {
showAdmin.value = false
currentBookId.value = null
await fetchMe()
if (isLoggedIn.value) {
pushRoute({ name: 'books' })
} else {
replaceRoute({ name: 'login' })
}
}
function openBook(id: string): void {
currentBookId.value = id
showAdmin.value = false
pushRoute({ name: 'book', bookId: id })
}
function backToList(): void {
currentBookId.value = null
pushRoute({ name: 'books' })
}
function openAdmin(): void {
showAdmin.value = true
currentBookId.value = null
pushRoute({ name: 'admin' })
}
function closeAdmin(): void {
showAdmin.value = false
}
watch(isLoggedIn, syncRouteForAuth)
</script>
<template>
<LoginPage v-if="!isLoggedIn" @success="handleLoginSuccess" />
<LoginPage v-if="route.name === 'login'" @success="handleLoginSuccess" />
<template v-else>
<AdminPage v-if="showAdmin" @back="closeAdmin" />
<AdminPage v-if="route.name === 'admin'" @back="backToList" />
<WorkspaceView
v-else-if="currentBookId"
:key="currentBookId"
:book-id="currentBookId"
v-else-if="route.name === 'book'"
:key="route.bookId"
:book-id="route.bookId"
@back="backToList"
/>
<BookListPage v-else @open="openBook" @admin="openAdmin" />

View 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)
})
})

View File

@@ -1,16 +1,12 @@
<script setup lang="ts">
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
import CoverPage from './CoverPage.vue'
import type { TeachingDesign } from '../domain/teachingDesign'
import TeachingDesignPage from './TeachingDesignPage.vue'
defineProps<{
cover: BookCover
selectedId: string
selectedDesign: TeachingDesign | null
}>()
const emit = defineEmits<{
'update:cover': [patch: Partial<BookCover>]
'update:design': [design: TeachingDesign]
}>()
</script>
@@ -18,16 +14,8 @@ const emit = defineEmits<{
<template>
<div class="a4-workspace">
<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
v-else-if="selectedDesign"
v-if="selectedDesign"
:design="selectedDesign"
:editable="true"
@update:design="emit('update:design', $event)"

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
total: number
currentTopic: string
error: string | null
defaultTheme?: string
}>()
const emit = defineEmits<{
@@ -19,7 +20,7 @@ const emit = defineEmits<{
}>()
const phase = ref<Phase>('theme')
const theme = ref('')
const theme = ref(props.defaultTheme ?? '')
const outlineText = ref('')
const outlineError = ref<string | null>(null)
@@ -61,7 +62,7 @@ function handleStart(): void {
function handleClose(): void {
phase.value = 'theme'
theme.value = ''
theme.value = props.defaultTheme ?? ''
outlineText.value = ''
outlineError.value = null
emit('close')
@@ -97,7 +98,7 @@ function handleClose(): void {
<!-- 第二步确认/编辑大纲 -->
<template v-else-if="phase === 'outline'">
<p>AI 已生成以下大纲可直接编辑后开始生成</p>
<textarea v-model="outlineText" class="batch-topics-input" rows="12" />
<textarea v-model="outlineText" class="batch-topics-input" rows="24" />
<p class="batch-topics-count"> {{ parsedTopics.length }} 个课题</p>
<div class="dialog-actions">
<button type="button" :disabled="parsedTopics.length === 0" @click="handleStart">开始生成</button>

View File

@@ -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>

View File

@@ -7,7 +7,7 @@ describe('LessonSidebar', () => {
it('emits a move when one lesson is dropped on another', async () => {
const designs = [createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md')]
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')
@@ -15,4 +15,14 @@ describe('LessonSidebar', () => {
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)
})
})

View File

@@ -4,11 +4,11 @@ import type { DesignId, TeachingDesign } from '../domain/teachingDesign'
defineProps<{
designs: TeachingDesign[]
selectedId: 'cover' | DesignId
selectedId: DesignId | null
}>()
const emit = defineEmits<{
select: [id: 'cover' | DesignId]
select: [id: DesignId]
remove: [id: DesignId]
move: [from: number, to: number]
}>()
@@ -29,15 +29,6 @@ function onDrop(targetIndex: number): void {
<template>
<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">
<li
v-for="(design, index) in designs"

View File

@@ -4,6 +4,16 @@ import { createEmptyTeachingDesign, type TeachingDesign } from '../domain/teachi
import TeachingDesignPage from './TeachingDesignPage.vue'
describe('TeachingDesignPage', () => {
it('does not show an empty additional content section while editing', () => {
const design = createEmptyTeachingDesign('1.md')
const wrapper = mount(TeachingDesignPage, {
props: { design, editable: true },
})
expect(wrapper.text()).not.toContain('附加内容')
})
it('adds and removes teaching process rows', async () => {
const design = createEmptyTeachingDesign('1.md')
const wrapper = mount(TeachingDesignPage, {

View File

@@ -269,7 +269,7 @@ function removeStep(index: number): void {
</tbody>
</table>
<template v-if="design.additionalContent || editable">
<template v-if="design.additionalContent.trim()">
<h2 class="section-heading">附加内容</h2>
<EditableMarkdown
:model-value="design.additionalContent"

View File

@@ -30,10 +30,10 @@ const saveStatusLabel: Record<SaveStatus, string> = {
<header class="workspace-toolbar">
<button type="button" data-testid="back" @click="$emit('back')">返回列表</button>
<button type="button" data-testid="upload" @click="$emit('upload')">导入教案</button>
<button type="button" data-testid="generate" @click="$emit('generate')">生成教案</button>
<button type="button" data-testid="generate" @click="$emit('generate')">生成一篇</button>
<button type="button" data-testid="batch-generate" @click="$emit('batchGenerate')">批量生成</button>
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 Markdown</button>
<button type="button" data-testid="export" :disabled="lessonCount === 0" @click="$emit('export')">导出 MD</button>
<button type="button" data-testid="clear" :disabled="lessonCount === 0" @click="$emit('clear')">清空</button>
<span class="workspace-toolbar-count"> {{ lessonCount }} </span>

View File

@@ -2,10 +2,13 @@ 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 * as zipExporter from '../services/zipExporter'
import BatchGenerateDialog from './BatchGenerateDialog.vue'
import GenerateLessonDialog from './GenerateLessonDialog.vue'
import WorkspaceView from './WorkspaceView.vue'
vi.mock('../services/booksApi')
vi.mock('../services/zipExporter')
function mockBook(data = createEmptyBook()): void {
vi.mocked(booksApi.getBook).mockResolvedValue({
@@ -16,6 +19,32 @@ function mockBook(data = createEmptyBook()): void {
})
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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()
@@ -74,6 +103,81 @@ describe('WorkspaceView', () => {
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('batch generates up to three lessons concurrently and keeps outline order', async () => {
mockBook()
const requests = new Map<string, ReturnType<typeof deferred<booksApi.GenerateResult>>>()
vi.mocked(booksApi.generateLesson).mockImplementation((topic) => {
const request = deferred<booksApi.GenerateResult>()
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'))
@@ -87,4 +191,20 @@ describe('WorkspaceView', () => {
expect(wrapper.text()).toContain('点击或拖拽上传')
})
it('downloads the exported zip with the book name', async () => {
const data = createEmptyBook()
data.designs.push(createEmptyTeachingDesign('1.md'))
const blob = new Blob(['zip'])
mockBook(data)
vi.mocked(zipExporter.createBookZip).mockResolvedValue(blob)
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
await flushPromises()
await wrapper.get('[data-testid="export"]').trigger('click')
await flushPromises()
expect(zipExporter.downloadBlob).toHaveBeenCalledWith(blob, '示例整本.zip')
})
})

View File

@@ -13,6 +13,9 @@ import PrintBook from './PrintBook.vue'
import UploadDropzone from './UploadDropzone.vue'
import WorkspaceToolbar from './WorkspaceToolbar.vue'
const BATCH_GENERATE_CONCURRENCY = 3
const DEFAULT_EXPORT_ZIP_NAME = 'teaching-design-book'
const props = defineProps<{ bookId: string }>()
defineEmits<{ back: [] }>()
@@ -32,10 +35,10 @@ const {
selectPage,
moveDesign,
removeDesign,
updateCover,
updateDesign,
clearBook,
generateLesson,
generateLessons,
regenerateLesson,
} = useTeachingBook(props.bookId)
@@ -102,10 +105,15 @@ function handlePrint(): void {
document.title = prev
}
function createExportZipFilename(name: string): string {
const stem = name.trim().replace(/[\\/:*?"<>|]/g, '_')
return `${stem || DEFAULT_EXPORT_ZIP_NAME}.zip`
}
async function handleExport(): Promise<void> {
try {
const blob = await createBookZip(book.value.designs)
downloadBlob(blob, 'teaching-design-book.zip')
downloadBlob(blob, createExportZipFilename(bookName.value))
} catch {
errorMessage.value = '导出失败,请重试。'
}
@@ -154,15 +162,19 @@ async function handleBatchStart(topics: string[]): Promise<void> {
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
@@ -248,6 +260,7 @@ function closeFixDialog(): void {
:total="batchTotal"
:current-topic="batchCurrentTopic"
:error="batchError"
:default-theme="bookName"
@start="handleBatchStart"
@cancel="handleBatchCancel"
@close="closeBatchDialog"
@@ -298,10 +311,7 @@ function closeFixDialog(): void {
@move="moveDesign"
/>
<A4Workspace
:cover="book.cover"
:selected-id="book.selectedId"
:selected-design="selectedDesign"
@update:cover="updateCover"
@update:design="handleDesignUpdate"
/>
</div>

View File

@@ -10,15 +10,58 @@ function mockGetBook(data: TeachingBook, id = 'b1'): void {
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 }
}
function generatedMarkdownWithAdditionalSection(topic: string): string {
return [
`# ${topic} 教学设计`,
'| | |',
'|:---|:---|',
`| **课题** | **${topic}** |`,
'| **课时** | 1课时40分钟 |',
'| **教学目标** | **知识目标**:理解概念。<br>**技能目标**:完成任务。<br>**素养目标**:规范表达。 |',
'| **教学重难点** | **重点**:任务流程。<br>**难点**:问题定位。 |',
'| **教学资源准备** | 机房、示例文件。 |',
'',
'## 教学过程',
'',
'| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |',
'|:---|:---|:---|:---|:---|',
'| **1. 导入**<br>5分钟 | 引出任务。 | **情境导入**<br>展示案例。 | **观察思考**<br>回答问题。 | 明确目标。 |',
'',
'## 板书设计',
'',
'```text',
`${topic}`,
'```',
'',
'## 教学成效与反思',
'',
'| | |',
'|:---|:---|',
'| **教学成效** | 学生完成任务。 |',
'| **教学反思** | 后续加强练习。 |',
'',
'## 附加说明',
'',
'这是模型额外生成的内容。',
].join('\n')
}
describe('useTeachingBook', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
it('loads the book from the API', async () => {
const data = createEmptyBook()
data.cover.courseName = 'Web 前端开发'
it('loads the book from the API without cover state', async () => {
const { data } = createBookWithDesign()
mockGetBook(data)
const store = useTeachingBook('b1')
@@ -26,7 +69,8 @@ describe('useTeachingBook', () => {
expect(booksApi.getBook).toHaveBeenCalledWith('b1')
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 () => {
@@ -82,13 +126,16 @@ describe('useTeachingBook', () => {
})
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' })
const store = useTeachingBook('b1')
await flushPromises()
store.updateCover({ courseName: '新课程名' })
store.updateDesign(design.id, (current) => {
current.title = '新课程名'
})
await vi.advanceTimersByTimeAsync(300)
expect(booksApi.updateBook).toHaveBeenCalledWith('b1', store.book.value)
@@ -96,13 +143,16 @@ describe('useTeachingBook', () => {
})
it('sets saveStatus to error when autosave fails', async () => {
mockGetBook(createEmptyBook())
const { data, design } = createBookWithDesign()
mockGetBook(data)
vi.mocked(booksApi.updateBook).mockRejectedValue(new Error('保存失败。'))
const store = useTeachingBook('b1')
await flushPromises()
store.updateCover({ courseName: '新课程名' })
store.updateDesign(design.id, (current) => {
current.title = '新课程名'
})
await vi.advanceTimersByTimeAsync(300)
expect(store.saveStatus.value).toBe('error')
@@ -126,6 +176,25 @@ describe('useTeachingBook', () => {
expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id)
})
it('generateLesson discards unclassified additional content from AI output', async () => {
mockGetBook(createEmptyBook())
vi.mocked(booksApi.generateLesson).mockResolvedValue({
filename: 'css-flex.md',
markdown: generatedMarkdownWithAdditionalSection('CSS 弹性布局'),
})
const store = useTeachingBook('b1')
await flushPromises()
const result = await store.generateLesson('CSS 弹性布局')
expect(result).toEqual({ ok: true })
expect(store.book.value.designs[0]?.additionalContent).toBe('')
expect(store.book.value.designs[0]?.warnings).not.toContainEqual(
expect.objectContaining({ code: 'unclassified-content' }),
)
})
it('generateLesson returns an error when the API call fails', async () => {
mockGetBook(createEmptyBook())
vi.mocked(booksApi.generateLesson).mockRejectedValue(new Error('Deepseek 请求失败。'))
@@ -139,10 +208,8 @@ describe('useTeachingBook', () => {
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'))
it('clearBook empties designs and clears selection', async () => {
const { data } = createBookWithDesign()
mockGetBook(data)
const store = useTeachingBook('b1')
@@ -151,7 +218,22 @@ describe('useTeachingBook', () => {
store.clearBook()
expect(store.book.value.designs).toEqual([])
expect(store.book.value.cover.courseName).toBe('Web 前端开发')
expect(store.book.value.selectedId).toBe('cover')
expect(store.book.value).not.toHaveProperty('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()
})
})

View File

@@ -1,7 +1,6 @@
import { nextTick, ref, watch, type Ref } from 'vue'
import {
createEmptyBook,
type BookCover,
type DesignId,
type TeachingBook,
type TeachingDesign,
@@ -20,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 }>
@@ -38,13 +48,16 @@ export interface TeachingBookStore {
warningCount: Ref<number>
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
detectDuplicates: (files: readonly File[]) => string[]
selectPage: (id: 'cover' | DesignId) => void
selectPage: (id: DesignId) => void
moveDesign: (from: number, to: number) => void
removeDesign: (id: DesignId) => void
updateCover: (patch: Partial<BookCover>) => void
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
clearBook: () => void
generateLesson: (topic: string) => Promise<GenerateLessonResult>
generateLessons: (
topics: readonly string[],
options?: BatchGenerateLessonOptions,
) => Promise<BatchGenerateLessonResult>
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
}
@@ -67,7 +80,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
const current = book.value
hasDesigns.value = current.designs.length > 0
selectedDesign.value =
current.selectedId === 'cover'
current.selectedId === null
? null
: current.designs.find((design) => design.id === current.selectedId) ?? null
warningCount.value = current.designs.reduce(
@@ -176,7 +189,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
}
@@ -187,7 +200,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
return { imported, failed, duplicates }
}
function selectPage(id: 'cover' | DesignId): void {
function selectPage(id: DesignId): void {
book.value.selectedId = id
}
@@ -210,17 +223,12 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
designs.splice(index, 1)
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()
}
function updateCover(patch: Partial<BookCover>): void {
Object.assign(book.value.cover, patch)
touch()
}
function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void {
const design = book.value.designs.find((candidate) => candidate.id === id)
if (!design) {
@@ -232,14 +240,22 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
function clearBook(): void {
book.value.designs = []
book.value.selectedId = 'cover'
book.value.selectedId = null
touch()
}
function removeGeneratedAdditionalContent(design: TeachingDesign): TeachingDesign {
design.additionalContent = ''
design.warnings = design.warnings.filter((warning) => warning.code !== 'unclassified-content')
return design
}
async function generateLesson(topic: string): Promise<GenerateLessonResult> {
try {
const result = await booksApi.generateLesson(topic)
const design = parseTeachingDesign(result.filename, result.markdown)
const design = removeGeneratedAdditionalContent(
parseTeachingDesign(result.filename, result.markdown),
)
book.value.designs.push(design)
book.value.selectedId = design.id
touch()
@@ -249,6 +265,66 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
}
}
async function generateLessons(
topics: readonly string[],
options: BatchGenerateLessonOptions = {},
): Promise<BatchGenerateLessonResult> {
const concurrency = Math.max(1, options.concurrency ?? 3)
const workerCount = Math.min(concurrency, topics.length)
const results = new Array<TeachingDesign | undefined>(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<void> {
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] = removeGeneratedAdditionalContent(
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<GenerateLessonResult> {
const existing = book.value.designs.find((d) => d.id === id)
if (!existing) return { ok: false, message: '找不到该教案。' }
@@ -256,7 +332,9 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
const topic = existing.originalFilename.replace(/\.md$/i, '')
try {
const result = await booksApi.generateLesson(topic)
const newDesign = parseTeachingDesign(result.filename, result.markdown)
const newDesign = removeGeneratedAdditionalContent(
parseTeachingDesign(result.filename, result.markdown),
)
const index = book.value.designs.findIndex((d) => d.id === id)
if (index !== -1) {
book.value.designs.splice(index, 1, newDesign)
@@ -286,10 +364,10 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
selectPage,
moveDesign,
removeDesign,
updateCover,
updateDesign,
clearBook,
generateLesson,
generateLessons,
regenerateLesson,
}
}

View File

@@ -53,24 +53,22 @@ describe('createEmptyTeachingDesign', () => {
})
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()
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)
})
it('creates independent cover and design collections', () => {
it('creates independent design collections', () => {
const first = createEmptyBook()
const second = createEmptyBook()
first.cover.courseName = 'Changed'
first.designs.push(createEmptyTeachingDesign('1.md'))
expect(first.cover).not.toBe(second.cover)
expect(first.designs).not.toBe(second.designs)
expect(second.cover.courseName).toBe('')
expect(second.designs).toEqual([])
})
})
@@ -79,7 +77,7 @@ describe('domain types', () => {
it('uses branded string design IDs and literal schema versions', () => {
expectTypeOf<DesignId>().toExtend<string>()
expectTypeOf<TeachingDesign['id']>().toEqualTypeOf<DesignId>()
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<'cover' | DesignId>()
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<DesignId | null>()
expectTypeOf<TeachingBook['schemaVersion']>().toEqualTypeOf<
typeof BOOK_SCHEMA_VERSION
>()

View File

@@ -50,16 +50,10 @@ export interface TeachingDesign {
warnings: ParseWarning[]
}
export interface BookCover {
courseName: string
teacherName: string
}
export interface TeachingBook {
schemaVersion: typeof BOOK_SCHEMA_VERSION
cover: BookCover
designs: TeachingDesign[]
selectedId: 'cover' | DesignId
selectedId: DesignId | null
updatedAt: string
}
@@ -104,9 +98,8 @@ export function createEmptyTeachingDesign(filename: string): TeachingDesign {
export function createEmptyBook(): TeachingBook {
return {
schemaVersion: BOOK_SCHEMA_VERSION,
cover: { courseName: '', teacherName: '' },
designs: [],
selectedId: 'cover',
selectedId: null,
updatedAt: new Date().toISOString(),
}
}

View File

@@ -82,10 +82,10 @@ input {
}
.ui-button:disabled {
color: var(--muted);
background: #f4f6f7;
color: #43515c;
border-color: var(--line);
cursor: not-allowed;
opacity: 0.6;
}
.ui-button--primary {
@@ -195,10 +195,10 @@ input {
}
.workspace-toolbar button:disabled {
color: var(--muted);
background: #f4f6f7;
color: #43515c;
border-color: var(--line);
cursor: not-allowed;
opacity: 0.6;
}
.workspace-toolbar-count,
@@ -229,8 +229,8 @@ input {
/* Sidebar */
.lesson-sidebar {
width: 260px;
flex: 0 0 260px;
width: 360px;
flex: 0 0 360px;
background: #fff;
border-right: 1px solid var(--line);
overflow-y: auto;
@@ -238,17 +238,6 @@ input {
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 {
list-style: none;
margin: 0;
@@ -311,7 +300,7 @@ input {
color: var(--muted);
cursor: pointer;
padding: 0 12px;
font-size: 16px;
font-size: 24px;
}
.lesson-sidebar-remove:hover {
@@ -346,41 +335,6 @@ input {
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 {
display: flex;
@@ -733,6 +687,7 @@ table {
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
width: 100%;
}
.book-list {

31
src/style.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import './style.css'
describe('shared button styles', () => {
it('keeps disabled shared button text readable without lowering opacity', () => {
const button = document.createElement('button')
button.className = 'ui-button ui-button--primary'
button.disabled = true
button.textContent = 'Create'
document.body.append(button)
expect(getComputedStyle(button).opacity).toBe('1')
button.remove()
})
it('keeps disabled toolbar button text readable without lowering opacity', () => {
const toolbar = document.createElement('div')
toolbar.className = 'workspace-toolbar'
const button = document.createElement('button')
button.disabled = true
button.textContent = 'Export'
toolbar.append(button)
document.body.append(toolbar)
expect(getComputedStyle(button).opacity).toBe('1')
toolbar.remove()
})
})