Compare commits

..
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 c217eb5ca0 素材图片在高分辨率屏幕上清晰显示;项目资源对话框点整行勾选
CI/CD / deploy (push) Canceled after 0s
CI/CD / test (push) Canceled after 0s
- 素材库每张图另外渲染一份两倍尺寸(name.2x.png),build-gallery.sh 把图片编号(原尺寸 PNG 的
  SHA-1,和运行器给图片的编号相同)和两倍图写进 index.json
- 网页认出素材图片(按编号或素材网址),图片框用 srcset、背景用 image-set,系统缩放 150%、200%
  的屏幕上不再发虚;学生程序读到的仍是原尺寸图片,宽高、评分都不变
- 素材清单 index.json 每次向服务器确认(不再缓存一小时),部署新素材后立即生效
- 项目资源对话框:点整行就能勾选(原来只能点复选框,"试听"不会勾选);缩略图用灰色棋盘格底,
  透明背景上的白色图片(ico_title)也看得见

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:49:34 +08:00
xuyueandClaude Opus 5.5 f304fc1ffc 窄屏工具栏不换行;数据库页面提示更新
- 屏幕宽度不到 1600 时不显示编译、往返时间(给开发看的),平台名、保存状态、链接不换行,
  1366 宽的机房电脑上工具栏是一行
- 数据库页面的提示:运行要用数据库的实验时自动建库,"恢复实验初始数据"用来恢复成初始数据

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:44:04 +08:00
xuyueandClaude Opus 5.5 fe62d50881 设计大窗体:运行区可收起、设计器缩放;工具箱补图标
- 编辑区和运行区之间的竖条点一下收起运行区,代码和设计器占满宽度;点"运行"自动展开,浏览器记住选择
- 设计器缩放:适应宽度(默认,只缩小不放大)、100%、75%、50%;缩放时拖动、改大小、
  双击、拖入控件的坐标按比例换算(教材项目 8 的窗体有 1043×791)
- 工具箱给列表框、图片框、日期时间选择器、滑动条、数据表格、图表、媒体播放器补上图标

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:42:14 +08:00
xuyueandClaude Opus 5.5 f256b313f7 实验页"评价项"和自测:提交前自己检查,不计分
- 工具栏"评价项"打开侧栏,列出自动检查的功能项(作业显示布置时的规则和各项分值)
- "自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和原因;不计分、不留记录,
  自由练习和作业都能用;每人同时一次,结果保留到下一次,之后改过代码提示结果可能过时;
  和提交评分共用全站并发上限
