Compare commits

...
7 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 f242308a3b 部署:update.sh 改由 deploy/update-bg.sh 在服务器上后台跑(setsid),SSH 断了它照样跑完。2026-11-03 开发机上 FlClash 切换配置重建了 TUN 网卡,SSH 正好断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。输出写到 /var/tmp/iot_sim-update.log,--follow <提交号> 接着看输出、退出码和 update.sh 一样,这个提交的部署还没开始时返回 75;上一次还在跑就等它结束。Gitea 的"起服务"ssh 返回 255 就 10 秒后重连 --follow,75 就重新开始,最多 6 次。GitHub 备用部署也改用 update-bg.sh。部署说明同步
Deploy / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:32:21 +08:00
xuyueandClaude Opus 5.5 2aa267839f 导出 VS 工程:实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。导出的 zip 是教材那样的 VS 2012 窗体应用工程(.sln + .csproj,.NET Framework 4.5),在机房电脑上解压、双击 .sln 用 VS 打开接着改,按 F5 运行。导出前照机房环境编译一遍要放进 zip 的代码:C# 5(VS 2012 的编译器)、真机版设备类库,有错不导出;新版 C# 写法报 CS8026,按功能名给出改法(字符串插值改 string.Format、?. 改先判断 null、=> 改大括号等 12 种)。导出 EXE 仍按最新 C#。平台的 Properties/Resources.Designer.cs 换成 VS 生成的写法和 Resources.resx,图片放 Resources\;代码里按文件名用的素材(ring.mp3)复制到输出目录,运行时在 bin\Debug\Resources\;每个窗体带 .resx(媒体播放器 OcxState 用教材默认状态),SubType、DependentUpon 和 VS 一样;补上 AssemblyInfo、Settings、App.config。教材 dll 全放 lib\,只引用代码直接用到的(和教材工程一样),只带这些编译不过时引用全部;用到书上的 ledHelper.Close() 时带上 LedHelperCompat.cs。文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 文件名用 GBK,另附 Info-ZIP Unicode 文件名字段(0x7075),中文 Windows 自带解压和 7-Zip 都显示正确(ZipWriter.cs)。23 个示例都能导出,导出的工程在 mono 的 msbuild 里全部编译通过、输出目录和教材 bin\Debug 一样、程序能启动;VS 2012 还要在机房实测。接口 POST /api/export-project,代码有错时 422。export-exe.ts 改名 export.ts(服务器和网页各一个)。使用说明、设计方案、README、部署说明同步
Deploy / deploy (push) Failing after 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:42:13 +08:00
xuyueandClaude Opus 5.5 4c3ce31fe1 部署:push origin 就由开发机编好、测过再传到服务器,服务器不再自己编,push 后一两分钟上线。照 OJ2 的做法,开发机上的 gitea-runner(和 OJ2 共用,标签 iot-deploy,一次只跑一个任务)跑 .gitea/workflows/deploy.yml:编 C# 运行时(Release)、网页和 server.js,跑服务端测试(用要上线的 Release 版运行时)、网页端测试和类型检查,产物 rsync 到服务器的 prebuilt/(按内容比、传完才换上、断了重试),服务器上 deploy/update.sh --prebuilt 用现成产物构建镜像,只剩几条 COPY。以前 GitHub 上测试约 5 分钟、服务器 2 核再编约一分钟(编的时候学生程序会卡),push 后 6 分钟多才上线。host 模式用开发机自己的 ssh 和 git 访问,不需要 secret。Dockerfile 加构建参数 ARTIFACTS(build 默认在镜像里编 / prebuilt 用现成产物),服务器自己编的能力保留;prebuilt/COMMIT 和要部署的提交对不上时 update.sh 直接失败。服务端测试同时跑:文件之间 --parallel,评分测试标 describe.concurrent(--max-concurrency=8),本机 4 分钟降到半分钟,连跑三遍都通过。GitHub 的 ci.yml 改成 PR 跑测试、手动触发才部署(服务器自己编),开发机不在时用,push 不再两边各部署一遍。顺手:update.sh 每次部署拉一下 caddy:2-alpine(up 不会自己拉,一直是第一次部署时那份);app 和 caddy 的容器日志限 10MB × 3。部署说明、README、设计方案同步
Deploy / deploy (push) Successful in 1m38s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:08:14 +08:00
xuyueandClaude Opus 5.5 77bfaba7ff 导出 EXE:服务器上的教材 dll 读不了(root 拷进去的文件是 600,容器里的平台用户读不到)时提示中文原因和改法,不再把英文的系统报错给学生看;部署说明补上 chmod 755 目录、644 文件
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:19:28 +08:00
xuyueandClaude Opus 5.5 5e5d29b2aa 导出 EXE:实验页工具栏"导出 EXE"把学生的程序编译成一个 exe,拷到机房电脑上双击就能控制真的实训台。编译服务照教材工程编译学生写的原始代码(.NET Framework 4.5 引用程序集,构建时只复制教材工程引用的 13 个到 netfx/;真机版设备类库),不做平台的仿真改写和 API 限制,和 VS 里一样。教材 dll(DeviceCtrlLibrary 及 API 下 7 个、WinFormsDeviceControlLibrary、媒体播放器的两个 Interop)作为资源嵌进 exe,模块初始化器注册 AssemblyResolve 从 exe 里加载,Program.cs 不用改;Properties.Resources 的图片、声音嵌进 exe,代码里按文件名用到的素材(项目 7 的 ring.mp3)启动时放到 exe 旁边的 Resources;媒体播放器的 OcxState 用教材 resx 的默认状态;书上的 ledHelper.Close() 在教材类库里没有,补一个空的扩展方法。23 个实验都能导出(约 0.9 MB),mono + Xvfb 里实测能启动、控件和图片正常。接口 POST /api/export-exe,代码有错时 422,实验页弹框列出报错、点击跳到代码。教材 dll 有版权、GitHub 仓库公开,不进仓库:本机放 textbook-libs/,服务器放 ~/IOT_SIM/textbook-libs/ 由 compose 只读挂载(deploy/README.md),没放时提示原因。使用说明、设计方案、README 同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:10:07 +08:00
xuyueandClaude Opus 5.5 46e0f40cce 实训台接线:串口要选对。接线做成数据(BoardState.wiring),默认按教材布局图 COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,实验可在 meta.json 的 wiring 里改:项目 7 的书上代码在一个串口上打开两个模块,这三个实验把 4150 并到 COM2 的 485 总线上。选错串口时返回值照反编译的真机版 dll(4017 全 null、4150 全 false,4150 的 OnOff 和 LED 推送返回成功但设备不动作,ZigBee 返回 false);串口没打开时照教材仿真版。Open / Close / 没打开就读写报告给服务器,实训台最上方用红字提示("程序在 COM2 上打开 ADAM-4150,它接在 COM3""ADAM-4150 的串口没有打开,程序却在读写它"),评分不通过时也附上。只读接线展示:鼠标移到实训台设备上,标题栏写出线路,对应通道、模块、串口高亮;串口服务器按接线列出设备,程序打开的口亮绿点;程序里串口下拉框的灰字按当前接线。打开实验时浏览器发 experiment 消息换接线,老师看学生提交时按作业的实验接线(提交接口返回 sampleId)。"4017"统一写成 ADAM-4017+
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
上课前逐个检查 23 个实验后的修改:补上三处评分漏洞(项目 3 任务 1、项目 4 任务 3 的关闭和项目 7 任务 2 的熄灯,程序什么都不做也能通过);评分说明的空格;任务说明按实测补充(项目 2 任务 1–3、项目 3 任务 2、项目 4 任务 2、项目 7 任务 1–3、项目 8 任务 5)。使用说明、设计方案同步,设计方案记下以后可能扩展"物联网设备安装与调试"课

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:40:57 +08:00
xuyueandClaude Opus 5.5 163e553de4 日期选择框兼容机房 Chrome 94:以前点下拉按钮靠 showPicker() 弹出日期框,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应,项目 6 任务 3、项目 8 任务 5 选不了日期。改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选)。运行时长日期和中文 Windows 一致,不带星期(Linux 的 zh-CN 长日期是"2026年10月28日星期三",日期框窄,星期被截掉)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:40:57 +08:00
63 changed files with 2799 additions and 188 deletions

No files matched your search

