Compare commits
21 Commits
804224fc3c
...
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,3 +27,6 @@ dist-ssr
|
|||||||
# Backend data and secrets
|
# Backend data and secrets
|
||||||
data/teaching-books.db
|
data/teaching-books.db
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Brainstorming visual companion
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ COPY package.json bun.lock ./
|
|||||||
RUN bun install --frozen-lockfile --production
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
COPY shared/ ./shared/
|
||||||
COPY --from=builder /app/dist ./dist/
|
COPY --from=builder /app/dist ./dist/
|
||||||
|
|
||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
|
|||||||
28
bun.lock
28
bun.lock
@@ -8,22 +8,22 @@
|
|||||||
"hono": "^4.12.25",
|
"hono": "^4.12.25",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "^14.2.0",
|
"markdown-it": "^14.2.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.38",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^25.9.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^3.2.8",
|
"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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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,13 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
teaching-design:
|
||||||
build: .
|
build: .
|
||||||
expose:
|
expose:
|
||||||
- 3001
|
- 3001
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
|
|
||||||
- TEACHING_BOOKS_DB=/app/data/teaching-books.db
|
- TEACHING_BOOKS_DB=/app/data/teaching-books.db
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- npm_proxy
|
- npm_proxy
|
||||||
@@ -15,6 +16,3 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
npm_proxy:
|
npm_proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
|
|||||||
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.
|
||||||
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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>教学设计</title>
|
<title>教学设计生成器</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "bun run server:dev & vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
|
||||||
"server": "bun run server/index.ts",
|
"server": "bun run server/index.ts",
|
||||||
"server:dev": "bun --watch run server/index.ts",
|
"server:dev": "bun --watch run server/index.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync, rmSync } from 'node:fs'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../shared/domain/teachingDesign'
|
||||||
import {
|
import {
|
||||||
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
|
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
|
||||||
createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash,
|
createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash,
|
||||||
|
|||||||
35
server/db.ts
35
server/db.ts
@@ -1,11 +1,12 @@
|
|||||||
import { Database } from 'bun:sqlite'
|
import { Database } from 'bun:sqlite'
|
||||||
import { createEmptyBook, type TeachingBook } from '../src/domain/teachingDesign'
|
import { createEmptyBook, type TeachingBook } from '../shared/domain/teachingDesign'
|
||||||
|
|
||||||
export interface BookSummary {
|
export interface BookSummary {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
lessonCount: number
|
lessonCount: number
|
||||||
|
createdBy: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookRecord {
|
export interface BookRecord {
|
||||||
@@ -26,6 +27,7 @@ interface BookRow {
|
|||||||
name: string
|
name: string
|
||||||
data: string
|
data: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
created_by: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
|
type StoredTeachingBook = Omit<TeachingBook, 'selectedId'> & {
|
||||||
@@ -67,7 +69,8 @@ const SCHEMA = `
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
created_at 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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -124,6 +127,14 @@ function parseBookData(data: string): TeachingBook {
|
|||||||
return normalizeBookData(JSON.parse(data) as StoredTeachingBook).data
|
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 {
|
function migrateStoredBooks(db: Database): void {
|
||||||
const rows = db.query<{ id: string; data: string }, []>('SELECT id, data FROM books').all()
|
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)
|
const db = new Database(path)
|
||||||
db.run('PRAGMA foreign_keys = ON')
|
db.run('PRAGMA foreign_keys = ON')
|
||||||
db.run(SCHEMA)
|
db.run(SCHEMA)
|
||||||
|
try {
|
||||||
|
db.run("ALTER TABLE books ADD COLUMN created_by TEXT NOT NULL DEFAULT ''")
|
||||||
|
} catch {
|
||||||
|
// column already exists
|
||||||
|
}
|
||||||
migrateStoredBooks(db)
|
migrateStoredBooks(db)
|
||||||
|
migrateBookOwnership(db)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBooks(db: Database): BookSummary[] {
|
export function listBooks(db: Database): BookSummary[] {
|
||||||
const rows = db
|
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()
|
.all()
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -153,21 +176,23 @@ export function listBooks(db: Database): BookSummary[] {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
lessonCount: parseBookData(row.data).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 id = crypto.randomUUID()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const data = createEmptyBook()
|
const data = createEmptyBook()
|
||||||
data.updatedAt = now
|
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,
|
id,
|
||||||
name,
|
name,
|
||||||
JSON.stringify(data),
|
JSON.stringify(data),
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
userId,
|
||||||
])
|
])
|
||||||
|
|
||||||
return { id, name, updatedAt: now, data }
|
return { id, name, updatedAt: now, data }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'bun:test'
|
import { beforeEach, describe, expect, it } from 'bun:test'
|
||||||
import type { Database } from 'bun:sqlite'
|
import type { Database } from 'bun:sqlite'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../../src/domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import { openDb } from '../db'
|
import { openDb } from '../db'
|
||||||
import { createBooksRouter } from './books'
|
import { createBooksRouter } from './books'
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Database } from 'bun:sqlite'
|
import type { Database } from 'bun:sqlite'
|
||||||
import { Hono } from 'hono'
|
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 { createBook, deleteBook, getBook, listBooks, renameBook, saveBookData } from '../db'
|
||||||
|
import type { AuthVariables } from '../middleware/bearerAuth'
|
||||||
|
|
||||||
export function createBooksRouter(db: Database): Hono {
|
export function createBooksRouter(db: Database): Hono<{ Variables: AuthVariables }> {
|
||||||
const app = new Hono()
|
const app = new Hono<{ Variables: AuthVariables }>()
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get('/', (c) => {
|
||||||
return c.json(listBooks(db))
|
return c.json(listBooks(db))
|
||||||
@@ -18,7 +19,7 @@ export function createBooksRouter(db: Database): Hono {
|
|||||||
return c.json({ error: '请提供整本名称。' }, 400)
|
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) => {
|
app.get('/:id', (c) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createEmptyBook } from './domain/teachingDesign'
|
import { createEmptyBook } from '../shared/domain/teachingDesign'
|
||||||
import * as booksApi from './services/booksApi'
|
import * as booksApi from './services/booksApi'
|
||||||
|
|
||||||
vi.mock('./services/booksApi')
|
vi.mock('./services/booksApi')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import A4Workspace from './A4Workspace.vue'
|
import A4Workspace from './A4Workspace.vue'
|
||||||
|
|
||||||
describe('A4Workspace', () => {
|
describe('A4Workspace', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import TeachingDesignPage from './TeachingDesignPage.vue'
|
import TeachingDesignPage from './TeachingDesignPage.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -176,4 +176,8 @@ onMounted(loadUsers)
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-list table td .ui-button + .ui-button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 * as booksApi from '../services/booksApi'
|
||||||
import BookListPage from './BookListPage.vue'
|
import BookListPage from './BookListPage.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async function removeBook(book: BookSummary): Promise<void> {
|
|||||||
<template>
|
<template>
|
||||||
<div class="book-list-page app-page">
|
<div class="book-list-page app-page">
|
||||||
<div class="app-page-header">
|
<div class="app-page-header">
|
||||||
<h1>教学设计</h1>
|
<h1>教学设计生成器<span class="page-subtitle">真亦假时假亦真</span></h1>
|
||||||
<div class="app-page-actions">
|
<div class="app-page-actions">
|
||||||
<button v-if="user?.role === 'admin'" class="ui-button" type="button" @click="emit('admin')">
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="book-list-name">{{ book.name }}</span>
|
<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 class="ui-button" type="button" :data-testid="`open-${book.id}`" @click="emit('open', book.id)">
|
||||||
打开
|
打开
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import LessonSidebar from './LessonSidebar.vue'
|
import LessonSidebar from './LessonSidebar.vue'
|
||||||
|
|
||||||
describe('LessonSidebar', () => {
|
describe('LessonSidebar', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { DesignId, TeachingDesign } from '../domain/teachingDesign'
|
import type { DesignId, TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
designs: TeachingDesign[]
|
designs: TeachingDesign[]
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-wrapper">
|
<div class="login-wrapper">
|
||||||
<form class="login-form" @submit.prevent="handleSubmit">
|
<form class="login-form" @submit.prevent="handleSubmit">
|
||||||
<h1>教学设计</h1>
|
<h1>教学设计生成器</h1>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">用户名</label>
|
<label for="username">用户名</label>
|
||||||
<input
|
<input
|
||||||
@@ -80,7 +80,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
|
box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import PrintBook from './PrintBook.vue'
|
import PrintBook from './PrintBook.vue'
|
||||||
|
|
||||||
describe('PrintBook', () => {
|
describe('PrintBook', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import TeachingDesignPage from './TeachingDesignPage.vue'
|
import TeachingDesignPage from './TeachingDesignPage.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import TeachingDesignPage from './TeachingDesignPage.vue'
|
||||||
|
|
||||||
describe('TeachingDesignPage', () => {
|
describe('TeachingDesignPage', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, toRaw } from 'vue'
|
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 EditableMarkdown from './EditableMarkdown.vue'
|
||||||
import EditableText from './EditableText.vue'
|
import EditableText from './EditableText.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
upload: []
|
|
||||||
print: []
|
print: []
|
||||||
export: []
|
export: []
|
||||||
clear: []
|
clear: []
|
||||||
@@ -29,7 +28,6 @@ const saveStatusLabel: Record<SaveStatus, string> = {
|
|||||||
<template>
|
<template>
|
||||||
<header class="workspace-toolbar">
|
<header class="workspace-toolbar">
|
||||||
<button type="button" data-testid="back" @click="$emit('back')">返回列表</button>
|
<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="batch-generate" @click="$emit('batchGenerate')">批量生成</button>
|
||||||
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
<button type="button" data-testid="print" :disabled="lessonCount === 0" @click="$emit('print')">打印整册</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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 booksApi from '../services/booksApi'
|
||||||
import * as zipExporter from '../services/zipExporter'
|
import * as zipExporter from '../services/zipExporter'
|
||||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { type DuplicateStrategy, useTeachingBook } from '../composables/useTeachingBook'
|
import { useTeachingBook } from '../composables/useTeachingBook'
|
||||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
import { createBookZip, downloadBlob } from '../services/zipExporter'
|
||||||
import A4Workspace from './A4Workspace.vue'
|
import A4Workspace from './A4Workspace.vue'
|
||||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||||
import FixBrokenDialog from './FixBrokenDialog.vue'
|
import FixBrokenDialog from './FixBrokenDialog.vue'
|
||||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||||
import ImportConflictDialog from './ImportConflictDialog.vue'
|
|
||||||
import LessonSidebar from './LessonSidebar.vue'
|
import LessonSidebar from './LessonSidebar.vue'
|
||||||
import PrintBook from './PrintBook.vue'
|
import PrintBook from './PrintBook.vue'
|
||||||
import UploadDropzone from './UploadDropzone.vue'
|
|
||||||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||||||
|
|
||||||
const BATCH_GENERATE_CONCURRENCY = 3
|
const BATCH_GENERATE_CONCURRENCY = 3
|
||||||
@@ -30,8 +28,6 @@ const {
|
|||||||
selectedDesign,
|
selectedDesign,
|
||||||
hasDesigns,
|
hasDesigns,
|
||||||
warningCount,
|
warningCount,
|
||||||
importFiles,
|
|
||||||
detectDuplicates,
|
|
||||||
selectPage,
|
selectPage,
|
||||||
moveDesign,
|
moveDesign,
|
||||||
removeDesign,
|
removeDesign,
|
||||||
@@ -42,10 +38,7 @@ const {
|
|||||||
regenerateLesson,
|
regenerateLesson,
|
||||||
} = useTeachingBook(props.bookId)
|
} = useTeachingBook(props.bookId)
|
||||||
|
|
||||||
const pendingFiles = ref<File[]>([])
|
|
||||||
const duplicateNames = ref<string[]>([])
|
|
||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
const uploadRef = ref<InstanceType<typeof UploadDropzone> | null>(null)
|
|
||||||
|
|
||||||
const showGenerateDialog = ref(false)
|
const showGenerateDialog = ref(false)
|
||||||
const generateLoading = ref(false)
|
const generateLoading = ref(false)
|
||||||
@@ -67,37 +60,6 @@ const fixCurrentTopic = ref('')
|
|||||||
const fixError = ref<string | null>(null)
|
const fixError = ref<string | null>(null)
|
||||||
const fixCancelled = ref(false)
|
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 {
|
function handlePrint(): void {
|
||||||
const prev = document.title
|
const prev = document.title
|
||||||
document.title = bookName.value || prev
|
document.title = bookName.value || prev
|
||||||
@@ -239,13 +201,6 @@ function closeFixDialog(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ImportConflictDialog
|
|
||||||
v-if="duplicateNames.length > 0"
|
|
||||||
:duplicates="duplicateNames"
|
|
||||||
@replace="resolveConflict('replace')"
|
|
||||||
@keep="resolveConflict('keep')"
|
|
||||||
@cancel="resolveConflict('cancel')"
|
|
||||||
/>
|
|
||||||
<GenerateLessonDialog
|
<GenerateLessonDialog
|
||||||
v-if="showGenerateDialog"
|
v-if="showGenerateDialog"
|
||||||
:loading="generateLoading"
|
:loading="generateLoading"
|
||||||
@@ -290,7 +245,6 @@ function closeFixDialog(): void {
|
|||||||
:warning-count="warningCount"
|
:warning-count="warningCount"
|
||||||
:save-status="saveStatus"
|
:save-status="saveStatus"
|
||||||
@back="$emit('back')"
|
@back="$emit('back')"
|
||||||
@upload="triggerUpload"
|
|
||||||
@generate="openGenerateDialog"
|
@generate="openGenerateDialog"
|
||||||
@batch-generate="showBatchDialog = true"
|
@batch-generate="showBatchDialog = true"
|
||||||
@fix-broken="openFixDialog"
|
@fix-broken="openFixDialog"
|
||||||
@@ -299,10 +253,7 @@ function closeFixDialog(): void {
|
|||||||
@clear="handleClear"
|
@clear="handleClear"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadDropzone v-if="!hasDesigns" @files="handleFiles" />
|
<div v-if="hasDesigns" class="workspace-layout">
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="workspace-layout">
|
|
||||||
<LessonSidebar
|
<LessonSidebar
|
||||||
:designs="book.designs"
|
:designs="book.designs"
|
||||||
:selected-id="book.selectedId"
|
:selected-id="book.selectedId"
|
||||||
@@ -315,8 +266,6 @@ function closeFixDialog(): void {
|
|||||||
@update:design="handleDesignUpdate"
|
@update:design="handleDesignUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<UploadDropzone ref="uploadRef" compact class="visually-hidden" @files="handleFiles" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<PrintBook :designs="book.designs" />
|
<PrintBook :designs="book.designs" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { flushPromises } from '@vue/test-utils'
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign, type TeachingBook } from '../../shared/domain/teachingDesign'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
import { useTeachingBook } from './useTeachingBook'
|
import { useTeachingBook } from './useTeachingBook'
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import {
|
|||||||
type DesignId,
|
type DesignId,
|
||||||
type TeachingBook,
|
type TeachingBook,
|
||||||
type TeachingDesign,
|
type TeachingDesign,
|
||||||
} from '../domain/teachingDesign'
|
} from '../../shared/domain/teachingDesign'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
import { parseTeachingDesign } from '../services/markdownParser'
|
import { parseTeachingDesign } from '../services/markdownParser'
|
||||||
import { sortFilesNaturally } from '../services/naturalSort'
|
|
||||||
|
|
||||||
const AUTOSAVE_DELAY_MS = 300
|
const AUTOSAVE_DELAY_MS = 300
|
||||||
|
|
||||||
export type DuplicateStrategy = 'replace' | 'keep'
|
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
|
||||||
export type LoadStatus = 'loading' | 'loaded' | 'error'
|
export type LoadStatus = 'loading' | 'loaded' | 'error'
|
||||||
@@ -30,12 +27,6 @@ export interface BatchGenerateLessonOptions {
|
|||||||
onLessonComplete?: (count: number) => void
|
onLessonComplete?: (count: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
imported: number
|
|
||||||
failed: Array<{ filename: string; message: string }>
|
|
||||||
duplicates: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeachingBookStore {
|
export interface TeachingBookStore {
|
||||||
book: Ref<TeachingBook>
|
book: Ref<TeachingBook>
|
||||||
bookName: Ref<string>
|
bookName: Ref<string>
|
||||||
@@ -46,8 +37,6 @@ export interface TeachingBookStore {
|
|||||||
selectedDesign: Ref<TeachingDesign | null>
|
selectedDesign: Ref<TeachingDesign | null>
|
||||||
hasDesigns: Ref<boolean>
|
hasDesigns: Ref<boolean>
|
||||||
warningCount: Ref<number>
|
warningCount: Ref<number>
|
||||||
importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise<ImportResult>
|
|
||||||
detectDuplicates: (files: readonly File[]) => string[]
|
|
||||||
selectPage: (id: DesignId) => void
|
selectPage: (id: DesignId) => void
|
||||||
moveDesign: (from: number, to: number) => void
|
moveDesign: (from: number, to: number) => void
|
||||||
removeDesign: (id: DesignId) => void
|
removeDesign: (id: DesignId) => void
|
||||||
@@ -142,64 +131,6 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
|
|
||||||
void load()
|
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 {
|
function selectPage(id: DesignId): void {
|
||||||
book.value.selectedId = id
|
book.value.selectedId = id
|
||||||
}
|
}
|
||||||
@@ -296,8 +227,15 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
async function runWorker(): Promise<void> {
|
async function runWorker(): Promise<void> {
|
||||||
while (!firstError && !options.isCancelled?.()) {
|
while (!firstError) {
|
||||||
|
if (options.isCancelled?.()) {
|
||||||
|
abortController.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const index = nextStartIndex
|
const index = nextStartIndex
|
||||||
if (index >= topics.length) return
|
if (index >= topics.length) return
|
||||||
|
|
||||||
@@ -306,12 +244,13 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
options.onTopicStart?.(topic)
|
options.onTopicStart?.(topic)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await booksApi.generateLesson(topic)
|
const result = await booksApi.generateLesson(topic, abortController.signal)
|
||||||
results[index] = removeGeneratedAdditionalContent(
|
results[index] = removeGeneratedAdditionalContent(
|
||||||
parseTeachingDesign(result.filename, result.markdown),
|
parseTeachingDesign(result.filename, result.markdown),
|
||||||
)
|
)
|
||||||
appendReadyLessons()
|
appendReadyLessons()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') return
|
||||||
firstError = error instanceof Error ? error.message : '生成失败。'
|
firstError = error instanceof Error ? error.message : '生成失败。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,8 +298,6 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
|||||||
selectedDesign,
|
selectedDesign,
|
||||||
hasDesigns,
|
hasDesigns,
|
||||||
warningCount,
|
warningCount,
|
||||||
importFiles,
|
|
||||||
detectDuplicates,
|
|
||||||
selectPage,
|
selectPage,
|
||||||
moveDesign,
|
moveDesign,
|
||||||
removeDesign,
|
removeDesign,
|
||||||
|
|||||||
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;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.basic-info-table,
|
||||||
|
.process-table,
|
||||||
|
.reflection-table {
|
||||||
|
width: calc(100% - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
.process-table {
|
.process-table {
|
||||||
break-inside: auto;
|
break-inside: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
import * as booksApi from './booksApi'
|
||||||
|
|
||||||
describe('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'
|
import { authedFetch } from '../composables/useAuth'
|
||||||
|
|
||||||
export interface BookSummary {
|
export interface BookSummary {
|
||||||
@@ -6,6 +6,7 @@ export interface BookSummary {
|
|||||||
name: string
|
name: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
lessonCount: number
|
lessonCount: number
|
||||||
|
createdBy: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookRecord {
|
export interface BookRecord {
|
||||||
@@ -50,8 +51,8 @@ export function deleteBook(id: string): Promise<{ ok: true }> {
|
|||||||
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
return authedFetch(`/api/books/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateLesson(topic: string): Promise<GenerateResult> {
|
export function generateLesson(topic: string, signal?: AbortSignal): Promise<GenerateResult> {
|
||||||
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) })
|
return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }), signal })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateOutline(theme: string): Promise<{ titles: string[] }> {
|
export function generateOutline(theme: string): Promise<{ titles: string[] }> {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ParseWarning,
|
type ParseWarning,
|
||||||
type TeachingDesign,
|
type TeachingDesign,
|
||||||
type TeachingStep,
|
type TeachingStep,
|
||||||
} from '../domain/teachingDesign'
|
} from '../../shared/domain/teachingDesign'
|
||||||
import { extractMarkdownTable } from './markdownTable'
|
import { extractMarkdownTable } from './markdownTable'
|
||||||
|
|
||||||
const BR = /<br\s*\/?>/gi
|
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 {
|
function escapeCell(value: string): string {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createEmptyTeachingDesign } from '../domain/teachingDesign'
|
import { createEmptyTeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import { createBookZip } from './zipExporter'
|
import { createBookZip } from './zipExporter'
|
||||||
|
|
||||||
describe('createBookZip', () => {
|
describe('createBookZip', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import type { TeachingDesign } from '../domain/teachingDesign'
|
import type { TeachingDesign } from '../../shared/domain/teachingDesign'
|
||||||
import { writeTeachingDesignMarkdown } from './markdownWriter'
|
import { writeTeachingDesignMarkdown } from './markdownWriter'
|
||||||
|
|
||||||
export async function createBookZip(designs: readonly TeachingDesign[]): Promise<Blob> {
|
export async function createBookZip(designs: readonly TeachingDesign[]): Promise<Blob> {
|
||||||
|
|||||||
195
src/style.css
195
src/style.css
@@ -11,6 +11,16 @@
|
|||||||
--muted: #68747f;
|
--muted: #68747f;
|
||||||
--paper-width: 210mm;
|
--paper-width: 210mm;
|
||||||
--paper-min-height: 297mm;
|
--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 {
|
.ui-button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-button:hover:not(:disabled) {
|
.ui-button:hover:not(:disabled) {
|
||||||
@@ -108,10 +119,26 @@ input {
|
|||||||
border-color: #c0392b;
|
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-field,
|
||||||
.ui-select {
|
.ui-select {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #202a33;
|
color: #202a33;
|
||||||
@@ -131,6 +158,19 @@ input {
|
|||||||
cursor: not-allowed;
|
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 {
|
.ui-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -142,7 +182,7 @@ input {
|
|||||||
.ui-table th,
|
.ui-table th,
|
||||||
.ui-table td {
|
.ui-table td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: var(--space-2) var(--space-2);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +196,44 @@ input {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-error {
|
.ui-table tbody tr:hover {
|
||||||
color: #c0392b;
|
background: #f8faf9;
|
||||||
font-size: 14px;
|
|
||||||
margin: 8px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
color: var(--green-700);
|
||||||
font-size: 14px;
|
font-weight: 600;
|
||||||
margin: 8px 0 0;
|
width: 8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-table th {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-step-actions {
|
||||||
|
width: 6em;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
@@ -183,10 +251,11 @@ input {
|
|||||||
.workspace-toolbar button {
|
.workspace-toolbar button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-toolbar button:hover:not(:disabled) {
|
.workspace-toolbar button:hover:not(:disabled) {
|
||||||
@@ -266,6 +335,11 @@ input {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 0;
|
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 {
|
.lesson-sidebar-number {
|
||||||
@@ -285,7 +359,7 @@ input {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: #e67e22;
|
background: #e67e22;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
min-width: 1.6em;
|
min-width: 1.6em;
|
||||||
@@ -349,6 +423,15 @@ input {
|
|||||||
color: var(--green-700);
|
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 {
|
.section-heading {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@@ -357,37 +440,6 @@ input {
|
|||||||
color: var(--green-700);
|
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 {
|
.objectives-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -397,7 +449,7 @@ table {
|
|||||||
.objective-row {
|
.objective-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-label {
|
.objective-label {
|
||||||
@@ -410,19 +462,15 @@ table {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-step-actions {
|
|
||||||
width: 6em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-step-actions button {
|
.process-step-actions button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-step-actions button:disabled {
|
.process-step-actions button:disabled {
|
||||||
@@ -434,7 +482,7 @@ table {
|
|||||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
min-height: 6em;
|
min-height: 6em;
|
||||||
}
|
}
|
||||||
@@ -444,7 +492,7 @@ table {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid #e6c98b;
|
border: 1px solid #e6c98b;
|
||||||
background: #fbf3e1;
|
background: #fbf3e1;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: #8a6116;
|
color: #8a6116;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -460,11 +508,12 @@ table {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable-text--multiline {
|
.editable-text--multiline {
|
||||||
@@ -495,9 +544,10 @@ table {
|
|||||||
.markdown-preview {
|
.markdown-preview {
|
||||||
min-height: 1.6em;
|
min-height: 1.6em;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-preview--empty {
|
.markdown-preview--empty {
|
||||||
@@ -520,7 +570,7 @@ table {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--green-600);
|
border: 1px solid var(--green-600);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -539,7 +589,7 @@ table {
|
|||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
border: 2px dashed var(--line);
|
border: 2px dashed var(--line);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-xl);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -557,7 +607,7 @@ table {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-dropzone-input {
|
.upload-dropzone-input {
|
||||||
@@ -591,7 +641,7 @@ table {
|
|||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: 0 12px 32px rgba(32, 42, 51, 0.25);
|
box-shadow: 0 12px 32px rgba(32, 42, 51, 0.25);
|
||||||
@@ -613,9 +663,10 @@ table {
|
|||||||
.dialog-actions button {
|
.dialog-actions button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-notice {
|
.app-notice {
|
||||||
@@ -636,7 +687,7 @@ table {
|
|||||||
.app-notice button {
|
.app-notice button {
|
||||||
border: 1px solid currentcolor;
|
border: 1px solid currentcolor;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: inherit;
|
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 */
|
||||||
.book-list-create {
|
.book-list-create {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -685,7 +754,7 @@ table {
|
|||||||
.dialog input {
|
.dialog input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -706,7 +775,7 @@ table {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-list-name {
|
.book-list-name {
|
||||||
@@ -729,7 +798,7 @@ table {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": 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