- 实验的评价项接口学生也能用(老师布置作业的对话框改用它)
- 修正:运行区的窗体和"放大"按钮会盖住网页的抽屉和对话框

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:35:25 +08:00
44 changed files with 574 additions and 73 deletions

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 - 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
尚未完成: 尚未完成:
+1
View File
@@ -315,6 +315,7 @@ flowchart TB
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。 - 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
## 技术风险与待确认事项 ## 技术风险与待确认事项
+1 -1
View File
@@ -2,6 +2,6 @@
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。 学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型)。 - `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型;图片还有编号 key 和两倍图 hidpi,由 `build-gallery.sh` 写入)。`name.png` 是原尺寸,学生程序读到的就是它;`name.2x.png` 只给高分辨率屏幕的浏览器显示用,宽高仍按原尺寸。
- `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。 - `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。 - `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
@@ -1,6 +1,23 @@
#!/bin/sh #!/bin/sh
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG(尺寸取 SVG 自己的 width / height)。需要 rsvg-convert 和思源黑体等中文字体。 # 把 svg/ 下的素材渲染成 gallery/ 下的 PNG:name.png 是原尺寸(学生程序读到的图片,宽高和教材一致),
# name.2x.png 是两倍尺寸(只给高分辨率屏幕的浏览器显示用)。然后把每张图的编号(原尺寸 PNG 的 SHA-1,
# 和运行器给图片的编号相同)和两倍图的文件名写进 gallery/index.json。需要 rsvg-convert 和思源黑体等中文字体。
cd "$(dirname "$0")" cd "$(dirname "$0")"
for f in svg/*.svg; do for f in svg/*.svg; do
rsvg-convert "$f" -o "gallery/$(basename "$f" .svg).png" name=$(basename "$f" .svg)
rsvg-convert "$f" -o "gallery/$name.png"
rsvg-convert -z 2 "$f" -o "gallery/$name.2x.png"
done done
python3 - <<'PY'
import hashlib, json, os
items = json.load(open("gallery/index.json", encoding="utf-8"))
for item in items:
if item["kind"] != "image":
continue
item["key"] = hashlib.sha1(open(f"gallery/{item['file']}", "rb").read()).hexdigest()
hidpi = item["file"].replace(".png", ".2x.png")
if os.path.exists(f"gallery/{hidpi}"):
item["hidpi"] = hidpi
lines = ",\n".join(" " + json.dumps(i, ensure_ascii=False) for i in items)
open("gallery/index.json", "w", encoding="utf-8").write(f"[\n{lines}\n]\n")
PY
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,27 +1,27 @@
[ [
{ "name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image" }, {"name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image", "key": "6e660e1ff6fc2ff3167919bc386c300112bbce72", "hidpi": "switch_on.2x.png"},
{ "name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image" }, {"name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image", "key": "5d748b614084e878d6189d22f27813477cb8f4d9", "hidpi": "switch_off.2x.png"},
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" }, {"name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio"},
{ "name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image" }, {"name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image", "key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f", "hidpi": "ico_title.2x.png"},
{ "name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image" }, {"name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image", "key": "e56bcc13f7128dec305b6012b255d63fb0f94450", "hidpi": "bg_home.2x.png"},
{ "name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image" }, {"name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image", "key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d", "hidpi": "icon_02.2x.png"},
{ "name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image" }, {"name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
{ "name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image" }, {"name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image", "key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3", "hidpi": "icon_04.2x.png"},
{ "name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image" }, {"name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image", "key": "05065abdb4538906bd7c86e940ab5fb02d51469d", "hidpi": "day_01.2x.png"},
{ "name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image" }, {"name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image", "key": "470d523e90e188fe5c423d8cffeae6993d5528ff", "hidpi": "day_02.2x.png"},
{ "name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image" }, {"name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image", "key": "7a8c0a16fcab37b28a66e041024b25341cc69462", "hidpi": "bg_from1.2x.png"},
{ "name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image" }, {"name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image", "key": "52497df301d2b7e4eac0c297ce2316ef24863c47", "hidpi": "bg_from2.2x.png"},
{ "name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image" }, {"name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image", "key": "23e6f1efc0f382c17d8a091b76531a3b5879349d", "hidpi": "icon_monitor.2x.png"},
{ "name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image" }, {"name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image", "key": "396443a1d8c141942c0f447e0c69786cf6ad3350", "hidpi": "panel_video_ctrl.2x.png"},
{ "name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image" }, {"name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
{ "name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image" }, {"name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image", "key": "51e1b9926d2bd611e3fda74a01231b46a930c460", "hidpi": "btn_arrow_up_press.2x.png"},
{ "name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image" }, {"name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image", "key": "caf46f81c15aaca788462ebc47e58df299f097d9", "hidpi": "btn_arrow_down_normal.2x.png"},
{ "name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image" }, {"name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image", "key": "88c1bea8d6fad99b14658d8265a88b04bc595006", "hidpi": "btn_arrow_down_press.2x.png"},
{ "name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image" }, {"name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image", "key": "a38beb2373154d0474ad1715035aec5e2adf8c3f", "hidpi": "btn_arrow_left_normal.2x.png"},
{ "name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image" }, {"name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image", "key": "47bd7a8ffc077130a8ceca627383a4647729c638", "hidpi": "btn_arrow_left_press.2x.png"},
{ "name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image" }, {"name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image", "key": "e69e459f54d67e2c52128015bd6cfcf0e800a893", "hidpi": "btn_arrow_right_normal.2x.png"},
{ "name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image" }, {"name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image", "key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170", "hidpi": "btn_arrow_right_press.2x.png"},
{ "name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image" }, {"name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
{ "name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image" }, {"name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image", "key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9", "hidpi": "arrow_right_normal.2x.png"},
{ "name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image" } {"name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.2x.png"}
] ]
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+40 -7
View File
@@ -6,6 +6,7 @@ import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type T
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes' import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
import type { Scoring, StartMode } from '@iot/shared' import type { Scoring, StartMode } from '@iot/shared'
import { import {
assignmentChecks,
assignmentTemplate, assignmentTemplate,
classSubmissions, classSubmissions,
createAssignment, createAssignment,
@@ -18,6 +19,7 @@ import {
} from './assignments' } from './assignments'
import { aiEnabled, getAiReview, type AiQueue } from './ai-review' import { aiEnabled, getAiReview, type AiQueue } from './ai-review'
import { parseChecks } from './grading/checks' import { parseChecks } from './grading/checks'
import { createSelfCheck } from './grading/selfcheck'
import { languageService } from './language-service' import { languageService } from './language-service'
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews' import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
import type { GradingQueue } from './grading/queue' import type { GradingQueue } from './grading/queue'
@@ -78,7 +80,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
if (!/^[\w.-]+$/.test(name)) return c.notFound() if (!/^[\w.-]+$/.test(name)) return c.notFound()
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`) const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`)
if (!(await file.exists())) return c.notFound() if (!(await file.exists())) return c.notFound()
return new Response(file, { headers: { 'Cache-Control': 'public, max-age=3600' } }) // 清单随部署更新(新素材、图片编号),每次都向服务器确认;图片缓存一小时
return new Response(file, { headers: { 'Cache-Control': name === 'index.json' ? 'no-cache' : 'public, max-age=3600' } })
}) })
// 其余接口都要先登录(请求头 Authorization: Bearer <令牌>) // 其余接口都要先登录(请求头 Authorization: Bearer <令牌>)
@@ -98,6 +101,30 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return files ? c.json(files) : c.notFound() return files ? c.json(files) : c.notFound()
}) })
// ---- 自测:提交前自己跑一遍评分,不计分、不留记录 ----
const selfCheck = createSelfCheck()
/** 实验的评价项(自动检查的功能项名称):实验页的评价项面板、老师布置作业时用 */
app.get('/api/samples/:id/checks', async (c) => {
const checks = await loadChecks(c.req.param('id'))
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 404)
return c.json(parseChecks(checks).map((check) => check.title))
})
app.post('/api/samples/:id/selfcheck', async (c) => {
const sampleId = c.req.param('id')
const yaml = (await listSamples()).some((s) => s.id === sampleId) ? await loadChecks(sampleId) : null
if (!yaml) return c.json({ message: '这个实验没有评价项' }, 404)
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
if (!selfCheck.start(c.get('user').id, `sample:${sampleId}`, files, parseChecks(yaml), sampleId)) {
return c.json({ message: '上一次检查还没完成' }, 409)
}
return c.json(selfCheck.get(c.get('user').id))
})
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查 // 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics']) const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => { app.post('/api/language', async (c) => {
@@ -197,6 +224,18 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return c.json(latestSubmission(db, assignment.id, user.id)) return c.json(latestSubmission(db, assignment.id, user.id))
}) })
app.post('/api/assignments/:id/selfcheck', async (c) => {
const assignment = visibleAssignment(c)
const checks = assignment && assignmentChecks(db, assignment.id)
if (!assignment || !checks) return c.json({ message: '作业不存在' }, 404)
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
if (!selfCheck.start(c.get('user').id, `assignment:${assignment.id}`, files, checks, assignment.sampleId)) {
return c.json({ message: '上一次检查还没完成' }, 409)
}
return c.json(selfCheck.get(c.get('user').id))
})
app.get('/api/assignments/:id/submission', (c) => { app.get('/api/assignments/:id/submission', (c) => {
const assignment = visibleAssignment(c) const assignment = visibleAssignment(c)
if (!assignment) return c.json({ message: '作业不存在' }, 404) if (!assignment) return c.json({ message: '作业不存在' }, 404)
@@ -247,12 +286,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
}) })
// 示例的自动评分项名称(布置作业时设置分值用) // 示例的自动评分项名称(布置作业时设置分值用)
app.get('/api/teacher/samples/:id/checks', async (c) => {
const checks = await loadChecks(c.req.param('id'))
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 404)
return c.json(parseChecks(checks).map((check) => check.title))
})
// 班级的作业,带上提交人数 // 班级的作业,带上提交人数
app.get('/api/teacher/classes/:id/assignments', (c) => app.get('/api/teacher/classes/:id/assignments', (c) =>
c.json( c.json(
+12 -6
View File
@@ -1,6 +1,7 @@
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。 // 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
// 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。 // 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。
import type { Database } from 'bun:sqlite' import type { Database } from 'bun:sqlite'
import type { SourceFile } from '@iot/shared'
import { mkdir, rm } from 'node:fs/promises' import { mkdir, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments' import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments'
@@ -10,6 +11,16 @@ import { grade } from './grader'
const MAX_ACTIVE = 3 const MAX_ACTIVE = 3
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
await rm(dir, { recursive: true, force: true })
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
}
export function createGradingQueue(db: Database, onGraded: (submissionId: number) => void = () => {}) { export function createGradingQueue(db: Database, onGraded: (submissionId: number) => void = () => {}) {
const queue: number[] = [] const queue: number[] = []
let active = 0 let active = 0
@@ -21,12 +32,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
if (!submission || !checks) return if (!submission || !checks) return
const dir = join(config.runsDir, `grade-${id}`) const dir = join(config.runsDir, `grade-${id}`)
try { try {
await rm(dir, { recursive: true, force: true }) await writeProject(dir, submission.files)
for (const file of submission.files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
saveGrade(db, id, await grade(dir, checks, submission.sampleId)) saveGrade(db, id, await grade(dir, checks, submission.sampleId))
onGraded(id) onGraded(id)
} catch (error) { } catch (error) {
+41
View File
@@ -0,0 +1,41 @@
// 自测:学生提交前自己跑一遍评分,告诉他哪几项不通过、为什么;不计分、不留记录。
// 每人同时只能有一次,结果放在内存里,保留到下一次自测(服务器重启后就没有了)。
// 检查项和提交评分一样受全站并发上限(grader.ts 的 GRADE_CONCURRENCY)约束,机器忙时排队。
import type { SelfCheck, SourceFile } from '@iot/shared'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { config } from '../config'
import type { Check } from './checks'
import { grade } from './grader'
import { writeProject } from './queue'
export function createSelfCheck() {
const states = new Map<number, SelfCheck>()
return {
/** 开始自测;上一次还没完成时返回 false */
start(userId: number, target: string, files: SourceFile[], checks: Check[], sampleId: string): boolean {
if (states.get(userId)?.status === 'running') return false
const state: SelfCheck = { status: 'running', target, result: null, startedAt: Date.now() }
states.set(userId, state)
const dir = join(config.runsDir, `selfcheck-${userId}`)
void (async () => {
try {
await writeProject(dir, files)
state.result = await grade(dir, checks, sampleId)
state.status = 'done'
} catch (error) {
console.error(`自测出错(用户 ${userId}):`, error)
state.status = 'error'
} finally {
await rm(dir, { recursive: true, force: true })
}
})()
return true
},
get(userId: number): SelfCheck | null {
return states.get(userId) ?? null
},
}
}
+27 -1
View File
@@ -279,7 +279,7 @@ describe('作业', () => {
}) })
expect(result.status).toBe(400) expect(result.status).toBe(400)
expect(result.body.message).toBe('自动评分项应有 2 项') expect(result.body.message).toBe('自动评分项应有 2 项')
expect((await t.get(`/api/teacher/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯']) expect((await t.get(`/api/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯'])
}) })
test('学生能看到自己班的作业和起点工程,别的班看不到', async () => { test('学生能看到自己班的作业和起点工程,别的班看不到', async () => {
@@ -332,6 +332,32 @@ describe('作业', () => {
expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30) expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30)
}, 60000) }, 60000)
test('自测:提交前自己检查,告诉哪项不通过;不计分、不留记录,同时只能一次', async () => {
const { student, assignmentId, files } = await setup()
const finished = async () => {
for (let i = 0; i < 300; i++) {
const { body } = await student.get('/api/selfcheck')
if (body.status !== 'running') return body
await Bun.sleep(200)
}
}
const broken = files.map((f) => (f.path === 'Form1.cs' ? { ...f, content: f.content.replace('AlarmOnOff("Alarm", false);', '') } : f))
const started = await student.post(`/api/assignments/${assignmentId}/selfcheck`, { files: broken })
expect(started.body).toMatchObject({ status: 'running', target: `assignment:${assignmentId}` })
expect((await student.post(`/api/assignments/${assignmentId}/selfcheck`, { files })).status).toBe(409)
const result = await finished()
expect(result.result.results.map((r: any) => r.passed)).toEqual([true, false])
expect(result.result.results[1].message).toBeString()
// 自测不算提交
expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body).toBeNull()
// 自由练习:按实验的评分规则检查
const sample = await student.post(`/api/samples/${encodeURIComponent(SAMPLE)}/selfcheck`, { files })
expect(sample.body.target).toBe(`sample:${SAMPLE}`)
expect((await finished()).result.results.map((r: any) => r.passed)).toEqual([true, true])
expect((await student.post('/api/samples/no-such/selfcheck', { files })).status).toBe(404)
}, 60000)
test('过了截止时间不能提交', async () => { test('过了截止时间不能提交', async () => {
const { student, assignmentId, files } = await setup(Date.now() - 1000) const { student, assignmentId, files } = await setup(Date.now() - 1000)
const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files }) const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
+9
View File
@@ -16,6 +16,15 @@ export interface GradeResult {
ms: number ms: number
} }
/** 自测(提交前自己跑一遍评分,不计分、不留记录):每人同时一次,结果保留到下一次自测 */
export interface SelfCheck {
status: 'running' | 'done' | 'error'
/** 检查的是哪个实验或作业(结果只对同一个显示) */
target: string
result: GradeResult | null
startedAt: number
}
// ---------- 作业 ---------- // ---------- 作业 ----------
/** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */ /** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */
+65 -10
View File
@@ -2,7 +2,7 @@
// 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。 // 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。
// 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。 // 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。
// 每次改动都重新生成 Designer.cs,通过 update 事件交给页面保存。 // 每次改动都重新生成 Designer.cs,通过 update 事件交给页面保存。
import { computed, ref, shallowRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import ControlView from '../winforms/ControlView.vue' import ControlView from '../winforms/ControlView.vue'
import { displayText, fontCss, type FontValue } from '../winforms/format' import { displayText, fontCss, type FontValue } from '../winforms/format'
import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog' import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog'
@@ -164,6 +164,41 @@ const selectedAutoSize = computed(() => doc.value && selected.value && getPropTe
// ---------- 指针操作:选中、移动、改大小 ---------- // ---------- 指针操作:选中、移动、改大小 ----------
const surface = ref<HTMLElement>() const surface = ref<HTMLElement>()
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
const ZOOM_KEY = 'iot-sim:designer-zoom'
const ZOOM_OPTIONS: [ZoomChoice, string][] = [['fit', '适应宽度'], ['1', '100%'], ['0.75', '75%'], ['0.5', '50%']]
const zoomChoice = ref<ZoomChoice>(readZoom())
function readZoom(): ZoomChoice {
try {
const saved = localStorage.getItem(ZOOM_KEY)
return ZOOM_OPTIONS.some(([v]) => v === saved) ? (saved as ZoomChoice) : 'fit'
} catch {
return 'fit'
}
}
function setZoom(value: ZoomChoice) {
zoomChoice.value = value
try {
localStorage.setItem(ZOOM_KEY, value)
} catch {
// 浏览器不让存时只在这次有效
}
}
const canvas = ref<HTMLElement>()
const canvasWidth = ref(0)
const canvasObserver = new ResizeObserver(([entry]) => {
canvasWidth.value = entry.contentRect.width
})
onMounted(() => canvas.value && canvasObserver.observe(canvas.value))
onBeforeUnmount(() => canvasObserver.disconnect())
/** 窗体的缩放比例;鼠标位置除以它换算回窗体坐标 */
const zoom = computed(() => {
if (zoomChoice.value !== 'fit') return Number(zoomChoice.value)
const width = clientSize.value.width + 2 + 8
return canvasWidth.value > 0 ? Math.max(0.3, Math.min(1, canvasWidth.value / width)) : 1
})
type Handle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' type Handle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
@@ -173,9 +208,9 @@ let drag:
| { mode: 'form'; startX: number; startY: number; origin: { width: number; height: number } } | { mode: 'form'; startX: number; startY: number; origin: { width: number; height: number } }
| undefined | undefined
function pointer(event: PointerEvent | DragEvent) { function pointer(event: PointerEvent | DragEvent | MouseEvent) {
const box = surface.value!.getBoundingClientRect() const box = surface.value!.getBoundingClientRect()
return { x: event.clientX - box.left, y: event.clientY - box.top } return { x: (event.clientX - box.left) / zoom.value, y: (event.clientY - box.top) / zoom.value }
} }
function onPointerDown(event: PointerEvent) { function onPointerDown(event: PointerEvent) {
@@ -286,8 +321,9 @@ function onKeyDown(event: KeyboardEvent) {
// ---------- 工具箱 ---------- // ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
const ICONS: Record<string, string> = { const ICONS: Record<string, string> = {
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
} }
const DRAG_TYPE = 'application/x-winforms-control' const DRAG_TYPE = 'application/x-winforms-control'
@@ -391,8 +427,8 @@ function clearEvent(target: string, eventName: string) {
function onDoubleClick(event: MouseEvent) { function onDoubleClick(event: MouseEvent) {
const d = parsed.value.doc const d = parsed.value.doc
if (!d) return if (!d) return
const box = surface.value!.getBoundingClientRect() const { x, y } = pointer(event)
const hit = hitTest(d, event.clientX - box.left, event.clientY - box.top) ?? '' const hit = hitTest(d, x, y) ?? ''
const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF
openEvent(hit, def.defaultEvent) openEvent(hit, def.defaultEvent)
} }
@@ -440,16 +476,26 @@ const noop = () => {}
</template> </template>
</aside> </aside>
<section class="canvas"> <section ref="canvas" class="canvas">
<div class="actions"> <div class="actions">
<button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button> <button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button>
<button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button> <button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button>
<span class="tips">拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span> <span class="tips">拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span>
<select
class="zoom"
title="窗体显示的大小(不影响窗体的实际大小)"
:value="zoomChoice"
@change="setZoom(($event.target as HTMLSelectElement).value as ZoomChoice)"
>
<option v-for="[value, label] in ZOOM_OPTIONS" :key="value" :value="value">
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
</option>
</select>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')"> <button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">
查看生成的代码 查看生成的代码
</button> </button>
</div> </div>
<div class="window" :class="{ selected: selected === '' }"> <div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
<div class="titlebar" @pointerdown="selected = ''"> <div class="titlebar" @pointerdown="selected = ''">
<span class="title">{{ displayText(formTitle) }}</span> <span class="title">{{ displayText(formTitle) }}</span>
<span class="buttons">─ ☐ ✕</span> <span class="buttons">─ ☐ ✕</span>
@@ -601,8 +647,17 @@ const noop = () => {}
cursor: default; cursor: default;
} }
.view-code { .zoom {
margin-left: auto; margin-left: auto;
padding: 1px 4px;
border: 1px solid #c5ccd5;
border-radius: 3px;
background: #fff;
font: inherit;
}
.view-code {
flex: none;
} }
.tips { .tips {
+17 -5
View File
@@ -45,13 +45,20 @@ function save() {
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)"> <NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p> <p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
<div class="items"> <div class="items">
<label v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }"> <!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
<NCheckbox :checked="chosen.has(item.name)" @update:checked="toggle(item.name, $event)" /> <div v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }" @click="toggle(item.name, !chosen.has(item.name))">
<img v-if="item.kind === 'image'" :src="`/resources/${item.file}`" alt="" class="thumb" /> <NCheckbox :checked="chosen.has(item.name)" @click.stop @update:checked="toggle(item.name, $event)" />
<NButton v-else size="tiny" class="audio" @click.prevent="listen(item.file)">▶ 试听</NButton> <img
v-if="item.kind === 'image'"
:src="`/resources/${item.file}`"
:srcset="item.hidpi ? `/resources/${item.file} 1x, /resources/${item.hidpi} 2x` : undefined"
alt=""
class="thumb"
/>
<NButton v-else size="tiny" class="audio" @click.stop="listen(item.file)">▶ 试听</NButton>
<span class="name">{{ item.name }}</span> <span class="name">{{ item.name }}</span>
<span class="label">{{ item.label }}</span> <span class="label">{{ item.label }}</span>
</label> </div>
<div v-if="gallery.length === 0" class="note">正在加载素材库…</div> <div v-if="gallery.length === 0" class="note">正在加载素材库…</div>
</div> </div>
<template #footer> <template #footer>
@@ -106,6 +113,11 @@ function save() {
max-height: 40px; max-height: 40px;
} }
/* 灰色棋盘格底:透明背景上的白色图片(如 ico_title 的白字)也看得见 */
.thumb {
background: repeating-conic-gradient(#9ca3af 0 25%, #c4c9d0 0 50%) 0 0 / 10px 10px;
}
.audio { .audio {
justify-self: start; justify-self: start;
} }
+4
View File
@@ -11,6 +11,10 @@ export interface GalleryItem {
/** 中文说明 */ /** 中文说明 */
label: string label: string
kind: 'image' | 'audio' kind: 'image' | 'audio'
/** 图片的编号(原尺寸 PNG 的 SHA-1,和运行器给图片的编号相同) */
key?: string
/** 两倍尺寸的图片文件,高分辨率屏幕上显示用 */
hidpi?: string
} }
export const RESOURCES_FILE = 'Properties/Resources.Designer.cs' export const RESOURCES_FILE = 'Properties/Resources.Designer.cs'
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
// 评价项面板:列出这个实验(或作业)自动检查的功能项;"自己检查"在服务器上运行学生现在的程序逐项检查,
// 告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { SelfCheck, SourceFile } from '@iot/shared'
import { api } from '../api'
const props = defineProps<{
show: boolean
/** 检查项名称 */
titles: string[]
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
target: string
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
url: string
/** 学生现在的代码 */
files: SourceFile[]
/** 开始检查前调用(先把没保存的改动存好) */
beforeStart?: () => Promise<void>
/** 作业的各项分值(自由练习没有) */
points?: number[]
}>()
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
const state = ref<SelfCheck | null>(null)
const error = ref('')
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
const checkedFiles = ref<string>()
const current = computed(() => (state.value?.target === props.target ? state.value : null))
const running = computed(() => current.value?.status === 'running')
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
clearTimeout(timer)
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
}
onBeforeUnmount(() => clearTimeout(timer))
watch(
() => props.show,
(show) => {
if (show) void refresh()
},
{ immediate: true },
)
async function start() {
error.value = ''
try {
await props.beforeStart?.()
checkedFiles.value = JSON.stringify(props.files)
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
timer = setTimeout(refresh, 2000)
} catch (e) {
error.value = (e as Error).message
}
}
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
</script>
<template>
<NDrawer :show="show" :width="420" placement="right" @update:show="emit('update:show', $event)">
<NDrawerContent title="评价项" closable>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
<span v-else-if="current?.status === 'done' && current.result" class="status">
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
</span>
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
</div>
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
{{ current.result.results[i].message }}
</div>
</div>
</li>
</ul>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.note {
margin: 0 0 12px;
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #374151;
}
.error {
color: #b91c1c;
font-size: 13px;
}
.outdated {
margin: 4px 0;
color: #a16207;
font-size: 13px;
}
.items {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.items li {
display: flex;
gap: 8px;
}
.mark {
width: 1em;
flex: none;
font-weight: bold;
color: #9ca3af;
}
.passed .mark {
color: #16a34a;
}
.failed .mark {
color: #dc2626;
}
.points {
margin-left: 8px;
color: #6b7280;
}
.message {
margin-top: 2px;
color: #b91c1c;
}
</style>
+1 -1
View File
@@ -200,7 +200,7 @@ onMounted(refresh)
<div v-else class="empty"> <div v-else class="empty">
<p>右键左边的"数据库"新建数据库;右键表可以编辑数据、设计表结构。</p> <p>右键左边的"数据库"新建数据库;右键表可以编辑数据、设计表结构。</p>
<p>也可以点"新建查询",用 SQL 语句建库、建表、增删改查。</p> <p>也可以点"新建查询",用 SQL 语句建库、建表、增删改查。</p>
<p>做教材项目 6 的任务 2、3 前,可以点"恢复实验初始数据"准备好"环境数据表"库。</p> <p>运行要用数据库的实验(教材项目 6 的任务 2、3,项目 8)时,平台会自动建好需要的库;把实验库恢复成初始数据,点"恢复实验初始数据"。</p>
</div> </div>
</main> </main>
</div> </div>
+99 -6
View File
@@ -18,6 +18,7 @@ import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue' import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session' import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue' import GradeResultView from '../grading/GradeResultView.vue'
import SelfCheckPanel from '../grading/SelfCheckPanel.vue'
import { captureForms } from '../grading/screenshots' import { captureForms } from '../grading/screenshots'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>() const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
@@ -134,9 +135,28 @@ async function openProject(key: string, original: () => Promise<SourceFile[]>, d
async function loadSample(id: string, discardWork = false) { async function loadSample(id: string, discardWork = false) {
sampleId.value = id sampleId.value = id
void router.replace({ query: { sample: id } }) void router.replace({ query: { sample: id } })
sampleChecks.value = []
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
.then((titles) => {
if (sampleId.value === id) sampleChecks.value = titles
})
.catch(() => {})
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork) await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
} }
// ---------- 评价项和自测 ----------
const sampleChecks = ref<string[]>([])
const selfCheckOpen = ref(false)
/** 作业用布置时的评分规则,自由练习用实验的评分规则 */
const selfCheck = computed(() => {
if (props.assignmentId) {
const a = assignment.value
return a ? { titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
}
const id = sampleId.value
return id && sampleChecks.value.length ? { titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` } : null
})
// ---------- 作业 ---------- // ---------- 作业 ----------
const assignment = ref<Assignment | null>(null) const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null) const submission = ref<Submission | null>(null)
@@ -407,8 +427,28 @@ function restoreOriginal() {
else if (sampleId.value) void loadSample(sampleId.value, true) else if (sampleId.value) void loadSample(sampleId.value, true)
} }
// ---------- 收起运行区(设计大窗体、写代码时左栏占满宽度;运行程序时自动展开) ----------
const RUN_COLLAPSED_KEY = 'iot-sim:run-collapsed'
const runCollapsed = ref(readFlag(RUN_COLLAPSED_KEY))
function readFlag(key: string) {
try {
return localStorage.getItem(key) === '1'
} catch {
return false
}
}
function toggleRunArea(collapsed = !runCollapsed.value) {
runCollapsed.value = collapsed
try {
localStorage.setItem(RUN_COLLAPSED_KEY, collapsed ? '1' : '0')
} catch {
// 浏览器不让存时只在这次有效
}
}
function run() { function run() {
if (running.value) return if (running.value) return
toggleRunArea(false)
session.run( session.run(
files.value.map((f) => ({ ...f })), files.value.map((f) => ({ ...f })),
props.assignmentId ? assignment.value?.sampleId : sampleId.value, props.assignmentId ? assignment.value?.sampleId : sampleId.value,
@@ -456,6 +496,9 @@ function run() {
{{ scoreText }} {{ scoreText }}
</NButton> </NButton>
</template> </template>
<NButton v-if="selfCheck && !viewing" size="small" title="这个任务自动检查哪些功能;可以自己先检查一遍(不计分)" @click="selfCheckOpen = true">
评价项
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal"> <NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger> <template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton> <NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -468,7 +511,7 @@ function run() {
编译 {{ 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>
<span v-else class="spacer" /> <span class="spacer" />
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink> <RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
<RouterLink v-if="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink> <RouterLink v-if="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink>
<span class="user"> <span class="user">
@@ -488,7 +531,18 @@ function run() {
<GradeResultView :assignment="assignment" :submission="submission" :review="review" /> <GradeResultView :assignment="assignment" :submission="submission" :review="review" />
</NModal> </NModal>
<main class="workspace" :class="{ designing: activeForm }"> <SelfCheckPanel
v-if="selfCheck"
v-model:show="selfCheckOpen"
:titles="selfCheck.titles"
:target="selfCheck.target"
:url="selfCheck.url"
:points="selfCheck.points"
:files="files"
:before-start="flushSave"
/>
<main class="workspace" :class="{ designing: activeForm, 'run-collapsed': runCollapsed }">
<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">
@@ -559,7 +613,14 @@ function run() {
/> />
</section> </section>
<RunArea :session="session" @diagnostic="showDiagnostic" /> <button
class="run-toggle"
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
@click="toggleRunArea()"
>
{{ runCollapsed ? '◂' : '▸' }}
</button>
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
</main> </main>
</div> </div>
</template> </template>
@@ -584,11 +645,17 @@ function run() {
} }
.metrics { .metrics {
margin-left: auto;
color: #6b7280; color: #6b7280;
font-size: 12px; font-size: 12px;
} }
/* 编译、往返时间是给开发看的,屏幕窄时(机房常见 1366 宽)不显示,免得工具栏挤成两行 */
@media (max-width: 1599px) {
.metrics {
display: none;
}
}
.spacer { .spacer {
margin-left: auto; margin-left: auto;
} }
@@ -610,6 +677,11 @@ function run() {
.save-state { .save-state {
color: #6b7280; color: #6b7280;
font-size: 12px; font-size: 12px;
white-space: nowrap;
}
.toolbar > strong {
white-space: nowrap;
} }
.save-state.error { .save-state.error {
@@ -624,6 +696,7 @@ function run() {
.nav-link { .nav-link {
color: #2563eb; color: #2563eb;
font-size: 13px; font-size: 13px;
white-space: nowrap;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
@@ -631,13 +704,33 @@ function run() {
.workspace { .workspace {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: minmax(360px, 2fr) 3fr; grid-template-columns: minmax(360px, 2fr) 14px 3fr;
min-height: 0; min-height: 0;
} }
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */ /* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
.workspace.designing { .workspace.designing {
grid-template-columns: minmax(0, 3fr) minmax(360px, 2fr); grid-template-columns: minmax(0, 3fr) 14px minmax(360px, 2fr);
}
.workspace.run-collapsed {
grid-template-columns: minmax(0, 1fr) 14px;
}
/* 编辑区和运行区之间的竖条:点一下收起、展开运行区 */
.run-toggle {
padding: 0;
border: none;
border-right: 1px solid #e5e7eb;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
cursor: pointer;
}
.run-toggle:hover {
background: #e5e7eb;
color: #111827;
} }
.editor { .editor {
+5
View File
@@ -2,6 +2,7 @@
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。 // 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
// 实验页和老师的评阅页共用。 // 实验页和老师的评阅页共用。
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
import { loadGallery } from '../designer/resources'
import TrainingBoard from '../scene/TrainingBoard.vue' import TrainingBoard from '../scene/TrainingBoard.vue'
import type { SizeValue } from '../winforms/format' import type { SizeValue } from '../winforms/format'
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue' import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
@@ -14,6 +15,8 @@ const emit = defineEmits<{ diagnostic: [file: string, line: number, column: numb
const state = props.session.state const state = props.session.state
provide(RUN_IMAGES, props.session.images) provide(RUN_IMAGES, props.session.images)
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
void loadGallery()
/** 照片列表里点开看大图 */ /** 照片列表里点开看大图 */
const viewing = ref<{ path: string; url: string } | null>(null) const viewing = ref<{ path: string; url: string } | null>(null)
@@ -142,6 +145,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
border-radius: 4px; border-radius: 4px;
/* 窗体被拖到桌面外面时可以滚动查看 */ /* 窗体被拖到桌面外面时可以滚动查看 */
overflow: auto; overflow: auto;
/* 窗体、提示框的层级只在桌面里比较,不会盖住网页的抽屉和对话框 */
isolation: isolate;
} }
.desktop-canvas { .desktop-canvas {
+1 -1
View File
@@ -63,7 +63,7 @@ async function chooseSample(id: string) {
sampleId.value = id sampleId.value = id
title.value = samples.value.find((s) => s.value === id)?.label ?? '' title.value = samples.value.find((s) => s.value === id)?.label ?? ''
try { try {
checkTitles.value = await api<string[]>('GET', `/api/teacher/samples/${encodeURIComponent(id)}/checks`) checkTitles.value = await api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
auto.value = checkTitles.value.map(() => 10) auto.value = checkTitles.value.map(() => 10)
} catch (e) { } catch (e) {
checkTitles.value = [] checkTitles.value = []
+3 -3
View File
@@ -4,7 +4,7 @@
import { computed, inject, provide, ref, type CSSProperties } from 'vue' import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared' import type { ControlNode, UserAction } from '@iot/shared'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format' import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, RUN_IMAGES, type ImageValue } from './images' import { backgroundCss, imageSources, RUN_IMAGES, srcset, type ImageValue } from './images'
import ChartControl from './ChartControl.vue' import ChartControl from './ChartControl.vue'
import DataGridViewControl from './DataGridViewControl.vue' import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue' import DateTimePickerControl from './DateTimePickerControl.vue'
@@ -72,7 +72,7 @@ function selectItem(index: number) {
// PictureBox:按 SizeMode 摆放图片 // PictureBox:按 SizeMode 摆放图片
const image = computed(() => p.value.Image as ImageValue | null | undefined) const image = computed(() => p.value.Image as ImageValue | null | undefined)
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined)) const imageSrc = computed(() => imageSources(image.value, runImages))
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小 // 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {})) const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {}))
const imageStyle = computed((): CSSProperties => { const imageStyle = computed((): CSSProperties => {
@@ -182,7 +182,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
@pointerdown="mouse('mouseDown', $event)" @pointerdown="mouse('mouseDown', $event)"
@pointerup="mouse('mouseUp', $event)" @pointerup="mouse('mouseUp', $event)"
> >
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" /> <img v-if="imageSrc" :src="imageSrc.src" :srcset="srcset(imageSrc)" :style="imageStyle" alt="" draggable="false" />
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" /> <ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div> </div>
+24 -4
View File
@@ -1,6 +1,7 @@
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。 // 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。 // 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
import type { CSSProperties, InjectionKey } from 'vue' import type { CSSProperties, InjectionKey } from 'vue'
import { gallery } from '../designer/resources'
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages') export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
@@ -13,12 +14,31 @@ export interface ImageValue {
url?: string url?: string
} }
/**
* 图片的网址。素材库里的图片(按编号或素材网址认出来)另有两倍尺寸的版本,
* 高分辨率屏幕(系统缩放 150%、200%)上用它,不然浏览器放大原图会发虚;程序读到的宽高不变。
*/
export function imageSources(value: unknown, images: ReadonlyMap<string, string>): { src: string; hidpi?: string } | undefined {
const image = value as ImageValue | null | undefined
if (!image) return undefined
const item = gallery.value.find((g) => (image.key && g.key === image.key) || (image.url && image.url === `/resources/${g.file}`))
if (item?.hidpi) return { src: `/resources/${item.file}`, hidpi: `/resources/${item.hidpi}` }
const src = image.url ?? images.get(image.key)
return src ? { src } : undefined
}
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
export const srcset = (sources: { src: string; hidpi?: string } | undefined) =>
sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */ /** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties { export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
const image = value as ImageValue | null | undefined const sources = imageSources(value, images)
const url = image ? image.url ?? images.get(image.key) : undefined if (!sources) return {}
if (!url) return {} // image-set 里的两倍图按 2x 显示,平铺、居中、左上角时仍是原尺寸
const base = { backgroundImage: `url("${url}")` } const base: CSSProperties = {
backgroundImage: sources.hidpi ? `image-set(url("${sources.src}") 1x, url("${sources.hidpi}") 2x)` : `url("${sources.src}")`,
}
switch (layout) { switch (layout) {
case 'None': case 'None':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' } return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' }