+1
View File
@@ -7,4 +7,5 @@
.git
docs
*.pdf
textbook-libs
.env
+135
View File
@@ -0,0 +1,135 @@
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物、跑测试 → 产物 rsync 到服务器的 prebuilt/ →
# 服务器上 deploy/update.sh --prebuilt(拉代码、用现成产物构建镜像、重启)。
#
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 iot-deploy;和 OJ2 共用一个 runner,一次只跑一个任务,
# 两边的部署排队,不会同时挤服务器)。runner 的装法见 deploy/README.md「自动部署」。
# - 服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满。
# - 以前在 GitHub 上测试(约 5 分钟)再 SSH 到服务器编,push 后 6 分钟多才上线。
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;
# 急着上线又不在开发机旁时,用 GitHub 上手动触发的备用部署(.github/workflows/ci.yml,服务器自己编)。
#
# host 模式直接用开发机自己的东西:PATH 里的 bun / dotnet / bwrap、~/.ssh/config 里的 `debian`(root@服务器)
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout。
#
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows。
name: Deploy
on:
push:
branches: [main]
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
REMOTE_DIR: /root/IOT_SIM
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
jobs:
deploy:
runs-on: iot-deploy
timeout-minutes: 20
steps:
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件(node_modules 留着,bun install 会对齐它)
- name: 取代码
env:
SHA: ${{ gitea.sha }}
REPO: ${{ gitea.repository }}
run: |
git init -q .
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
git checkout -q --force FETCH_HEAD
git clean -ffdxq -e node_modules
git log --oneline -1
# server.js 是开发机的 bun 打包的,到服务器上由镜像里的 bun(Dockerfile 里写死的版本)运行。
# 打包出来是普通 JS,不像 OJ2 的单二进制那样把 bun 本身带过去,版本差一点一般没事,所以只提醒不拦。
- name: 看 bun 版本
run: |
want=$(sed -nE 's|^FROM oven/bun:([0-9.]+).*|\1|p' Dockerfile | head -1)
have=$(bun --version)
echo "开发机 bun $have,镜像里 bun $want"
[ "$have" = "$want" ] || echo "::warning::开发机的 bun 是 $have,镜像里是 $want,升级 Dockerfile 和 ci.yml 里的版本,或者确认没有用到新版本才有的功能"
- name: 装依赖
run: bun install --frozen-lockfile
- name: 编 C# 运行时
run: dotnet build runtime/Runtime.slnx -c Release -v q
- name: 编网页、打包服务端
run: |
(cd web && bun run build)
bun build server/src/index.ts --target=bun --outfile=server.js
# 有一项不过就不部署。服务端测试用上一步编的 Release 版运行时,测的就是要上线的那份。
# --parallel 让 6 个测试文件同时跑;grading 里的测试标了 describe.concurrent,
# --max-concurrency 限制同时跑几个(太多时定时器来不及触发会误判)。挨个跑约 4 分钟,这样半分钟左右
- name: 测试
env:
RUNNER_DLL: runtime/Sim.Runner/bin/Release/net10.0/Sim.Runner.dll
run: |
(cd server && bun test --parallel --max-concurrency=8)
(cd web && bun test src && bunx vue-tsc --noEmit)
# 目录结构就是 Dockerfile 里 artifacts-prebuilt 阶段要的;COMMIT 让服务器确认产物和要部署的代码是同一个提交
- name: 收集产物
env:
SHA: ${{ gitea.sha }}
run: |
rm -rf prebuilt
mkdir -p prebuilt/runtime prebuilt/web
cp -a runtime/Sim.Runner/bin/Release/net10.0/. prebuilt/runtime/
cp -a web/dist/. prebuilt/web/
cp server.js prebuilt/
echo "$SHA" > prebuilt/COMMIT
du -sh prebuilt
# 只传产物,源码由服务器自己从 origin 拉(git.xuyue.cc 就在那台服务器上)。
# -c 按内容比:每次编出来的 mtime 都是「刚才」,按 mtime 比会把没变的文件也传一遍
# --delay-updates 全部传完才一起换上去,中途断了不会留下半新半旧的产物(反正 COMMIT 对不上也会被 update.sh 拦下)
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用
- name: 同步到服务器
timeout-minutes: 10
run: |
for attempt in 1 2 3; do
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--compress --compress-choice=zstd \
--timeout=60 --stats --human-readable \
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
prebuilt/ "$REMOTE:$REMOTE_DIR/prebuilt/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# 先把服务器上的代码切到这个提交再跑 update.sh:不然跑的是上一版的 update.sh,改了它要晚一次部署才生效。
# 重启会结束正在运行的学生程序,上课时间尽量不要推送 main。
#
# update.sh 由 update-bg.sh 在服务器上后台跑,SSH 断了(开发机切代理、换网络)它照样跑完;
# 这边 ssh 返回 255(连接出错)就重连,用 --follow 接着看、拿退出码。
# 75 = 服务器上这个提交的部署还没开始(断在 git fetch 那里),重新开始。
- name: 起服务
timeout-minutes: 10
env:
SHA: ${{ gitea.sha }}
run: |
remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" "$1"; }
start="cd $REMOTE_DIR && git fetch --quiet origin && git reset --quiet --hard $SHA && deploy/update-bg.sh $SHA --prebuilt"
follow="cd $REMOTE_DIR && deploy/update-bg.sh --follow $SHA"
code=0; remote "$start" || code=$?
for attempt in 1 2 3 4 5 6; do
case $code in
255) echo "::warning::和服务器的 SSH 断了,部署在服务器上接着跑,10 秒后重连接着看"
sleep 10; code=0; remote "$follow" || code=$? ;;
75) echo "::warning::服务器上的部署还没开始,重新开始"
code=0; remote "$start" || code=$? ;;
*) exit $code ;;
esac
done
exit $code
+10 -9
View File
@@ -1,13 +1,13 @@
# 每次推送和 PR 都跑测试;推送到 main 且测试通过后,SSH 到服务器执行 deploy/update.sh(服务器从 origin 拉代码、构建、重启)。
# 部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
# 只在 github.com 上运行;仓库同时推到 git.xuyue.cc,那边的 Gitea Actions 也会读这个目录,用 if 跳过。
# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml),
# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。
# - PR:只跑测试。
# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、
# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。
name: CI/CD
on:
push:
branches: [main]
pull_request:
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
workflow_dispatch:
jobs:
@@ -37,9 +37,10 @@ jobs:
- name: 编译 C# 运行时
run: bun run build:runtime
# 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
working-directory: server
run: bun test
run: bun test --parallel --max-concurrency=8
- name: 网页端测试和类型检查
working-directory: web
@@ -49,7 +50,7 @@ jobs:
deploy:
needs: test
if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
if: github.server_url == 'https://github.com' && github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
timeout-minutes: 20
@@ -99,4 +100,4 @@ jobs:
- name: 部署(在服务器上拉代码、构建、重启)
if: steps.check.outputs.configured == 'true'
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update.sh ${{ github.sha }}"
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update-bg.sh ${{ github.sha }}"
+5
View File
@@ -10,6 +10,8 @@ obj/
# 教材资料(体积大、有版权,不进仓库)
物联网编程与应用配套/
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
/textbook-libs/
*.pdf
# 编辑器
@@ -25,3 +27,6 @@ obj/
# 服务器上 deploy/backup.sh 做的备份
/backups/
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
/prebuilt/
+23 -3
View File
@@ -1,5 +1,11 @@
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
# 构建:docker compose build;部署说明见 deploy/README.md
#
# 产物(C# 运行时、网页、server.js)有两种来源,由构建参数 ARTIFACTS 选:
# build (默认)在镜像里编。只要有 docker 就能构建,但服务器只有 2 核,编一次约一分钟,期间学生程序会卡
# prebuilt 用 prebuilt/ 里现成的产物(开发机上的 gitea-runner 编好、测过后 rsync 过来,见 .gitea/workflows/deploy.yml),
# 镜像构建只剩几条 COPY。buildx 不会去跑用不到的 runtime-build / web-build 阶段
ARG ARTIFACTS=build
# ---- C# 运行时(编译服务 + 仿真库) ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
@@ -22,6 +28,20 @@ RUN cd web && bun run build
# 服务端打包成单个文件,运行镜像里不需要 node_modules
RUN bun build server/src/index.ts --target=bun --outfile=server.js
# ---- 产物:在镜像里编的 ----
FROM scratch AS artifacts-build
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ /runtime/
COPY --from=web-build /src/web/dist/ /web/
COPY --from=web-build /src/server.js /server.js
# ---- 产物:现成的(目录结构和上面一样,名字里不带 dist / bin,免得被 .dockerignore 挡掉) ----
FROM scratch AS artifacts-prebuilt
COPY prebuilt/runtime/ /runtime/
COPY prebuilt/web/ /web/
COPY prebuilt/server.js /server.js
FROM artifacts-${ARTIFACTS} AS artifacts
# ---- 运行镜像 ----
FROM mcr.microsoft.com/dotnet/runtime:10.0
RUN apt-get update \
@@ -29,9 +49,9 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
COPY --from=web-build /src/web/dist/ web/dist/
COPY --from=web-build /src/server.js ./
COPY --from=artifacts /runtime/ runtime/
COPY --from=artifacts /web/ web/dist/
COPY --from=artifacts /server.js ./
COPY samples/ samples/
ENV APP_ROOT=/app \
RUNNER_DLL=runtime/Sim.Runner.dll \
+4 -3
View File
@@ -38,7 +38,7 @@ BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学
## 部署
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。
## 运行器的四种模式
@@ -73,11 +73,12 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
- 第 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)。
尚未完成:
- 一个班试用。
- 书上的扩展练习("边想边做""自主探究")、导出 VS 工程、校园街景场景、故障注入题库(第 4 阶段)。
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
+14 -2
View File
@@ -5,7 +5,11 @@ services:
init-data:
image: iot-sim:latest
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
build: .
build: &build
context: .
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
args:
ARTIFACTS: ${ARTIFACTS:-build}
user: root
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
volumes:
@@ -15,8 +19,13 @@ services:
app:
image: iot-sim:latest
build: .
build: *build
restart: unless-stopped
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
driver: json-file
options:
max-size: 10m
max-file: '3'
depends_on:
init-data:
condition: service_completed_successfully
@@ -41,6 +50,8 @@ services:
# 数据库(账号、班级、学生作品),重新部署不会丢
volumes:
- data:/data
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
- ./textbook-libs:/app/textbook-libs:ro
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
@@ -58,6 +69,7 @@ services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
logging: *logging
depends_on: [app]
expose:
- 80
+68 -16
View File
@@ -66,15 +66,35 @@
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
## 自动部署(GitHub Actions)
## 自动部署(push origin 就部署)
`.github/workflows/ci.yml`:
`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。
- 每次推送和 PR:编译 C# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。
- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。
- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。
- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。
为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。
**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。
### 部署 runner
和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。
host 模式直接用开发机自己的东西,所以**不需要配任何 secret**:
- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync;
- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`);
- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。
开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。
### 备用:GitHub 手动触发
`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update-bg.sh <提交号>`(后台跑 `update.sh`),**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。
一次性配置(在本机执行,需要已登录的 `gh`):
@@ -96,15 +116,47 @@
在服务器的 `~/IOT_SIM` 目录下:
| 做什么 | 命令 |
| ------------ | --------------------------------------------------------------------------------------- |
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
| 看资源占用 | `docker stats` |
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
| 做什么 | 命令 |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
| 看资源占用 | `docker stats` |
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 导出 EXE、VS 工程用的教材类库
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
| 文件 | 在配套资料里的位置 |
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `DeviceCtrlLibrary.dll` | `公共库/设备控制类库/设备控制/`(**不是** `仿真/`) |
| `ComLibrary.dll`、`ICS.Acquisition.dll`、`ICS.Common.dll`、`ICS.Models.dll`、`IPCameraDll.dll`、`LEDLibrary.dll`、`MyCamer.dll` | `公共库/设备控制类库/设备控制/API/` |
| `WinFormsDeviceControlLibrary.dll` | `公共库/设备控件库/` |
| `AxInterop.WMPLib.dll`、`Interop.WMPLib.dll`(媒体播放器,项目 7 任务 2) | `项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/` |
在本机(配套资料解压在 `~/下载/物联网编程与应用配套/`)执行:
```bash
B=~/下载/物联网编程与应用配套/物联网编程与应用
ssh 服务器 mkdir -p IOT_SIM/textbook-libs
scp "$B"/公共库/设备控制类库/设备控制/DeviceCtrlLibrary.dll "$B"/公共库/设备控制类库/设备控制/API/*.dll \
"$B"/公共库/设备控件库/WinFormsDeviceControlLibrary.dll \
"$B"/项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/*Interop.WMPLib.dll 服务器:IOT_SIM/textbook-libs/
```
容器里的平台用普通用户运行,目录和文件要让其他用户能读(用 root 拷的文件常常是 `-rw-------`,导出时会提示"权限不够"):
```bash
chmod 755 ~/IOT_SIM/textbook-libs
chmod 644 ~/IOT_SIM/textbook-libs/*.dll
```
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
## 备份与恢复
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
# 这个提交的部署还没开始过时退出码 75
#
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
set -euo pipefail
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
main() {
cd "$(dirname "$0")/.."
local state=/var/tmp/iot_sim-update
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
if [[ ${1:-} == --follow ]]; then
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
echo "服务器上没有 $sha 的部署记录(还没开始)"
exit 75
fi
follow
fi
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
local old
old=$(cat $state.pid 2>/dev/null || true)
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
while kill -0 "$old" 2>/dev/null; do sleep 2; done
fi
rm -f $state.pid $state.exit
echo "$sha" >$state.sha
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
"$state" "$@" >$state.log 2>&1 </dev/null &
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
follow
}
follow() {
local pid code
pid=$(cat $state.pid 2>/dev/null || true)
if [[ -n $pid ]]; then
# update 进程结束后 tail 自己退出
tail -n +1 -f --pid="$pid" $state.log
else
cat $state.log 2>/dev/null || true
fi
code=$(cat $state.exit 2>/dev/null || true)
if [[ -z $code ]]; then
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
exit 1
fi
exit "$code"
}
main "$@"
+37 -5
View File
@@ -1,14 +1,27 @@
#!/usr/bin/env bash
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
# GitHub Actions 测试通过后通过 SSH 调用它,也可以登录服务器手动执行。
# 用法:deploy/update.sh [提交号,默认 origin/main]
# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt]
#
# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。
# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。
# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner
# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交,
# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。
#
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
set -euo pipefail
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
main() {
cd "$(dirname "$0")/.."
local ref=${1:-origin/main}
local ref=origin/main prebuilt=0 arg
for arg in "$@"; do
case "$arg" in
--prebuilt) prebuilt=1 ;;
-*) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;;
*) ref=$arg ;;
esac
done
if [[ ! -f .env ]]; then
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
@@ -16,8 +29,7 @@ main() {
fi
echo "== 拉取代码"
# 代码从 origin(git.xuyue.cc)拉取。Actions 由推送到 GitHub 触发,
# 两个仓库推送有先后,这个提交可能还没到 origin,多等一会儿
# 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿
for attempt in $(seq 10); do
git fetch --quiet origin
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
@@ -33,7 +45,27 @@ main() {
git reset --quiet --hard "$ref"
git log --oneline -1
if [[ $prebuilt == 1 ]]; then
local head built
head=$(git rev-parse HEAD)
built=$(cat prebuilt/COMMIT 2>/dev/null || true)
if [[ $built != "$head" ]]; then
echo "prebuilt/ 里的产物是 ${built:-(没有)} 编的,要部署的是 $head。"
echo '重新跑一次开发机上的部署,或者去掉 --prebuilt 让服务器自己编'
exit 1
fi
for f in prebuilt/runtime/Sim.Runner.dll prebuilt/web/index.html prebuilt/server.js; do
[[ -e $f ]] || { echo "prebuilt/ 不完整,缺 $f"; exit 1; }
done
export ARTIFACTS=prebuilt
echo "== 用开发机编好的产物($(du -sh prebuilt | cut -f1))"
else
export ARTIFACTS=build
fi
echo "== 构建镜像并重启"
# caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份
docker compose pull --quiet caddy
docker compose up -d --build --remove-orphans
docker image prune -f >/dev/null
+47 -37
View File
@@ -51,13 +51,14 @@
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤)。
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
**导入 VS 工程、导出 EXE 和 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件;也能导出成教材那样的 VS 2012 工程(zip),在机房的 VS 里接着改。
## 设备类库设计(兼容教材)
@@ -97,7 +98,10 @@ private void btn_Send_Click(object sender, EventArgs e)
兼容细节:
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
- 4017 和 4150 允许打开同一个串口;允许重复调用 `Open`。
- **串口要选对**(按实训台接线):接线按教材布局图,ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,即 COM2 4017+、COM3 4150、COM4 ZigBee 协调器、COM5 LED 屏(`shared/protocol.ts` 的 `DEFAULT_WIRING`)。实验可以在 `meta.json` 的 `wiring` 里改:项目 7 的书上代码在一个串口上打开两个模块(书上截图是用仿真版 dll 截的,选哪个口都行),这三个实验把 4150 并到 COM2 的 485 总线上(两个模块地址不同,真机上也能这样接)。
- 在没接这台设备的串口上打开它,返回值照真机版 dll(已反编译核对):`Open` 不报错;4017 的 `SetData()` 全是 null、4150 的全是 false;4150 的 `OnOff` 只管发命令、不等回应,返回 true 但继电器不动作;ZigBee 的 `OnOff` 收不到回应,返回 false;LED 的 `SendLedString` 原样返回文字但屏上不显示。真机上这只是"没反应",平台另外在实训台最上方用红字提示"程序在 COM2 上打开 ADAM-4150,它接在 COM3",评分不通过时也附上这句。
- 串口没打开(或已经 `Close`)时程序还在读数据、控制设备:返回值照教材仿真版 dll(4150 的 `SetData()` 返回各项为 false 的对象,不是真机版的 null;书上代码是照仿真版写的,返回 null 会让学生看到一个指不出原因的空引用异常),实训台同样用红字提示"ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)",评分说明也附上。学生忘了点"打开"时一眼能看出原因。
- 允许重复调用 `Open`(项目 7 任务 3 在定时器里每秒打开一次)。
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
- 摄像头事件在非界面线程触发,与真实行为一致。
@@ -150,37 +154,37 @@ IOT/
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
| 层 | 用途 | 选择 |
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
| 前端 | 组件库(网站外壳) | Naive UI |
| 前端 | 代码编辑器 | Monaco Editor |
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
| 前端 | 图表 | 计划:Chart.js |
| 前端 | 实时通信 | 浏览器原生 WebSocket |
| 后端 | 运行时 | Bun |
| 后端 | Web 框架 | Hono |
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS |
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
| 工程 | 仓库 | Bun workspaces 单仓库 |
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
| 层 | 用途 | 选择 |
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
| 前端 | 组件库(网站外壳) | Naive UI |
| 前端 | 代码编辑器 | Monaco Editor |
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
| 前端 | 图表 | 计划:Chart.js |
| 前端 | 实时通信 | 浏览器原生 WebSocket |
| 后端 | 运行时 | Bun |
| 后端 | Web 框架 | Hono |
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS |
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
| 工程 | 仓库 | Bun workspaces 单仓库 |
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
## 安全与服务器资源
@@ -224,7 +228,7 @@ IOT/
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
老师不写代码也能修改或新增实验。
@@ -238,7 +242,7 @@ flowchart TB
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
P2 -->|"关口:一个班试用顺利"| P3
@@ -249,6 +253,8 @@ flowchart TB
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
**可能的扩展:物联网设备安装与调试课**(2026-10-28 讨论,还没定开不开)。那门课的核心是接线(传感器信号线、电源、485 的 A/B 线)、用厂家工具配置设备(ADAM 地址、波特率、4017+ 量程,ZigBee 网络号)、用串口调试助手收发报文、排故,和本课"不学协议、不接线"正好相反。仿真替代不了拧螺丝,但适合预习、在家练、接错不烧设备,尤其是老师设故障让学生找的**排故练习**(和"故障注入题库"是一回事)。现在为它做的准备是把接线做成数据(`BoardState.wiring`),本课用实验预设的接线、只读显示;将来那门课在同一份数据上让学生改接线,再加上通道接线、电源、配置工具。开工前要先拿到那门课的教材和实训台资料。
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
| 指标 | 结果 |
@@ -335,6 +341,10 @@ flowchart TB
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
- 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。
- 2026-11-03:**导出 VS 工程**。实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。讨论后定下:机房装的是 VS 2012;代码有错不让导出(和 EXE 一样);这次不做把工程导回平台。①检查:编译服务收到 `export.kind = "project"` 的请求时(`runtime/Sim.Runner/ProjectExporter.cs`),照机房的环境编译一遍要放进 zip 的代码:**C# 5**(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库。新版 C# 的写法报 CS8026,`CompileHints` 按报错里的功能名给出改法(字符串插值改 `string.Format`、`?.` 改先判断 null、`=>` 改大括号等 12 种)。导出 EXE 仍按最新的 C# 编译,不受影响。23 个示例都是 C# 5 的写法,全部能导出。②工程照教材的样子(VS 2012 窗体应用,`.sln` + `.csproj`,ToolsVersion 4.0):平台生成的 `Properties/Resources.Designer.cs` 从素材库取图,导出时换成 VS 的 ResXFileCodeGenerator 生成的写法和 `Resources.resx`(ResXFileRef 指向 `Resources\` 下的图片);代码里按文件名用到的素材(ring.mp3)设成复制到输出目录,运行时在 `bin\Debug\Resources\`,和教材一样;每个窗体、用户控件带 `.resx`(媒体播放器的 OcxState 用教材的默认状态),`SubType`、`DependentUpon` 和 VS 一样,VS 里能打开设计器;补上 AssemblyInfo、Settings、App.config。③引用和教材工程一样只加代码直接用到的教材 dll(一般是 DeviceCtrlLibrary 和 WinFormsDeviceControlLibrary,项目 7 寝室加媒体播放器的两个 Interop),全部教材 dll 放在工程的 `lib\`,HintPath 指过去,编译时 VS 把依赖一起复制到 `bin\Debug`;只带这些引用编译不过时引用全部。书上的 `ledHelper.Close()` 用到时带上 `LedHelperCompat.cs`。④文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 的文件名用 GBK(中文 Windows 资源管理器自带的解压、老的压缩软件都按 GBK 读,UTF-8 在 Windows 7 上会乱码),另附 Info-ZIP 的 Unicode 文件名字段(0x7075),7-Zip、Linux 的 unzip 在别的语言的系统上也显示正确(`ZipWriter.cs`)。⑤23 个示例导出的工程在 mono 的 msbuild 里全部编译通过,输出目录和教材工程的 `bin\Debug` 一样,程序能启动;VS 2012 还要在机房电脑上实测。接口 `POST /api/export-project`,返回 zip,代码有错时 422,弹框说明"照机房电脑上的 VS 2012 编译"。
## 技术风险与待确认事项
@@ -356,7 +366,7 @@ flowchart TB
**待确认**
- [ ] 串口是否要求接对:目前学生选任意 COM 口都能控制设备;是否在部分实验中要求按实训台接线(COM2 4017、COM3 4150、COM4 ZigBee、COM5 LED)
- [x] 串口要求接对:按实训台接线(COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,项目 7 的 4150 并在 COM2),选错和真机一样没反应,实训台上提示
- [ ] 虚拟实训台的画面细节(目前为第一版)
- [x] 第一个场景:智慧校园(和教材一致)
- [x] 设备写法:兼容教材类库
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DeviceCtrlLibrary.Model;
using Sim.Host;
@@ -7,7 +8,8 @@ namespace DeviceCtrlLibrary;
/// <summary>
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数,
/// 并且和真机版一样,设备要在它接的那个串口上打开才有反应(接线见 TrainingBoard)。
/// </summary>
public class DeviceCtrlHelper
{
@@ -20,6 +22,38 @@ public class DeviceCtrlHelper
public static IPCameraHelper ipCameraHelper = new();
}
/// <summary>
/// 告诉实训台程序在哪个串口上打开、关闭了设备,以及串口没打开时还在读写设备(网页上据此提示)。
/// 没变就不发:书上有在定时器里反复 Open 的写法;没打开时的读写每次打开之后最多报一次
/// </summary>
internal static class PortReport
{
private static readonly HashSet<string> Unopened = new();
public static string? Open(string device, string? current, string? portName)
{
lock (Unopened) Unopened.Remove(device);
if (current != portName) SimHost.SendDevice(device, "open", portName ?? "");
return portName;
}
public static string? Close(string device, string? current)
{
if (current is not null) SimHost.SendDevice(device, "close", null);
return null;
}
/// <summary>串口没打开(portName 为空)时返回 true,并报告一次</summary>
public static bool NotOpen(string device, string? portName)
{
if (!string.IsNullOrWhiteSpace(portName)) return false;
bool first;
lock (Unopened) first = Unopened.Add(device);
if (first) SimHost.SendDevice(device, "unopened", null);
return true;
}
}
public class SerialPortHelper
{
private string? _portName;
@@ -48,18 +82,21 @@ public class LEDHelper
private const string SendFailed = "Error:发送失败";
private string? _portName;
public void OpenLedCom(string portName) => _portName = portName;
public void OpenLedCom(string portName) => _portName = PortReport.Open("led", _portName, portName);
/// <summary>成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"</summary>
/// <summary>
/// 成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"。
/// 打开的串口上没接 LED 屏时和真机一样也原样返回(真机只管发出去、不等回应),只是屏上不显示
/// </summary>
public string SendLedString(string sendString)
{
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
SimHost.SendDevice("led", "text", sendString);
if (PortReport.NotOpen("led", _portName)) return SendFailed;
if (TrainingBoard.Reaches("led", _portName)) SimHost.SendDevice("led", "text", sendString);
return sendString;
}
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
public void Close() => _portName = null;
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留它让书上代码能编译</summary>
public void Close() => _portName = PortReport.Close("led", _portName);
}
public class ADAM4017Helper
@@ -70,19 +107,19 @@ public class ADAM4017Helper
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
{
_portName = PortName;
_portName = PortReport.Open("adam4017", _portName, PortName);
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
}
/// <summary>
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
/// 串口未打开时所有属性为 null,与教材仿真版一致。
/// 串口未打开时所有属性为 null,与教材仿真版一致;打开的串口上没接 4017+ 时收不到回应,和真机版一样也全是 null。
/// </summary>
public ADAM4017ResModel SetData()
{
LegacyThreads.CheckPoint();
var model = new ADAM4017ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
if (PortReport.NotOpen("adam4017", _portName) || !TrainingBoard.Reaches("adam4017", _portName)) return model;
model.temperatureValue = Read(0);
model.humidityValue = Read(1);
model.lightValue = Read(2);
@@ -95,7 +132,7 @@ public class ADAM4017Helper
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("adam4017", _portName);
}
public class ADAM4150Helper
@@ -105,16 +142,16 @@ public class ADAM4150Helper
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
{
_portName = PortName;
_portName = PortReport.Open("adam4150", _portName, PortName);
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
}
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开、或打开的串口上没接 4150(收不到回应)时全部为 false</summary>
public ADAM4150ResModel SetData()
{
LegacyThreads.CheckPoint();
var model = new ADAM4150ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
if (PortReport.NotOpen("adam4150", _portName) || !TrainingBoard.Reaches("adam4150", _portName)) return model;
model.bodyInfraredValue = Read(0);
model.fireValue = Read(1);
model.smokeValue = Read(2);
@@ -126,31 +163,35 @@ public class ADAM4150Helper
/// <summary>
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物);
/// 真机版只管发出命令、不等回应,所以打开的串口上没接 4150 时也返回 true,只是继电器不动作。
/// </summary>
public bool OnOff(int DO, bool IsOpen)
{
if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false;
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
if (DO < 0 || DO > 4 || PortReport.NotOpen("adam4150", _portName)) return false;
if (TrainingBoard.Reaches("adam4150", _portName)) SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("adam4150", _portName);
}
public class ZigBeeHelper
{
private string? _portName;
public void Open(string PortName) => _portName = PortName;
public void Open(string PortName) => _portName = PortReport.Open("zigbee", _portName, PortName);
/// <summary>控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false</summary>
/// <summary>
/// 控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false。
/// 真机版要等协调器回应,打开的串口上没接协调器时收不到回应,也返回 false
/// </summary>
public bool OnOff(int RelayNum, bool IsOpen)
{
if (RelayNum < 0 || RelayNum > 1 || string.IsNullOrWhiteSpace(_portName)) return false;
if (RelayNum < 0 || RelayNum > 1 || PortReport.NotOpen("zigbee", _portName) || !TrainingBoard.Reaches("zigbee", _portName)) return false;
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("zigbee", _portName);
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace DeviceCtrlLibrary;
@@ -15,6 +16,14 @@ public static class TrainingBoard
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
private static bool[] _digital = new bool[7];
// 接线:设备 → 串口(教材布局图的接法,随实验而定,见 shared/protocol.ts 的 DEFAULT_WIRING)
private static readonly Dictionary<string, string> Wiring = new()
{
["adam4017"] = "COM2",
["adam4150"] = "COM3",
["zigbee"] = "COM4",
["led"] = "COM5",
};
static TrainingBoard()
{
@@ -22,7 +31,7 @@ public static class TrainingBoard
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
}
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…}(clock 不带时表示不变)</summary>
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…,"wiring":{…}}(clock、wiring 不带时表示不变)</summary>
public static void Update(JsonObject json)
{
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
@@ -32,9 +41,22 @@ public static class TrainingBoard
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
if (json["digital"] is JsonArray digital)
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
if (json["wiring"] is JsonObject wiring)
foreach (var (device, port) in wiring)
if (Wiring.ContainsKey(device) && port is not null) Wiring[device] = (string)port!;
}
}
/// <summary>
/// 程序打开的串口上有没有接这台设备。和真机一样,串口打开错了不报错,只是设备收不到命令、也不回应。
/// Windows 的串口名不分大小写
/// </summary>
internal static bool Reaches(string device, string? portName)
{
if (string.IsNullOrWhiteSpace(portName)) return false;
lock (Lock) return Wiring.TryGetValue(device, out var port) && string.Equals(port, portName.Trim(), StringComparison.OrdinalIgnoreCase);
}
internal static int ReadAnalog(int channel)
{
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
+26
View File
@@ -1,3 +1,4 @@
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Sim.Runner;
@@ -23,7 +24,32 @@ internal static class CompileHints
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
("CS8026", _) => Vs2012(diagnostic),
_ => null,
};
}
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
private static readonly (string Feature, string Fix)[] NewerFeatures =
{
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
("nameof", "nameof(x) 直接写成字符串 \"x\""),
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
("default 字面量", "default 写成 default(类型),例如 default(int)"),
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
};
private static string Vs2012(Diagnostic diagnostic)
{
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
}
}
+40 -1
View File
@@ -11,12 +11,16 @@ namespace Sim.Runner;
/// <summary>
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
/// </summary>
internal static class CompileServer
{
public const string AssemblyFile = "StudentApp.dll";
public const string PdbFile = "StudentApp.pdb";
public const string ExportFile = "export.exe";
public const string ExportProjectFile = "export.zip";
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
@@ -36,7 +40,10 @@ internal static class CompileServer
{
var request = JsonNode.Parse(line)!.AsObject();
id = request["id"]?.DeepClone();
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
reply = request["export"] is JsonObject export
? Export(dir, (string?)export["kind"] == "project", (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
: Compile(dir);
}
catch (Exception ex)
{
@@ -66,6 +73,38 @@ internal static class CompileServer
};
}
/// <summary>
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
/// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
/// </summary>
private static JsonObject Export(string dir, bool project, string libs, string name)
{
var watch = Stopwatch.StartNew();
if (string.IsNullOrWhiteSpace(name)) name = "上位机程序";
bool ok;
JsonArray diagnostics;
if (project)
{
var result = ProjectExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportProjectFile), result.Zip);
(ok, diagnostics) = (result.Success, result.Diagnostics);
}
else
{
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
(ok, diagnostics) = (result.Success, result.Diagnostics);
}
return new JsonObject
{
["type"] = "compiled",
["ok"] = ok,
["diagnostics"] = diagnostics,
["ms"] = watch.ElapsedMilliseconds,
};
}
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
private static void WarmUp()
{
+313
View File
@@ -0,0 +1,313 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
/// <list type="bullet">
/// <item>编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。</item>
/// <item>教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。</item>
/// <item>Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。</item>
/// </list>
/// </summary>
internal static class ExeExporter
{
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
private const string LibPrefix = "IotSimExport.lib.";
private const string FilePrefix = "IotSimExport.file.";
private const string MediaPrefix = "IotSimExport.media.";
/// <summary>
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
/// </summary>
internal const string MediaPlayerOcxState =
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
"AAAL";
private const string AxHostStateType =
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
/// <summary>平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取</summary>
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
.WithPlatform(Platform.AnyCpu32BitPreferred)
.WithOptimizationLevel(OptimizationLevel.Release)
.WithNullableContextOptions(NullableContextOptions.Disable)
.WithAllowUnsafe(false)
.WithConcurrentBuild(true);
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
.ToList());
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
{
var libs = TextbookLibs(libsDir, "导出 EXE");
var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
.ToList();
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
var files = new SortedSet<string>(StringComparer.Ordinal);
for (var i = 0; i < sources.Count; i++)
{
if (!sources[i].Text.Contains(PlatformResources)) continue;
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
}
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
var trees = sources
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
.ToList();
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
var resources = new List<ResourceDescription>();
foreach (var lib in libs)
{
var path = lib;
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
}
foreach (var file in files)
{
var path = Path.Combine(galleryDir, file);
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
}
foreach (var file in media)
{
var path = Path.Combine(galleryDir, file);
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
}
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
using var pe = new MemoryStream();
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
var diagnostics = new JsonArray();
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
}
return new Result(emit.Success, pe.ToArray(), diagnostics);
}
/// <summary>代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序</summary>
internal static List<string> ReferencedMedia(string galleryDir, IEnumerable<string> texts)
{
if (!Directory.Exists(galleryDir)) return new List<string>();
var all = texts.ToList();
return Directory.GetFiles(galleryDir)
.Select(Path.GetFileName)
.OfType<string>()
.Where(name => !name.Contains(".2x.") && name != "index.json")
.Where(name => all.Any(text => text.Contains(name + "\"")))
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
/// </summary>
internal static string[] TextbookLibs(string libsDir, string what)
{
var libs = ReadableLibs(libsDir, what);
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
return libs;
}
private static string[] ReadableLibs(string libsDir, string what)
{
if (!Directory.Exists(libsDir)) return Array.Empty<string>();
string? current = null;
try
{
var libs = Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray();
foreach (var lib in libs)
{
current = lib;
using var _ = File.OpenRead(lib);
}
return libs;
}
catch (UnauthorizedAccessException)
{
var item = current == null ? "目录" : Path.GetFileName(current);
throw new InvalidOperationException(
$"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
}
}
/// <summary>
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
/// </summary>
private static List<ResourceDescription> ControlResources(CSharpCompilation compilation, HashSet<string> ocxKeys)
{
var stream = new MemoryStream();
using (var writer = new System.Resources.ResourceWriter(stream))
{
var state = Convert.FromBase64String(MediaPlayerOcxState);
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
writer.Generate();
}
var data = stream.ToArray();
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
var resources = new List<ResourceDescription>();
if (control == null) return resources;
var names = new HashSet<string>();
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
{
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
var derives = false;
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
}
}
return resources;
}
/// <summary>
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
/// </summary>
private static string Bootstrap(IReadOnlyList<string> media) => $$"""
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
namespace System.Runtime.CompilerServices
{
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
}
/// <summary>
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
/// </summary>
internal static class IotSimExportLedCompat
{
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
}
namespace IotSimExport
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
internal static class Bootstrap
{
private static readonly Dictionary<string, Assembly> Loaded = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Init()
{
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
ExtractMedia();
}
private static Assembly Resolve(object sender, ResolveEventArgs args)
{
var name = new AssemblyName(args.Name).Name;
lock (Loaded)
{
Assembly assembly;
if (Loaded.TryGetValue(name, out assembly)) return assembly;
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
if (bytes == null) return null;
assembly = Assembly.Load(bytes);
Loaded[name] = assembly;
return assembly;
}
}
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
private static void ExtractMedia()
{
foreach (var file in Media)
{
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
var path = Path.Combine(dir, file);
if (File.Exists(path)) continue;
Directory.CreateDirectory(dir);
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
}
catch (Exception)
{
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
}
}
}
}
internal static class EmbeddedFiles
{
internal static byte[] Read(string name)
{
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
{
if (stream == null) return null;
var bytes = new byte[stream.Length];
var read = 0;
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
return bytes;
}
}
internal static global::System.Drawing.Bitmap Image(string file)
{
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
}
internal static byte[] Bytes(string file)
{
return Read("{{FilePrefix}}" + Path.GetFileName(file));
}
}
}
""";
}
+625
View File
@@ -0,0 +1,625 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。
/// <list type="bullet">
/// <item>先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。</item>
/// <item>平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹;
/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。</item>
/// <item>教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。</item>
/// <item>文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。</item>
/// </list>
/// </summary>
internal static class ProjectExporter
{
public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics);
/// <summary>VS 2012 的编译器支持到 C# 5</summary>
internal static readonly CSharpParseOptions Vs2012 = new(LanguageVersion.CSharp5);
private const string PlatformResourcesFile = "Properties/Resources.Designer.cs";
private const string LedCompatFile = "LedHelperCompat.cs";
private const string LedCompatClass = "LedHelperCompat";
private static readonly Regex ResourceMember = new(
@"internal static (System\.Drawing\.Bitmap|byte\[\]) ([\p{L}\p{N}_]+) \{\s*get \{ return global::Sim\.Host\.ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\); \}");
private static readonly Regex ResourcesNamespace = new(@"namespace\s+([\p{L}\p{N}_.]+)\.Properties\b");
private static readonly Regex FirstNamespace = new(@"^\s*namespace\s+([\p{L}\p{N}_.]+)", RegexOptions.Multiline);
/// <summary>VS 2012 窗体应用模板默认的引用(教材工程都有这些)</summary>
private static readonly string[] DefaultReferences =
{
"System", "System.Core", "System.Xml.Linq", "System.Data.DataSetExtensions", "Microsoft.CSharp",
"System.Data", "System.Deployment", "System.Drawing", "System.Windows.Forms", "System.Xml",
};
private const string BitmapType = "System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a";
private const string BytesType = "System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
private sealed record ResourceItem(string Name, bool Image, string File);
private sealed record Source(string Path, string Text);
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名)</summary>
public static Result Export(string projectDir, string libsDir, string galleryDir, string name)
{
var libs = ExeExporter.TextbookLibs(libsDir, "导出工程");
var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => new Source(Path.GetRelativePath(projectDir, path).Replace('\\', '/'), File.ReadAllText(path)))
.ToList();
var platformResources = sources.FirstOrDefault(s => s.Path == PlatformResourcesFile)?.Text;
var items = platformResources == null
? new List<ResourceItem>()
: ResourceMember.Matches(platformResources)
.Select(m => new ResourceItem(m.Groups[2].Value, m.Groups[1].Value != "byte[]", Path.GetFileName(m.Groups[3].Value)))
.ToList();
var rootNamespace = RootNamespace(sources, platformResources, name);
sources.RemoveAll(s => s.Path == PlatformResourcesFile);
// VS 模板里有、平台上没有的文件(Properties 文件夹由平台管理,学生建不了同名文件)
var generated = new List<Source>
{
new("Properties/AssemblyInfo.cs", AssemblyInfo(name)),
new("Properties/Resources.Designer.cs", ResourcesDesigner(rootNamespace, items)),
new("Properties/Settings.Designer.cs", SettingsDesigner(rootNamespace)),
};
var compat = new Source(LedCompatFile, LedCompat);
// 照机房的环境编译一遍:C# 5、.NET Framework 4.5、真机版设备类库
var trees = sources.Concat(generated).Append(compat)
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), Vs2012, path: s.Path))
.ToList();
var libReferences = libs.ToDictionary(p => (MetadataReference)MetadataReference.CreateFromFile(p), p => p);
var compilation = CSharpCompilation.Create(name, trees, ExeExporter.Framework.Value.Concat(libReferences.Keys), ExeExporter.Options);
using (var pe = new MemoryStream())
{
var emit = compilation.Emit(pe);
var diagnostics = new JsonArray();
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
}
if (!emit.Success) return new Result(false, Array.Empty<byte>(), diagnostics);
}
var studentTrees = trees.Take(sources.Count).ToList();
var code = sources.Concat(generated).ToList();
if (UsesLedCompat(compilation, studentTrees))
{
code.Add(compat);
studentTrees.Add(trees[^1]);
}
else
{
compilation = compilation.RemoveSyntaxTrees(trees[^1]);
}
// 引用:和教材工程一样,教材 dll 只引用代码里直接用到的(其他的作为依赖由 VS 复制到输出目录),
// 框架引用在 VS 模板默认的之外加上用到的(如图表控件)。只带这些引用编译不过时(用到的类型的基类在别的 dll 里等)引用全部教材 dll
var direct = DirectlyUsedAssemblies(compilation, studentTrees);
var frameworkReferences = DefaultReferences
.Concat(ExeExporter.Framework.Value.OfType<PortableExecutableReference>()
.Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType<string>()
.Where(n => direct.Contains(n) && n != "mscorlib" && !DefaultReferences.Contains(n))
.OrderBy(n => n, StringComparer.Ordinal))
.ToList();
var textbookReferences = libs.Where(p => direct.Contains(Path.GetFileNameWithoutExtension(p))).ToList();
var reduced = compilation.WithReferences(ExeExporter.Framework.Value.Concat(libReferences.Where(r => textbookReferences.Contains(r.Value)).Select(r => r.Key)));
if (reduced.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) textbookReferences = libs.ToList();
textbookReferences = textbookReferences.Select(p => Path.GetFileName(p)).ToList();
var forms = Forms(compilation, sources, studentTrees);
var media = ExeExporter.ReferencedMedia(galleryDir, sources.Select(s => s.Text));
var images = items.Select(i => i.File).Where(f => File.Exists(Path.Combine(galleryDir, f))).Distinct().ToList();
var zip = new ZipWriter(DateTime.UtcNow.AddHours(8));
var root = $"{name}/";
var dir = $"{name}/{name}/";
zip.Add(root + name + ".sln", Text(Solution(name)));
zip.Add(dir + name + ".csproj", Text(Project(name, rootNamespace, code, forms, frameworkReferences, textbookReferences, images, media)));
zip.Add(dir + "App.config", Text(AppConfig));
zip.Add(dir + "Properties/Settings.settings", Text(SettingsFile));
zip.Add(dir + "Properties/Resources.resx", Text(Resx(items.Select(ResourceEntry))));
foreach (var source in code) zip.Add(dir + source.Path, Text(source.Text));
foreach (var form in forms.Where(f => f.Resx))
{
zip.Add(dir + Path.ChangeExtension(form.Path, ".resx"), Text(Resx(form.OcxKeys.Select(OcxEntry))));
}
foreach (var file in images.Union(media).OrderBy(f => f, StringComparer.Ordinal))
{
zip.Add(dir + "Resources/" + file, File.ReadAllBytes(Path.Combine(galleryDir, file)));
}
foreach (var lib in libs) zip.Add(dir + "lib/" + Path.GetFileName(lib), File.ReadAllBytes(lib));
return new Result(true, zip.Finish(), new JsonArray());
}
/// <summary>工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间</summary>
private static string RootNamespace(List<Source> sources, string? platformResources, string name)
{
if (platformResources != null && ResourcesNamespace.Match(platformResources) is { Success: true } r) return r.Groups[1].Value;
foreach (var source in sources.OrderBy(s => s.Path == "Program.cs" ? 0 : 1).ThenBy(s => s.Path, StringComparer.Ordinal))
{
var m = FirstNamespace.Match(source.Text);
if (m.Success) return m.Groups[1].Value;
}
return name;
}
/// <summary>代码里直接用到的类型、成员所在的程序集(程序集名)</summary>
private static HashSet<string> DirectlyUsedAssemblies(CSharpCompilation compilation, List<SyntaxTree> trees)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(ISymbol? symbol)
{
while (symbol is IArrayTypeSymbol array) symbol = array.ElementType;
if (symbol?.ContainingAssembly is { } assembly) names.Add(assembly.Identity.Name);
if (symbol is INamedTypeSymbol { IsGenericType: true } generic)
{
foreach (var argument in generic.TypeArguments) Add(argument);
}
}
foreach (var tree in trees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var node in tree.GetRoot().DescendantNodes())
{
Add(model.GetSymbolInfo(node).Symbol);
Add(model.GetTypeInfo(node).Type);
}
}
return names;
}
/// <summary>学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有)</summary>
private static bool UsesLedCompat(CSharpCompilation compilation, List<SyntaxTree> trees)
{
foreach (var tree in trees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var invocation in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
{
if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true;
}
}
return false;
}
/// <summary>窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面</summary>
private sealed record Form(string Path, string SubType, bool Resx, List<string> OcxKeys);
private static List<Form> Forms(CSharpCompilation compilation, List<Source> sources, List<SyntaxTree> trees)
{
var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form");
var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl");
var result = new List<Form>();
for (var i = 0; i < sources.Count; i++)
{
var path = sources[i].Path;
if (path.EndsWith(".Designer.cs")) continue;
var designer = sources.FirstOrDefault(s => s.Path == path[..^3] + ".Designer.cs");
var model = compilation.GetSemanticModel(trees[i]);
var type = trees[i].GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
.Select(c => model.GetDeclaredSymbol(c)).FirstOrDefault(t => t != null);
string? subType = null;
for (var t = type?.BaseType; t != null && subType == null; t = t.BaseType)
{
if (SymbolEqualityComparer.Default.Equals(t, form)) subType = "Form";
else if (SymbolEqualityComparer.Default.Equals(t, userControl)) subType = "UserControl";
}
if (subType == null) continue;
var keys = designer == null
? new List<string>()
: ExeExporter.OcxStateKey.Matches(designer.Text).Select(m => m.Groups[1].Value).Distinct().OrderBy(k => k, StringComparer.Ordinal).ToList();
result.Add(new Form(path, subType, designer != null, keys));
}
return result;
}
private static string Project(string name, string rootNamespace, List<Source> code, List<Form> forms,
List<string> frameworkReferences, List<string> textbookReferences, List<string> images, List<string> media)
{
static string Win(string path) => path.Replace('/', '\\');
static string X(string text) => SecurityElement.Escape(text);
var s = new StringBuilder();
s.Append($"""
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{ProjectGuid(name)}</ProjectGuid>
<OutputType>WinExe</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>{X(rootNamespace)}</RootNamespace>
<AssemblyName>{X(name)}</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
""");
foreach (var lib in textbookReferences)
{
s.Append($"""
<Reference Include="{X(Path.GetFileNameWithoutExtension(lib))}">
<HintPath>lib\{X(lib)}</HintPath>
</Reference>
""");
}
foreach (var reference in frameworkReferences) s.Append($" <Reference Include=\"{reference}\" />\n");
s.Append(" </ItemGroup>\n <ItemGroup>\n");
var formPaths = forms.ToDictionary(f => f.Path);
// 和 VS 一样 Form1.cs 在 Form1.Designer.cs 前面
static string Order(string path) => path.EndsWith(".Designer.cs") ? path[..^12] + ".cs\u0001" : path;
foreach (var source in code.Where(c => !c.Path.StartsWith("Properties/")).OrderBy(c => Order(c.Path), StringComparer.Ordinal))
{
var path = source.Path;
if (formPaths.TryGetValue(path, out var form))
{
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <SubType>{form.SubType}</SubType>\n </Compile>\n");
}
else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs"))
{
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <DependentUpon>{X(Path.GetFileName(path[..^12] + ".cs"))}</DependentUpon>\n </Compile>\n");
}
else
{
s.Append($" <Compile Include=\"{X(Win(path))}\" />\n");
}
}
s.Append(" <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n");
foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal))
{
s.Append($" <EmbeddedResource Include=\"{X(Win(Path.ChangeExtension(form.Path, ".resx")))}\">\n <DependentUpon>{X(Path.GetFileName(form.Path))}</DependentUpon>\n </EmbeddedResource>\n");
}
s.Append("""
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<SubType>Designer</SubType>
</EmbeddedResource>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
</Compile>
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<Compile Include="Properties\Settings.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
""");
var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList();
if (files.Count > 0)
{
s.Append(" <ItemGroup>\n");
foreach (var file in files)
{
// 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序
s.Append(media.Contains(file)
? $" <Content Include=\"Resources\\{X(file)}\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n"
: $" <None Include=\"Resources\\{X(file)}\" />\n");
}
s.Append(" </ItemGroup>\n");
}
s.Append("""
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
""");
return s.ToString();
}
private static string Solution(string name) => $$"""
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "{{name}}", "{{name}}\{{name}}.csproj", "{{ProjectGuid(name)}}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{{ProjectGuid(name)}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{{ProjectGuid(name)}}.Debug|Any CPU.Build.0 = Debug|Any CPU
{{ProjectGuid(name)}}.Release|Any CPU.ActiveCfg = Release|Any CPU
{{ProjectGuid(name)}}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
""";
/// <summary>同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程)</summary>
private static string ProjectGuid(string name) => "{" + NameGuid("project:" + name).ToString().ToUpperInvariant() + "}";
private static Guid NameGuid(string text) => new(MD5.HashData(Encoding.UTF8.GetBytes(text)));
private static string AssemblyInfo(string name) => $$"""
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// 有关程序集的常规信息通过以下
// 特性集控制。更改这些特性值可修改
// 与程序集关联的信息。
[assembly: AssemblyTitle({{SymbolDisplay.FormatLiteral(name, true)}})]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct({{SymbolDisplay.FormatLiteral(name, true)}})]
[assembly: AssemblyCopyright("Copyright © {{DateTime.UtcNow.AddHours(8).Year}}")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// 将 ComVisible 设置为 false 使此程序集中的类型
// 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型,
// 则将该类型上的 ComVisible 特性设置为 true。
[assembly: ComVisible(false)]
// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID
[assembly: Guid("{{NameGuid("assembly:" + name)}}")]
// 程序集的版本信息由下面四个值组成:
//
// 主版本
// 次版本
// 生成号
// 修订号
//
// 可以指定所有这些值,也可以使用“生成号”和“修订号”的默认值,
// 方法是按如下所示使用“*”:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
""";
/// <summary>和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释)</summary>
private static string ResourcesDesigner(string ns, List<ResourceItem> items)
{
var members = string.Concat(items.Select(item =>
{
var type = item.Image ? "System.Drawing.Bitmap" : "byte[]";
var what = item.Image ? "System.Drawing.Bitmap" : "System.Byte[]";
return $$"""
/// <summary>
/// 查找 {{what}} 类型的本地化资源。
/// </summary>
internal static {{type}} {{item.Name}} {
get {
object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture);
return (({{type}})(obj));
}
}
""";
}));
return $$"""
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码由工具生成。
// 运行时版本:4.0.30319.42000
//
// 对此文件的更改可能会导致不正确的行为,并且如果
// 重新生成代码,这些更改将会丢失。
// </auto-generated>
//------------------------------------------------------------------------------
namespace {{ns}}.Properties {
using System;
/// <summary>
/// 一个强类型的资源类,用于查找本地化的字符串等。
/// </summary>
// 此类是由 StronglyTypedResourceBuilder
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
// (以 /str 作为命令选项),或重新生成 VS 项目。
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// 返回此类使用的缓存的 ResourceManager 实例。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("{{ns}}.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// 使用此强类型资源类,为所有资源查找
/// 重写当前线程的 CurrentUICulture 属性。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
{{members}} }
}
""";
}
private static string SettingsDesigner(string ns) => $$"""
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace {{ns}}.Properties
{
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
{
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default
{
get
{
return defaultInstance;
}
}
}
}
""";
private const string SettingsFile = """
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>
""";
private const string AppConfig = """
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
</configuration>
""";
/// <summary>
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
/// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。
/// </summary>
private const string LedCompat = """
// 教材的设备类库里 LEDHelper 没有 Close 方法(LED 屏每次发送时才打开串口,发完就关),
// 书上的代码调用了 ledHelper.Close(),这里补一个什么都不做的方法,让代码能编译。
internal static class LedHelperCompat
{
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper)
{
}
}
""";
private static readonly Lazy<string> ResxHeader = new(() =>
{
using var stream = typeof(ProjectExporter).Assembly.GetManifestResourceStream("ResxHeader.xml")!;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd().TrimEnd('\n');
});
private static string Resx(IEnumerable<string> entries)
{
var list = entries.ToList();
var s = new StringBuilder(ResxHeader.Value).Append('\n');
if (list.Any(e => e.Contains("ResXFileRef")))
{
s.Append(" <assembly alias=\"System.Windows.Forms\" name=\"System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\" />\n");
}
foreach (var entry in list) s.Append(entry);
return s.Append("</root>").ToString();
}
private static string ResourceEntry(ResourceItem item) =>
$" <data name=\"{SecurityElement.Escape(item.Name)}\" type=\"System.Resources.ResXFileRef, System.Windows.Forms\">\n" +
$" <value>..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}</value>\n" +
" </data>\n";
/// <summary>媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符</summary>
private static string OcxEntry(string key)
{
var data = ExeExporter.MediaPlayerOcxState;
var lines = Enumerable.Range(0, (data.Length + 79) / 80).Select(i => " " + data.Substring(i * 80, Math.Min(80, data.Length - i * 80)));
return $" <data name=\"{SecurityElement.Escape(key)}\" mimetype=\"application/x-microsoft.net.object.binary.base64\">\n" +
" <value>\n" + string.Join("\n", lines) + "\n</value>\n </data>\n";
}
/// <summary>文本文件:带 BOM 的 UTF-8,CRLF 换行</summary>
private static byte[] Text(string text)
{
var crlf = text.Replace("\r\n", "\n").Replace("\n", "\r\n");
return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(crlf)).ToArray();
}
}
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
+18
View File
@@ -14,6 +14,24 @@
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
</ItemGroup>
<!-- 导出 EXE:照教材工程(.NET Framework 4.5)编译学生代码,要用 4.5 的引用程序集(只供编译,不含实现)。
只复制教材工程引用的那些(VS 窗体应用模板默认的引用加图表控件),放到输出目录的 netfx/ -->
<ItemGroup>
<PackageDownload Include="Microsoft.NETFramework.ReferenceAssemblies.net45" Version="[1.0.3]" />
</ItemGroup>
<Target Name="CopyNetFxReferences" AfterTargets="Build">
<PropertyGroup>
<NetFxDir>$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/</NetFxDir>
</PropertyGroup>
<ItemGroup>
<NetFxReference Include="$(NetFxDir)mscorlib.dll;$(NetFxDir)System.dll;$(NetFxDir)System.Core.dll;$(NetFxDir)System.Data.dll;$(NetFxDir)System.Data.DataSetExtensions.dll;$(NetFxDir)System.Deployment.dll;$(NetFxDir)System.Drawing.dll;$(NetFxDir)System.Net.Http.dll;$(NetFxDir)System.Windows.Forms.dll;$(NetFxDir)System.Windows.Forms.DataVisualization.dll;$(NetFxDir)System.Xml.dll;$(NetFxDir)System.Xml.Linq.dll;$(NetFxDir)Microsoft.CSharp.dll" />
</ItemGroup>
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
<ItemGroup>
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
+7 -3
View File
@@ -43,9 +43,7 @@ internal static class StudentCompiler
public static Result Compile(string projectDir)
{
var parseOptions = ParseOptions;
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal)
var trees = SourceFiles(projectDir)
.Select(path => CSharpSyntaxTree.ParseText(
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
parseOptions,
@@ -123,6 +121,12 @@ internal static class StudentCompiler
return json;
}
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
internal static IEnumerable<string> SourceFiles(string projectDir) =>
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal);
private static bool IsBuildOutput(string projectDir, string path)
{
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
+4 -1
View File
@@ -28,10 +28,13 @@ internal static class StudentProgramHost
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出。
// Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是),这里改成 Windows 的写法
// Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是);长日期后面多了星期(2024年1月16日星期二),
// 中文 Windows 的长日期不带星期(日期选择框的长日期格式窄,带上星期会被截掉)。这里都改成 Windows 的写法
var zhCN = (CultureInfo)CultureInfo.GetCultureInfo("zh-CN").Clone();
zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss";
zhCN.DateTimeFormat.ShortTimePattern = "H:mm";
zhCN.DateTimeFormat.LongDatePattern = "yyyy'年'M'月'd'日'";
zhCN.DateTimeFormat.FullDateTimePattern = "yyyy'年'M'月'd'日' H:mm:ss";
zhCN = CultureInfo.ReadOnly(zhCN);
CultureInfo.DefaultThreadCurrentCulture = zhCN;
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
+155
View File
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace Sim.Runner;
/// <summary>
/// 写 zip 文件。文件名照中文 Windows 的习惯用 GBK 编码(资源管理器自带的解压、老版本的压缩软件都按 GBK 读,
/// 用 UTF-8 时 Windows 7 等会显示乱码),另附 Info-ZIP 的 Unicode 文件名扩展字段(0x7075),
/// 7-Zip、Linux 的 unzip 等认这个字段的软件在别的语言的系统上也能显示正确。
/// GBK 表示不了的文件名改用 UTF-8 并设置 UTF-8 标志位。
/// </summary>
internal sealed class ZipWriter
{
private static readonly Encoding Gbk;
private static readonly uint[] CrcTable = new uint[256];
static ZipWriter()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Gbk = Encoding.GetEncoding(936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
CrcTable[n] = c;
}
}
private sealed record Entry(byte[] Name, byte[] Extra, ushort Flags, ushort Method, uint Crc, uint CompressedSize, uint Size, uint Offset);
private readonly MemoryStream output = new();
private readonly List<Entry> entries = new();
private readonly ushort dosTime;
private readonly ushort dosDate;
/// <param name="time">文件的修改时间(机房电脑上解压后看到的时间)</param>
public ZipWriter(DateTime time)
{
dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
}
/// <summary>添加文件,path 用 / 分隔</summary>
public void Add(string path, byte[] data)
{
byte[] name;
byte[] extra = Array.Empty<byte>();
ushort flags = 0;
try
{
name = Gbk.GetBytes(path);
var utf8 = Encoding.UTF8.GetBytes(path);
if (utf8.Length != name.Length || !utf8.AsSpan().SequenceEqual(name))
{
// Unicode Path Extra Field:版本 1、GBK 文件名的 CRC、UTF-8 文件名
extra = new byte[4 + 1 + 4 + utf8.Length];
BitConverter.TryWriteBytes(extra.AsSpan(0), (ushort)0x7075);
BitConverter.TryWriteBytes(extra.AsSpan(2), (ushort)(1 + 4 + utf8.Length));
extra[4] = 1;
BitConverter.TryWriteBytes(extra.AsSpan(5), Crc32(name));
utf8.CopyTo(extra, 9);
}
}
catch (EncoderFallbackException)
{
name = Encoding.UTF8.GetBytes(path);
flags = 1 << 11;
}
var crc = Crc32(data);
var compressed = Deflate(data);
var method = (ushort)8;
if (compressed.Length >= data.Length)
{
compressed = data;
method = 0;
}
var entry = new Entry(name, extra, flags, method, crc, (uint)compressed.Length, (uint)data.Length, (uint)output.Position);
entries.Add(entry);
using var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
w.Write(0x04034b50u);
w.Write((ushort)20);
w.Write(entry.Flags);
w.Write(entry.Method);
w.Write(dosTime);
w.Write(dosDate);
w.Write(entry.Crc);
w.Write(entry.CompressedSize);
w.Write(entry.Size);
w.Write((ushort)entry.Name.Length);
w.Write((ushort)entry.Extra.Length);
w.Write(entry.Name);
w.Write(entry.Extra);
w.Write(compressed);
}
/// <summary>写中央目录,返回整个 zip</summary>
public byte[] Finish()
{
var start = (uint)output.Position;
using (var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true))
{
foreach (var e in entries)
{
w.Write(0x02014b50u);
w.Write((ushort)20); // 由 MS-DOS 兼容的系统生成:解压时不设置 Unix 权限
w.Write((ushort)20);
w.Write(e.Flags);
w.Write(e.Method);
w.Write(dosTime);
w.Write(dosDate);
w.Write(e.Crc);
w.Write(e.CompressedSize);
w.Write(e.Size);
w.Write((ushort)e.Name.Length);
w.Write((ushort)e.Extra.Length);
w.Write((ushort)0); // 注释长度
w.Write((ushort)0); // 磁盘号
w.Write((ushort)0); // 内部属性
w.Write(0u); // 外部属性
w.Write(e.Offset);
w.Write(e.Name);
w.Write(e.Extra);
}
var size = (uint)output.Position - start;
w.Write(0x06054b50u);
w.Write((ushort)0);
w.Write((ushort)0);
w.Write((ushort)entries.Count);
w.Write((ushort)entries.Count);
w.Write(size);
w.Write(start);
w.Write((ushort)0);
}
return output.ToArray();
}
private static byte[] Deflate(byte[] data)
{
using var stream = new MemoryStream();
using (var deflate = new DeflateStream(stream, CompressionLevel.Optimal, leaveOpen: true)) deflate.Write(data);
return stream.ToArray();
}
private static uint Crc32(byte[] data)
{
var c = 0xFFFFFFFFu;
foreach (var b in data) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
}
+2 -2
View File
@@ -22,7 +22,7 @@
1. 平台已经引用好了 `DeviceCtrlLibrary.dll` 和 `WinFormsDeviceControlLibrary.dll`,不用像书上那样复制 dll、添加引用;LED 显示屏、灯泡、报警灯这些控件直接在工具箱的"物联网设备"里。
2. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using WinFormsDeviceControlLibrary;`。
3. **com_Port 的 Click**(在 ⚡ 事件里双击 Click):先清空下拉框,再用 foreach 把 `SerialPort.GetPortNames()` 的每个串口号加进去。这样每次点开下拉框都会刷新串口列表。
3. **com_Port 的 Click**(在 ⚡ 事件里双击"单击",即 Click):先清空下拉框,再用 foreach 把 `SerialPort.GetPortNames()` 的每个串口号加进去。这样每次点开下拉框都会刷新串口列表。
4. **btn_Open 的 Click**:调用 `DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text)` 打开选中的串口。
5. **btn_Send 的 Click**:调用 `DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text)`,把返回的字符串存到变量 res;如果 res 里含有 "Error"(`res.IndexOf("Error") >= 0`)就用 MessageBox 显示出来,否则调用 `led.setLedText(res)` 让窗体上的 LED 控件显示。
@@ -34,4 +34,4 @@
1. 运行后点开"串口"下拉框,选 **COM5**(LED 屏),点"打开"。
2. 输入"欢迎光临",点"推送",实训台的 **LED 显示屏**和窗体上的 LED 控件都应显示"欢迎光临"。
3. 重新运行,不点"打开"直接推送,应弹出"Error:发送失败"。
3. 重新运行,先点实训台的"实训台复位"清掉 LED 屏上的字(和真的 LED 屏一样,上次显示的字会一直留着),再不点"打开"直接推送:应弹出"Error:发送失败",LED 屏不显示,实训台最上方用红字提示串口没有打开。
+1
View File
@@ -14,6 +14,7 @@
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `grp_config` | 配置 |
| 标签 | `lbl_port` | 串口 |
| 下拉框 | `com_port` | 串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
+1 -1
View File
@@ -37,7 +37,7 @@
## 和书上不一样的地方
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台保留了它(什么也不做),书上的代码可以原样编译。
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台补上了这个方法(关闭 LED 屏的串口),书上的代码可以原样编译。
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
+1
View File
@@ -13,6 +13,7 @@
- select: { control: com_port, item: COM4 }
- click: btn_open
- click: rb_Open
- expect: { zigbee: 1, on: true, desc: 先打开 }
- click: rb_Close
- expect: { zigbee: 1, on: false }
- expect: { control: lamp1, on: false }
@@ -13,6 +13,8 @@
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
| 按钮 | `btn_lampClose` | 关(手动关灯) |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `lbl_lamp` | 路灯 |
| 标签 | `lbl_environment` | 环境采集 |
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
| 复选框 | `ch_bodyInfrared` | 人体开关 |
@@ -33,7 +35,7 @@
- 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。
7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**:在设计器里双击 timer1 就会生成 `timer1_Tick`。
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**(事件窗口里叫"计时到"):在设计器里双击 timer1 就会生成 `timer1_Tick`。
## 在平台上怎么试
+2 -2
View File
@@ -37,7 +37,7 @@
## 在平台上怎么试
1. 运行后在 IP 框里填 **192.168.2.50**(填别的 IP 也能连上),点"开",按钮变成"关",图片框里出现东围墙的画面,实训台顶棚的**网络摄像头**亮起。
2. 点四个方向键,画面应跟着转动:每点一次转一格,左右各 3 格、上下各 1 格,转到头就不再动。
1. 运行后在 IP 框里填 **192.168.2.50**(填别的 IP 也能连上,但不能空着:空着点"开",按钮照样变,画面却一直是黑的),点"开",按钮变成"关",图片框里出现东围墙的画面,实训台顶棚的**网络摄像头**亮起。
2. 窗体比较大,运行区会缩小显示,可以点运行区右上角的"放大"。点四个方向键,画面应跟着转动:每点一次转一格,左右各 3 格、上下各 1 格,转到头就不再动。
3. 点"拍照",运行区下方出现"程序保存的图片",点开能看到刚拍的照片(只在这次运行中保留)。
4. 再点"关",按钮变回"开",画面停止更新。
+1
View File
@@ -13,6 +13,7 @@
- select: { control: com_port, item: COM3 }
- click: btn_open
- click: btn_alarmOpen
- expect: { relay: 0, on: true, desc: 先打开 }
- click: btn_alarmClose
- expect: { relay: 0, on: false }
- expect: { control: alarm1, on: false }
+4 -1
View File
@@ -1,4 +1,7 @@
{
"title": "项目7 任务1 智慧教室",
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库"
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库",
"wiring": {
"adam4150": "COM2"
}
}
+2 -2
View File
@@ -56,8 +56,8 @@
## 在平台上怎么试
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的)。
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的);这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以能行。
2. 在实训台上拖动光照、温度、湿度的滑块,2 秒内三个文本框应跟着变。
3. 把光照拖到 400 以下,窗体上的灯泡和实训台 ADAM-4150 的 **DO1 照明灯**应一起亮;拖到 800 以上应熄灭。
4. 把温度拖到 30℃ 以上,空调滑块应停在 26;拖到 5℃ 以下停在 25;其他温度回到 18。
5. 点灯光的开关图片,灯应该一下开、一下关。
5. 先把光照拖回 400–800 之间(光照在 800 以上时程序每 2 秒自动关一次灯,400 以下自动开灯,会盖过手动开关),再点灯光的开关图片,灯应该一下开、一下关。
+1
View File
@@ -25,6 +25,7 @@
- expect: { control: trb_curtain, value: 0, desc: 上课模式窗帘全开 }
- clock: '21:30:00'
- expect: { control: lbl_mode, contains: 回寝模式, within: 5s, desc: 21:30 是回寝模式 }
- expect: { relay: 1, on: true, desc: 回寝模式打开灯光 }
- expect: { control: trb_curtain, value: 10, desc: 回寝模式关窗帘 }
- clock: '23:00:00'
- expect: { control: lbl_mode, contains: 熄灯模式, within: 5s, desc: 23:00 是熄灯模式 }
+4 -1
View File
@@ -1,4 +1,7 @@
{
"title": "项目7 任务2 智慧寝室",
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间"
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间",
"wiring": {
"adam4150": "COM2"
}
}
+1 -1
View File
@@ -63,7 +63,7 @@
## 在平台上怎么试
1. 运行后串口选 **COM2**,点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
1. 运行后串口选 **COM2**(这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以书上在一个串口上打开两个模块也能用),点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
2. 在实训台的时钟上拨到 **06:50:00**:应显示起床模式,ADAM-4150 的 **DO1 照明灯**亮,窗帘滑块到 5,广播响一声铃。
3. 拨到 **08:00:00**:上课模式,灯灭,窗帘到 0。
4. 拨到 **21:30:00**:回寝模式,灯亮,窗帘到 10,广播响铃。
+4 -1
View File
@@ -1,4 +1,7 @@
{
"title": "项目7 任务3 智慧会议室",
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间"
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间",
"wiring": {
"adam4150": "COM2"
}
}
+5 -3
View File
@@ -78,7 +78,9 @@
## 在平台上怎么试
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。
2. 把时钟拨到 **13:19:54**;模式选"讨论",时分秒填 13、20、00,点"预约"。
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,书上在一个串口上打开两个模块也能用。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。刚运行还没选串口时,实训台会提示串口没选(书上在定时器里每秒打开一次串口),选了 COM2 就消失。
2. 先点"实训台复位"(上次运行开着的灯会一直亮着)。模式选"讨论",时分秒填 13、20、00,然后把时钟拨到 **13:19:40**,马上点"预约"。
3. 预约时间没到时,ADAM-4150 的 **DO1 照明灯**不亮;到 13:20:00 后,应显示"智慧会议室已开启讨论模式",照明灯亮,窗帘滑块到 0,主、客话筒都是开。
4. 点"退出",灯、话筒都关掉。想再试一次,重新运行程序。
4. 点"退出",灯、话筒都关掉。想再试一次,先点"实训台复位",再重新运行程序。
> 模式开启后第一次点灯光或话筒的开关图片没有反应,第二次才变:书上的 ChooseMode 只换了开关图片,没有改记录开关状态的变量,参考工程也是这样,不是你写错了。
+2
View File
@@ -47,6 +47,8 @@
## 在平台上怎么试
> 先准备:做过任务 4 的话,SecurityData 表里已经存了任务 4 拍的照片和入侵照片,下面看到的第一张就不是 9:00 那张。先到右上角的**数据库**页面点"恢复实验初始数据",选"项目8 任务5",把表清空(自动评分每次都用干净的库,不受影响)。
1. 在实训台的**时钟**里拨到 9:00:00,运行后打开校园安防,等画面出来点"拍照";再拨到 10:30:00,再拍一张。
2. 点"查看历史图像",打开历史报警记录窗体:两个日期默认都是今天,显示第一张照片,右下角时间是 9:00 左右,"NO DATA"不显示。
3. 点右箭头显示 10:30 那张,再点回到第一张;点左箭头往回翻。
+31
View File
@@ -27,6 +27,7 @@ import { config } from './config'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
import { SqlError, sqlService } from './sql-service'
import { type ExportKind, exportProgram, programName } from './export'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -129,6 +130,36 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
/**
* 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
* (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
*/
const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' },
project: { ext: 'zip', type: 'application/zip' },
}
for (const kind of ['exe', 'project'] as const) {
app.post(`/api/export-${kind}`, async (c) => {
const { files, name } = await body<{ files: unknown; name: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
let result
try {
result = await exportProgram(files, programName(name), kind)
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : String(error) }, 503)
}
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422)
const { ext, type } = EXPORTS[kind]
return new Response(result.file, {
headers: {
'Content-Type': type,
'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`,
'Cache-Control': 'no-store',
},
})
})
}
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => {
+35 -2
View File
@@ -4,9 +4,13 @@ import {
ANALOG_INFO,
type BoardState,
type ClientMessage,
DEFAULT_WIRING,
DIGITAL_CHANNELS,
PORT_DEVICES,
type PortDevice,
type RunnerInput,
type RunnerMessage,
type Wiring,
} from '@iot/shared'
import { config } from './config'
@@ -19,9 +23,16 @@ export function defaultBoardState(): BoardState {
ledText: '',
camera: { on: false, x: 3, y: 1 },
clock: null,
wiring: { ...DEFAULT_WIRING },
opened: closedPorts(),
usedUnopened: [],
}
}
function closedPorts(): BoardState['opened'] {
return { adam4017: null, adam4150: null, zigbee: null, led: null }
}
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
export class Board {
state = defaultBoardState()
@@ -33,9 +44,17 @@ export class Board {
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
clock: this.state.clock,
wiring: this.state.wiring,
}
}
/** 换成某个实验的接线;返回 true 表示有变化 */
setWiring(wiring: Wiring) {
if (PORT_DEVICES.every((device) => this.state.wiring[device] === wiring[device])) return false
this.state.wiring = { ...wiring }
return true
}
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
let changed = false
@@ -81,6 +100,17 @@ export class Board {
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
const device = message.device as PortDevice
if (PORT_DEVICES.includes(device) && (message.action === 'open' || message.action === 'close')) {
this.state.opened[device] = message.action === 'open' ? String(message.data ?? '') : null
if (message.action === 'open') this.state.usedUnopened = this.state.usedUnopened.filter((d) => d !== device)
return true
}
if (PORT_DEVICES.includes(device) && message.action === 'unopened') {
if (this.state.usedUnopened.includes(device)) return false
this.state.usedUnopened = [...this.state.usedUnopened, device]
return true
}
switch (`${message.device}/${message.action}`) {
case 'led/text':
this.state.ledText = String(message.data ?? '')
@@ -111,13 +141,16 @@ export class Board {
}
}
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始) */
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始);打开的串口随程序关闭 */
programExited() {
this.state.camera = { on: false, x: 3, y: 1 }
this.state.opened = closedPorts()
this.state.usedUnopened = []
}
/** 复位不改接线(接线跟着实验走),也不动正在运行的程序打开的串口 */
reset() {
this.state = defaultBoardState()
this.state = { ...defaultBoardState(), wiring: this.state.wiring, opened: this.state.opened, usedUnopened: this.state.usedUnopened }
}
}
+26 -2
View File
@@ -9,6 +9,10 @@ export interface CompileResult {
ms: number
}
/** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */
export const EXPORT_FILE = 'export.exe'
export const EXPORT_PROJECT_FILE = 'export.zip'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
/**
@@ -22,11 +26,31 @@ class CompileService {
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
/** 编译工程目录;成功时程序集写到同一目录 */
async compile(dir: string): Promise<CompileResult> {
compile(dir: string): Promise<CompileResult> {
return this.request({ dir })
}
/**
* 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir),
* 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe)
*/
exportExe(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
}
/**
* 导出 VS 工程:照机房的 VS 2012(C# 5、.NET Framework 4.5、真机版设备类库)检查,
* 打包成 zip 写到工程目录的 EXPORT_PROJECT_FILE。name 是工程名
*/
exportProject(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> {
await this.start()
const id = this.nextId++
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
this.proc!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`)
this.proc!.stdin.flush()
return result
}
+5
View File
@@ -13,6 +13,11 @@ export const config = {
/** 每次运行的临时工程目录放在这里 */
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
/**
* 导出 EXE 用的教材设备类库(真机版 DeviceCtrlLibrary.dll 及其 API 目录下的 dll、WinFormsDeviceControlLibrary.dll、
* 媒体播放器的 AxInterop.WMPLib.dll / Interop.WMPLib.dll,放在同一个目录里)。有版权,不进仓库;没有时不能导出
*/
textbookLibsDir: resolve(ROOT, env.SIM_TEXTBOOK_LIBS ?? 'textbook-libs'),
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
+36
View File
@@ -0,0 +1,36 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Diagnostic, SourceFile } from '@iot/shared'
import { compileService, EXPORT_FILE, EXPORT_PROJECT_FILE } from './compile-service'
import { config } from './config'
import { safeJoin } from './run-session'
export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
export type ExportKind = 'exe' | 'project'
/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function programName(name: unknown) {
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
return cleaned || '上位机程序'
}
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-'))
try {
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
const result = kind === 'project' ? await compileService.exportProject(dir, name) : await compileService.exportExe(dir, name)
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
} finally {
await rm(dir, { recursive: true, force: true })
}
}
+13 -4
View File
@@ -1,11 +1,12 @@
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
import { describePortProblem, portProblems, type CheckResult, type ControlNode, type GradeResult } from '@iot/shared'
import { rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { config } from '../config'
import { compileService } from '../compile-service'
import { gradingDatabaseDir } from '../databases'
import { sampleWiring } from '../samples'
import type { Check, Expectation, Step } from './checks'
import { ProgramDriver } from './driver'
@@ -56,6 +57,7 @@ class Failure extends Error {}
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
const driver = new ProgramDriver()
driver.board.setWiring(await sampleWiring(sampleId))
const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined
try {
driver.start(dir, dbDir)
@@ -237,7 +239,7 @@ async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'ex
key = found
}
const ok = await driver.waitFor(() => satisfied(driver, e, key), within)
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait))
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait) + portNote(driver))
}
/** 列表框的 Text 只是选中的那一项,检查"包含"时看所有列表项 */
@@ -303,8 +305,15 @@ function chartSeries(node: ControlNode | undefined): { name: string; points: unk
return (node?.props.Chart as { series?: { name: string; points: unknown[] }[] } | undefined)?.series ?? []
}
/** 串口打开错了或没打开时,设备没反应的原因多半在这里,附在不通过的说明后面 */
function portNote(driver: ProgramDriver) {
const problems = portProblems(driver.board.state)
return problems.length ? `。注意:${problems.map(describePortProblem).join(';')}` : ''
}
function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>, desc: string | undefined, wait: string): string {
const prefix = wait ? `${wait}` : ''
// "期望 6 秒内……":数字前空一格
const prefix = wait ? ` ${wait}` : ''
const onOff = (on: boolean) => (on ? '打开' : '关闭')
switch (e.kind) {
case 'prop': {
@@ -333,7 +342,7 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'relay':
return `期望${prefix}实训台的${RELAY_NAMES[e.index] ?? `DO${e.index}`}(ADAM-4150 DO${e.index})${desc ?? onOff(e.on)},实际是${onOff(driver.board.state.relays[e.index])}的`
case 'zigbee':
return `期望${prefix}实训台的${ZIGBEE_NAMES[e.index] ?? `ZigBee 继电器 ${e.index}`}${desc ?? onOff(e.on)},实际是${onOff(driver.board.state.zigbee[e.index])}的`
return `期望${prefix}实训台的${ZIGBEE_NAMES[e.index] ?? `ZigBee 继电器 ${e.index}`} ${desc ?? onOff(e.on)},实际是${onOff(driver.board.state.zigbee[e.index])}的`
case 'messageBox': {
const shown = driver.messageBoxes.map((b) => `"${b.text}"`).join('、')
return `期望${prefix}弹出${desc ?? `包含"${e.text}"的提示框`},${shown ? `实际弹出的是 ${shown}` : '实际没有弹出提示框'}`
+6 -3
View File
@@ -9,7 +9,7 @@ import { config } from './config'
import { openDatabase } from './db'
import { createGradingQueue } from './grading/queue'
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples } from './samples'
import { knownSample, sampleWiring } from './samples'
import { cleanupStaleRuns, RunSession } from './run-session'
import { sqlService } from './sql-service'
import { checkSandbox, sandboxError } from './sandbox'
@@ -78,15 +78,18 @@ const server = Bun.serve({
if (!session) return
if (message.type === 'run') {
try {
const { sampleId } = message
if (typeof sampleId === 'string' && (await listSamples()).some((s) => s.id === sampleId)) {
const sampleId = await knownSample(message.sampleId)
if (sampleId) {
// 建库失败不影响运行(程序连不上库时会报出来)
await restoreExperimentDatabases(ws.data.userId!, sampleId, true).catch((error) => console.error('自动建实验数据库失败', error))
}
session.setWiring(await sampleWiring(sampleId))
await session.start(message.files)
} catch (error) {
ws.send(JSON.stringify({ type: 'serverError', message: String(error) } satisfies ServerMessage))
}
} else if (message.type === 'experiment') {
session.setWiring(await sampleWiring(await knownSample(message.sampleId)))
} else if (message.type === 'stop') {
await session.stop()
} else if (message.type === 'boardInput') {
+4 -2
View File
@@ -75,8 +75,8 @@ export function studentDetail(db: Database, assignmentId: number, userId: number
/** 一份提交的代码和它属于谁(老师在实验页里运行学生的程序时用) */
export function submissionFiles(db: Database, submissionId: number) {
const row = db
.query<{ files: string; assignment_id: number; title: string; name: string; username: string }, [number]>(
`SELECT s.files, s.assignment_id, a.title, u.name, u.username
.query<{ files: string; assignment_id: number; sample_id: string; title: string; name: string; username: string }, [number]>(
`SELECT s.files, s.assignment_id, a.sample_id, a.title, u.name, u.username
FROM submissions s JOIN assignments a ON a.id = s.assignment_id JOIN users u ON u.id = s.user_id WHERE s.id = ?`,
)
.get(submissionId)
@@ -84,6 +84,8 @@ export function submissionFiles(db: Database, submissionId: number) {
return {
files: JSON.parse(row.files) as SourceFile[],
assignmentId: row.assignment_id,
/** 作业基于的实验:运行时按它给实训台接线、建实验数据库 */
sampleId: row.sample_id,
assignmentTitle: row.title,
studentName: row.name,
username: row.username,
+8 -1
View File
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join, normalize, resolve } from 'node:path'
import type { Subprocess } from 'bun'
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile, Wiring } from '@iot/shared'
import { Board } from './board'
import { compileService } from './compile-service'
import { config } from './config'
@@ -129,6 +129,13 @@ export class RunSession {
this.input(this.board.runnerInputs())
}
/** 换成实验的接线(打开实验、运行时) */
setWiring(wiring: Wiring) {
if (!this.board.setWiring(wiring)) return
this.sendBoard()
this.input(this.board.runnerInputs())
}
boardReset() {
this.board.reset()
this.sendBoard()
+25 -1
View File
@@ -1,6 +1,6 @@
import { readdir } from 'node:fs/promises'
import { join, relative, resolve } from 'node:path'
import type { SourceFile } from '@iot/shared'
import { BOARD_PORTS, DEFAULT_WIRING, PORT_DEVICES, type SourceFile, type Wiring } from '@iot/shared'
import { config } from './config'
const SAMPLES_DIR = config.samplesDir
@@ -22,6 +22,30 @@ export async function listSamples(): Promise<SampleInfo[]> {
return samples.sort((a, b) => a.id.localeCompare(b.id))
}
/** 浏览器传来的实验编号:是已有的示例才返回 */
export async function knownSample(id: unknown): Promise<string | undefined> {
return typeof id === 'string' && (await listSamples()).some((s) => s.id === id) ? id : undefined
}
/**
* 实验的实训台接线:meta.json 的 wiring 只写和默认接线不同的设备,如项目 7 的 { "adam4150": "COM2" }。
* 没有这个实验(或没给 id)时用默认接线
*/
export async function sampleWiring(id?: string): Promise<Wiring> {
const wiring = { ...DEFAULT_WIRING }
if (!id) return wiring
const path = resolve(SAMPLES_DIR, id, 'meta.json')
if (!path.startsWith(`${SAMPLES_DIR}/`)) return wiring
const file = Bun.file(path)
if (!(await file.exists())) return wiring
const custom = ((await file.json()) as { wiring?: Partial<Wiring> }).wiring ?? {}
for (const device of PORT_DEVICES) {
const port = custom[device]
if (port !== undefined && BOARD_PORTS.includes(port)) wiring[device] = port
}
return wiring
}
export async function loadSample(id: string): Promise<SourceFile[] | null> {
const dir = resolve(SAMPLES_DIR, id)
if (!dir.startsWith(`${SAMPLES_DIR}/`)) return null
+155
View File
@@ -1,5 +1,6 @@
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
import { createApp } from '../src/app'
@@ -738,3 +739,157 @@ describe('AI 评阅', () => {
expect((await t.post(`/api/teacher/assignments/${assignmentId}/adopt-ai`)).body).toEqual({ adopted: 0 })
}, 60000)
})
describe('导出 EXE、VS 工程', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
const exportExe = (browser: Browser, json: unknown) => exportRequest(browser, 'exe', json)
async function exportRequest(browser: Browser, kind: 'exe' | 'project', json: unknown) {
const response = await app.fetch(
new Request(`http://localhost/api/export-${kind}`, {
method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json),
}),
)
return response
}
test.skipIf(!hasLibs)(
'照真机版类库编译成 exe;书上的 ledHelper.Close() 也能编译;代码有错时返回报错',
async () => {
const t = await teacher()
const files = (await loadSample('p2-t3-LED推送模拟量'))!
const response = await exportExe(t, { files, name: 'LED推送 模拟量/..' })
expect(response.status).toBe(200)
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('LED推送模拟量.exe')}`)
const exe = new Uint8Array(await response.arrayBuffer())
expect(String.fromCharCode(exe[0]!, exe[1]!)).toBe('MZ')
// 教材 dll 嵌在 exe 里
expect(Buffer.from(exe).includes('IotSimExport.lib.DeviceCtrlLibrary.dll')).toBe(true)
const broken = files.map((f) => (f.path === 'frm_LED.cs' ? { ...f, content: f.content.replace('IsOpen = true;', 'IsOpen = true') } : f))
const failed = await exportExe(t, { files: broken, name: 'x' })
expect(failed.status).toBe(422)
expect(((await failed.json()) as any).diagnostics[0]).toMatchObject({ file: 'frm_LED.cs', severity: 'error' })
},
60000,
)
/** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */
function unzip(zip: Uint8Array<ArrayBuffer>) {
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
let end = zip.length - 22
while (view.getUint32(end, true) !== 0x06054b50) end--
const count = view.getUint16(end + 10, true)
let p = view.getUint32(end + 16, true)
const files = new Map<string, Uint8Array<ArrayBuffer>>()
for (let i = 0; i < count; i++) {
expect(view.getUint32(p, true)).toBe(0x02014b50)
const flags = view.getUint16(p + 8, true)
const method = view.getUint16(p + 10, true)
const compressedSize = view.getUint32(p + 20, true)
const nameLength = view.getUint16(p + 28, true)
const extraLength = view.getUint16(p + 30, true)
const commentLength = view.getUint16(p + 32, true)
const offset = view.getUint32(p + 42, true)
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
expect(flags & 0x800).toBe(0)
const name = new TextDecoder('gbk').decode(nameBytes)
if (extra.length > 0) {
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
} else {
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
}
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
const data = zip.subarray(local, local + compressedSize)
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
p += 46 + nameLength + extraLength + commentLength
}
return files
}
test.skipIf(!hasLibs)(
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
async () => {
const t = await teacher()
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
const files = unzip(new Uint8Array(await response.arrayBuffer()))
const text = (name: string) => {
const bytes = files.get(name)
expect(bytes, name).toBeDefined()
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
const content = new TextDecoder().decode(bytes!.subarray(3))
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
return content
}
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
const csproj = text('寝室/寝室/寝室.csproj')
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
expect(csproj).not.toContain('ICS.Models')
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
expect(resources).not.toContain('Sim.Host')
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
const led = unzip(
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
)
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
// 机房的 VS 2012 只支持 C# 5
const login = (await loadSample('p1-t1-登录'))!
const newer = login.map((f) =>
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
)
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
expect(failed.status).toBe(422)
const diagnostics = ((await failed.json()) as any).diagnostics
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
// 导出 EXE 不受这个限制
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher()
const saved = config.textbookLibsDir
config.textbookLibsDir = '/nonexistent/textbook-libs'
try {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库')
const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(project.status).toBe(503)
expect(((await project.json()) as any).message).toContain('不能导出工程')
} finally {
config.textbookLibsDir = saved
}
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400)
}, 60000)
})
+49
View File
@@ -1,5 +1,7 @@
import { expect, test } from 'bun:test'
import { DEFAULT_WIRING, describePortProblem, portProblems } from '@iot/shared'
import { Board } from '../src/board'
import { sampleWiring } from '../src/samples'
test('滑块输入取整并限制在范围内,开关输入只接受布尔值', () => {
const board = new Board()
@@ -31,3 +33,50 @@ test('学生程序控制输出设备:LED 屏、4150 继电器、ZigBee 继电
expect(board.state.relays.every((on) => !on)).toBe(true)
expect(board.state.ledText).toBe('')
})
test('接线:记下程序打开的串口,指出打开错了的设备;复位不改接线,程序结束时串口关闭', () => {
const board = new Board()
board.applyDevice({ type: 'device', device: 'adam4017', action: 'open', data: 'COM2' })
board.applyDevice({ type: 'device', device: 'adam4150', action: 'open', data: 'com2' })
board.applyDevice({ type: 'device', device: 'led', action: 'open', data: '' })
expect(portProblems(board.state).map(describePortProblem)).toEqual([
'程序在 com2 上打开 ADAM-4150,它接在 COM3',
'程序没有选串口就打开了 LED 屏,它接在 COM5',
])
// 项目 7:4150 并到 COM2 的 485 总线上
expect(board.setWiring({ ...DEFAULT_WIRING, adam4150: 'COM2' })).toBe(true)
expect(board.setWiring({ ...DEFAULT_WIRING, adam4150: 'COM2' })).toBe(false)
expect(board.runnerInputs().wiring?.adam4150).toBe('COM2')
expect(portProblems(board.state).map((w) => w.device)).toEqual(['led'])
board.applyDevice({ type: 'device', device: 'led', action: 'close', data: null })
expect(portProblems(board.state)).toEqual([])
board.reset()
expect(board.state.wiring.adam4150).toBe('COM2')
expect(board.state.opened.adam4017).toBe('COM2')
board.programExited()
expect(Object.values(board.state.opened).every((port) => port === null)).toBe(true)
})
test('串口没打开(或已关闭)时程序还在读写设备:提示一次,打开后、程序结束时清除', () => {
const board = new Board()
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })).toBe(true)
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })).toBe(false)
expect(portProblems(board.state).map(describePortProblem)).toEqual(['ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)'])
board.applyDevice({ type: 'device', device: 'adam4150', action: 'open', data: 'COM3' })
expect(portProblems(board.state)).toEqual([])
board.applyDevice({ type: 'device', device: 'adam4150', action: 'close', data: null })
board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })
expect(portProblems(board.state).map((p) => p.device)).toEqual(['adam4150'])
board.programExited()
expect(portProblems(board.state)).toEqual([])
})
test('实验的接线:项目 7 把 4150 并到 COM2,其他实验和未知实验用默认接线', async () => {
expect(await sampleWiring('p7-t1-智慧教室')).toEqual({ ...DEFAULT_WIRING, adam4150: 'COM2' })
expect(await sampleWiring('p4-t3-报警灯')).toEqual(DEFAULT_WIRING)
expect(await sampleWiring('../p7-t1-智慧教室')).toEqual(DEFAULT_WIRING)
expect(await sampleWiring()).toEqual(DEFAULT_WIRING)
})
+25 -5
View File
@@ -33,7 +33,9 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
return dir
}
describe('参考答案全部通过', async () => {
// 每个检查大部分时间在等学生程序的定时器,挨个跑要 3 分钟;测试之间各用各的临时目录,可以同时跑(30 秒左右)。
// 同时跑的个数由 bun test 的 --max-concurrency 管,CI 里给的是 8:太多时 CPU 跟不上,定时器来不及触发会误判
describe.concurrent('参考答案全部通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort()
test('每个示例都有评分规则', () => expect(ids.length).toBe(23))
for (const id of ids) {
@@ -49,7 +51,7 @@ describe('参考答案全部通过', async () => {
}
})
describe('改坏的代码', () => {
describe.concurrent('改坏的代码', () => {
test('编译不通过时所有项都不通过', async () => {
const id = 'p2-t1-LED显示屏'
const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' })
@@ -78,7 +80,7 @@ describe('改坏的代码', () => {
const result = await grade(dir, await checksOf(id), id)
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
expect(realtime.passed).toBe(false)
expect(realtime.message).toBe('期望6 秒内 txt_even 的文字包含"发现入侵",实际是"正常"')
expect(realtime.message).toBe('期望 6 秒内 txt_even 的文字包含"发现入侵",实际是"正常"')
// 开关按钮的两项不受影响
expect(result.results.filter((r) => r.passed).length).toBe(2)
}, 60000)
@@ -135,7 +137,11 @@ describe('改坏的代码', () => {
`),
]
const result = await grade(dir, checks, id)
expect(result.results.at(-1)).toMatchObject({ passed: false, message: '期望窗体上的 lamp 点开关后灯亮,实际是灭的' })
// 这一项没有先打开串口,程序控制 4150 时串口没开,说明后面附上提示
expect(result.results.at(-1)).toMatchObject({
passed: false,
message: '期望窗体上的 lamp 点开关后灯亮,实际是灭的。注意:ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM2)',
})
}, 60000)
test('关闭时忘了挂起线程', async () => {
@@ -163,7 +169,7 @@ describe('改坏的代码', () => {
})
const result = await grade(dir, await checksOf(id), id)
const fire = result.results.find((r) => r.title === '能自动推送火情到 LED 屏')!
expect(fire).toMatchObject({ passed: false, message: '期望8 秒内实训台 LED 屏显示"发现火情"(勾选"自动推送火情"后有火焰),实际什么也没显示' })
expect(fire).toMatchObject({ passed: false, message: '期望 8 秒内实训台 LED 屏显示"发现火情"(勾选"自动推送火情"后有火焰),实际什么也没显示' })
expect(result.results.filter((r) => !r.passed)).toHaveLength(1)
}, 60000)
@@ -189,6 +195,20 @@ describe('改坏的代码', () => {
const result = await grade(dir, await checksOf(id), id)
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
}, 60000)
test('串口写死成 COM2:4150 收不到命令,说明里指出接在 COM3', async () => {
const id = 'p4-t3-报警灯'
const dir = await prepare(id, 'port', {
file: 'Form1.cs',
from: 'DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);',
to: 'DeviceCtrlHelper.adam4150Helper.Open("COM2");',
})
const result = await grade(dir, await checksOf(id), id)
expect(result.results[0]).toMatchObject({
passed: false,
message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的。注意:程序在 COM2 上打开 ADAM-4150,它接在 COM3',
})
}, 60000)
})
describe('评分规则写错时指出位置', () => {
+28 -8
View File
@@ -160,7 +160,7 @@ test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async ()
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const device = await runner.waitFor('device', (m) => m.device === 'led')
const device = await runner.waitFor('device', (m) => m.device === 'led' && m.action === 'text')
expect(device.data).toBe('欢迎学习物联网')
const led = id('led')
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
@@ -219,10 +219,11 @@ test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,
const { runner, id } = await startSample('p3-t1-ZigBee路灯')
const lamp = id('lamp1')
// 串口没打开时控制无效:没有继电器动作,但界面上的灯照样会亮(学生能看出两者不一致)
// 串口没打开时控制无效:没有继电器动作(实训台提示串口没打开),但界面上的灯照样会亮(学生能看出两者不一致)
runner.send({ type: 'action', id: id('rb_Open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === lamp && c.prop === 'IsOn' && c.value === true))
expect(runner.messages.some((m) => m.type === 'device')).toBe(false)
await runner.waitFor('device', (m) => m.device === 'zigbee' && m.action === 'unopened')
expect(runner.messages.some((m) => m.type === 'device' && m.action === 'relay')).toBe(false)
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
@@ -232,7 +233,7 @@ test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,
runner.send({ type: 'action', id: id('rb_Close'), action: 'click' })
const devices: unknown[] = []
await relayOff
for (const m of runner.messages) if (m.type === 'device') devices.push(m.data)
for (const m of runner.messages) if (m.type === 'device' && m.action === 'relay') devices.push(m.data)
expect(devices).toEqual([
[1, 1],
[1, 0],
@@ -329,6 +330,27 @@ test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async ()
await even('发现入侵')
}, 30000)
test('串口要选对:4150 接在 COM3,在 COM2 上打开读不到传感器;换成项目 7 的接线(并到 COM2)就能读到', async () => {
const { runner, id } = await startSample('p4-t1-红外对射')
const even = (value: string) => runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === value), 5000)
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
const opened = runner.next('device', (m) => m.device === 'adam4150' && m.action === 'open')
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 0 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
// 实训台据此提示"程序在 COM2 上打开 ADAM-4150";收不到回应,和真机一样读到的全是 false
expect((await opened).data).toBe('COM2')
await even('正常')
const intrusion = even('发现入侵')
runner.send({
type: 'board',
analog: ANALOG_DEFAULT,
digital: [false, false, false, true],
wiring: { adam4017: 'COM2', adam4150: 'COM2', zigbee: 'COM4', led: 'COM5' },
})
await intrusion
}, 30000)
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
const { runner, id } = await startSample('p4-t2-网络摄像机')
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
@@ -367,7 +389,7 @@ test('项目4 任务2:打开摄像机显示画面,转动后画面变化,
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
const { runner, id } = await startSample('p4-t3-报警灯')
await openAdam4150(runner, id)
const relay = runner.next('device', (m) => m.device === 'adam4150')
const relay = runner.next('device', (m) => m.device === 'adam4150' && m.action === 'do')
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
expect((await relay).data).toEqual([0, 1])
@@ -730,9 +752,7 @@ test('禁用:分组框禁用后里面的控件 Enabled 读出 false、触发 E
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
// 浏览器只收到分组框自己的 Enabled,子控件按父控件画成禁用
expect(findByName(form, 'button').props.Enabled).toBe(true)
expect(output).toBe(
'button changed=False\ngroup=False button=False off=False\nbutton changed=True\ngroup=True button=True off=False\nclick\n',
)
expect(output).toBe('button changed=False\ngroup=False button=False off=False\nbutton changed=True\ngroup=True button=True off=False\nclick\n')
}, 30000)
test('透明背景:文本框、窗体等设透明背景色和 WinForms 一样报错,标签、面板可以', async () => {
+57 -3
View File
@@ -66,6 +66,55 @@ export interface BoardState {
camera: { on: boolean; x: number; y: number }
/** 虚拟时钟;null 表示真实时间 */
clock: VirtualClock | null
/** 接线:每台串口设备接在哪个串口,随实验而定(示例 meta.json 的 wiring) */
wiring: Wiring
/** 学生程序在哪个串口上打开了各设备(Open / OpenLedCom);没打开或已 Close 为 null,程序结束时清空 */
opened: Record<PortDevice, string | null>
/** 串口没打开(或已关闭)时程序还在读写的设备(读数据、控制继电器、推送文字);打开后、程序结束时清除 */
usedUnopened: PortDevice[]
}
/** 实训台上经串口服务器连到电脑的设备 */
export type PortDevice = 'adam4017' | 'adam4150' | 'zigbee' | 'led'
export const PORT_DEVICES: PortDevice[] = ['adam4017', 'adam4150', 'zigbee', 'led']
export const PORT_DEVICE_NAMES: Record<PortDevice, string> = { adam4017: 'ADAM-4017+', adam4150: 'ADAM-4150', zigbee: 'ZigBee 协调器', led: 'LED 屏' }
/** 串口服务器的四个口(电脑自带的 COM1 不接设备,GetPortNames 也不返回) */
export const BOARD_PORTS = ['COM2', 'COM3', 'COM4', 'COM5']
export type Wiring = Record<PortDevice, string>
/**
* 教材实训台布局图的接线:ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,ZigBee 协调器、LED 屏直接接(项目 8 的 AppSet 也是这样)。
* 485 是总线,两个模块地址不同(4150 为 1、4017+ 为 2),也可以并在同一个口上:项目 7 的书上代码就在一个串口上打开两个模块。
*/
export const DEFAULT_WIRING: Wiring = { adam4017: 'COM2', adam4150: 'COM3', zigbee: 'COM4', led: 'COM5' }
/**
* 串口问题,设备因此没有反应(和真机一样不报错):opened 是程序打开的串口名(打开错了),null 表示串口没打开程序就在读写它。
* Windows 的串口名不分大小写
*/
export interface PortProblem {
device: PortDevice
opened: string | null
wired: string
}
export function portProblems(state: Pick<BoardState, 'wiring' | 'opened' | 'usedUnopened'>): PortProblem[] {
return PORT_DEVICES.flatMap((device): PortProblem[] => {
const opened = state.opened[device]
const wired = state.wiring[device]
if (opened === null) return state.usedUnopened.includes(device) ? [{ device, opened, wired }] : []
return opened.trim().toUpperCase() !== wired ? [{ device, opened, wired }] : []
})
}
/** 一句话说明串口问题,如"程序在 COM2 上打开 ADAM-4150,它接在 COM3" */
export function describePortProblem(p: PortProblem) {
const name = PORT_DEVICE_NAMES[p.device]
// 名字以中文结尾时直接接"的"("LED 屏的串口"),以字母、数字结尾时空一格("ADAM-4150 的串口")
const owner = /[\x00-\x7f]$/.test(name) ? `${name} 的` : `${name}的`
if (p.opened === null) return `${owner}串口没有打开,程序却在读写它(它接在 ${p.wired})`
return p.opened.trim() ? `程序在 ${p.opened.trim()} 上打开 ${name},它接在 ${p.wired}` : `程序没有选串口就打开了 ${name},它接在 ${p.wired}`
}
/**
@@ -117,7 +166,10 @@ export type RunnerMessage =
| { type: 'media'; id: string; action: 'play' | 'stop'; key?: string; name?: string }
/** 学生程序用 Image.Save 保存了图片;path 相对程序所在目录,如 Camera/20241022_10 30 00.jpg */
| { type: 'imageSaved'; path: string; image: string }
/** 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay、camera/on、camera/pan,data 见 DeviceCtrlHelper.cs、IPCameraHelper.cs */
/**
* 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay、camera/on、camera/pan,data 见 DeviceCtrlHelper.cs、IPCameraHelper.cs;
* 以及打开、关闭串口设备:adam4017/open(data 是串口名)、adam4017/close,串口没打开时程序还在读写:adam4017/unopened(每次打开后最多报一次),4150、zigbee、led 同样
*/
| { type: 'device'; device: string; action: string; data: unknown }
| { type: 'console'; text: string }
/** 学生程序没有处理的异常;file、line 是出错的那行学生代码(找得到时才有),网页上点击可以跳过去 */
@@ -150,14 +202,16 @@ export type UserAction =
export type RunnerInput =
| ({ type: 'action'; id: string } & UserAction)
| { type: 'reply'; requestId: number; result: unknown }
/** 实训台传感器读数,按通道号排列(见 ANALOG_CHANNELS、DIGITAL_CHANNELS),以及虚拟时钟(不带时表示不变) */
| { type: 'board'; analog: number[]; digital: boolean[]; clock?: VirtualClock | null }
/** 实训台传感器读数,按通道号排列(见 ANALOG_CHANNELS、DIGITAL_CHANNELS),以及虚拟时钟、接线(不带时表示不变) */
| { type: 'board'; analog: number[]; digital: boolean[]; clock?: VirtualClock | null; wiring?: Wiring }
| { type: 'stop' }
/** 浏览器发给服务器的消息 */
export type ClientMessage =
/** sampleId:当前实验,第一次运行时自动建好实验预置的数据库(学生还没有这些库时) */
| { type: 'run'; files: SourceFile[]; sampleId?: string }
/** 打开了哪个实验:实训台换成这个实验的接线(没有 sampleId 时用默认接线) */
| { type: 'experiment'; sampleId?: string }
/** 调节实训台环境:拖动滑块、触发传感器 */
/** clock:拨到某个时刻(time:yyyy-MM-ddTHH:mm:ss)、改倍速,或 null 恢复真实时间 */
| { type: 'boardInput'; analog?: Partial<BoardState['analog']>; digital?: Partial<BoardState['digital']>; clock?: { time?: string; speed?: number } | null }
+11
View File
@@ -6,6 +6,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
/** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */
readonly data: Record<string, unknown> = {},
) {
super(message)
}
@@ -22,6 +24,15 @@ export async function api<T>(method: string, path: string, json?: unknown, init?
return data
}
/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */
export async function apiResponse(method: string, path: string, json?: unknown): Promise<Response> {
const response = await send(method, path, json, undefined)
if (response.ok) return response
const data = (await response.json().catch(() => ({}))) as Record<string, unknown> & { message?: string }
if (response.status === 401) apiHooks.onUnauthorized()
throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status, data)
}
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
const headers: Record<string, string> = {}
+36
View File
@@ -0,0 +1,36 @@
// 导出:服务器照教材工程(真机版设备类库)编译,下载到机房电脑上用。
// EXE:一个 exe,双击就能控制真的实训台;VS 工程:教材那样的 VS 2012 工程(zip),用 VS 打开接着改。
import type { Diagnostic, SourceFile } from '@iot/shared'
import { apiResponse, ApiError } from '../api'
import { projectNamespace } from '../designer/templates'
export type ExportKind = 'exe' | 'project'
const EXT: Record<ExportKind, string> = { exe: 'exe', project: 'zip' }
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise<Diagnostic[] | null> {
let response: Response
try {
response = await apiResponse('POST', `/api/export-${kind}`, { files, name: projectNamespace(files) })
} catch (error) {
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
throw error
}
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.${EXT[kind]}`
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = name
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
return null
}
/** Content-Disposition 里的中文文件名(filename*=UTF-8''…) */
function fileName(header: string | null) {
const m = header && /filename\*=UTF-8''([^;]+)/i.exec(header)
return m ? decodeURIComponent(m[1]) : null
}
+38 -2
View File
@@ -106,7 +106,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<h3>5. 虚拟实训台</h3>
<ul>
<li>运行区下面是虚拟实训台,代替书上的实物设备。程序打开的串口:COM2 → ADAM-4017+,COM3 → ADAM-4150,COM4 → ZigBee 协调器,COM5 → LED 屏。程序里的串口下拉框展开时,每个串口后面也会用灰字标出接的设备。</li>
<li>
运行区下面是虚拟实训台,代替书上的实物设备。设备接在串口服务器上:COM2 → ADAM-4017+,COM3 → ADAM-4150,COM4 → ZigBee 协调器,COM5 → LED 屏(项目 7
的 ADAM-4150 和 ADAM-4017+ 一起接在 COM2
上,书上的代码就是在一个串口上打开两个模块)。程序里的串口下拉框展开时,每个串口后面也会用灰字标出接的设备。
</li>
<li>
<b>串口要选对</b
>:和真机一样,在没接这台设备的串口上打开它不会报错,只是设备没有反应(读到的数据是空的、灯不亮)。忘了点“打开”就读数据、开灯也一样。这时实训台最上方会用红字提示,比如“程序在
COM2 上打开 ADAM-4150,它接在 COM3”。
</li>
<li>
鼠标移到实训台的设备上,标题栏会写出它的接线,比如“人体红外 → ADAM-4150 DI0 → 串口服务器
COM3”,接的通道和串口也会高亮。串口前面的绿点表示程序打开了这个串口。
</li>
<li>拖动滑块(或点一下滑块再按方向键)调温度、湿度、光照;点人体红外、烟雾、火焰、红外对射切换有无。程序控制的灯、警示灯、LED 屏会跟着变化。</li>
<li>
两盏照明灯:<b>照明灯 1</b> 接 ZigBee 继电器节点 1(项目 3 的路灯),<b>照明灯 2</b> 接 ADAM-4150 的 DO1(项目 7 的教室、寝室、会议室);警示灯接
@@ -142,7 +155,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<h3>7. 数据库(项目 6、8)</h3>
<ul>
<li>点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:左边点“+ 新建库”“+ 新建表”,点表名编辑表里的数据,表右边的 ⋯(或右键)里能查询、设计表结构、删除;<b>新建查询</b>写 SQL(输入时会提示关键字、表名和列名,写"表名."或"别名."列出这张表的列),按 F5 执行。</li>
<li>
点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:左边点“+ 新建库”“+ 新建表”,点表名编辑表里的数据,表右边的
⋯(或右键)里能查询、设计表结构、删除;<b>新建查询</b>写 SQL(输入时会提示关键字、表名和列名,写"表名."或"别名."列出这张表的列),按 F5 执行。
</li>
<li>每个人有自己的一套数据库,只有自己能看到,一直保留。程序里的连接字符串只看数据库名,服务器名、账号密码照书上写就行。</li>
<li>数据弄乱了:点<b>恢复实验初始数据</b>,选实验(默认是刚才做的实验),把这个实验用到的库换回初始数据(其他库不动)。</li>
<li>点<b>← 返回实验</b>回到刚才做的实验。</li>
@@ -151,6 +167,26 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<a href="/help-img/db.webp" title="点击放大" @click.prevent="zoom"><img src="/help-img/db.webp" alt="" loading="lazy" /></a>
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
</figure>
<h3>8. 导出 EXE 或 VS 工程,在机房电脑上运行</h3>
<ul>
<li>
点工具栏的<b>导出</b>→<b>导出 EXE</b>,下载一个
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
</li>
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
<li>
想在机房的 VS 里接着改,点<b>导出</b>→<b>导出 VS 工程</b>,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5
运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。
</li>
<li>
机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如
<code>$"温度{t}"</code>)会弹出报错和改法,改好再导出。
</li>
</ul>
</section>
<section v-if="currentUser?.role === 'teacher'" id="teacher">
+105 -5
View File
@@ -4,8 +4,22 @@
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import {
NButton,
NDropdown,
NInput,
NModal,
NPopconfirm,
NPopover,
NSelect,
NTab,
NTabs,
NTag,
useMessage,
type DropdownOption,
type SelectGroupOption,
} from 'naive-ui'
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
@@ -25,6 +39,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { checkProject } from '../editor/language'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { downloadExport, type ExportKind } from '../editor/export'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
@@ -222,7 +237,7 @@ async function loadAssignment(discardWork = false) {
}
// ---------- 老师运行学生提交的程序 ----------
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string; sampleId: string } | null>(null)
// 第一次打开一个实验(或作业)时自动打开任务面板;记在浏览器里,之后不再自动打开
const TASK_SEEN_KEY = 'iot-sim:task-seen'
@@ -243,7 +258,7 @@ watch(
async function loadSubmission() {
try {
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string; sampleId: string }>(
'GET',
`/api/teacher/submissions/${props.submissionId}`,
)
@@ -478,6 +493,50 @@ async function showDiagnostic(file: string, line: number, column: number) {
editorRef.value?.reveal(file, line, column)
}
// ---------- 导出:照真机版类库编译,EXE 在机房电脑上双击运行,VS 工程用机房的 VS 2012 打开 ----------
const exporting = ref(false)
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
const exportErrors = ref<Diagnostic[]>([])
const exportErrorsOpen = ref(false)
const exportKind = ref<ExportKind>('exe')
const EXPORT_OPTIONS: DropdownOption[] = [
{ key: 'exe', label: '导出 EXE', props: { title: '编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台' } },
{ key: 'project', label: '导出 VS 工程', props: { title: '打包成 VS 2012 工程(.zip),在机房电脑上解压后用 VS 打开,接着改代码、按 F5 运行' } },
]
const EXPORT_DONE: Record<ExportKind, string> = {
exe: '已导出,在机房电脑上双击运行(要接真的实训台)',
project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)',
}
async function exportProgram(kind: ExportKind) {
if (exporting.value) return
exporting.value = true
exportKind.value = kind
try {
const errors = await downloadExport(
kind,
files.value.map((f) => ({ ...f })),
)
if (errors) {
exportErrors.value = errors
exportErrorsOpen.value = true
} else {
message.success(EXPORT_DONE[kind])
}
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
} finally {
exporting.value = false
}
}
function jumpToExportError(d: Diagnostic) {
exportErrorsOpen.value = false
void showDiagnostic(d.file, d.line, d.column)
}
function restoreOriginal() {
if (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
@@ -513,10 +572,14 @@ function start() {
rerunBlocked.value = false
session.run(
files.value.map((f) => ({ ...f })),
props.assignmentId ? assignment.value?.sampleId : sampleId.value,
experimentId.value,
)
}
/** 当前实验:做作业、看学生提交时是作业基于的实验。实训台按它接线(项目 7 的 4150 并在 COM2 上) */
const experimentId = computed(() => (props.submissionId ? viewing.value?.sampleId : props.assignmentId ? assignment.value?.sampleId : sampleId.value))
watch(experimentId, (id) => session.setExperiment(id), { immediate: true })
// ---------- 运行中改了代码或窗体:自动重新运行,不用再按 F5 ----------
/** 停止修改多久后重新运行 */
const RERUN_DELAY_MS = 1500
@@ -596,6 +659,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务
</NButton>
<NDropdown trigger="click" :options="EXPORT_OPTIONS" :disabled="exporting" @select="(key: ExportKind) => exportProgram(key)">
<NButton size="small" :loading="exporting" title="导出到机房电脑上用:EXE 双击运行,VS 工程用 VS 打开">导出 ▾</NButton>
</NDropdown>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -619,6 +685,18 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
<p v-if="exportKind === 'project'" class="export-errors-note">
导出时照机房电脑上的 VS 2012 编译(只支持 C# 5,用真机版设备类库)。点击报错跳到出错的代码。
</p>
<p v-else class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<p v-if="exportErrors.length > 20" class="export-errors-note">还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。</p>
</NModal>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
@@ -790,6 +868,28 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
color: #dc2626;
}
.export-errors-note {
margin: 0 0 8px;
color: #6b7280;
font-size: 13px;
}
.export-error {
color: #b91c1c;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.export-error:hover {
text-decoration: underline;
}
.export-error .hint {
color: #2563eb;
padding-left: 1em;
}
.rerun-blocked {
color: #b45309;
font-size: 12px;
+5
View File
@@ -7,6 +7,7 @@ import TrainingBoard from '../scene/TrainingBoard.vue'
import type { SizeValue } from '../winforms/format'
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
import { RUN_IMAGES } from '../winforms/images'
import { PORT_LABELS, portLabels } from '../scene/wiring'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
import type { RunSession } from './session'
@@ -17,6 +18,10 @@ const emit = defineEmits<{
const state = props.session.state
provide(RUN_IMAGES, props.session.images)
provide(
PORT_LABELS,
computed(() => portLabels(props.session.state.board?.wiring)),
)
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
void loadGallery()
+12 -1
View File
@@ -61,6 +61,8 @@ export function createRunSession() {
/** 离开页面时主动断开,不再重连 */
let closed = false
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
/** 当前实验(服务器按它给实训台接线);重连后重新告诉服务器 */
let experiment: string | undefined
/** 连续重连失败的次数,等待时间随之加长(最长 RECONNECT_MAX_MS) */
let reconnectFailures = 0
const RECONNECT_MAX_MS = 10_000
@@ -105,6 +107,7 @@ export function createRunSession() {
ws.onopen = () => {
state.connection = state.connection === 'lost' ? 'restored' : 'open'
reconnectFailures = 0
send({ type: 'experiment', sampleId: experiment })
resolve()
}
ws.onclose = () => {
@@ -155,6 +158,14 @@ export function createRunSession() {
send({ type: 'run', files, sampleId })
}
/** 打开了哪个实验:实训台换成它的接线 */
function setExperiment(sampleId: string | undefined) {
if (sampleId === experiment) return
experiment = sampleId
// 还在连接时不能发,连上后 onopen 会发
if (socket?.readyState === WebSocket.OPEN) send({ type: 'experiment', sampleId })
}
/** 调节实训台环境(滑块、传感器开关) */
function setBoard(input: Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>) {
send({ type: 'boardInput', ...input })
@@ -292,7 +303,7 @@ export function createRunSession() {
socket?.close()
}
return { state, images, connect, run, stop, act, replyMessageBox, setBoard, resetBoard, close }
return { state, images, connect, run, stop, act, replyMessageBox, setExperiment, setBoard, resetBoard, close }
}
export type RunSession = ReturnType<typeof createRunSession>
+192 -20
View File
@@ -2,7 +2,21 @@
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ANALOG_INFO, clockWall, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
import {
ANALOG_INFO,
BOARD_PORTS,
clockWall,
DEFAULT_WIRING,
describePortProblem,
PORT_DEVICE_NAMES,
portProblems,
type AnalogKey,
type BoardState,
type ClientMessage,
type DigitalKey,
type PortDevice,
} from '@iot/shared'
import { devicesOn } from './wiring'
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
@@ -145,6 +159,57 @@ function setSpeed(speed: number) {
emit('set', { clock: { speed } })
}
// ---------- 接线:鼠标移到设备上,高亮它接的通道、模块和串口,并写出整条线路(只看,不能改) ----------
interface Link {
label: string
module: PortDevice | null
/** 接在模块的哪个通道上 */
channels?: string[]
/** 没有模块通道时的线路说明 */
route?: string
}
const LINKS: Record<string, Link> = {
infrared: { label: '红外对射', module: 'adam4150', channels: ['DI3'] },
alarm: { label: '警示灯', module: 'adam4150', channels: ['DO0'] },
camera: { label: '网络摄像头', module: null, route: '网线 → 路由器(按 IP 地址访问,不经过串口)' },
led: { label: 'LED 显示屏', module: 'led' },
streetLamp: { label: '照明灯 1', module: 'zigbee', route: 'ZigBee 继电器节点 1 → 无线 → ZigBee 协调器' },
bodyInfrared: { label: '人体红外', module: 'adam4150', channels: ['DI0'] },
humiture: { label: '温湿度传感器', module: 'adam4017', channels: ['Vin0', 'Vin1'] },
light: { label: '光照传感器', module: 'adam4017', channels: ['Vin2'] },
speaker: { label: '广播', module: null, route: '电脑的声卡(Windows Media Player 播放)' },
smoke: { label: '烟雾传感器', module: 'adam4150', channels: ['DI2'] },
fire: { label: '火焰传感器', module: 'adam4150', channels: ['DI1'] },
roomLamp: { label: '照明灯 2', module: 'adam4150', channels: ['DO1'] },
}
const hovered = ref<string | null>(null)
const wiring = computed(() => props.board?.wiring ?? DEFAULT_WIRING)
const link = computed(() => (hovered.value ? LINKS[hovered.value] : undefined))
/** 如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3" */
const linkPath = computed(() => {
const l = link.value
if (!l) return ''
const steps = [l.label]
if (l.route) steps.push(l.route)
if (l.module && l.channels) steps.push(`${PORT_DEVICE_NAMES[l.module]} ${l.channels.join('、')}`)
if (l.module) steps.push(`串口服务器 ${wiring.value[l.module]}`)
return steps.join(' → ')
})
const linkedChannel = (module: PortDevice, channel: string) => link.value?.module === module && link.value.channels?.includes(channel) === true
const linkedModule = (module: PortDevice) => link.value?.module === module
const linkedPort = (port: string) => !!link.value?.module && wiring.value[link.value.module] === port
/** 学生程序打开了这个串口(不管上面接的是不是它要的设备) */
const portOpen = (port: string) => Object.values(props.board?.opened ?? {}).some((opened) => opened?.trim().toUpperCase() === port)
const portDevices = (port: string) =>
devicesOn(wiring.value, port)
.map((device) => PORT_DEVICE_NAMES[device])
.join('、') || '空'
/** 串口打开错了、或没打开就读写的设备:设备没有反应,提示学生 */
const problems = computed(() => (props.board ? portProblems(props.board) : []))
const cameraTitle = computed(() =>
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
)
@@ -175,6 +240,7 @@ const cameraTitle = computed(() =>
</button>
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
<span v-if="clock" class="summary-clock" title="虚拟时钟:学生程序里的 DateTime.Now">🕒 {{ wall.slice(11) }}({{ clock.speed }}×)</span>
<span v-for="p in problems" :key="p.device" class="port-warning">⚠ {{ describePortProblem(p) }}</span>
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
</div>
@@ -186,39 +252,72 @@ const cameraTitle = computed(() =>
>
<div class="board-header">
<span class="board-name">虚拟实训台</span>
<span class="wiring-path" :class="{ active: link }" :title="linkPath">{{ linkPath || '鼠标移到设备上,看它接在哪个通道、哪个串口' }}</span>
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
</div>
<!-- 串口问题放在最上面,不用往下滚就能看到 -->
<div v-if="problems.length" class="port-warnings">
<div v-for="p in problems" :key="p.device" class="port-warning">⚠ {{ describePortProblem(p) }},设备收不到命令</div>
</div>
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
<div class="ceiling">
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
<button
class="device beam-end"
:class="{ active: digital.infrared }"
title="点击遮挡/恢复光束"
@click="toggle('infrared')"
@mouseenter="hovered = 'infrared'"
@mouseleave="hovered = null"
>
红外对射
</button>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
</div>
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)" @mouseenter="hovered = 'alarm'" @mouseleave="hovered = null">
警示灯
</div>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle" @mouseenter="hovered = 'camera'" @mouseleave="hovered = null">
<svg viewBox="0 0 24 24" width="22" height="22">
<path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" />
</svg>
</div>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
<button
class="device beam-end"
:class="{ active: digital.infrared }"
title="点击遮挡/恢复光束"
@click="toggle('infrared')"
@mouseenter="hovered = 'infrared'"
@mouseleave="hovered = null"
>
红外对射
</button>
</div>
<!-- LED 显示屏 -->
<div class="led-screen" title="LED 显示屏">
<div class="led-screen" title="LED 显示屏" @mouseenter="hovered = 'led'" @mouseleave="hovered = null">
<span v-if="ledText" :key="ledText" class="led-text">{{ ledText }}</span>
</div>
<!-- 传感器与执行器 -->
<div class="sensors">
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)">
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)" @mouseenter="hovered = 'streetLamp'" @mouseleave="hovered = null">
<div class="bulb" :class="{ on: streetLampOn }" />
<div class="card-title">照明灯 1</div>
<div class="card-note">ZigBee 节点 1</div>
</div>
<button class="card toggle" :class="{ active: digital.bodyInfrared }" title="点击切换:有人 / 无人" @click="toggle('bodyInfrared')">
<button
class="card toggle"
:class="{ active: digital.bodyInfrared }"
title="点击切换:有人 / 无人"
@click="toggle('bodyInfrared')"
@mouseenter="hovered = 'bodyInfrared'"
@mouseleave="hovered = null"
>
<svg viewBox="0 0 24 24" width="30" height="30" class="icon">
<circle cx="12" cy="5" r="2.6" fill="currentColor" />
<path fill="currentColor" d="M9 9h6l1.5 6h-2l-.8 7h-3.4l-.8-7h-2z" />
@@ -227,7 +326,7 @@ const cameraTitle = computed(() =>
<div class="card-note">{{ digital.bodyInfrared ? '有人' : '无人' }}</div>
</button>
<div class="card wide">
<div class="card wide" @mouseenter="hovered = 'humiture'" @mouseleave="hovered = null">
<div class="card-title">温湿度传感器</div>
<label v-for="key in ['temperature', 'humidity'] as AnalogKey[]" :key="key" class="slider">
<span>{{ ANALOG_INFO[key].label }}</span>
@@ -244,7 +343,7 @@ const cameraTitle = computed(() =>
</label>
</div>
<div class="card wide">
<div class="card wide" @mouseenter="hovered = 'light'" @mouseleave="hovered = null">
<div class="card-title">光照传感器</div>
<label class="slider">
<span>光照</span>
@@ -262,7 +361,12 @@ const cameraTitle = computed(() =>
</label>
</div>
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
<div
class="card"
:title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'"
@mouseenter="hovered = 'speaker'"
@mouseleave="hovered = null"
>
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
<path
@@ -278,13 +382,27 @@ const cameraTitle = computed(() =>
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
</div>
<button class="card toggle" :class="{ active: digital.smoke }" title="点击切换:有烟雾 / 正常" @click="toggle('smoke')">
<button
class="card toggle"
:class="{ active: digital.smoke }"
title="点击切换:有烟雾 / 正常"
@click="toggle('smoke')"
@mouseenter="hovered = 'smoke'"
@mouseleave="hovered = null"
>
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
<div class="card-title">烟雾传感器</div>
<div class="card-note">{{ digital.smoke ? '有烟雾' : '正常' }}</div>
</button>
<button class="card toggle" :class="{ active: digital.fire }" title="点击切换:有火焰 / 正常" @click="toggle('fire')">
<button
class="card toggle"
:class="{ active: digital.fire }"
title="点击切换:有火焰 / 正常"
@click="toggle('fire')"
@mouseenter="hovered = 'fire'"
@mouseleave="hovered = null"
>
<svg viewBox="0 0 24 24" width="30" height="30" class="icon flame" :class="{ on: digital.fire }">
<path fill="currentColor" d="M12 2c1 4 5 5.5 5 11a5 5 0 0 1-10 0c0-3 1.5-4.5 2.5-6 .5 2 1.5 3 2.5 3-1-3 0-6 0-8z" />
</svg>
@@ -292,7 +410,12 @@ const cameraTitle = computed(() =>
<div class="card-note">{{ digital.fire ? '有火焰' : '正常' }}</div>
</button>
<div class="card" title="照明灯 2:由 ADAM-4150 的 DO1 控制(教材项目 7 的教室、寝室、会议室照明)">
<div
class="card"
title="照明灯 2:由 ADAM-4150 的 DO1 控制(教材项目 7 的教室、寝室、会议室照明)"
@mouseenter="hovered = 'roomLamp'"
@mouseleave="hovered = null"
>
<div class="bulb" :class="{ on: roomLampOn }" />
<div class="card-title">照明灯 2</div>
<div class="card-note">ADAM-4150 DO1</div>
@@ -301,10 +424,10 @@ const cameraTitle = computed(() =>
<!-- 采集模块与串口 -->
<div class="modules">
<div class="module">
<div class="module" :class="{ linked: linkedModule('adam4017') }">
<div class="module-title">ADAM-4017+ 模拟量(地址 2)</div>
<div class="readings">
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key" :class="{ linked: linkedChannel('adam4017', `Vin${i}`) }">
Vin{{ i }} {{ ANALOG_INFO[key].label }} <b>{{ board?.analog[key] ?? '-' }}</b>
</span>
<button class="link" @click="showMore = !showMore">{{ showMore ? '收起' : 'Vin3–6 更多传感器' }}</button>
@@ -326,19 +449,32 @@ const cameraTitle = computed(() =>
</div>
</div>
<div class="module">
<div class="module" :class="{ linked: linkedModule('adam4150') }">
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
<div class="io-row">
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }"> <i />DI{{ i }} {{ input.label }} </span>
<span
v-for="(input, i) in DIGITAL_INPUTS"
:key="input.key"
class="io"
:class="{ lit: digital[input.key], linked: linkedChannel('adam4150', `DI${i}`) }"
>
<i />DI{{ i }} {{ input.label }}
</span>
</div>
<div class="io-row">
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }"> <i />DO{{ i }} {{ label }} </span>
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i], linked: linkedChannel('adam4150', `DO${i}`) }">
<i />DO{{ i }} {{ label }}
</span>
</div>
</div>
<div class="module ports">
<div class="module-title">串口服务器</div>
<div class="port-list"><span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span></div>
<div class="port-list" title="绿点:程序打开了这个串口">
<span v-for="port in BOARD_PORTS" :key="port" class="io out" :class="{ lit: portOpen(port), linked: linkedPort(port) }">
<i />{{ port }} → {{ portDevices(port) }}
</span>
</div>
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
</div>
</div>
@@ -860,6 +996,42 @@ button {
color: var(--muted);
}
.port-warning {
color: #ff8a80;
}
.port-warnings {
padding: 4px 8px;
background: #4a2f35;
border-radius: 4px;
}
/* 接线:鼠标所在设备接的模块、通道和串口 */
.wiring-path {
flex: 1;
min-width: 0;
margin: 0 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: #6c7889;
}
.wiring-path.active {
color: #9fd3ff;
}
.module.linked {
box-shadow: inset 0 0 0 1px var(--accent);
}
.readings .linked,
.io.linked {
color: #9fd3ff;
text-decoration: underline;
text-underline-offset: 3px;
}
.link {
padding: 0;
background: none;
+21
View File
@@ -0,0 +1,21 @@
// 实训台接线在网页上的显示:串口服务器上每个口接了哪些设备。
// 串口下拉框展开时也用灰字标出来:运行区 provide,ControlView inject;设计器里没有实训台,用默认接线。
import type { InjectionKey, Ref } from 'vue'
import { BOARD_PORTS, DEFAULT_WIRING, PORT_DEVICE_NAMES, PORT_DEVICES, type PortDevice, type Wiring } from '@iot/shared'
/** 接在这个串口上的设备(项目 7 的 COM2 上并着 4017+ 和 4150) */
export function devicesOn(wiring: Wiring, port: string): PortDevice[] {
return PORT_DEVICES.filter((device) => wiring[device] === port)
}
/** 串口 → 设备名,如 { COM2: 'ADAM-4017+、ADAM-4150' };没接设备的口不列出 */
export function portLabels(wiring: Wiring = DEFAULT_WIRING): Record<string, string> {
const labels: Record<string, string> = {}
for (const port of BOARD_PORTS) {
const names = devicesOn(wiring, port).map((device) => PORT_DEVICE_NAMES[device])
if (names.length) labels[port] = names.join('、')
}
return labels
}
export const PORT_LABELS: InjectionKey<Ref<Record<string, string>>> = Symbol('portLabels')
+3 -2
View File
@@ -10,6 +10,7 @@ import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue'
import LedControl from './LedControl.vue'
import TrackBarControl from './TrackBarControl.vue'
import { PORT_LABELS, portLabels } from '../scene/wiring'
const props = defineProps<{
node: ControlNode
@@ -61,8 +62,8 @@ const items = computed(() => (p.value.Items as string[] | undefined) ?? [])
const editable = computed(() => p.value.DropDownStyle !== 'DropDownList')
const combo = ref<HTMLElement>()
const listStyle = ref<CSSProperties>({})
/** 串口列表里每个口后面用灰字标出实训台上接的设备,只是提示,下拉框的值还是 COM2 这样的串口名 */
const portDevices: Record<string, string> = { COM2: 'ADAM-4017+', COM3: 'ADAM-4150', COM4: 'ZigBee 协调器', COM5: 'LED 屏' }
/** 串口列表里每个口后面用灰字标出实训台上接的设备(按当前实验的接线),只是提示,下拉框的值还是 COM2 这样的串口名 */
const portDevices = inject(PORT_LABELS, ref(portLabels()))
function openDropDown() {
if (!enabled.value) return
+19 -9
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
// 日期时间选择器:文字由运行器按 Format 生成(如 2024年1月16日),点下拉按钮弹出浏览器的日期 / 时间选择框,
// 选好后把完整的日期时间发给运行器(只选日期时保留原来的时间,和 WinForms 一样)。
import { computed, ref, type CSSProperties } from 'vue'
// 下拉按钮上透明地盖着浏览器的日期框(日历图标撑满按钮),点按钮其实点的是日期框自己的图标,由浏览器弹出选择框。
// 不用 showPicker():它 Chrome 99 才正式有,机房 Chrome 94 的原型上虽然有这个方法,调用却没有反应。
import { computed, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
const props = defineProps<{
@@ -20,12 +22,9 @@ const nativeValue = computed(() => {
const [date, time] = value.value.split('T')
return inputType.value === 'date' ? date : inputType.value === 'time' ? time : value.value
})
const picker = ref<HTMLInputElement>()
function open() {
if (!props.enabled) return
props.act({ action: 'dropDown' })
picker.value?.showPicker?.()
/** 点的是盖在按钮上的日期框:告诉运行器"展开了"(触发 DropDown),弹出由浏览器负责 */
function nativeDown() {
if (props.enabled) props.act({ action: 'dropDown' })
}
function pick(event: Event) {
@@ -58,7 +57,18 @@ function step(delta: number) {
<span @mousedown.prevent="step(1)">▴</span>
<span @mousedown.prevent="step(-1)">▾</span>
</span>
<span v-else class="wf-dtp-button" title="选择日期" @mousedown.prevent="open">▾</span>
<input ref="picker" class="wf-dtp-native" :type="inputType" step="1" :value="nativeValue" tabindex="-1" @change="pick" />
<span v-else class="wf-dtp-button">▾</span>
<input
class="wf-dtp-native"
:class="{ 'wf-dtp-overlay': !p.ShowUpDown }"
title="选择日期"
:type="inputType"
step="1"
:value="nativeValue"
:disabled="!enabled"
tabindex="-1"
@mousedown="nativeDown"
@change="pick"
/>
</div>
</template>
+20
View File
@@ -365,6 +365,26 @@
padding: 0;
}
/* 日期框透明地盖在下拉按钮上,日历图标撑满整个按钮,点它就由浏览器弹出选择框(见 DateTimePickerControl.vue) */
.wf-dtp-native.wf-dtp-overlay {
left: auto;
right: 0;
top: 0;
width: 17px;
height: auto;
pointer-events: auto;
}
.wf-dtp-overlay::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
/* TrackBar:灰色轨道、刻度、蓝色滑块(Windows 10 样式) */
.wf-trackbar {
outline: none;