Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f242308a3b | ||
|
|
2aa267839f | ||
|
|
4c3ce31fe1 | ||
|
|
77bfaba7ff | ||
|
|
5e5d29b2aa | ||
|
|
46e0f40cce | ||
|
|
163e553de4 | ||
|
|
9ed446f95a | ||
|
|
6a18bdf2e4 | ||
|
|
ab933d1896 | ||
|
|
50dced4d37 | ||
|
|
50946467f6 | ||
|
|
a2f03ee57f | ||
|
|
e177753ad6 | ||
|
|
f9a2739353 | ||
|
|
b292d38ba7 | ||
|
|
d6e891cdb0 | ||
|
|
53005e69e4 | ||
|
|
313acf2453 | ||
|
|
b7e7f77fb0 | ||
|
|
b3d6e1f88f | ||
|
|
f6024dc113 | ||
|
|
da623fa648 | ||
|
|
e4c53f5e55 | ||
|
|
61a32e900d | ||
|
|
4ce800c77f | ||
|
|
de277d3e9d | ||
|
|
d4b8bf9241 | ||
|
|
90b739a2c7 | ||
|
|
3b9f635c35 | ||
|
|
27fdc3615d | ||
|
|
982b1297aa | ||
|
|
e8058376dd | ||
|
|
c217eb5ca0 | ||
|
|
f304fc1ffc | ||
|
|
fe62d50881 | ||
|
|
f256b313f7 |
No files matched your search
@@ -7,4 +7,5 @@
|
||||
.git
|
||||
docs
|
||||
*.pdf
|
||||
textbook-libs
|
||||
.env
|
||||
@@ -19,3 +19,5 @@ SIM_HEAP_LIMIT_MB=128
|
||||
SIM_CPU_LIMIT_SECONDS=120
|
||||
# 每次运行最长时间(分钟)
|
||||
SIM_MAX_RUN_MINUTES=60
|
||||
# 全站同时跑几个评分检查项(提交评分和"自己检查"共用);2 核服务器不要超过 24,CPU 不够会误判
|
||||
SIM_GRADE_CONCURRENCY=16
|
||||
@@ -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
|
||||
|
||||
# 编辑器
|
||||
@@ -22,3 +24,9 @@ obj/
|
||||
|
||||
# 本机开发的数据库
|
||||
/data/
|
||||
|
||||
# 服务器上 deploy/backup.sh 做的备份
|
||||
/backups/
|
||||
|
||||
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
|
||||
/prebuilt/
|
||||
@@ -0,0 +1,3 @@
|
||||
# bun fmt 不格式化的文件
|
||||
# 书上的摘录,保持原样
|
||||
docs/教材摘要/
|
||||
@@ -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 \
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## 目录
|
||||
|
||||
| 目录 | 内容 |
|
||||
|---|---|
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
||||
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||
@@ -33,11 +33,12 @@ cd server
|
||||
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
||||
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
|
||||
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||
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)。
|
||||
|
||||
## 运行器的四种模式
|
||||
|
||||
@@ -55,7 +56,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
||||
本机实测(16 核、28 GB):
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| ------------------------ | ----------------------------------------------------------- |
|
||||
| 编译(编译服务预热后) | 约 10–300 ms |
|
||||
| 启动到窗体显示 | 约 100 ms |
|
||||
| 单次操作往返 | 约 15 ms |
|
||||
@@ -72,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 自动部署。
|
||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
||||
- 导出 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 阶段)。
|
||||
@@ -0,0 +1,5 @@
|
||||
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
|
||||
[fmt]
|
||||
printWidth = 160
|
||||
semi = false
|
||||
singleQuote = true
|
||||
@@ -5,18 +5,27 @@ 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"]
|
||||
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
|
||||
volumes:
|
||||
- data:/data
|
||||
restart: "no"
|
||||
restart: 'no'
|
||||
networks: [default]
|
||||
|
||||
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
|
||||
@@ -33,6 +42,7 @@ services:
|
||||
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||
SIM_GRADE_CONCURRENCY: ${SIM_GRADE_CONCURRENCY:-16}
|
||||
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
|
||||
read_only: true
|
||||
tmpfs:
|
||||
@@ -40,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
|
||||
@@ -51,12 +63,13 @@ services:
|
||||
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||
pids_limit: 4096
|
||||
expose: ["3000"]
|
||||
expose: ['3000']
|
||||
networks: [default]
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
logging: *logging
|
||||
depends_on: [app]
|
||||
expose:
|
||||
- 80
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -97,20 +117,67 @@
|
||||
在服务器的 `~/IOT_SIM` 目录下:
|
||||
|
||||
| 做什么 | 命令 |
|
||||
|---|---|
|
||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| 更新到新版本 | `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` 指到别处。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行:
|
||||
|
||||
```bash
|
||||
deploy/backup.sh # 备份到 backups/<日期时间>/,平台运行中也可以,学生不受影响
|
||||
deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部数据
|
||||
```
|
||||
|
||||
- 备份包括账号、班级、学生作品、作业、提交、评阅(`iot-sim.db`)和每个学生自己的数据库(`databases/`)。一个班一学期的数据估计两三百 MB,主要是提交时的界面截图。
|
||||
- 备份放在宿主机的 `~/IOT_SIM/backups/` 里,不在 Docker 数据卷里,误删数据卷也还在;**要防服务器硬盘坏,把这个目录拷到别的机器上**。
|
||||
- 建议在部署大的新版本前、学期中间、期末导出成绩后各备份一次。
|
||||
- 恢复会先自动备份现在的数据(恢复错了还能换回来),然后停止平台约十几秒、替换数据、重新启动;正在运行的学生程序会结束,尽量不在上课时恢复。
|
||||
|
||||
## 可调参数
|
||||
|
||||
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
||||
@@ -120,3 +187,4 @@
|
||||
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
||||
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
||||
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在服务器上执行:备份平台的全部数据(账号、班级、学生作品、作业、提交、评阅,以及每个学生自己的数据库)
|
||||
# 到 backups/<日期时间>/。备份在宿主机上,不在 Docker 数据卷里,数据卷被删也还在;要防硬盘坏,把它拷到别的机器上。
|
||||
# 平台运行中也可以备份,学生不受影响。恢复见 deploy/restore.sh。
|
||||
# 用法:deploy/backup.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p backups
|
||||
name=$(date +%Y%m%d-%H%M%S)
|
||||
# 同一秒里做了两次(如 restore.sh 恢复前先备份)时,等到下一秒,不和已有的备份重名
|
||||
while [[ -e backups/$name ]]; do
|
||||
sleep 1
|
||||
name=$(date +%Y%m%d-%H%M%S)
|
||||
done
|
||||
inside=/data/.backup-$name
|
||||
# 在容器里导出(SQLite 一致读取,不会备份出写了一半的库),再拷到宿主机
|
||||
docker compose exec -T app bun server.js backup "$inside" >/dev/null
|
||||
docker compose cp "app:$inside" "backups/$name" 2>/dev/null
|
||||
docker compose exec -T app rm -rf "$inside"
|
||||
echo "已备份到 $(pwd)/backups/$name($(du -sh "backups/$name" | cut -f1))"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在服务器上执行:用 deploy/backup.sh 做的备份替换平台现在的全部数据。
|
||||
# 替换前先自动备份现在的数据(恢复错了还能换回来);恢复时平台停止约十几秒,正在运行的学生程序会结束。
|
||||
# 用法:deploy/restore.sh backups/<日期时间>
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
dir=${1:?用法:deploy/restore.sh backups/<日期时间>}
|
||||
if [[ ! -f $dir/iot-sim.db ]]; then
|
||||
echo "$dir 不是备份目录(里面应该有 iot-sim.db)"
|
||||
exit 1
|
||||
fi
|
||||
read -r -p "用 $dir 替换平台现在的全部数据?现在的数据会先备份。[y/N] " answer
|
||||
[[ $answer == y || $answer == Y ]] || exit 1
|
||||
|
||||
echo "== 备份现在的数据"
|
||||
deploy/backup.sh
|
||||
echo "== 停止平台、替换数据"
|
||||
docker compose stop app
|
||||
# 旧库的 -wal、-shm 文件一定要删掉,否则会被当成新库的未写完的日志,把恢复的库弄坏
|
||||
docker compose run --rm --no-deps -T -v "$(realpath "$dir"):/restore:ro" app \
|
||||
sh -c 'rm -rf /data/iot-sim.db /data/iot-sim.db-wal /data/iot-sim.db-shm /data/databases && cp -r /restore/. /data/'
|
||||
echo "== 启动平台"
|
||||
docker compose up -d app
|
||||
echo "已恢复到 $dir"
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## 已确定的关键决策
|
||||
|
||||
| 事项 | 决定 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 学生编程语言 | C# | 课程要求 |
|
||||
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||
@@ -51,20 +51,21 @@
|
||||
|
||||
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
||||
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
||||
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
|
||||
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
||||
|
||||
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
||||
|
||||
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM1–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 里接着改。
|
||||
|
||||
## 设备类库设计(兼容教材)
|
||||
|
||||
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
||||
|
||||
| 对象 | 主要成员 | 平台仿真行为 |
|
||||
| --- | --- | --- |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
||||
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
||||
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
||||
@@ -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。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
||||
- 摄像头事件在非界面线程触发,与真实行为一致。
|
||||
|
||||
@@ -151,7 +155,7 @@ IOT/
|
||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||
|
||||
| 层 | 用途 | 选择 |
|
||||
| --- | --- | --- |
|
||||
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||
@@ -179,7 +183,7 @@ IOT/
|
||||
| 部署 | 沙箱 | 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 分钟传不完 |
|
||||
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
|
||||
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||
|
||||
## 安全与服务器资源
|
||||
@@ -207,7 +211,7 @@ IOT/
|
||||
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
||||
|
||||
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
|
||||
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
||||
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
||||
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
||||
@@ -224,7 +228,7 @@ IOT/
|
||||
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
||||
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
||||
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
||||
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||
|
||||
老师不写代码也能修改或新增实验。
|
||||
|
||||
@@ -235,10 +239,10 @@ IOT/
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
|
||||
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||
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/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||
P2 -->|"关口:一个班试用顺利"| P3
|
||||
@@ -247,10 +251,14 @@ flowchart TB
|
||||
|
||||
并行事项:尽早办理 ICP 备案(需数周)。
|
||||
|
||||
**当前状态(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):
|
||||
|
||||
| 指标 | 结果 |
|
||||
| --- | --- |
|
||||
| ---------------------- | ---------------------------------------------------- |
|
||||
| 编译(编译服务预热后) | 10–300 ms |
|
||||
| 点"运行"到窗体出现 | 约 100 ms |
|
||||
| 单次操作往返 | 约 15 ms |
|
||||
@@ -287,7 +295,7 @@ flowchart TB
|
||||
- **SqlClient 仿冒**(`runtime/Sim.Data`):SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter,DataSet、DataTable、DataView 直接用 .NET 自带的。连接字符串只按 Initial Catalog 打开学生自己的同名库(服务器名、账号密码不检查,书上的长服务器名、"." 都能连上),库不存在时报 4060。
|
||||
- **T-SQL 转写**:ScriptDom(SQL Server 2008 语法)解析后按白名单逐个节点转写成 SQLite 语句,不认识的语法报"平台暂不支持",生成的 SQL 里只有平台写出的关键字和 tsql_ 函数。表用 STRICT 模式保存,原始的 T-SQL 列类型记在元数据表里,读出时还原成 SQL Server 返回的 .NET 类型(int、DateTime、bool……);varchar(n) 超长、类型不对、NULL、重复主键都报和 SQL Server 一样的错误号和中文信息。`insert into 表 values (...)` 不写列名时跳过自增列(照书上写时值的顺序和列顺序不一致,数据会存进错的列,和真实情况一样);日期统一存成 yyyy-MM-dd HH:mm:ss,书上拼接的 `DateTime.Now.ToString()`(2024/1/16 9:05:03)在插入和 BETWEEN 比较时都转换;支持 TOP(含 UPDATE / DELETE TOP)、CONVERT 样式 120 等、year / getdate / dateadd / datediff、`+` 按类型连接或相加、参数化查询、@@IDENTITY、多条语句。SqlCommandBuilder 根据带中文别名的查询生成 UPDATE。
|
||||
- **DataGridView**:绑定 DataView / DataTable / DataSet,自动生成列;在单元格里修改的值写回 DataTable,配合 SqlDataAdapter.Update 保存;当前行 ▶、新增行 *、选中行、CurrentRow.Cells[0].Value;类型转换失败时和 WinForms 一样触发 DataError。**DateTimePicker**:Long / Short / Time / Custom 格式与中文 Windows 一致,弹出浏览器的日期选择框。设计器工具箱加"数据表格""日期时间选择器"。
|
||||
- **数据库页面**(代替 SSMS,`/database`):对象资源管理器(库 → 表 → 列,右键新建数据库、新建表、设计、选择前 1000 行、编辑前 200 行、删除);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。
|
||||
- **数据库页面**(代替 SSMS,`/database`):对象资源管理器(只有库 → 表两层,比 SSMS 简单:“+ 新建库”“+ 新建表”,点表名编辑前 200 行,⋯ 和右键是同一个菜单:查询前 1000 行、设计表结构、删除;鼠标停在表名上显示列);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。
|
||||
- **评分**:每个实验可以带 `database.sql`,评分时每个检查项用一份新建的实验库,不碰学生自己的数据;评分规则加"点 / 修改表格单元格""在日期时间选择器里选日期""表格里有 / 没有某行"。新增示例项目 6 任务 2、3(任务 1 在数据库页面上完成),共 15 个。
|
||||
- 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。
|
||||
|
||||
@@ -315,13 +323,35 @@ flowchart TB
|
||||
|
||||
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
|
||||
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
|
||||
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
|
||||
- 2026-10-25:一个班试用前的准备。
|
||||
- **全班模拟**(`server/test/classroom.ts`):45 个虚拟学生通过 HTTP / WebSocket 依次登录、打开作业、边写边检查、运行、点按钮、自己检查、提交,每个环节在 10 秒内陆续开始,统计每人等了多久。服务器限制在 2 核、用 Release 版运行时:登录、打开、检查、运行到窗体出现、点按钮都在 1.5 秒内(项目 8 第一次运行要建 WisdomCampus 库,约 5 秒)。
|
||||
- **评分并发 6 → 16**:评分时 CPU 只用了两三成,慢在并发上限。45 人同时自测项目 8 任务 3,最后一人原来要等约 4 分钟,现在约 1.5 分钟;项目 3 任务 2 从约 1.5 分钟降到半分钟。并发 24 时 CPU 接近 80%,定时器来不及触发,参考答案被误判,所以停在 16(可用 `SIM_GRADE_CONCURRENCY` 调整)。提交评分队列同时评的份数跟着并发上限走,不再固定为 3 份。
|
||||
- **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。
|
||||
- **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。
|
||||
- **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。
|
||||
- **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。
|
||||
- **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。
|
||||
- **旧浏览器提示**:网页按 Chrome / Edge 94 以上打包(机房电脑是 Chrome 94,见 2026-10-28),更旧的浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
|
||||
- **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。
|
||||
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
|
||||
|
||||
- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板,随后补齐其余 21 份(23 个实验全部有说明)。
|
||||
- 2026-10-26:**窗体改名**。23 个示例里 11 个的主窗体不叫 Form1(Login、frm_analog、frm_LED、frm_control、Main),学生的空白工程却只有 Form1,以前属性窗口改不了窗体名(项目 8 任务 2 的评分还要找 Main 窗体)。现在选中窗体可以改"名称":和 VS 一样,类名、构造函数、各文件里的 `new X()` 和 X 类型的变量都改(跳过字符串和注释),Designer.cs 里窗体的块头注释和 `this.Name` 也改,两个文件跟着改名;事件处理方法名(如 Form1_Load)和窗体标题不变。改名不进设计器的撤销历史。任务说明里提示先改名再双击窗体生成 Load 事件。
|
||||
- 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 编译"。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||
|
||||
| 风险 | 担心什么 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||
@@ -336,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,7 +1,11 @@
|
||||
{
|
||||
"name": "iot-sim",
|
||||
"private": true,
|
||||
"workspaces": ["shared", "server", "web"],
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:server": "bun run --cwd server dev",
|
||||
"dev:web": "bun run --cwd web dev",
|
||||
|
||||
@@ -802,9 +802,10 @@ internal sealed class Translator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DATEADD(day, ...) 的第一个参数是关键字,解析成列名</summary>
|
||||
/// <summary>DATEADD(day, ...) 的第一个参数是关键字:ScriptDom 解析成 IdentifierLiteral(旧版本是列名)</summary>
|
||||
private static string DatePartName(ScalarExpression expression) => expression switch
|
||||
{
|
||||
IdentifierLiteral i => i.Value,
|
||||
ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value,
|
||||
StringLiteral s => s.Value,
|
||||
_ => throw SqlException.NotSupported("这种 datepart 写法"),
|
||||
|
||||
@@ -30,7 +30,10 @@ await rm(framesDir, { recursive: true, force: true })
|
||||
await mkdir(framesDir, { recursive: true })
|
||||
await mkdir(work, { recursive: true })
|
||||
try {
|
||||
for (const [flag, svg] of [[0, scene], [1, intruder]] as const) {
|
||||
for (const [flag, svg] of [
|
||||
[0, scene],
|
||||
[1, intruder],
|
||||
] as const) {
|
||||
const svgPath = join(work, `scene-${flag}.svg`)
|
||||
const pngPath = join(work, `scene-${flag}.png`)
|
||||
await Bun.write(svgPath, svg)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace System.Windows.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。
|
||||
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值,只把显式设置的值发给浏览器。
|
||||
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值(文本框等的颜色除外,见 OwnBackColor),只把显式设置的值发给浏览器。
|
||||
/// </summary>
|
||||
public class Control : Component, IWin32Window
|
||||
{
|
||||
@@ -178,24 +178,42 @@ public class Control : Component, IWin32Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>和 WinForms 一样:父控件(分组框、面板)禁用时子控件也是禁用的,读出 false、不响应操作;浏览器按同样的规则画成灰色</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get => _enabled;
|
||||
get => _enabled && (_parent?.Enabled ?? true);
|
||||
set
|
||||
{
|
||||
if (SetProp(ref _enabled, value, nameof(Enabled))) OnEnabledChanged(EventArgs.Empty);
|
||||
var old = Enabled;
|
||||
SetProp(ref _enabled, value, nameof(Enabled));
|
||||
if (Enabled != old) OnEnabledChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 没有显式设置时控件自己的颜色;null 表示沿用父控件。文本框、列表框、下拉框、日期选择是白底黑字(SystemColors.Window / WindowText),
|
||||
/// 不沿用父控件:分组框设成白字时,里面的文本框还是黑字。
|
||||
/// </summary>
|
||||
private protected virtual Color? OwnBackColor => null;
|
||||
|
||||
private protected virtual Color? OwnForeColor => null;
|
||||
|
||||
/// <summary>能不能把 BackColor 设成透明(含半透明):窗体、文本框、列表框、下拉框、日期选择、数据表格不行,和 WinForms 一样报错</summary>
|
||||
private protected virtual bool SupportsTransparentBackColor => true;
|
||||
|
||||
public virtual Color BackColor
|
||||
{
|
||||
get => !_backColor.IsEmpty ? _backColor : _parent?.BackColor ?? DefaultBackColor;
|
||||
set => SetProp(ref _backColor, value, nameof(BackColor));
|
||||
get => !_backColor.IsEmpty ? _backColor : OwnBackColor ?? _parent?.BackColor ?? DefaultBackColor;
|
||||
set
|
||||
{
|
||||
if (!value.IsEmpty && value.A < 255 && !SupportsTransparentBackColor) throw new ArgumentException("控件不支持透明的背景色。");
|
||||
SetProp(ref _backColor, value, nameof(BackColor));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Color ForeColor
|
||||
{
|
||||
get => !_foreColor.IsEmpty ? _foreColor : _parent?.ForeColor ?? DefaultForeColor;
|
||||
get => !_foreColor.IsEmpty ? _foreColor : OwnForeColor ?? _parent?.ForeColor ?? DefaultForeColor;
|
||||
set => SetProp(ref _foreColor, value, nameof(ForeColor));
|
||||
}
|
||||
|
||||
@@ -302,7 +320,14 @@ public class Control : Component, IWin32Window
|
||||
protected virtual void OnMouseLeave(EventArgs e) => MouseLeave?.Invoke(this, e);
|
||||
protected virtual void OnTextChanged(EventArgs e) => TextChanged?.Invoke(this, e);
|
||||
protected virtual void OnVisibleChanged(EventArgs e) => VisibleChanged?.Invoke(this, e);
|
||||
protected virtual void OnEnabledChanged(EventArgs e) => EnabledChanged?.Invoke(this, e);
|
||||
protected virtual void OnEnabledChanged(EventArgs e)
|
||||
{
|
||||
EnabledChanged?.Invoke(this, e);
|
||||
// 自己没被禁用的子控件实际状态也跟着变,同样触发 EnabledChanged(WinForms 的 OnParentEnabledChanged)
|
||||
for (var i = 0; i < Controls.Count; i++)
|
||||
if (Controls[i]._enabled) Controls[i].OnEnabledChanged(e);
|
||||
}
|
||||
|
||||
protected virtual void OnGotFocus(EventArgs e) => GotFocus?.Invoke(this, e);
|
||||
protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e);
|
||||
protected virtual void OnPaint(PaintEventArgs e) => Paint?.Invoke(this, e);
|
||||
|
||||
@@ -468,6 +468,8 @@ public class DataGridView : Control, ISupportInitialize
|
||||
private bool _initializing;
|
||||
private bool _rowsDirty;
|
||||
|
||||
private protected override bool SupportsTransparentBackColor => false;
|
||||
|
||||
public DataGridView()
|
||||
{
|
||||
Columns = new DataGridViewColumnCollection(this);
|
||||
|
||||
@@ -22,6 +22,10 @@ public class DateTimePicker : Control
|
||||
|
||||
protected override Size DefaultSize => new(200, 21);
|
||||
|
||||
private protected override Color? OwnBackColor => SystemColors.Window;
|
||||
private protected override Color? OwnForeColor => SystemColors.WindowText;
|
||||
private protected override bool SupportsTransparentBackColor => false;
|
||||
|
||||
public DateTime Value
|
||||
{
|
||||
get => _value;
|
||||
|
||||
@@ -23,6 +23,8 @@ public class Form : ContainerControl
|
||||
private bool _modal;
|
||||
private DialogResult _dialogResult;
|
||||
|
||||
private protected override bool SupportsTransparentBackColor => false;
|
||||
|
||||
public Form()
|
||||
{
|
||||
base.Visible = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
@@ -10,6 +11,10 @@ public abstract class ListControl : Control
|
||||
/// <summary>选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged)</summary>
|
||||
private protected int SelectedIndexValue = -1;
|
||||
|
||||
private protected override Color? OwnBackColor => SystemColors.Window;
|
||||
private protected override Color? OwnForeColor => SystemColors.WindowText;
|
||||
private protected override bool SupportsTransparentBackColor => false;
|
||||
|
||||
public abstract int SelectedIndex { get; set; }
|
||||
|
||||
public string DisplayMember { get; set; } = "";
|
||||
|
||||
@@ -10,6 +10,10 @@ public abstract class TextBoxBase : Control
|
||||
private bool _readOnly;
|
||||
private BorderStyle _borderStyle = BorderStyle.Fixed3D;
|
||||
|
||||
private protected override Color? OwnBackColor => _readOnly ? SystemColors.Control : SystemColors.Window;
|
||||
private protected override Color? OwnForeColor => SystemColors.WindowText;
|
||||
private protected override bool SupportsTransparentBackColor => false;
|
||||
|
||||
public bool Multiline
|
||||
{
|
||||
get => _multiline;
|
||||
|
||||
@@ -134,12 +134,54 @@ public static class SimHost
|
||||
public static void ReportException(Exception ex)
|
||||
{
|
||||
while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!;
|
||||
Send(new JsonObject
|
||||
var type = ex.GetType().FullName;
|
||||
// 仿真库自己抛的异常信息已经是中文;.NET 自带的是英文,常见的几种配上中文说明,原文放在括号里
|
||||
var message = HasChinese(ex.Message) ? $"{ex.Message}({type})"
|
||||
: ExplainException(ex) is { } explained ? $"{explained}({type}: {ex.Message})"
|
||||
: $"{type}: {ex.Message}";
|
||||
var frame = StudentFrame(ex);
|
||||
var result = new JsonObject
|
||||
{
|
||||
["type"] = "exception",
|
||||
["message"] = $"{ex.GetType().FullName}: {ex.Message}",
|
||||
["detail"] = StudentStackTrace(ex),
|
||||
});
|
||||
["message"] = message,
|
||||
["detail"] = frame is { } f ? $"出错位置:{f.File} 第 {f.Line} 行({f.Method} 方法)" : StudentStackTrace(ex),
|
||||
};
|
||||
if (frame is { } at)
|
||||
{
|
||||
result["file"] = at.File;
|
||||
result["line"] = at.Line;
|
||||
}
|
||||
Send(result);
|
||||
}
|
||||
|
||||
private static bool HasChinese(string text) => text.Any(c => c >= '\u4e00' && c <= '\u9fff');
|
||||
|
||||
private static string? ExplainException(Exception ex) => ex switch
|
||||
{
|
||||
FormatException => "文字转换成数字或日期时格式不对,比如文本框是空的,或者里面不是数字",
|
||||
NullReferenceException => "用到的对象是 null:变量还没有赋值,或者对象、窗体还没有 new 出来",
|
||||
IndexOutOfRangeException => "数组的下标超出了范围:下标从 0 开始,最大是长度减 1",
|
||||
ArgumentOutOfRangeException => "下标或参数超出了范围,比如列表、字符串的下标太大,或者下拉框还没有选中",
|
||||
DivideByZeroException => "整数除以了 0",
|
||||
OverflowException => "数字太大或太小,超出了这个类型能表示的范围",
|
||||
InvalidCastException => "类型转换失败,这个对象不能转换成要求的类型",
|
||||
KeyNotFoundException => "字典里没有这个键",
|
||||
FileNotFoundException => "找不到这个文件",
|
||||
DirectoryNotFoundException => "找不到这个文件夹",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>堆栈里离出错点最近的一行学生代码:文件名、行号、方法名</summary>
|
||||
private static (string File, int Line, string Method)? StudentFrame(Exception ex)
|
||||
{
|
||||
foreach (var frame in new System.Diagnostics.StackTrace(ex, true).GetFrames())
|
||||
{
|
||||
var file = frame.GetFileName();
|
||||
var line = frame.GetFileLineNumber();
|
||||
if (file == null || line <= 0 || file.Contains("/runtime/Sim.")) continue;
|
||||
return (Path.GetFileName(file), line, frame.GetMethod()?.Name ?? "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>只保留学生代码的堆栈行,去掉仿真库内部的调用</summary>
|
||||
|
||||
@@ -8,8 +8,8 @@ namespace System.IO.Ports;
|
||||
/// </summary>
|
||||
public class SerialPort : Component
|
||||
{
|
||||
/// <summary>COM1 是电脑自带串口,COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏</summary>
|
||||
private static readonly string[] VirtualPorts = { "COM1", "COM2", "COM3", "COM4", "COM5" };
|
||||
/// <summary>COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏,不提供电脑自带的 COM1</summary>
|
||||
private static readonly string[] VirtualPorts = { "COM2", "COM3", "COM4", "COM5" };
|
||||
|
||||
public SerialPort() { }
|
||||
|
||||
@@ -19,7 +19,7 @@ public class SerialPort : Component
|
||||
|
||||
public static string[] GetPortNames() => (string[])VirtualPorts.Clone();
|
||||
|
||||
public string PortName { get; set; } = "COM1";
|
||||
public string PortName { get; set; } = "COM2";
|
||||
public int BaudRate { get; set; } = 9600;
|
||||
public int DataBits { get; set; } = 8;
|
||||
public int ReadTimeout { get; set; } = -1;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
|
||||
|
||||
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型)。
|
||||
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型;图片还有编号 key 和两倍图 hidpi,由 `build-gallery.sh` 写入)。`name.png` 是原尺寸,学生程序读到的就是它;`name.2x.png` 只给高分辨率屏幕的浏览器显示用,宽高仍按原尺寸。
|
||||
- `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。
|
||||
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
|
||||
@@ -1,6 +1,23 @@
|
||||
#!/bin/sh
|
||||
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG(尺寸取 SVG 自己的 width / height)。需要 rsvg-convert 和思源黑体等中文字体。
|
||||
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG:name.png 是原尺寸(学生程序读到的图片,宽高和教材一致),
|
||||
# name.2x.png 是两倍尺寸(只给高分辨率屏幕的浏览器显示用)。然后把每张图的编号(原尺寸 PNG 的 SHA-1,
|
||||
# 和运行器给图片的编号相同)和两倍图的文件名写进 gallery/index.json。需要 rsvg-convert 和思源黑体等中文字体。
|
||||
cd "$(dirname "$0")"
|
||||
for f in svg/*.svg; do
|
||||
rsvg-convert "$f" -o "gallery/$(basename "$f" .svg).png"
|
||||
name=$(basename "$f" .svg)
|
||||
rsvg-convert "$f" -o "gallery/$name.png"
|
||||
rsvg-convert -z 2 "$f" -o "gallery/$name.2x.png"
|
||||
done
|
||||
python3 - <<'PY'
|
||||
import hashlib, json, os
|
||||
items = json.load(open("gallery/index.json", encoding="utf-8"))
|
||||
for item in items:
|
||||
if item["kind"] != "image":
|
||||
continue
|
||||
item["key"] = hashlib.sha1(open(f"gallery/{item['file']}", "rb").read()).hexdigest()
|
||||
hidpi = item["file"].replace(".png", ".2x.png")
|
||||
if os.path.exists(f"gallery/{hidpi}"):
|
||||
item["hidpi"] = hidpi
|
||||
lines = ",\n".join(" " + json.dumps(i, ensure_ascii=False) for i in items)
|
||||
open("gallery/index.json", "w", encoding="utf-8").write(f"[\n{lines}\n]\n")
|
||||
PY
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,27 +1,195 @@
|
||||
[
|
||||
{ "name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image" },
|
||||
{ "name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image" },
|
||||
{
|
||||
"name": "switch_on",
|
||||
"file": "switch_on.png",
|
||||
"label": "开关:开",
|
||||
"kind": "image",
|
||||
"key": "6e660e1ff6fc2ff3167919bc386c300112bbce72",
|
||||
"hidpi": "switch_on.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "switch_off",
|
||||
"file": "switch_off.png",
|
||||
"label": "开关:关",
|
||||
"kind": "image",
|
||||
"key": "5d748b614084e878d6189d22f27813477cb8f4d9",
|
||||
"hidpi": "switch_off.2x.png"
|
||||
},
|
||||
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" },
|
||||
{ "name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image" },
|
||||
{ "name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image" },
|
||||
{ "name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image" },
|
||||
{ "name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image" },
|
||||
{ "name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image" },
|
||||
{ "name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image" },
|
||||
{ "name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image" },
|
||||
{ "name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image" },
|
||||
{ "name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image" },
|
||||
{ "name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image" },
|
||||
{ "name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image" },
|
||||
{ "name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image" },
|
||||
{ "name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image" },
|
||||
{ "name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image" },
|
||||
{ "name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image" },
|
||||
{ "name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image" },
|
||||
{ "name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image" },
|
||||
{ "name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image" },
|
||||
{ "name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image" },
|
||||
{ "name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image" }
|
||||
{
|
||||
"name": "ico_title",
|
||||
"file": "ico_title.png",
|
||||
"label": "标题:智慧校园",
|
||||
"kind": "image",
|
||||
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
|
||||
"hidpi": "ico_title.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "bg_home",
|
||||
"file": "bg_home.png",
|
||||
"label": "主界面背景:校园",
|
||||
"kind": "image",
|
||||
"key": "e56bcc13f7128dec305b6012b255d63fb0f94450",
|
||||
"hidpi": "bg_home.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "icon_02",
|
||||
"file": "icon_02.png",
|
||||
"label": "图标:校园环境",
|
||||
"kind": "image",
|
||||
"key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d",
|
||||
"hidpi": "icon_02.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "icon_03",
|
||||
"file": "icon_03.png",
|
||||
"label": "图标:校园安防",
|
||||
"kind": "image",
|
||||
"key": "d085064a505544968278a85233b7d9ac23661169",
|
||||
"hidpi": "icon_03.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "icon_04",
|
||||
"file": "icon_04.png",
|
||||
"label": "图标:退出",
|
||||
"kind": "image",
|
||||
"key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3",
|
||||
"hidpi": "icon_04.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "day_01",
|
||||
"file": "day_01.png",
|
||||
"label": "路灯场景:亮灯",
|
||||
"kind": "image",
|
||||
"key": "05065abdb4538906bd7c86e940ab5fb02d51469d",
|
||||
"hidpi": "day_01.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "day_02",
|
||||
"file": "day_02.png",
|
||||
"label": "路灯场景:灭灯",
|
||||
"kind": "image",
|
||||
"key": "470d523e90e188fe5c423d8cffeae6993d5528ff",
|
||||
"hidpi": "day_02.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "bg_from1",
|
||||
"file": "bg_from1.png",
|
||||
"label": "面板背景(宽)",
|
||||
"kind": "image",
|
||||
"key": "7a8c0a16fcab37b28a66e041024b25341cc69462",
|
||||
"hidpi": "bg_from1.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "bg_from2",
|
||||
"file": "bg_from2.png",
|
||||
"label": "面板背景(窄)",
|
||||
"kind": "image",
|
||||
"key": "52497df301d2b7e4eac0c297ce2316ef24863c47",
|
||||
"hidpi": "bg_from2.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "icon_monitor",
|
||||
"file": "icon_monitor.png",
|
||||
"label": "监控:无画面",
|
||||
"kind": "image",
|
||||
"key": "23e6f1efc0f382c17d8a091b76531a3b5879349d",
|
||||
"hidpi": "icon_monitor.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "panel_video_ctrl",
|
||||
"file": "panel_video_ctrl.png",
|
||||
"label": "监控:方向盘底座",
|
||||
"kind": "image",
|
||||
"key": "396443a1d8c141942c0f447e0c69786cf6ad3350",
|
||||
"hidpi": "panel_video_ctrl.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_up_normal",
|
||||
"file": "btn_arrow_up_normal.png",
|
||||
"label": "方向键:上",
|
||||
"kind": "image",
|
||||
"key": "3c8b75ad465c8a61d0279facc233a0c64b62205a",
|
||||
"hidpi": "btn_arrow_up_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_up_press",
|
||||
"file": "btn_arrow_up_press.png",
|
||||
"label": "方向键:上(按下)",
|
||||
"kind": "image",
|
||||
"key": "51e1b9926d2bd611e3fda74a01231b46a930c460",
|
||||
"hidpi": "btn_arrow_up_press.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_down_normal",
|
||||
"file": "btn_arrow_down_normal.png",
|
||||
"label": "方向键:下",
|
||||
"kind": "image",
|
||||
"key": "caf46f81c15aaca788462ebc47e58df299f097d9",
|
||||
"hidpi": "btn_arrow_down_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_down_press",
|
||||
"file": "btn_arrow_down_press.png",
|
||||
"label": "方向键:下(按下)",
|
||||
"kind": "image",
|
||||
"key": "88c1bea8d6fad99b14658d8265a88b04bc595006",
|
||||
"hidpi": "btn_arrow_down_press.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_left_normal",
|
||||
"file": "btn_arrow_left_normal.png",
|
||||
"label": "方向键:左",
|
||||
"kind": "image",
|
||||
"key": "a38beb2373154d0474ad1715035aec5e2adf8c3f",
|
||||
"hidpi": "btn_arrow_left_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_left_press",
|
||||
"file": "btn_arrow_left_press.png",
|
||||
"label": "方向键:左(按下)",
|
||||
"kind": "image",
|
||||
"key": "47bd7a8ffc077130a8ceca627383a4647729c638",
|
||||
"hidpi": "btn_arrow_left_press.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_right_normal",
|
||||
"file": "btn_arrow_right_normal.png",
|
||||
"label": "方向键:右",
|
||||
"kind": "image",
|
||||
"key": "e69e459f54d67e2c52128015bd6cfcf0e800a893",
|
||||
"hidpi": "btn_arrow_right_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "btn_arrow_right_press",
|
||||
"file": "btn_arrow_right_press.png",
|
||||
"label": "方向键:右(按下)",
|
||||
"kind": "image",
|
||||
"key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170",
|
||||
"hidpi": "btn_arrow_right_press.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "arrow_left_normal",
|
||||
"file": "arrow_left_normal.png",
|
||||
"label": "翻页:上一张",
|
||||
"kind": "image",
|
||||
"key": "5a9036955f791f0c41ba52afffb5ecc640d137f1",
|
||||
"hidpi": "arrow_left_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "arrow_right_normal",
|
||||
"file": "arrow_right_normal.png",
|
||||
"label": "翻页:下一张",
|
||||
"kind": "image",
|
||||
"key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9",
|
||||
"hidpi": "arrow_right_normal.2x.png"
|
||||
},
|
||||
{
|
||||
"name": "windowstate_closed_hover",
|
||||
"file": "windowstate_closed_hover.png",
|
||||
"label": "关闭按钮",
|
||||
"kind": "image",
|
||||
"key": "e361965dbd881dc21511208ce11f649517e8ddb6",
|
||||
"hidpi": "windowstate_closed_hover.2x.png"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -4,11 +4,11 @@
|
||||
steps:
|
||||
- click: btnAdd
|
||||
- text: { control: txtNewUser1, value: zhangsan }
|
||||
- text: { control: txtPass1, value: "123" }
|
||||
- text: { control: txtPass2, value: "123" }
|
||||
- text: { control: txtPass1, value: '123' }
|
||||
- text: { control: txtPass2, value: '123' }
|
||||
- click: btnOk
|
||||
- text: { control: txtUserName, value: zhangsan }
|
||||
- text: { control: txtPassword, value: "123" }
|
||||
- text: { control: txtPassword, value: '123' }
|
||||
- click: btnOk
|
||||
- expect: { messageBox: 登录成功 }
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
steps:
|
||||
- click: btnAdd
|
||||
- text: { control: txtNewUser1, value: lisi }
|
||||
- text: { control: txtPass1, value: "123" }
|
||||
- text: { control: txtPass2, value: "456" }
|
||||
- text: { control: txtPass1, value: '123' }
|
||||
- text: { control: txtPass2, value: '456' }
|
||||
- click: btnOk
|
||||
- expect: { messageBox: 用户名或密码错误 }
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目1 任务1 C#引入(登录与增加用户)",
|
||||
"title": "项目1 任务1 登录和注册用户",
|
||||
"source": "教材书 p2–7,按书上代码整理。修正:删去未声明的变量 change;增加用户窗体关闭后会被释放,改为每次点击时新建"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
## 任务目标
|
||||
|
||||
做一个登录程序:登录窗体里输入用户名和密码,点"登录"检查账号是否存在;点"增加用户"打开第二个窗体,输入用户名和两遍密码后保存。用户数据放在一个公共的静态类里,两个窗体都能用。
|
||||
|
||||
练习重点:多个窗体(新建、Show、Close)、静态类和属性、数组和 for 循环、MessageBox。详细步骤见书 p2–7。
|
||||
|
||||
> 书上先用 Python 写了一遍登录程序,平台只支持 C#,这一部分看书了解即可。知识提炼里的控制台示例可以把代码放进按钮的 Click 里运行,`Console.WriteLine` 的输出显示在运行区下面;`Console.ReadLine` 读不到输入。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
登录窗体:
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | -------------------------------------------------------------------- |
|
||||
| 窗体 | `Login` | 登录 |
|
||||
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
|
||||
| 文本框 | `txtPassword` | 输入密码,PasswordChar 设为星号 *(前面放标签 `LblPwd`,文字"密码") |
|
||||
| 按钮 | `btnOk` | 登录 |
|
||||
| 按钮 | `btnCancel` | 取消 |
|
||||
| 按钮 | `btnAdd` | 增加用户 |
|
||||
|
||||
用户名前面的标签 `LblUsername` 文字是"用户名称"。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `Login`,类名、文件名和 Program.cs 会跟着改。
|
||||
|
||||
增加用户窗体(点"+ 新建窗体",名字填 AddUser):
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | --------------------------------- |
|
||||
| 窗体 | `AddUser` | 增加用户 |
|
||||
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
|
||||
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
|
||||
| 文本框 | `txtPass2` | 确认密码,PasswordChar 设为星号 * |
|
||||
| 按钮 | `btnOk` | 确定 |
|
||||
| 按钮 | `btnCancel` | 取消 |
|
||||
|
||||
> 书上的控件表和代码对不上:表里写 `txtPwd`、`BtnOk`,增加用户窗体写 `txtUserName`、`txtPwd`、`txtCheckPwd`,代码里用的是上面这些名字。请按上面的表起名,自动评分按这些名字操作。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点"+ 新建类",新建 `PublicData` 类,写 4 个静态成员:用户名数组 `SysUserName`、密码数组 `SysPwd`(都是 `new string[4]`,最多存 4 个用户),已存的用户数 `intNum`、当前登录用户的序号 `intUser`(这两个写成 `{ get; set; }` 属性)。
|
||||
2. Login 窗体里声明 `public bool IsLogin = false;` 记录是否登录成功。
|
||||
3. **登录窗体 btnOk 的 Click**:用 for 循环逐个比较 `PublicData.SysUserName[i]` 和 `SysPwd[i]`,用户名和密码都相同(且用户名不为空)时把 IsLogin 设为 true,提示"登录成功,欢迎进入!",记下 `intUser = i`,break 跳出循环;循环结束后 IsLogin 还是 false,就提示"无效的密码或用户名,请重试",并让密码框获得焦点(`Focus()`)。
|
||||
4. **登录窗体 btnCancel 的 Click**:IsLogin 设为 false,`this.Close()` 关闭窗体。
|
||||
5. **btnAdd 的 Click**:`new AddUser()` 创建增加用户窗体,再调用 `Show()` 显示出来。
|
||||
6. **增加用户窗体 btnOk 的 Click**:
|
||||
- `intNum` 小于 4 时,如果两次密码相同(`txtPass1.Text.Equals(txtPass2.Text)`)且不为空,就把用户名和密码存进两个数组的第 `intNum` 个位置,`intNum` 加 1,关闭窗体;否则提示"用户名或密码错误,请重新输入"。
|
||||
- 已经存满 4 个,提示"用户名已满不能增加"。
|
||||
7. **增加用户窗体 btnCancel 的 Click**:`this.Close()`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **增加用户窗体每次点击时新建**:书上在 Login 里写了全局变量 `AddUser addfrm = new AddUser();`,点按钮时只 `Show()`。窗体关闭后就被释放了,第二次点"增加用户"会出错,所以改成在 btnAdd 的 Click 里 `new AddUser()` 再 `Show()`。
|
||||
|
||||
> 书上登录成功后用到的变量 `change` 没有声明过,照抄会编译不过,这几行删掉即可。
|
||||
|
||||
> 程序刚启动时一个用户都没有,要先点"增加用户"加一个账号才能登录。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点"增加用户",用户名填 zhangsan,两次密码都填 123,点"确定",窗体关闭。
|
||||
2. 在登录窗体输入 zhangsan 和 123,点"登录",应提示"登录成功,欢迎进入!"。
|
||||
3. 输入一个不存在的用户名或错误的密码,应提示"无效的密码或用户名,请重试"。
|
||||
4. 再点"增加用户",两次密码填得不一样,点"确定",应提示"用户名或密码错误,请重新输入"。
|
||||
@@ -2,14 +2,14 @@
|
||||
# 格式说明见 server/src/grading/checks.ts
|
||||
- title: 计算器能正确计算(装箱与拆箱)
|
||||
steps:
|
||||
- text: { control: txtNum1, value: "7" }
|
||||
- text: { control: txtNum2, value: "2" }
|
||||
- select: { control: cmboxType, item: "+" }
|
||||
- text: { control: txtNum1, value: '7' }
|
||||
- text: { control: txtNum2, value: '2' }
|
||||
- select: { control: cmboxType, item: '+' }
|
||||
- click: btnCalculate
|
||||
- expect: { control: txtResult, text: "9" }
|
||||
- select: { control: cmboxType, item: "/" }
|
||||
- expect: { control: txtResult, text: '9' }
|
||||
- select: { control: cmboxType, item: '/' }
|
||||
- click: btnCalculate
|
||||
- expect: { control: txtResult, text: "3.5" }
|
||||
- expect: { control: txtResult, text: '3.5' }
|
||||
|
||||
- title: 多态:不同国家的人说不同的语言
|
||||
steps:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目1 任务2 C#面向对象特性(计算器、多态)",
|
||||
"title": "项目1 任务2 计算器和多态",
|
||||
"source": "教材书 p13–16,界面取自配套参考工程,控件名和窗体标题按书修改(BtnCalculate → btnCalculate)"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
## 任务目标
|
||||
|
||||
一个窗体里做两件事:上半部分是简易计算器,输入两个数、选运算符,点"="算出结果;下半部分点"显示",让两个中国人、两个美国人依次"说话",在文本框里输出各自说的语言。
|
||||
|
||||
练习重点:装箱与拆箱(object 和值类型互相转换)、类的继承、虚方法(virtual)和重写(override)、List 和 Dictionary。详细步骤见书 p13–16。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 窗体 | `Form1` | C#入门基础 |
|
||||
| 分组框 | `groupBox1` | 简易计算器 |
|
||||
| 文本框 | `txtNum1` | 第一个数 |
|
||||
| 下拉框 | `cmboxType` | 运算符,Items 填 `+`、`-`、`*`、`/` 四项 |
|
||||
| 文本框 | `txtNum2` | 第二个数 |
|
||||
| 按钮 | `btnCalculate` | = |
|
||||
| 文本框 | `txtResult` | 计算结果,ReadOnly 设为 True |
|
||||
| 分组框 | `groupBox2` | 类的多态性 |
|
||||
| 文本框 | `txtDispaly` | 输出说话内容,Multiline 设为 True |
|
||||
| 按钮 | `btnDisplay` | 显示 |
|
||||
|
||||
> `txtDispaly` 是书上的拼法(不是 Display),代码和自动评分都用这个名字,照着写。书上的表格没写下拉框的 Items 和文本框的 Multiline,要自己在属性里设置。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 写一个方法 `public Object Result(Object a, Object b, String type)`:先把 a、b 拆箱成 float,用 switch 按 `+ - * /` 计算(其他情况结果为 0),最后把结果装箱成 Object 返回。
|
||||
2. **btnCalculate 的 Click**:两个文本框都不为空时,用 `float.Parse` 把文字转成数,调用 `Result(a, b, cmboxType.Text)`,返回值拆箱成 float,显示到 `txtResult`。
|
||||
3. 点"+ 新建类",新建 `People` 类,写一个虚方法 `public virtual void Speak(TextBox Txtbox, string name)`,把 name 追加到文本框的 Text 后面。
|
||||
4. 新建 `Chinese` 类继承 People,重写 Speak:调用 `base.Speak`,传入 `name + "说的是汉语!\r\n"`。
|
||||
5. 新建 `American` 类继承 People,同样重写,内容改成"说的是英语!"。
|
||||
6. **btnDisplay 的 Click**:
|
||||
- 建一个 `Dictionary<int, string>`,放入 1 张三、2 李四、3 Mary、4 Jimmy;
|
||||
- 建一个 `List<People>`,先加 2 个 `new Chinese()`,再加 2 个 `new American()`;
|
||||
- 遍历 List,对第 i 个对象调用 `Speak(this.txtDispaly, dicName[i + 1])`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 8 步写的是"双击'='按钮",其实应该双击"**显示**"按钮,生成 `btnDisplay_Click`。
|
||||
|
||||
> 书上排版把减号印成了长一点的"–",抄 `case "-"` 和下拉框的 Items 时要用键盘上的减号,否则选"-"时算不出来。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后输入 7 和 2,选"+",点"=",结果应为 9;改选"/"再点"=",结果应为 3.5。
|
||||
2. 点"显示",文本框里应依次出现"张三说的是汉语!""李四说的是汉语!""Mary说的是英语!""Jimmy说的是英语!"四行。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目2 任务1 LED显示屏的使用",
|
||||
"title": "项目2 任务1 LED 屏发布信息",
|
||||
"source": "教材书 p23–27,按书上代码整理(控件名以书上代码为准)"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
## 任务目标
|
||||
|
||||
模拟校园信息发布:选好 LED 显示屏的串口并打开,在文本框里输入一段文字,点"推送"后实训台的 LED 屏和窗体上的 LED 控件都显示这段文字;推送失败时弹出出错信息。
|
||||
|
||||
练习重点:分组框(GroupBox)、下拉框的 Click 事件、用 `SerialPort.GetPortNames()` 列出串口、调用设备类库、自定义控件 LED。详细步骤见书 p23–27。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ---------- | -------------- | ----------------------------------------------- |
|
||||
| 窗体 | `Form1` | LED显示屏 |
|
||||
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
|
||||
| 分组框 | `groupBox1` | LED控制 |
|
||||
| 下拉框 | `com_Port` | 串口(前面放标签"串口") |
|
||||
| 按钮 | `btn_Open` | 打开 |
|
||||
| 文本框 | `txt_SendText` | 要推送的文字(前面放标签"推送LED信息") |
|
||||
| 按钮 | `btn_Send` | 推送 |
|
||||
|
||||
> 书上的表格把 LED 控件写成 `LED`,代码里用的是小写的 `led`,请按代码起名。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 平台已经引用好了 `DeviceCtrlLibrary.dll` 和 `WinFormsDeviceControlLibrary.dll`,不用像书上那样复制 dll、添加引用;LED 显示屏、灯泡、报警灯这些控件直接在工具箱的"物联网设备"里。
|
||||
2. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using WinFormsDeviceControlLibrary;`。
|
||||
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 控件显示。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 知识提炼的 try-catch 示例里,书上把 `catch (Exception ex)` 印成了 `Catch(EventArgs ex)`,照抄会编译不过:catch 要小写,括号里是异常类型 Exception。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点开"串口"下拉框,选 **COM5**(LED 屏),点"打开"。
|
||||
2. 输入"欢迎光临",点"推送",实训台的 **LED 显示屏**和窗体上的 LED 控件都应显示"欢迎光临"。
|
||||
3. 重新运行,先点实训台的"实训台复位"清掉 LED 屏上的字(和真的 LED 屏一样,上次显示的字会一直留着),再不点"打开"直接推送:应弹出"Error:发送失败",LED 屏不显示,实训台最上方用红字提示串口没有打开。
|
||||
@@ -6,9 +6,9 @@
|
||||
- click: btn_Open
|
||||
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
|
||||
- click: btn_get
|
||||
- expect: { control: txt_temperature, text: "25" }
|
||||
- expect: { control: txt_humidity, text: "60" }
|
||||
- expect: { control: txt_light, text: "500" }
|
||||
- expect: { control: txt_temperature, text: '25' }
|
||||
- expect: { control: txt_humidity, text: '60' }
|
||||
- expect: { control: txt_light, text: '500' }
|
||||
|
||||
- title: 环境变化后能获取到新的数值
|
||||
steps:
|
||||
@@ -16,5 +16,5 @@
|
||||
- click: btn_Open
|
||||
- board: { temperature: 31, light: 300 }
|
||||
- click: btn_get
|
||||
- expect: { control: txt_temperature, text: "31" }
|
||||
- expect: { control: txt_light, text: "300" }
|
||||
- expect: { control: txt_temperature, text: '31' }
|
||||
- expect: { control: txt_light, text: '300' }
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目2 任务2 实时采集温度、湿度和光照度数据",
|
||||
"title": "项目2 任务2 读取温湿度和光照",
|
||||
"source": "教材书 p36–39,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
## 任务目标
|
||||
|
||||
从 ADAM-4017+ 模拟量采集器读出光照度、温度、湿度:选好串口点"开启",再点"获取",三个文本框显示传感器的当前值;"开启"按钮打开后变成"关闭",再点一次关闭串口。
|
||||
|
||||
练习重点:标签(Label)和按钮(Button)、窗体的 Load 事件、用 bool 变量记住串口是否打开、读取设备类库返回的数据。详细步骤见书 p36–39。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ---------------------------------------------- | -------------------------- |
|
||||
| 窗体 | `frm_analog` | 模拟量采集 |
|
||||
| 分组框 | `grp_show` | 数据显示 |
|
||||
| 标签 | `lbl_light`、`lbl_temperature`、`lbl_humidity` | 光照度、温度、湿度 |
|
||||
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
|
||||
| 按钮 | `btn_get` | 获取 |
|
||||
| 分组框 | `grp_config` | 配置 |
|
||||
| 标签 | `lbl_port` | 串口 |
|
||||
| 下拉框 | `com_port` | 串口 |
|
||||
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_analog`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_analog_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`(平台已经引用好了设备类库,不用复制 dll)。
|
||||
2. 声明两个全局变量:`bool IsOpen = false;` 记录串口是否打开,`ADAM4017ResModel ResModel;` 存放读到的数据。
|
||||
3. **frm_analog_Load**(窗体的 Load 事件,双击窗体空白处生成):清空 `com_port`,用 foreach 把 `SerialPort.GetPortNames()` 的串口号加进去。
|
||||
4. **btn_Open 的 Click**:
|
||||
- IsOpen 为 false 时:`DeviceCtrlHelper.adam4017Helper.Open(com_port.Text)` 打开串口,按钮文字改成"关闭",IsOpen 设为 true;
|
||||
- 否则:`adam4017Helper.Close()` 关闭串口,按钮文字改回"开启",IsOpen 设为 false。
|
||||
5. **btn_get 的 Click**:`ResModel = DeviceCtrlHelper.adam4017Helper.SetData();`,把 `ResModel.lightValue`、`temperatureValue`、`humidityValue` 分别填进三个文本框。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上把 `IsOpen = true`、`IsOpen = false` 印成了 `True`、`False`(首字母大写),C# 的布尔值只能小写,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**(ADAM-4017+),点"开启",按钮应变成"关闭"。
|
||||
2. 点"获取",三个文本框应显示实训台上的默认值:温度 25、湿度 60、光照度 500。
|
||||
3. 在实训台上把温度拖到 31、光照拖到 300,再点"获取",文本框应变成新的数值。
|
||||
@@ -6,10 +6,10 @@
|
||||
- select: { control: com_ledPort, item: COM5 }
|
||||
- click: btn_open
|
||||
- click: btn_get
|
||||
- expect: { control: txt_temperature, text: "25" }
|
||||
- expect: { led: "25", desc: 显示温度 25 }
|
||||
- expect: { led: "60", desc: 显示湿度 60 }
|
||||
- expect: { led: "500", desc: 显示光照度 500 }
|
||||
- expect: { control: txt_temperature, text: '25' }
|
||||
- expect: { led: '25', desc: 显示温度 25 }
|
||||
- expect: { led: '60', desc: 显示湿度 60 }
|
||||
- expect: { led: '500', desc: 显示光照度 500 }
|
||||
|
||||
- title: 能推送最新的环境信息
|
||||
steps:
|
||||
@@ -19,4 +19,4 @@
|
||||
- click: btn_get
|
||||
- board: { temperature: 31 }
|
||||
- click: btn_get
|
||||
- expect: { led: "31", desc: 显示最新的温度 31 }
|
||||
- expect: { led: '31', desc: 显示最新的温度 31 }
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目2 任务3 在LED显示屏中显示环境数据",
|
||||
"title": "项目2 任务3 环境数据推送到 LED 屏",
|
||||
"source": "教材书 p42–45,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
## 任务目标
|
||||
|
||||
把任务 1 和任务 2 合起来:同时打开 ADAM-4017+ 和 LED 屏的串口,点"获取"读出光照度、温度、湿度,显示在文本框里,再拼成一句话推送到 LED 屏,例如"当前环境信息 光照度:500lx温度:25℃湿度:60%"。
|
||||
|
||||
练习重点:下拉框(ComboBox)和文本框(TextBox)、字符串拼接、一个按钮同时控制两个设备。详细步骤见书 p42–45。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ---------- | ---------------------------------------------- | ---------------------------------------- |
|
||||
| 窗体 | `frm_LED` | LED推送模拟量 |
|
||||
| LED 显示屏 | `led` | 窗体上方的 LED 控件 |
|
||||
| 分组框 | `grp_show` | 数据显示 |
|
||||
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值(前面放标签) |
|
||||
| 按钮 | `btn_get` | 获取 |
|
||||
| 分组框 | `grp_config` | 配置 |
|
||||
| 下拉框 | `com_adamPort` | 模拟量串口(ADAM-4017+) |
|
||||
| 下拉框 | `com_ledPort` | LED串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
|
||||
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`(小写 o);表格里也没有列 LED 控件,代码里叫 `led`。请按代码起名,自动评分按这些名字找控件。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_LED`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_LED_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||
2. 声明全局变量:`bool IsOpen = false;`、`ADAM4017ResModel ResModel;`、`String LED_MSG = "";`(要推送的文字)。
|
||||
3. **frm_LED_Load**:清空两个下拉框,用一个 foreach 把串口号同时加进 `com_adamPort` 和 `com_ledPort`。
|
||||
4. **btn_open 的 Click**:
|
||||
- 没打开时:`adam4017Helper.Open(com_adamPort.Text)` 和 `ledHelper.OpenLedCom(com_ledPort.Text)` 打开两个串口,按钮改成"关闭",IsOpen 设为 true;
|
||||
- 已打开时:`adam4017Helper.Close()` 和 `ledHelper.Close()` 关闭,按钮改回"开启",IsOpen 设为 false。
|
||||
5. **btn_get 的 Click**:
|
||||
- 先把 LED_MSG 清空,用 `adam4017Helper.SetData()` 读数据,填进三个文本框(和任务 2 一样);
|
||||
- 把"当前环境信息"和三个值拼进 LED_MSG(光照度后面加 lx,温度加 ℃,湿度加 %);
|
||||
- 调用 `ledHelper.SendLedString(LED_MSG)`,返回值里没有 "Error" 时,调用 `led.setLedText` 让窗体上的 LED 控件也显示。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台补上了这个方法(关闭 LED 屏的串口),书上的代码可以原样编译。
|
||||
|
||||
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后"模拟量串口"选 **COM2**(ADAM-4017+),"LED串口"选 **COM5**(LED 屏),点"开启"。
|
||||
2. 点"获取",文本框显示温度 25、湿度 60、光照度 500,实训台的 **LED 显示屏**上应出现这三个数。
|
||||
3. 在实训台上把温度拖到 31,再点"获取",LED 屏上的温度应变成 31。
|
||||
@@ -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 }
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目3 任务1 使用ZigBee技术控制路灯开关",
|
||||
"title": "项目3 任务1 路灯开关",
|
||||
"source": "教材书 p53–56,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
## 任务目标
|
||||
|
||||
做一个路灯遥控程序:选好 ZigBee 协调器的串口点"打开",然后选"开"单选按钮路灯亮,选"关"路灯灭,窗体上的灯泡跟着实训台上的灯一起变。
|
||||
|
||||
练习重点:if 语句、按钮文字切换、单选按钮(RadioButton)的 CheckedChanged 事件、自己定义一个方法。详细步骤见书 p53–56。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| -------- | ------------ | ------------------------------------------------------ |
|
||||
| 窗体 | `Form1` | ZigBee路灯 |
|
||||
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
|
||||
| 单选按钮 | `rb_Open` | 开(开灯) |
|
||||
| 单选按钮 | `rb_Close` | 关(关灯) |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `lbl_Lamp` | 路灯 |
|
||||
| 下拉框 | `com_port` | ZigBee 协调器的串口 |
|
||||
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
|
||||
|
||||
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 写一个方法 `LampOnOff(int RelayNum, bool IsOpen)`:调用 `DeviceCtrlHelper.zigBeeHelper.OnOff` 控制实训台的路灯,再调用 `lamp1.OnOff` 让窗体上的灯泡跟着变。
|
||||
3. **Form1_Load**:清空 `com_port`,用 `SerialPort.GetPortNames()` 把串口号加进去。
|
||||
4. **btn_open 的 Click**:用 if 判断按钮文字——是"打开"就用 `zigBeeHelper.Open(com_port.Text)` 打开串口,文字改成"关闭";是"关闭"就 `zigBeeHelper.Close()`,文字改回"打开"。
|
||||
5. **rb_Open 的 CheckedChanged**:调用 `LampOnOff(1, true)`。
|
||||
6. **rb_Close 的 CheckedChanged**:调用 `LampOnOff(1, false)`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 12 步"关"按钮的事件方法名印成了 `rb_Open_CheckedChanged`,应该是 `rb_Close_CheckedChanged`。在设计器里双击"关"单选按钮,生成的名字就是对的。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM4**(ZigBee 协调器),点"打开",按钮变成"关闭"。
|
||||
2. 选"开",窗体上的灯泡和实训台的**照明灯 1** 应一起亮;选"关",一起灭。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目3 任务2 实现人体红外传感器和路灯联动",
|
||||
"title": "项目3 任务2 人来灯亮",
|
||||
"source": "教材书 p58–61,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
## 任务目标
|
||||
|
||||
做一个"楼梯灯":勾选**人体开关**后,程序每 5 秒检查一次人体红外传感器,有人就开路灯,没人就关灯;不勾选时用"开""关"两个按钮手动控制。
|
||||
|
||||
练习重点:复选框的 Click 事件、定时器(Timer)、控件的 Enabled 属性。详细步骤见书 p58–61。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ----------------- | ---------------------------------------------- |
|
||||
| 窗体 | `Form1` | 人体红外ZigBee联动 |
|
||||
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 |
|
||||
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
|
||||
| 按钮 | `btn_lampClose` | 关(手动关灯) |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `lbl_lamp` | 路灯 |
|
||||
| 标签 | `lbl_environment` | 环境采集 |
|
||||
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
|
||||
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
|
||||
| 复选框 | `ch_bodyInfrared` | 人体开关 |
|
||||
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
|
||||
| 定时器 | `timer1` | Interval 设为 5000(5 秒),Enabled 保持 False |
|
||||
|
||||
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. **Form1_Load**:清空两个下拉框,用 `SerialPort.GetPortNames()` 把串口号加进去。
|
||||
3. 写一个方法 `LampOnOff(int RelayNum, bool IsOpen)`:调用 `DeviceCtrlHelper.zigBeeHelper.OnOff` 控制实训台的路灯,再调用 `lamp1.OnOff` 让窗体上的灯泡跟着变。
|
||||
4. **btn_open 的 Click**:和任务 1 一样,按钮文字是"打开"时打开 ZigBee 串口(`com_lamp.Text`)并改成"关闭",反之关闭串口。
|
||||
5. **btn_lampOpen、btn_lampClose 的 Click**:分别调用 `LampOnOff(1, true)`、`LampOnOff(1, false)`。
|
||||
6. **ch_bodyInfrared 的 Click**:
|
||||
- 勾上时:打开 ADAM-4150 串口(`adam4150Helper.Open(com_environment.Text)`),启动 `timer1`,两个手动按钮设为不可用;
|
||||
- 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。
|
||||
7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。
|
||||
|
||||
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**(事件窗口里叫"计时到"):在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后,"路灯"选 **COM4**(ZigBee 协调器),"环境采集"选 **COM3**(ADAM-4150),点"打开"。
|
||||
2. 点"开""关",看窗体上的灯泡和实训台的**照明灯 1** 是否一起亮灭。
|
||||
3. 勾选"人体开关","开""关"按钮应变成灰色;在实训台上把**人体红外**点成"有人",5 秒内路灯应亮起,点回"无人"后 5 秒内熄灭。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目4 任务1 采集红外对射入侵传感器数据",
|
||||
"title": "项目4 任务1 围墙入侵检测",
|
||||
"source": "教材书 p73–76,界面取自配套参考工程,控件名按书上表格修改;修正书上 boolIsOpen 少空格"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
## 任务目标
|
||||
|
||||
做一个围墙入侵监测程序:点"开启"后,每 2 秒读一次红外对射传感器,在窗体上显示当前时间和"发现入侵"或"正常";再点一次"关闭",停止检测。
|
||||
|
||||
练习重点:定时器(Timer)的 Tick 事件、用一个 bool 变量记住开关状态、foreach 语句。详细步骤见书 p73–76。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------ | -------------------------- |
|
||||
| 窗体 | `Form1` | 红外对射 |
|
||||
| 标签 | `lbl_port` | 环境串口 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 标签 | `lbl_time` | 时间 |
|
||||
| 文本框 | `txt_time` | 显示当前时间 |
|
||||
| 标签 | `lbl_even` | 入侵 |
|
||||
| 文本框 | `txt_even` | 显示"发现入侵"或"正常" |
|
||||
| 定时器 | `timer1` | Interval 设为 2000(2 秒) |
|
||||
|
||||
> 书上的表格里没有分组框,看书上的界面图,"时间""入侵"两行放在"数据显示"分组框里。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明全局变量 `bool IsOpen = false;`,记住串口有没有打开。
|
||||
3. **Form1_Load**:清空 `com_port`,用 foreach 把 `SerialPort.GetPortNames()` 里的串口号逐个加进去。
|
||||
4. **btn_open 的 Click**:先把 `IsOpen` 取反;
|
||||
- 变成 true 时:打开 ADAM-4150 串口(`adam4150Helper.Open(com_port.Text)`),启动 `timer1`,按钮文字改成"关闭";
|
||||
- 变成 false 时:关闭串口,停止 `timer1`,按钮文字改回"开启"。
|
||||
5. **timer1 的 Tick**:把 `DateTime.Now` 显示到 `txt_time`;读 `adam4150Helper.SetData().infraredValue`,是 `true` 就在 `txt_even` 显示"发现入侵",否则显示"正常"。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上的全局变量印成了 `boolIsOpen = false;`,`bool` 和 `IsOpen` 之间要有空格,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭",约 2 秒后时间框开始显示时间。
|
||||
2. 在实训台顶棚点**红外对射**(光束断开,显示"有人闯入"),几秒内入侵框应显示"发现入侵";再点一次恢复光束,应变回"正常"。
|
||||
3. 点"关闭",按钮变回"开启",时间框不再更新。
|
||||
@@ -14,7 +14,7 @@
|
||||
steps:
|
||||
- text: { control: txt_ip, value: 192.168.2.50 }
|
||||
- click: btn_open
|
||||
- expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 }
|
||||
- expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 }
|
||||
|
||||
- title: 能控制摄像机上下左右旋转
|
||||
steps:
|
||||
@@ -35,6 +35,6 @@
|
||||
steps:
|
||||
- text: { control: txt_ip, value: 192.168.2.50 }
|
||||
- click: btn_open
|
||||
- expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 }
|
||||
- expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 }
|
||||
- click: btn_camera
|
||||
- expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 }
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目4 任务2 控制网络摄像机",
|
||||
"title": "项目4 任务2 网络摄像机监控",
|
||||
"source": "教材书 p78–81,界面取自配套参考工程,标签文字按书上表格改为“摄像机IP”;补上书上漏写的 using System.IO;"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
## 任务目标
|
||||
|
||||
做一个校园监控程序:输入摄像机 IP 点"开",图片框里实时显示摄像机拍到的画面;用四个方向键转动摄像机;点"拍照"把当前画面保存成图片。
|
||||
|
||||
练习重点:图片框(PictureBox)、给设备的事件绑定处理方法(`+=`)、static 变量、文件夹和图片的保存。详细步骤见书 p78–81。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | -------------------------------------------------------------- |
|
||||
| 窗体 | `Form1` | 网络摄像机 |
|
||||
| 图片框 | `pictureBox1` | 显示画面;背景色设为黑色,显示方式选"拉伸填满"(StretchImage) |
|
||||
| 标签 | `label1` | 摄像机IP |
|
||||
| 文本框 | `txt_ip` | 输入摄像机的 IP |
|
||||
| 按钮 | `btn_open` | 开(打开后变成"关") |
|
||||
| 按钮 | `btn_up` | ↑(向上转) |
|
||||
| 按钮 | `btn_down` | ↓(向下转) |
|
||||
| 按钮 | `btn_left` | ←(向左转) |
|
||||
| 按钮 | `btn_right` | →(向右转) |
|
||||
| 按钮 | `btn_camera` | 拍照 |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明 4 个全局变量:拍照保存路径 `CameraPath`(static,程序所在目录下的 `Camera/` 文件夹)、摄像机 IP `IpCameraIPAddress`(static)、`bool IsOpen`、存当前画面的 `Image ReadImage`。
|
||||
3. 写一个方法 `ipCameraHelper_ImageRead(ImageReadEvenArgs e)`:把 `e.img` 存进 `ReadImage`,再显示到 `pictureBox1`。
|
||||
4. **Form1_Load**:`Camera` 文件夹不存在就用 `Directory.CreateDirectory` 建一个;用 `+=` 把第 3 步的方法绑定到 `DeviceCtrlHelper.ipCameraHelper.ImageRead` 事件上。
|
||||
5. **btn_open 的 Click**:先把 `IsOpen` 取反;
|
||||
- 打开时:取 `txt_ip` 里的 IP,调用 `ipCameraHelper.Open(IP, "admin", "")` 连接,再 `Start()` 开始传画面,按钮文字改成"关";
|
||||
- 关闭时:`Stop()` 停止传画面,按钮文字改回"开"。
|
||||
6. **四个方向键的 Click**:分别调用 `ipCameraHelper.PanUp()`、`PanDown()`、`PanLeft()`、`PanRight()`。
|
||||
7. **btn_camera 的 Click**:`ReadImage` 不是 null 时,用当前时间做文件名,`ReadImage.Save(...)` 存成 .jpg 到 `CameraPath`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 6 步只写了 `using System.IO.Ports;`,可是 `Directory` 要用 `using System.IO;`,不加会编译不过。这个任务不用串口,`System.IO.Ports` 可以不写。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
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,4 @@
|
||||
{
|
||||
"title": "项目4 任务3 控制报警灯",
|
||||
"title": "项目4 任务3 报警灯开关",
|
||||
"source": "教材书 p84–87,界面取自配套参考工程;修正书上 boolIsOpen 少空格"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
## 任务目标
|
||||
|
||||
做一个报警灯控制程序:选好 ADAM-4150 的串口点"开启",点"开"报警灯亮,点"关"报警灯灭,窗体上的报警灯跟着实训台上的灯一起变。
|
||||
|
||||
练习重点:switch 语句、带参数的方法。详细步骤见书 p84–87。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ---------------- | ---------------------------------------------------------- |
|
||||
| 窗体 | `Form1` | 报警灯 |
|
||||
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
|
||||
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
|
||||
| 按钮 | `btn_alarmClose` | 关(关闭报警灯) |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `label1` | 报警灯 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
|
||||
> 书上的表格把报警灯控件的名称写成 `Alarm`,那是控件的类型名;代码里用的名字是 `alarm1`,请按代码写,否则自动评分找不到它。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明全局变量 `bool IsOpen = false;`。
|
||||
3. **Form1_Load**:和任务 1 一样,把串口号加进 `com_port`。
|
||||
4. 写一个方法 `AlarmOnOff(string Device, bool IsOpen)`,用 switch 按设备名分支:
|
||||
- `"Alarm"`:调用 `adam4150Helper.OnOff(0, IsOpen)` 控制接在 DO0 的报警灯,再调用 `alarm1.OnOff(IsOpen)` 改窗体上的报警灯;
|
||||
- `"LED1"`、`"LED2"`:分别控制继电器 1、2;
|
||||
- 其他(default):弹出"没有合适的接口程序!"。
|
||||
- 每个分支最后都要有 `break;`。
|
||||
5. **btn_open 的 Click**:把 `IsOpen` 取反,打开时 `adam4150Helper.Open(com_port.Text)`、文字改成"关闭",关闭时 `adam4150Helper.Close()`、文字改回"开启"。这个任务不用定时器。
|
||||
6. **btn_alarmOpen、btn_alarmClose 的 Click**:分别调用 `AlarmOnOff("Alarm", true)`、`AlarmOnOff("Alarm", false)`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上的全局变量又印成了 `boolIsOpen = false;`,`bool` 和 `IsOpen` 之间要有空格,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭"。
|
||||
2. 点"开",窗体上的报警灯和实训台顶棚的**警示灯**应一起亮;点"关",一起灭。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目5 任务1 采集火焰传感器数据",
|
||||
"title": "项目5 任务1 火情监测",
|
||||
"source": "教材书 p91–94,界面取自配套参考工程,控件名按书上表格修改"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
## 任务目标
|
||||
|
||||
做一个火情监测窗体:选好串口点"开启"后,程序每 2 秒读一次火焰传感器,在"时间"框里显示当前时间,在"事件"框里显示"发现火情!"或"正常!";再点一次按钮(这时文字是"关闭")就停止采集。
|
||||
|
||||
练习重点:for 循环、带返回值的方法和 return 语句、DateTime.Now、定时器(Timer)。详细步骤见书 p91–94。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------ | ---------------------------------------------- |
|
||||
| 窗体 | `Form1` | 火焰 |
|
||||
| 标签 | `lbl_port` | 串口 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 标签 | `lbl_time` | 时间 |
|
||||
| 文本框 | `txt_time` | 显示当前时间 |
|
||||
| 标签 | `lbl_even` | 事件 |
|
||||
| 文本框 | `txt_even` | 显示有没有火情 |
|
||||
| 定时器 | `timer1` | Interval 设为 2000(2 秒),Enabled 保持 False |
|
||||
|
||||
> 书上的表格里没有分组框,照图加一个 `groupBox1`(数据显示),把时间、事件两行放进去。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`,声明全局变量 `bool IsOpen = false;`。
|
||||
2. **Form1_Load**:这次用 **for 循环**,从 0 数到 `SerialPort.GetPortNames().Length - 1`,把每个串口号加进 `com_port`。
|
||||
3. **btn_open 的 Click**:先把 `IsOpen` 取反;打开时调用 `adam4150Helper.Open(com_port.Text)`、启动 `timer1`、按钮改成"关闭";关闭时调用 `Close()`、停止 `timer1`、按钮改回"开启"。
|
||||
4. 写一个方法 `GetFireInfo()`,返回 `String`:`adam4150Helper.SetData().fireValue` 是 `true` 就 `return "发现火情!";`,否则 `return "正常!";`。
|
||||
5. **timer1 的 Tick**:`txt_time` 显示 `DateTime.Now.ToString()`,`txt_even` 显示 `GetFireInfo()` 的返回值。
|
||||
|
||||
> 在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭",2 秒左右"时间"框开始走。
|
||||
2. 在实训台上把**火焰传感器**点成"有火焰",几秒内"事件"框显示"发现火情!";点回"正常",显示"正常!"。
|
||||
3. 点"关闭",按钮变回"开启","时间"框不再变化。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目5 任务2 采集烟雾传感器数据",
|
||||
"title": "项目5 任务2 烟雾监测(线程版)",
|
||||
"source": "教材书 p98–101,界面取自配套参考工程,控件名按书上表格修改;关闭窗体时先让挂起的线程继续再终止(书上直接 Abort 挂起的线程会出错)"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
## 任务目标
|
||||
|
||||
和任务 1 一样监测灾情,这次读的是烟雾传感器,而且不用定时器,改用**线程**:线程里用 while 循环每 2 秒读一次烟雾传感器,显示时间和"发现烟情"或"正常"。点"关闭"时把线程挂起,再点"开启"让线程继续,关闭窗体时终止线程。
|
||||
|
||||
练习重点:while 循环、线程 Thread(Start、Suspend、Resume、Abort、Thread.Sleep)、窗体的 FormClosing 事件。详细步骤见书 p98–101。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------ | ------------------------ |
|
||||
| 窗体 | `Form1` | 烟雾 |
|
||||
| 标签 | `lbl_port` | 串口 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 标签 | `lbl_time` | 时间 |
|
||||
| 文本框 | `txt_time` | 显示当前时间 |
|
||||
| 标签 | `lbl_even` | 事件 |
|
||||
| 文本框 | `txt_even` | 显示有没有烟情 |
|
||||
|
||||
> 界面和任务 1 一样,但**没有定时器**。书上的表格里没有分组框,照图加一个 `groupBox1`(数据显示)。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using System.Threading;`。
|
||||
2. 声明全局变量:`bool IsOpen = false;`、`bool IsFirst = true;`(线程是不是第一次启动)、`Thread thd;`。
|
||||
3. 写线程要运行的方法 `GetData()`:`while (true)` 循环里,`adam4150Helper.SetData().smokeValue` 是 `true` 就显示"发现烟情",否则显示"正常",两种情况都把当前时间写进 `txt_time`;每次循环最后 `Thread.Sleep(2000)`。
|
||||
4. **Form1_Load**:这次用 **foreach** 把串口号加进 `com_port`;然后 `thd = new Thread(GetData);`,再写 `Control.CheckForIllegalCrossThreadCalls = false;`,允许线程直接改窗体上的控件。
|
||||
5. **btn_open 的 Click**:先把 `IsOpen` 取反。
|
||||
- 打开时:打开 ADAM-4150 串口,按钮改成"关闭";`IsFirst` 是 `true` 就把它改成 `false` 并 `thd.Start()`,否则 `thd.Resume()`;
|
||||
- 关闭时:关闭串口,按钮改回"开启",`thd.Suspend()`。
|
||||
6. **Form1 的 FormClosing**:终止线程(见下面"和书上不一样的地方")。在设计器里选中窗体,到 **⚡ 事件** 里双击 FormClosing 生成方法。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **关闭窗体时先让线程继续**:书上 FormClosing 里直接 `thd.Abort();`。如果点过"关闭",线程正处于挂起状态,直接终止会出错(在真的 Visual Studio 里也一样)。要先判断 `thd.ThreadState == ThreadState.Suspended`,是的话先 `thd.Resume();`,再 `thd.Abort();`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭","时间"框开始走。
|
||||
2. 在实训台上把**烟雾传感器**点成"有烟雾",几秒内"事件"框显示"发现烟情";点回"正常",显示"正常"。
|
||||
3. 点"关闭","时间"框应停住不动;再点"开启",时间接着走,烟雾状态也继续更新。
|
||||
4. 点"关闭"之后直接关掉窗体,不应弹出错误。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目5 任务3 实时推送灾情信息",
|
||||
"title": "项目5 任务3 灾情推送和历史记录",
|
||||
"source": "教材书 p104–107,界面取自配套参考工程,控件名按书上表格修改;修正烟雾记成“发现火情”的错误,列表框按表5-7 命名为 lstbRecored"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
## 任务目标
|
||||
|
||||
把任务 1、任务 2 合在一起:点"获取"时同时读火焰和烟雾传感器,显示在窗体上,把灾情推送到 LED 屏(实训台的屏和窗体上的 LED 控件都显示);每次发现灾情都记进一个二维数组,点"历史灾情信息"打开第二个窗体,在列表框里列出全部记录。
|
||||
|
||||
练习重点:二维数组、静态类成员、continue 语句、两个窗体、列表框(ListBox)。详细步骤见书 p104–107。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
主窗体 Form1:
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ---------- | ----------------------------------- | ---------------------------- |
|
||||
| 窗体 | `Form1` | 火焰烟雾 |
|
||||
| LED 显示屏 | `led1` | 窗体顶部,显示推送的灾情 |
|
||||
| 分组框 | `groupBox2` | 数据采集 |
|
||||
| 标签 | `lbl_time`、`lbl_fire`、`lbl_smoke` | 时间、火焰、烟雾 |
|
||||
| 文本框 | `txt_time`、`txt_fire`、`txt_smoke` | 显示时间、火焰状态、烟雾状态 |
|
||||
| 按钮 | `btn_get` | 获取 |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `lbl_Ledport` | LED |
|
||||
| 下拉框 | `com_ledPort` | LED 屏的串口 |
|
||||
| 标签 | `lbl_4150port` | 环境采集 |
|
||||
| 下拉框 | `com_environmentPort` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
||||
| 按钮 | `OpenRecord` | 历史灾情信息 |
|
||||
|
||||
历史记录窗体 Form2:
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | ------------------ |
|
||||
| 窗体 | `Form2` | 历史灾情记录 |
|
||||
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
|
||||
|
||||
> 书上表 5-6 没列 `btn_get`、`OpenRecord`、`groupBox2`,照图加上。`lstbRecored` 的拼写就是这样(不是 Record),照着写。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点 **+ 新建类**,建 `RecordClass`,写两个静态成员:`string[,] record = new string[50, 2]`(每条记录存时间和内容)、`int recoudeindex = 0`(下一条记录存在第几行)。
|
||||
2. Form1 的代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`;全局变量:`ADAM4150ResModel ResModel;`、`bool IsOpen = false;`、`string LED_MSG = "";`。
|
||||
3. **Form1_Load**:清空两个下拉框,用 for 循环加串口号;遇到 `COM1`(电脑自带的串口)就 `continue` 跳过。平台上没有 COM1,这段照书保留,在机房真机上才用得到。
|
||||
4. **btn_Open 的 Click**:打开时调用 `adam4150Helper.Open(com_environmentPort.Text)` 和 `ledHelper.OpenLedCom(com_ledPort.Text)`,按钮改成"关闭";关闭时关闭 ADAM-4150 串口,按钮改回"开启"。
|
||||
5. **btn_get 的 Click**:
|
||||
- 用 `adam4150Helper.SetData()` 读出数据存进 `ResModel`,清空 `LED_MSG`,显示当前时间,火焰、烟雾两个框先写"正常";
|
||||
- `fireValue` 为 `true`:火焰框写"发现火情",`LED_MSG` 加上"发现火情",把时间和内容记进数组当前行,`recoudeindex` 加 1;
|
||||
- `smokeValue` 为 `true`:同样处理,写的是"发现烟情";
|
||||
- 用 `ledHelper.SendLedString(LED_MSG)` 推送到 LED 屏,返回的字符串里没有 "Error" 时,再用 `led1.setLedText(...)` 显示到窗体上。
|
||||
6. 点 **+ 新建窗体**,建 Form2,放列表框 `lstbRecored`。
|
||||
7. **OpenRecord 的 Click**:`new` 一个 Form2 并 `Show()`。
|
||||
8. **Form2_Load**:for 循环从 0 到 `recoudeindex - 1`,把每条记录的"时间 + 内容"加进 `lstbRecored`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **烟雾记录写成了火情**:书上烟雾分支往数组里记的内容是" 发现火情",要改成" 发现烟情",否则历史记录里看不到烟情。
|
||||
|
||||
> **列表框的名字**:书上 Form2_Load 里写的是 `listBox1`,表 5-7 和界面里叫 `lstbRecored`,代码要写 `lstbRecored`。
|
||||
|
||||
> 书上烟雾分支有一行注释只写了一个 `/`,照抄会编译不过,要写成 `//`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后"LED"选 **COM5**(LED 屏),"环境采集"选 **COM3**(ADAM-4150),点"开启"。
|
||||
2. 在实训台上把**火焰传感器**点成"有火焰",点"获取":火焰框显示"发现火情"、烟雾框显示"正常",实训台的 LED 屏和窗体上的 LED 控件都显示"发现火情"。
|
||||
3. 把火焰点回"正常"、**烟雾传感器**点成"有烟雾",再点"获取",火焰框显示"正常"、烟雾框显示"发现烟情"。
|
||||
4. 点"历史灾情信息",列表里应有"发现火情"和"发现烟情"两条,每条前面是时间。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "项目6 任务2 存储校园环境数据",
|
||||
"title": "项目6 任务2 环境数据存进数据库",
|
||||
"source": "教材书 p119–124,界面取自配套参考工程,控件名按书上表格修改;修正 True/False 大写、全角引号;INSERT 写明列名(书上不写列名时,值的顺序和建表的列顺序不一致,温度和光照会存反)"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
## 任务目标
|
||||
|
||||
从 ADAM-4017+ 读出光照度、温度、湿度,点"保存"存进 SQL Server 数据库的 CampusEnvironment 表,在表格里显示全部记录;还能在表格里改数据后点"修改"保存,选中一行点"删除"删掉。
|
||||
|
||||
练习重点:ADO.NET 的 SqlConnection、SqlCommand、SqlDataAdapter,insert / update / delete 语句,DataGridView 绑定数据。详细步骤见书 p119–124。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
这个任务用任务 1 建的**环境数据表**库,并在库里新建 **CampusEnvironment** 表:ID(int,标识列,主键)、light、humidity、temperature(都是 varchar(50))、addtime(datetime)。
|
||||
|
||||
在右上角的**数据库**页面里建表;还没做任务 1,或者数据弄乱了,点**恢复实验初始数据**,选这个实验,库和表会自动建好。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| -------- | ---------------------------------------------- | ------------------------ |
|
||||
| 窗体 | `Form1` | 校园灾情数据存储 |
|
||||
| 分组框 | `groupBox2` | 配置 |
|
||||
| 下拉框 | `com_port` | ADAM-4017+ 的串口 |
|
||||
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
|
||||
| 按钮 | `btn_get` | 获取 |
|
||||
| 按钮 | `btn_save` | 保存 |
|
||||
| 按钮 | `btnUpdate` | 修改 |
|
||||
| 按钮 | `btnDelete` | 删除 |
|
||||
| 分组框 | `groupBox3` | 历史记录 |
|
||||
| 数据表格 | `dataGridView1` | 显示数据库里的记录 |
|
||||
|
||||
> 注意按钮名字的写法不统一:`btn_Open`、`btn_get`、`btn_save` 带下划线,`btnUpdate`、`btnDelete` 不带,照书上的写,自动评分按这些名字找按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 引用 `System.Data.SqlClient`、`System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。
|
||||
2. 声明全局变量:连接字符串、SqlConnection、SqlCommand、SqlDataAdapter、DataSet、串口是否打开(bool)、ADAM4017ResModel。
|
||||
3. **Form1_Load**:打开数据库连接,把串口号加进 `com_port`。
|
||||
4. **btn_Open 的 Click**:串口没打开就打开(`adam4017Helper.Open`)并把按钮改成"关闭",否则关闭并改回"开启"。
|
||||
5. **btn_get 的 Click**:`adam4017Helper.SetData()` 读出数据,把 lightValue、temperatureValue、humidityValue 填进三个文本框。
|
||||
6. 写一个 **Databind()** 方法:用 select 语句(列名起中文别名:编号、温度、湿度、光照度、时间)查出全部记录,用 SqlDataAdapter 填进 DataSet,绑定到 `dataGridView1`。
|
||||
7. **btn_save 的 Click**:用 insert 语句把三个值和当前时间存进表,提示"保存成功!",再调用 Databind() 刷新表格。
|
||||
8. **btnUpdate 的 Click**:用 SqlCommandBuilder 生成更新语句,`sqldata.Update(...)` 把表格里改过的数据写回数据库。
|
||||
9. **btnDelete 的 Click**:按表格当前行第一列的编号,用 delete 语句删掉这一行,再刷新表格。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **连接字符串**:平台只看 `Initial Catalog=环境数据表`,服务器名、用户名和密码照书上写或写成 `Data Source=.` 都可以。
|
||||
|
||||
> **insert 要写列名**:书上写的是 `insert into CampusEnvironment values (温度, 湿度, 光照度, 时间)`,没写列名时值按建表的列顺序(light、humidity、temperature)存进去,温度和光照度就存反了。要写成 `insert into CampusEnvironment (temperature, humidity, light, addtime) values (...)`。
|
||||
|
||||
> 书上的 `True` / `False` 大写、中文引号 `”关闭”`、`privatevoid` 都是印刷错误,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**(ADAM-4017+),点"开启"。
|
||||
2. 在实训台上拖动光照、温度、湿度的滑块,点"获取",三个文本框应显示对应的值。
|
||||
3. 点"保存",弹出"保存成功!",表格最后一行是刚存的数据——检查温度、湿度、光照度有没有存进对应的列。
|
||||
4. 在表格里改一个温度,点"修改";点中某一行,点"删除"。到**数据库**页面查询 CampusEnvironment 表,看数据是不是真的变了。
|
||||