Compare commits
31 Commits
febce4015b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ae7e60d43 | |||
| ceb04a1b83 | |||
| 2de18e40e2 | |||
| a0f26117a6 | |||
| e3a21a46b5 | |||
| be8fe206bf | |||
| 1d123bfac3 | |||
| 59e8bfb2a9 | |||
| 58dfe3e455 | |||
| 973348115c | |||
| 028ba0f2f9 | |||
| 33d5bfd8e9 | |||
| fb0b8d11c9 | |||
| 6e2a17f63f | |||
| b0e70d7b71 | |||
| c49221ac22 | |||
| 55282963b5 | |||
| 02ca889bc2 | |||
| 10edc664e8 | |||
| 667f8be21c | |||
| 0dc1a35365 | |||
| 804224fc3c | |||
| 494fbef7b4 | |||
| 660039b3cf | |||
| 19cc1ffdfa | |||
| dcec78d4b7 | |||
| 8b0e4df43d | |||
| 2321b5f68e | |||
| 7b95324649 | |||
| a2534459d0 | |||
| 085f70bd64 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,3 +27,6 @@ dist-ssr
|
||||
# Backend data and secrets
|
||||
data/teaching-books.db
|
||||
.env
|
||||
|
||||
# Brainstorming visual companion
|
||||
.superpowers/
|
||||
|
||||
@@ -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,10 +15,14 @@ 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
|
||||
|
||||
COPY server/ ./server/
|
||||
COPY shared/ ./shared/
|
||||
COPY --from=builder /app/dist ./dist/
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -8,22 +8,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
"@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "https://registry.npmjs.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "https://registry.npmjs.com/@types/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.13.2", "https://registry.npmjs.com/@types/node/-/node-24.13.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||
"@types/node": ["@types/node@25.9.3", "https://registry.npmmirror.com/@types/node/-/node-25.9.3.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="],
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.1.1", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-2.1.1.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
"bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "https://registry.npmjs.com/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
@@ -460,7 +460,7 @@
|
||||
|
||||
"undici": ["undici@7.28.0", "https://registry.npmjs.com/undici/-/undici-7.28.0.tgz", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://registry.npmjs.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmjs.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
@@ -502,6 +502,8 @@
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@24.13.2", "https://registry.npmjs.com/@types/node/-/node-24.13.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||
|
||||
"parse5/entities": ["entities@8.0.0", "https://registry.npmjs.com/entities/-/entities-8.0.0.tgz", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmjs.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -518,6 +520,8 @@
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmjs.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "https://registry.npmjs.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmjs.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmjs.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
services:
|
||||
app:
|
||||
teaching-design:
|
||||
build: .
|
||||
ports:
|
||||
- "3001:3001"
|
||||
expose:
|
||||
- 3001
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
|
||||
- TEACHING_BOOKS_DB=/app/data/teaching-books.db
|
||||
volumes:
|
||||
- db_data:/app/data
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- npm_proxy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
networks:
|
||||
npm_proxy:
|
||||
external: true
|
||||
|
||||
340
docs/superpowers/plans/2026-06-16-frontend-routing.md
Normal file
340
docs/superpowers/plans/2026-06-16-frontend-routing.md
Normal 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.
|
||||
779
docs/superpowers/plans/2026-06-16-remove-cover-page.md
Normal file
779
docs/superpowers/plans/2026-06-16-remove-cover-page.md
Normal 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.
|
||||
607
docs/superpowers/plans/2026-06-16-ui-detail-polish.md
Normal file
607
docs/superpowers/plans/2026-06-16-ui-detail-polish.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# UI Detail Polish 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:** Apply the changes in `docs/superpowers/specs/2026-06-16-ui-detail-polish-design.md` — radius/spacing design tokens, subtle interaction feedback (hover/active/focus-visible), and a mobile workspace-toolbar wrapping fix.
|
||||
|
||||
**Architecture:** This is a CSS-only change set, almost entirely confined to `src/style.css`, plus one selector update in `src/components/LoginPage.vue`'s `<style scoped>` block. There is no component markup or behavior change, and no new dependencies.
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript (Vite + Bun), plain CSS (no preprocessor, no CSS framework).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No markup or `<script>` changes in any `.vue` file — CSS only.
|
||||
- Do not touch `src/print.css` or the A4 canvas layout (`.page`, `.a4-workspace`, `.a4-paper`).
|
||||
- Do not replace `window.confirm()` with the `.dialog` component anywhere (out of scope, separate future spec).
|
||||
- No new npm/bun dependencies, no icon or animation libraries.
|
||||
- This repo has no component test files (`bun run test` runs `vitest run` with zero test files — confirmed before writing this plan). The testable deliverable for each task is: `bun run build` passes (type-check via `vue-tsc -b` + Vite build) AND a manual visual check in the browser matches the description in that task.
|
||||
- Dev server: `bun run dev` (Vite on `http://localhost:5173/`). It was already running on port 5173 at the time this plan was written — check before starting a new one (`curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/`).
|
||||
- Admin login credentials for manual checks (from `.env`): `admin` / `admin123`.
|
||||
- Make one commit per task, after its build+visual check passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Radius and spacing tokens
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/style.css:1-14` (`:root` block), and every line listed below in the same file.
|
||||
- Modify: `src/components/LoginPage.vue:76-86` (`.login-form` rule in `<style scoped>`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: CSS custom properties `--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-xl`, `--radius-pill`, `--space-1`, `--space-2`, `--space-3`, `--space-4`, `--space-6` on `:root` in `src/style.css`. Every later task in this plan may reference these by name.
|
||||
|
||||
- [ ] **Step 1: Add the token block to `:root`**
|
||||
|
||||
In `src/style.css`, the current `:root` block is:
|
||||
|
||||
```css
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #202a33;
|
||||
background: #edf0f2;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--green-700: #216447;
|
||||
--green-600: #2d7a58;
|
||||
--green-100: #dceee5;
|
||||
--line: #cfd5da;
|
||||
--muted: #68747f;
|
||||
--paper-width: 210mm;
|
||||
--paper-min-height: 297mm;
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```css
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #202a33;
|
||||
background: #edf0f2;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--green-700: #216447;
|
||||
--green-600: #2d7a58;
|
||||
--green-100: #dceee5;
|
||||
--line: #cfd5da;
|
||||
--muted: #68747f;
|
||||
--paper-width: 210mm;
|
||||
--paper-min-height: 297mm;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-pill: 999px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap every hardcoded `border-radius` value onto the matching token (like-for-like, no visual change)**
|
||||
|
||||
Make these exact replacements in `src/style.css` (line numbers are pre-edit; re-find by selector if they've shifted from Step 1):
|
||||
|
||||
| Line | Selector | Before | After |
|
||||
|---|---|---|---|
|
||||
| 72 | `.ui-button` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 114 | `.ui-field,`<br>`.ui-select` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 186 | `.workspace-toolbar button` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 288 | `.lesson-sidebar-badge` | `border-radius: 999px;` | `border-radius: var(--radius-pill);` |
|
||||
| 430 | `.process-step-actions button` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 446 | `.board-design` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 456 | `.warning-summary` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 472 | `.editable-text` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 507 | `.markdown-preview` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 532 | `.markdown-source` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 551 | `.upload-dropzone` | `border-radius: 12px;` | `border-radius: var(--radius-xl);` |
|
||||
| 569 | `.upload-dropzone--compact` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 603 | `.dialog` | `border-radius: 8px;` | `border-radius: var(--radius-lg);` |
|
||||
| 625 | `.dialog-actions button` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 648 | `.app-notice button` | `border-radius: 4px;` | `border-radius: var(--radius-sm);` |
|
||||
| 697 | `.dialog input` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
| 718 | `.book-list-item` | `border-radius: 8px;` | `border-radius: var(--radius-lg);` |
|
||||
| 741 | `.batch-topics-input` | `border-radius: 6px;` | `border-radius: var(--radius-md);` |
|
||||
|
||||
Leave `.batch-progress-bar` and `.batch-progress-fill` (`border-radius: 3px`) unchanged — 3px isn't on the new scale and isn't called out in the spec.
|
||||
|
||||
- [ ] **Step 3: Swap the radius in `LoginPage.vue`**
|
||||
|
||||
In `src/components/LoginPage.vue`, inside `<style scoped>`, change:
|
||||
|
||||
```css
|
||||
.login-form {
|
||||
width: min(100%, 340px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
|
||||
padding: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.login-form {
|
||||
width: min(100%, 340px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
|
||||
padding: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Snap the three off-grid spacing values**
|
||||
|
||||
In `src/components/LoginPage.vue`, change `.field`:
|
||||
|
||||
```css
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/style.css`, change `.ui-table th, .ui-table td`:
|
||||
|
||||
```css
|
||||
.ui-table th,
|
||||
.ui-table td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.ui-table th,
|
||||
.ui-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/style.css`, change `.objective-row`:
|
||||
|
||||
```css
|
||||
.objective-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.objective-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and verify no visual regression**
|
||||
|
||||
Run: `bun run build`
|
||||
Expected: exits 0, no TypeScript or Vue compiler errors.
|
||||
|
||||
Then, with the dev server running at `http://localhost:5173/`, open the app in a browser and compare against the pre-change appearance (radius values are identical, so nothing should look different):
|
||||
- Login page (`/`): card corners, input corners.
|
||||
- Book list and admin page (login as `admin`/`admin123`): buttons, table.
|
||||
- Open a lesson workspace: toolbar buttons, sidebar badge, editable text fields, dialogs (e.g. open the batch-generate dialog).
|
||||
|
||||
Confirm visually that nothing shifted except the three intentional spacing snaps (table cell padding, login field label gap, objective row gap — all changed by at most 2px and should not be perceptible as broken).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/style.css src/components/LoginPage.vue
|
||||
git commit -m "feat: add radius/spacing design tokens and apply across styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Transitions and active states on buttons/editable elements
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/style.css` (selectors listed below)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--green-100`, `--green-600`, `--green-700` (existing tokens).
|
||||
- Produces: every button-like selector listed below now has a `transition` declaration and an `:active:not(:disabled)` rule. Task 3 will add `:focus-visible` to the same selector group — keep the group identical so the two tasks compose cleanly: `.ui-button`, `.workspace-toolbar button`, `.dialog-actions button`, `.process-step-actions button`.
|
||||
|
||||
- [ ] **Step 1: Add transitions to button selectors**
|
||||
|
||||
In `src/style.css`, change:
|
||||
|
||||
```css
|
||||
.ui-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.ui-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.workspace-toolbar button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.workspace-toolbar button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.dialog-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.dialog-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.process-step-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #c0392b;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.process-step-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #c0392b;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.editable-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.editable-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.markdown-preview {
|
||||
min-height: 1.6em;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: text;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.markdown-preview {
|
||||
min-height: 1.6em;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: text;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `:active:not(:disabled)` feedback to button selectors**
|
||||
|
||||
Add this new rule directly after the `.ui-button--danger:hover:not(:disabled)` rule (currently ending around line 109):
|
||||
|
||||
```css
|
||||
.ui-button:active:not(:disabled),
|
||||
.workspace-toolbar button:active:not(:disabled),
|
||||
.dialog-actions button:active:not(:disabled),
|
||||
.process-step-actions button:active:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
```
|
||||
|
||||
Using `filter: brightness()` gives every button variant (default, primary, danger) a consistent "pressed" darkening with one rule, instead of hand-picking a darker shade per variant.
|
||||
|
||||
- [ ] **Step 3: Build and verify**
|
||||
|
||||
Run: `bun run build`
|
||||
Expected: exits 0.
|
||||
|
||||
In the browser, hover and click (mouse-down, hold) each of: a `.ui-button` (e.g. "返回" on admin page), a workspace toolbar button, a dialog button (open any dialog), a process-step action button (in a lesson's process table), an editable text field, and a markdown preview block. Confirm:
|
||||
- Hover transitions smoothly (no instant snap) over ~150ms.
|
||||
- Mouse-down on buttons visibly darkens the button while held.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/style.css
|
||||
git commit -m "feat: add transitions and active-press feedback to buttons and editable fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: New hover states and shared focus-visible ring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/style.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the button selector group from Task 2 (`.ui-button`, `.workspace-toolbar button`, `.dialog-actions button`, `.process-step-actions button`) and `--green-600`.
|
||||
- Produces: nothing consumed by later tasks.
|
||||
|
||||
- [ ] **Step 1: Add hover tint to non-active sidebar items**
|
||||
|
||||
In `src/style.css`, locate `.lesson-sidebar-select` (currently no hover rule) and add a new rule immediately after it:
|
||||
|
||||
```css
|
||||
.lesson-sidebar-select {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.lesson-sidebar-item:not(.lesson-sidebar-item--active) .lesson-sidebar-select:hover {
|
||||
background: #f4f6f7;
|
||||
}
|
||||
```
|
||||
|
||||
(The `:not(.lesson-sidebar-item--active)` guard keeps this from fighting with the existing green `--active` background on the selected item.)
|
||||
|
||||
- [ ] **Step 2: Add hover tint to table rows**
|
||||
|
||||
In `src/style.css`, locate the `.ui-table tr:last-child td` rule and add a new rule right after it:
|
||||
|
||||
```css
|
||||
.ui-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ui-table tbody tr:hover {
|
||||
background: #f8faf9;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add shared `:focus-visible` ring for buttons**
|
||||
|
||||
In `src/style.css`, add this new rule right after the `:active:not(:disabled)` rule added in Task 2 Step 2:
|
||||
|
||||
```css
|
||||
.ui-button:focus-visible,
|
||||
.workspace-toolbar button:focus-visible,
|
||||
.dialog-actions button:focus-visible,
|
||||
.process-step-actions button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--green-600);
|
||||
box-shadow: 0 0 0 2px rgba(45, 122, 88, 0.16);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `bun run build`
|
||||
Expected: exits 0.
|
||||
|
||||
In the browser:
|
||||
- Open a lesson with multiple chapters in the sidebar. Hover a non-active sidebar row → light gray background appears; hover does NOT appear on the currently active (green) row.
|
||||
- Hover over admin/book-list table rows → light tint appears per row.
|
||||
- Tab (keyboard) through buttons (e.g. admin page header buttons) → a green focus ring appears on the focused button. Click a button with the mouse → no ring appears on click (only on keyboard focus), confirming `:focus-visible` rather than `:focus` is in effect.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/style.css
|
||||
git commit -m "feat: add sidebar/table hover tints and focus-visible ring for buttons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Mobile workspace toolbar wrap fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/style.css` (the existing `@media (max-width: 900px)` block, by adding a new sibling media block — do not nest inside it)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--space-2`, `--space-4` from Task 1.
|
||||
- Produces: nothing consumed by later tasks. This is the last task in the plan.
|
||||
|
||||
- [ ] **Step 1: Add the new breakpoint**
|
||||
|
||||
In `src/style.css`, the existing responsive block currently ends with:
|
||||
|
||||
```css
|
||||
@media (max-width: 900px) {
|
||||
.workspace-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lesson-sidebar {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
max-height: 180px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.a4-workspace {
|
||||
padding: 6mm;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a new, separate media block directly after this one (same `/* Responsive */` section, not nested inside the 900px block):
|
||||
|
||||
```css
|
||||
@media (max-width: 600px) {
|
||||
.workspace-toolbar {
|
||||
height: auto;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-toolbar button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-toolbar-count {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `bun run build`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 3: Verify at 390px width**
|
||||
|
||||
With the dev server running, open a lesson workspace in the browser and resize/emulate the viewport to 390px wide (e.g. iPhone 12 Pro preset). Confirm:
|
||||
- The toolbar's 6 buttons wrap onto two rows, each button keeps its full label on one line (no character-by-character wrapping).
|
||||
- The "已选 N 个" count text drops to its own line below the buttons.
|
||||
- At desktop width (e.g. 1280px), the toolbar is unchanged from before (single row, fixed 56px height).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/style.css
|
||||
git commit -m "fix: wrap workspace toolbar buttons on narrow mobile widths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Section 1 (tokens + 3 spacing snaps) → Task 1. Section 2 (transitions, active states, new hovers, focus-visible) → Tasks 2–3. Section 3 (mobile toolbar) → Task 4. Testing section (build + manual desktop/390px check) → covered in every task's verification step.
|
||||
- **Out-of-scope guardrails carried into Global Constraints:** no `window.confirm()` → `.dialog` migration, no A4/`print.css` changes, no new dependencies, no markup changes — all stated explicitly so a task executor with zero conversation context doesn't drift into them.
|
||||
- **Type/selector consistency:** the four-selector "button group" (`.ui-button`, `.workspace-toolbar button`, `.dialog-actions button`, `.process-step-actions button`) is used identically across Task 2's transitions/active rule and Task 3's focus-visible rule — verified the selector list matches verbatim in both places.
|
||||
80
docs/superpowers/specs/2026-06-16-frontend-routing-design.md
Normal file
80
docs/superpowers/specs/2026-06-16-frontend-routing-design.md
Normal 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.
|
||||
97
docs/superpowers/specs/2026-06-16-ui-detail-polish-design.md
Normal file
97
docs/superpowers/specs/2026-06-16-ui-detail-polish-design.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# UI Detail Polish Design
|
||||
|
||||
## Goal
|
||||
|
||||
Polish UI details across the whole app: interaction feedback, spacing/radius consistency, and a real mobile layout bug in the workspace toolbar. Found via live screenshots of the running app (login, book list, admin, workspace, dialogs) at desktop and 390px mobile width.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `src/style.css`: design tokens for border-radius and spacing, transitions, hover/active/focus-visible states, table/sidebar hover tints, mobile toolbar fix.
|
||||
- Any component `<style scoped>` blocks that hardcode a radius/spacing value covered by the new tokens (e.g. `AdminPage.vue`, `LoginPage.vue`, dialog components).
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Replacing native `window.confirm()` (used for delete in `BookListPage.vue` and `AdminPage.vue`) with the app's custom `.dialog` component. This is a behavior/component-level change, not a styling detail — flagged for a future, separate spec.
|
||||
- Redesigning the A4 teaching-design canvas or `src/print.css`.
|
||||
- Adding icons, animation libraries, or new dependencies.
|
||||
- Changing any component's markup structure or behavior, except where strictly required to apply a hover/focus class.
|
||||
|
||||
## 1. Radius and Spacing Tokens
|
||||
|
||||
Add to `:root` in `src/style.css`:
|
||||
|
||||
```css
|
||||
--radius-sm: 4px; /* inline controls: editable-text, markdown-preview/source, board-design, process-step-actions button */
|
||||
--radius-md: 6px; /* default control radius: ui-button, ui-field, ui-select, workspace-toolbar button, dialog-actions button */
|
||||
--radius-lg: 8px; /* cards/surfaces: dialog, login-form */
|
||||
--radius-xl: 12px; /* upload-dropzone */
|
||||
--radius-pill: 999px; /* lesson-sidebar-badge */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
```
|
||||
|
||||
Replace existing hardcoded radius values with the matching variable everywhere they appear in `src/style.css` and in component `<style scoped>` blocks (`AdminPage.vue`, `LoginPage.vue`, dialog components). This is a like-for-like swap — current values already map cleanly onto this scale, so visual output should not change except for the spacing snap below.
|
||||
|
||||
Snap off-grid spacing values onto the 4px scale where they don't change layout meaningfully:
|
||||
|
||||
- `.field` gap `6px` → `var(--space-2)` (8px)
|
||||
- `.ui-table th/td` padding `8px 10px` → `var(--space-2) var(--space-2)` (8px 8px)
|
||||
- `.objective-row` gap `6px` → `var(--space-2)` (8px)
|
||||
|
||||
Skip snapping any value where it would visibly break an intentional layout (e.g. `process-step-actions { width: 6em }` is a width, not a spacing token, and stays as-is).
|
||||
|
||||
## 2. Interaction Feedback (subtle intensity)
|
||||
|
||||
Apply `transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease;` to:
|
||||
|
||||
- `.ui-button` (covers `--primary` and `--danger` variants)
|
||||
- `.workspace-toolbar button`
|
||||
- `.dialog-actions button`
|
||||
- `.process-step-actions button`
|
||||
- `.editable-text`, `.markdown-preview` (already have hover background, currently instant)
|
||||
|
||||
Add `:active:not(:disabled)` states (one shade darker / more saturated than hover) to the same button selectors above, so clicks feel acknowledged.
|
||||
|
||||
Add hover feedback where there is currently none:
|
||||
|
||||
- `.lesson-sidebar-select:not(.lesson-sidebar-item--active *)`: light neutral hover background (e.g. `#f4f6f7`), distinct from the existing green `--active` state, transition included.
|
||||
- `.ui-table tbody tr:hover`: light tint (e.g. `var(--green-100)` at reduced opacity or `#f8faf9`) for scanability.
|
||||
|
||||
Add a shared `:focus-visible` style for buttons, matching the existing `.ui-field:focus` treatment (`border-color: var(--green-600); box-shadow: 0 0 0 2px rgba(45, 122, 88, 0.16);`) so keyboard focus is visually consistent between buttons and inputs. Mouse clicks should not trigger this (hence `:focus-visible`, not `:focus`).
|
||||
|
||||
## 3. Mobile Workspace Toolbar Fix
|
||||
|
||||
Current bug: `.workspace-toolbar` is a fixed-height (56px) single-row flex container with no wrapping. At ~390px width its 6 buttons compress until button text wraps character-by-character, making the toolbar unreadable and unusable.
|
||||
|
||||
Fix (selected option B — wrap): add a breakpoint at `max-width: 600px`:
|
||||
|
||||
```css
|
||||
@media (max-width: 600px) {
|
||||
.workspace-toolbar {
|
||||
height: auto;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.workspace-toolbar button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.workspace-toolbar-count {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verified by live injection at 390px: all 6 buttons stay on readable single-line labels across two rows, count text drops to its own line below.
|
||||
|
||||
## Testing
|
||||
|
||||
- Run existing component tests (no behavior or markup changes expected to break them).
|
||||
- Run `bun run build` (includes `vue-tsc -b`) to confirm no type errors from any `<style scoped>` edits.
|
||||
- Manually verify in the browser at desktop width and at 390px width (workspace toolbar, sidebar hover, table hover, button focus-visible ring).
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>教学设计</title>
|
||||
<title>教学设计生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
152
package-lock.json
generated
152
package-lock.json
generated
@@ -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"
|
||||
|
||||
19
package.json
19
package.json
@@ -4,9 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "bun run server:dev & vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"server": "bun run server/index.ts",
|
||||
"server:dev": "bun --watch run server/index.ts",
|
||||
"test": "vitest run",
|
||||
@@ -18,21 +17,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { createEmptyBook, createEmptyTeachingDesign } from '../shared/domain/teachingDesign'
|
||||
import {
|
||||
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
|
||||
createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash,
|
||||
@@ -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', () => {
|
||||
|
||||
101
server/db.ts
101
server/db.ts
@@ -1,11 +1,12 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { createEmptyBook, type TeachingBook } from '../src/domain/teachingDesign'
|
||||
import { createEmptyBook, type TeachingBook } from '../shared/domain/teachingDesign'
|
||||
|
||||
export interface BookSummary {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
lessonCount: number
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
export interface BookRecord {
|
||||
@@ -26,6 +27,17 @@ interface BookRow {
|
||||
name: string
|
||||
data: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
|
||||
cover?: unknown
|
||||
selectedId?: string | null
|
||||
}
|
||||
|
||||
interface NormalizedBookData {
|
||||
data: TeachingBook
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
@@ -57,7 +69,8 @@ const SCHEMA = `
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -77,38 +90,109 @@ 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 migrateBookOwnership(db: Database): void {
|
||||
const admin = db
|
||||
.query<{ id: string }, []>("SELECT id FROM users WHERE role = 'admin' LIMIT 1")
|
||||
.get()
|
||||
if (!admin) return
|
||||
db.run("UPDATE books SET created_by = ? WHERE created_by = ''", [admin.id])
|
||||
}
|
||||
|
||||
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)
|
||||
try {
|
||||
db.run("ALTER TABLE books ADD COLUMN created_by TEXT NOT NULL DEFAULT ''")
|
||||
} catch {
|
||||
// column already exists
|
||||
}
|
||||
migrateStoredBooks(db)
|
||||
migrateBookOwnership(db)
|
||||
return db
|
||||
}
|
||||
|
||||
export function listBooks(db: Database): BookSummary[] {
|
||||
const rows = db
|
||||
.query<BookRow, []>('SELECT id, name, data, updated_at FROM books ORDER BY updated_at DESC')
|
||||
.query<BookRow & { creator_username: string }, []>(
|
||||
`SELECT b.id, b.name, b.data, b.updated_at, b.created_by,
|
||||
COALESCE(u.username, '') AS creator_username
|
||||
FROM books b
|
||||
LEFT JOIN users u ON b.created_by = u.id
|
||||
ORDER BY b.updated_at DESC`,
|
||||
)
|
||||
.all()
|
||||
|
||||
return rows.map((row) => ({
|
||||
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,
|
||||
createdBy: row.creator_username,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createBook(db: Database, name: string): BookRecord {
|
||||
export function createBook(db: Database, name: string, userId = ''): BookRecord {
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const data = createEmptyBook()
|
||||
data.updatedAt = now
|
||||
|
||||
db.run('INSERT INTO books (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
db.run('INSERT INTO books (id, name, data, created_at, updated_at, created_by) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
name,
|
||||
JSON.stringify(data),
|
||||
now,
|
||||
now,
|
||||
userId,
|
||||
])
|
||||
|
||||
return { id, name, updatedAt: now, data }
|
||||
@@ -124,7 +208,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 +219,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 }
|
||||
}
|
||||
|
||||
@@ -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 '../../shared/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 () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { Hono } from 'hono'
|
||||
import type { TeachingBook } from '../../src/domain/teachingDesign'
|
||||
import type { TeachingBook } from '../../shared/domain/teachingDesign'
|
||||
import { createBook, deleteBook, getBook, listBooks, renameBook, saveBookData } from '../db'
|
||||
import type { AuthVariables } from '../middleware/bearerAuth'
|
||||
|
||||
export function createBooksRouter(db: Database): Hono {
|
||||
const app = new Hono()
|
||||
export function createBooksRouter(db: Database): Hono<{ Variables: AuthVariables }> {
|
||||
const app = new Hono<{ Variables: AuthVariables }>()
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json(listBooks(db))
|
||||
@@ -18,7 +19,7 @@ export function createBooksRouter(db: Database): Hono {
|
||||
return c.json({ error: '请提供整本名称。' }, 400)
|
||||
}
|
||||
|
||||
return c.json(createBook(db, name.trim()))
|
||||
return c.json(createBook(db, name.trim(), c.get('userId')))
|
||||
})
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
|
||||
@@ -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
|
||||
>()
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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 { createEmptyBook } from '../shared/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('登录')
|
||||
})
|
||||
})
|
||||
|
||||
110
src/App.vue
110
src/App.vue
@@ -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" />
|
||||
|
||||
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 '../../shared/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">
|
||||
import type { BookCover, TeachingDesign } from '../domain/teachingDesign'
|
||||
import CoverPage from './CoverPage.vue'
|
||||
import type { TeachingDesign } from '../../shared/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)"
|
||||
|
||||
@@ -176,4 +176,8 @@ onMounted(loadUsers)
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-list table td .ui-button + .ui-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEmptyBook } from '../domain/teachingDesign'
|
||||
import { createEmptyBook } from '../../shared/domain/teachingDesign'
|
||||
import * as booksApi from '../services/booksApi'
|
||||
import BookListPage from './BookListPage.vue'
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ async function removeBook(book: BookSummary): Promise<void> {
|
||||
<template>
|
||||
<div class="book-list-page app-page">
|
||||
<div class="app-page-header">
|
||||
<h1>教学设计</h1>
|
||||
<h1>教学设计生成器<span class="page-subtitle">真亦假时假亦真</span></h1>
|
||||
<div class="app-page-actions">
|
||||
<button v-if="user?.role === 'admin'" class="ui-button" type="button" @click="emit('admin')">
|
||||
用户管理
|
||||
@@ -156,7 +156,7 @@ async function removeBook(book: BookSummary): Promise<void> {
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="book-list-name">{{ book.name }}</span>
|
||||
<span class="book-list-meta">更新于 {{ formatCstUpdatedAt(book.updatedAt) }} · {{ book.lessonCount }} 课</span>
|
||||
<span class="book-list-meta">更新于 {{ formatCstUpdatedAt(book.updatedAt) }} · {{ book.lessonCount }} 课<template v-if="book.createdBy"> · 创建者:{{ book.createdBy }}</template></span>
|
||||
<button class="ui-button" type="button" :data-testid="`open-${book.id}`" @click="emit('open', book.id)">
|
||||
打开
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import LessonSidebar from './LessonSidebar.vue'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { DesignId, TeachingDesign } from '../domain/teachingDesign'
|
||||
import type { DesignId, TeachingDesign } from '../../shared/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"
|
||||
|
||||
@@ -28,7 +28,7 @@ async function handleSubmit(): Promise<void> {
|
||||
<template>
|
||||
<div class="login-wrapper">
|
||||
<form class="login-form" @submit.prevent="handleSubmit">
|
||||
<h1>教学设计</h1>
|
||||
<h1>教学设计生成器</h1>
|
||||
<div class="field">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
@@ -80,7 +80,7 @@ async function handleSubmit(): Promise<void> {
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ async function handleSubmit(): Promise<void> {
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field label {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import PrintBook from './PrintBook.vue'
|
||||
|
||||
describe('PrintBook', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import TeachingDesignPage from './TeachingDesignPage.vue'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyTeachingDesign, type TeachingDesign } from '../domain/teachingDesign'
|
||||
import { createEmptyTeachingDesign, type TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
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, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw } from 'vue'
|
||||
import { createTeachingStep, type TeachingDesign, type TeachingStep } from '../domain/teachingDesign'
|
||||
import { createTeachingStep, type TeachingDesign, type TeachingStep } from '../../shared/domain/teachingDesign'
|
||||
import EditableMarkdown from './EditableMarkdown.vue'
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,7 +8,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
upload: []
|
||||
print: []
|
||||
export: []
|
||||
clear: []
|
||||
@@ -29,11 +28,10 @@ const saveStatusLabel: Record<SaveStatus, string> = {
|
||||
<template>
|
||||
<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>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||
import { createEmptyBook, createEmptyTeachingDesign } from '../../shared/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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { type DuplicateStrategy, useTeachingBook } from '../composables/useTeachingBook'
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
import { useTeachingBook } from '../composables/useTeachingBook'
|
||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
||||
import A4Workspace from './A4Workspace.vue'
|
||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||
import FixBrokenDialog from './FixBrokenDialog.vue'
|
||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||
import ImportConflictDialog from './ImportConflictDialog.vue'
|
||||
import LessonSidebar from './LessonSidebar.vue'
|
||||
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: [] }>()
|
||||
@@ -27,22 +28,17 @@ const {
|
||||
selectedDesign,
|
||||
hasDesigns,
|
||||
warningCount,
|
||||
importFiles,
|
||||
detectDuplicates,
|
||||
selectPage,
|
||||
moveDesign,
|
||||
removeDesign,
|
||||
updateCover,
|
||||
updateDesign,
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
regenerateLesson,
|
||||
} = useTeachingBook(props.bookId)
|
||||
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const duplicateNames = ref<string[]>([])
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const uploadRef = ref<InstanceType<typeof UploadDropzone> | null>(null)
|
||||
|
||||
const showGenerateDialog = ref(false)
|
||||
const generateLoading = ref(false)
|
||||
@@ -64,37 +60,6 @@ const fixCurrentTopic = ref('')
|
||||
const fixError = ref<string | null>(null)
|
||||
const fixCancelled = ref(false)
|
||||
|
||||
async function runImport(files: File[], strategy: DuplicateStrategy): Promise<void> {
|
||||
const result = await importFiles(files, strategy)
|
||||
if (result.failed.length > 0) {
|
||||
errorMessage.value = `${result.failed.length} 个文件导入失败:${result.failed
|
||||
.map((entry) => `${entry.filename}(${entry.message})`)
|
||||
.join('、')}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiles(files: File[]): Promise<void> {
|
||||
const duplicates = detectDuplicates(files)
|
||||
if (duplicates.length > 0) {
|
||||
pendingFiles.value = files
|
||||
duplicateNames.value = duplicates
|
||||
return
|
||||
}
|
||||
await runImport(files, 'keep')
|
||||
}
|
||||
|
||||
async function resolveConflict(strategy: DuplicateStrategy | 'cancel'): Promise<void> {
|
||||
const files = pendingFiles.value
|
||||
pendingFiles.value = []
|
||||
duplicateNames.value = []
|
||||
if (strategy === 'cancel') return
|
||||
await runImport(files, strategy)
|
||||
}
|
||||
|
||||
function triggerUpload(): void {
|
||||
uploadRef.value?.openPicker()
|
||||
}
|
||||
|
||||
function handlePrint(): void {
|
||||
const prev = document.title
|
||||
document.title = bookName.value || prev
|
||||
@@ -102,10 +67,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 +124,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
|
||||
@@ -227,13 +201,6 @@ function closeFixDialog(): void {
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ImportConflictDialog
|
||||
v-if="duplicateNames.length > 0"
|
||||
:duplicates="duplicateNames"
|
||||
@replace="resolveConflict('replace')"
|
||||
@keep="resolveConflict('keep')"
|
||||
@cancel="resolveConflict('cancel')"
|
||||
/>
|
||||
<GenerateLessonDialog
|
||||
v-if="showGenerateDialog"
|
||||
:loading="generateLoading"
|
||||
@@ -248,6 +215,7 @@ function closeFixDialog(): void {
|
||||
:total="batchTotal"
|
||||
:current-topic="batchCurrentTopic"
|
||||
:error="batchError"
|
||||
:default-theme="bookName"
|
||||
@start="handleBatchStart"
|
||||
@cancel="handleBatchCancel"
|
||||
@close="closeBatchDialog"
|
||||
@@ -277,7 +245,6 @@ function closeFixDialog(): void {
|
||||
:warning-count="warningCount"
|
||||
:save-status="saveStatus"
|
||||
@back="$emit('back')"
|
||||
@upload="triggerUpload"
|
||||
@generate="openGenerateDialog"
|
||||
@batch-generate="showBatchDialog = true"
|
||||
@fix-broken="openFixDialog"
|
||||
@@ -286,27 +253,19 @@ function closeFixDialog(): void {
|
||||
@clear="handleClear"
|
||||
/>
|
||||
|
||||
<UploadDropzone v-if="!hasDesigns" @files="handleFiles" />
|
||||
|
||||
<template v-else>
|
||||
<div class="workspace-layout">
|
||||
<LessonSidebar
|
||||
:designs="book.designs"
|
||||
:selected-id="book.selectedId"
|
||||
@select="selectPage"
|
||||
@remove="removeDesign"
|
||||
@move="moveDesign"
|
||||
/>
|
||||
<A4Workspace
|
||||
:cover="book.cover"
|
||||
:selected-id="book.selectedId"
|
||||
:selected-design="selectedDesign"
|
||||
@update:cover="updateCover"
|
||||
@update:design="handleDesignUpdate"
|
||||
/>
|
||||
</div>
|
||||
<UploadDropzone ref="uploadRef" compact class="visually-hidden" @files="handleFiles" />
|
||||
</template>
|
||||
<div v-if="hasDesigns" class="workspace-layout">
|
||||
<LessonSidebar
|
||||
:designs="book.designs"
|
||||
:selected-id="book.selectedId"
|
||||
@select="selectPage"
|
||||
@remove="removeDesign"
|
||||
@move="moveDesign"
|
||||
/>
|
||||
<A4Workspace
|
||||
:selected-design="selectedDesign"
|
||||
@update:design="handleDesignUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PrintBook :designs="book.designs" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../domain/teachingDesign'
|
||||
import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../../shared/domain/teachingDesign'
|
||||
import * as booksApi from '../services/booksApi'
|
||||
import { useTeachingBook } from './useTeachingBook'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { nextTick, ref, watch, type Ref } from 'vue'
|
||||
import {
|
||||
createEmptyBook,
|
||||
type BookCover,
|
||||
type DesignId,
|
||||
type TeachingBook,
|
||||
type TeachingDesign,
|
||||
} from '../domain/teachingDesign'
|
||||
} from '../../shared/domain/teachingDesign'
|
||||
import * as booksApi from '../services/booksApi'
|
||||
import { parseTeachingDesign } from '../services/markdownParser'
|
||||
import { sortFilesNaturally } from '../services/naturalSort'
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 300
|
||||
|
||||
export type DuplicateStrategy = 'replace' | 'keep'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
export type LoadStatus = 'loading' | 'loaded' | 'error'
|
||||
|
||||
export type GenerateLessonResult = { ok: true } | { ok: false; message: string }
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
failed: Array<{ filename: string; message: string }>
|
||||
duplicates: 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 TeachingBookStore {
|
||||
@@ -36,15 +37,16 @@ export interface TeachingBookStore {
|
||||
selectedDesign: Ref<TeachingDesign | null>
|
||||
hasDesigns: Ref<boolean>
|
||||
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 +69,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(
|
||||
@@ -129,65 +131,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
|
||||
void load()
|
||||
|
||||
function detectDuplicates(files: readonly File[]): string[] {
|
||||
const existingNames = new Set(book.value.designs.map((design) => design.originalFilename))
|
||||
return files.map((file) => file.name).filter((name) => existingNames.has(name))
|
||||
}
|
||||
|
||||
async function importFiles(
|
||||
files: readonly File[],
|
||||
strategy: DuplicateStrategy,
|
||||
): Promise<ImportResult> {
|
||||
const markdownFiles = files.filter((file) => /\.md$/i.test(file.name))
|
||||
const failed: ImportResult['failed'] = files
|
||||
.filter((file) => !/\.md$/i.test(file.name))
|
||||
.map((file) => ({ filename: file.name, message: '仅支持 .md 文件。' }))
|
||||
|
||||
const sortedFiles = sortFilesNaturally([...markdownFiles])
|
||||
const duplicates: string[] = []
|
||||
let imported = 0
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const design = parseTeachingDesign(file.name, text)
|
||||
|
||||
const existingIndex = book.value.designs.findIndex(
|
||||
(existing) => existing.originalFilename === file.name,
|
||||
)
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
duplicates.push(file.name)
|
||||
if (strategy === 'replace') {
|
||||
book.value.designs.splice(existingIndex, 1, design)
|
||||
} else {
|
||||
book.value.designs.push(design)
|
||||
}
|
||||
} else {
|
||||
book.value.designs.push(design)
|
||||
}
|
||||
|
||||
imported++
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
filename: file.name,
|
||||
message: error instanceof Error ? error.message : '解析失败。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0 && book.value.selectedId === 'cover' && book.value.designs.length > 0) {
|
||||
book.value.selectedId = book.value.designs[0]!.id
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
touch()
|
||||
}
|
||||
|
||||
return { imported, failed, duplicates }
|
||||
}
|
||||
|
||||
function selectPage(id: 'cover' | DesignId): void {
|
||||
function selectPage(id: DesignId): void {
|
||||
book.value.selectedId = id
|
||||
}
|
||||
|
||||
@@ -210,17 +154,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 +171,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 +196,74 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (!firstError) {
|
||||
if (options.isCancelled?.()) {
|
||||
abortController.abort()
|
||||
return
|
||||
}
|
||||
|
||||
const index = nextStartIndex
|
||||
if (index >= topics.length) return
|
||||
|
||||
nextStartIndex++
|
||||
const topic = topics[index]!
|
||||
options.onTopicStart?.(topic)
|
||||
|
||||
try {
|
||||
const result = await booksApi.generateLesson(topic, abortController.signal)
|
||||
results[index] = removeGeneratedAdditionalContent(
|
||||
parseTeachingDesign(result.filename, result.markdown),
|
||||
)
|
||||
appendReadyLessons()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
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 +271,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)
|
||||
@@ -281,15 +298,13 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
selectedDesign,
|
||||
hasDesigns,
|
||||
warningCount,
|
||||
importFiles,
|
||||
detectDuplicates,
|
||||
selectPage,
|
||||
moveDesign,
|
||||
removeDesign,
|
||||
updateCover,
|
||||
updateDesign,
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
regenerateLesson,
|
||||
}
|
||||
}
|
||||
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent
|
||||
export default component
|
||||
}
|
||||
@@ -40,6 +40,12 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.basic-info-table,
|
||||
.process-table,
|
||||
.reflection-table {
|
||||
width: calc(100% - 1px);
|
||||
}
|
||||
|
||||
.process-table {
|
||||
break-inside: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEmptyBook } from '../domain/teachingDesign'
|
||||
import { createEmptyBook } from '../../shared/domain/teachingDesign'
|
||||
import * as booksApi from './booksApi'
|
||||
|
||||
describe('booksApi', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeachingBook } from '../domain/teachingDesign'
|
||||
import type { TeachingBook } from '../../shared/domain/teachingDesign'
|
||||
import { authedFetch } from '../composables/useAuth'
|
||||
|
||||
export interface BookSummary {
|
||||
@@ -6,6 +6,7 @@ export interface BookSummary {
|
||||
name: string
|
||||
updatedAt: string
|
||||
lessonCount: number
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
export interface BookRecord {
|
||||
@@ -50,8 +51,8 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
|
||||
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function generateLesson(topic: string): Promise<GenerateResult> {
|
||||
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) })
|
||||
export function generateLesson(topic: string, signal?: AbortSignal): Promise<GenerateResult> {
|
||||
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }), signal })
|
||||
}
|
||||
|
||||
export function generateOutline(theme: string): Promise<{ titles: string[] }> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ParseWarning,
|
||||
type TeachingDesign,
|
||||
type TeachingStep,
|
||||
} from '../domain/teachingDesign'
|
||||
} from '../../shared/domain/teachingDesign'
|
||||
import { extractMarkdownTable } from './markdownTable'
|
||||
|
||||
const BR = /<br\s*\/?>/gi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
|
||||
function escapeCell(value: string): string {
|
||||
return value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import { createBookZip } from './zipExporter'
|
||||
|
||||
describe('createBookZip', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
||||
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||
import { writeTeachingDesignMarkdown } from './markdownWriter'
|
||||
|
||||
export async function createBookZip(designs: readonly TeachingDesign[]): Promise<Blob> {
|
||||
|
||||
256
src/style.css
256
src/style.css
@@ -11,6 +11,16 @@
|
||||
--muted: #68747f;
|
||||
--paper-width: 210mm;
|
||||
--paper-min-height: 297mm;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-pill: 999px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -69,11 +79,12 @@ input {
|
||||
.ui-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.ui-button:hover:not(:disabled) {
|
||||
@@ -82,10 +93,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 {
|
||||
@@ -108,10 +119,26 @@ input {
|
||||
border-color: #c0392b;
|
||||
}
|
||||
|
||||
.ui-button:active:not(:disabled),
|
||||
.workspace-toolbar button:active:not(:disabled),
|
||||
.dialog-actions button:active:not(:disabled),
|
||||
.process-step-actions button:active:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.ui-button:focus-visible,
|
||||
.workspace-toolbar button:focus-visible,
|
||||
.dialog-actions button:focus-visible,
|
||||
.process-step-actions button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--green-600);
|
||||
box-shadow: 0 0 0 2px rgba(45, 122, 88, 0.16);
|
||||
}
|
||||
|
||||
.ui-field,
|
||||
.ui-select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
color: #202a33;
|
||||
@@ -131,6 +158,19 @@ input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-error {
|
||||
color: #c0392b;
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.ui-success {
|
||||
color: var(--green-700);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.ui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -142,7 +182,7 @@ input {
|
||||
.ui-table th,
|
||||
.ui-table td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -156,16 +196,44 @@ input {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ui-error {
|
||||
color: #c0392b;
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0;
|
||||
.ui-table tbody tr:hover {
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.ui-success {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.basic-info-table th,
|
||||
.basic-info-table td,
|
||||
.process-table th,
|
||||
.process-table td,
|
||||
.reflection-table th,
|
||||
.reflection-table td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 8px;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.basic-info-table th,
|
||||
.process-table th,
|
||||
.reflection-table th {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0;
|
||||
font-weight: 600;
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
.process-table th {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.process-step-actions {
|
||||
width: 6em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
@@ -183,10 +251,11 @@ input {
|
||||
.workspace-toolbar button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
color: var(--green-700);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.workspace-toolbar button:hover:not(:disabled) {
|
||||
@@ -195,10 +264,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 +298,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 +307,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;
|
||||
@@ -277,6 +335,11 @@ input {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.lesson-sidebar-item:not(.lesson-sidebar-item--active) .lesson-sidebar-select:hover {
|
||||
background: #f4f6f7;
|
||||
}
|
||||
|
||||
.lesson-sidebar-number {
|
||||
@@ -296,7 +359,7 @@ input {
|
||||
flex: 0 0 auto;
|
||||
background: #e67e22;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
min-width: 1.6em;
|
||||
@@ -311,7 +374,7 @@ input {
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.lesson-sidebar-remove:hover {
|
||||
@@ -346,41 +409,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;
|
||||
@@ -395,6 +423,15 @@ input {
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--green-600);
|
||||
margin-left: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 10px;
|
||||
@@ -403,37 +440,6 @@ input {
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.basic-info-table th,
|
||||
.basic-info-table td,
|
||||
.process-table th,
|
||||
.process-table td,
|
||||
.reflection-table th,
|
||||
.reflection-table td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 8px;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.basic-info-table th,
|
||||
.process-table th,
|
||||
.reflection-table th {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
font-weight: 600;
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
.process-table th {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.objectives-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -443,7 +449,7 @@ table {
|
||||
.objective-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.objective-label {
|
||||
@@ -456,19 +462,15 @@ table {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.process-step-actions {
|
||||
width: 6em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.process-step-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #c0392b;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.process-step-actions button:disabled {
|
||||
@@ -480,7 +482,7 @@ table {
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
min-height: 6em;
|
||||
}
|
||||
@@ -490,7 +492,7 @@ table {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e6c98b;
|
||||
background: #fbf3e1;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #8a6116;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -506,11 +508,12 @@ table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.editable-text--multiline {
|
||||
@@ -541,9 +544,10 @@ table {
|
||||
.markdown-preview {
|
||||
min-height: 1.6em;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: text;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.markdown-preview--empty {
|
||||
@@ -566,7 +570,7 @@ table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid var(--green-600);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
background: #fff;
|
||||
resize: none;
|
||||
@@ -585,7 +589,7 @@ table {
|
||||
max-width: 480px;
|
||||
min-height: 200px;
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
@@ -603,7 +607,7 @@ table {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.upload-dropzone-input {
|
||||
@@ -637,7 +641,7 @@ table {
|
||||
|
||||
.dialog {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 12px 32px rgba(32, 42, 51, 0.25);
|
||||
@@ -659,9 +663,10 @@ table {
|
||||
.dialog-actions button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.app-notice {
|
||||
@@ -682,7 +687,7 @@ table {
|
||||
.app-notice button {
|
||||
border: 1px solid currentcolor;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
@@ -721,6 +726,24 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.workspace-toolbar {
|
||||
height: auto;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-toolbar button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-toolbar-count {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Book list */
|
||||
.book-list-create {
|
||||
display: flex;
|
||||
@@ -731,8 +754,9 @@ table {
|
||||
.dialog input {
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.book-list {
|
||||
@@ -751,7 +775,7 @@ table {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.book-list-name {
|
||||
@@ -774,7 +798,7 @@ table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
resize: vertical;
|
||||
margin-top: 8px;
|
||||
|
||||
31
src/style.test.ts
Normal file
31
src/style.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -10,5 +10,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user