- 所有页面共用顶栏(教程 / 挑战 / 自由创作 / 作品展,右边“教学”和用户菜单); 没登录时顶栏有“登录”按钮,关掉登录框不再白屏;/submission/:id 全屏页不带顶栏 - 后台改成“教学”侧栏,分出题 / 班级 / 管理三组;“全部提交”留在侧栏里打开; admin 也能进作品展管理(后端本来就允许) - 路由按角色守卫:meta.role 为 staff / super,学生和 admin 进不了无权页面 - 配色只在 theme.ts 定义,同时生成 --wp-* CSS 变量和 Naive UI 主题,暗色值先定好 - 图标改用打包进来的 lucide(断网也能显示),去掉 @iconify/vue 和 PUBLIC_ICONIFY_URL; 代码字体换成打包的 JetBrains Mono,编辑器默认字号 24 → 16 - 教程面板去掉和顶栏重复的切换和图标按钮,图标按钮都补上文字或提示 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e47fac1732
commit
ff1eb2982e
43 files changed
+813
-405
No files matched your search
@@ -26,7 +26,10 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
|
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
|
||||||
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
||||||
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
||||||
- `src/router.ts` — Vue Router with auth guards
|
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
||||||
|
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors. No `color-mix()` (Chrome 105)
|
||||||
|
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
||||||
|
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||||
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
||||||
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"marked": "^18.1.1",
|
"marked": "^18.1.1",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-code-preview": "^1.3.7",
|
"marked-code-preview": "^1.3.7",
|
||||||
@@ -30,7 +32,6 @@
|
|||||||
"vue-router": "^5.4.0",
|
"vue-router": "^5.4.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.4",
|
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
@@ -302,9 +303,7 @@
|
|||||||
|
|
||||||
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||||
|
|
||||||
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
"@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "https://registry.npmjs.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="],
|
||||||
|
|
||||||
"@iconify/vue": ["@iconify/vue@5.0.4", "https://registry.npmjs.com/@iconify/vue/-/vue-5.0.4.tgz", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-9yP5PjOv5G9vStHfyPtQ69Ay1CSNAC3AZTRDJ4LX3UMX7nEH0GO8OKhc3qhcrXp1KS2GnlLufGbFCwD+U4Fi5A=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
@@ -460,11 +459,11 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.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 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.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 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||||
|
|
||||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmjs.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||||
|
|
||||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmjs.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||||
|
|
||||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||||
|
|
||||||
"@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
"@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
||||||
|
|
||||||
@@ -482,7 +481,7 @@
|
|||||||
|
|
||||||
"@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
|
"@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
|
||||||
|
|
||||||
"@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
"@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||||
|
|
||||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||||
|
|
||||||
@@ -506,7 +505,7 @@
|
|||||||
|
|
||||||
"agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
"agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
|
|
||||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmjs.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
@@ -684,6 +683,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-vue-next": ["lucide-vue-next@1.0.0", "https://registry.npmjs.com/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="],
|
"magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="],
|
||||||
|
|
||||||
"magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
"magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
||||||
@@ -736,7 +737,7 @@
|
|||||||
|
|
||||||
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmjs.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
@@ -802,7 +803,7 @@
|
|||||||
|
|
||||||
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "https://registry.npmjs.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
"uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||||
|
|
||||||
@@ -832,7 +833,7 @@
|
|||||||
|
|
||||||
"vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
"vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
||||||
|
|
||||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
"vscode-uri": ["vscode-uri@3.2.0", "https://registry.npmjs.com/vscode-uri/-/vscode-uri-3.2.0.tgz", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||||
|
|
||||||
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||||
|
|
||||||
@@ -840,7 +841,7 @@
|
|||||||
|
|
||||||
"vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
|
"vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
|
||||||
|
|
||||||
"vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
"vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||||
|
|
||||||
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
PUBLIC_WEB_URL=https://web.xuyue.cc
|
PUBLIC_WEB_URL=https://web.xuyue.cc
|
||||||
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
||||||
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
PUBLIC_WEB_URL=http://10.13.114.114:91
|
PUBLIC_WEB_URL=http://10.13.114.114:91
|
||||||
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
<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>
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
<link rel="stylesheet" href="/style.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+2
-1
@@ -15,12 +15,14 @@
|
|||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"marked": "^18.1.1",
|
"marked": "^18.1.1",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-code-preview": "^1.3.7",
|
"marked-code-preview": "^1.3.7",
|
||||||
@@ -32,7 +34,6 @@
|
|||||||
"vue-router": "^5.4.0"
|
"vue-router": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.4",
|
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: "Monaco";
|
|
||||||
src: url(/Monaco.ttf);
|
|
||||||
}
|
|
||||||
+10
-23
@@ -1,32 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dateZhCN, zhCN } from "naive-ui"
|
import { dateZhCN, zhCN } from "naive-ui"
|
||||||
import type { GlobalThemeOverrides } from "naive-ui"
|
|
||||||
import Login from "./components/Login.vue"
|
import Login from "./components/Login.vue"
|
||||||
|
import AppShell from "./components/layout/AppShell.vue"
|
||||||
import { onMounted } from "vue"
|
import { onMounted } from "vue"
|
||||||
import { Account } from "./api"
|
import { useRoute } from "vue-router"
|
||||||
import { user } from "./store/user"
|
import { loadUser } from "./store/session"
|
||||||
|
import { light, naiveOverrides } from "./theme"
|
||||||
import hljs from "highlight.js/lib/core"
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const route = useRoute()
|
||||||
common: {
|
const themeOverrides = naiveOverrides(light)
|
||||||
borderRadius: "6px",
|
|
||||||
borderRadiusSmall: "4px",
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
borderRadius: "8px",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(loadUser)
|
||||||
try {
|
|
||||||
const data = await Account.getMyProfile()
|
|
||||||
user.loaded = true
|
|
||||||
user.username = data.username
|
|
||||||
user.role = data.role
|
|
||||||
} catch {
|
|
||||||
user.loaded = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,7 +25,9 @@ onMounted(async () => {
|
|||||||
<n-modal-provider>
|
<n-modal-provider>
|
||||||
<n-message-provider :max="1">
|
<n-message-provider :max="1">
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<router-view></router-view>
|
<!-- 全屏预览这类页面不要顶栏 -->
|
||||||
|
<router-view v-if="route.meta.bare" />
|
||||||
|
<AppShell v-else />
|
||||||
<Login />
|
<Login />
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue"
|
import { ref, computed, watch, nextTick } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
@@ -168,7 +168,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-role {
|
.guidance-msg.assistant .msg-role {
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-content {
|
.guidance-msg.assistant .msg-content {
|
||||||
@@ -194,7 +194,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue"
|
import { onMounted, ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import { Prompt } from "../../api"
|
import { Prompt } from "../../api"
|
||||||
@@ -307,7 +307,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.history-card.is-selected {
|
.history-card.is-selected {
|
||||||
--n-color: #f7fffa;
|
--n-color: #f7fffa;
|
||||||
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card.is-selected .history-main {
|
.history-card.is-selected .history-main {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
import { ref, watch, nextTick, computed } from "vue"
|
import { ref, watch, nextTick, computed } from "vue"
|
||||||
import { useStorage } from "@vueuse/core"
|
import { useStorage } from "@vueuse/core"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import GuidancePanel from "./GuidancePanel.vue"
|
import GuidancePanel from "./GuidancePanel.vue"
|
||||||
import { openGuidance } from "../../store/guidance"
|
import { openGuidance } from "../../store/guidance"
|
||||||
import {
|
import {
|
||||||
@@ -289,7 +289,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.assistant .message-role {
|
.message.assistant .message-role {
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
@@ -329,7 +329,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #18a058;
|
background: var(--wp-brand);
|
||||||
animation: bounce 1.4s ease-in-out infinite;
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ import { computed } from "vue"
|
|||||||
|
|
||||||
const styleTheme = EditorView.baseTheme({
|
const styleTheme = EditorView.baseTheme({
|
||||||
"& .cm-scroller": {
|
"& .cm-scroller": {
|
||||||
"font-family": "Monaco",
|
"font-family": "var(--wp-font-mono)",
|
||||||
},
|
},
|
||||||
"&.cm-editor.cm-focused": {
|
"&.cm-editor.cm-focused": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||||
"font-family": "Monaco",
|
"font-family": "var(--wp-font-mono)",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const bgColors: Record<string, string> = {
|
const bgColors: Record<string, string> = {
|
||||||
html: "#fff6f3",
|
html: "var(--wp-html-soft)",
|
||||||
css: "#f3f6ff",
|
css: "var(--wp-css-soft)",
|
||||||
js: "#fffdf0",
|
js: "var(--wp-js-soft)",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,7 +31,7 @@ interface Props {
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
language: "html",
|
language: "html",
|
||||||
fontSize: 20,
|
fontSize: 16,
|
||||||
})
|
})
|
||||||
|
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
@@ -43,7 +43,7 @@ const lang = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bgTheme = computed(() => {
|
const bgTheme = computed(() => {
|
||||||
const bg = bgColors[props.language] ?? "#ffffff"
|
const bg = bgColors[props.language] ?? "var(--wp-surface)"
|
||||||
return EditorView.theme({
|
return EditorView.theme({
|
||||||
"&": { backgroundColor: bg },
|
"&": { backgroundColor: bg },
|
||||||
".cm-gutters": { backgroundColor: bg },
|
".cm-gutters": { backgroundColor: bg },
|
||||||
|
|||||||
@@ -10,37 +10,25 @@
|
|||||||
>
|
>
|
||||||
<n-tab-pane name="html" tab="HTML">
|
<n-tab-pane name="html" tab="HTML">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-html">HTML</span>
|
||||||
<Icon :width="20" icon="skill-icons:html"></Icon>
|
|
||||||
<span>HTML</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="html" :font-size="size" language="html" />
|
<Editor v-model:value="html" :font-size="size" language="html" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="css" tab="CSS">
|
<n-tab-pane name="css" tab="CSS">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-css">CSS</span>
|
||||||
<Icon :width="20" icon="skill-icons:css"></Icon>
|
|
||||||
<span>CSS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="css" :font-size="size" language="css" />
|
<Editor v-model:value="css" :font-size="size" language="css" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="js" tab="JS">
|
<n-tab-pane name="js" tab="JS">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-js">JS</span>
|
||||||
<Icon :width="20" icon="skill-icons:javascript"></Icon>
|
|
||||||
<span>JS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="js" :font-size="size" language="js" />
|
<Editor v-model:value="js" :font-size="size" language="js" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="actions" tab="选项">
|
<n-tab-pane name="actions" tab="选项">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab">选项</span>
|
||||||
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
|
|
||||||
<span>选项</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<n-flex class="wrapper" vertical>
|
<n-flex class="wrapper" vertical>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
@@ -53,7 +41,7 @@
|
|||||||
<span class="label">字号</span>
|
<span class="label">字号</span>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
||||||
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
|
<n-button :disabled="size <= 12" @click="changeSize(size - 2)">
|
||||||
调小
|
调小
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
||||||
@@ -78,7 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import Editor from "./Editor.vue"
|
import Editor from "./Editor.vue"
|
||||||
import Toolbar from "./Toolbar.vue"
|
import Toolbar from "./Toolbar.vue"
|
||||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||||
@@ -193,14 +180,50 @@ async function formatAndSubmit() {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
|
||||||
|
.lang-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lang-tab.lang-html::before,
|
||||||
|
.lang-tab.lang-css::before,
|
||||||
|
.lang-tab.lang-js::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.lang-html {
|
||||||
|
color: var(--wp-html);
|
||||||
|
}
|
||||||
|
.lang-html::before {
|
||||||
|
background: var(--wp-html);
|
||||||
|
}
|
||||||
|
.lang-css {
|
||||||
|
color: var(--wp-css);
|
||||||
|
}
|
||||||
|
.lang-css::before {
|
||||||
|
background: var(--wp-css);
|
||||||
|
}
|
||||||
|
.lang-js {
|
||||||
|
color: var(--wp-js);
|
||||||
|
}
|
||||||
|
.lang-js::before {
|
||||||
|
background: var(--wp-js);
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||||
background-color: #fff6f3 !important;
|
background-color: var(--wp-html-soft) !important;
|
||||||
}
|
}
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
||||||
background-color: #f3f6ff !important;
|
background-color: var(--wp-css-soft) !important;
|
||||||
}
|
}
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
||||||
background-color: #fffdf0 !important;
|
background-color: var(--wp-js-soft) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editors-root {
|
.editors-root {
|
||||||
@@ -208,12 +231,12 @@ async function formatAndSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.tab-active-html .n-tab-pane) {
|
:deep(.tab-active-html .n-tab-pane) {
|
||||||
background-color: #fff6f3;
|
background-color: var(--wp-html-soft);
|
||||||
}
|
}
|
||||||
:deep(.tab-active-css .n-tab-pane) {
|
:deep(.tab-active-css .n-tab-pane) {
|
||||||
background-color: #f3f6ff;
|
background-color: var(--wp-css-soft);
|
||||||
}
|
}
|
||||||
:deep(.tab-active-js .n-tab-pane) {
|
:deep(.tab-active-js .n-tab-pane) {
|
||||||
background-color: #fffdf0;
|
background-color: var(--wp-js-soft);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -57,7 +57,7 @@ import { useRouter } from "vue-router"
|
|||||||
import { Submission } from "../../api"
|
import { Submission } from "../../api"
|
||||||
import { submission } from "../../store/submission"
|
import { submission } from "../../store/submission"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import copy from "copy-text-to-clipboard"
|
import copy from "copy-text-to-clipboard"
|
||||||
import {
|
import {
|
||||||
buildPreviewDocument,
|
buildPreviewDocument,
|
||||||
|
|||||||
@@ -1,43 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex align="center" class="corner">
|
<n-flex align="center" class="corner">
|
||||||
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
|
<n-button quaternary v-if="!show" @click="showTutorial">显示教程</n-button>
|
||||||
<template v-if="user.loaded && authed">
|
<template v-if="user.loaded && authed">
|
||||||
<n-button quaternary @click="emit('format')">整理</n-button>
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary @click="emit('format')">整理代码</n-button>
|
||||||
|
</template>
|
||||||
|
自动整理缩进和换行
|
||||||
|
</n-tooltip>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
type="primary"
|
||||||
secondary
|
|
||||||
:disabled="submitDisabled"
|
:disabled="submitDisabled"
|
||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
@click="emit('submit')"
|
@click="emit('submit')"
|
||||||
>
|
>
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-dropdown :options="menu" @select="clickMenu">
|
|
||||||
<n-button>{{ user.username }}</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
</template>
|
</template>
|
||||||
<n-button
|
|
||||||
v-if="user.loaded && !authed"
|
|
||||||
@click="handleLogin"
|
|
||||||
secondary
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, h } from "vue"
|
import { computed } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { authed, user } from "../../store/user"
|
||||||
import { authed, roleAdmin, roleNormal, user } from "../../store/user"
|
|
||||||
import { loginModal } from "../../store/modal"
|
|
||||||
import { show, panelSize } from "../../store/panel"
|
import { show, panelSize } from "../../store/panel"
|
||||||
import { taskId } from "../../store/task"
|
import { taskId } from "../../store/task"
|
||||||
import { Account } from "../../api"
|
|
||||||
import { Role } from "../../utils/type"
|
|
||||||
import { router } from "../../router"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
submitLoading: boolean
|
submitLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(["format", "submit"])
|
const emit = defineEmits(["format", "submit"])
|
||||||
@@ -46,78 +34,13 @@ const submitDisabled = computed(() => {
|
|||||||
return taskId.value === 0
|
return taskId.value === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const menu = computed(() => [
|
|
||||||
{
|
|
||||||
label: "后台管理",
|
|
||||||
key: "dashboard",
|
|
||||||
show: !roleNormal.value,
|
|
||||||
icon: () =>
|
|
||||||
h(Icon, {
|
|
||||||
icon: "streamline-emojis:robot-face-1",
|
|
||||||
width: 20,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "我的提交",
|
|
||||||
key: "submissions",
|
|
||||||
icon: () =>
|
|
||||||
h(Icon, {
|
|
||||||
icon: "streamline-emojis:bar-chart",
|
|
||||||
width: 20,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "退出账号",
|
|
||||||
key: "logout",
|
|
||||||
icon: () =>
|
|
||||||
h(Icon, {
|
|
||||||
icon: "streamline-emojis:hot-beverage-2",
|
|
||||||
width: 20,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function showTutorial() {
|
function showTutorial() {
|
||||||
show.value = true
|
show.value = true
|
||||||
panelSize.value = 2 / 5
|
panelSize.value = 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickMenu(name: string) {
|
|
||||||
switch (name) {
|
|
||||||
case "dashboard": {
|
|
||||||
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
|
|
||||||
router.push({ name: route, params: { display: 0 } })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "submissions":
|
|
||||||
router.push({
|
|
||||||
name: "submissions",
|
|
||||||
params: { page: 1 },
|
|
||||||
query: { username: user.username },
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "logout":
|
|
||||||
handleLogout()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogin() {
|
|
||||||
loginModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
|
|
||||||
try {
|
|
||||||
await Account.logout()
|
|
||||||
} finally {
|
|
||||||
user.username = ""
|
|
||||||
user.role = Role.Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.corner {
|
.corner {
|
||||||
margin-right: 20px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<router-link class="brand" :to="tutorialRoute">
|
||||||
|
<img src="/noto--honeybee.ico" alt="" width="20" height="20" />
|
||||||
|
<span>前端开发学习平台</span>
|
||||||
|
</router-link>
|
||||||
|
<nav class="nav">
|
||||||
|
<router-link
|
||||||
|
v-for="item in nav"
|
||||||
|
:key="item.label"
|
||||||
|
:to="item.to"
|
||||||
|
:class="['nav-item', { active: item.active }]"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<template v-if="user.loaded">
|
||||||
|
<router-link
|
||||||
|
v-if="roleStaff"
|
||||||
|
:to="{ name: 'dashboard' }"
|
||||||
|
:class="['nav-item', { active: inTeach }]"
|
||||||
|
>
|
||||||
|
教学
|
||||||
|
</router-link>
|
||||||
|
<n-dropdown
|
||||||
|
v-if="authed"
|
||||||
|
:options="userMenu"
|
||||||
|
trigger="click"
|
||||||
|
@select="clickUserMenu"
|
||||||
|
>
|
||||||
|
<n-button quaternary class="user-btn">
|
||||||
|
{{ user.username }}
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
<n-button v-else type="primary" size="small" @click="loginModal = true">
|
||||||
|
登录
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</header>
|
||||||
|
<main class="main">
|
||||||
|
<router-view v-if="$route.matched.length" />
|
||||||
|
<div v-else-if="user.loaded && !authed" class="signed-out">
|
||||||
|
<p>登录后开始学习</p>
|
||||||
|
<n-button type="primary" @click="loginModal = true">登录</n-button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { authed, roleStaff, user } from "../../store/user"
|
||||||
|
import { loginModal } from "../../store/modal"
|
||||||
|
import { step } from "../../store/tutorial"
|
||||||
|
import { logout } from "../../store/session"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const routeName = computed(() => String(route.name ?? ""))
|
||||||
|
const inTeach = computed(() => route.path.startsWith("/dashboard"))
|
||||||
|
|
||||||
|
const tutorialRoute = computed(() =>
|
||||||
|
step.value
|
||||||
|
? { name: "home-tutorial", params: { display: step.value } }
|
||||||
|
: { name: "home-tutorial-list" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const nav = computed(() => [
|
||||||
|
{
|
||||||
|
label: "教程",
|
||||||
|
to: tutorialRoute.value,
|
||||||
|
active:
|
||||||
|
routeName.value === "home" || routeName.value.startsWith("home-tutorial"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "挑战",
|
||||||
|
to: { name: "home-challenge-list" },
|
||||||
|
active: routeName.value.startsWith("home-challenge"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "自由创作",
|
||||||
|
to: { name: "creation-list" },
|
||||||
|
active: routeName.value.startsWith("creation"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "作品展",
|
||||||
|
to: { name: "showcase" },
|
||||||
|
active: routeName.value === "showcase",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const userMenu = [
|
||||||
|
{ label: "我的提交", key: "submissions" },
|
||||||
|
{ type: "divider", key: "d1" },
|
||||||
|
{ label: "退出登录", key: "logout" },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function clickUserMenu(key: string) {
|
||||||
|
if (key === "submissions") {
|
||||||
|
router.push({
|
||||||
|
name: "submissions",
|
||||||
|
params: { page: 1 },
|
||||||
|
query: { username: user.username },
|
||||||
|
})
|
||||||
|
} else if (key === "logout") {
|
||||||
|
await logout()
|
||||||
|
// 留在需要登录或特定角色的页面上没有意义
|
||||||
|
if (route.meta.auth || route.meta.role) router.push(tutorialRoute.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: var(--wp-topbar-h);
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 20px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--wp-ink);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:focus-visible,
|
||||||
|
.brand:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
height: var(--wp-main-h);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-out {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import { storageShim } from "../../utils/previewStorage"
|
import { storageShim } from "../../utils/previewStorage"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt, Submission } from "../../api"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import type { UploadFileInfo } from "naive-ui"
|
import type { UploadFileInfo } from "naive-ui"
|
||||||
import { TaskAssets } from "../../api"
|
import { TaskAssets } from "../../api"
|
||||||
|
|||||||
@@ -1,93 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title" :wrap="false">
|
||||||
<n-flex align="center">
|
<n-flex align="center" :wrap="false">
|
||||||
<Icon
|
<span class="panel-name">
|
||||||
:icon="
|
{{ taskTab === TASK_TYPE.Tutorial ? "教程" : "挑战" }}
|
||||||
taskTab === TASK_TYPE.Tutorial
|
</span>
|
||||||
? 'twemoji:books'
|
|
||||||
: 'twemoji:crossed-swords'
|
|
||||||
"
|
|
||||||
:width="20"
|
|
||||||
></Icon>
|
|
||||||
<n-tabs
|
|
||||||
style="width: 150px"
|
|
||||||
type="segment"
|
|
||||||
animated
|
|
||||||
:value="taskTab"
|
|
||||||
@update:value="changeTab"
|
|
||||||
>
|
|
||||||
<n-tab name="tutorial" tab="教程"></n-tab>
|
|
||||||
<n-tab name="challenge" tab="挑战"></n-tab>
|
|
||||||
</n-tabs>
|
|
||||||
<template v-if="!hideNav">
|
<template v-if="!hideNav">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="prev()" :disabled="prevDisabled()">
|
<n-button text @click="prev()" :disabled="prevDisabled()">
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
<Icon :width="18" icon="lucide:arrow-left" />
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
上一节
|
||||||
|
</n-tooltip>
|
||||||
<span v-if="progressText" class="progress-text">
|
<span v-if="progressText" class="progress-text">
|
||||||
{{ progressText }}
|
{{ progressText }}
|
||||||
</span>
|
</span>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="next()" :disabled="nextDisabled()">
|
<n-button text @click="next()" :disabled="nextDisabled()">
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
<Icon :width="18" icon="lucide:arrow-right" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
下一节
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex>
|
<n-flex align="center" :wrap="false">
|
||||||
<n-tooltip
|
|
||||||
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
|
||||||
trigger="hover"
|
|
||||||
>
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="showAssets = true">
|
|
||||||
<Icon :width="16" icon="lucide:image"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
素材
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip v-if="authed" trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
<n-button
|
||||||
text
|
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
||||||
@click="
|
size="small"
|
||||||
$router.push({ name: 'submissions', params: { page: 1 } })
|
quaternary
|
||||||
"
|
@click="showAssets = true"
|
||||||
>
|
>
|
||||||
<Icon :width="16" icon="lucide:list"></Icon>
|
<template #icon><Icon icon="lucide:image" /></template>
|
||||||
|
素材
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
|
||||||
提交记录
|
<template #icon><Icon icon="lucide:edit" /></template>
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip v-if="authed" trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="$router.push({ name: 'creation-list' })">
|
|
||||||
<Icon :width="16" icon="lucide:sparkles"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
自由创作
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip v-if="authed" trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="$router.push({ name: 'showcase' })">
|
|
||||||
<Icon :width="16" icon="lucide:award"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
创意工坊
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip v-if="roleSuper" trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="edit">
|
|
||||||
<Icon :width="16" icon="lucide:edit"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
编辑
|
编辑
|
||||||
</n-tooltip>
|
</n-button>
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text @click="$emit('hide')">
|
<n-button text @click="$emit('hide')">
|
||||||
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
|
<Icon :width="18" icon="lucide:x" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
关闭
|
收起教程,编辑器占满屏幕
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -119,7 +79,7 @@
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import {
|
import {
|
||||||
step,
|
step,
|
||||||
@@ -129,7 +89,7 @@ import {
|
|||||||
prevDisabled,
|
prevDisabled,
|
||||||
nextDisabled,
|
nextDisabled,
|
||||||
} from "../../store/tutorial"
|
} from "../../store/tutorial"
|
||||||
import { authed, roleSuper } from "../../store/user"
|
import { roleSuper } from "../../store/user"
|
||||||
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
@@ -167,6 +127,8 @@ function syncRoute(routeName: string) {
|
|||||||
taskTab.value = TASK_TYPE.Challenge
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
if (route.params.display)
|
if (route.params.display)
|
||||||
challengeDisplay.value = Number(route.params.display)
|
challengeDisplay.value = Number(route.params.display)
|
||||||
|
// 挑战列表里没有选中的任务,“提交”不可用
|
||||||
|
else taskId.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
syncRoute(route.name as string)
|
syncRoute(route.name as string)
|
||||||
@@ -189,20 +151,6 @@ const progressText = computed(() => {
|
|||||||
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
||||||
})
|
})
|
||||||
|
|
||||||
function changeTab(v: TASK_TYPE) {
|
|
||||||
taskId.value = 0
|
|
||||||
taskTab.value = v
|
|
||||||
if (v === TASK_TYPE.Tutorial) {
|
|
||||||
router.push(
|
|
||||||
step.value
|
|
||||||
? { name: "home-tutorial", params: { display: step.value } }
|
|
||||||
: { name: "home-tutorial-list" },
|
|
||||||
)
|
|
||||||
} else if (v === TASK_TYPE.Challenge) {
|
|
||||||
router.push({ name: "home-challenge-list" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
const name =
|
const name =
|
||||||
taskTab.value === TASK_TYPE.Tutorial
|
taskTab.value === TASK_TYPE.Tutorial
|
||||||
@@ -222,15 +170,22 @@ function edit() {
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
height: 43px;
|
height: 43px;
|
||||||
padding: 0 20px;
|
padding: 0 12px 0 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: var(--wp-faint);
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -392,7 +392,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Submission } from "../../api"
|
import { Submission } from "../../api"
|
||||||
import type { TaskStatsOut } from "../../utils/type"
|
import type { TaskStatsOut } from "../../utils/type"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ watch(step, (v) => {
|
|||||||
.markdown-body pre code {
|
.markdown-body pre code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: Monaco;
|
font-family: var(--wp-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .codeblock-wrapper {
|
.markdown-body .codeblock-wrapper {
|
||||||
@@ -178,7 +178,7 @@ watch(step, (v) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.codeblock-action .action-btn:hover {
|
.codeblock-action .action-btn:hover {
|
||||||
border-color: #18a058;
|
border-color: var(--wp-brand);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Vendored
-1
@@ -3,7 +3,6 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_WEB_URL: string
|
readonly PUBLIC_WEB_URL: string
|
||||||
readonly PUBLIC_BASE_URL: string
|
readonly PUBLIC_BASE_URL: string
|
||||||
readonly PUBLIC_ICONIFY_URL: string
|
|
||||||
readonly PUBLIC_WS_URL: string
|
readonly PUBLIC_WS_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -4,10 +4,26 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-family: var(--wp-font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 顶栏下面的内容区高度;页面用它代替 100vh */
|
||||||
|
:root {
|
||||||
|
--wp-topbar-h: 48px;
|
||||||
|
--wp-main-h: calc(100vh - var(--wp-topbar-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -18,10 +34,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #d0d0d0;
|
background: var(--wp-line);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #b0b0b0;
|
background: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iframe 里都是学生写的网页,网页默认是白底;不设的话会透出页面的底色 */
|
||||||
|
iframe {
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { defineComponent, h, type Component } from "vue"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Award,
|
||||||
|
BookOpen,
|
||||||
|
ChartNoAxesColumn,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
CircleCheck,
|
||||||
|
Download,
|
||||||
|
GraduationCap,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
List,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
Monitor,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Smartphone,
|
||||||
|
Sparkles,
|
||||||
|
SquarePen,
|
||||||
|
Star,
|
||||||
|
Swords,
|
||||||
|
Tablet,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-vue-next"
|
||||||
|
|
||||||
|
// 全站只用 lucide 一套图标,并且打包进前端(以前走在线的 iconify 接口,机房断网时图标会全部消失)。
|
||||||
|
// 沿用 <Icon icon="集合:名字" /> 的写法;原来其他图标集里用到的几个映射到 lucide 的对应图标
|
||||||
|
const registry: Record<string, Component> = {
|
||||||
|
"arrow-left": ArrowLeft,
|
||||||
|
"arrow-right": ArrowRight,
|
||||||
|
award: Award,
|
||||||
|
"book-open": BookOpen,
|
||||||
|
"bar-chart-2": ChartNoAxesColumn,
|
||||||
|
check: Check,
|
||||||
|
"chevron-down": ChevronDown,
|
||||||
|
"chevron-right": ChevronRight,
|
||||||
|
"chevron-up": ChevronUp,
|
||||||
|
globe: Globe,
|
||||||
|
lock: Lock,
|
||||||
|
"check-circle": CircleCheck,
|
||||||
|
download: Download,
|
||||||
|
eye: Eye,
|
||||||
|
image: Image,
|
||||||
|
info: Info,
|
||||||
|
lightbulb: Lightbulb,
|
||||||
|
list: List,
|
||||||
|
"log-out": LogOut,
|
||||||
|
play: Play,
|
||||||
|
plus: Plus,
|
||||||
|
"refresh-cw": RefreshCw,
|
||||||
|
save: Save,
|
||||||
|
search: Search,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
edit: SquarePen,
|
||||||
|
star: Star,
|
||||||
|
swords: Swords,
|
||||||
|
"trash-2": Trash2,
|
||||||
|
users: Users,
|
||||||
|
"graduation-cap": GraduationCap,
|
||||||
|
x: X,
|
||||||
|
// 原 material-symbols / pepicons
|
||||||
|
"close-rounded": X,
|
||||||
|
"desktop-windows-outline": Monitor,
|
||||||
|
"smartphone-outline": Smartphone,
|
||||||
|
"tablet-outline": Tablet,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Icon = defineComponent({
|
||||||
|
name: "Icon",
|
||||||
|
props: {
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
width: { type: [Number, String], default: 16 },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
const name = props.icon.slice(props.icon.indexOf(":") + 1)
|
||||||
|
const comp = registry[name]
|
||||||
|
if (!comp) {
|
||||||
|
if (import.meta.env.DEV) console.warn(`图标 ${props.icon} 没有登记在 icons.ts 里`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return h(comp, { size: props.width, strokeWidth: 2, "aria-hidden": "true" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
+4
-7
@@ -1,8 +1,10 @@
|
|||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import { create } from "naive-ui"
|
import { create } from "naive-ui"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
import "@fontsource/jetbrains-mono/400.css"
|
||||||
|
import "@fontsource/jetbrains-mono/600.css"
|
||||||
import "./global.css"
|
import "./global.css"
|
||||||
import { addAPIProvider } from "@iconify/vue"
|
import { applyCssVars, light } from "./theme"
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "github-markdown-css/github-markdown-light.css"
|
import "github-markdown-css/github-markdown-light.css"
|
||||||
@@ -14,6 +16,7 @@ import "highlight.js/styles/github.min.css"
|
|||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
|
|
||||||
marked.use(...trustedExtensions)
|
marked.use(...trustedExtensions)
|
||||||
|
applyCssVars(light)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const naive = create()
|
const naive = create()
|
||||||
@@ -21,9 +24,3 @@ const naive = create()
|
|||||||
app.use(naive)
|
app.use(naive)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount("#app")
|
app.mount("#app")
|
||||||
|
|
||||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
|
||||||
addAPIProvider("", {
|
|
||||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,31 +3,33 @@
|
|||||||
<div class="challenge-sider">
|
<div class="challenge-sider">
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
<n-button text @click="back" style="margin: 0 8px">
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
<Icon :width="18" icon="lucide:arrow-left" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
返回挑战列表
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<n-flex style="margin: 0 8px">
|
<n-flex style="margin: 0 8px">
|
||||||
<n-button
|
<n-tooltip v-if="roleAdmin || roleSuper">
|
||||||
v-if="roleAdmin || roleSuper"
|
<template #trigger>
|
||||||
text
|
<n-button text @click="showStats = true">
|
||||||
@click="showStats = true"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
</template>
|
||||||
v-if="authed"
|
本题统计
|
||||||
text
|
</n-tooltip>
|
||||||
@click="
|
<n-tooltip v-if="roleSuper">
|
||||||
$router.push({ name: 'submissions', params: { page: 1 } })
|
<template #trigger>
|
||||||
"
|
<n-button text @click="edit">
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:list" />
|
|
||||||
</n-button>
|
|
||||||
<n-button v-if="roleSuper" text @click="edit">
|
|
||||||
<Icon :width="16" icon="lucide:edit" />
|
<Icon :width="16" icon="lucide:edit" />
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
编辑这道题
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||||
@@ -141,7 +143,7 @@
|
|||||||
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { marked, type MarkedOptions } from "marked"
|
import { marked, type MarkedOptions } from "marked"
|
||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||||
@@ -312,7 +314,7 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.challenge-layout {
|
.challenge-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +398,7 @@ onUnmounted(() => {
|
|||||||
.challenge-sider .codeblock-wrapper pre code {
|
.challenge-sider .codeblock-wrapper pre code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: Monaco, monospace;
|
font-family: var(--wp-font-mono);
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +428,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .action-btn:hover {
|
.challenge-sider .action-btn:hover {
|
||||||
border-color: #18a058;
|
border-color: var(--wp-brand);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
width="24"
|
width="24"
|
||||||
:icon="
|
:icon="
|
||||||
item.is_public
|
item.is_public
|
||||||
? 'twemoji:check-mark-button'
|
? 'lucide:globe'
|
||||||
: 'twemoji:locked'
|
: 'lucide:lock'
|
||||||
"
|
"
|
||||||
></Icon>
|
></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 100px)"
|
style="height: calc(var(--wp-main-h) - 100px)"
|
||||||
v-model="challenge.content"
|
v-model="challenge.content"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Challenge } from "../api"
|
import { Challenge } from "../api"
|
||||||
import { roleSuper } from "../store/user"
|
import { roleSuper } from "../store/user"
|
||||||
import type { ChallengeSlim } from "../utils/type"
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
@@ -296,7 +296,7 @@ onMounted(getContent)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.list {
|
.list {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: calc(100vh - 20px);
|
height: calc(var(--wp-main-h) - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.col {
|
.col {
|
||||||
@@ -308,6 +308,6 @@ onMounted(getContent)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
height: calc(100vh - 200px);
|
height: calc(var(--wp-main-h) - 200px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,10 +3,15 @@
|
|||||||
<div class="creation-sider">
|
<div class="creation-sider">
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
<n-button text @click="back" style="margin: 0 8px">
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
<Icon :width="18" icon="lucide:arrow-left" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
返回我的创作
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<n-flex style="margin: 0 8px">
|
<n-flex style="margin: 0 8px">
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip trigger="hover">
|
||||||
@@ -112,7 +117,7 @@
|
|||||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||||
import Preview from "../components/editor/Preview.vue"
|
import Preview from "../components/editor/Preview.vue"
|
||||||
@@ -229,7 +234,7 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.creation-layout {
|
.creation-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,7 @@
|
|||||||
style="margin-bottom: 32px"
|
style="margin-bottom: 32px"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<n-flex align="center" :size="8">
|
|
||||||
<n-button text @click="$router.push({ name: 'home' })">
|
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
|
||||||
</n-button>
|
|
||||||
<n-h2 style="margin: 0">自由创作</n-h2>
|
<n-h2 style="margin: 0">自由创作</n-h2>
|
||||||
</n-flex>
|
|
||||||
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
|
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
|
||||||
</div>
|
</div>
|
||||||
<n-button type="primary" @click="openCreate">
|
<n-button type="primary" @click="openCreate">
|
||||||
@@ -106,7 +101,7 @@
|
|||||||
import { onMounted, reactive, ref } from "vue"
|
import { onMounted, reactive, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Creation } from "../api"
|
import { Creation } from "../api"
|
||||||
import type { CreationSlim } from "../utils/type"
|
import type { CreationSlim } from "../utils/type"
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +1,151 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex class="container" :wrap="false">
|
<div class="teach">
|
||||||
<div class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
|
<template v-for="group in groups" :key="group.label">
|
||||||
← 返回
|
<div class="group-label">{{ group.label }}</div>
|
||||||
</div>
|
<router-link
|
||||||
<n-divider style="margin: 8px 0" />
|
v-for="item in group.items"
|
||||||
<div
|
|
||||||
v-for="item in menu"
|
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
:class="['nav-item', { active: $route.name === item.route.name }]"
|
:to="item.route"
|
||||||
@click="$router.push(item.route)"
|
:class="['nav-item', { active: item.names.includes(String($route.name)) }]"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
<Icon :icon="item.icon" :width="16" />
|
||||||
</div>
|
<span>{{ item.label }}</span>
|
||||||
</div>
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { taskTab } from "../store/task"
|
import { Icon } from "../icons"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { roleAdmin, roleSuper } from "../store/user"
|
import { roleSuper } from "../store/user"
|
||||||
import { goHome } from "../utils/helper"
|
|
||||||
|
|
||||||
const menu = computed(() =>
|
// admin 只能出挑战、看提交、管作品展;其余是超管的(和后端权限一致)
|
||||||
|
const groups = computed(() =>
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
label: "出题",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: "教程",
|
label: "教程",
|
||||||
|
icon: "lucide:book-open",
|
||||||
route: { name: "tutorial-editor", params: { display: step.value } },
|
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||||
|
names: ["tutorial-editor"],
|
||||||
show: roleSuper.value,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "挑战",
|
label: "挑战",
|
||||||
|
icon: "lucide:swords",
|
||||||
route: { name: "challenge-editor", params: { display: 0 } },
|
route: { name: "challenge-editor", params: { display: 0 } },
|
||||||
show: roleAdmin.value || roleSuper.value,
|
names: ["challenge-editor"],
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "班级",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "成绩册",
|
||||||
|
icon: "lucide:graduation-cap",
|
||||||
|
route: { name: "gradebook" },
|
||||||
|
names: ["gradebook"],
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "全部提交",
|
||||||
|
icon: "lucide:list",
|
||||||
|
route: { name: "teach-submissions", params: { page: 1 } },
|
||||||
|
names: ["teach-submissions"],
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "管理",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "作品展",
|
||||||
|
icon: "lucide:award",
|
||||||
|
route: { name: "showcase-manage" },
|
||||||
|
names: ["showcase-manage"],
|
||||||
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "用户",
|
label: "用户",
|
||||||
|
icon: "lucide:users",
|
||||||
route: { name: "user-manage", params: { page: 1 } },
|
route: { name: "user-manage", params: { page: 1 } },
|
||||||
|
names: ["user-manage"],
|
||||||
show: roleSuper.value,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
label: "工坊",
|
|
||||||
route: { name: "showcase-manage" },
|
|
||||||
show: roleSuper.value,
|
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
label: "成绩",
|
.map((g) => ({ ...g, items: g.items.filter((i) => i.show) }))
|
||||||
route: { name: "gradebook" },
|
.filter((g) => g.items.length),
|
||||||
show: roleSuper.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "提交",
|
|
||||||
route: { name: "submissions", params: { page: 1 } },
|
|
||||||
show: roleAdmin.value || roleSuper.value,
|
|
||||||
},
|
|
||||||
].filter((item) => item.show),
|
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.teach {
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
width: 100vw;
|
display: flex;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 110px;
|
width: 148px;
|
||||||
min-width: 110px;
|
flex: none;
|
||||||
padding: 12px 8px;
|
padding: 8px;
|
||||||
border-right: 1px solid #efeff5;
|
border-right: 1px solid var(--wp-line);
|
||||||
background: #fafafa;
|
background: var(--wp-surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.group-label {
|
||||||
padding: 7px 10px;
|
padding: 12px 10px 4px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--wp-faint);
|
||||||
cursor: pointer;
|
letter-spacing: 0.04em;
|
||||||
border-radius: 6px;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #444;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 8px 10px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
color: var(--wp-muted);
|
||||||
color: #444;
|
text-decoration: none;
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background-color: #f0f0f0;
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background-color: #e8f8f0;
|
background: var(--wp-brand-soft);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
import { storageShim } from "../utils/previewStorage"
|
import { storageShim } from "../utils/previewStorage"
|
||||||
import { onMounted, ref } from "vue"
|
import { onMounted, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Showcase } from "../api"
|
import { Showcase } from "../api"
|
||||||
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ import {
|
|||||||
useMessage,
|
useMessage,
|
||||||
type DataTableColumn,
|
type DataTableColumn,
|
||||||
} from "naive-ui"
|
} from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Showcase } from "../api"
|
import { Showcase } from "../api"
|
||||||
import type {
|
import type {
|
||||||
AwardItemManageOut,
|
AwardItemManageOut,
|
||||||
@@ -640,8 +640,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.award-row.active {
|
.award-row.active {
|
||||||
background: #e8f8f0;
|
background: var(--wp-brand-soft);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.table-number-input) {
|
:deep(.table-number-input) {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||||
import { parseTime } from "../utils/helper"
|
import { parseTime } from "../utils/helper"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
width="24"
|
width="24"
|
||||||
:icon="
|
:icon="
|
||||||
item.is_public
|
item.is_public
|
||||||
? 'twemoji:check-mark-button'
|
? 'lucide:globe'
|
||||||
: 'twemoji:locked'
|
: 'lucide:lock'
|
||||||
"
|
"
|
||||||
></Icon>
|
></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 100px)"
|
style="height: calc(var(--wp-main-h) - 100px)"
|
||||||
v-model="tutorial.content"
|
v-model="tutorial.content"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Tutorial } from "../api"
|
import { Tutorial } from "../api"
|
||||||
import type { TutorialSlim } from "../utils/type"
|
import type { TutorialSlim } from "../utils/type"
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
@@ -258,7 +258,7 @@ onMounted(getContent)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.list {
|
.list {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: calc(100vh - 20px);
|
height: calc(var(--wp-main-h) - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.col {
|
.col {
|
||||||
@@ -270,6 +270,6 @@ onMounted(getContent)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
height: calc(100vh - 200px);
|
height: calc(var(--wp-main-h) - 200px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -198,7 +198,7 @@ onMounted(init)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
padding: 10px 10px 10px 0;
|
padding: 10px 10px 10px 0;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+38
-6
@@ -1,12 +1,23 @@
|
|||||||
import { createWebHistory, createRouter } from "vue-router"
|
import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"
|
||||||
import { loginModal } from "./store/modal"
|
import { loginModal } from "./store/modal"
|
||||||
|
|
||||||
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
|
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
|
||||||
const Workspace = () => import("./pages/Workspace.vue")
|
const Workspace = () => import("./pages/Workspace.vue")
|
||||||
import { hasLogin } from "./token"
|
import { hasLogin } from "./token"
|
||||||
import { authed } from "./store/user"
|
import { authed, roleStaff, roleSuper } from "./store/user"
|
||||||
|
import { loadUser } from "./store/session"
|
||||||
|
|
||||||
const routes = [
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
auth?: boolean
|
||||||
|
// 教学页面:staff 是 admin 和 super,super 只有超管(和后端的 requireAdmin / requireSuper 对应)
|
||||||
|
role?: "staff" | "super"
|
||||||
|
// 不带顶栏的全屏页面
|
||||||
|
bare?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
||||||
{
|
{
|
||||||
path: "/tutorial",
|
path: "/tutorial",
|
||||||
@@ -43,7 +54,7 @@ const routes = [
|
|||||||
name: "submission",
|
name: "submission",
|
||||||
component: () => import("./pages/Submission.vue"),
|
component: () => import("./pages/Submission.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { auth: true },
|
meta: { auth: true, bare: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/creation",
|
path: "/creation",
|
||||||
@@ -67,12 +78,13 @@ const routes = [
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
component: () => import("./pages/Dashboard.vue"),
|
component: () => import("./pages/Dashboard.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true, role: "staff" },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "tutorial/:display",
|
path: "tutorial/:display",
|
||||||
name: "tutorial-editor",
|
name: "tutorial-editor",
|
||||||
component: () => import("./pages/TutorialEditor.vue"),
|
component: () => import("./pages/TutorialEditor.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "challenge/:display",
|
path: "challenge/:display",
|
||||||
@@ -83,6 +95,7 @@ const routes = [
|
|||||||
path: "user-manage/:page",
|
path: "user-manage/:page",
|
||||||
name: "user-manage",
|
name: "user-manage",
|
||||||
component: () => import("./pages/UserManage.vue"),
|
component: () => import("./pages/UserManage.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "showcase",
|
path: "showcase",
|
||||||
@@ -93,6 +106,13 @@ const routes = [
|
|||||||
path: "gradebook",
|
path: "gradebook",
|
||||||
name: "gradebook",
|
name: "gradebook",
|
||||||
component: () => import("./pages/Gradebook.vue"),
|
component: () => import("./pages/Gradebook.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
||||||
|
path: "submissions/:page",
|
||||||
|
name: "teach-submissions",
|
||||||
|
component: () => import("./pages/Submissions.vue"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -103,10 +123,22 @@ export const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
const isLoggedIn = authed.value || hasLogin()
|
const isLoggedIn = authed.value || hasLogin()
|
||||||
if (to.meta.auth && !isLoggedIn) {
|
if (to.meta.auth && !isLoggedIn) {
|
||||||
loginModal.value = true
|
loginModal.value = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (to.meta.role || to.name === "dashboard") {
|
||||||
|
await loadUser()
|
||||||
|
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
|
||||||
|
if (!allowed) return { name: "home" }
|
||||||
|
}
|
||||||
|
// 教学的默认页:超管从教程开始,管理员只能出挑战
|
||||||
|
if (to.name === "dashboard") {
|
||||||
|
return {
|
||||||
|
name: roleSuper.value ? "tutorial-editor" : "challenge-editor",
|
||||||
|
params: { display: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
@@ -9,7 +9,7 @@ export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
|
|||||||
export const js = useStorage(STORAGE_KEY.JS, "")
|
export const js = useStorage(STORAGE_KEY.JS, "")
|
||||||
|
|
||||||
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
||||||
export const size = useStorage(STORAGE_KEY.FONTSIZE, 24)
|
export const size = useStorage(STORAGE_KEY.FONTSIZE, 16)
|
||||||
|
|
||||||
export function reset(lang: "html" | "css" | "js") {
|
export function reset(lang: "html" | "css" | "js") {
|
||||||
if (lang === "html") {
|
if (lang === "html") {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Account } from "../api"
|
||||||
|
import { Role } from "../utils/type"
|
||||||
|
import { user } from "./user"
|
||||||
|
|
||||||
|
let loading: Promise<void> | null = null
|
||||||
|
|
||||||
|
// 取一次当前登录用户(路由守卫要按角色判断,可能比 App 挂载还早)
|
||||||
|
export function loadUser(): Promise<void> {
|
||||||
|
if (user.loaded) return Promise.resolve()
|
||||||
|
loading ??= Account.getMyProfile()
|
||||||
|
.then((data) => {
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
user.loaded = true
|
||||||
|
loading = null
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
|
||||||
|
try {
|
||||||
|
await Account.logout()
|
||||||
|
} finally {
|
||||||
|
user.username = ""
|
||||||
|
user.role = Role.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,5 @@ export const roleAdmin = computed(
|
|||||||
export const roleSuper = computed(
|
export const roleSuper = computed(
|
||||||
() => authed.value && user.role === Role.Super,
|
() => authed.value && user.role === Role.Super,
|
||||||
)
|
)
|
||||||
|
// 老师端(教学):admin 和 super
|
||||||
|
export const roleStaff = computed(() => roleAdmin.value || roleSuper.value)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { GlobalThemeOverrides } from "naive-ui"
|
||||||
|
|
||||||
|
// 全站配色只在这里定义:同一份值既生成 CSS 变量(组件样式里用 var(--wp-*)),
|
||||||
|
// 也生成 Naive UI 的主题设置。机房是 Chrome 105,不支持 color-mix(),浅色底色单独列出来
|
||||||
|
export interface Palette {
|
||||||
|
brand: string
|
||||||
|
brandHover: string
|
||||||
|
brandPressed: string
|
||||||
|
brandSoft: string
|
||||||
|
ink: string
|
||||||
|
muted: string
|
||||||
|
faint: string
|
||||||
|
paper: string
|
||||||
|
surface: string
|
||||||
|
line: string
|
||||||
|
html: string
|
||||||
|
htmlSoft: string
|
||||||
|
css: string
|
||||||
|
cssSoft: string
|
||||||
|
js: string
|
||||||
|
jsSoft: string
|
||||||
|
warn: string
|
||||||
|
warnSoft: string
|
||||||
|
bad: string
|
||||||
|
badSoft: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const light: Palette = {
|
||||||
|
brand: "#0d7a52",
|
||||||
|
brandHover: "#118f61",
|
||||||
|
brandPressed: "#0a6343",
|
||||||
|
brandSoft: "#e3f2eb",
|
||||||
|
ink: "#17201c",
|
||||||
|
muted: "#5d6b65",
|
||||||
|
faint: "#8a9791",
|
||||||
|
paper: "#f5f7f6",
|
||||||
|
surface: "#ffffff",
|
||||||
|
line: "#dfe5e2",
|
||||||
|
html: "#d9532c",
|
||||||
|
htmlSoft: "#fdf3ef",
|
||||||
|
css: "#2c6bd6",
|
||||||
|
cssSoft: "#f1f5fd",
|
||||||
|
js: "#b98900",
|
||||||
|
jsSoft: "#fdf9ec",
|
||||||
|
warn: "#b7791f",
|
||||||
|
warnSoft: "#fbf3e6",
|
||||||
|
bad: "#c2413b",
|
||||||
|
badSoft: "#fbeceb",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色(阶段 4 才接上切换开关,值先定好)
|
||||||
|
export const dark: Palette = {
|
||||||
|
brand: "#3cc189",
|
||||||
|
brandHover: "#5ad19d",
|
||||||
|
brandPressed: "#2ea374",
|
||||||
|
brandSoft: "#173326",
|
||||||
|
ink: "#e4ebe7",
|
||||||
|
muted: "#9aaba3",
|
||||||
|
faint: "#6f7f78",
|
||||||
|
paper: "#0f1412",
|
||||||
|
surface: "#161d1a",
|
||||||
|
line: "#2a3531",
|
||||||
|
html: "#f07a52",
|
||||||
|
htmlSoft: "#2a1c17",
|
||||||
|
css: "#6c9cf0",
|
||||||
|
cssSoft: "#18202e",
|
||||||
|
js: "#e2b93b",
|
||||||
|
jsSoft: "#262113",
|
||||||
|
warn: "#e0a34a",
|
||||||
|
warnSoft: "#2b2316",
|
||||||
|
bad: "#ec6b65",
|
||||||
|
badSoft: "#2e1a19",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontSans =
|
||||||
|
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", system-ui, sans-serif'
|
||||||
|
export const fontMono =
|
||||||
|
'"JetBrains Mono", Consolas, "Microsoft YaHei", monospace'
|
||||||
|
|
||||||
|
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())
|
||||||
|
|
||||||
|
export function applyCssVars(p: Palette) {
|
||||||
|
const root = document.documentElement.style
|
||||||
|
for (const [k, v] of Object.entries(p)) root.setProperty(`--wp-${kebab(k)}`, v)
|
||||||
|
root.setProperty("--wp-font-sans", fontSans)
|
||||||
|
root.setProperty("--wp-font-mono", fontMono)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function naiveOverrides(p: Palette): GlobalThemeOverrides {
|
||||||
|
return {
|
||||||
|
common: {
|
||||||
|
primaryColor: p.brand,
|
||||||
|
primaryColorHover: p.brandHover,
|
||||||
|
primaryColorPressed: p.brandPressed,
|
||||||
|
primaryColorSuppl: p.brandHover,
|
||||||
|
warningColor: p.warn,
|
||||||
|
errorColor: p.bad,
|
||||||
|
textColorBase: p.ink,
|
||||||
|
textColor1: p.ink,
|
||||||
|
textColor2: p.ink,
|
||||||
|
textColor3: p.muted,
|
||||||
|
borderColor: p.line,
|
||||||
|
dividerColor: p.line,
|
||||||
|
fontFamily: fontSans,
|
||||||
|
fontFamilyMono: fontMono,
|
||||||
|
borderRadius: "6px",
|
||||||
|
borderRadiusSmall: "4px",
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export const STORAGE_KEY = {
|
|||||||
CSS: "web-css",
|
CSS: "web-css",
|
||||||
JS: "web-js",
|
JS: "web-js",
|
||||||
TAB: "web-tab",
|
TAB: "web-tab",
|
||||||
FONTSIZE: "web-fontsize",
|
// 默认字号从 24 改成 16 时换了 key,让机房电脑上存着的旧值失效
|
||||||
|
FONTSIZE: "web-fontsize-v2",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
||||||
|
|||||||
Reference in new issue
Block a user