Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c217eb5ca0 | ||
|
|
f304fc1ffc | ||
|
|
fe62d50881 | ||
|
|
f256b313f7 |
No files matched your search
@@ -73,7 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||
- 部署: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)。
|
||||
|
||||
尚未完成:
|
||||
|
||||
@@ -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:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
|
||||
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
学生在"项目资源"里添加的图片和声音(`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 和思源黑体)。
|
||||
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
|
||||
@@ -1,6 +1,23 @@
|
||||
#!/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")"
|
||||
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
|
||||
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
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,27 +1,27 @@
|
||||
[
|
||||
{ "name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image" },
|
||||
{ "name": "switch_off", "file": "switch_off.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", "key": "5d748b614084e878d6189d22f27813477cb8f4d9", "hidpi": "switch_off.2x.png"},
|
||||
{"name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio"},
|
||||
{ "name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image" },
|
||||
{ "name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image" },
|
||||
{ "name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image" },
|
||||
{ "name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image" },
|
||||
{ "name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image" },
|
||||
{ "name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image" },
|
||||
{ "name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image" },
|
||||
{ "name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image" },
|
||||
{ "name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image" },
|
||||
{ "name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image" },
|
||||
{ "name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image" },
|
||||
{ "name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image" },
|
||||
{ "name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image" },
|
||||
{ "name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image" },
|
||||
{ "name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image" },
|
||||
{ "name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image" },
|
||||
{ "name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image" },
|
||||
{ "name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image" },
|
||||
{ "name": "windowstate_closed_hover", "file": "windowstate_closed_hover.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", "key": "e56bcc13f7128dec305b6012b255d63fb0f94450", "hidpi": "bg_home.2x.png"},
|
||||
{"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", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
|
||||
{"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", "key": "05065abdb4538906bd7c86e940ab5fb02d51469d", "hidpi": "day_01.2x.png"},
|
||||
{"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", "key": "7a8c0a16fcab37b28a66e041024b25341cc69462", "hidpi": "bg_from1.2x.png"},
|
||||
{"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", "key": "23e6f1efc0f382c17d8a091b76531a3b5879349d", "hidpi": "icon_monitor.2x.png"},
|
||||
{"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", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
|
||||
{"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", "key": "caf46f81c15aaca788462ebc47e58df299f097d9", "hidpi": "btn_arrow_down_normal.2x.png"},
|
||||
{"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", "key": "a38beb2373154d0474ad1715035aec5e2adf8c3f", "hidpi": "btn_arrow_left_normal.2x.png"},
|
||||
{"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", "key": "e69e459f54d67e2c52128015bd6cfcf0e800a893", "hidpi": "btn_arrow_right_normal.2x.png"},
|
||||
{"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", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
|
||||
{"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", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.2x.png"}
|
||||
]
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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 type { Scoring, StartMode } from '@iot/shared'
|
||||
import {
|
||||
assignmentChecks,
|
||||
assignmentTemplate,
|
||||
classSubmissions,
|
||||
createAssignment,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from './assignments'
|
||||
import { aiEnabled, getAiReview, type AiQueue } from './ai-review'
|
||||
import { parseChecks } from './grading/checks'
|
||||
import { createSelfCheck } from './grading/selfcheck'
|
||||
import { languageService } from './language-service'
|
||||
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
|
||||
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()
|
||||
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`)
|
||||
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 <令牌>)
|
||||
@@ -98,6 +101,30 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
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'])
|
||||
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))
|
||||
})
|
||||
|
||||
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) => {
|
||||
const assignment = visibleAssignment(c)
|
||||
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) =>
|
||||
c.json(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
|
||||
// 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments'
|
||||
@@ -10,6 +11,16 @@ import { grade } from './grader'
|
||||
|
||||
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 = () => {}) {
|
||||
const queue: number[] = []
|
||||
let active = 0
|
||||
@@ -21,12 +32,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
|
||||
if (!submission || !checks) return
|
||||
const dir = join(config.runsDir, `grade-${id}`)
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
for (const file of submission.files) {
|
||||
const target = safeJoin(dir, file.path)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await Bun.write(target, file.content)
|
||||
}
|
||||
await writeProject(dir, submission.files)
|
||||
saveGrade(db, id, await grade(dir, checks, submission.sampleId))
|
||||
onGraded(id)
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ describe('作业', () => {
|
||||
})
|
||||
expect(result.status).toBe(400)
|
||||
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 () => {
|
||||
@@ -332,6 +332,32 @@ describe('作业', () => {
|
||||
expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30)
|
||||
}, 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 () => {
|
||||
const { student, assignmentId, files } = await setup(Date.now() - 1000)
|
||||
const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
|
||||
|
||||
@@ -16,6 +16,15 @@ export interface GradeResult {
|
||||
ms: number
|
||||
}
|
||||
|
||||
/** 自测(提交前自己跑一遍评分,不计分、不留记录):每人同时一次,结果保留到下一次自测 */
|
||||
export interface SelfCheck {
|
||||
status: 'running' | 'done' | 'error'
|
||||
/** 检查的是哪个实验或作业(结果只对同一个显示) */
|
||||
target: string
|
||||
result: GradeResult | null
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
// ---------- 作业 ----------
|
||||
|
||||
/** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。
|
||||
// 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。
|
||||
// 每次改动都重新生成 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 { displayText, fontCss, type FontValue } from '../winforms/format'
|
||||
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>()
|
||||
|
||||
// ---------- 缩放:教材项目 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'
|
||||
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 } }
|
||||
| undefined
|
||||
|
||||
function pointer(event: PointerEvent | DragEvent) {
|
||||
function pointer(event: PointerEvent | DragEvent | MouseEvent) {
|
||||
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) {
|
||||
@@ -286,8 +321,9 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
// ---------- 工具箱 ----------
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
||||
const ICONS: Record<string, string> = {
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢',
|
||||
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
|
||||
复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
|
||||
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
}
|
||||
const DRAG_TYPE = 'application/x-winforms-control'
|
||||
|
||||
@@ -391,8 +427,8 @@ function clearEvent(target: string, eventName: string) {
|
||||
function onDoubleClick(event: MouseEvent) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
const box = surface.value!.getBoundingClientRect()
|
||||
const hit = hitTest(d, event.clientX - box.left, event.clientY - box.top) ?? ''
|
||||
const { x, y } = pointer(event)
|
||||
const hit = hitTest(d, x, y) ?? ''
|
||||
const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF
|
||||
openEvent(hit, def.defaultEvent)
|
||||
}
|
||||
@@ -440,16 +476,26 @@ const noop = () => {}
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="canvas">
|
||||
<section ref="canvas" class="canvas">
|
||||
<div class="actions">
|
||||
<button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button>
|
||||
<button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="window" :class="{ selected: selected === '' }">
|
||||
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
|
||||
<div class="titlebar" @pointerdown="selected = ''">
|
||||
<span class="title">{{ displayText(formTitle) }}</span>
|
||||
<span class="buttons">─ ☐ ✕</span>
|
||||
@@ -601,8 +647,17 @@ const noop = () => {}
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.view-code {
|
||||
.zoom {
|
||||
margin-left: auto;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #c5ccd5;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.view-code {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tips {
|
||||
|
||||
@@ -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)">
|
||||
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
|
||||
<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)" />
|
||||
<img v-if="item.kind === 'image'" :src="`/resources/${item.file}`" alt="" class="thumb" />
|
||||
<NButton v-else size="tiny" class="audio" @click.prevent="listen(item.file)">▶ 试听</NButton>
|
||||
<!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
|
||||
<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))">
|
||||
<NCheckbox :checked="chosen.has(item.name)" @click.stop @update:checked="toggle(item.name, $event)" />
|
||||
<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="label">{{ item.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="gallery.length === 0" class="note">正在加载素材库…</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -106,6 +113,11 @@ function save() {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
/* 灰色棋盘格底:透明背景上的白色图片(如 ico_title 的白字)也看得见 */
|
||||
.thumb {
|
||||
background: repeating-conic-gradient(#9ca3af 0 25%, #c4c9d0 0 50%) 0 0 / 10px 10px;
|
||||
}
|
||||
|
||||
.audio {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface GalleryItem {
|
||||
/** 中文说明 */
|
||||
label: string
|
||||
kind: 'image' | 'audio'
|
||||
/** 图片的编号(原尺寸 PNG 的 SHA-1,和运行器给图片的编号相同) */
|
||||
key?: string
|
||||
/** 两倍尺寸的图片文件,高分辨率屏幕上显示用 */
|
||||
hidpi?: string
|
||||
}
|
||||
|
||||
export const RESOURCES_FILE = 'Properties/Resources.Designer.cs'
|
||||
|
||||
@@ -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>
|
||||
@@ -200,7 +200,7 @@ onMounted(refresh)
|
||||
<div v-else class="empty">
|
||||
<p>右键左边的"数据库"新建数据库;右键表可以编辑数据、设计表结构。</p>
|
||||
<p>也可以点"新建查询",用 SQL 语句建库、建表、增删改查。</p>
|
||||
<p>做教材项目 6 的任务 2、3 前,可以点"恢复实验初始数据"准备好"环境数据表"库。</p>
|
||||
<p>运行要用数据库的实验(教材项目 6 的任务 2、3,项目 8)时,平台会自动建好需要的库;把实验库恢复成初始数据,点"恢复实验初始数据"。</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import RunArea from '../run/RunArea.vue'
|
||||
import { createRunSession } from '../run/session'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import SelfCheckPanel from '../grading/SelfCheckPanel.vue'
|
||||
import { captureForms } from '../grading/screenshots'
|
||||
|
||||
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) {
|
||||
sampleId.value = 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)
|
||||
}
|
||||
|
||||
// ---------- 评价项和自测 ----------
|
||||
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 submission = ref<Submission | null>(null)
|
||||
@@ -407,8 +427,28 @@ function restoreOriginal() {
|
||||
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() {
|
||||
if (running.value) return
|
||||
toggleRunArea(false)
|
||||
session.run(
|
||||
files.value.map((f) => ({ ...f })),
|
||||
props.assignmentId ? assignment.value?.sampleId : sampleId.value,
|
||||
@@ -456,6 +496,9 @@ function run() {
|
||||
{{ scoreText }}
|
||||
</NButton>
|
||||
</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">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||
@@ -468,7 +511,7 @@ function run() {
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
{{ state.metrics.lastRoundTripMs }} ms
|
||||
</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="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink>
|
||||
<span class="user">
|
||||
@@ -488,7 +531,18 @@ function run() {
|
||||
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
|
||||
</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">
|
||||
<div class="tab-row">
|
||||
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
|
||||
@@ -559,7 +613,14 @@ function run() {
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -584,11 +645,17 @@ function run() {
|
||||
}
|
||||
|
||||
.metrics {
|
||||
margin-left: auto;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 编译、往返时间是给开发看的,屏幕窄时(机房常见 1366 宽)不显示,免得工具栏挤成两行 */
|
||||
@media (max-width: 1599px) {
|
||||
.metrics {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -610,6 +677,11 @@ function run() {
|
||||
.save-state {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar > strong {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-state.error {
|
||||
@@ -624,6 +696,7 @@ function run() {
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -631,13 +704,33 @@ function run() {
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 2fr) 3fr;
|
||||
grid-template-columns: minmax(360px, 2fr) 14px 3fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
|
||||
.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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
|
||||
// 实验页和老师的评阅页共用。
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
|
||||
import { loadGallery } from '../designer/resources'
|
||||
import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||
import type { SizeValue } from '../winforms/format'
|
||||
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
|
||||
provide(RUN_IMAGES, props.session.images)
|
||||
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
|
||||
void loadGallery()
|
||||
|
||||
/** 照片列表里点开看大图 */
|
||||
const viewing = ref<{ path: string; url: string } | null>(null)
|
||||
@@ -142,6 +145,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
border-radius: 4px;
|
||||
/* 窗体被拖到桌面外面时可以滚动查看 */
|
||||
overflow: auto;
|
||||
/* 窗体、提示框的层级只在桌面里比较,不会盖住网页的抽屉和对话框 */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.desktop-canvas {
|
||||
|
||||
@@ -63,7 +63,7 @@ async function chooseSample(id: string) {
|
||||
sampleId.value = id
|
||||
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
|
||||
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)
|
||||
} catch (e) {
|
||||
checkTitles.value = []
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { computed, inject, provide, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
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 DataGridViewControl from './DataGridViewControl.vue'
|
||||
import DateTimePickerControl from './DateTimePickerControl.vue'
|
||||
@@ -72,7 +72,7 @@ function selectItem(index: number) {
|
||||
|
||||
// PictureBox:按 SizeMode 摆放图片
|
||||
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 imageStyle = computed((): CSSProperties => {
|
||||
@@ -182,7 +182,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
|
||||
@pointerdown="mouse('mouseDown', $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" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
|
||||
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
|
||||
import type { CSSProperties, InjectionKey } from 'vue'
|
||||
import { gallery } from '../designer/resources'
|
||||
|
||||
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
|
||||
|
||||
@@ -13,12 +14,31 @@ export interface ImageValue {
|
||||
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 */
|
||||
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
|
||||
const image = value as ImageValue | null | undefined
|
||||
const url = image ? image.url ?? images.get(image.key) : undefined
|
||||
if (!url) return {}
|
||||
const base = { backgroundImage: `url("${url}")` }
|
||||
const sources = imageSources(value, images)
|
||||
if (!sources) return {}
|
||||
// image-set 里的两倍图按 2x 显示,平铺、居中、左上角时仍是原尺寸
|
||||
const base: CSSProperties = {
|
||||
backgroundImage: sources.hidpi ? `image-set(url("${sources.src}") 1x, url("${sources.hidpi}") 2x)` : `url("${sources.src}")`,
|
||||
}
|
||||
switch (layout) {
|
||||
case 'None':
|
||||
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' }
|
||||
|
||||