Compare commits
33
Commits
c217eb5ca0
..
main
| 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 |
No files matched your search
@@ -7,4 +7,5 @@
|
|||||||
.git
|
.git
|
||||||
docs
|
docs
|
||||||
*.pdf
|
*.pdf
|
||||||
|
textbook-libs
|
||||||
.env
|
.env
|
||||||
@@ -19,3 +19,5 @@ SIM_HEAP_LIMIT_MB=128
|
|||||||
SIM_CPU_LIMIT_SECONDS=120
|
SIM_CPU_LIMIT_SECONDS=120
|
||||||
# 每次运行最长时间(分钟)
|
# 每次运行最长时间(分钟)
|
||||||
SIM_MAX_RUN_MINUTES=60
|
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 拉代码、构建、重启)。
|
# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml),
|
||||||
# 部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
|
# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。
|
||||||
# 只在 github.com 上运行;仓库同时推到 git.xuyue.cc,那边的 Gitea Actions 也会读这个目录,用 if 跳过。
|
# - PR:只跑测试。
|
||||||
|
# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、
|
||||||
|
# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
|
||||||
|
# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。
|
||||||
name: CI/CD
|
name: CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -37,9 +37,10 @@ jobs:
|
|||||||
- name: 编译 C# 运行时
|
- name: 编译 C# 运行时
|
||||||
run: bun run build:runtime
|
run: bun run build:runtime
|
||||||
|
|
||||||
|
# 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步
|
||||||
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
|
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
|
||||||
working-directory: server
|
working-directory: server
|
||||||
run: bun test
|
run: bun test --parallel --max-concurrency=8
|
||||||
|
|
||||||
- name: 网页端测试和类型检查
|
- name: 网页端测试和类型检查
|
||||||
working-directory: web
|
working-directory: web
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: test
|
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
|
runs-on: ubuntu-24.04
|
||||||
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
|
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -99,4 +100,4 @@ jobs:
|
|||||||
|
|
||||||
- name: 部署(在服务器上拉代码、构建、重启)
|
- name: 部署(在服务器上拉代码、构建、重启)
|
||||||
if: steps.check.outputs.configured == 'true'
|
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
|
*.pdf
|
||||||
|
|
||||||
# 编辑器
|
# 编辑器
|
||||||
@@ -22,3 +24,9 @@ obj/
|
|||||||
|
|
||||||
# 本机开发的数据库
|
# 本机开发的数据库
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
# 服务器上 deploy/backup.sh 做的备份
|
||||||
|
/backups/
|
||||||
|
|
||||||
|
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
|
||||||
|
/prebuilt/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# bun fmt 不格式化的文件
|
||||||
|
# 书上的摘录,保持原样
|
||||||
|
docs/教材摘要/
|
||||||
+23
-3
@@ -1,5 +1,11 @@
|
|||||||
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
||||||
# 构建:docker compose build;部署说明见 deploy/README.md
|
# 构建: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# 运行时(编译服务 + 仿真库) ----
|
# ---- C# 运行时(编译服务 + 仿真库) ----
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
||||||
@@ -22,6 +28,20 @@ RUN cd web && bun run build
|
|||||||
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
||||||
RUN bun build server/src/index.ts --target=bun --outfile=server.js
|
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
|
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -29,9 +49,9 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
|
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
|
COPY --from=artifacts /runtime/ runtime/
|
||||||
COPY --from=web-build /src/web/dist/ web/dist/
|
COPY --from=artifacts /web/ web/dist/
|
||||||
COPY --from=web-build /src/server.js ./
|
COPY --from=artifacts /server.js ./
|
||||||
COPY samples/ samples/
|
COPY samples/ samples/
|
||||||
ENV APP_ROOT=/app \
|
ENV APP_ROOT=/app \
|
||||||
RUNNER_DLL=runtime/Sim.Runner.dll \
|
RUNNER_DLL=runtime/Sim.Runner.dll \
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
| 目录 | 内容 |
|
| 目录 | 内容 |
|
||||||
|---|---|
|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
||||||
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
||||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||||
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、仿冒 SqlClient(`Sim.Data`,T-SQL 转写成 SQLite)、运行器(`Sim.Runner`) |
|
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、仿冒 SqlClient(`Sim.Data`,T-SQL 转写成 SQLite)、运行器(`Sim.Runner`) |
|
||||||
| `samples/` | 示例工程(按教材任务整理) |
|
| `samples/` | 示例工程(按教材任务整理) |
|
||||||
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
|
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
|
||||||
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
|
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -33,11 +33,12 @@ cd server
|
|||||||
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
||||||
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
|
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
|
||||||
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
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)。
|
||||||
|
|
||||||
## 运行器的四种模式
|
## 运行器的四种模式
|
||||||
|
|
||||||
@@ -54,13 +55,13 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
|||||||
|
|
||||||
本机实测(16 核、28 GB):
|
本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
|---|---|
|
| ------------------------ | ----------------------------------------------------------- |
|
||||||
| 编译(编译服务预热后) | 约 10–300 ms |
|
| 编译(编译服务预热后) | 约 10–300 ms |
|
||||||
| 启动到窗体显示 | 约 100 ms |
|
| 启动到窗体显示 | 约 100 ms |
|
||||||
| 单次操作往返 | 约 15 ms |
|
| 单次操作往返 | 约 15 ms |
|
||||||
| 100 个程序同时运行:内存 | 合计约 1.1 GB(PSS,平均每个 11 MB);另加编译服务约 150 MB |
|
| 100 个程序同时运行:内存 | 合计约 1.1 GB(PSS,平均每个 11 MB);另加编译服务约 150 MB |
|
||||||
| 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms |
|
| 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms |
|
||||||
|
|
||||||
## 第 1 阶段进行中
|
## 第 1 阶段进行中
|
||||||
|
|
||||||
@@ -72,11 +73,12 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
|||||||
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
|
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。
|
||||||
|
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
|
||||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
||||||
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
|
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
|
||||||
|
|
||||||
尚未完成:
|
尚未完成:
|
||||||
|
|
||||||
- 一个班试用。
|
- 一个班试用。
|
||||||
- 书上的扩展练习("边想边做""自主探究")、导出 VS 工程、校园街景场景、故障注入题库(第 4 阶段)。
|
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
|
||||||
|
[fmt]
|
||||||
|
printWidth = 160
|
||||||
|
semi = false
|
||||||
|
singleQuote = true
|
||||||
+18
-5
@@ -5,18 +5,27 @@ services:
|
|||||||
init-data:
|
init-data:
|
||||||
image: iot-sim:latest
|
image: iot-sim:latest
|
||||||
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
|
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
|
||||||
build: .
|
build: &build
|
||||||
|
context: .
|
||||||
|
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
|
||||||
|
args:
|
||||||
|
ARTIFACTS: ${ARTIFACTS:-build}
|
||||||
user: root
|
user: root
|
||||||
command: ["sh", "-c", "chown -R \"$$APP_UID\" /data"]
|
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
|
||||||
volumes:
|
volumes:
|
||||||
- data:/data
|
- data:/data
|
||||||
restart: "no"
|
restart: 'no'
|
||||||
networks: [default]
|
networks: [default]
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: iot-sim:latest
|
image: iot-sim:latest
|
||||||
build: .
|
build: *build
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: '3'
|
||||||
depends_on:
|
depends_on:
|
||||||
init-data:
|
init-data:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -33,6 +42,7 @@ services:
|
|||||||
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||||
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||||
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||||
|
SIM_GRADE_CONCURRENCY: ${SIM_GRADE_CONCURRENCY:-16}
|
||||||
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
|
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
@@ -40,6 +50,8 @@ services:
|
|||||||
# 数据库(账号、班级、学生作品),重新部署不会丢
|
# 数据库(账号、班级、学生作品),重新部署不会丢
|
||||||
volumes:
|
volumes:
|
||||||
- data:/data
|
- data:/data
|
||||||
|
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
|
||||||
|
- ./textbook-libs:/app/textbook-libs:ro
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -51,12 +63,13 @@ services:
|
|||||||
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||||
mem_limit: ${APP_MEM_LIMIT:-4g}
|
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||||
pids_limit: 4096
|
pids_limit: 4096
|
||||||
expose: ["3000"]
|
expose: ['3000']
|
||||||
networks: [default]
|
networks: [default]
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
depends_on: [app]
|
depends_on: [app]
|
||||||
expose:
|
expose:
|
||||||
- 80
|
- 80
|
||||||
|
|||||||
+94
-26
@@ -66,15 +66,35 @@
|
|||||||
|
|
||||||
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
|
数据库(账号、班级、学生作品)在 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# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。
|
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
|
||||||
- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
|
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
|
||||||
- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。
|
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
|
||||||
- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。
|
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
|
||||||
- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。
|
- `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`):
|
一次性配置(在本机执行,需要已登录的 `gh`):
|
||||||
|
|
||||||
@@ -96,27 +116,75 @@
|
|||||||
|
|
||||||
在服务器的 `~/IOT_SIM` 目录下:
|
在服务器的 `~/IOT_SIM` 目录下:
|
||||||
|
|
||||||
| 做什么 | 命令 |
|
| 做什么 | 命令 |
|
||||||
|---|---|
|
| ------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
|
||||||
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||||
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||||
| 看日志 | `docker compose logs -f app` |
|
| 看日志 | `docker compose logs -f app` |
|
||||||
| 看资源占用 | `docker stats` |
|
| 看资源占用 | `docker stats` |
|
||||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
| 备份数据 | `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` 生效。
|
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
|---|---|---|
|
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||||
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||||
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
||||||
| `AI_MODEL` | `deepseek-flash` | AI 评阅用的模型(需要支持图片输入) |
|
| `AI_MODEL` | `deepseek-flash` | AI 评阅用的模型(需要支持图片输入) |
|
||||||
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
||||||
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
||||||
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||||
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
||||||
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
||||||
|
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
|
||||||
Executable
+21
@@ -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))"
|
||||||
Executable
+25
@@ -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"
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
|
||||||
|
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
|
||||||
|
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
|
||||||
|
# 这个提交的部署还没开始过时退出码 75
|
||||||
|
#
|
||||||
|
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
|
||||||
|
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
|
||||||
|
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||||
|
main() {
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
local state=/var/tmp/iot_sim-update
|
||||||
|
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
|
||||||
|
|
||||||
|
if [[ ${1:-} == --follow ]]; then
|
||||||
|
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
|
||||||
|
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
|
||||||
|
echo "服务器上没有 $sha 的部署记录(还没开始)"
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
follow
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
|
||||||
|
|
||||||
|
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
|
||||||
|
local old
|
||||||
|
old=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
|
||||||
|
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
|
||||||
|
while kill -0 "$old" 2>/dev/null; do sleep 2; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f $state.pid $state.exit
|
||||||
|
echo "$sha" >$state.sha
|
||||||
|
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
|
||||||
|
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
|
||||||
|
"$state" "$@" >$state.log 2>&1 </dev/null &
|
||||||
|
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
|
||||||
|
follow
|
||||||
|
}
|
||||||
|
|
||||||
|
follow() {
|
||||||
|
local pid code
|
||||||
|
pid=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $pid ]]; then
|
||||||
|
# update 进程结束后 tail 自己退出
|
||||||
|
tail -n +1 -f --pid="$pid" $state.log
|
||||||
|
else
|
||||||
|
cat $state.log 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
code=$(cat $state.exit 2>/dev/null || true)
|
||||||
|
if [[ -z $code ]]; then
|
||||||
|
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+37
-5
@@ -1,14 +1,27 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
|
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
|
||||||
# GitHub Actions 测试通过后通过 SSH 调用它,也可以登录服务器手动执行。
|
# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt]
|
||||||
# 用法:deploy/update.sh [提交号,默认 origin/main]
|
#
|
||||||
|
# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。
|
||||||
|
# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。
|
||||||
|
# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner
|
||||||
|
# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交,
|
||||||
|
# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。
|
||||||
|
#
|
||||||
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
|
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
|
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||||
main() {
|
main() {
|
||||||
cd "$(dirname "$0")/.."
|
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
|
if [[ ! -f .env ]]; then
|
||||||
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
|
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
|
||||||
@@ -16,8 +29,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== 拉取代码"
|
echo "== 拉取代码"
|
||||||
# 代码从 origin(git.xuyue.cc)拉取。Actions 由推送到 GitHub 触发,
|
# 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿
|
||||||
# 两个仓库推送有先后,这个提交可能还没到 origin,多等一会儿
|
|
||||||
for attempt in $(seq 10); do
|
for attempt in $(seq 10); do
|
||||||
git fetch --quiet origin
|
git fetch --quiet origin
|
||||||
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
|
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
|
||||||
@@ -33,7 +45,27 @@ main() {
|
|||||||
git reset --quiet --hard "$ref"
|
git reset --quiet --hard "$ref"
|
||||||
git log --oneline -1
|
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 "== 构建镜像并重启"
|
echo "== 构建镜像并重启"
|
||||||
|
# caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份
|
||||||
|
docker compose pull --quiet caddy
|
||||||
docker compose up -d --build --remove-orphans
|
docker compose up -d --build --remove-orphans
|
||||||
docker image prune -f >/dev/null
|
docker image prune -f >/dev/null
|
||||||
|
|
||||||
|
|||||||
+129
-100
@@ -14,27 +14,27 @@
|
|||||||
|
|
||||||
## 已确定的关键决策
|
## 已确定的关键决策
|
||||||
|
|
||||||
| 事项 | 决定 | 理由 |
|
| 事项 | 决定 | 理由 |
|
||||||
| --- | --- | --- |
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| 学生编程语言 | C# | 课程要求 |
|
| 学生编程语言 | C# | 课程要求 |
|
||||||
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||||
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
||||||
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
||||||
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
|
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
|
||||||
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
|
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
|
||||||
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
|
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
|
||||||
| AI 评阅 | DeepSeek Flash 给老师评分项(界面、注释、命名)出建议分和评语,加上自动评分就是建议的最终成绩;老师采纳(或修改后保存)才生效,学生在老师确认前看不到 | 减轻老师批改负担,成绩仍由老师负责;服务器在国内,用国内可合规使用的模型 |
|
| AI 评阅 | DeepSeek Flash 给老师评分项(界面、注释、命名)出建议分和评语,加上自动评分就是建议的最终成绩;老师采纳(或修改后保存)才生效,学生在老师确认前看不到 | 减轻老师批改负担,成绩仍由老师负责;服务器在国内,用国内可合规使用的模型 |
|
||||||
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
||||||
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
||||||
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
||||||
| 学生是否学协议 | 不学 | 协议只是仿真内部实现 |
|
| 学生是否学协议 | 不学 | 协议只是仿真内部实现 |
|
||||||
| 使用形式 | 纯网页,免安装 | 学生在机房和家里都能用 |
|
| 使用形式 | 纯网页,免安装 | 学生在机房和家里都能用 |
|
||||||
| 学生代码在哪运行 | 服务器(沙箱隔离) | 只供本校一两个班同时使用;原生 .NET 多线程行为真实;浏览器轻量 |
|
| 学生代码在哪运行 | 服务器(沙箱隔离) | 只供本校一两个班同时使用;原生 .NET 多线程行为真实;浏览器轻量 |
|
||||||
| 前端 | Vue 3 + Naive UI + Chart.js | 开发者熟悉 TypeScript 生态 |
|
| 前端 | Vue 3 + Naive UI + Chart.js | 开发者熟悉 TypeScript 生态 |
|
||||||
| 后端 | Bun + SQLite | 一人开发,单机足够 |
|
| 后端 | Bun + SQLite | 一人开发,单机足够 |
|
||||||
| 部署 | 公网云服务器(2 核 8G,已备案);HTTPS 由服务器上已有的 Nginx Proxy Manager 负责 | 本平台只在 Docker 网络里暴露 HTTP |
|
| 部署 | 公网云服务器(2 核 8G,已备案);HTTPS 由服务器上已有的 Nginx Proxy Manager 负责 | 本平台只在 Docker 网络里暴露 HTTP |
|
||||||
| 开发人力 | 一人 | 范围和路线图按此控制 |
|
| 开发人力 | 一人 | 范围和路线图按此控制 |
|
||||||
|
|
||||||
## 学生使用体验
|
## 学生使用体验
|
||||||
|
|
||||||
@@ -51,26 +51,27 @@
|
|||||||
|
|
||||||
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
||||||
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
||||||
|
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
|
||||||
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
||||||
|
|
||||||
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
**还原真实行为**:在后台线程里直接修改控件,会抛出和 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 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
||||||
|
|
||||||
| 对象 | 主要成员 | 平台仿真行为 |
|
| 对象 | 主要成员 | 平台仿真行为 |
|
||||||
| --- | --- | --- |
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
||||||
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
||||||
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
||||||
| `zigBeeHelper` | `Open(串口)`、`OnOff(继电器, 开关)`、`Close()` | 控制 ZigBee 继电器;节点 1 接实训台照明灯 1(教材项目 3 的路灯) |
|
| `zigBeeHelper` | `Open(串口)`、`OnOff(继电器, 开关)`、`Close()` | 控制 ZigBee 继电器;节点 1 接实训台照明灯 1(教材项目 3 的路灯) |
|
||||||
| `ipCameraHelper`(未实现) | `Open(IP, 用户, 密码)`、`Start` / `Stop`、`PanUp` / `PanDown` / `PanLeft` / `PanRight`、`ImageRead` 事件 | 在后台线程定时推送画面;云台转动即在全景图上移动取景 |
|
| `ipCameraHelper`(未实现) | `Open(IP, 用户, 密码)`、`Start` / `Stop`、`PanUp` / `PanDown` / `PanLeft` / `PanRight`、`ImageRead` 事件 | 在后台线程定时推送画面;云台转动即在全景图上移动取景 |
|
||||||
| 控件 `Lamp` / `Alarm` / `LED` / `ShowMsg` | `OnOff(bool)`、`setLedText(文本)`、`AddText(文本)` | 窗体上的图示控件(自绘图片,尺寸与教材控件一致);LED 文字滚动。ShowMsg 尚未实现 |
|
| 控件 `Lamp` / `Alarm` / `LED` / `ShowMsg` | `OnOff(bool)`、`setLedText(文本)`、`AddText(文本)` | 窗体上的图示控件(自绘图片,尺寸与教材控件一致);LED 文字滚动。ShowMsg 尚未实现 |
|
||||||
|
|
||||||
教材代码示例(项目 2 任务 1,向 LED 屏发送信息):
|
教材代码示例(项目 2 任务 1,向 LED 屏发送信息):
|
||||||
|
|
||||||
@@ -97,7 +98,10 @@ private void btn_Send_Click(object sender, EventArgs e)
|
|||||||
兼容细节:
|
兼容细节:
|
||||||
|
|
||||||
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
|
- 数值一律返回整数字符串(项目 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。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
||||||
- 摄像头事件在非界面线程触发,与真实行为一致。
|
- 摄像头事件在非界面线程触发,与真实行为一致。
|
||||||
|
|
||||||
@@ -150,37 +154,37 @@ IOT/
|
|||||||
|
|
||||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||||
|
|
||||||
| 层 | 用途 | 选择 |
|
| 层 | 用途 | 选择 |
|
||||||
| --- | --- | --- |
|
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||||
| 前端 | 代码编辑器 | Monaco Editor |
|
| 前端 | 代码编辑器 | Monaco Editor |
|
||||||
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
||||||
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
||||||
| 前端 | 图表 | 计划:Chart.js |
|
| 前端 | 图表 | 计划:Chart.js |
|
||||||
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||||
| 后端 | 运行时 | Bun |
|
| 后端 | 运行时 | Bun |
|
||||||
| 后端 | Web 框架 | Hono |
|
| 后端 | Web 框架 | Hono |
|
||||||
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
||||||
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||||
| 后端 | 参数校验 | 计划:Zod |
|
| 后端 | 参数校验 | 计划:Zod |
|
||||||
| C# 运行时 | 平台 | .NET 10 LTS |
|
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||||
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
|
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
|
||||||
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
|
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
|
||||||
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
||||||
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
||||||
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
||||||
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
||||||
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
|
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
|
||||||
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||||
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||||
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||||
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||||
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||||
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
| 部署 | 反向代理 / 压缩 | 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` + 异地拷贝 |
|
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||||
|
|
||||||
## 安全与服务器资源
|
## 安全与服务器资源
|
||||||
|
|
||||||
@@ -206,16 +210,16 @@ IOT/
|
|||||||
|
|
||||||
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
||||||
|
|
||||||
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
|
||||||
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
||||||
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
||||||
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
||||||
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 |
|
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 |
|
||||||
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
|
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
|
||||||
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
|
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
|
||||||
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
|
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
|
||||||
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
|
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
|
||||||
|
|
||||||
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
|
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
|
||||||
|
|
||||||
@@ -224,7 +228,7 @@ IOT/
|
|||||||
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
||||||
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
||||||
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
||||||
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||||
|
|
||||||
老师不写代码也能修改或新增实验。
|
老师不写代码也能修改或新增实验。
|
||||||
|
|
||||||
@@ -235,10 +239,10 @@ IOT/
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||||
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
|
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
|
||||||
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔"]
|
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
|
||||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||||
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||||
P2 -->|"关口:一个班试用顺利"| P3
|
P2 -->|"关口:一个班试用顺利"| P3
|
||||||
@@ -247,15 +251,19 @@ flowchart TB
|
|||||||
|
|
||||||
并行事项:尽早办理 ICP 备案(需数周)。
|
并行事项:尽早办理 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):
|
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
| --- | --- |
|
| ---------------------- | ---------------------------------------------------- |
|
||||||
| 编译(编译服务预热后) | 10–300 ms |
|
| 编译(编译服务预热后) | 10–300 ms |
|
||||||
| 点"运行"到窗体出现 | 约 100 ms |
|
| 点"运行"到窗体出现 | 约 100 ms |
|
||||||
| 单次操作往返 | 约 15 ms |
|
| 单次操作往返 | 约 15 ms |
|
||||||
| 100 个程序同时运行 | 合计约 1.1 GB,平均每个 11 MB,另加编译服务约 150 MB |
|
| 100 个程序同时运行 | 合计约 1.1 GB,平均每个 11 MB,另加编译服务约 150 MB |
|
||||||
| 100 个程序同时点击 | 往返中位数约 60 ms,P95 约 75 ms |
|
| 100 个程序同时点击 | 往返中位数约 60 ms,P95 约 75 ms |
|
||||||
|
|
||||||
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。接入沙箱后在 2 核环境下的复测见上文"资源估算"。
|
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。接入沙箱后在 2 核环境下的复测见上文"资源估算"。
|
||||||
|
|
||||||
@@ -287,7 +295,7 @@ flowchart TB
|
|||||||
- **SqlClient 仿冒**(`runtime/Sim.Data`):SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter,DataSet、DataTable、DataView 直接用 .NET 自带的。连接字符串只按 Initial Catalog 打开学生自己的同名库(服务器名、账号密码不检查,书上的长服务器名、"." 都能连上),库不存在时报 4060。
|
- **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。
|
- **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 一致,弹出浏览器的日期选择框。设计器工具箱加"数据表格""日期时间选择器"。
|
- **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 个。
|
- **评分**:每个实验可以带 `database.sql`,评分时每个检查项用一份新建的实验库,不碰学生自己的数据;评分规则加"点 / 修改表格单元格""在日期时间选择器里选日期""表格里有 / 没有某行"。新增示例项目 6 任务 2、3(任务 1 在数据库页面上完成),共 15 个。
|
||||||
- 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。
|
- 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。
|
||||||
|
|
||||||
@@ -316,28 +324,49 @@ flowchart TB
|
|||||||
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
|
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
|
||||||
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
|
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
|
||||||
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
|
- 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 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||||
|
|
||||||
| 风险 | 担心什么 | 应对 |
|
| 风险 | 担心什么 | 应对 |
|
||||||
| --- | --- | --- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||||
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||||
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||||
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
|
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
|
||||||
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
|
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
|
||||||
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
|
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
|
||||||
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
|
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
|
||||||
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
||||||
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
||||||
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
|
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
|
||||||
| ICP 备案 | 办理需要数周 | 已备案 |
|
| ICP 备案 | 办理需要数周 | 已备案 |
|
||||||
|
|
||||||
**待确认**
|
**待确认**
|
||||||
|
|
||||||
- [ ] 串口是否要求接对:目前学生选任意 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] 第一个场景:智慧校园(和教材一致)
|
||||||
- [x] 设备写法:兼容教材类库
|
- [x] 设备写法:兼容教材类库
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "iot-sim",
|
"name": "iot-sim",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["shared", "server", "web"],
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "bun run --cwd server dev",
|
"dev:server": "bun run --cwd server dev",
|
||||||
"dev:web": "bun run --cwd web 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
|
private static string DatePartName(ScalarExpression expression) => expression switch
|
||||||
{
|
{
|
||||||
|
IdentifierLiteral i => i.Value,
|
||||||
ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value,
|
ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value,
|
||||||
StringLiteral s => s.Value,
|
StringLiteral s => s.Value,
|
||||||
_ => throw SqlException.NotSupported("这种 datepart 写法"),
|
_ => throw SqlException.NotSupported("这种 datepart 写法"),
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ await rm(framesDir, { recursive: true, force: true })
|
|||||||
await mkdir(framesDir, { recursive: true })
|
await mkdir(framesDir, { recursive: true })
|
||||||
await mkdir(work, { recursive: true })
|
await mkdir(work, { recursive: true })
|
||||||
try {
|
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 svgPath = join(work, `scene-${flag}.svg`)
|
||||||
const pngPath = join(work, `scene-${flag}.png`)
|
const pngPath = join(work, `scene-${flag}.png`)
|
||||||
await Bun.write(svgPath, svg)
|
await Bun.write(svgPath, svg)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DeviceCtrlLibrary.Model;
|
using DeviceCtrlLibrary.Model;
|
||||||
using Sim.Host;
|
using Sim.Host;
|
||||||
@@ -7,7 +8,8 @@ namespace DeviceCtrlLibrary;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
||||||
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
|
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数,
|
||||||
|
/// 并且和真机版一样,设备要在它接的那个串口上打开才有反应(接线见 TrainingBoard)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeviceCtrlHelper
|
public class DeviceCtrlHelper
|
||||||
{
|
{
|
||||||
@@ -20,6 +22,38 @@ public class DeviceCtrlHelper
|
|||||||
public static IPCameraHelper ipCameraHelper = new();
|
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
|
public class SerialPortHelper
|
||||||
{
|
{
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
@@ -48,18 +82,21 @@ public class LEDHelper
|
|||||||
private const string SendFailed = "Error:发送失败";
|
private const string SendFailed = "Error:发送失败";
|
||||||
private string? _portName;
|
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)
|
public string SendLedString(string sendString)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
|
if (PortReport.NotOpen("led", _portName)) return SendFailed;
|
||||||
SimHost.SendDevice("led", "text", sendString);
|
if (TrainingBoard.Reaches("led", _portName)) SimHost.SendDevice("led", "text", sendString);
|
||||||
return sendString;
|
return sendString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
|
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留它让书上代码能编译</summary>
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("led", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ADAM4017Helper
|
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,
|
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)
|
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 };
|
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
||||||
/// 串口未打开时所有属性为 null,与教材仿真版一致。
|
/// 串口未打开时所有属性为 null,与教材仿真版一致;打开的串口上没接 4017+ 时收不到回应,和真机版一样也全是 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ADAM4017ResModel SetData()
|
public ADAM4017ResModel SetData()
|
||||||
{
|
{
|
||||||
LegacyThreads.CheckPoint();
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4017ResModel();
|
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.temperatureValue = Read(0);
|
||||||
model.humidityValue = Read(1);
|
model.humidityValue = Read(1);
|
||||||
model.lightValue = Read(2);
|
model.lightValue = Read(2);
|
||||||
@@ -95,7 +132,7 @@ public class ADAM4017Helper
|
|||||||
|
|
||||||
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
|
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
|
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)
|
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 };
|
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
|
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开、或打开的串口上没接 4150(收不到回应)时全部为 false</summary>
|
||||||
public ADAM4150ResModel SetData()
|
public ADAM4150ResModel SetData()
|
||||||
{
|
{
|
||||||
LegacyThreads.CheckPoint();
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4150ResModel();
|
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.bodyInfraredValue = Read(0);
|
||||||
model.fireValue = Read(1);
|
model.fireValue = Read(1);
|
||||||
model.smokeValue = Read(2);
|
model.smokeValue = Read(2);
|
||||||
@@ -126,31 +163,35 @@ public class ADAM4150Helper
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
||||||
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
|
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物);
|
||||||
|
/// 真机版只管发出命令、不等回应,所以打开的串口上没接 4150 时也返回 true,只是继电器不动作。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnOff(int DO, bool IsOpen)
|
public bool OnOff(int DO, bool IsOpen)
|
||||||
{
|
{
|
||||||
if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false;
|
if (DO < 0 || DO > 4 || PortReport.NotOpen("adam4150", _portName)) return false;
|
||||||
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
if (TrainingBoard.Reaches("adam4150", _portName)) SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("adam4150", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ZigBeeHelper
|
public class ZigBeeHelper
|
||||||
{
|
{
|
||||||
private string? _portName;
|
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)
|
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 });
|
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("zigbee", _portName);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace DeviceCtrlLibrary;
|
namespace DeviceCtrlLibrary;
|
||||||
@@ -15,6 +16,14 @@ public static class TrainingBoard
|
|||||||
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
||||||
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
||||||
private static bool[] _digital = new bool[7];
|
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()
|
static TrainingBoard()
|
||||||
{
|
{
|
||||||
@@ -22,7 +31,7 @@ public static class TrainingBoard
|
|||||||
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
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)
|
public static void Update(JsonObject json)
|
||||||
{
|
{
|
||||||
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
|
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;
|
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
|
||||||
if (json["digital"] is JsonArray digital)
|
if (json["digital"] is JsonArray digital)
|
||||||
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
|
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)
|
internal static int ReadAnalog(int channel)
|
||||||
{
|
{
|
||||||
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
namespace Sim.Runner;
|
namespace Sim.Runner;
|
||||||
@@ -23,7 +24,32 @@ internal static class CompileHints
|
|||||||
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
||||||
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
||||||
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
||||||
|
("CS8026", _) => Vs2012(diagnostic),
|
||||||
_ => null,
|
_ => 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>
|
/// <summary>
|
||||||
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
||||||
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
||||||
|
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
|
||||||
|
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
|
||||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class CompileServer
|
internal static class CompileServer
|
||||||
{
|
{
|
||||||
public const string AssemblyFile = "StudentApp.dll";
|
public const string AssemblyFile = "StudentApp.dll";
|
||||||
public const string PdbFile = "StudentApp.pdb";
|
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 };
|
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||||
|
|
||||||
@@ -36,7 +40,10 @@ internal static class CompileServer
|
|||||||
{
|
{
|
||||||
var request = JsonNode.Parse(line)!.AsObject();
|
var request = JsonNode.Parse(line)!.AsObject();
|
||||||
id = request["id"]?.DeepClone();
|
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)
|
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>
|
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
||||||
private static void WarmUp()
|
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 模式会加载 -->
|
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
|
||||||
</ItemGroup>
|
</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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ internal static class StudentCompiler
|
|||||||
public static Result Compile(string projectDir)
|
public static Result Compile(string projectDir)
|
||||||
{
|
{
|
||||||
var parseOptions = ParseOptions;
|
var parseOptions = ParseOptions;
|
||||||
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
var trees = SourceFiles(projectDir)
|
||||||
.Where(path => !IsBuildOutput(projectDir, path))
|
|
||||||
.OrderBy(path => path, StringComparer.Ordinal)
|
|
||||||
.Select(path => CSharpSyntaxTree.ParseText(
|
.Select(path => CSharpSyntaxTree.ParseText(
|
||||||
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
||||||
parseOptions,
|
parseOptions,
|
||||||
@@ -123,6 +121,12 @@ internal static class StudentCompiler
|
|||||||
return json;
|
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)
|
private static bool IsBuildOutput(string projectDir, string path)
|
||||||
{
|
{
|
||||||
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ internal static class StudentProgramHost
|
|||||||
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
|
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
|
||||||
|
|
||||||
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出。
|
// 和中文 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();
|
var zhCN = (CultureInfo)CultureInfo.GetCultureInfo("zh-CN").Clone();
|
||||||
zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss";
|
zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss";
|
||||||
zhCN.DateTimeFormat.ShortTimePattern = "H:mm";
|
zhCN.DateTimeFormat.ShortTimePattern = "H:mm";
|
||||||
|
zhCN.DateTimeFormat.LongDatePattern = "yyyy'年'M'月'd'日'";
|
||||||
|
zhCN.DateTimeFormat.FullDateTimePattern = "yyyy'年'M'月'd'日' H:mm:ss";
|
||||||
zhCN = CultureInfo.ReadOnly(zhCN);
|
zhCN = CultureInfo.ReadOnly(zhCN);
|
||||||
CultureInfo.DefaultThreadCurrentCulture = zhCN;
|
CultureInfo.DefaultThreadCurrentCulture = zhCN;
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = 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>
|
/// <summary>
|
||||||
/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。
|
/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。
|
||||||
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值,只把显式设置的值发给浏览器。
|
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值(文本框等的颜色除外,见 OwnBackColor),只把显式设置的值发给浏览器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Control : Component, IWin32Window
|
public class Control : Component, IWin32Window
|
||||||
{
|
{
|
||||||
@@ -178,24 +178,42 @@ public class Control : Component, IWin32Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>和 WinForms 一样:父控件(分组框、面板)禁用时子控件也是禁用的,读出 false、不响应操作;浏览器按同样的规则画成灰色</summary>
|
||||||
public bool Enabled
|
public bool Enabled
|
||||||
{
|
{
|
||||||
get => _enabled;
|
get => _enabled && (_parent?.Enabled ?? true);
|
||||||
set
|
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
|
public virtual Color BackColor
|
||||||
{
|
{
|
||||||
get => !_backColor.IsEmpty ? _backColor : _parent?.BackColor ?? DefaultBackColor;
|
get => !_backColor.IsEmpty ? _backColor : OwnBackColor ?? _parent?.BackColor ?? DefaultBackColor;
|
||||||
set => SetProp(ref _backColor, value, nameof(BackColor));
|
set
|
||||||
|
{
|
||||||
|
if (!value.IsEmpty && value.A < 255 && !SupportsTransparentBackColor) throw new ArgumentException("控件不支持透明的背景色。");
|
||||||
|
SetProp(ref _backColor, value, nameof(BackColor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual Color ForeColor
|
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));
|
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 OnMouseLeave(EventArgs e) => MouseLeave?.Invoke(this, e);
|
||||||
protected virtual void OnTextChanged(EventArgs e) => TextChanged?.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 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 OnGotFocus(EventArgs e) => GotFocus?.Invoke(this, e);
|
||||||
protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e);
|
protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e);
|
||||||
protected virtual void OnPaint(PaintEventArgs e) => Paint?.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 _initializing;
|
||||||
private bool _rowsDirty;
|
private bool _rowsDirty;
|
||||||
|
|
||||||
|
private protected override bool SupportsTransparentBackColor => false;
|
||||||
|
|
||||||
public DataGridView()
|
public DataGridView()
|
||||||
{
|
{
|
||||||
Columns = new DataGridViewColumnCollection(this);
|
Columns = new DataGridViewColumnCollection(this);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class DateTimePicker : Control
|
|||||||
|
|
||||||
protected override Size DefaultSize => new(200, 21);
|
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
|
public DateTime Value
|
||||||
{
|
{
|
||||||
get => _value;
|
get => _value;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class Form : ContainerControl
|
|||||||
private bool _modal;
|
private bool _modal;
|
||||||
private DialogResult _dialogResult;
|
private DialogResult _dialogResult;
|
||||||
|
|
||||||
|
private protected override bool SupportsTransparentBackColor => false;
|
||||||
|
|
||||||
public Form()
|
public Form()
|
||||||
{
|
{
|
||||||
base.Visible = false;
|
base.Visible = false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace System.Windows.Forms;
|
namespace System.Windows.Forms;
|
||||||
@@ -10,6 +11,10 @@ public abstract class ListControl : Control
|
|||||||
/// <summary>选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged)</summary>
|
/// <summary>选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged)</summary>
|
||||||
private protected int SelectedIndexValue = -1;
|
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 abstract int SelectedIndex { get; set; }
|
||||||
|
|
||||||
public string DisplayMember { get; set; } = "";
|
public string DisplayMember { get; set; } = "";
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ public abstract class TextBoxBase : Control
|
|||||||
private bool _readOnly;
|
private bool _readOnly;
|
||||||
private BorderStyle _borderStyle = BorderStyle.Fixed3D;
|
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
|
public bool Multiline
|
||||||
{
|
{
|
||||||
get => _multiline;
|
get => _multiline;
|
||||||
|
|||||||
@@ -134,12 +134,54 @@ public static class SimHost
|
|||||||
public static void ReportException(Exception ex)
|
public static void ReportException(Exception ex)
|
||||||
{
|
{
|
||||||
while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!;
|
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",
|
["type"] = "exception",
|
||||||
["message"] = $"{ex.GetType().FullName}: {ex.Message}",
|
["message"] = message,
|
||||||
["detail"] = StudentStackTrace(ex),
|
["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>
|
/// <summary>只保留学生代码的堆栈行,去掉仿真库内部的调用</summary>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ namespace System.IO.Ports;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SerialPort : Component
|
public class SerialPort : Component
|
||||||
{
|
{
|
||||||
/// <summary>COM1 是电脑自带串口,COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏</summary>
|
/// <summary>COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏,不提供电脑自带的 COM1</summary>
|
||||||
private static readonly string[] VirtualPorts = { "COM1", "COM2", "COM3", "COM4", "COM5" };
|
private static readonly string[] VirtualPorts = { "COM2", "COM3", "COM4", "COM5" };
|
||||||
|
|
||||||
public SerialPort() { }
|
public SerialPort() { }
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ public class SerialPort : Component
|
|||||||
|
|
||||||
public static string[] GetPortNames() => (string[])VirtualPorts.Clone();
|
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 BaudRate { get; set; } = 9600;
|
||||||
public int DataBits { get; set; } = 8;
|
public int DataBits { get; set; } = 8;
|
||||||
public int ReadTimeout { get; set; } = -1;
|
public int ReadTimeout { get; set; } = -1;
|
||||||
|
|||||||
@@ -1,27 +1,195 @@
|
|||||||
[
|
[
|
||||||
{"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": "switch_on",
|
||||||
{"name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio"},
|
"file": "switch_on.png",
|
||||||
{"name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image", "key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f", "hidpi": "ico_title.2x.png"},
|
"label": "开关:开",
|
||||||
{"name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image", "key": "e56bcc13f7128dec305b6012b255d63fb0f94450", "hidpi": "bg_home.2x.png"},
|
"kind": "image",
|
||||||
{"name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image", "key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d", "hidpi": "icon_02.2x.png"},
|
"key": "6e660e1ff6fc2ff3167919bc386c300112bbce72",
|
||||||
{"name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
|
"hidpi": "switch_on.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": "switch_off",
|
||||||
{"name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image", "key": "7a8c0a16fcab37b28a66e041024b25341cc69462", "hidpi": "bg_from1.2x.png"},
|
"file": "switch_off.png",
|
||||||
{"name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image", "key": "52497df301d2b7e4eac0c297ce2316ef24863c47", "hidpi": "bg_from2.2x.png"},
|
"label": "开关:关",
|
||||||
{"name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image", "key": "23e6f1efc0f382c17d8a091b76531a3b5879349d", "hidpi": "icon_monitor.2x.png"},
|
"kind": "image",
|
||||||
{"name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image", "key": "396443a1d8c141942c0f447e0c69786cf6ad3350", "hidpi": "panel_video_ctrl.2x.png"},
|
"key": "5d748b614084e878d6189d22f27813477cb8f4d9",
|
||||||
{"name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
|
"hidpi": "switch_off.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": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" },
|
||||||
{"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": "ico_title",
|
||||||
{"name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image", "key": "47bd7a8ffc077130a8ceca627383a4647729c638", "hidpi": "btn_arrow_left_press.2x.png"},
|
"file": "ico_title.png",
|
||||||
{"name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image", "key": "e69e459f54d67e2c52128015bd6cfcf0e800a893", "hidpi": "btn_arrow_right_normal.2x.png"},
|
"label": "标题:智慧校园",
|
||||||
{"name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image", "key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170", "hidpi": "btn_arrow_right_press.2x.png"},
|
"kind": "image",
|
||||||
{"name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
|
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
|
||||||
{"name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image", "key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9", "hidpi": "arrow_right_normal.2x.png"},
|
"hidpi": "ico_title.2x.png"
|
||||||
{"name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
steps:
|
steps:
|
||||||
- click: btnAdd
|
- click: btnAdd
|
||||||
- text: { control: txtNewUser1, value: zhangsan }
|
- text: { control: txtNewUser1, value: zhangsan }
|
||||||
- text: { control: txtPass1, value: "123" }
|
- text: { control: txtPass1, value: '123' }
|
||||||
- text: { control: txtPass2, value: "123" }
|
- text: { control: txtPass2, value: '123' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- text: { control: txtUserName, value: zhangsan }
|
- text: { control: txtUserName, value: zhangsan }
|
||||||
- text: { control: txtPassword, value: "123" }
|
- text: { control: txtPassword, value: '123' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- expect: { messageBox: 登录成功 }
|
- expect: { messageBox: 登录成功 }
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
steps:
|
steps:
|
||||||
- click: btnAdd
|
- click: btnAdd
|
||||||
- text: { control: txtNewUser1, value: lisi }
|
- text: { control: txtNewUser1, value: lisi }
|
||||||
- text: { control: txtPass1, value: "123" }
|
- text: { control: txtPass1, value: '123' }
|
||||||
- text: { control: txtPass2, value: "456" }
|
- text: { control: txtPass2, value: '456' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- expect: { messageBox: 用户名或密码错误 }
|
- expect: { messageBox: 用户名或密码错误 }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目1 任务1 C#引入(登录与增加用户)",
|
"title": "项目1 任务1 登录和注册用户",
|
||||||
"source": "教材书 p2–7,按书上代码整理。修正:删去未声明的变量 change;增加用户窗体关闭后会被释放,改为每次点击时新建"
|
"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
|
# 格式说明见 server/src/grading/checks.ts
|
||||||
- title: 计算器能正确计算(装箱与拆箱)
|
- title: 计算器能正确计算(装箱与拆箱)
|
||||||
steps:
|
steps:
|
||||||
- text: { control: txtNum1, value: "7" }
|
- text: { control: txtNum1, value: '7' }
|
||||||
- text: { control: txtNum2, value: "2" }
|
- text: { control: txtNum2, value: '2' }
|
||||||
- select: { control: cmboxType, item: "+" }
|
- select: { control: cmboxType, item: '+' }
|
||||||
- click: btnCalculate
|
- click: btnCalculate
|
||||||
- expect: { control: txtResult, text: "9" }
|
- expect: { control: txtResult, text: '9' }
|
||||||
- select: { control: cmboxType, item: "/" }
|
- select: { control: cmboxType, item: '/' }
|
||||||
- click: btnCalculate
|
- click: btnCalculate
|
||||||
- expect: { control: txtResult, text: "3.5" }
|
- expect: { control: txtResult, text: '3.5' }
|
||||||
|
|
||||||
- title: 多态:不同国家的人说不同的语言
|
- title: 多态:不同国家的人说不同的语言
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目1 任务2 C#面向对象特性(计算器、多态)",
|
"title": "项目1 任务2 计算器和多态",
|
||||||
"source": "教材书 p13–16,界面取自配套参考工程,控件名和窗体标题按书修改(BtnCalculate → btnCalculate)"
|
"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,按书上代码整理(控件名以书上代码为准)"
|
"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
|
- click: btn_Open
|
||||||
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
|
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "25" }
|
- expect: { control: txt_temperature, text: '25' }
|
||||||
- expect: { control: txt_humidity, text: "60" }
|
- expect: { control: txt_humidity, text: '60' }
|
||||||
- expect: { control: txt_light, text: "500" }
|
- expect: { control: txt_light, text: '500' }
|
||||||
|
|
||||||
- title: 环境变化后能获取到新的数值
|
- title: 环境变化后能获取到新的数值
|
||||||
steps:
|
steps:
|
||||||
@@ -16,5 +16,5 @@
|
|||||||
- click: btn_Open
|
- click: btn_Open
|
||||||
- board: { temperature: 31, light: 300 }
|
- board: { temperature: 31, light: 300 }
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "31" }
|
- expect: { control: txt_temperature, text: '31' }
|
||||||
- expect: { control: txt_light, text: "300" }
|
- expect: { control: txt_light, text: '300' }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目2 任务2 实时采集温度、湿度和光照度数据",
|
"title": "项目2 任务2 读取温湿度和光照",
|
||||||
"source": "教材书 p36–39,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
"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 }
|
- select: { control: com_ledPort, item: COM5 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "25" }
|
- expect: { control: txt_temperature, text: '25' }
|
||||||
- expect: { led: "25", desc: 显示温度 25 }
|
- expect: { led: '25', desc: 显示温度 25 }
|
||||||
- expect: { led: "60", desc: 显示湿度 60 }
|
- expect: { led: '60', desc: 显示湿度 60 }
|
||||||
- expect: { led: "500", desc: 显示光照度 500 }
|
- expect: { led: '500', desc: 显示光照度 500 }
|
||||||
|
|
||||||
- title: 能推送最新的环境信息
|
- title: 能推送最新的环境信息
|
||||||
steps:
|
steps:
|
||||||
@@ -19,4 +19,4 @@
|
|||||||
- click: btn_get
|
- click: btn_get
|
||||||
- board: { temperature: 31 }
|
- board: { temperature: 31 }
|
||||||
- click: btn_get
|
- 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,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
"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 }
|
- select: { control: com_port, item: COM4 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: rb_Open
|
- click: rb_Open
|
||||||
|
- expect: { zigbee: 1, on: true, desc: 先打开 }
|
||||||
- click: rb_Close
|
- click: rb_Close
|
||||||
- expect: { zigbee: 1, on: false }
|
- expect: { zigbee: 1, on: false }
|
||||||
- expect: { control: lamp1, on: false }
|
- expect: { control: lamp1, on: false }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目3 任务1 使用ZigBee技术控制路灯开关",
|
"title": "项目3 任务1 路灯开关",
|
||||||
"source": "教材书 p53–56,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
"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,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
"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 少空格"
|
"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:
|
steps:
|
||||||
- text: { control: txt_ip, value: 192.168.2.50 }
|
- text: { control: txt_ip, value: 192.168.2.50 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 }
|
- expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 }
|
||||||
|
|
||||||
- title: 能控制摄像机上下左右旋转
|
- title: 能控制摄像机上下左右旋转
|
||||||
steps:
|
steps:
|
||||||
@@ -35,6 +35,6 @@
|
|||||||
steps:
|
steps:
|
||||||
- text: { control: txt_ip, value: 192.168.2.50 }
|
- text: { control: txt_ip, value: 192.168.2.50 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 }
|
- expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 }
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
- expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 }
|
- expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目4 任务2 控制网络摄像机",
|
"title": "项目4 任务2 网络摄像机监控",
|
||||||
"source": "教材书 p78–81,界面取自配套参考工程,标签文字按书上表格改为“摄像机IP”;补上书上漏写的 using System.IO;"
|
"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 }
|
- select: { control: com_port, item: COM3 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: btn_alarmOpen
|
- click: btn_alarmOpen
|
||||||
|
- expect: { relay: 0, on: true, desc: 先打开 }
|
||||||
- click: btn_alarmClose
|
- click: btn_alarmClose
|
||||||
- expect: { relay: 0, on: false }
|
- expect: { relay: 0, on: false }
|
||||||
- expect: { control: alarm1, on: false }
|
- expect: { control: alarm1, on: false }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目4 任务3 控制报警灯",
|
"title": "项目4 任务3 报警灯开关",
|
||||||
"source": "教材书 p84–87,界面取自配套参考工程;修正书上 boolIsOpen 少空格"
|
"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,界面取自配套参考工程,控件名按书上表格修改"
|
"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 挂起的线程会出错)"
|
"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"
|
"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 写明列名(书上不写列名时,值的顺序和建表的列顺序不一致,温度和光照会存反)"
|
"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 表,看数据是不是真的变了。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 评分规则:书上评价表的功能项。每项单独启动一次程序(使用 database.sql 建的初始数据库),按步骤操作,核对结果。
|
# 评分规则:书上评价表的功能项。每项单独启动一次程序(使用 database.sql 建的初始数据库),按步骤操作,核对结果。
|
||||||
# 初始数据有 3 行:2024-01-16 09:00、2024-01-18 14:30、2024-01-23 08:15
|
# 初始数据有 3 行:2024-01-16 09:00、2024-01-18 14:30、2024-01-23 08:15
|
||||||
# 格式说明见 server/src/grading/checks.ts
|
# 格式说明见 server/src/grading/checks.ts
|
||||||
- title: 能实现按日期查询灾情数据
|
- title: 能实现按日期查询环境数据
|
||||||
steps:
|
steps:
|
||||||
- pick: { control: StartTime, value: '2024-01-15' }
|
- pick: { control: StartTime, value: '2024-01-15' }
|
||||||
- pick: { control: EndTime, value: '2024-01-20' }
|
- pick: { control: EndTime, value: '2024-01-20' }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目6 任务3 查询校园安防数据",
|
"title": "项目6 任务3 按日期查询环境数据",
|
||||||
"source": "教材书 p127–128,界面取自配套参考工程;补上书上漏写的 ds = new DataSet();(不写时 Fill 报空引用)。数据库和任务 2 相同(环境数据表)"
|
"source": "教材书 p127–128,界面取自配套参考工程;补上书上漏写的 ds = new DataSet();(不写时 Fill 报空引用)。数据库和任务 2 相同(环境数据表)"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
选一个开始日期和结束日期,点"查询",从任务 2 存的 CampusEnvironment 表里查出这段时间内的记录,显示在表格里。
|
||||||
|
|
||||||
|
练习重点:select 语句和 where … between … and …、日期时间控件(DateTimePicker)、SqlDataAdapter 和 DataSet、DataGridView 绑定数据。详细步骤见书 p127–128。
|
||||||
|
|
||||||
|
## 先准备数据库
|
||||||
|
|
||||||
|
这个任务用任务 2 的**环境数据表**库和里面的 **CampusEnvironment** 表(ID、light、humidity、temperature、addtime)。表里要先有几条不同日期的记录,查询才看得出效果。
|
||||||
|
|
||||||
|
还没做任务 2,或者数据弄乱了,到右上角的**数据库**页面点**恢复实验初始数据**,选这个实验:库和表会自动建好,CampusEnvironment 里有 3 条记录,时间是 2024 年 1 月 16 日、18 日、23 日。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------------ | --------------- | ------------------ |
|
||||||
|
| 窗体 | `Form1` | 查询校园灾情数据 |
|
||||||
|
| 标签 | `LblStartTime` | 开始日期 |
|
||||||
|
| 日期时间控件 | `StartTime` | 选开始日期 |
|
||||||
|
| 标签 | `LblEndTime` | 结束日期 |
|
||||||
|
| 日期时间控件 | `EndTime` | 选结束日期 |
|
||||||
|
| 按钮 | `btnCx` | 查询 |
|
||||||
|
| 分组框 | `groupBox4` | 历史记录 |
|
||||||
|
| 数据表格 | `dataGridView1` | 显示查到的记录 |
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 引用 `System.Data.SqlClient` 和 `System.IO.Ports`。
|
||||||
|
2. 声明全局变量:连接字符串 `sqlConnectString`、`SqlConnection mySqlConnection`、`SqlCommand mySqlCmd`、`SqlDataAdapter sqldata`、`DataSet ds`。
|
||||||
|
3. **btnCx 的 Click**:
|
||||||
|
- 创建连接对象并打开;
|
||||||
|
- 写 select 语句:查出 ID、temperature、humidity、light、addtime,列名起中文别名(编号、温度、湿度、光照度、时间),条件是 `addtime BETWEEN '开始' AND '结束'`,开始、结束分别拼进 `StartTime.Value.ToString()` 和 `EndTime.Value.ToString()`;
|
||||||
|
- 用这条命令创建 SqlDataAdapter,`ds = new DataSet();`,`Fill(ds)`;
|
||||||
|
- `dataGridView1.DataSource = ds.Tables[0].DefaultView;`,最后关闭连接。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **要先 new 一个 DataSet**:书上直接 `sqldata.Fill(ds)`,`ds` 还没有创建,运行时会报空引用错误。在 Fill 前面加一行 `ds = new DataSet();`。
|
||||||
|
|
||||||
|
> **连接字符串**:平台只看 `Initial Catalog=环境数据表`,服务器名、用户名和密码照书上写或写成 `Data Source=.` 都可以。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 先按上面准备好数据库(用初始数据最好对照)。
|
||||||
|
2. 运行后开始日期选 **2024 年 1 月 15 日**,结束日期选 **2024 年 1 月 20 日**,点"查询":表格里应有编号 1、2 两条(16 日、18 日),没有 23 日的那条。
|
||||||
|
3. 改成 1 月 21 日到 1 月 31 日再点"查询":表格里只剩编号 3(23 日)。
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务1 实现智慧教室",
|
"title": "项目7 任务1 智慧教室",
|
||||||
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库"
|
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
做一个"智慧教室"控制窗体:点"启用"后,程序每 2 秒读一次光照度、温度、湿度并显示出来,再按读数自动控制设备——光线暗就开灯、太亮就关灯;太热或太冷就开空调并调好温度,否则关空调。灯也可以点开关图片手动开关。
|
||||||
|
|
||||||
|
练习重点:滑动条(TrackBar)的 Minimum / Maximum / Value,图片框(PictureBox)显示项目资源里的图片,定时器 Tick 里的 if / else if 判断。详细步骤见书 p133–137。
|
||||||
|
|
||||||
|
## 先添加项目资源
|
||||||
|
|
||||||
|
开关图片用 `switch_on`(开)和 `switch_off`(关)。书上是"项目属性 → 资源 → 添加资源",平台上点代码上方的**项目资源**,在素材库里勾选这两张图(名字和书上一样,不能自己上传图片)。
|
||||||
|
|
||||||
|
加好后,图片框的**图片**属性可以直接选 `Resources.switch_off`;代码里写 `Properties.Resources.switch_on`。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------ | ---------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| 窗体 | `frm_control` | 智慧教室情景控制 |
|
||||||
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
|
| 标签 | `lbl_mode` | 智慧教室将开启(启用后变成"智慧教室已开启") |
|
||||||
|
| 按钮 | `btn_apply` | 启用(启用后变成"关闭") |
|
||||||
|
| 分组框 | `grp_input` | 采集量 |
|
||||||
|
| 下拉框 | `com_import` | 采集器的串口 |
|
||||||
|
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
|
||||||
|
| 分组框 | `grp_status` | 状态栏 |
|
||||||
|
| 图片框 | `pic_airconditioneStatus` | 空调开关状态,图片选 switch_off |
|
||||||
|
| 滑动条 | `trb_airconditione` | 模拟空调温度:最小值 18,最大值 32,值 18 |
|
||||||
|
| 图片框 | `pic_lightsStatus` | 灯光开关状态,图片选 switch_off;点它手动开关灯 |
|
||||||
|
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
|
||||||
|
| 定时器 | `timer` | 间隔 2000(2 秒) |
|
||||||
|
|
||||||
|
其余标签(串口、光照度、温度、湿度、空调、灯光、18℃、32℃)照书上的图摆好,名字不影响评分。
|
||||||
|
|
||||||
|
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||||
|
2. 声明全局变量:串口是否打开、灯是否打开、空调是否打开(三个 bool,都是 false),以及 `ADAM4017ResModel ResModel`。
|
||||||
|
3. **Load**:用 `SerialPort.GetPortNames()` 把串口号加进 `com_import`。
|
||||||
|
4. **btn_apply 的 Click**:
|
||||||
|
- 没打开时:用 `com_import.Text` 打开 `adam4017Helper` 和 `adam4150Helper`,按钮改成"关闭",`lbl_mode` 改成"智慧教室已开启",启动 `timer`;
|
||||||
|
- 已打开时:关闭两个采集器,停止 `timer`,文字改回"启用""智慧教室将开启"。
|
||||||
|
5. **timer 的 Tick**:
|
||||||
|
- `adam4017Helper.SetData()` 读出数据,把 lightValue、temperatureValue、humidityValue 填进三个文本框;
|
||||||
|
- 光照度(用 `Convert.ToInt16` 转成数字)**小于 400** 开灯:图片换成 switch_on,`lamp.OnOff(true)`,`adam4150Helper.OnOff(1, true)`,记下灯已打开;**大于 800** 关灯(反过来做一遍);
|
||||||
|
- 温度**高于 30℃**:空调图片换成 switch_on,`trb_airconditione.Value = 26`;**低于 5℃**:图片换成 switch_on,`Value = 25`;其他情况:图片换成 switch_off,`Value = 18`。
|
||||||
|
6. **pic_lightsStatus 的 Click**:灯开着就关(图片、灯泡、实训台照明灯一起关),关着就开。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **手动开关灯的判断写反了**:书上 pic_lightsStatus_Click 写的是 `if (!IsLampOpen) { 关灯 }`,这样灯关着时点了还是关。应该是 `if (IsLampOpen) { 关灯 } else { 开灯 }`。
|
||||||
|
|
||||||
|
> **滑动条要设最小值 18、最大值 32**:书上的表格没写。滑动条默认最大值是 10,代码里把 Value 设成 26 时程序会出错。
|
||||||
|
|
||||||
|
> 书上表格里温度文本框写的是 `txt_temperation`,代码里用的是 `txt_temperature`,请按代码写,自动评分按 `txt_temperature` 找。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的);这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以能行。
|
||||||
|
2. 在实训台上拖动光照、温度、湿度的滑块,2 秒内三个文本框应跟着变。
|
||||||
|
3. 把光照拖到 400 以下,窗体上的灯泡和实训台 ADAM-4150 的 **DO1 照明灯**应一起亮;拖到 800 以上应熄灭。
|
||||||
|
4. 把温度拖到 30℃ 以上,空调滑块应停在 26;拖到 5℃ 以下停在 25;其他温度回到 18。
|
||||||
|
5. 先把光照拖回 400–800 之间(光照在 800 以上时程序每 2 秒自动关一次灯,400 以下自动开灯,会盖过手动开关),再点灯光的开关图片,灯应该一下开、一下关。
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
- expect: { control: trb_curtain, value: 0, desc: 上课模式窗帘全开 }
|
- expect: { control: trb_curtain, value: 0, desc: 上课模式窗帘全开 }
|
||||||
- clock: '21:30:00'
|
- clock: '21:30:00'
|
||||||
- expect: { control: lbl_mode, contains: 回寝模式, within: 5s, desc: 21:30 是回寝模式 }
|
- expect: { control: lbl_mode, contains: 回寝模式, within: 5s, desc: 21:30 是回寝模式 }
|
||||||
|
- expect: { relay: 1, on: true, desc: 回寝模式打开灯光 }
|
||||||
- expect: { control: trb_curtain, value: 10, desc: 回寝模式关窗帘 }
|
- expect: { control: trb_curtain, value: 10, desc: 回寝模式关窗帘 }
|
||||||
- clock: '23:00:00'
|
- clock: '23:00:00'
|
||||||
- expect: { control: lbl_mode, contains: 熄灯模式, within: 5s, desc: 23:00 是熄灯模式 }
|
- expect: { control: lbl_mode, contains: 熄灯模式, within: 5s, desc: 23:00 是熄灯模式 }
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务2 实现智慧寝室",
|
"title": "项目7 任务2 智慧寝室",
|
||||||
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间"
|
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
做一个"智慧寝室"控制窗体:点"开启情景模式"后,程序每秒显示当前时间和光照度、温度、湿度,并按时间段自动切换四种情景模式,控制灯光、窗帘和广播:
|
||||||
|
|
||||||
|
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
|
||||||
|
| ----------- | -------- | ---- | -------------- | ------ |
|
||||||
|
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
|
||||||
|
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
|
||||||
|
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
|
||||||
|
| 其他时间 | 熄灯模式 | 关 | 10(全关) | 关 |
|
||||||
|
|
||||||
|
练习重点:DateTime 的比较,自己写方法(CheckMode、PlayMp3),用线程(Thread)播放声音,媒体播放器控件。详细步骤见书 p141–145。
|
||||||
|
|
||||||
|
## 先添加项目资源
|
||||||
|
|
||||||
|
点代码上方的**项目资源**,在素材库里勾选开关图片 `switch_on`、`switch_off` 和铃声 `ring`(就是书上的 ring.mp3,可以先试听)。名字和书上一样,不能自己上传。
|
||||||
|
|
||||||
|
广播图片框的**图片**属性选 `Resources.switch_off`。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ---------- | ---------------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| 窗体 | `frm_control` | 智慧寝室情景控制 |
|
||||||
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
|
| 文本框 | `txt_time` | 显示当前时间 |
|
||||||
|
| 标签 | `lbl_mode` | 显示现在是哪种模式(文字先空着) |
|
||||||
|
| 按钮 | `btn_apply` | 开启情景模式(开启后变成"停止情景模式") |
|
||||||
|
| 分组框 | `grp_input` | 采集量 |
|
||||||
|
| 下拉框 | `com_import` | 采集器的串口 |
|
||||||
|
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
|
||||||
|
| 分组框 | `grp_status` | 状态栏 |
|
||||||
|
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关(最小值 0、最大值 10 用默认的就行) |
|
||||||
|
| 图片框 | `pic_boardcastStatus` | 广播状态,图片选 switch_off |
|
||||||
|
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
|
||||||
|
| 定时器 | `timer` | 间隔 1000(1 秒) |
|
||||||
|
| 媒体播放器 | `axWindowsMediaPlayer1` | 播放铃声;可见设为 False |
|
||||||
|
|
||||||
|
其余标签(当前时间、串口、光照度、温度、湿度、窗帘、全开、全关、广播、灯光)照书上的图摆好,名字不影响评分。
|
||||||
|
|
||||||
|
媒体播放器在工具箱的"物联网设备"里,直接拖到窗体上就行,不用像书上小贴士那样去"选择工具箱项"添加 COM 组件。
|
||||||
|
|
||||||
|
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`、`using System.Threading;`。
|
||||||
|
2. 声明全局变量:串口、灯、广播是否打开(三个 bool),`ADAM4017ResModel ResModel`,`DateTime now`,以及四个时间点:起床 `DateTime.Today.AddHours(6.75)`、上课 `AddHours(7.5)`、回寝 `AddHours(21.0)`、熄灯 `AddHours(22.0)`。
|
||||||
|
3. **Load**:清空 `com_import`,把串口号加进去。
|
||||||
|
4. **btn_apply 的 Click**:没打开时打开 `adam4017Helper`、`adam4150Helper`,按钮改成"停止情景模式",启动 `timer`;打开着就反过来。
|
||||||
|
5. 写 **PlayMp3()** 方法:广播图片换成 switch_on,给媒体播放器的 `URL` 设铃声文件的路径(`AppDomain.CurrentDomain.BaseDirectory + "//Resources//ring.mp3"`),调用 `Ctlcontrols.play()`,`Thread.Sleep(4000)` 等 4 秒,图片换回 switch_off。
|
||||||
|
6. 写 **CheckMode()** 方法:用 `now` 和四个时间点比较,判断现在是哪种模式,`lbl_mode` 显示"智慧寝室正处于××模式",再按上面的表格控制:
|
||||||
|
- 开灯 / 关灯:`adam4150Helper.OnOff(1, ...)` 和 `lamp.OnOff(...)`,只在状态要变时才做;
|
||||||
|
- 放铃声:广播还没开时 `new Thread(PlayMp3).Start()`,让铃声在另一个线程里放,不卡住界面;关广播:图片换成 switch_off;
|
||||||
|
- 窗帘:设 `trb_curtain.Value`。
|
||||||
|
7. **timer 的 Tick**:`now = DateTime.Now`,`txt_time` 显示 `now.ToString("HH:mm:ss")`,读出数据填进三个文本框,最后调用 `CheckMode()`。
|
||||||
|
|
||||||
|
## 在平台上怎么用时钟和广播
|
||||||
|
|
||||||
|
- **时钟**在实训台上:填一个时刻点"拨到",程序里的 `DateTime.Now` 就从这个时刻往后走;还可以选 10×、60× 倍速,点"恢复真实时间"回到现在。不用等到早上 6:45 才能测试起床模式。
|
||||||
|
- 拨时间时**日期别动**:四个时间点是程序启动时按"今天"算好的,改了日期就对不上了。
|
||||||
|
- 程序放铃声时,实训台上的**广播**喇叭亮起,浏览器里能听到声音(电脑要开音量)。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后串口选 **COM2**(这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以书上在一个串口上打开两个模块也能用),点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
|
||||||
|
2. 在实训台的时钟上拨到 **06:50:00**:应显示起床模式,ADAM-4150 的 **DO1 照明灯**亮,窗帘滑块到 5,广播响一声铃。
|
||||||
|
3. 拨到 **08:00:00**:上课模式,灯灭,窗帘到 0。
|
||||||
|
4. 拨到 **21:30:00**:回寝模式,灯亮,窗帘到 10,广播响铃。
|
||||||
|
5. 拨到 **23:00:00**:熄灯模式,灯灭,窗帘到 10。
|
||||||
|
6. 也可以拨到 6:44:50,看程序到 6:45 时自己切进起床模式。
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务3 实现智慧会议室",
|
"title": "项目7 任务3 智慧会议室",
|
||||||
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间"
|
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
做一个"智慧会议室"预约窗体:程序一启动就每秒显示当前时间和光照度、温度、湿度、气压、空气质量。填好时、分、秒,选一种模式(会议、讨论、展示),点"预约";到了预约时间,程序自动开启这种模式,控制灯光、窗帘和主、客话筒:
|
||||||
|
|
||||||
|
| 模式 | 灯光 | 窗帘(滑动条) | 主话筒 | 客话筒 |
|
||||||
|
| ---- | ---- | -------------- | ------ | ------ |
|
||||||
|
| 会议 | 开 | 0(全开) | 开 | 关 |
|
||||||
|
| 讨论 | 开 | 0(全开) | 开 | 开 |
|
||||||
|
| 展示 | 开 | 10(全关) | 开 | 关 |
|
||||||
|
|
||||||
|
勾选"拍照记录"时,预约时间到了还要打开网络摄像头,每 5 分钟存一张照片。点"退出"关掉所有设备。
|
||||||
|
|
||||||
|
练习重点:DateTime 的计算和比较(AddHours、AddMinutes、AddSeconds),switch 语句,用 `+=` 订阅事件。详细步骤见书 p148–153。
|
||||||
|
|
||||||
|
## 先添加项目资源
|
||||||
|
|
||||||
|
点代码上方的**项目资源**,在素材库里勾选 `switch_on`、`switch_off` 两张开关图片(名字和书上一样,不能自己上传)。三个开关图片框的**图片**属性都选 `Resources.switch_off`。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------ | ---------------------------------------------- | ---------------------------------------- |
|
||||||
|
| 窗体 | `frm_control` | 智慧会议室情景模式 |
|
||||||
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
|
| 标签 | `lbl_time` | 当前时间:00:00:00(运行时显示当前时间) |
|
||||||
|
| 标签 | `lbl_mode` | 智慧会议室未开启情景模式 |
|
||||||
|
| 复选框 | `chk_record` | 拍照记录 |
|
||||||
|
| 文本框 | `txt_hour`、`txt_minute`、`txt_second` | 预约的时、分、秒,默认 13、20、00 |
|
||||||
|
| 标签 | `lbl_hour`、`lbl_minute`、`lbl_second` | 时、分、秒将会有场 |
|
||||||
|
| 下拉框 | `com_mode` | 选择模式,默认文字"会议" |
|
||||||
|
| 按钮 | `btn_reserve` | 预约 |
|
||||||
|
| 按钮 | `btn_exit` | 退出 |
|
||||||
|
| 分组框 | `grp_input` | 采集量 |
|
||||||
|
| 下拉框 | `com_import` | 采集器的串口 |
|
||||||
|
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
|
||||||
|
| 文本框 | `txt_airPressure`、`txt_airQuality` | 气压、空气质量 |
|
||||||
|
| 文本框 | `txt_camera` | 摄像头 IP 地址,默认 192.168.14.131 |
|
||||||
|
| 分组框 | `grp_status` | 状态栏 |
|
||||||
|
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关 |
|
||||||
|
| 图片框 | `pic_lightsStatus` | 灯光开关,点它手动开关灯 |
|
||||||
|
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
|
||||||
|
| 图片框 | `pic_mainMicrophone`、`pic_subMicrophone` | 主、客话筒开关,点它手动开关 |
|
||||||
|
| 定时器 | `timer` | 间隔 1000(1 秒) |
|
||||||
|
|
||||||
|
其余标签(串口、光照度、温度、湿度、气压、空气质量、摄像头IP地址、窗帘、全开、全关、灯光、话筒、主、客)照书上的图摆好,名字不影响评分。书上表格里还有 CO₂ 浓度一行,但代码里没有用到,可以不做。
|
||||||
|
|
||||||
|
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 代码开头加上 `using System.IO;`、`using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||||
|
2. 声明全局变量:灯、主话筒、客话筒是否打开,模式是否已经开启(`IsInitialized`),`ADAM4017ResModel ResModel`,`DateTime now`,预约时间 `reserve`(先设成 `DateTime.Now.AddDays(1)`,也就是明天,免得一启动就"到时间"),摄像头读到的图片 `Image ReadImage = null`。
|
||||||
|
3. **Load**:
|
||||||
|
- 清空两个下拉框,把串口号加进 `com_import`,`com_mode` 加入"会议""讨论""展示";
|
||||||
|
- 启动 `timer`;
|
||||||
|
- 程序目录下没有 `Camera/` 文件夹就建一个(`Directory.Exists`、`Directory.CreateDirectory`);
|
||||||
|
- 用 `+=` 给 `ipCameraHelper.ImageRead` 绑定一个方法,在方法里把 `e.img` 存进 `ReadImage`。
|
||||||
|
4. **btn_reserve 的 Click**:`reserve = DateTime.Today`,再依次加上三个文本框里的时、分、秒(`Convert.ToInt16` 转成数字)。
|
||||||
|
5. 写 **ChooseMode()** 方法:`lbl_mode` 显示"智慧会议室已开启××模式",用 `switch (com_mode.Text)` 按上面的表格设置灯(图片、`lamp`、`adam4150Helper.OnOff(1, ...)`)、`trb_curtain.Value` 和两个话筒的图片。
|
||||||
|
6. **timer 的 Tick**:
|
||||||
|
- 用 `com_import.Text` 打开 `adam4017Helper`、`adam4150Helper`;
|
||||||
|
- `now = DateTime.Now`,`lbl_time` 显示 `HH:mm:ss`,读出数据填进五个文本框;
|
||||||
|
- `now >= reserve` 时:模式还没开启就调用 `ChooseMode()`,勾了"拍照记录"就打开并启动摄像头(`ipCameraHelper.Open(txt_camera.Text, "admin", "")`、`Start()`),然后记下已开启;分钟能被 5 整除且秒是 0 时,把 `ReadImage` 存到 `Camera/` 文件夹,文件名用当前时间。
|
||||||
|
7. **三个开关图片框的 Click**:灯、主话筒、客话筒各自开着就关、关着就开(灯还要控制 `lamp` 和实训台的照明灯)。
|
||||||
|
8. **btn_exit 的 Click**:停止 `timer`,`reserve` 改回明天,"拍照记录"设为不可用,关灯、关两个话筒,`trb_curtain.Value = 0`,`IsInitialized` 改回 false,关闭两个采集器和摄像头。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **书上 Load 里没有把串口号加进 `com_import`**(注释写了,代码漏了),下拉框是空的,选不了串口。要补上 `foreach (string item in SerialPort.GetPortNames())` 那几行。
|
||||||
|
|
||||||
|
> 书上表格里温度文本框写的是 `txt_temperation`,代码里用的是 `txt_temperature`,请按代码写,自动评分按 `txt_temperature` 找。
|
||||||
|
|
||||||
|
## 在平台上怎么用时钟
|
||||||
|
|
||||||
|
- 实训台上的**时钟**可以拨到任意时刻:填好时刻点"拨到",程序里的 `DateTime.Now` 就从这个时刻往后走,点"恢复真实时间"回到现在。不用真的等到下午 1 点 20 分。
|
||||||
|
- 拨时间时**日期别动**:预约时间是按"今天"算的。
|
||||||
|
- 摄像头拍的照片会出现在运行区下方的"程序保存的图片"里,只在这次运行中保留。"每 5 分钟拍照"这一项自动评分不检查,由老师看代码评阅。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,书上在一个串口上打开两个模块也能用。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。刚运行还没选串口时,实训台会提示串口没选(书上在定时器里每秒打开一次串口),选了 COM2 就消失。
|
||||||
|
2. 先点"实训台复位"(上次运行开着的灯会一直亮着)。模式选"讨论",时分秒填 13、20、00,然后把时钟拨到 **13:19:40**,马上点"预约"。
|
||||||
|
3. 预约时间没到时,ADAM-4150 的 **DO1 照明灯**不亮;到 13:20:00 后,应显示"智慧会议室已开启讨论模式",照明灯亮,窗帘滑块到 0,主、客话筒都是开。
|
||||||
|
4. 点"退出",灯、话筒都关掉。想再试一次,先点"实训台复位",再重新运行程序。
|
||||||
|
|
||||||
|
> 模式开启后第一次点灯光或话筒的开关图片没有反应,第二次才变:书上的 ChooseMode 只换了开关图片,没有改记录开关状态的变量,参考工程也是这样,不是你写错了。
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目8 任务1 制作管理平台的主界面",
|
"title": "项目8 任务1 平台主界面",
|
||||||
"source": "教材书 p157–159,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致)"
|
"source": "教材书 p157–159,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致)"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
开始做项目 8 的"智慧校园管理平台":先做主界面。主界面没有标题栏,上面一条蓝色标题栏放"智慧校园"标题图,下面是校园背景图,背景上有"校园环境""校园安防""退出"三个图标;点"退出"关闭程序。后面四个任务都在这个程序上接着做。
|
||||||
|
|
||||||
|
练习重点:无边框窗体(FormBorderStyle)、面板(Panel)的停靠(Dock)、背景图片、图片框(PictureBox)和项目资源里的图片。详细步骤见书 p157–159。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------ | ----------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| 窗体 | `Main` | 主界面;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
||||||
|
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 53, 147, 237 |
|
||||||
|
| 图片框 | `pictureBox1` | 放在 panel1 上,图片 `ico_title`("智慧校园"标题),SizeMode 设为 StretchImage |
|
||||||
|
| 面板 | `panel2` | 停靠 Fill,背景图片 `bg_home`(校园),背景排列 Stretch |
|
||||||
|
| 图片框 | `pic_environment` | "校园环境"入口,图片 `icon_02` |
|
||||||
|
| 图片框 | `pic_security` | "校园安防"入口,图片 `icon_03` |
|
||||||
|
| 图片框 | `pic_exit` | "退出",图片 `icon_04` |
|
||||||
|
|
||||||
|
> 三个图标放在 panel2 的背景图上,背景色都设成 **Transparent**,否则图标四周会露出一块灰色。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 点代码上方的**项目资源**,勾选 `ico_title`、`bg_home`、`icon_02`、`icon_03`、`icon_04` 五张图片。
|
||||||
|
2. 设置窗体:无边框、屏幕居中。
|
||||||
|
3. 拖两个面板:panel1 停靠 Top 当标题栏,里面放标题图;panel2 停靠 Fill,设背景图。
|
||||||
|
4. 在 panel2 上放三个图片框,按上表设名字、图片和透明背景。
|
||||||
|
5. 双击 `pic_exit`,在它的 Click 里写 `this.Close();`。窗体没有标题栏和关闭按钮,只能靠这个图标退出程序。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **图片从项目资源里选**:书上第 2 步把配套资料里的 Resources 文件夹复制到工程里。平台上点**项目资源**,从素材库里勾选同名图片(平台自己画的,名字和尺寸与书上一致),设计器里"图片""背景图片"属性就能选到它们。
|
||||||
|
|
||||||
|
> **窗体名 Main**:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `Main`,类名、文件名和 Program.cs 会跟着改。任务 2 的自动评分要找到 Main 窗体。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后,运行区显示没有标题栏的主界面:蓝色标题栏上是"智慧校园",下面是校园背景和三个图标(窗体比运行区大时会按比例缩小,点**放大**看得更清楚)。
|
||||||
|
2. 点"退出"图标,程序应结束。
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目8 任务2 制作\"校园环境\"界面",
|
"title": "项目8 任务2 校园环境:搭界面",
|
||||||
"source": "教材书 p159–164,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);本任务只做界面,为了能看效果,加上了主界面\"校园环境\"打开窗体和\"<返回主窗体\"关闭窗体(书上在任务 3 第 10、22 步)"
|
"source": "教材书 p159–164,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);本任务只做界面,为了能看效果,加上了主界面\"校园环境\"打开窗体和\"<返回主窗体\"关闭窗体(书上在任务 3 第 10、22 步)"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
在任务 1 的程序里新建"校园环境"窗体 `Environment`,把界面搭好:顶部标题栏和"<返回主窗体",中间是路灯场景的背景图,底部一栏显示火焰、烟雾、光照度、温度、湿度,右边是"路灯控制"和"LED推送"两组控件,左下方是显示一周温度的折线图。这个任务**只做界面**,采集数据、控制路灯、推送 LED 在任务 3 做。
|
||||||
|
|
||||||
|
练习重点:多窗体、面板的多层停靠(Top / Fill / Bottom / Right)、透明背景、复选框、图表(Chart)的系列和图例。详细步骤见书 p159–164。
|
||||||
|
|
||||||
|
> 接着任务 1 的程序做。为了能看到效果,这次先让主界面的"校园环境"能打开窗体、"<返回主窗体"能关闭窗体(书上放在后面的任务里)。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| 窗体 | `Environment` | 校园环境;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
||||||
|
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 100, 116, 133,放标题图 `ico_title` |
|
||||||
|
| 面板 | `panel2` | 停靠 Fill,下面的面板都放在它里面 |
|
||||||
|
| 面板 | `panel3` | panel2 里停靠 Top,背景色 229, 229, 229 |
|
||||||
|
| 标签 | `lb_return` | < 返回主窗体(放在 panel3 上) |
|
||||||
|
| 面板 | `panel_bg` | panel3 下面停靠 Fill,背景图片 `day_02`(路灯没亮的场景),背景排列 Stretch |
|
||||||
|
| 面板 | `panel5` | panel_bg 里停靠 Bottom:环境数据栏 |
|
||||||
|
| 文本框 | `txt_fire`、`txt_smoke`、`txt_light`、`txt_temperature`、`txt_humidity` | 火焰、烟雾、光照、温度、湿度(左边各配一个标签) |
|
||||||
|
| 按钮 | `btn_dataAnalyse` | 数据分析 |
|
||||||
|
| 按钮 | `btn_ledEnvironment1` | 推送天气信息(环境数据栏里的那个) |
|
||||||
|
| 面板 | `panel_form` | panel_bg 里停靠 Right,背景色 Transparent,背景图片 `bg_from2` |
|
||||||
|
| 分组框 | `groupBox2` | 路灯控制 |
|
||||||
|
| 复选框 | `ch_autoLamp` | 自动控制 |
|
||||||
|
| 文本框 | `txt_autoLight` | 设定开灯光照值(后面配标签"lx") |
|
||||||
|
| 图片框 | `pic_lampOpen` | 灯光控制的开关:背景色 Transparent,背景图片 `switch_off` |
|
||||||
|
| 分组框 | `groupBox3` | LED推送 |
|
||||||
|
| 文本框 | `txt_ledMsg` | LED信息(Multiline 设为 True) |
|
||||||
|
| 复选框 | `ch_autoLedFire` | 自动推送火情 |
|
||||||
|
| 按钮 | `btn_ledSendMsg` | 推送LED信息 |
|
||||||
|
| 按钮 | `btn_ledEnvironment2` | 推送天气信息(LED推送组里的那个) |
|
||||||
|
| 图表 | `chart1` | 放在 panel_bg 左下方,显示最高温度和平均温度 |
|
||||||
|
|
||||||
|
> 书上表 8-6、表 8-7 把四个按钮的"名称"写成了"数据分析""推送天气信息""推送LED信息",那是按钮上的文字。名称按上表写,任务 3 的代码和自动评分都用这些名字。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. **项目资源**里再勾选 `day_02`、`bg_from2`、`switch_off`(任务 3 还要用 `day_01`、`switch_on`,可以一起勾上)。
|
||||||
|
2. 点 **+ 新建窗体**,建 `Environment`,设成无边框、屏幕居中,调到合适的大小。
|
||||||
|
3. 按"标题栏 → panel2 → panel3 → panel_bg"的顺序拖面板,设好停靠。先放的面板先占地方,放错了可以在属性里改停靠。
|
||||||
|
4. 在 panel_bg 上依次放底部的 panel5(环境数据栏)和右边的 panel_form,再把两组控件放进 panel_form 里的两个分组框。书上要求 panel_form 里的其他控件背景色也设成 Transparent,让背景图透出来。
|
||||||
|
5. 拖一个**图表**到 panel_bg 上,在属性窗口点**系列和图例**:
|
||||||
|
- 图例两个:`MaxLegend`、`AVGLegend`;
|
||||||
|
- 系列两个:`MaxSeries`(图表类型 Line,图例 MaxLegend,图例文字"最大值",X 值字段 `_Time`,Y 值字段 `MaxTemperature`)和 `AVGSeries`(Line,AVGLegend,"平均值",`_Time`,`AVGTemperature`)。
|
||||||
|
6. 主窗体 `pic_environment` 的 Click:`new Environment().Show();`。
|
||||||
|
7. `lb_return` 的 Click:`this.Close();`(只关掉这个窗体,主窗体还在)。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **背景图片叫 `bg_from2`**:书上写的是 `bg_form2.png`,配套资料里的文件名是 `bg_from2`,素材库也用这个名字。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后在主界面点"校园环境",打开 Environment 窗体:背景是路灯场景,底部有五个文本框和"数据分析""推送天气信息",右边有"路灯控制""LED推送"两组,左下方有图表(现在还没有数据)。
|
||||||
|
2. 点"< 返回主窗体",Environment 关闭,主界面还在。
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
||||||
-- EnvironmentData 预置了 7 天的历史数据,"数据分析"的图表一打开就有内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
-- EnvironmentData 预置了今天之前 7 天的历史数据(按建库那天往前推),"数据分析"的图表一打开就有最近一周的内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
||||||
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
||||||
CREATE DATABASE WisdomCampus
|
CREATE DATABASE WisdomCampus
|
||||||
GO
|
GO
|
||||||
@@ -10,45 +10,45 @@ CREATE TABLE EnvironmentData (Id int IDENTITY(1,1) PRIMARY KEY, Fire bit NOT NUL
|
|||||||
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
||||||
GO
|
GO
|
||||||
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, '2026-10-14 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, DATEADD(hour, 8, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, '2026-10-14 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, DATEADD(hour, 10, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, '2026-10-14 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, DATEADD(hour, 12, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, '2026-10-14 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, DATEADD(hour, 14, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, '2026-10-14 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, DATEADD(hour, 16, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, '2026-10-14 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, DATEADD(hour, 18, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, '2026-10-15 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, DATEADD(hour, 8, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, '2026-10-15 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, DATEADD(hour, 10, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, '2026-10-15 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, DATEADD(hour, 12, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, '2026-10-15 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, DATEADD(hour, 14, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, '2026-10-15 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, DATEADD(hour, 16, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, '2026-10-15 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, DATEADD(hour, 18, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, '2026-10-16 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, DATEADD(hour, 8, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, '2026-10-16 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, DATEADD(hour, 10, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, '2026-10-16 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, DATEADD(hour, 12, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, '2026-10-16 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, DATEADD(hour, 14, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, '2026-10-16 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, DATEADD(hour, 16, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, '2026-10-16 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, DATEADD(hour, 18, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, '2026-10-17 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, DATEADD(hour, 8, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, '2026-10-17 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, DATEADD(hour, 10, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, '2026-10-17 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, DATEADD(hour, 12, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, '2026-10-17 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, DATEADD(hour, 14, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, '2026-10-17 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, DATEADD(hour, 16, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, '2026-10-17 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, DATEADD(hour, 18, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, '2026-10-18 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, DATEADD(hour, 8, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, '2026-10-18 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, DATEADD(hour, 10, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, '2026-10-18 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, DATEADD(hour, 12, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, '2026-10-18 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, DATEADD(hour, 14, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, '2026-10-18 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, DATEADD(hour, 16, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, '2026-10-18 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, DATEADD(hour, 18, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, '2026-10-19 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, DATEADD(hour, 8, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, '2026-10-19 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, DATEADD(hour, 10, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, '2026-10-19 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, DATEADD(hour, 12, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, '2026-10-19 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, DATEADD(hour, 14, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, '2026-10-19 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, DATEADD(hour, 16, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, '2026-10-19 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, DATEADD(hour, 18, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, '2026-10-20 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, DATEADD(hour, 8, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, '2026-10-20 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, DATEADD(hour, 10, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, '2026-10-20 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, DATEADD(hour, 12, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, '2026-10-20 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, DATEADD(hour, 14, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, '2026-10-20 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, DATEADD(hour, 16, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, '2026-10-20 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, DATEADD(hour, 18, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目8 任务3 实现\"校园环境\"数据采集及数据分析功能",
|
"title": "项目8 任务3 校园环境:采集和分析",
|
||||||
"source": "教材书 p164–174,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);按书补全了参考工程里空着的\"推送LED信息\"\"自动控制\",给\"自动推送火情\"复选框加上 CheckedChanged(书上第 23 步把控件名写成了 ch_autoLamp);数据库 WisdomCampus 是平台按教材的表结构建的,预置 7 天环境数据"
|
"source": "教材书 p164–174,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);按书补全了参考工程里空着的\"推送LED信息\"\"自动控制\",给\"自动推送火情\"复选框加上 CheckedChanged(书上第 23 步把控件名写成了 ch_autoLamp);数据库 WisdomCampus 是平台按教材的表结构建的,预置 7 天环境数据"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
让任务 2 做好的"校园环境"界面动起来:打开窗体后,后台线程每隔 5 秒多采集一次环境数据,显示在底部的文本框里并存进数据库;路灯可以点开关手动控制,也可以按光照度自动开关,开灯时背景换成亮灯的场景;能把天气信息、自己输入的文字推送到 LED 屏,勾选"自动推送火情"后有火情自动推送;点"数据分析"用折线图显示最近 7 天每天的最高温度和平均温度。
|
||||||
|
|
||||||
|
练习重点:自己写类(AppSet、SQLHelper、SQLBLL)、后台线程(Thread)和 SynchronizationContext 回到界面线程更新控件、参数化的 SQL 语句、图表绑定数据(DataSource + DataBind)、复选框的 CheckedChanged 事件。详细步骤见书 p164–174。
|
||||||
|
|
||||||
|
## 先准备数据库
|
||||||
|
|
||||||
|
这个任务用 **WisdomCampus** 库(书上配套资料里附带的库):EnvironmentData 表存环境数据,SecurityData 表存报警图片(任务 4、5 用),Users 表存用户。平台已经按书上的表结构建好,并预置了 7 天的环境数据,"数据分析"一打开就有图。
|
||||||
|
|
||||||
|
第一次运行这个实验时,平台会自动建好这个库;数据弄乱了,到右上角的**数据库**页面点**恢复实验初始数据**,选这个实验。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
界面在任务 2 已经做好,这次要写代码的控件:
|
||||||
|
|
||||||
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| -------- | ---------------------------------------------- | ------------------------------------------ |
|
||||||
|
| 主界面 | `Main` | Load 里打开所有设备的串口 |
|
||||||
|
| 主界面 | `pic_environment` | "校园环境":打开 Environment 窗体 |
|
||||||
|
| 校园环境 | `Environment` | Load 里启动采集线程,FormClosed 里停止线程 |
|
||||||
|
| 校园环境 | `txt_fire`、`txt_smoke` | 显示"发生火情"/"发生烟情"或"正常" |
|
||||||
|
| 校园环境 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照、温度、湿度 |
|
||||||
|
| 校园环境 | `btn_dataAnalyse` | 数据分析 / 关闭图表 |
|
||||||
|
| 校园环境 | `chart1` | 最近 7 天的最高温度、平均温度 |
|
||||||
|
| 校园环境 | `btn_ledEnvironment1`、`btn_ledEnvironment2` | 推送天气信息(两个按钮做同一件事) |
|
||||||
|
| 校园环境 | `pic_lampOpen` | 灯光控制开关:点一下开灯,再点关灯 |
|
||||||
|
| 校园环境 | `ch_autoLamp` | 自动控制路灯 |
|
||||||
|
| 校园环境 | `txt_autoLight` | 设定开灯光照值:光照低于它就开灯 |
|
||||||
|
| 校园环境 | `txt_ledMsg` | 要推送的 LED 信息 |
|
||||||
|
| 校园环境 | `btn_ledSendMsg` | 推送LED信息 |
|
||||||
|
| 校园环境 | `ch_autoLedFire` | 自动推送火情 |
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 点 **+ 新建类**,建 `AppSet`:四个串口(ADAM-4017+ 是 COM2、ADAM-4150 是 COM3、ZigBee 是 COM4、LED 屏是 COM5)、摄像机的 IP / 用户名 / 密码(任务 4 用)、数据库连接字符串,都写成 `public static string`。
|
||||||
|
2. 再建 `SQLHelpers/SQLHelper` 和 `SQLHelpers/SQLBLL` 两个类(带文件夹,命名空间是 `智慧校园.SQLHelpers`):
|
||||||
|
- SQLHelper:`getData` 执行查询返回 DataSet,`Exec` 执行增删改返回影响的行数(出错返回 −1),`SaveData` 用 SqlCommandBuilder 保存 DataTable;
|
||||||
|
- SQLBLL:`EnvironmentUpData` 把一次采集的数据 insert 进 EnvironmentData,`GetEnvironmentGraphData` 按天分组查出最近 7 天的最高温度和平均温度,`SecurityUpData`、`GetSecurityData` 存取报警图片(任务 4、5 用);
|
||||||
|
- 在 SQLBLL 文件里再写一个 `SQLRes` 类(Msg、Result 两个属性)当返回结果。
|
||||||
|
3. **Main_Load**:在 try 里依次打开 ADAM-4017+、ADAM-4150、ZigBee、LED 屏的串口(用 AppSet 里的串口号)。
|
||||||
|
4. **Environment_Load**:隐藏 chart1,创建线程运行 `Tick` 方法,记下 `WindowsFormsSynchronizationContext.Current`,启动线程。
|
||||||
|
5. **Tick()**:`while (true)` 循环,每次:
|
||||||
|
- 读 ADAM-4017+ 和 ADAM-4150 的数据;
|
||||||
|
- 用 `_main.Send(...)` 回到界面线程,把火焰、烟雾、光照、温度、湿度显示到文本框;
|
||||||
|
- 勾了自动控制时,光照低于设定值就 `LampOnOff(1, true)`,否则 `LampOnOff(1, false)`;
|
||||||
|
- 勾了自动推送火情时,火焰和烟雾都有推送"发生严重火灾",只有火焰推送"发现火情",只有烟雾推送"发现烟情";
|
||||||
|
- 调用 `SQLBLL.EnvironmentUpData(...)` 存数据,`Thread.Sleep(5000)`。
|
||||||
|
6. **LampOnOff(int RelayNum, bool IsOpen)**:调用 `zigBeeHelper.OnOff` 开关路灯,再用 `_main.Send` 换图片:开灯时开关换成 `switch_on`、背景换成 `day_01`,关灯时换回 `switch_off`、`day_02`。
|
||||||
|
7. **Environment_FormClosed**:停止采集线程。
|
||||||
|
8. **btn_dataAnalyse 的 Click**:文字是"数据分析"时,把 `GetEnvironmentGraphData()` 的结果给 `chart1.DataSource`,`DataBind()`,显示图表,文字改成"关闭图表";反过来隐藏图表、改回"数据分析"。
|
||||||
|
9. **两个"推送天气信息"按钮**:推送"当前温度:…℃ 湿度:…%"。
|
||||||
|
10. **pic_lampOpen 的 Click**:按 `IsLampOpen` 开灯或关灯,然后把 `IsLampOpen` 取反。
|
||||||
|
11. **btn_ledSendMsg 的 Click**:推送 `txt_ledMsg` 里的文字。
|
||||||
|
12. **txt_autoLight 的 TextChanged**:用 `double.TryParse` 把输入的数存进 `setLight`。
|
||||||
|
13. **ch_autoLamp 的 CheckedChanged**:`IsAuto_Lamp = ch_autoLamp.Checked;`
|
||||||
|
14. **ch_autoLedFire 的 CheckedChanged**:`IsAuto_FireMsg = ch_autoLedFire.Checked;`
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **第 23 步的控件名写错了**:书上说在 `ch_autoLamp` 的 CheckedChanged 里写 `IsAuto_FireMsg = ch_autoLedFire.Checked;`,应该是 **`ch_autoLedFire`** 的 CheckedChanged(双击"自动推送火情"复选框生成)。写到 ch_autoLamp 里,"自动推送火情"就不起作用。
|
||||||
|
|
||||||
|
> **连接字符串**:平台只看 `Initial Catalog=WisdomCampus`,服务器名、用户名和密码照书上写就行。
|
||||||
|
|
||||||
|
> 书上 `GetSecurityData` 最后一行印成了 `eturn ImgDictionary;`,要写成 `return`。
|
||||||
|
|
||||||
|
> 书上关窗体时用的 `thread.Suspend()` 在新版 .NET 里已经不能用,平台做了兼容,照书上写就行。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后点"校园环境"。在实训台上拖动温度、湿度、光照滑块,8 秒内文本框里的数应跟着变;点火焰、烟雾,"火焰""烟雾"应显示"发生火情""发生烟情"。
|
||||||
|
2. 点灯光控制的开关:实训台的**照明灯 1** 亮起,开关和背景换成开灯的图片;再点一次全部换回来。
|
||||||
|
3. 在"设定开灯光照值"里输入 400,勾选"自动控制"。把光照拖到 200,几秒内路灯自动亮;拖到 800,路灯自动熄灭。
|
||||||
|
4. 点"推送天气信息",实训台的 LED 屏显示"当前温度:26℃ 湿度:50%"这样的文字;在"LED信息"里输入文字,点"推送LED信息",LED 屏显示这段文字。
|
||||||
|
5. 勾选"自动推送火情",点实训台的火焰,LED 屏显示"发现火情";再点烟雾,显示"发生严重火灾"。
|
||||||
|
6. 点"数据分析",出现两条折线(最大值、平均值),按钮变成"关闭图表";再点一次图表隐藏。到**数据库**页面查询 EnvironmentData 表,能看到程序刚存进去的数据。
|
||||||
@@ -5,15 +5,15 @@
|
|||||||
- click: pic_security
|
- click: pic_security
|
||||||
- expect: { window: Security, desc: 点"校园安防"打开 Security 窗体 }
|
- expect: { window: Security, desc: 点"校园安防"打开 Security 窗体 }
|
||||||
- expect: { camera: on }
|
- expect: { camera: on }
|
||||||
- expect: { control: pic_video, image: true, within: 3s, desc: 显示摄像机拍到的图像 }
|
- expect: { control: pic_video, image: true, desc: 显示摄像机拍到的图像 }
|
||||||
|
|
||||||
- title: 人体入侵有文字提示
|
- title: 人体入侵有文字提示
|
||||||
steps:
|
steps:
|
||||||
- click: pic_security
|
- click: pic_security
|
||||||
- board: { infrared: true }
|
- board: { infrared: true }
|
||||||
- expect: { control: lb_infrared, text: 发现入侵, within: 3s, desc: 红外对射被遮挡时显示"发现入侵" }
|
- expect: { control: lb_infrared, text: 发现入侵, desc: 红外对射被遮挡时显示"发现入侵" }
|
||||||
- board: { infrared: false }
|
- board: { infrared: false }
|
||||||
- expect: { control: lb_infrared, text: 正常, within: 3s }
|
- expect: { control: lb_infrared, text: 正常 }
|
||||||
|
|
||||||
- title: 摄像机可以调整方向
|
- title: 摄像机可以调整方向
|
||||||
steps:
|
steps:
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
- title: 摄像机能拍照并保存图片
|
- title: 摄像机能拍照并保存图片
|
||||||
steps:
|
steps:
|
||||||
- click: pic_security
|
- click: pic_security
|
||||||
- expect: { control: pic_video, image: true, within: 3s }
|
- expect: { control: pic_video, image: true }
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
- expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 }
|
- expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
||||||
-- EnvironmentData 预置了 7 天的历史数据,"数据分析"的图表一打开就有内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
-- EnvironmentData 预置了今天之前 7 天的历史数据(按建库那天往前推),"数据分析"的图表一打开就有最近一周的内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
||||||
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
||||||
CREATE DATABASE WisdomCampus
|
CREATE DATABASE WisdomCampus
|
||||||
GO
|
GO
|
||||||
@@ -10,45 +10,45 @@ CREATE TABLE EnvironmentData (Id int IDENTITY(1,1) PRIMARY KEY, Fire bit NOT NUL
|
|||||||
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
||||||
GO
|
GO
|
||||||
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, '2026-10-14 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, DATEADD(hour, 8, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, '2026-10-14 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, DATEADD(hour, 10, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, '2026-10-14 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, DATEADD(hour, 12, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, '2026-10-14 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, DATEADD(hour, 14, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, '2026-10-14 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, DATEADD(hour, 16, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, '2026-10-14 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, DATEADD(hour, 18, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, '2026-10-15 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, DATEADD(hour, 8, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, '2026-10-15 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, DATEADD(hour, 10, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, '2026-10-15 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, DATEADD(hour, 12, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, '2026-10-15 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, DATEADD(hour, 14, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, '2026-10-15 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, DATEADD(hour, 16, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, '2026-10-15 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, DATEADD(hour, 18, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, '2026-10-16 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, DATEADD(hour, 8, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, '2026-10-16 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, DATEADD(hour, 10, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, '2026-10-16 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, DATEADD(hour, 12, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, '2026-10-16 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, DATEADD(hour, 14, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, '2026-10-16 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, DATEADD(hour, 16, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, '2026-10-16 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, DATEADD(hour, 18, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, '2026-10-17 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, DATEADD(hour, 8, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, '2026-10-17 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, DATEADD(hour, 10, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, '2026-10-17 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, DATEADD(hour, 12, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, '2026-10-17 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, DATEADD(hour, 14, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, '2026-10-17 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, DATEADD(hour, 16, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, '2026-10-17 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, DATEADD(hour, 18, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, '2026-10-18 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, DATEADD(hour, 8, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, '2026-10-18 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, DATEADD(hour, 10, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, '2026-10-18 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, DATEADD(hour, 12, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, '2026-10-18 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, DATEADD(hour, 14, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, '2026-10-18 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, DATEADD(hour, 16, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, '2026-10-18 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, DATEADD(hour, 18, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, '2026-10-19 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, DATEADD(hour, 8, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, '2026-10-19 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, DATEADD(hour, 10, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, '2026-10-19 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, DATEADD(hour, 12, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, '2026-10-19 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, DATEADD(hour, 14, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, '2026-10-19 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, DATEADD(hour, 16, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, '2026-10-19 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, DATEADD(hour, 18, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, '2026-10-20 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, DATEADD(hour, 8, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, '2026-10-20 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, DATEADD(hour, 10, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, '2026-10-20 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, DATEADD(hour, 12, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, '2026-10-20 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, DATEADD(hour, 14, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, '2026-10-20 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, DATEADD(hour, 16, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, '2026-10-20 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, DATEADD(hour, 18, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目8 任务4 实现\"校园安防\"功能模块",
|
"title": "项目8 任务4 校园安防:监控和入侵",
|
||||||
"source": "教材书 p174–180,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);\"查看历史图像\"在任务 5 做"
|
"source": "教材书 p174–180,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);\"查看历史图像\"在任务 5 做"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
在主界面上接通"校园安防":新建 `Security` 窗体,打开时连上网络摄像机显示实时画面,后台线程每秒检查一次红外对射,有人闯入时显示"发现入侵"并把当前画面存进数据库;四个方向键控制摄像机转动;"拍照"把画面存成图片文件、同时存进数据库;返回主窗体时关掉摄像机。"查看历史图像"按钮先放好,任务 5 再做。
|
||||||
|
|
||||||
|
练习重点:摄像机的 ImageRead 事件、后台线程、图片框的鼠标按下 / 松开事件(MouseDown / MouseUp)、换背景图片、保存图片文件。详细步骤见书 p174–180。
|
||||||
|
|
||||||
|
## 先准备数据库
|
||||||
|
|
||||||
|
用任务 3 的 **WisdomCampus** 库,入侵和拍照的图片存进 **SecurityData** 表(报警时间 EvenTime、图片 EvenImage),存的方法是任务 3 写好的 `SQLBLL.SecurityUpData`。还没有这个库或数据弄乱了,到**数据库**页面点**恢复实验初始数据**,选这个实验。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
接着任务 3 的程序做。Security 窗体顶部和 Environment 一样:标题栏 + "< 返回主窗体"。
|
||||||
|
|
||||||
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| -------- | --------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| 主界面 | `pic_security` | "校园安防":打开 Security 窗体 |
|
||||||
|
| 校园安防 | `Security` | 无边框、屏幕居中 |
|
||||||
|
| 校园安防 | `panel1`、`panel3` | 标题栏(放标题图 `ico_title`)、返回栏,都停靠 Top |
|
||||||
|
| 校园安防 | `lb_return` | < 返回主窗体 |
|
||||||
|
| 校园安防 | `panel_bg_video` | 停靠 Fill,白色背景,下面的控件都放在它上面 |
|
||||||
|
| 校园安防 | `panel2` | 灰色(DimGray)的视频区 |
|
||||||
|
| 校园安防 | `pic_video` | 放在 panel2 上,显示摄像机画面;背景图片 `icon_monitor`,背景排列 Center |
|
||||||
|
| 校园安防 | `lb_infrared` | 放在 panel2 上,标签"入侵:"的右边,开始是 N/A,之后显示"发现入侵"或"正常" |
|
||||||
|
| 校园安防 | `panel_video_ctrl` | 方向盘:背景图片 `panel_video_ctrl`,背景排列 Stretch |
|
||||||
|
| 校园安防 | `pic_up`、`pic_down`、`pic_left`、`pic_right` | 放在方向盘上的四个方向键,背景图片 `btn_arrow_up_normal` 等,背景排列 Stretch |
|
||||||
|
| 校园安防 | `btn_camera` | 拍照 |
|
||||||
|
| 校园安防 | `btn_history` | 查看历史图像(任务 5 再写代码) |
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. **项目资源**里再勾选 `icon_monitor`、`panel_video_ctrl` 和四个方向键的 `btn_arrow_*_normal`、`btn_arrow_*_press`(共 8 张)。
|
||||||
|
2. 点 **+ 新建窗体** 建 `Security`,按上表搭界面。
|
||||||
|
3. 主窗体 `pic_security` 的 Click:`new Security().Show();`。
|
||||||
|
4. 在 Security 里引用 `System.IO`、`System.Threading`、`DeviceCtrlLibrary`、`智慧校园.SQLHelpers`,声明字段:入侵标志 `IsInfrared`、图片文件夹 `ImgDir`(程序目录下的 Camera 文件夹)、线程、SynchronizationContext、当前画面 `img`。
|
||||||
|
5. 写 **ipCameraHelper_ImageRead(ImageReadEvenArgs e)**:把 `e.img` 记到 `img`,并显示到 `pic_video`。
|
||||||
|
6. 写 **tick()**:`while (true)` 循环,读 ADAM-4150 的 `infraredValue`,用 `_main.Send` 把"发现入侵"或"正常"显示到 `lb_infrared`;有入侵且有画面时调用 `SQLBLL.SecurityUpData(img)`;`Thread.Sleep(1000)`。
|
||||||
|
7. **Security_Load**:打开摄像机(AppSet 里的 IP、用户名、密码),挂上 ImageRead 事件,`Start()`;启动 tick 线程;Camera 文件夹不存在就建一个。
|
||||||
|
8. **Security_FormClosed**:摄像机 `Stop()`、`Close()`,停止线程。
|
||||||
|
9. **btn_camera 的 Click**:把 `pic_video.Image` 存成 `ImgDir + 年月日_时分秒 + ".jpg"`,再调用 `SQLBLL.SecurityUpData` 存进数据库。
|
||||||
|
10. **四个方向键**:在 ⚡ 事件里双击"鼠标按下"和"鼠标松开"生成方法。按下时换成 `_press` 图片并调用 `PanUp()`(或 PanDown / PanLeft / PanRight),松开时换回 `_normal` 图片并再调用一次同一个方法。
|
||||||
|
11. **lb_return 的 Click**:`this.Close();`。
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **摄像机每次调用转一格**:平台的摄像机左右 7 格、上下 3 格,开始在正中,`PanUp()` 等方法每调用一次转一格。书上按下、松开各调用一次,所以点一下方向键会转两格。
|
||||||
|
|
||||||
|
> **面板的名字**:示例(取自配套工程)里白色的大面板叫 `panel_bg_video`、灰色视频区叫 `panel2`,书上叫 `panel_bg`、`panel_bg_video`。面板的名字不评分,照书上起名也可以。
|
||||||
|
|
||||||
|
> 书上的路径 `"Camera\\"` 照写就行,平台会换成服务器上能用的路径。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
1. 运行后点"校园安防",打开 Security 窗体,几秒内 `pic_video` 里出现摄像机画面(校园东围墙)。
|
||||||
|
2. 点实训台的**红外对射**遮挡光束,"入侵:"后面显示"发现入侵",画面里出现翻墙的人;再点一次恢复,显示"正常"。
|
||||||
|
3. 点四个方向键,画面跟着转;松开后方向键换回原来的图片。把鼠标放在实训台的**网络摄像头**上能看到云台的位置。
|
||||||
|
4. 点"拍照",运行区下面的"程序保存的图片"里多出一张 Camera 文件夹下的照片(文件名是拍照的日期和时间);到**数据库**页面查询 SecurityData 表,能看到拍照和入侵时存的记录。
|
||||||
|
5. 点"< 返回主窗体",Security 关闭,摄像机也关闭。
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
steps:
|
steps:
|
||||||
- clock: '09:00:00'
|
- clock: '09:00:00'
|
||||||
- click: pic_security
|
- click: pic_security
|
||||||
- expect: { control: pic_video, image: true, within: 3s }
|
- expect: { control: pic_video, image: true }
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
- click: btn_history
|
- click: btn_history
|
||||||
- expect: { window: SecurityCameraData, desc: 点"查看历史图像"打开历史报警记录窗体 }
|
- expect: { window: SecurityCameraData, desc: 点"查看历史图像"打开历史报警记录窗体 }
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
steps:
|
steps:
|
||||||
- clock: '09:00:00'
|
- clock: '09:00:00'
|
||||||
- click: pic_security
|
- click: pic_security
|
||||||
- expect: { control: pic_video, image: true, within: 3s }
|
- expect: { control: pic_video, image: true }
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
- clock: '10:30:00'
|
- clock: '10:30:00'
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
steps:
|
steps:
|
||||||
- clock: '09:00:00'
|
- clock: '09:00:00'
|
||||||
- click: pic_security
|
- click: pic_security
|
||||||
- expect: { control: pic_video, image: true, within: 3s }
|
- expect: { control: pic_video, image: true }
|
||||||
- click: btn_camera
|
- click: btn_camera
|
||||||
- click: btn_history
|
- click: btn_history
|
||||||
- pick: { control: dateTimePicker1, value: '2024-01-01' }
|
- pick: { control: dateTimePicker1, value: '2024-01-01' }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
-- 实验的初始数据库 WisdomCampus(教材"综合实训"附带的库):环境数据表 EnvironmentData、报警图片表 SecurityData、用户表 Users。
|
||||||
-- EnvironmentData 预置了 7 天的历史数据,"数据分析"的图表一打开就有内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
-- EnvironmentData 预置了今天之前 7 天的历史数据(按建库那天往前推),"数据分析"的图表一打开就有最近一周的内容;SecurityData 是空的,拍照或有人入侵时才有记录。
|
||||||
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。
|
||||||
CREATE DATABASE WisdomCampus
|
CREATE DATABASE WisdomCampus
|
||||||
GO
|
GO
|
||||||
@@ -10,45 +10,45 @@ CREATE TABLE EnvironmentData (Id int IDENTITY(1,1) PRIMARY KEY, Fire bit NOT NUL
|
|||||||
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
CREATE TABLE SecurityData (Id int IDENTITY(1,1) PRIMARY KEY, EvenTime datetime NOT NULL, EvenImage image NOT NULL)
|
||||||
GO
|
GO
|
||||||
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
INSERT INTO Users (UserName, Password) VALUES ('admin', '123')
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, '2026-10-14 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 58, 258, DATEADD(hour, 8, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, '2026-10-14 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.1, 53, 475, DATEADD(hour, 10, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, '2026-10-14 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 761, DATEADD(hour, 12, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, '2026-10-14 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.5, 49, 733, DATEADD(hour, 14, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, '2026-10-14 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 54, 528, DATEADD(hour, 16, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, '2026-10-14 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 283, DATEADD(hour, 18, DATEADD(day, -7, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, '2026-10-15 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.9, 59, 261, DATEADD(hour, 8, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, '2026-10-15 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 53, 499, DATEADD(hour, 10, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, '2026-10-15 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 764, DATEADD(hour, 12, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, '2026-10-15 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.5, 47, 778, DATEADD(hour, 14, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, '2026-10-15 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.8, 52, 503, DATEADD(hour, 16, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, '2026-10-15 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 56, 218, DATEADD(hour, 18, DATEADD(day, -6, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, '2026-10-16 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 13.9, 63, 258, DATEADD(hour, 8, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, '2026-10-16 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 60, 477, DATEADD(hour, 10, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, '2026-10-16 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.8, 51, 741, DATEADD(hour, 12, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, '2026-10-16 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.5, 54, 796, DATEADD(hour, 14, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, '2026-10-16 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 488, DATEADD(hour, 16, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, '2026-10-16 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.6, 59, 214, DATEADD(hour, 18, DATEADD(day, -5, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, '2026-10-17 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 11.9, 67, 234, DATEADD(hour, 8, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, '2026-10-17 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 63, 544, DATEADD(hour, 10, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, '2026-10-17 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.3, 60, 804, DATEADD(hour, 12, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, '2026-10-17 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.5, 54, 801, DATEADD(hour, 14, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, '2026-10-17 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.8, 59, 524, DATEADD(hour, 16, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, '2026-10-17 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.1, 58, 286, DATEADD(hour, 18, DATEADD(day, -4, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, '2026-10-18 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 16.4, 59, 250, DATEADD(hour, 8, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, '2026-10-18 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.6, 55, 533, DATEADD(hour, 10, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, '2026-10-18 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.3, 50, 749, DATEADD(hour, 12, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, '2026-10-18 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 24, 48, 766, DATEADD(hour, 14, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, '2026-10-18 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.8, 54, 534, DATEADD(hour, 16, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, '2026-10-18 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.1, 53, 241, DATEADD(hour, 18, DATEADD(day, -3, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, '2026-10-19 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.4, 54, 253, DATEADD(hour, 8, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, '2026-10-19 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.1, 50, 474, DATEADD(hour, 10, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, '2026-10-19 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 22.3, 48, 778, DATEADD(hour, 12, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, '2026-10-19 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 26, 46, 783, DATEADD(hour, 14, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, '2026-10-19 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 23.3, 50, 520, DATEADD(hour, 16, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, '2026-10-19 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 20.6, 50, 213, DATEADD(hour, 18, DATEADD(day, -2, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, '2026-10-20 08:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 15.9, 62, 230, DATEADD(hour, 8, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, '2026-10-20 10:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 18.6, 52, 471, DATEADD(hour, 10, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, '2026-10-20 12:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 53, 751, DATEADD(hour, 12, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, '2026-10-20 14:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 21.5, 48, 747, DATEADD(hour, 14, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, '2026-10-20 16:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 19.3, 55, 527, DATEADD(hour, 16, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, '2026-10-20 18:00:00')
|
INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (0, 0, 17.1, 57, 220, DATEADD(hour, 18, DATEADD(day, -1, CONVERT(varchar(10), GETDATE(), 120))))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "项目8 任务5 实现\"校园安防\"模块中显示历史图片的功能",
|
"title": "项目8 任务5 校园安防:历史图片",
|
||||||
"source": "教材书 p181–185,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);每次查询前清空 Keys(书上没清空,重复查询时照片和时间对不上)"
|
"source": "教材书 p181–185,界面和代码取自配套参考工程;图片是平台素材库里自绘的同名素材(尺寸和教材一致);每次查询前清空 Keys(书上没清空,重复查询时照片和时间对不上)"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 任务目标
|
||||||
|
|
||||||
|
做"历史报警记录"窗体 `SecurityCameraData`:在校园安防界面点"查看历史图像"打开它,按开始时间、结束时间从数据库里查出这段时间拍下的照片(拍照和入侵时存的),一次显示一张和它的拍摄时间,用左右箭头前后翻;查不到就显示"NO DATA"。
|
||||||
|
|
||||||
|
练习重点:ShowDialog 打开对话框式的窗体、日期时间选择器(DateTimePicker)、Dictionary 和 List 集合、运行时改控件的父容器(Parent)和位置(Location)。详细步骤见书 p181–185。
|
||||||
|
|
||||||
|
## 先准备数据库
|
||||||
|
|
||||||
|
照片在 **WisdomCampus** 库的 **SecurityData** 表里,任务 4 的拍照和入侵检测会往里存;初始数据里这张表是空的,先在校园安防界面拍几张照片再查。查询用任务 3 写好的 `SQLBLL.GetSecurityData(开始日期, 结束日期)`,它返回"拍摄时间 → 照片"的 Dictionary。
|
||||||
|
|
||||||
|
## 界面和控件名
|
||||||
|
|
||||||
|
接着任务 4 的程序做。
|
||||||
|
|
||||||
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
|
| 校园安防 | `btn_history` | 查看历史图像:打开 SecurityCameraData 窗体 |
|
||||||
|
| 历史报警记录 | `SecurityCameraData` | 无边框、屏幕居中 |
|
||||||
|
| 历史报警记录 | `panel1` | 标题栏:背景色 100, 116, 133,左边标签 `label1`"历史报警记录"(白字) |
|
||||||
|
| 历史报警记录 | `pic_close` | 标题栏右边的关闭按钮:背景图片 `windowstate_closed_hover`,背景排列 Center |
|
||||||
|
| 历史报警记录 | `panel_toolBar` | 工具栏:背景色 229, 229, 229 |
|
||||||
|
| 历史报警记录 | `dateTimePicker1`、`dateTimePicker2` | 开始时间、结束时间(左边各配一个标签) |
|
||||||
|
| 历史报警记录 | `btn_get` | 查询 |
|
||||||
|
| 历史报警记录 | `pic_camera` | 显示照片:背景色 64, 64, 64 |
|
||||||
|
| 历史报警记录 | `lb_noData` | NO DATA(红字,背景色 64, 64, 64,字号 48),叠在 pic_camera 中间 |
|
||||||
|
| 历史报警记录 | `lb_time` | TIME(红字,背景色 64, 64, 64),叠在 pic_camera 右下角,显示拍摄时间 |
|
||||||
|
| 历史报警记录 | `pic_up`、`pic_down` | 左、右翻页箭头:图片 `arrow_left_normal`、`arrow_right_normal`,SizeMode 设为 CenterImage |
|
||||||
|
|
||||||
|
> 注意 `pic_up` 是**左**箭头(上一张)、`pic_down` 是**右**箭头(下一张),名字照书上写。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. **项目资源**里再勾选 `windowstate_closed_hover`、`arrow_left_normal`、`arrow_right_normal`。
|
||||||
|
2. 点 **+ 新建窗体** 建 `SecurityCameraData`,按上表搭界面。
|
||||||
|
3. 校园安防 `btn_history` 的 Click:`new SecurityCameraData().ShowDialog();`。
|
||||||
|
4. 引用 `DeviceCtrlLibrary`、`智慧校园.SQLHelpers`,声明字段:照片集合 `ImgDictionary`(`Dictionary<string, Image>`)、当前下标 `index = -1`、当前照片 `img`、时间列表 `Keys`(`List<string>`)。
|
||||||
|
5. 写 **ImageChanged(char UpOrDown = '+')**:`'+'` 时下标加 1、`'-'` 时减 1,超过最后一张回到第一张、小于 0 回到最后一张;按 `Keys[index]` 从集合里取出照片显示到 `pic_camera`,时间显示到 `lb_time`。
|
||||||
|
6. 写 **GetData()**:用两个日期时间选择器的 Value 调用 `SQLBLL.GetSecurityData`,下标设回 −1,清空 `Keys`;没有照片就显示 `lb_noData` 并返回,有照片就隐藏它,把所有时间加进 `Keys`,调用 `ImageChanged()` 显示第一张。
|
||||||
|
7. **SecurityCameraData_Load**:把 `pic_up`、`pic_down` 的 Parent 设成 `pic_camera`,位置分别设为 (20, 0) 和 (560, 0),然后调用 `GetData()`。
|
||||||
|
8. **btn_get 的 Click**:`GetData();`
|
||||||
|
9. **pic_up 的 Click**:`ImageChanged('-');` **pic_down 的 Click**:`ImageChanged();`
|
||||||
|
10. **pic_close 的 Click**:`this.Close();`
|
||||||
|
|
||||||
|
## 和书上不一样的地方
|
||||||
|
|
||||||
|
> **每次查询前清空 Keys**:书上的 GetData 没有 `Keys.Clear();`,查第二次时 Keys 里还留着上次的时间,翻页时照片和时间就对不上了。在下标设回 −1 的地方加上 `Keys.Clear();`。
|
||||||
|
|
||||||
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
> 先准备:做过任务 4 的话,SecurityData 表里已经存了任务 4 拍的照片和入侵照片,下面看到的第一张就不是 9:00 那张。先到右上角的**数据库**页面点"恢复实验初始数据",选"项目8 任务5",把表清空(自动评分每次都用干净的库,不受影响)。
|
||||||
|
|
||||||
|
1. 在实训台的**时钟**里拨到 9:00:00,运行后打开校园安防,等画面出来点"拍照";再拨到 10:30:00,再拍一张。
|
||||||
|
2. 点"查看历史图像",打开历史报警记录窗体:两个日期默认都是今天,显示第一张照片,右下角时间是 9:00 左右,"NO DATA"不显示。
|
||||||
|
3. 点右箭头显示 10:30 那张,再点回到第一张;点左箭头往回翻。
|
||||||
|
4. 开始时间选 2024-01-01,点"查询",还能查到今天的照片;结束时间再改成 2024-01-31,点"查询",显示"NO DATA"。
|
||||||
|
5. 点右上角的关闭按钮,回到校园安防界面。
|
||||||
+11
-7
@@ -64,7 +64,10 @@ function userPrompt(input: {
|
|||||||
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
|
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
|
||||||
: '编译没有通过。'
|
: '编译没有通过。'
|
||||||
: '(还没有自动评分结果)'
|
: '(还没有自动评分结果)'
|
||||||
const hints = conventionHints(input.files).map((h) => `- ${h.text}`).join('\n') || '- 无'
|
const hints =
|
||||||
|
conventionHints(input.files)
|
||||||
|
.map((h) => `- ${h.text}`)
|
||||||
|
.join('\n') || '- 无'
|
||||||
const code = input.files
|
const code = input.files
|
||||||
.filter((f) => f.path.endsWith('.cs'))
|
.filter((f) => f.path.endsWith('.cs'))
|
||||||
.map((f) => `===== ${f.path} =====\n${f.content}`)
|
.map((f) => `===== ${f.path} =====\n${f.content}`)
|
||||||
@@ -75,8 +78,12 @@ function userPrompt(input: {
|
|||||||
${items}
|
${items}
|
||||||
|
|
||||||
${BOOK_CRITERIA}
|
${BOOK_CRITERIA}
|
||||||
${input.bookNames.length ? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
|
${
|
||||||
` : ''}
|
input.bookNames.length
|
||||||
|
? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
平台自动评分的结果(功能,供参考,不用再评):
|
平台自动评分的结果(功能,供参考,不用再评):
|
||||||
${auto}
|
${auto}
|
||||||
|
|
||||||
@@ -101,10 +108,7 @@ async function callModel(system: string, user: string, screenshots: Screenshot[]
|
|||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [{ type: 'text', text: user }, ...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } }))],
|
||||||
{ type: 'text', text: user },
|
|
||||||
...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } })),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user