第 2 阶段:账号与班级,作品保存在服务器
- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品 - 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数 - 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8, 识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码, 导出带密码的名单 - 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存 - 前端加路由:登录页、实验页、班级管理页 - 部署:TEACHER_PASSWORD 必填,数据库放在数据卷 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00622be15
commit
7cec273d58
31 files changed
+1776
-382
No files matched your search
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# 复制为 .env 后修改。.env 不进 git。
|
# 复制为 .env 后修改。.env 不进 git。
|
||||||
# 学生进入平台要输入的口令,换口令后所有人要重新输入
|
# 老师账号的密码(账号默认 admin,可用 TEACHER_USERNAME 修改)
|
||||||
ACCESS_PASSWORD=请改成不容易猜到的口令
|
TEACHER_PASSWORD=请改成不容易猜到的密码
|
||||||
# 平台最多使用的内存,默认 4g
|
# 平台最多使用的内存,默认 4g
|
||||||
# APP_MEM_LIMIT=4g
|
# APP_MEM_LIMIT=4g
|
||||||
@@ -19,3 +19,6 @@ obj/
|
|||||||
|
|
||||||
# 部署配置(含访问口令)
|
# 部署配置(含访问口令)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# 本机开发的数据库
|
||||||
|
/data/
|
||||||
@@ -70,7 +70,8 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
|||||||
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、访问口令、Docker Compose 部署脚本。
|
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
|
||||||
|
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上。
|
||||||
|
|
||||||
尚未完成:
|
尚未完成:
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"monaco-editor": "^0.57.0",
|
"monaco-editor": "^0.57.0",
|
||||||
"naive-ui": "^2.41.0",
|
"naive-ui": "^2.41.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -195,6 +196,8 @@
|
|||||||
|
|
||||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||||
|
|
||||||
|
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||||
|
|
||||||
"@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/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=="],
|
||||||
@@ -221,7 +224,7 @@
|
|||||||
|
|
||||||
"date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmjs.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
"date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmjs.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
||||||
|
|
||||||
"dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
"dompurify": ["dompurify@3.4.15", "https://registry.npmjs.com/dompurify/-/dompurify-3.4.15.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
||||||
|
|
||||||
"entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
@@ -245,9 +248,9 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"monaco-editor": ["monaco-editor@0.57.0", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||||
|
|
||||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
@@ -287,6 +290,8 @@
|
|||||||
|
|
||||||
"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-router": ["vue-router@4.6.4", "https://registry.npmjs.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,4 @@
|
|||||||
# 部署:先在 .env 里写好 ACCESS_PASSWORD(参考 .env.example),再 docker compose up -d
|
# 部署:先在 .env 里写好 TEACHER_PASSWORD(参考 .env.example),再 docker compose up -d
|
||||||
# HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md
|
# HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -6,16 +6,22 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
ACCESS_PASSWORD: ${ACCESS_PASSWORD:?请在 .env 里设置 ACCESS_PASSWORD}
|
# 老师账号(只有一个);学生账号由老师在网页上导入名单
|
||||||
|
TEACHER_USERNAME: ${TEACHER_USERNAME:-admin}
|
||||||
|
TEACHER_PASSWORD: ${TEACHER_PASSWORD:?请在 .env 里设置 TEACHER_PASSWORD}
|
||||||
|
DATA_DIR: /data
|
||||||
# 以下可在 .env 里调整,说明见 deploy/README.md
|
# 以下可在 .env 里调整,说明见 deploy/README.md
|
||||||
SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150}
|
SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150}
|
||||||
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||||
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||||
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||||
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)可写
|
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=1g
|
- /tmp:size=1g
|
||||||
|
# 数据库(账号、班级、学生作品),重新部署不会丢
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -47,6 +53,7 @@ services:
|
|||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -35,7 +35,7 @@
|
|||||||
```bash
|
```bash
|
||||||
git clone git@git.xuyue.cc:xuyue/IOT_SIM.git ~/IOT_SIM
|
git clone git@git.xuyue.cc:xuyue/IOT_SIM.git ~/IOT_SIM
|
||||||
cd ~/IOT_SIM
|
cd ~/IOT_SIM
|
||||||
echo 'ACCESS_PASSWORD=换成你的口令' > .env
|
echo 'TEACHER_PASSWORD=换成你的密码' > .env
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,7 +61,9 @@
|
|||||||
- **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上)
|
- **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上)
|
||||||
- SSL:申请证书,打开 Force SSL
|
- SSL:申请证书,打开 Force SSL
|
||||||
|
|
||||||
5. 浏览器打开域名,输入口令进入。
|
5. 浏览器打开域名,用老师账号登录,在"班级管理"里建班、导入学生名单。
|
||||||
|
|
||||||
|
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
|
||||||
|
|
||||||
## 自动部署(GitHub Actions)
|
## 自动部署(GitHub Actions)
|
||||||
|
|
||||||
@@ -96,7 +98,8 @@
|
|||||||
| 做什么 | 命令 |
|
| 做什么 | 命令 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
||||||
| 换口令 | 修改 `.env` 后 `docker compose up -d`(所有人要重新输入口令) |
|
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||||
|
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||||
| 看日志 | `docker compose logs -f app` |
|
| 看日志 | `docker compose logs -f app` |
|
||||||
| 看资源占用 | `docker stats` |
|
| 看资源占用 | `docker stats` |
|
||||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||||
@@ -107,7 +110,8 @@
|
|||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ACCESS_PASSWORD` | (必填) | 访问口令 |
|
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||||
|
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||||
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
||||||
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
||||||
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ main() {
|
|||||||
local ref=${1:-origin/main}
|
local ref=${1:-origin/main}
|
||||||
|
|
||||||
if [[ ! -f .env ]]; then
|
if [[ ! -f .env ]]; then
|
||||||
echo '还没有 .env:cp .env.example .env 后设置 ACCESS_PASSWORD'
|
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -21,6 +21,9 @@
|
|||||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||||
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
||||||
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
||||||
|
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
|
||||||
|
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
|
||||||
|
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
|
||||||
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
||||||
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
||||||
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
||||||
@@ -158,7 +161,7 @@ IOT/
|
|||||||
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||||
| 后端 | 运行时 | Bun |
|
| 后端 | 运行时 | Bun |
|
||||||
| 后端 | Web 框架 | Hono |
|
| 后端 | Web 框架 | Hono |
|
||||||
| 后端 | 数据库 | 计划:SQLite(`bun:sqlite`)+ Drizzle ORM |
|
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
||||||
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||||
| 后端 | 参数校验 | 计划:Zod |
|
| 后端 | 参数校验 | 计划:Zod |
|
||||||
| C# 运行时 | 平台 | .NET 10 LTS |
|
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||||
@@ -181,12 +184,12 @@ IOT/
|
|||||||
|
|
||||||
在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。
|
在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。
|
||||||
|
|
||||||
**安全措施**(1、2 已完成;3 在第 2 阶段之前先用访问口令代替)
|
**安全措施**(已完成)
|
||||||
|
|
||||||
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
||||||
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
||||||
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
||||||
3. **账号管控**:不开放自由注册,由老师用班级码或批量导入创建账号(第 2 阶段)。在此之前,全站用一个访问口令(`.env` 里设置,换口令后所有人要重新输入),按 IP 限制输错次数。
|
3. **账号管控**:不开放注册,老师上传名单批量创建学生账号;所有接口和运行程序的 WebSocket 都要先登录;按 IP 限制输错次数。会话令牌数据库里只存哈希。学生密码由老师发放、老师可查看,按原样保存(有意的取舍)。
|
||||||
|
|
||||||
**资源估算**
|
**资源估算**
|
||||||
|
|
||||||
@@ -230,8 +233,8 @@ IOT/
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、访问口令、Docker Compose)✔"]
|
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||||
P2["阶段 2 · 教学闭环(约 2 个月)<br/>班级码登录、提交作业、自动评测、老师看板、成绩导出;一个班试用"]
|
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔<br/>布置与提交作业、自动评测、老师看板、成绩导出;一个班试用"]
|
||||||
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||||
@@ -265,6 +268,10 @@ flowchart TB
|
|||||||
- 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。
|
- 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。
|
||||||
- 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。
|
- 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。
|
||||||
|
|
||||||
|
**第 2 阶段进展**:
|
||||||
|
|
||||||
|
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||||
|
|
||||||
## 技术风险与待确认事项
|
## 技术风险与待确认事项
|
||||||
|
|
||||||
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||||
@@ -280,7 +287,7 @@ flowchart TB
|
|||||||
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
|
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
|
||||||
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
||||||
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
||||||
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查(已完成)+ 访问口令,第 2 阶段换成账号 |
|
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
|
||||||
| ICP 备案 | 办理需要数周 | 已备案 |
|
| ICP 备案 | 办理需要数周 | 已备案 |
|
||||||
|
|
||||||
**待确认**
|
**待确认**
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
// 访问口令:账号系统(第 2 阶段)做好之前,用一个统一口令挡住外人。
|
|
||||||
// 输对口令后发一个 cookie,值是口令的哈希;老师换口令后,旧 cookie 全部失效。
|
|
||||||
import type { Context } from 'hono'
|
|
||||||
import { setCookie } from 'hono/cookie'
|
|
||||||
import { config } from './config'
|
|
||||||
|
|
||||||
const COOKIE = 'iot_access'
|
|
||||||
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
|
|
||||||
const MAX_FAILURES = 30
|
|
||||||
const FAILURE_WINDOW_MS = 10 * 60_000
|
|
||||||
|
|
||||||
const token = config.accessPassword
|
|
||||||
? new Bun.CryptoHasher('sha256').update(`iot-sim-access:${config.accessPassword}`).digest('hex')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
export const accessRequired = token !== ''
|
|
||||||
|
|
||||||
/** 请求是否带着有效的口令 cookie(没设口令时总是放行) */
|
|
||||||
export function hasAccess(cookieHeader: string | null) {
|
|
||||||
if (!accessRequired) return true
|
|
||||||
const value = cookieHeader
|
|
||||||
?.split(';')
|
|
||||||
.map((part) => part.trim().split('='))
|
|
||||||
.find(([name]) => name === COOKIE)?.[1]
|
|
||||||
return value === token
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 每个 IP 一段时间内的失败次数,防止暴力猜口令 */
|
|
||||||
const failures = new Map<string, { count: number; since: number }>()
|
|
||||||
|
|
||||||
export function tooManyFailures(ip: string) {
|
|
||||||
const entry = failures.get(ip)
|
|
||||||
if (!entry) return false
|
|
||||||
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
|
|
||||||
failures.delete(ip)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return entry.count >= MAX_FAILURES
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 检查口令;对了就设置 cookie */
|
|
||||||
export function login(c: Context, password: string, ip: string) {
|
|
||||||
if (password !== config.accessPassword) {
|
|
||||||
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
|
|
||||||
entry.count++
|
|
||||||
failures.set(ip, entry)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
failures.delete(ip)
|
|
||||||
setCookie(c, COOKIE, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'Lax',
|
|
||||||
secure: new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 180 * 24 * 3600,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。
|
||||||
|
import type { Database } from 'bun:sqlite'
|
||||||
|
import { Hono, type Context } from 'hono'
|
||||||
|
import { deleteCookie, setCookie } from 'hono/cookie'
|
||||||
|
import { login, logout, sessionMaxAge, SESSION_COOKIE, sessionToken, sessionUser, tooManyFailures, type User } from './auth'
|
||||||
|
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
|
||||||
|
import { listSamples, loadSample } from './samples'
|
||||||
|
import { serveStatic } from './static'
|
||||||
|
import { deleteWork, getWork, saveWork, validFiles } from './works'
|
||||||
|
|
||||||
|
/** Bun.serve 的 server 对象(测试里直接调用 app.fetch 时没有) */
|
||||||
|
interface Env {
|
||||||
|
requestIP?: (req: Request) => { address: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppContext = Context<{ Bindings: Env; Variables: { user: User } }>
|
||||||
|
|
||||||
|
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
|
||||||
|
function clientIp(c: AppContext) {
|
||||||
|
return c.req.header('x-real-ip') || c.env?.requestIP?.(c.req.raw)?.address || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHttps = (c: AppContext) => new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https'
|
||||||
|
|
||||||
|
/** 读取 JSON 请求体,格式不对时返回空对象 */
|
||||||
|
const body = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
|
||||||
|
|
||||||
|
export function createApp(db: Database) {
|
||||||
|
const app = new Hono<{ Bindings: Env; Variables: { user: User } }>()
|
||||||
|
|
||||||
|
app.post('/api/login', async (c) => {
|
||||||
|
const ip = clientIp(c)
|
||||||
|
if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429)
|
||||||
|
const { username, password } = await body<{ username: string; password: string }>(c)
|
||||||
|
const token = login(db, String(username ?? ''), String(password ?? ''), ip)
|
||||||
|
if (!token) return c.json({ message: '账号或密码不对' }, 401)
|
||||||
|
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: isHttps(c), path: '/', maxAge: sessionMaxAge })
|
||||||
|
return c.json({ user: sessionUser(db, token) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/logout', (c) => {
|
||||||
|
const token = sessionToken(c.req.header('cookie'))
|
||||||
|
if (token) logout(db, token)
|
||||||
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||||
|
return c.json({})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 其余接口都要先登录
|
||||||
|
app.use('/api/*', async (c, next) => {
|
||||||
|
const user = sessionUser(db, sessionToken(c.req.header('cookie')))
|
||||||
|
if (!user) return c.json({ message: '请先登录' }, 401)
|
||||||
|
c.set('user', user)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/me', (c) => c.json({ user: c.get('user') }))
|
||||||
|
|
||||||
|
app.get('/api/samples', async (c) => c.json(await listSamples()))
|
||||||
|
|
||||||
|
app.get('/api/samples/:id', async (c) => {
|
||||||
|
const files = await loadSample(c.req.param('id'))
|
||||||
|
return files ? c.json(files) : c.notFound()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自己的作品:key 如 "sample:p2-t1-LED显示屏"
|
||||||
|
app.get('/api/works/:key', (c) => c.json(getWork(db, c.get('user').id, c.req.param('key'))))
|
||||||
|
|
||||||
|
app.put('/api/works/:key', async (c) => {
|
||||||
|
const { files } = await body<{ files: unknown }>(c)
|
||||||
|
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
|
||||||
|
return c.json({ updatedAt: saveWork(db, c.get('user').id, c.req.param('key'), files) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/works/:key', (c) => {
|
||||||
|
deleteWork(db, c.get('user').id, c.req.param('key'))
|
||||||
|
return c.json({})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 老师 ----
|
||||||
|
app.use('/api/teacher/*', async (c, next) => {
|
||||||
|
if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = (c: AppContext) => Number(c.req.param('id'))
|
||||||
|
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
|
||||||
|
|
||||||
|
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
|
||||||
|
|
||||||
|
app.post('/api/teacher/classes', async (c) => {
|
||||||
|
const { name } = await body<{ name: string }>(c)
|
||||||
|
const result = createClass(db, String(name ?? ''))
|
||||||
|
return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/teacher/classes/:id', async (c) => {
|
||||||
|
const { name } = await body<{ name: string }>(c)
|
||||||
|
return reply(c, renameClass(db, id(c), String(name ?? '')))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/teacher/classes/:id', (c) => reply(c, deleteClass(db, id(c))))
|
||||||
|
|
||||||
|
app.get('/api/teacher/classes/:id/students', (c) => c.json(listStudents(db, id(c))))
|
||||||
|
|
||||||
|
app.post('/api/teacher/classes/:id/import', async (c) => {
|
||||||
|
const { rows } = await body<{ rows: ImportRow[] }>(c)
|
||||||
|
if (!Array.isArray(rows) || rows.some((r) => typeof r?.username !== 'string' || typeof r?.name !== 'string')) {
|
||||||
|
return c.json({ message: '名单格式不对' }, 400)
|
||||||
|
}
|
||||||
|
if (!listClasses(db).some((cls) => cls.id === id(c))) return c.json({ message: '班级不存在' }, 404)
|
||||||
|
return c.json(importStudents(db, id(c), rows))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/teacher/students/:id/reset-password', (c) => {
|
||||||
|
const password = resetPassword(db, id(c))
|
||||||
|
return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/teacher/students/:id', (c) => {
|
||||||
|
deleteStudent(db, id(c))
|
||||||
|
return c.json({})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404))
|
||||||
|
|
||||||
|
// 打包好的网页(部署时);开发时网页由 Vite 提供
|
||||||
|
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// 登录与会话:账号密码登录后发一个 HttpOnly cookie,数据库只存令牌的哈希。
|
||||||
|
// 老师账号只有一个,账号密码来自配置;学生账号由老师导入名单创建。
|
||||||
|
import type { Database } from 'bun:sqlite'
|
||||||
|
import { config } from './config'
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'iot_session'
|
||||||
|
const SESSION_DAYS = 180
|
||||||
|
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
|
||||||
|
const MAX_FAILURES = 30
|
||||||
|
const FAILURE_WINDOW_MS = 10 * 60_000
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
role: 'student' | 'teacher'
|
||||||
|
classId: number | null
|
||||||
|
className: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
|
||||||
|
|
||||||
|
let warnedDefaultPassword = false
|
||||||
|
|
||||||
|
/** 服务器启动时按配置创建或更新老师账号 */
|
||||||
|
export function ensureTeacher(db: Database) {
|
||||||
|
let password = config.teacherPassword
|
||||||
|
if (!password) {
|
||||||
|
password = 'admin'
|
||||||
|
if (!warnedDefaultPassword) console.warn(`没有设置 TEACHER_PASSWORD,老师账号为 ${config.teacherUsername} / admin(只适合本机开发)`)
|
||||||
|
warnedDefaultPassword = true
|
||||||
|
}
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO users (username, name, password, role, created_at) VALUES (?, '老师', ?, 'teacher', ?)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET password = excluded.password, role = 'teacher', class_id = NULL`,
|
||||||
|
).run(config.teacherUsername, password, Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */
|
||||||
|
const failures = new Map<string, { count: number; since: number }>()
|
||||||
|
|
||||||
|
export function tooManyFailures(ip: string) {
|
||||||
|
const entry = failures.get(ip)
|
||||||
|
if (!entry) return false
|
||||||
|
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
|
||||||
|
failures.delete(ip)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return entry.count >= MAX_FAILURES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号密码正确时创建会话,返回令牌(放进 cookie);不正确返回 null */
|
||||||
|
export function login(db: Database, username: string, password: string, ip: string): string | null {
|
||||||
|
const row = db.query<{ id: number; password: string }, [string]>('SELECT id, password FROM users WHERE username = ?').get(username.trim())
|
||||||
|
if (!row || row.password !== password) {
|
||||||
|
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
|
||||||
|
entry.count++
|
||||||
|
failures.set(ip, entry)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
failures.delete(ip)
|
||||||
|
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')
|
||||||
|
db.query('INSERT INTO sessions (token_hash, user_id, created_at) VALUES (?, ?, ?)').run(hash(token), row.id, Date.now())
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(db: Database, token: string) {
|
||||||
|
db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除某个用户的所有会话(重置密码、删除学生时,让他已登录的浏览器失效) */
|
||||||
|
export function endSessions(db: Database, userId: number) {
|
||||||
|
db.query('DELETE FROM sessions WHERE user_id = ?').run(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Cookie 请求头取出令牌 */
|
||||||
|
export function sessionToken(cookieHeader: string | null | undefined) {
|
||||||
|
return cookieHeader
|
||||||
|
?.split(';')
|
||||||
|
.map((part) => part.trim().split('='))
|
||||||
|
.find(([name]) => name === SESSION_COOKIE)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 令牌对应的用户;没登录或会话已过期返回 null */
|
||||||
|
export function sessionUser(db: Database, token: string | undefined): User | null {
|
||||||
|
if (!token) return null
|
||||||
|
const row = db
|
||||||
|
.query<
|
||||||
|
{ id: number; username: string; name: string; role: User['role']; class_id: number | null; class_name: string | null; created_at: number },
|
||||||
|
[string]
|
||||||
|
>(
|
||||||
|
`SELECT u.id, u.username, u.name, u.role, u.class_id, c.name AS class_name, s.created_at
|
||||||
|
FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN classes c ON c.id = u.class_id
|
||||||
|
WHERE s.token_hash = ?`,
|
||||||
|
)
|
||||||
|
.get(hash(token))
|
||||||
|
if (!row) return null
|
||||||
|
if (Date.now() - row.created_at > SESSION_DAYS * 86_400_000) {
|
||||||
|
logout(db, token)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { id: row.id, username: row.username, name: row.name, role: row.role, classId: row.class_id, className: row.class_name }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionMaxAge = SESSION_DAYS * 86_400
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// 班级和学生名单(老师用)。学生账号由老师上传名单导入,密码由老师发放,学生不能自己改。
|
||||||
|
import type { Database } from 'bun:sqlite'
|
||||||
|
import { endSessions } from './auth'
|
||||||
|
|
||||||
|
export interface ClassInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
studentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Student {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRow {
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
/** 有问题的行(行号从 1 开始,对应上传的名单),这些行没有导入 */
|
||||||
|
errors: { row: number; message: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台生成的初始密码:6 位数字,学生好输入;老师在名单里能看到 */
|
||||||
|
export function generatePassword() {
|
||||||
|
return String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listClasses(db: Database): ClassInfo[] {
|
||||||
|
return db
|
||||||
|
.query<ClassInfo, []>(
|
||||||
|
`SELECT c.id, c.name, COUNT(u.id) AS studentCount
|
||||||
|
FROM classes c LEFT JOIN users u ON u.class_id = c.id
|
||||||
|
GROUP BY c.id ORDER BY c.name`,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建班级;名字为空或重名时返回错误信息 */
|
||||||
|
export function createClass(db: Database, name: string): { id: number } | { error: string } {
|
||||||
|
name = name.trim()
|
||||||
|
if (!name) return { error: '班级名不能为空' }
|
||||||
|
if (db.query('SELECT 1 FROM classes WHERE name = ?').get(name)) return { error: `已经有叫"${name}"的班级` }
|
||||||
|
const result = db.query('INSERT INTO classes (name, created_at) VALUES (?, ?)').run(name, Date.now())
|
||||||
|
return { id: Number(result.lastInsertRowid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameClass(db: Database, id: number, name: string): { error?: string } {
|
||||||
|
name = name.trim()
|
||||||
|
if (!name) return { error: '班级名不能为空' }
|
||||||
|
if (db.query('SELECT 1 FROM classes WHERE name = ? AND id <> ?').get(name, id)) return { error: `已经有叫"${name}"的班级` }
|
||||||
|
db.query('UPDATE classes SET name = ? WHERE id = ?').run(name, id)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只能删除没有学生的班级,避免误删全班的作品 */
|
||||||
|
export function deleteClass(db: Database, id: number): { error?: string } {
|
||||||
|
if (db.query('SELECT 1 FROM users WHERE class_id = ?').get(id)) return { error: '班级里还有学生,先删除学生再删除班级' }
|
||||||
|
db.query('DELETE FROM classes WHERE id = ?').run(id)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStudents(db: Database, classId: number): Student[] {
|
||||||
|
return db
|
||||||
|
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
|
||||||
|
.all(classId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入名单到一个班:账号不存在就新建,已存在就更新姓名、班级(以及填了的密码)。
|
||||||
|
* 密码留空时:新学生生成 6 位数字密码,老学生保持原密码。
|
||||||
|
*/
|
||||||
|
export function importStudents(db: Database, classId: number, rows: ImportRow[]): ImportResult {
|
||||||
|
const result: ImportResult = { created: 0, updated: 0, errors: [] }
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const find = db.query<{ id: number; role: string }, [string]>('SELECT id, role FROM users WHERE username = ?')
|
||||||
|
const insert = db.query(`INSERT INTO users (username, name, password, role, class_id, created_at) VALUES (?, ?, ?, 'student', ?, ?)`)
|
||||||
|
const update = db.query('UPDATE users SET name = ?, class_id = ?, password = COALESCE(?, password) WHERE id = ?')
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
const fail = (message: string) => result.errors.push({ row: index + 1, message })
|
||||||
|
const username = row.username.trim()
|
||||||
|
const name = row.name.trim()
|
||||||
|
const password = row.password?.trim() || undefined
|
||||||
|
if (!username) {
|
||||||
|
fail('账号为空')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/\s/.test(username)) {
|
||||||
|
fail(`账号"${username}"里有空格`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
fail(`账号"${username}"的姓名为空`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (seen.has(username)) {
|
||||||
|
fail(`账号"${username}"在名单里重复`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(username)
|
||||||
|
|
||||||
|
const existing = find.get(username)
|
||||||
|
if (existing?.role === 'teacher') {
|
||||||
|
fail(`"${username}"是老师账号`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
update.run(name, classId, password ?? null, existing.id)
|
||||||
|
if (password) endSessions(db, existing.id)
|
||||||
|
result.updated++
|
||||||
|
} else {
|
||||||
|
insert.run(username, name, password ?? generatePassword(), classId, Date.now())
|
||||||
|
result.created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置为新的 6 位数字密码,学生已登录的浏览器需要重新登录 */
|
||||||
|
export function resetPassword(db: Database, studentId: number): string | null {
|
||||||
|
const password = generatePassword()
|
||||||
|
const changed = db.query(`UPDATE users SET password = ? WHERE id = ? AND role = 'student'`).run(password, studentId).changes
|
||||||
|
if (!changed) return null
|
||||||
|
endSessions(db, studentId)
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除学生,连同他的作品和登录会话 */
|
||||||
|
export function deleteStudent(db: Database, studentId: number) {
|
||||||
|
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
|
||||||
|
}
|
||||||
@@ -28,7 +28,10 @@ export const config = {
|
|||||||
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
|
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
|
||||||
/** 一次运行最长多少分钟,到时自动结束 */
|
/** 一次运行最长多少分钟,到时自动结束 */
|
||||||
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
|
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
|
||||||
/** 访问口令;不设置表示不需要口令(本机开发) */
|
/** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */
|
||||||
accessPassword: env.ACCESS_PASSWORD ?? '',
|
dataDir: resolve(ROOT, env.DATA_DIR ?? 'data'),
|
||||||
|
/** 老师账号(只有一个)。开发时不设置密码就用 admin / admin */
|
||||||
|
teacherUsername: env.TEACHER_USERNAME ?? 'admin',
|
||||||
|
teacherPassword: env.TEACHER_PASSWORD ?? '',
|
||||||
timeZone: env.TZ ?? 'Asia/Shanghai',
|
timeZone: env.TZ ?? 'Asia/Shanghai',
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// SQLite 数据库:账号、班级、登录会话、学生作品。
|
||||||
|
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
|
||||||
|
import { Database } from 'bun:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { config } from './config'
|
||||||
|
|
||||||
|
const MIGRATIONS = [
|
||||||
|
`
|
||||||
|
CREATE TABLE classes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
-- 学生的密码由老师发放、老师可以查看,所以按原样保存(学生不能自己改)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('student', 'teacher')),
|
||||||
|
class_id INTEGER REFERENCES classes (id),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX users_class ON users (class_id);
|
||||||
|
-- 只保存令牌的哈希,数据库泄露也拿不到可用的登录令牌
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
-- 学生在某个实验上的代码(自动保存),key 如 "sample:p2-t1-LED显示屏"
|
||||||
|
CREATE TABLE works (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
files TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
|
||||||
|
export function openDatabase(path = join(config.dataDir, 'iot-sim.db')) {
|
||||||
|
if (path !== ':memory:') mkdirSync(config.dataDir, { recursive: true })
|
||||||
|
const db = new Database(path, { create: true, strict: true })
|
||||||
|
db.run('PRAGMA journal_mode = WAL')
|
||||||
|
db.run('PRAGMA foreign_keys = ON')
|
||||||
|
db.run('PRAGMA busy_timeout = 5000')
|
||||||
|
const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version
|
||||||
|
for (let i = version; i < MIGRATIONS.length; i++) {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.run(MIGRATIONS[i])
|
||||||
|
db.run(`PRAGMA user_version = ${i + 1}`)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
+7
-38
@@ -1,50 +1,19 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
import type { Server } from 'bun'
|
|
||||||
import type { ClientMessage, ServerMessage } from '@iot/shared'
|
import type { ClientMessage, ServerMessage } from '@iot/shared'
|
||||||
import { accessRequired, hasAccess, login, tooManyFailures } from './access'
|
import { createApp } from './app'
|
||||||
|
import { ensureTeacher, sessionToken, sessionUser } from './auth'
|
||||||
import { compileService } from './compile-service'
|
import { compileService } from './compile-service'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
|
import { openDatabase } from './db'
|
||||||
import { cleanupStaleRuns, RunSession } from './run-session'
|
import { cleanupStaleRuns, RunSession } from './run-session'
|
||||||
import { listSamples, loadSample } from './samples'
|
|
||||||
import { checkSandbox, sandboxError } from './sandbox'
|
import { checkSandbox, sandboxError } from './sandbox'
|
||||||
import { serveStatic } from './static'
|
|
||||||
|
|
||||||
await cleanupStaleRuns()
|
await cleanupStaleRuns()
|
||||||
await checkSandbox()
|
await checkSandbox()
|
||||||
if (sandboxError) console.error(sandboxError)
|
if (sandboxError) console.error(sandboxError)
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Server<SocketData> }>()
|
const db = openDatabase()
|
||||||
|
ensureTeacher(db)
|
||||||
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
|
const app = createApp(db)
|
||||||
function clientIp(req: Request, server: Server<SocketData>) {
|
|
||||||
return req.headers.get('x-real-ip') || server.requestIP(req)?.address || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/api/auth', (c) => c.json({ required: accessRequired, ok: hasAccess(c.req.header('cookie') ?? null) }))
|
|
||||||
|
|
||||||
app.post('/api/login', async (c) => {
|
|
||||||
const ip = clientIp(c.req.raw, c.env)
|
|
||||||
if (tooManyFailures(ip)) return c.json({ ok: false, message: '输错次数太多,请 10 分钟后再试' }, 429)
|
|
||||||
const { password } = await c.req.json<{ password?: string }>().catch(() => ({ password: undefined }))
|
|
||||||
if (!login(c, String(password ?? ''), ip)) return c.json({ ok: false, message: '口令不对' }, 401)
|
|
||||||
return c.json({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// 其余接口都要先输入口令
|
|
||||||
app.use('/api/*', async (c, next) => {
|
|
||||||
if (!hasAccess(c.req.header('cookie') ?? null)) return c.json({ message: '请先输入访问口令' }, 401)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/samples', async (c) => c.json(await listSamples()))
|
|
||||||
|
|
||||||
app.get('/api/samples/:id', async (c) => {
|
|
||||||
const files = await loadSample(c.req.param('id'))
|
|
||||||
return files ? c.json(files) : c.notFound()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打包好的网页(部署时);开发时网页由 Vite 提供
|
|
||||||
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
|
|
||||||
|
|
||||||
interface SocketData {
|
interface SocketData {
|
||||||
session?: RunSession
|
session?: RunSession
|
||||||
@@ -54,7 +23,7 @@ const server = Bun.serve({
|
|||||||
port: config.port,
|
port: config.port,
|
||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
if (new URL(req.url).pathname === '/ws') {
|
if (new URL(req.url).pathname === '/ws') {
|
||||||
if (!hasAccess(req.headers.get('cookie'))) return new Response('请先输入访问口令', { status: 401 })
|
if (!sessionUser(db, sessionToken(req.headers.get('cookie')))) return new Response('请先登录', { status: 401 })
|
||||||
if (server.upgrade(req, { data: {} })) return
|
if (server.upgrade(req, { data: {} })) return
|
||||||
return new Response('WebSocket 升级失败', { status: 400 })
|
return new Response('WebSocket 升级失败', { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 学生作品:每个人在每个实验上的代码,浏览器改动后自动保存,换电脑登录也能接着做。
|
||||||
|
import type { Database } from 'bun:sqlite'
|
||||||
|
import type { SourceFile } from '@iot/shared'
|
||||||
|
|
||||||
|
/** 一份作品最大字节数(全部文件内容合计),防止误传大文件 */
|
||||||
|
export const MAX_WORK_BYTES = 1_000_000
|
||||||
|
|
||||||
|
export function getWork(db: Database, userId: number, key: string): { files: SourceFile[]; updatedAt: number } | null {
|
||||||
|
const row = db
|
||||||
|
.query<{ files: string; updated_at: number }, [number, string]>('SELECT files, updated_at FROM works WHERE user_id = ? AND key = ?')
|
||||||
|
.get(userId, key)
|
||||||
|
return row ? { files: JSON.parse(row.files) as SourceFile[], updatedAt: row.updated_at } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWork(db: Database, userId: number, key: string, files: SourceFile[]) {
|
||||||
|
const updatedAt = Date.now()
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO works (user_id, key, files, updated_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, key) DO UPDATE SET files = excluded.files, updated_at = excluded.updated_at`,
|
||||||
|
).run(userId, key, JSON.stringify(files), updatedAt)
|
||||||
|
return updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除作品(学生点"恢复原始代码") */
|
||||||
|
export function deleteWork(db: Database, userId: number, key: string) {
|
||||||
|
db.query('DELETE FROM works WHERE user_id = ? AND key = ?').run(userId, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查浏览器传来的文件列表:路径和内容都是字符串,总大小不超限 */
|
||||||
|
export function validFiles(value: unknown): value is SourceFile[] {
|
||||||
|
if (!Array.isArray(value) || value.length > 100) return false
|
||||||
|
let bytes = 0
|
||||||
|
for (const file of value) {
|
||||||
|
if (typeof file?.path !== 'string' || typeof file?.content !== 'string') return false
|
||||||
|
bytes += file.path.length + file.content.length
|
||||||
|
}
|
||||||
|
return bytes <= MAX_WORK_BYTES
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
|
||||||
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { createApp } from '../src/app'
|
||||||
|
import { ensureTeacher } from '../src/auth'
|
||||||
|
import { config } from '../src/config'
|
||||||
|
import { openDatabase } from '../src/db'
|
||||||
|
|
||||||
|
let app: ReturnType<typeof createApp>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
ensureTeacher(db)
|
||||||
|
app = createApp(db)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 一个浏览器:记住登录 cookie */
|
||||||
|
class Browser {
|
||||||
|
cookie = ''
|
||||||
|
constructor(readonly ip = '10.0.0.1') {}
|
||||||
|
|
||||||
|
async request(method: string, path: string, json?: unknown) {
|
||||||
|
const headers: Record<string, string> = { 'x-real-ip': this.ip }
|
||||||
|
if (this.cookie) headers.cookie = this.cookie
|
||||||
|
if (json !== undefined) headers['content-type'] = 'application/json'
|
||||||
|
const response = await app.fetch(
|
||||||
|
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
|
||||||
|
)
|
||||||
|
const setCookie = response.headers.get('set-cookie')
|
||||||
|
if (setCookie) this.cookie = setCookie.split(';')[0]
|
||||||
|
return { status: response.status, body: (await response.json()) as any }
|
||||||
|
}
|
||||||
|
|
||||||
|
get = (path: string) => this.request('GET', path)
|
||||||
|
post = (path: string, json: unknown = {}) => this.request('POST', path, json)
|
||||||
|
put = (path: string, json: unknown) => this.request('PUT', path, json)
|
||||||
|
patch = (path: string, json: unknown) => this.request('PATCH', path, json)
|
||||||
|
delete = (path: string) => this.request('DELETE', path)
|
||||||
|
|
||||||
|
login(username: string, password: string) {
|
||||||
|
return this.post('/api/login', { username, password })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teacher() {
|
||||||
|
const browser = new Browser()
|
||||||
|
expect((await browser.login(config.teacherUsername, config.teacherPassword || 'admin')).status).toBe(200)
|
||||||
|
return browser
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 老师建一个班并导入名单,返回班级 id */
|
||||||
|
async function classWithStudents(t: Browser, rows: { username: string; name: string; password?: string }[]) {
|
||||||
|
const { body } = await t.post('/api/teacher/classes', { name: '物联网 2401' })
|
||||||
|
await t.post(`/api/teacher/classes/${body.id}/import`, { rows })
|
||||||
|
return body.id as number
|
||||||
|
}
|
||||||
|
|
||||||
|
test('没登录时接口返回 401', async () => {
|
||||||
|
const browser = new Browser()
|
||||||
|
expect((await browser.get('/api/me')).status).toBe(401)
|
||||||
|
expect((await browser.get('/api/samples')).status).toBe(401)
|
||||||
|
expect((await browser.get('/api/works/sample:x')).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('账号或密码不对时登录失败,老师登录后能看到自己的信息', async () => {
|
||||||
|
const browser = new Browser()
|
||||||
|
const wrong = await browser.login('admin', 'wrong')
|
||||||
|
expect(wrong.status).toBe(401)
|
||||||
|
expect(wrong.body.message).toBe('账号或密码不对')
|
||||||
|
|
||||||
|
const t = await teacher()
|
||||||
|
const me = await t.get('/api/me')
|
||||||
|
expect(me.body.user).toMatchObject({ username: config.teacherUsername, role: 'teacher' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('同一个 IP 输错 30 次后暂停登录,别的 IP 不受影响', async () => {
|
||||||
|
const attacker = new Browser('10.0.0.66')
|
||||||
|
for (let i = 0; i < 30; i++) await attacker.login('admin', `guess${i}`)
|
||||||
|
expect((await attacker.login('admin', 'whatever')).status).toBe(429)
|
||||||
|
expect((await new Browser('10.0.0.67').login('admin', 'whatever')).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('班级和名单', () => {
|
||||||
|
test('新建班级,不能重名', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
expect((await t.post('/api/teacher/classes', { name: '物联网 2401' })).status).toBe(200)
|
||||||
|
const duplicate = await t.post('/api/teacher/classes', { name: ' 物联网 2401 ' })
|
||||||
|
expect(duplicate.status).toBe(400)
|
||||||
|
expect(duplicate.body.message).toContain('已经有')
|
||||||
|
expect((await t.get('/api/teacher/classes')).body).toEqual([{ id: 1, name: '物联网 2401', studentCount: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入名单:新学生没填密码时生成 6 位数字,有问题的行单独报出来', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
const { body: cls } = await t.post('/api/teacher/classes', { name: '物联网 2401' })
|
||||||
|
const result = await t.post(`/api/teacher/classes/${cls.id}/import`, {
|
||||||
|
rows: [
|
||||||
|
{ username: '2024001', name: '张三', password: 'abc123' },
|
||||||
|
{ username: '2024002', name: '李四' },
|
||||||
|
{ username: '2024001', name: '张三重复' },
|
||||||
|
{ username: '2024003', name: '' },
|
||||||
|
{ username: '2024 004', name: '空格' },
|
||||||
|
{ username: 'admin', name: '冒充老师' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
created: 2,
|
||||||
|
updated: 0,
|
||||||
|
errors: [
|
||||||
|
{ row: 3, message: '账号"2024001"在名单里重复' },
|
||||||
|
{ row: 4, message: '账号"2024003"的姓名为空' },
|
||||||
|
{ row: 5, message: '账号"2024 004"里有空格' },
|
||||||
|
{ row: 6, message: '"admin"是老师账号' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const students = (await t.get(`/api/teacher/classes/${cls.id}/students`)).body
|
||||||
|
expect(students.map((s: any) => [s.username, s.name])).toEqual([
|
||||||
|
['2024001', '张三'],
|
||||||
|
['2024002', '李四'],
|
||||||
|
])
|
||||||
|
expect(students[0].password).toBe('abc123')
|
||||||
|
expect(students[1].password).toMatch(/^\d{6}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('再次导入:更新姓名;密码留空时保留原密码', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
const classId = await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }])
|
||||||
|
const result = await t.post(`/api/teacher/classes/${classId}/import`, { rows: [{ username: '2024001', name: '张叁' }] })
|
||||||
|
expect(result.body).toMatchObject({ created: 0, updated: 1 })
|
||||||
|
expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0]).toMatchObject({ name: '张叁', password: 'abc123' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('学生用老师发的密码登录,不能使用老师接口', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }])
|
||||||
|
const student = new Browser()
|
||||||
|
const login = await student.login('2024001', 'abc123')
|
||||||
|
expect(login.body.user).toMatchObject({ name: '张三', role: 'student', className: '物联网 2401' })
|
||||||
|
expect((await student.get('/api/teacher/classes')).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('重置密码后,学生已登录的浏览器要重新登录,新密码可以登录', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
const classId = await classWithStudents(t, [{ username: '2024001', name: '张三', password: 'abc123' }])
|
||||||
|
const student = new Browser()
|
||||||
|
await student.login('2024001', 'abc123')
|
||||||
|
const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body
|
||||||
|
const { body } = await t.post(`/api/teacher/students/${id}/reset-password`)
|
||||||
|
expect(body.password).toMatch(/^\d{6}$/)
|
||||||
|
expect((await student.get('/api/me')).status).toBe(401)
|
||||||
|
expect((await new Browser().login('2024001', 'abc123')).status).toBe(401)
|
||||||
|
expect((await new Browser().login('2024001', body.password)).status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('班级里有学生时不能删除;删除学生后可以删除班级', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
const classId = await classWithStudents(t, [{ username: '2024001', name: '张三' }])
|
||||||
|
expect((await t.delete(`/api/teacher/classes/${classId}`)).status).toBe(400)
|
||||||
|
const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body
|
||||||
|
await t.delete(`/api/teacher/students/${id}`)
|
||||||
|
expect((await t.delete(`/api/teacher/classes/${classId}`)).status).toBe(200)
|
||||||
|
expect((await t.get('/api/teacher/classes')).body).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('作品', () => {
|
||||||
|
const files = [{ path: 'Form1.cs', content: '// 我的代码' }]
|
||||||
|
|
||||||
|
test('保存后再读取;每个人只能看到自己的作品;恢复原始代码会删除作品', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
await classWithStudents(t, [
|
||||||
|
{ username: 's1', name: '甲', password: 'p1' },
|
||||||
|
{ username: 's2', name: '乙', password: 'p2' },
|
||||||
|
])
|
||||||
|
const s1 = new Browser()
|
||||||
|
await s1.login('s1', 'p1')
|
||||||
|
const key = encodeURIComponent('sample:p2-t1-LED显示屏')
|
||||||
|
expect((await s1.get(`/api/works/${key}`)).body).toBeNull()
|
||||||
|
expect((await s1.put(`/api/works/${key}`, { files })).status).toBe(200)
|
||||||
|
expect((await s1.get(`/api/works/${key}`)).body.files).toEqual(files)
|
||||||
|
|
||||||
|
const s2 = new Browser()
|
||||||
|
await s2.login('s2', 'p2')
|
||||||
|
expect((await s2.get(`/api/works/${key}`)).body).toBeNull()
|
||||||
|
|
||||||
|
await s1.delete(`/api/works/${key}`)
|
||||||
|
expect((await s1.get(`/api/works/${key}`)).body).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('格式不对或太大的作品拒绝保存', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
expect((await t.put('/api/works/x', { files: 'not-a-list' })).status).toBe(400)
|
||||||
|
expect((await t.put('/api/works/x', { files: [{ path: 'a.cs' }] })).status).toBe(400)
|
||||||
|
expect((await t.put('/api/works/x', { files: [{ path: 'a.cs', content: 'x'.repeat(1_000_001) }] })).status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('退出登录后会话失效', async () => {
|
||||||
|
const t = await teacher()
|
||||||
|
const cookie = t.cookie
|
||||||
|
await t.post('/api/logout')
|
||||||
|
t.cookie = cookie
|
||||||
|
expect((await t.get('/api/me')).status).toBe(401)
|
||||||
|
})
|
||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"@iot/shared": "workspace:*",
|
"@iot/shared": "workspace:*",
|
||||||
"monaco-editor": "^0.57.0",
|
"monaco-editor": "^0.57.0",
|
||||||
"naive-ui": "^2.41.0",
|
"naive-ui": "^2.41.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
// 访问口令页:输对后刷新页面进入平台(口令由服务器记在 cookie 里,之后不用再输)。
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { NButton, NConfigProvider, NInput, zhCN } from 'naive-ui'
|
|
||||||
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!password.value || busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password: password.value }),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
location.reload()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
error.value = ((await response.json().catch(() => ({}))) as { message?: string }).message ?? '登录失败'
|
|
||||||
} catch {
|
|
||||||
error.value = '连不上服务器,请稍后再试'
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NConfigProvider :locale="zhCN">
|
|
||||||
<div class="gate">
|
|
||||||
<form class="card" @submit.prevent="submit">
|
|
||||||
<h1>物联网编程仿真平台</h1>
|
|
||||||
<p>请输入老师给的访问口令</p>
|
|
||||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="访问口令" autofocus />
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!password">进入</NButton>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</NConfigProvider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.gate {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
padding: 28px 24px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NConfigProvider :locale="zhCN">
|
||||||
|
<NMessageProvider>
|
||||||
|
<NDialogProvider>
|
||||||
|
<RouterView />
|
||||||
|
</NDialogProvider>
|
||||||
|
</NMessageProvider>
|
||||||
|
</NConfigProvider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明;
|
||||||
|
// 登录过期(401)时回到登录页。
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录过期时的处理,由 router.ts 设置(避免这里依赖路由) */
|
||||||
|
export const apiHooks = { onUnauthorized: () => {} }
|
||||||
|
|
||||||
|
export async function api<T>(method: string, path: string, json?: unknown, init?: RequestInit): Promise<T> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: json === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
body: json === undefined ? undefined : JSON.stringify(json),
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('连不上服务器,请检查网络', 0)
|
||||||
|
}
|
||||||
|
const data = (await response.json().catch(() => ({}))) as T & { message?: string }
|
||||||
|
if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized()
|
||||||
|
if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 当前登录的用户。页面启动时先问一次服务器,之后登录、退出时更新。
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
role: 'student' | 'teacher'
|
||||||
|
classId: number | null
|
||||||
|
className: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentUser = ref<User | null>(null)
|
||||||
|
|
||||||
|
/** 页面启动时调用:已登录(cookie 有效)就取到用户 */
|
||||||
|
export async function loadCurrentUser() {
|
||||||
|
currentUser.value = await api<{ user: User }>('GET', '/api/me')
|
||||||
|
.then((r) => r.user)
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
currentUser.value = (await api<{ user: User }>('POST', '/api/login', { username, password })).user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await api('POST', '/api/logout').catch(() => {})
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// 学生改过的代码保存在浏览器本地(localStorage),刷新页面不丢失。
|
|
||||||
// 这只是方便功能:隐私窗口、清除网站数据等情况下可能读写失败,失败时当作没有草稿。
|
|
||||||
// 作业的正式保存以后放到服务器上。
|
|
||||||
import type { SourceFile } from '@iot/shared'
|
|
||||||
|
|
||||||
const PREFIX = 'iot-sim:draft:'
|
|
||||||
|
|
||||||
export function loadDraft(sampleId: string): SourceFile[] | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PREFIX + sampleId)
|
|
||||||
return raw ? (JSON.parse(raw) as SourceFile[]) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveDraft(sampleId: string, files: SourceFile[]) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PREFIX + sampleId, JSON.stringify(files))
|
|
||||||
} catch {
|
|
||||||
// 存储不可用或已满:忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDraft(sampleId: string) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(PREFIX + sampleId)
|
|
||||||
} catch {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 学生的作品保存在服务器上(按账号),换电脑登录也能接着做。
|
||||||
|
// key 标识是哪个实验,如 "sample:p2-t1-LED显示屏"。
|
||||||
|
import type { SourceFile } from '@iot/shared'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export const sampleWorkKey = (sampleId: string) => `sample:${sampleId}`
|
||||||
|
|
||||||
|
const url = (key: string) => `/api/works/${encodeURIComponent(key)}`
|
||||||
|
|
||||||
|
export async function loadWork(key: string): Promise<SourceFile[] | null> {
|
||||||
|
return (await api<{ files: SourceFile[] } | null>('GET', url(key)))?.files ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** keepalive:页面关闭时也把请求发出去 */
|
||||||
|
export async function saveWork(key: string, files: SourceFile[], keepalive = false) {
|
||||||
|
await api('PUT', url(key), { files }, { keepalive })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWork(key: string) {
|
||||||
|
await api('DELETE', url(key))
|
||||||
|
}
|
||||||
+6
-7
@@ -1,11 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import AccessGate from './AccessGate.vue'
|
import { loadCurrentUser } from './auth'
|
||||||
import App from './App.vue'
|
import Root from './Root.vue'
|
||||||
|
import { router } from './router'
|
||||||
import './winforms/winforms.css'
|
import './winforms/winforms.css'
|
||||||
|
|
||||||
// 服务器设置了访问口令而浏览器还没输过时,先显示口令页
|
// 先确认是否已登录,路由守卫据此决定进登录页还是实验页
|
||||||
const auth = await fetch('/api/auth')
|
await loadCurrentUser()
|
||||||
.then((response) => response.json() as Promise<{ ok: boolean }>)
|
|
||||||
.catch(() => ({ ok: true }))
|
|
||||||
|
|
||||||
createApp(auth.ok ? App : AccessGate).mount('#app')
|
createApp(Root).use(router).mount('#app')
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 登录页:学生用老师发的账号密码登录,老师用自己的账号登录。
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NInput } from 'naive-ui'
|
||||||
|
import { login } from '../auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!username.value || !password.value || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await login(username.value.trim(), password.value)
|
||||||
|
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/'
|
||||||
|
await router.replace(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gate">
|
||||||
|
<form class="card" @submit.prevent="submit">
|
||||||
|
<h1>物联网编程仿真平台</h1>
|
||||||
|
<p>用老师发给你的账号和密码登录</p>
|
||||||
|
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="密码"
|
||||||
|
:input-props="{ autocomplete: 'current-password' }"
|
||||||
|
/>
|
||||||
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生。
|
||||||
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { currentUser, logout } from '../auth'
|
||||||
|
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
|
||||||
|
|
||||||
|
interface ClassInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
studentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Student {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
errors: { row: number; message: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const classes = ref<ClassInfo[]>([])
|
||||||
|
const selectedId = ref<number>()
|
||||||
|
const selected = computed(() => classes.value.find((c) => c.id === selectedId.value))
|
||||||
|
const students = ref<Student[]>([])
|
||||||
|
|
||||||
|
/** 出错时弹出服务器给的说明 */
|
||||||
|
async function attempt<T>(action: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await action()
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClasses() {
|
||||||
|
classes.value = (await attempt(() => api<ClassInfo[]>('GET', '/api/teacher/classes'))) ?? []
|
||||||
|
if (!selected.value) selectedId.value = classes.value[0]?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
students.value = selectedId.value
|
||||||
|
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectClass(id: number) {
|
||||||
|
selectedId.value = id
|
||||||
|
await loadStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadClasses()
|
||||||
|
await loadStudents()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 班级 ----------
|
||||||
|
const newClassName = ref('')
|
||||||
|
const newClassOpen = ref(false)
|
||||||
|
|
||||||
|
async function createClass() {
|
||||||
|
const created = await attempt(() => api<{ id: number }>('POST', '/api/teacher/classes', { name: newClassName.value }))
|
||||||
|
if (!created) return
|
||||||
|
newClassName.value = ''
|
||||||
|
newClassOpen.value = false
|
||||||
|
await loadClasses()
|
||||||
|
await selectClass(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameValue = ref('')
|
||||||
|
const renameOpen = ref(false)
|
||||||
|
|
||||||
|
async function renameClass() {
|
||||||
|
if (!selected.value) return
|
||||||
|
const ok = await attempt(() => api('PATCH', `/api/teacher/classes/${selected.value!.id}`, { name: renameValue.value }))
|
||||||
|
if (!ok) return
|
||||||
|
renameOpen.value = false
|
||||||
|
await loadClasses()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClass() {
|
||||||
|
if (!selected.value) return
|
||||||
|
const ok = await attempt(() => api('DELETE', `/api/teacher/classes/${selected.value!.id}`))
|
||||||
|
if (!ok) return
|
||||||
|
selectedId.value = undefined
|
||||||
|
await loadClasses()
|
||||||
|
await loadStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 学生 ----------
|
||||||
|
async function resetPassword(student: Student) {
|
||||||
|
const result = await attempt(() => api<{ password: string }>('POST', `/api/teacher/students/${student.id}/reset-password`))
|
||||||
|
if (!result) return
|
||||||
|
message.success(`${student.name} 的新密码:${result.password}`)
|
||||||
|
await loadStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteStudent(student: Student) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除学生',
|
||||||
|
content: `将删除 ${student.name}(${student.username})和他保存的所有作品,不能恢复。确定删除吗?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await attempt(() => api('DELETE', `/api/teacher/students/${student.id}`))
|
||||||
|
await loadClasses()
|
||||||
|
await loadStudents()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<Student> = [
|
||||||
|
{ title: '账号', key: 'username', sorter: 'default' },
|
||||||
|
{ title: '姓名', key: 'name' },
|
||||||
|
{ title: '密码', key: 'password', render: (s) => h('code', s.password) },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 180,
|
||||||
|
render: (s) =>
|
||||||
|
h('div', { class: 'row-actions' }, [
|
||||||
|
h(NButton, { size: 'tiny', onClick: () => resetPassword(s) }, () => '重置密码'),
|
||||||
|
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteStudent(s) }, () => '删除'),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------- 导入、导出名单 ----------
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
const preview = ref<RosterRow[] | null>(null)
|
||||||
|
const previewValid = computed(() => preview.value?.filter((r) => !r.problem) ?? [])
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
|
async function onFileChosen(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
const rows = toRoster(parseCsv(decodeCsv(await file.arrayBuffer())))
|
||||||
|
if (rows.length === 0) {
|
||||||
|
message.warning('文件里没有读到学生')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport() {
|
||||||
|
if (!selected.value || previewValid.value.length === 0) return
|
||||||
|
importing.value = true
|
||||||
|
const rows = previewValid.value.map(({ username, name, password }) => ({ username, name, password }))
|
||||||
|
const result = await attempt(() => api<ImportResult>('POST', `/api/teacher/classes/${selected.value!.id}/import`, { rows }))
|
||||||
|
importing.value = false
|
||||||
|
if (!result) return
|
||||||
|
preview.value = null
|
||||||
|
await loadClasses()
|
||||||
|
await loadStudents()
|
||||||
|
const summary = `新增 ${result.created} 人,更新 ${result.updated} 人`
|
||||||
|
if (result.errors.length === 0) message.success(summary)
|
||||||
|
else
|
||||||
|
dialog.warning({
|
||||||
|
title: '部分学生没有导入',
|
||||||
|
content: () =>
|
||||||
|
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
||||||
|
positiveText: '知道了',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载 CSV:带 BOM,Excel 打开时中文不乱码;账号前加制表符,长学号不会变成科学计数法 */
|
||||||
|
function downloadCsv(filename: string, rows: string[][]) {
|
||||||
|
const quote = (cell: string) => (/[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell)
|
||||||
|
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRoster() {
|
||||||
|
if (!selected.value) return
|
||||||
|
downloadCsv(`${selected.value.name}-名单.csv`, [
|
||||||
|
['学号', '姓名', '密码'],
|
||||||
|
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
downloadCsv('学生名单模板.csv', [
|
||||||
|
['学号', '姓名', '密码'],
|
||||||
|
['\t2024001', '张三', ''],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout()
|
||||||
|
await router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="toolbar">
|
||||||
|
<strong>物联网编程仿真平台</strong>
|
||||||
|
<span class="title">班级管理</span>
|
||||||
|
<RouterLink to="/" class="nav-link">返回实验</RouterLink>
|
||||||
|
<span class="spacer" />
|
||||||
|
<span class="user">{{ currentUser?.name }}</span>
|
||||||
|
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<aside class="classes">
|
||||||
|
<div class="classes-head">
|
||||||
|
<span>班级</span>
|
||||||
|
<NPopover v-model:show="newClassOpen" trigger="click" placement="bottom-end">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small" quaternary>+ 新建</NButton>
|
||||||
|
</template>
|
||||||
|
<form class="popover-form" @submit.prevent="createClass">
|
||||||
|
<NInput v-model:value="newClassName" size="small" placeholder="班级名,如 物联网 2401" autofocus />
|
||||||
|
<NButton size="small" type="primary" attr-type="submit" :disabled="!newClassName.trim()">创建</NButton>
|
||||||
|
</form>
|
||||||
|
</NPopover>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="c in classes"
|
||||||
|
:key="c.id"
|
||||||
|
class="class-item"
|
||||||
|
:class="{ active: c.id === selectedId }"
|
||||||
|
@click="selectClass(c.id)"
|
||||||
|
>
|
||||||
|
<span>{{ c.name }}</span>
|
||||||
|
<span class="count">{{ c.studentCount }} 人</span>
|
||||||
|
</button>
|
||||||
|
<NEmpty v-if="classes.length === 0" description="还没有班级,先新建一个" size="small" class="empty" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section v-if="selected" class="students">
|
||||||
|
<div class="students-head">
|
||||||
|
<h2>{{ selected.name }}</h2>
|
||||||
|
<NPopover v-model:show="renameOpen" trigger="click" @update:show="(v: boolean) => v && (renameValue = selected!.name)">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small" quaternary>改名</NButton>
|
||||||
|
</template>
|
||||||
|
<form class="popover-form" @submit.prevent="renameClass">
|
||||||
|
<NInput v-model:value="renameValue" size="small" autofocus />
|
||||||
|
<NButton size="small" type="primary" attr-type="submit" :disabled="!renameValue.trim()">保存</NButton>
|
||||||
|
</form>
|
||||||
|
</NPopover>
|
||||||
|
<NPopconfirm positive-text="删除" negative-text="取消" @positive-click="deleteClass">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small" quaternary>删除班级</NButton>
|
||||||
|
</template>
|
||||||
|
只能删除没有学生的班级。确定删除"{{ selected.name }}"吗?
|
||||||
|
</NPopconfirm>
|
||||||
|
<span class="spacer" />
|
||||||
|
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
|
||||||
|
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
|
||||||
|
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
|
||||||
|
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||||
|
</div>
|
||||||
|
<p class="tip">
|
||||||
|
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||||
|
</p>
|
||||||
|
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||||
|
</section>
|
||||||
|
<section v-else class="students">
|
||||||
|
<NEmpty description="选择或新建一个班级" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<NModal
|
||||||
|
:show="preview !== null"
|
||||||
|
preset="card"
|
||||||
|
:title="`导入到 ${selected?.name ?? ''}`"
|
||||||
|
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||||
|
@update:show="(v: boolean) => !v && (preview = null)"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
|
||||||
|
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
||||||
|
>。
|
||||||
|
</p>
|
||||||
|
<div class="preview">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
|
||||||
|
<td>{{ r.username }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ r.password || '(自动生成)' }}</td>
|
||||||
|
<td class="problem">{{ r.problem }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<NButton @click="preview = null">取消</NButton>
|
||||||
|
<NButton type="primary" :loading="importing" :disabled="previewValid.length === 0" @click="confirmImport">
|
||||||
|
导入 {{ previewValid.length }} 人
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4px 6px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item.active {
|
||||||
|
background: #e0ecff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.students {
|
||||||
|
padding: 12px 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.students-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.students-head h2 {
|
||||||
|
margin: 0 4px 0 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.row-actions) {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview th,
|
||||||
|
.preview td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview td.problem {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview tr.bad td {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { NButton, NConfigProvider, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag } from 'naive-ui'
|
||||||
import type { SourceFile } from '@iot/shared'
|
import type { SourceFile } from '@iot/shared'
|
||||||
import FormDesigner from './designer/FormDesigner.vue'
|
import FormDesigner from '../designer/FormDesigner.vue'
|
||||||
import { createDesignerHistory } from './designer/history'
|
import { createDesignerHistory } from '../designer/history'
|
||||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './designer/templates'
|
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||||
import CodeEditor from './editor/CodeEditor.vue'
|
import CodeEditor from '../editor/CodeEditor.vue'
|
||||||
import { clearDraft, loadDraft, saveDraft } from './editor/drafts'
|
import { api } from '../api'
|
||||||
import { createRunSession } from './run/session'
|
import { currentUser, logout } from '../auth'
|
||||||
import TrainingBoard from './scene/TrainingBoard.vue'
|
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||||
import FormWindow from './winforms/FormWindow.vue'
|
import { createRunSession } from '../run/session'
|
||||||
import MessageBoxDialog from './winforms/MessageBoxDialog.vue'
|
import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||||
|
import FormWindow from '../winforms/FormWindow.vue'
|
||||||
|
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||||
|
|
||||||
const session = createRunSession()
|
const session = createRunSession()
|
||||||
const { state } = session
|
const { state } = session
|
||||||
@@ -51,11 +54,12 @@ const fileText = (path: string) => files.value.find((f) => f.path === path)?.con
|
|||||||
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
|
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
|
||||||
|
|
||||||
// 开发模式下暴露页面状态,供浏览器自动化测试读取
|
// 开发模式下暴露页面状态,供浏览器自动化测试读取
|
||||||
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab } })
|
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab, updateFile } })
|
||||||
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
|
/** 当前代码是否和示例原始代码不同(服务器上有这个人的作品) */
|
||||||
const modified = ref(false)
|
const modified = ref(false)
|
||||||
const editorRef = ref<InstanceType<typeof CodeEditor>>()
|
const editorRef = ref<InstanceType<typeof CodeEditor>>()
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
||||||
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
|
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
|
||||||
@@ -79,26 +83,68 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
||||||
void session.connect()
|
void session.connect()
|
||||||
const list: { id: string; title: string }[] = await (await fetch('/api/samples')).json()
|
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||||
const requested = new URLSearchParams(location.search).get('sample')
|
const initial = list.find((s) => s.id === route.query.sample) ?? list[0]
|
||||||
const initial = list.find((s) => s.id === requested) ?? list[0]
|
|
||||||
if (initial) await loadSample(initial.id)
|
if (initial) await loadSample(initial.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadSample(id: string, discardDraft = false) {
|
async function loadSample(id: string, discardWork = false) {
|
||||||
|
await flushSave()
|
||||||
sampleId.value = id
|
sampleId.value = id
|
||||||
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
|
void router.replace({ query: { sample: id } })
|
||||||
if (discardDraft) clearDraft(id)
|
|
||||||
designerHistory.clear()
|
designerHistory.clear()
|
||||||
const draft = discardDraft ? null : loadDraft(id)
|
const key = sampleWorkKey(id)
|
||||||
modified.value = draft !== null
|
if (discardWork) await deleteWork(key).catch(() => {})
|
||||||
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
|
const work = discardWork ? null : await loadWork(key).catch(() => null)
|
||||||
|
modified.value = work !== null
|
||||||
|
saveState.value = work ? 'saved' : 'idle'
|
||||||
|
files.value = work ?? (await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`))
|
||||||
// 和 VS 一样,默认打开主窗体的设计器
|
// 和 VS 一样,默认打开主窗体的设计器
|
||||||
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 自动保存到服务器 ----------
|
||||||
|
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
||||||
|
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
|
const SAVE_TEXT = { idle: '', pending: '未保存', saving: '保存中…', saved: '已保存', error: '保存失败,请检查网络' } as const
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
saveState.value = 'pending'
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => void flushSave(), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即保存还没保存的改动(切换示例、离开页面之前) */
|
||||||
|
async function flushSave(keepalive = false) {
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
if (saveState.value !== 'pending' || !sampleId.value) return
|
||||||
|
saveState.value = 'saving'
|
||||||
|
const snapshot = files.value.map((f) => ({ ...f }))
|
||||||
|
try {
|
||||||
|
await saveWork(sampleWorkKey(sampleId.value), snapshot, keepalive)
|
||||||
|
// 保存期间又有改动时保持 pending,等下一次保存
|
||||||
|
if (saveState.value === 'saving') saveState.value = 'saved'
|
||||||
|
} catch {
|
||||||
|
saveState.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageHide = () => void flushSave(true)
|
||||||
|
onMounted(() => window.addEventListener('pagehide', onPageHide))
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('pagehide', onPageHide)
|
||||||
|
void flushSave()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await flushSave()
|
||||||
|
await logout()
|
||||||
|
await router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
function updateFile(path: string, content: string) {
|
function updateFile(path: string, content: string) {
|
||||||
const file = files.value.find((f) => f.path === path)
|
const file = files.value.find((f) => f.path === path)
|
||||||
if (!file || file.content === content) return
|
if (!file || file.content === content) return
|
||||||
@@ -108,8 +154,7 @@ function updateFile(path: string, content: string) {
|
|||||||
|
|
||||||
function filesChanged() {
|
function filesChanged() {
|
||||||
modified.value = true
|
modified.value = true
|
||||||
clearTimeout(saveTimer)
|
scheduleSave()
|
||||||
saveTimer = setTimeout(() => sampleId.value && saveDraft(sampleId.value, files.value), 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 新建、删除窗体 ----------
|
// ---------- 新建、删除窗体 ----------
|
||||||
@@ -197,124 +242,121 @@ function run() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NConfigProvider :locale="zhCN">
|
<div class="page">
|
||||||
<div class="page">
|
<header class="toolbar">
|
||||||
<header class="toolbar">
|
<strong>物联网编程仿真平台</strong>
|
||||||
<strong>物联网编程仿真平台</strong>
|
<NSelect
|
||||||
<NSelect
|
class="sample-select"
|
||||||
class="sample-select"
|
:value="sampleId"
|
||||||
:value="sampleId"
|
:options="samples"
|
||||||
:options="samples"
|
placeholder="选择示例工程"
|
||||||
placeholder="选择示例工程"
|
@update:value="loadSample"
|
||||||
@update:value="loadSample"
|
/>
|
||||||
/>
|
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||||
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
<template #trigger>
|
||||||
<template #trigger>
|
<NButton quaternary size="small">恢复原始代码</NButton>
|
||||||
<NButton quaternary size="small">恢复原始代码</NButton>
|
</template>
|
||||||
</template>
|
你对这个示例的修改将全部丢弃,确定恢复吗?
|
||||||
你对这个示例的修改将全部丢弃,确定恢复吗?
|
</NPopconfirm>
|
||||||
</NPopconfirm>
|
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||||
<span class="metrics">
|
<span class="metrics">
|
||||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||||
{{ state.metrics.lastRoundTripMs }} ms
|
{{ state.metrics.lastRoundTripMs }} ms
|
||||||
</span>
|
</span>
|
||||||
</header>
|
<span class="user">
|
||||||
|
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||||
|
</span>
|
||||||
|
<RouterLink v-if="currentUser?.role === 'teacher'" to="/teacher" class="nav-link">班级管理</RouterLink>
|
||||||
|
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="workspace" :class="{ designing: activeForm }">
|
<main class="workspace" :class="{ designing: activeForm }">
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
<div class="tab-row">
|
<div class="tab-row">
|
||||||
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
|
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
|
||||||
<NTab v-for="tab in tabs" :key="tab.name" :name="tab.name">{{ tab.label }}</NTab>
|
<NTab v-for="tab in tabs" :key="tab.name" :name="tab.name">{{ tab.label }}</NTab>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
<NPopover trigger="click" placement="bottom-end" :show="newFormOpen" @update:show="openNewForm">
|
<NPopover trigger="click" placement="bottom-end" :show="newFormOpen" @update:show="openNewForm">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton size="small" quaternary>+ 新建窗体</NButton>
|
<NButton size="small" quaternary>+ 新建窗体</NButton>
|
||||||
</template>
|
</template>
|
||||||
<div class="new-form">
|
<div class="new-form">
|
||||||
<div>窗体名(也是类名和文件名)</div>
|
<div>窗体名(也是类名和文件名)</div>
|
||||||
<NInput v-model:value="newFormName" size="small" @keydown.enter="createForm" />
|
<NInput v-model:value="newFormName" size="small" @keydown.enter="createForm" />
|
||||||
<div v-if="newFormError" class="new-form-error">{{ newFormError }}</div>
|
<div v-if="newFormError" class="new-form-error">{{ newFormError }}</div>
|
||||||
<NButton size="small" type="primary" :disabled="!!newFormError" @click="createForm">创建</NButton>
|
<NButton size="small" type="primary" :disabled="!!newFormError" @click="createForm">创建</NButton>
|
||||||
</div>
|
|
||||||
</NPopover>
|
|
||||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton size="small" quaternary>删除窗体</NButton>
|
|
||||||
</template>
|
|
||||||
将删除 {{ deletableForm.code }} 和 {{ deletableForm.designer }},代码里用到这个窗体的地方需要自己修改。确定删除吗?
|
|
||||||
</NPopconfirm>
|
|
||||||
</div>
|
|
||||||
<FormDesigner
|
|
||||||
v-if="activeForm"
|
|
||||||
class="code"
|
|
||||||
:designer-text="fileText(activeForm.designer)"
|
|
||||||
:code-text="fileText(activeForm.code)"
|
|
||||||
:can-undo="designerHistory.canUndo(activeForm.designer)"
|
|
||||||
:can-redo="designerHistory.canRedo(activeForm.designer)"
|
|
||||||
@update="onDesignerUpdate"
|
|
||||||
@undo="designerHistory.undo(activeForm.designer, activeForm.code)"
|
|
||||||
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
|
|
||||||
@open-code="openCode"
|
|
||||||
/>
|
|
||||||
<CodeEditor
|
|
||||||
v-show="!activeForm"
|
|
||||||
ref="editorRef"
|
|
||||||
class="code"
|
|
||||||
:files="files"
|
|
||||||
:active-file="activeFile"
|
|
||||||
:diagnostics="state.diagnostics"
|
|
||||||
@change="updateFile"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="run-area">
|
|
||||||
<div class="desktop">
|
|
||||||
<FormWindow
|
|
||||||
v-for="(form, index) in state.forms"
|
|
||||||
:key="form.root.id"
|
|
||||||
:form="form.root"
|
|
||||||
:modal="form.modal"
|
|
||||||
:offset="index"
|
|
||||||
:act="session.act"
|
|
||||||
/>
|
|
||||||
<MessageBoxDialog
|
|
||||||
v-for="box in state.messageBoxes"
|
|
||||||
:key="box.requestId"
|
|
||||||
:box="box"
|
|
||||||
@reply="session.replyMessageBox(box.requestId, $event)"
|
|
||||||
/>
|
|
||||||
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
|
||||||
</div>
|
|
||||||
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
|
||||||
<div class="output">
|
|
||||||
<div
|
|
||||||
v-for="(d, i) in state.diagnostics"
|
|
||||||
:key="`d${i}`"
|
|
||||||
:class="['diag', d.severity]"
|
|
||||||
title="点击跳到出错的代码"
|
|
||||||
@click="showDiagnostic(d.file, d.line, d.column)"
|
|
||||||
>
|
|
||||||
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
|
|
||||||
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<pre v-for="(e, i) in state.errors" :key="`e${i}`" class="error">{{ e }}</pre>
|
</NPopover>
|
||||||
<pre v-if="state.console" class="console">{{ state.console }}</pre>
|
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||||
</div>
|
<template #trigger>
|
||||||
</section>
|
<NButton size="small" quaternary>删除窗体</NButton>
|
||||||
</main>
|
</template>
|
||||||
</div>
|
将删除 {{ deletableForm.code }} 和 {{ deletableForm.designer }},代码里用到这个窗体的地方需要自己修改。确定删除吗?
|
||||||
</NConfigProvider>
|
</NPopconfirm>
|
||||||
</template>
|
</div>
|
||||||
|
<FormDesigner
|
||||||
|
v-if="activeForm"
|
||||||
|
class="code"
|
||||||
|
:designer-text="fileText(activeForm.designer)"
|
||||||
|
:code-text="fileText(activeForm.code)"
|
||||||
|
:can-undo="designerHistory.canUndo(activeForm.designer)"
|
||||||
|
:can-redo="designerHistory.canRedo(activeForm.designer)"
|
||||||
|
@update="onDesignerUpdate"
|
||||||
|
@undo="designerHistory.undo(activeForm.designer, activeForm.code)"
|
||||||
|
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
|
||||||
|
@open-code="openCode"
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
v-show="!activeForm"
|
||||||
|
ref="editorRef"
|
||||||
|
class="code"
|
||||||
|
:files="files"
|
||||||
|
:active-file="activeFile"
|
||||||
|
:diagnostics="state.diagnostics"
|
||||||
|
@change="updateFile"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<section class="run-area">
|
||||||
body {
|
<div class="desktop">
|
||||||
margin: 0;
|
<FormWindow
|
||||||
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
v-for="(form, index) in state.forms"
|
||||||
}
|
:key="form.root.id"
|
||||||
</style>
|
:form="form.root"
|
||||||
|
:modal="form.modal"
|
||||||
|
:offset="index"
|
||||||
|
:act="session.act"
|
||||||
|
/>
|
||||||
|
<MessageBoxDialog
|
||||||
|
v-for="box in state.messageBoxes"
|
||||||
|
:key="box.requestId"
|
||||||
|
:box="box"
|
||||||
|
@reply="session.replyMessageBox(box.requestId, $event)"
|
||||||
|
/>
|
||||||
|
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||||
|
</div>
|
||||||
|
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
||||||
|
<div class="output">
|
||||||
|
<div
|
||||||
|
v-for="(d, i) in state.diagnostics"
|
||||||
|
:key="`d${i}`"
|
||||||
|
:class="['diag', d.severity]"
|
||||||
|
title="点击跳到出错的代码"
|
||||||
|
@click="showDiagnostic(d.file, d.line, d.column)"
|
||||||
|
>
|
||||||
|
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
|
||||||
|
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
|
||||||
|
</div>
|
||||||
|
<pre v-for="(e, i) in state.errors" :key="`e${i}`" class="error">{{ e }}</pre>
|
||||||
|
<pre v-if="state.console" class="console">{{ state.console }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
@@ -341,6 +383,27 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-state {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-state.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 页面路由:登录页、实验页(学生和老师都用)、老师的班级管理页。
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { apiHooks } from './api'
|
||||||
|
import { currentUser } from './auth'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
|
||||||
|
{ path: '/', component: () => import('./pages/WorkbenchPage.vue') },
|
||||||
|
{ path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } },
|
||||||
|
{ path: '/:rest(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.public) return currentUser.value && to.path === '/login' ? '/' : true
|
||||||
|
if (!currentUser.value) return { path: '/login', query: to.fullPath === '/' ? {} : { redirect: to.fullPath } }
|
||||||
|
if (to.meta.teacher && currentUser.value.role !== 'teacher') return '/'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登录过期(比如老师重置了密码):回到登录页
|
||||||
|
apiHooks.onUnauthorized = () => {
|
||||||
|
currentUser.value = null
|
||||||
|
if (router.currentRoute.value.path !== '/login') void router.push('/login')
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { decodeCsv, parseCsv, toRoster } from './roster'
|
||||||
|
|
||||||
|
describe('decodeCsv', () => {
|
||||||
|
test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => {
|
||||||
|
const bytes = new TextEncoder().encode('学号,姓名\n2024001,张三\n')
|
||||||
|
expect(decodeCsv(bytes.buffer)).toBe('学号,姓名\n2024001,张三\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GBK(中文 Windows 上 Excel 的"CSV(逗号分隔)")', () => {
|
||||||
|
// "学号,姓名" 的 GBK 编码
|
||||||
|
const gbk = new Uint8Array([0xd1, 0xa7, 0xba, 0xc5, 0x2c, 0xd0, 0xd5, 0xc3, 0xfb])
|
||||||
|
expect(decodeCsv(gbk.buffer)).toBe('学号,姓名')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseCsv', () => {
|
||||||
|
test('引号里的逗号、换行和两个双引号;CRLF 换行;忽略空行', () => {
|
||||||
|
expect(parseCsv('a,"b,c","d ""e""\nf"\r\n\r\n1,2,3\r\n,,\r\n')).toEqual([
|
||||||
|
['a', 'b,c', 'd "e"\nf'],
|
||||||
|
['1', '2', '3'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toRoster', () => {
|
||||||
|
test('有表头时按表头找列,列的顺序可以不同', () => {
|
||||||
|
expect(toRoster(parseCsv('姓名,学号,密码\n张三,2024001,abc\n李四,2024002,\n'))).toEqual([
|
||||||
|
{ username: '2024001', name: '张三', password: 'abc' },
|
||||||
|
{ username: '2024002', name: '李四', password: '' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有表头时按"账号、姓名、密码"的顺序', () => {
|
||||||
|
expect(toRoster(parseCsv('2024001,张三\n'))).toEqual([{ username: '2024001', name: '张三', password: '' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('科学计数法的学号、空账号、空姓名标出问题', () => {
|
||||||
|
const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n'))
|
||||||
|
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '账号为空', '姓名为空'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出:
|
||||||
|
// 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持;
|
||||||
|
// 第一行可能是表头(学号、姓名、密码),没有表头时按"账号、姓名、密码"的顺序读。
|
||||||
|
|
||||||
|
export interface RosterRow {
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
/** 这一行的问题;有问题的行不导入 */
|
||||||
|
problem?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件内容转成文字:先按 UTF-8 解码,不是合法的 UTF-8 就按 GBK(GB18030)解码 */
|
||||||
|
export function decodeCsv(bytes: ArrayBuffer): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^/, '')
|
||||||
|
} catch {
|
||||||
|
return new TextDecoder('gb18030').decode(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 CSV 规则拆成表格:支持双引号包起来的字段(里面可以有逗号、换行、两个双引号表示一个) */
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const table: string[][] = []
|
||||||
|
let row: string[] = []
|
||||||
|
let field = ''
|
||||||
|
let quoted = false
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (quoted) {
|
||||||
|
if (ch === '"' && text[i + 1] === '"') {
|
||||||
|
field += '"'
|
||||||
|
i++
|
||||||
|
} else if (ch === '"') {
|
||||||
|
quoted = false
|
||||||
|
} else {
|
||||||
|
field += ch
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
quoted = true
|
||||||
|
} else if (ch === ',') {
|
||||||
|
row.push(field)
|
||||||
|
field = ''
|
||||||
|
} else if (ch === '\n' || ch === '\r') {
|
||||||
|
if (ch === '\r' && text[i + 1] === '\n') i++
|
||||||
|
row.push(field)
|
||||||
|
table.push(row)
|
||||||
|
row = []
|
||||||
|
field = ''
|
||||||
|
} else {
|
||||||
|
field += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field || row.length) {
|
||||||
|
row.push(field)
|
||||||
|
table.push(row)
|
||||||
|
}
|
||||||
|
// 去掉空行(Excel 导出的文件常在末尾多出空行)
|
||||||
|
return table.filter((r) => r.some((cell) => cell.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
username: ['账号', '帐号', '用户名', '学号', 'username', 'account'],
|
||||||
|
name: ['姓名', '名字', '真实姓名', 'name'],
|
||||||
|
password: ['密码', 'password'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格转成名单:识别表头,检查每行的问题 */
|
||||||
|
export function toRoster(table: string[][]): RosterRow[] {
|
||||||
|
if (table.length === 0) return []
|
||||||
|
const header = table[0].map((cell) => cell.trim().toLowerCase())
|
||||||
|
const column = (aliases: string[]) => header.findIndex((cell) => aliases.includes(cell))
|
||||||
|
const hasHeader = column(HEADERS.username) >= 0 && column(HEADERS.name) >= 0
|
||||||
|
const index = hasHeader
|
||||||
|
? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) }
|
||||||
|
: { username: 0, name: 1, password: 2 }
|
||||||
|
|
||||||
|
return table.slice(hasHeader ? 1 : 0).map((cells) => {
|
||||||
|
const cell = (i: number) => (i >= 0 ? (cells[i] ?? '').trim() : '')
|
||||||
|
const row: RosterRow = { username: cell(index.username), name: cell(index.name), password: cell(index.password) }
|
||||||
|
// Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去
|
||||||
|
if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) {
|
||||||
|
row.problem = '账号变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
|
||||||
|
} else if (!row.username) {
|
||||||
|
row.problem = '账号为空'
|
||||||
|
} else if (!row.name) {
|
||||||
|
row.problem = '姓名为空'
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user