Compare commits
7
Commits
9ed446f95a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f242308a3b | ||
|
|
2aa267839f | ||
|
|
4c3ce31fe1 | ||
|
|
77bfaba7ff | ||
|
|
5e5d29b2aa | ||
|
|
46e0f40cce | ||
|
|
163e553de4 |
No files matched your search
@@ -7,4 +7,5 @@
|
||||
.git
|
||||
docs
|
||||
*.pdf
|
||||
textbook-libs
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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` 指到别处。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
|
||||
Executable
+63
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}。");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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('\\', '/');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 屏不显示,实训台最上方用红字提示串口没有打开。
|
||||
@@ -14,6 +14,7 @@
|
||||
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
|
||||
| 按钮 | `btn_get` | 获取 |
|
||||
| 分组框 | `grp_config` | 配置 |
|
||||
| 标签 | `lbl_port` | 串口 |
|
||||
| 下拉框 | `com_port` | 串口 |
|
||||
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台保留了它(什么也不做),书上的代码可以原样编译。
|
||||
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台补上了这个方法(关闭 LED 屏的串口),书上的代码可以原样编译。
|
||||
|
||||
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
|
||||
@@ -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. 再点"关",按钮变回"开",画面停止更新。
|
||||
@@ -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 }
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"title": "项目7 任务1 智慧教室",
|
||||
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库"
|
||||
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库",
|
||||
"wiring": {
|
||||
"adam4150": "COM2"
|
||||
}
|
||||
}
|
||||
@@ -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 以下自动开灯,会盖过手动开关),再点灯光的开关图片,灯应该一下开、一下关。
|
||||
@@ -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 是熄灯模式 }
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"title": "项目7 任务2 智慧寝室",
|
||||
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间"
|
||||
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间",
|
||||
"wiring": {
|
||||
"adam4150": "COM2"
|
||||
}
|
||||
}
|
||||
@@ -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,广播响铃。
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"title": "项目7 任务3 智慧会议室",
|
||||
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间"
|
||||
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间",
|
||||
"wiring": {
|
||||
"adam4150": "COM2"
|
||||
}
|
||||
}
|
||||
@@ -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 只换了开关图片,没有改记录开关状态的变量,参考工程也是这样,不是你写错了。
|
||||
@@ -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 那张,再点回到第一张;点左箭头往回翻。
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 就用 */
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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('评分规则写错时指出位置', () => {
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user