Compare commits

...

21 Commits

Author SHA1 Message Date
3ae7e60d43 update 2026-06-16 22:54:38 -06:00
ceb04a1b83 fix: wrap workspace toolbar buttons on narrow mobile widths 2026-06-16 22:23:23 -06:00
2de18e40e2 feat: add sidebar/table hover tints and focus-visible ring for buttons 2026-06-16 22:20:06 -06:00
a0f26117a6 feat: add transitions and active-press feedback to buttons and editable fields 2026-06-16 22:18:01 -06:00
e3a21a46b5 feat: add radius/spacing design tokens and apply across styles 2026-06-16 22:15:09 -06:00
be8fe206bf docs: add UI detail polish implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:11:27 -06:00
1d123bfac3 fix: add spacing between reset-password and delete buttons in user management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:10:45 -06:00
59e8bfb2a9 docs: add UI detail polish design spec
Spec covering radius/spacing tokens, interaction feedback (hover/active/
focus-visible), and a mobile workspace-toolbar wrapping fix, based on
live screenshot review of the running app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:03:07 -06:00
58dfe3e455 update 2026-06-16 11:19:12 -06:00
973348115c update 2026-06-16 11:16:34 -06:00
028ba0f2f9 feat: add creator 2026-06-16 11:05:56 -06:00
33d5bfd8e9 fix 2026-06-16 10:05:23 -06:00
fb0b8d11c9 remove import teaching design feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 09:59:15 -06:00
6e2a17f63f update 2026-06-16 09:54:46 -06:00
b0e70d7b71 fix 2026-06-16 09:44:20 -06:00
c49221ac22 fix 2026-06-16 09:39:18 -06:00
55282963b5 fix 2026-06-16 09:37:50 -06:00
02ca889bc2 fix 2026-06-16 09:33:44 -06:00
10edc664e8 fix 2026-06-16 09:28:41 -06:00
667f8be21c fix 2026-06-16 09:23:00 -06:00
0dc1a35365 fix 2026-06-16 09:08:13 -06:00
42 changed files with 970 additions and 263 deletions

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ dist-ssr
# Backend data and secrets
data/teaching-books.db
.env
# Brainstorming visual companion
.superpowers/

View File

@@ -22,6 +22,7 @@ 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

View File

@@ -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=="],

View File

@@ -1,13 +1,14 @@
services:
app:
teaching-design:
build: .
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
@@ -15,6 +16,3 @@ services:
networks:
npm_proxy:
external: true
volumes:
db_data:

View 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 23. 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.

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ 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,

View File

@@ -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,7 @@ interface BookRow {
name: string
data: string
updated_at: string
created_by: string
}
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
@@ -67,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 (
@@ -124,6 +127,14 @@ 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()
@@ -139,13 +150,25 @@ 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) => ({
@@ -153,21 +176,23 @@ export function listBooks(db: Database): BookSummary[] {
name: row.name,
updatedAt: row.updated_at,
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 }

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import { createEmptyBook, createEmptyTeachingDesign } from '../../src/domain/teachingDesign'
import { createEmptyBook, createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
import { openDb } from '../db'
import { createBooksRouter } from './books'

View File

@@ -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) => {

View File

@@ -2,7 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
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')

View File

@@ -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 A4Workspace from './A4Workspace.vue'
describe('A4Workspace', () => {

View File

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

View File

@@ -176,4 +176,8 @@ onMounted(loadUsers)
flex-wrap: wrap;
align-items: center;
}
.user-list table td .ui-button + .ui-button {
margin-left: 8px;
}
</style>

View File

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

View File

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

View File

@@ -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 LessonSidebar from './LessonSidebar.vue'
describe('LessonSidebar', () => {

View File

@@ -1,6 +1,6 @@
<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[]

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -1,6 +1,6 @@
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', () => {

View File

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

View File

@@ -8,7 +8,6 @@ const props = defineProps<{
}>()
defineEmits<{
upload: []
print: []
export: []
clear: []
@@ -29,7 +28,6 @@ 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="batch-generate" @click="$emit('batchGenerate')">批量生成</button>
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>

View File

@@ -1,6 +1,6 @@
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'

View File

@@ -1,16 +1,14 @@
<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
@@ -30,8 +28,6 @@ const {
selectedDesign,
hasDesigns,
warningCount,
importFiles,
detectDuplicates,
selectPage,
moveDesign,
removeDesign,
@@ -42,10 +38,7 @@ const {
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)
@@ -67,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
@@ -239,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"
@@ -290,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"
@@ -299,24 +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
:selected-design="selectedDesign"
@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>

View File

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

View File

@@ -4,15 +4,12 @@ import {
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'
@@ -30,12 +27,6 @@ export interface BatchGenerateLessonOptions {
onLessonComplete?: (count: number) => void
}
export interface ImportResult {
imported: number
failed: Array<{ filename: string; message: string }>
duplicates: string[]
}
export interface TeachingBookStore {
book: Ref<TeachingBook>
bookName: Ref<string>
@@ -46,8 +37,6 @@ 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: DesignId) => void
moveDesign: (from: number, to: number) => void
removeDesign: (id: DesignId) => void
@@ -142,64 +131,6 @@ 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 === null && book.value.designs.length > 0) {
book.value.selectedId = book.value.designs[0]!.id
}
if (imported > 0) {
touch()
}
return { imported, failed, duplicates }
}
function selectPage(id: DesignId): void {
book.value.selectedId = id
}
@@ -296,8 +227,15 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
}
}
const abortController = new AbortController()
async function runWorker(): Promise<void> {
while (!firstError && !options.isCancelled?.()) {
while (!firstError) {
if (options.isCancelled?.()) {
abortController.abort()
return
}
const index = nextStartIndex
if (index >= topics.length) return
@@ -306,12 +244,13 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
options.onTopicStart?.(topic)
try {
const result = await booksApi.generateLesson(topic)
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 : '生成失败。'
}
}
@@ -359,8 +298,6 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
selectedDesign,
hasDesigns,
warningCount,
importFiles,
detectDuplicates,
selectPage,
moveDesign,
removeDesign,

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent
export default component
}

View File

@@ -40,6 +40,12 @@
box-shadow: none;
}
.basic-info-table,
.process-table,
.reflection-table {
width: calc(100% - 1px);
}
.process-table {
break-inside: auto;
}

View File

@@ -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', () => {

View File

@@ -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[] }> {

View File

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

View File

@@ -1,4 +1,4 @@
import type { TeachingDesign } from '../domain/teachingDesign'
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
function escapeCell(value: string): string {
return value

View File

@@ -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', () => {

View File

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

View File

@@ -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) {
@@ -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) {
@@ -266,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 {
@@ -285,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;
@@ -349,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;
@@ -357,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;
@@ -397,7 +449,7 @@ table {
.objective-row {
display: flex;
align-items: baseline;
gap: 6px;
gap: var(--space-2);
}
.objective-label {
@@ -410,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 {
@@ -434,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;
}
@@ -444,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;
}
@@ -460,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 {
@@ -495,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 {
@@ -520,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;
@@ -539,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;
@@ -557,7 +607,7 @@ table {
min-height: 0;
margin: 0;
padding: 6px 14px;
border-radius: 6px;
border-radius: var(--radius-md);
}
.upload-dropzone-input {
@@ -591,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);
@@ -613,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 {
@@ -636,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;
@@ -675,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;
@@ -685,7 +754,7 @@ 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%;
}
@@ -706,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 {
@@ -729,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;

View File

@@ -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"]
}