Compare commits
2
Commits
40f3063b91
...
bd84e3f8d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd84e3f8d1 | ||
|
|
03878f6cc2 |
No files matched your search
@@ -42,7 +42,7 @@ bun install # installs both workspaces into the single root bun.loc
|
|||||||
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
|
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
|
||||||
bun run build # client production build (build:staging for the school server)
|
bun run build # client production build (build:staging for the school server)
|
||||||
bun run test # server tests
|
bun run test # server tests
|
||||||
bun run typecheck # server typecheck
|
bun run typecheck # server (tsc 7) + client (vue-tsc) typecheck
|
||||||
bun run fmt # format the client
|
bun run fmt # format the client
|
||||||
```
|
```
|
||||||
Add a dependency from inside its workspace (`cd client && bun add x`); the lockfile is still the root one.
|
Add a dependency from inside its workspace (`cd client && bun add x`); the lockfile is still the root one.
|
||||||
@@ -62,6 +62,7 @@ Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/d
|
|||||||
```bash
|
```bash
|
||||||
bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
|
bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
|
||||||
bun run build # Production build (bun run build:staging for the school server)
|
bun run build # Production build (bun run build:staging for the school server)
|
||||||
|
bun run typecheck # vue-tsc; the client pins TypeScript 5.9 because vue-tsc can't run on TS 7 (the server uses TS 7)
|
||||||
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
|
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
|
||||||
```
|
```
|
||||||
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
||||||
|
|||||||
@@ -35,9 +35,10 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.3.5",
|
"vite": "^8.3.5",
|
||||||
|
"vue-tsc": "^3.3.13",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
@@ -459,6 +460,12 @@
|
|||||||
|
|
||||||
"@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/source-map": ["@volar/source-map@2.4.28", "", {}, "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=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||||
@@ -475,6 +482,8 @@
|
|||||||
|
|
||||||
"@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/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=="],
|
||||||
|
|
||||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||||
@@ -497,6 +506,8 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
"ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
||||||
@@ -725,6 +736,8 @@
|
|||||||
|
|
||||||
"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-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=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
@@ -789,7 +802,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@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
"typescript": ["typescript@5.9.3", "", { "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=="],
|
||||||
|
|
||||||
@@ -819,12 +832,16 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="],
|
"vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
@@ -857,6 +874,8 @@
|
|||||||
|
|
||||||
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"@vue/language-core/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
"ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
|
"ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
|
||||||
|
|
||||||
"ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
|
"ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
|
||||||
@@ -879,6 +898,8 @@
|
|||||||
|
|
||||||
"vue-router/unplugin": ["unplugin@3.5.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.5.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="],
|
"vue-router/unplugin": ["unplugin@3.5.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.5.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="],
|
||||||
|
|
||||||
|
"webpreview-server/typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||||
|
|
||||||
"@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
|
"@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
|
||||||
|
|
||||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
||||||
|
|||||||
+5
-3
@@ -7,7 +7,8 @@
|
|||||||
"dev": "bun --bun vite",
|
"dev": "bun --bun vite",
|
||||||
"build": "bun --bun vite build",
|
"build": "bun --bun vite build",
|
||||||
"build:staging": "bun --bun vite build --mode staging",
|
"build:staging": "bun --bun vite build --mode staging",
|
||||||
"fmt": "bun fmt src/"
|
"fmt": "bun fmt src/",
|
||||||
|
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -36,8 +37,9 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5",
|
||||||
|
"vue-tsc": "^3.3.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-3
@@ -60,6 +60,8 @@ http.interceptors.response.use(
|
|||||||
) {
|
) {
|
||||||
err.config._retried = true
|
err.config._retried = true
|
||||||
if (await refreshAccessToken()) return http(err.config)
|
if (await refreshAccessToken()) return http(err.config)
|
||||||
|
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
|
||||||
|
if (hasLogin()) return Promise.reject(err)
|
||||||
}
|
}
|
||||||
switch (err.response.status) {
|
switch (err.response.status) {
|
||||||
case 401: // 未授权
|
case 401: // 未授权
|
||||||
@@ -277,7 +279,7 @@ export const Submission = {
|
|||||||
username?: string
|
username?: string
|
||||||
user_id?: number
|
user_id?: number
|
||||||
flag?: string | null
|
flag?: string | null
|
||||||
zone?: string
|
zone?: string | null
|
||||||
task_id?: number
|
task_id?: number
|
||||||
task_type?: string
|
task_type?: string
|
||||||
score_min?: number
|
score_min?: number
|
||||||
@@ -553,7 +555,11 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteChallenge(display: number, name: string) {
|
async deleteChallenge(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/challenge/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
async listTutorial(display: number): Promise<TaskAsset[]> {
|
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||||
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||||
@@ -573,6 +579,10 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteTutorial(display: number, name: string) {
|
async deleteTutorial(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,15 @@ function selectItem(item: HistoryViewItem) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载中又要求刷新(比如自动提交了新代码、换了任务),等这次完成后再加载一次
|
||||||
|
let reloadAfter = false
|
||||||
|
|
||||||
async function load(force = true) {
|
async function load(force = true) {
|
||||||
if (!props.taskId || loading.value) return
|
if (!props.taskId) return
|
||||||
|
if (loading.value) {
|
||||||
|
reloadAfter = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!force && loadedTaskId === props.taskId) return
|
if (!force && loadedTaskId === props.taskId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +237,10 @@ async function load(force = true) {
|
|||||||
loadedTaskId = props.taskId
|
loadedTaskId = props.taskId
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (reloadAfter) {
|
||||||
|
reloadAfter = false
|
||||||
|
load(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="prompt-panel">
|
<div class="prompt-panel">
|
||||||
<div class="messages" ref="messagesRef">
|
<div class="messages" ref="messagesRef">
|
||||||
<div
|
<div
|
||||||
v-for="(pair, pi) in pairs"
|
v-for="pair in pairs"
|
||||||
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||||
class="message-pair"
|
class="message-pair"
|
||||||
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||||
|
|||||||
@@ -107,9 +107,13 @@ function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await Account.logout()
|
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
|
||||||
user.username = ""
|
try {
|
||||||
user.role = Role.Normal
|
await Account.logout()
|
||||||
|
} finally {
|
||||||
|
user.username = ""
|
||||||
|
user.role = Role.Normal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -486,21 +486,6 @@ const countBuckets = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const scoreBars = computed(() => {
|
|
||||||
if (!stats.value) return []
|
|
||||||
const d = stats.value.score_distribution
|
|
||||||
const vals = [d.range_1_2, d.range_2_3, d.range_3_4, d.range_4_5, d.range_5]
|
|
||||||
const max = Math.max(...vals, 1)
|
|
||||||
const colors = ["#d03050", "#f0a020", "#2080f0", "#18a058", "#18a058"]
|
|
||||||
const labels = ["★", "★★", "★★★", "★★★★", "★★★★★"]
|
|
||||||
return vals.map((v, i) => ({
|
|
||||||
value: v,
|
|
||||||
label: labels[i],
|
|
||||||
color: colors[i],
|
|
||||||
height: Math.max(Math.round((v / max) * 68), 3) + "px",
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const flagBadges = computed(() => {
|
const flagBadges = computed(() => {
|
||||||
if (!stats.value) return []
|
if (!stats.value) return []
|
||||||
const f = stats.value.flag_stats
|
const f = stats.value.flag_stats
|
||||||
|
|||||||
@@ -317,20 +317,28 @@ async function loadClasses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速切换班级时只用最后一次请求的结果
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function loadGradebook() {
|
async function loadGradebook() {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!query.classname) {
|
if (!query.classname) {
|
||||||
gradebook.value = null
|
gradebook.value = null
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ""
|
loadError.value = ""
|
||||||
try {
|
try {
|
||||||
gradebook.value = await Gradebook.get(query)
|
const data = await Gradebook.get(query)
|
||||||
classes.value = gradebook.value.classes
|
if (seq !== loadSeq) return
|
||||||
|
gradebook.value = data
|
||||||
|
classes.value = data.classes
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,8 +366,10 @@ watchDebounced(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const before = query.classname
|
||||||
await loadClasses()
|
await loadClasses()
|
||||||
await loadGradebook()
|
// loadClasses 选了默认班级时,上面的 watch 已经会加载
|
||||||
|
if (query.classname === before) loadGradebook()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -115,10 +115,8 @@ import FlagCell from "../components/submissions/FlagCell.vue"
|
|||||||
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
||||||
|
|
||||||
import { submission } from "../store/submission"
|
import { submission } from "../store/submission"
|
||||||
import { taskTab, challengeDisplay } from "../store/task"
|
|
||||||
import { step } from "../store/tutorial"
|
|
||||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||||
import { roleAdmin, roleSuper, user } from "../store/user"
|
import { roleAdmin, roleSuper } from "../store/user"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const submission = ref<SubmissionAll>({
|
|||||||
task_type: TASK_TYPE.Tutorial,
|
task_type: TASK_TYPE.Tutorial,
|
||||||
score: 0.0,
|
score: 0.0,
|
||||||
my_score: 0,
|
my_score: 0,
|
||||||
|
submit_count: 0,
|
||||||
|
view_count: 0,
|
||||||
html: "",
|
html: "",
|
||||||
css: "",
|
css: "",
|
||||||
js: "",
|
js: "",
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"build": "bun run --filter webpreview-client build",
|
"build": "bun run --filter webpreview-client build",
|
||||||
"build:staging": "bun run --filter webpreview-client build:staging",
|
"build:staging": "bun run --filter webpreview-client build:staging",
|
||||||
"test": "bun run --filter webpreview-server test",
|
"test": "bun run --filter webpreview-server test",
|
||||||
"typecheck": "bun run --filter webpreview-server typecheck",
|
"typecheck": "bun run --filter '*' typecheck",
|
||||||
"fmt": "bun run --filter webpreview-client fmt"
|
"fmt": "bun run --filter webpreview-client fmt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-23
@@ -1,6 +1,5 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { mkdir, rm } from "node:fs/promises"
|
import { mkdir, rename, rm } from "node:fs/promises"
|
||||||
import { existsSync } from "node:fs"
|
|
||||||
import { dirname, extname, join, resolve, sep } from "node:path"
|
import { dirname, extname, join, resolve, sep } from "node:path"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
@@ -22,20 +21,6 @@ export function mediaPath(relative: string) {
|
|||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFile(relative: string, file: File) {
|
|
||||||
let target = relative
|
|
||||||
// 同名文件已存在时加随机后缀(Django 存储的行为)
|
|
||||||
while (existsSync(mediaPath(target))) {
|
|
||||||
const ext = extname(relative)
|
|
||||||
const suffix = Math.random().toString(36).slice(2, 9)
|
|
||||||
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
|
|
||||||
}
|
|
||||||
const full = mediaPath(target)
|
|
||||||
await mkdir(dirname(full), { recursive: true })
|
|
||||||
await Bun.write(full, file)
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formFile(c: Ctx, field: string) {
|
async function formFile(c: Ctx, field: string) {
|
||||||
const form = await c.req.formData().catch(() => null)
|
const form = await c.req.formData().catch(() => null)
|
||||||
const value = form?.get(field)
|
const value = form?.get(field)
|
||||||
@@ -88,21 +73,35 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
|||||||
if (typeof name !== "string" || !name) {
|
if (typeof name !== "string" || !name) {
|
||||||
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
||||||
}
|
}
|
||||||
const existing = get<{ id: number; file: string }>(
|
// 学生代码按这个名字引用素材(<base href> 指向素材目录),存储的文件名必须和它一致
|
||||||
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
if (name !== validFilename(name) || name.length > 100) {
|
||||||
|
throw new ValidationError([
|
||||||
|
{ loc: ["body", "name"], msg: "文件名只能包含字母、数字和 - _ .,最多 100 个字符" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const existing = get<{ file: string }>(
|
||||||
|
`SELECT file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
||||||
{ task_id: task.id, name },
|
{ task_id: task.id, name },
|
||||||
)
|
)
|
||||||
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
|
// 先写临时文件再 rename 覆盖:写失败时旧文件还在,并发上传同名文件也不会留下坏文件
|
||||||
const stored = await saveFile(
|
const stored = `tasks/${task.task_type}/${task.display}/${name}`
|
||||||
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
|
const full = mediaPath(stored)
|
||||||
file,
|
const tmp = `${full}.${crypto.randomUUID()}.uploading`
|
||||||
)
|
await mkdir(dirname(full), { recursive: true })
|
||||||
|
try {
|
||||||
|
await Bun.write(tmp, file)
|
||||||
|
await rename(tmp, full)
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { force: true })
|
||||||
|
}
|
||||||
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
||||||
run(
|
run(
|
||||||
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
||||||
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
||||||
{ name, file: stored, task_id: task.id },
|
{ name, file: stored, task_id: task.id },
|
||||||
)
|
)
|
||||||
|
// 旧数据里同名素材可能存在带随机后缀的文件
|
||||||
|
if (existing?.file && existing.file !== stored) await rm(mediaPath(existing.file), { force: true })
|
||||||
return c.json(assetOut({ name, file: stored }))
|
return c.json(assetOut({ name, file: stored }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.disp
|
|||||||
s.view_count, s.created, s.modified,
|
s.view_count, s.created, s.modified,
|
||||||
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
||||||
|
|
||||||
|
// +s.task_id:不让 SQLite 从 task_task 出发、用 task_id 单列索引去找提交(那样要回表读
|
||||||
|
// html/css/js 大行,6 万条提交时列表要几百毫秒),改走 slim 覆盖索引;按 task_id 筛选不受影响
|
||||||
const SUBMISSION_FROM = `FROM submission_submission s
|
const SUBMISSION_FROM = `FROM submission_submission s
|
||||||
JOIN account_user u ON u.id = s.user_id
|
JOIN account_user u ON u.id = s.user_id
|
||||||
JOIN task_task t ON t.id = s.task_id`
|
JOIN task_task t ON t.id = +s.task_id`
|
||||||
|
|
||||||
function submissionOut(s: any) {
|
function submissionOut(s: any) {
|
||||||
return {
|
return {
|
||||||
@@ -269,7 +271,8 @@ router.get("/", (c) => {
|
|||||||
score: "s.score ASC",
|
score: "s.score ASC",
|
||||||
"-created": "s.created DESC",
|
"-created": "s.created DESC",
|
||||||
}
|
}
|
||||||
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
|
// 同分、同一时间的提交很多,加上 rowid 让排序唯一,翻页才不会重复或漏掉
|
||||||
|
const orderBy = `${orderings[f.ordering ?? ""] ?? "s.created DESC"}, s.rowid DESC`
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
||||||
const { limit, offset } = pageWindow(f)
|
const { limit, offset } = pageWindow(f)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { run } from "../db"
|
import { all, run } from "../db"
|
||||||
|
|
||||||
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
||||||
// 调用方负责包在事务里。
|
// 调用方负责包在事务里。
|
||||||
|
|
||||||
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
||||||
const ids = `SELECT id FROM submission_submission WHERE ${where}`
|
// 先查出 id 再逐个按等值删:prompt_message.submission_id 绝大多数是 NULL,
|
||||||
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
|
// 统计信息会让 SQLite 觉得这个索引没用,写成 IN (子查询) 时它会扫整张大表(几百毫秒)
|
||||||
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
|
const ids = all<{ id: string }>(`SELECT id FROM submission_submission WHERE ${where}`, params)
|
||||||
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
|
for (const { id } of ids) {
|
||||||
|
run(`DELETE FROM submission_rating WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM submission_submissionaward WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
|
}
|
||||||
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test"
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { get, all } from "../src/db"
|
import { get, all } from "../src/db"
|
||||||
|
import { app } from "../src/app"
|
||||||
|
import { config } from "../src/config"
|
||||||
|
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
||||||
|
|
||||||
beforeEach(freshDb)
|
beforeEach(freshDb)
|
||||||
@@ -373,3 +378,40 @@ describe("自由创作", () => {
|
|||||||
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("任务素材", () => {
|
||||||
|
test("同名上传原地覆盖,文件名不合法 422", async () => {
|
||||||
|
const media = await mkdtemp(join(tmpdir(), "media-"))
|
||||||
|
const saved = config.mediaRoot
|
||||||
|
config.mediaRoot = media
|
||||||
|
try {
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
makeTask("挑战", "challenge", 3)
|
||||||
|
const adm = await client("adm")
|
||||||
|
const upload = (name: string, body: string) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", new File([body], "x"))
|
||||||
|
return app.request("/api/assets/challenge/3", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${adm.tokens.access_token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect((await upload("a.css", "v1")).status).toBe(200)
|
||||||
|
const res = await upload("a.css", "v2")
|
||||||
|
expect(((await res.json()) as any).url).toBe("/media/tasks/challenge/3/a.css")
|
||||||
|
const dir = join(media, "tasks/challenge/3")
|
||||||
|
expect(await readdir(dir)).toEqual(["a.css"])
|
||||||
|
expect(await readFile(join(dir, "a.css"), "utf8")).toBe("v2")
|
||||||
|
for (const bad of ["a b.png", "../x.png", "a/b.png", "x#.png"]) {
|
||||||
|
expect((await upload(bad, "x")).status).toBe(422)
|
||||||
|
}
|
||||||
|
expect((await adm("DELETE", "/api/assets/challenge/3/a.css")).status).toBe(200)
|
||||||
|
expect(await readdir(dir)).toEqual([])
|
||||||
|
} finally {
|
||||||
|
config.mediaRoot = saved
|
||||||
|
await rm(media, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user