Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f242308a3b | ||
|
|
2aa267839f | ||
|
|
4c3ce31fe1 | ||
|
|
77bfaba7ff | ||
|
|
5e5d29b2aa | ||
|
|
46e0f40cce | ||
|
|
163e553de4 | ||
|
|
9ed446f95a | ||
|
|
6a18bdf2e4 | ||
|
|
ab933d1896 | ||
|
|
50dced4d37 | ||
|
|
50946467f6 | ||
|
|
a2f03ee57f | ||
|
|
e177753ad6 | ||
|
|
f9a2739353 | ||
|
|
b292d38ba7 | ||
|
|
d6e891cdb0 | ||
|
|
53005e69e4 | ||
|
|
313acf2453 | ||
|
|
b7e7f77fb0 | ||
|
|
b3d6e1f88f | ||
|
|
f6024dc113 | ||
|
|
da623fa648 | ||
|
|
e4c53f5e55 | ||
|
|
61a32e900d | ||
|
|
4ce800c77f | ||
|
|
de277d3e9d | ||
|
|
d4b8bf9241 | ||
|
|
90b739a2c7 | ||
|
|
3b9f635c35 | ||
|
|
27fdc3615d | ||
|
|
982b1297aa | ||
|
|
e8058376dd | ||
|
|
c217eb5ca0 | ||
|
|
f304fc1ffc | ||
|
|
fe62d50881 | ||
|
|
f256b313f7 | ||
|
|
aadb2c9e93 | ||
|
|
751c4c964d | ||
|
|
65a1b3a81e | ||
|
|
b20db5721e | ||
|
|
10c50265b2 | ||
|
|
5ae07b851d | ||
|
|
2cd0111967 | ||
|
|
d60868d912 | ||
|
|
42e258ddcd | ||
|
|
13c3e4c3ec | ||
|
|
19d6872d80 | ||
|
|
eedf1aa2fb |
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:
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.4.3
|
bun-version: 1.4.4
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v6
|
- uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
@@ -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/教材摘要/
|
||||||
@@ -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
|
||||||
@@ -8,7 +14,7 @@ COPY runtime/ runtime/
|
|||||||
RUN dotnet build runtime/Runtime.slnx -c Release
|
RUN dotnet build runtime/Runtime.slnx -c Release
|
||||||
|
|
||||||
# ---- 网页和服务端 ----
|
# ---- 网页和服务端 ----
|
||||||
FROM oven/bun:1.4.3 AS web-build
|
FROM oven/bun:1.4.4 AS web-build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
COPY shared/package.json shared/
|
COPY shared/package.json shared/
|
||||||
@@ -22,16 +28,30 @@ 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 \
|
||||||
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
|
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=oven/bun:1.4.3 /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,28 +55,30 @@ 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 阶段进行中
|
||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
|
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
|
||||||
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox,教材控件库的 Lamp、Alarm。
|
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox、DataGridView、DateTimePicker、TrackBar、Windows Media Player、Chart(图表),教材控件库的 Lamp、Alarm;Panel 停靠布局、背景图片、无边框窗体。
|
||||||
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3,共 15 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。
|
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3、项目 7 任务 1–3、项目 8 任务 1–5,共 23 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。
|
||||||
- 代码编辑器(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)。
|
||||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
|
||||||
- 第 3 阶段(进行中):教材项目 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)。
|
- 第 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)。
|
||||||
|
|
||||||
尚未完成:
|
尚未完成:
|
||||||
|
|
||||||
- 教材项目 7–8(需要 TrackBar、资源图片、图表、虚拟时钟、无边框窗体等)。
|
- 一个班试用。
|
||||||
|
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"name": "@iot/web",
|
"name": "@iot/web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iot/shared": "workspace:*",
|
"@iot/shared": "workspace:*",
|
||||||
|
"chart.js": "^4.6.1",
|
||||||
"modern-screenshot": "^4.7.0",
|
"modern-screenshot": "^4.7.0",
|
||||||
"monaco-editor": "^0.57.0",
|
"monaco-editor": "^0.57.0",
|
||||||
"naive-ui": "^2.41.0",
|
"naive-ui": "^2.41.0",
|
||||||
@@ -115,6 +116,8 @@
|
|||||||
|
|
||||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||||
|
|
||||||
|
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||||
|
|
||||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
@@ -217,6 +220,8 @@
|
|||||||
|
|
||||||
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
||||||
|
|
||||||
|
"chart.js": ["chart.js@4.6.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g=="],
|
||||||
|
|
||||||
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
@@ -251,7 +256,7 @@
|
|||||||
|
|
||||||
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"modern-screenshot": ["modern-screenshot@4.7.0", "", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
|
"modern-screenshot": ["modern-screenshot@4.7.0", "https://registry.npmjs.com/modern-screenshot/-/modern-screenshot-4.7.0.tgz", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
|
||||||
|
|
||||||
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
|
||||||
|
[fmt]
|
||||||
|
printWidth = 160
|
||||||
|
semi = false
|
||||||
|
singleQuote = true
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 不够,定时器来不及触发,正确的程序会被误判 |
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:备份平台的全部数据(账号、班级、学生作品、作业、提交、评阅,以及每个学生自己的数据库)
|
||||||
|
# 到 backups/<日期时间>/。备份在宿主机上,不在 Docker 数据卷里,数据卷被删也还在;要防硬盘坏,把它拷到别的机器上。
|
||||||
|
# 平台运行中也可以备份,学生不受影响。恢复见 deploy/restore.sh。
|
||||||
|
# 用法:deploy/backup.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mkdir -p backups
|
||||||
|
name=$(date +%Y%m%d-%H%M%S)
|
||||||
|
# 同一秒里做了两次(如 restore.sh 恢复前先备份)时,等到下一秒,不和已有的备份重名
|
||||||
|
while [[ -e backups/$name ]]; do
|
||||||
|
sleep 1
|
||||||
|
name=$(date +%Y%m%d-%H%M%S)
|
||||||
|
done
|
||||||
|
inside=/data/.backup-$name
|
||||||
|
# 在容器里导出(SQLite 一致读取,不会备份出写了一半的库),再拷到宿主机
|
||||||
|
docker compose exec -T app bun server.js backup "$inside" >/dev/null
|
||||||
|
docker compose cp "app:$inside" "backups/$name" 2>/dev/null
|
||||||
|
docker compose exec -T app rm -rf "$inside"
|
||||||
|
echo "已备份到 $(pwd)/backups/$name($(du -sh "backups/$name" | cut -f1))"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:用 deploy/backup.sh 做的备份替换平台现在的全部数据。
|
||||||
|
# 替换前先自动备份现在的数据(恢复错了还能换回来);恢复时平台停止约十几秒,正在运行的学生程序会结束。
|
||||||
|
# 用法:deploy/restore.sh backups/<日期时间>
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
dir=${1:?用法:deploy/restore.sh backups/<日期时间>}
|
||||||
|
if [[ ! -f $dir/iot-sim.db ]]; then
|
||||||
|
echo "$dir 不是备份目录(里面应该有 iot-sim.db)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -r -p "用 $dir 替换平台现在的全部数据?现在的数据会先备份。[y/N] " answer
|
||||||
|
[[ $answer == y || $answer == Y ]] || exit 1
|
||||||
|
|
||||||
|
echo "== 备份现在的数据"
|
||||||
|
deploy/backup.sh
|
||||||
|
echo "== 停止平台、替换数据"
|
||||||
|
docker compose stop app
|
||||||
|
# 旧库的 -wal、-shm 文件一定要删掉,否则会被当成新库的未写完的日志,把恢复的库弄坏
|
||||||
|
docker compose run --rm --no-deps -T -v "$(realpath "$dir"):/restore:ro" app \
|
||||||
|
sh -c 'rm -rf /data/iot-sim.db /data/iot-sim.db-wal /data/iot-sim.db-shm /data/databases && cp -r /restore/. /data/'
|
||||||
|
echo "== 启动平台"
|
||||||
|
docker compose up -d app
|
||||||
|
echo "已恢复到 $dir"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
|
||||||
|
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
|
||||||
|
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
|
||||||
|
# 这个提交的部署还没开始过时退出码 75
|
||||||
|
#
|
||||||
|
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
|
||||||
|
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
|
||||||
|
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||||
|
main() {
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
local state=/var/tmp/iot_sim-update
|
||||||
|
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
|
||||||
|
|
||||||
|
if [[ ${1:-} == --follow ]]; then
|
||||||
|
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
|
||||||
|
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
|
||||||
|
echo "服务器上没有 $sha 的部署记录(还没开始)"
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
follow
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
|
||||||
|
|
||||||
|
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
|
||||||
|
local old
|
||||||
|
old=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
|
||||||
|
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
|
||||||
|
while kill -0 "$old" 2>/dev/null; do sleep 2; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f $state.pid $state.exit
|
||||||
|
echo "$sha" >$state.sha
|
||||||
|
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
|
||||||
|
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
|
||||||
|
"$state" "$@" >$state.log 2>&1 </dev/null &
|
||||||
|
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
|
||||||
|
follow
|
||||||
|
}
|
||||||
|
|
||||||
|
follow() {
|
||||||
|
local pid code
|
||||||
|
pid=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $pid ]]; then
|
||||||
|
# update 进程结束后 tail 自己退出
|
||||||
|
tail -n +1 -f --pid="$pid" $state.log
|
||||||
|
else
|
||||||
|
cat $state.log 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
code=$(cat $state.exit 2>/dev/null || true)
|
||||||
|
if [[ -z $code ]]; then
|
||||||
|
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
||||||
|
|
||||||
|
|||||||
@@ -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,36 +295,78 @@ 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。
|
||||||
|
|
||||||
|
- 2026-10-23:项目 7 情景控制(3 个任务),共 18 个示例。
|
||||||
|
- **虚拟时钟**:实训台加时钟面板,默认真实时间,可以拨到任意日期时刻、选 1×/10×/60× 倍速、一键恢复真实时间;时钟状态由服务器保存(`BoardState.clock`),运行器按它算当前时间。编译时把学生代码里的 `DateTime.Now / Today / UtcNow` 改写成 `SimClock`(和线程 API 的改写一样不换行、编辑器里看到的是原代码)。任务 2 按 6:45 起床、7:30 上课、21:00 回寝、22:00 熄灯切换,任务 3 按预约时刻开启,都靠它来测试。评分规则加 `clock` 步骤(拨到今天的某个时刻,可设倍速)。
|
||||||
|
- **项目资源**(`Properties.Resources`):只用平台素材库(自绘的开关图片、ffmpeg 合成的门铃声,名字和教材一致,`runtime/Sim.WinForms/Resources/gallery`)。实验页"项目资源"对话框勾选素材(图片看缩略图、声音可试听),平台生成 `Properties/Resources.Designer.cs`(图片是 Bitmap、声音是 byte[],用法和 VS 的强类型资源类一样,不在标签页里显示);设计器里图片框的"图片"属性可以从项目资源里选,写进 Designer.cs 的是 `global::命名空间.Properties.Resources.名字`,预览直接显示素材。学生程序的程序目录下有 Resources 文件夹指向素材库(书上用 `BaseDirectory + "//Resources//ring.mp3"`)。没添加资源就用时给中文提示。
|
||||||
|
- **Windows Media Player**(`AxWMPLib.AxWindowsMediaPlayer`):设置 URL 后和真的一样自动播放,`Ctlcontrols.play() / stop()`;声音在浏览器里播放,实训台上加"广播"喇叭,播放时亮起。VS 为它生成的 `ComponentResourceManager(...).GetObject("…OcxState")`:编译时给每个窗体嵌一份空资源,取到 null 不报错。图片、声音数据统一按内容哈希缓存(`blob` 消息),每份只传一次。
|
||||||
|
- **TrackBar**:拖动、点轨道(LargeChange)、方向键(SmallChange),先 ValueChanged 再 Scroll,竖向时最小值在下面;设计器工具箱加"滑动条""媒体播放器"。评分规则加 `value`(滑动条的值)、`media`(播放了某个文件)检查。
|
||||||
|
- 示例:书上任务 1 手动开关灯的判断写反(参考工程是对的),任务 3 漏了填充串口列表,示例里都已改正;任务 3 的"每 5 分钟整点拍照"依赖定时器正好在 0 秒触发,自动评分不检查这一项,由老师评阅。编译检查修正:`System.AppDomain.CurrentDomain.BaseDirectory`(带命名空间)也放行。
|
||||||
|
|
||||||
|
- 2026-10-23:项目 8 智慧校园管理平台(5 个任务,每个任务一个示例,代码逐个累加),至此教材 8 个项目全部完成,共 23 个示例。
|
||||||
|
- **新建类**:实验页"+ 新建类",可以带文件夹(`SQLHelpers/SQLBLL`),文件夹成为命名空间的一段,模板和 VS 2012 一样;可以删除自己建的类。
|
||||||
|
- **停靠布局**:Dock(上、下、左、右、填充)和 WinForms 一样按 Controls 倒序依次停靠,Panel 的边框、GroupBox 的标题占的地方扣掉;窗体改大小、控件显示隐藏、加入移出时重新布局。设计器里改了停靠或大小也立即按同样的规则排好。
|
||||||
|
- **背景图片**:BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放),窗体、面板、图片框都可以设,设计器里从项目资源选。**背景色沿用父控件**:没设置背景色的标签、面板、分组框等画父控件的背景色(不是父控件的背景图片),和 WinForms 一样,所以书上要把放在背景图上的控件设成 Transparent。分组框的标题处边框断开、透出背景(书上分组框放在深色背景上、标题是白字)。文本框的 BackColor 生效。
|
||||||
|
- **无边框窗体**:FormBorderStyle = None 时没有标题栏;**运行区自动缩放**:窗体比运行区大时(项目 8 的窗体有 1043×791)整体按比例缩小,右上角显示缩放比例;"放大"让运行区占满整个网页(窗体在左、实训台在右),Esc 还原。
|
||||||
|
- **图表**(`System.Windows.Forms.DataVisualization.Charting.Chart`):ChartAreas、Legends、Series(AddXY、Points、ChartType 等)、DataSource + DataBind(DataTable / DataView / DataSet,按 XValueMember、YValueMembers 取列),浏览器用 Chart.js 画,配色是 WinForms 默认的 BrightPastel。设计器工具箱加"图表"(和 VS 一样带 ChartArea1、Legend1、Series1),属性窗口"系列和图例"打开对话框编辑系列(名称、图表类型、图例、图例文字、X / Y 值字段)和图例,按 VS 的格式和顺序写回 Designer.cs,对话框不管的属性原样保留。
|
||||||
|
- **路径的反斜杠**:书上写 `BaseDirectory + "Camera\\"`,Linux 上反斜杠会成为文件名的一部分。编译时把传给 System.IO(File、Directory、Path、各种 Stream)和 Image / Bitmap 读写文件方法的路径参数包上 `WindowsPaths.Fix`,反斜杠换成斜杠(和线程、时钟的改写一样不换行、编辑器里看到的是原代码)。
|
||||||
|
- **其他**:Paint 事件(VS 双击面板生成的空 `panel1_Paint` 能编译,平台不触发);图片框把鼠标按下、松开发给程序(书上方向键按下时转动摄像机、换成按下的图片);数据库里参数化的日期字符串比较时也转换格式(书上拼出的 `"2024/1/23 23:59:59"` 有两个空格)。
|
||||||
|
- **素材**:项目 8 用到的 20 多张图片(标题、校园背景、三个入口图标、亮灯 / 灭灯的路灯场景、面板背景、监控图标、方向盘和方向键、翻页箭头、关闭按钮)全部自绘,名字和尺寸与教材一致(`runtime/Sim.WinForms/Resources/project8.py` 生成 SVG,`build-gallery.sh` 渲染成 PNG)。
|
||||||
|
- **数据库 WisdomCampus**:平台按教材的表结构建(EnvironmentData、SecurityData、Users),预置 7 天环境数据,"数据分析"的图表一打开就有内容。学生第一次运行带预置数据库的实验时自动建好自己还没有的库(已有的库不动)。
|
||||||
|
- **评分规则**新增:`visible`(连同所在容器都可见)、`window`(某个窗体打开着 / 已关闭)、`exited`(程序已退出)、`image` / `background` 写素材名(如 `background: day_01`,按图片内容核对显示的是不是这张素材)、`points`(图表每个系列的数据点数);"点击"图片框时和浏览器一样先发鼠标按下、松开。
|
||||||
|
- **示例**:书上任务 3 第 23 步把"自动推送火情"的处理写到了 ch_autoLamp 上,参考工程里"推送LED信息""自动控制"是空方法、"自动推送火情"没有绑定事件,示例按书的意思补全;任务 5 每次查询前清空 Keys(书上没清空,重复查询时照片和时间对不上)。任务 2 只做界面,为了能看效果加上了打开、返回窗体。
|
||||||
|
|
||||||
**其他改进**:
|
**其他改进**:
|
||||||
|
|
||||||
- 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:一个班试用前的准备。
|
||||||
|
- **全班模拟**(`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 小时 | 提供可调、可加速的虚拟时钟 |
|
| 时间相关逻辑 | 项目 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] 设备写法:兼容教材类库
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal static class Functions
|
|||||||
c.CreateFunction<object?, object?>("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true);
|
c.CreateFunction<object?, object?>("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true);
|
||||||
c.CreateFunction<object?, object?, object?>("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true);
|
c.CreateFunction<object?, object?, object?>("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true);
|
||||||
c.CreateFunction<object?, object?>("tsql_sqrt", x => x is null ? null : Math.Sqrt(ToDouble(x)), isDeterministic: true);
|
c.CreateFunction<object?, object?>("tsql_sqrt", x => x is null ? null : Math.Sqrt(ToDouble(x)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_cmp", v => v is string s && Values.LooksLikeDate(s) ? Values.NormalizeDateLiteral(s) : v, isDeterministic: true);
|
||||||
c.CreateFunction("tsql_newid", () => Guid.NewGuid().ToString().ToUpperInvariant());
|
c.CreateFunction("tsql_newid", () => Guid.NewGuid().ToString().ToUpperInvariant());
|
||||||
c.CreateFunction("tsql_rand", () => Random.Shared.NextDouble());
|
c.CreateFunction("tsql_rand", () => Random.Shared.NextDouble());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 写法"),
|
||||||
@@ -911,6 +912,14 @@ internal sealed class Translator
|
|||||||
_sql.Append(Values.Quote(Values.NormalizeDateLiteral(s.Value)));
|
_sql.Append(Values.Quote(Values.NormalizeDateLiteral(s.Value)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 参数的值执行时才知道:是日期样子的字符串时同样转成标准格式(教材 BETWEEN @time_start AND @time_end 传的是字符串)
|
||||||
|
if (expression is VariableReference)
|
||||||
|
{
|
||||||
|
_sql.Append("tsql_cmp(");
|
||||||
|
Expression(expression);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
Expression(expression);
|
Expression(expression);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,13 +17,16 @@ internal static class Values
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
/// <summary>形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
||||||
private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T]\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$");
|
private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}((\s+|T)\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$");
|
||||||
|
|
||||||
|
/// <summary>日期和时间之间多余的空格(教材拼出的 "2024/1/23 23:59:59")</summary>
|
||||||
|
private static readonly Regex Spaces = new(@"\s+");
|
||||||
|
|
||||||
public static bool LooksLikeDate(string text) => DateLike.IsMatch(text.Trim());
|
public static bool LooksLikeDate(string text) => DateLike.IsMatch(text.Trim());
|
||||||
|
|
||||||
/// <summary>按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03)</summary>
|
/// <summary>按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03)</summary>
|
||||||
public static bool TryParseDateTime(string text, out DateTime value) =>
|
public static bool TryParseDateTime(string text, out DateTime value) =>
|
||||||
DateTime.TryParseExact(text.Trim(), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value);
|
DateTime.TryParseExact(Spaces.Replace(text.Trim(), " "), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value);
|
||||||
|
|
||||||
public static string FormatDateTime(DateTime value) =>
|
public static string FormatDateTime(DateTime value) =>
|
||||||
value.Millisecond == 0 && value.Ticks % TimeSpan.TicksPerMillisecond == 0
|
value.Millisecond == 0 && value.Ticks % TimeSpan.TicksPerMillisecond == 0
|
||||||
|
|||||||
@@ -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,18 +31,32 @@ 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":[…]}</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"]);
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (json["analog"] is JsonArray analog)
|
if (json["analog"] is JsonArray analog)
|
||||||
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;
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ internal static class ApiPolicy
|
|||||||
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
|
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
|
||||||
// var 推断出的类型在初始化表达式里已经检查过
|
// var 推断出的类型在初始化表达式里已经检查过
|
||||||
if (name is IdentifierNameSyntax { IsVar: true }) continue;
|
if (name is IdentifierNameSyntax { IsVar: true }) continue;
|
||||||
if (name.Parent is MemberAccessExpressionSyntax m && m.Expression == name && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
|
// 类型名只是用来限定成员(AppDomain.CurrentDomain、System.AppDomain.CurrentDomain):检查后面的成员就够了
|
||||||
|
ExpressionSyntax qualifier = name.Parent is MemberAccessExpressionSyntax inner && inner.Name == name ? inner : name;
|
||||||
|
if (qualifier.Parent is MemberAccessExpressionSyntax m && m.Expression == qualifier && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
|
||||||
|
|
||||||
var symbol = model.GetSymbolInfo(name).Symbol;
|
var symbol = model.GetSymbolInfo(name).Symbol;
|
||||||
if (symbol == null) continue;
|
if (symbol == null) continue;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译前把学生代码里的 DateTime.Now / Today / UtcNow 改写成虚拟时钟 Sim.Host.SimClock(实训台上可以拨时间、调倍速,
|
||||||
|
/// 教材项目 7 按 6:45 起床、22:00 熄灯等时刻切换情景模式)。只改写语义上确实是 System.DateTime 这三个属性的地方;
|
||||||
|
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ClockRewriter
|
||||||
|
{
|
||||||
|
private const string Target = "global::Sim.Host.SimClock";
|
||||||
|
|
||||||
|
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var result = compilation;
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var text = tree.GetText().ToString();
|
||||||
|
if (!text.Contains("Now") && !text.Contains("Today")) continue;
|
||||||
|
var rewriter = new Rewriter(compilation.GetSemanticModel(tree));
|
||||||
|
var root = rewriter.Visit(tree.GetRoot());
|
||||||
|
if (!rewriter.Changed) continue;
|
||||||
|
result = result.ReplaceSyntaxTree(tree,
|
||||||
|
CSharpSyntaxTree.ParseText(SourceText.From(root.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Replacement(ISymbol? symbol) =>
|
||||||
|
symbol is IPropertySymbol { IsStatic: true, Name: "Now" or "Today" or "UtcNow" } property
|
||||||
|
&& property.ContainingType.SpecialType == SpecialType.System_DateTime
|
||||||
|
? $"{Target}.{property.Name}"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
|
||||||
|
{
|
||||||
|
public bool Changed { get; private set; }
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||||
|
{
|
||||||
|
// DateTime.Now、System.DateTime.Now
|
||||||
|
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
|
||||||
|
{
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
|
||||||
|
}
|
||||||
|
return base.VisitMemberAccessExpression(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node)
|
||||||
|
{
|
||||||
|
// using static System.DateTime; 之后直接写 Now
|
||||||
|
if (node.Parent is not MemberAccessExpressionSyntax access || access.Expression == node)
|
||||||
|
{
|
||||||
|
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
|
||||||
|
{
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.VisitIdentifierName(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
namespace Sim.Runner;
|
namespace Sim.Runner;
|
||||||
@@ -17,11 +18,38 @@ internal static class CompileHints
|
|||||||
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
|
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
|
||||||
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
|
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
|
||||||
$"“{name}”像是类型和名字之间少了一个空格。",
|
$"“{name}”像是类型和名字之间少了一个空格。",
|
||||||
|
("CS0234", var s) when s.Contains("Properties") => "要用 Properties.Resources,先点代码上方的“项目资源”,添加需要的图片或声音。",
|
||||||
|
("CS0117", var s) when s.Contains("Resources.") => $"项目资源里还没有“{s[(s.LastIndexOf('.') + 1)..]}”:点代码上方的“项目资源”添加(名字要和代码里一致)。",
|
||||||
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
|
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
|
||||||
("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,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Operations;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译前把传给 System.IO(File、Directory、Path、FileStream、StreamWriter 等)和 Image / Bitmap 读写文件方法的路径参数
|
||||||
|
/// 包上 Sim.Host.WindowsPaths.Fix(...):教材按 Windows 写 AppDomain.CurrentDomain.BaseDirectory + "Camera\\",
|
||||||
|
/// 在 Linux 上反斜杠要换成斜杠。只包语义上确实是这些方法里名字带 path / file / dir 的字符串参数;
|
||||||
|
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
|
||||||
|
/// </summary>
|
||||||
|
internal static class PathRewriter
|
||||||
|
{
|
||||||
|
private const string Fix = "global::Sim.Host.WindowsPaths.Fix";
|
||||||
|
|
||||||
|
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var result = compilation;
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var root = tree.GetRoot();
|
||||||
|
var targets = root.DescendantNodes()
|
||||||
|
.Where(n => n is InvocationExpressionSyntax or BaseObjectCreationExpressionSyntax)
|
||||||
|
.SelectMany(n => PathArguments(model.GetOperation(n)))
|
||||||
|
.ToHashSet();
|
||||||
|
if (targets.Count == 0) continue;
|
||||||
|
var rewritten = root.ReplaceNodes(targets, (_, node) =>
|
||||||
|
SyntaxFactory.ParseExpression($"{Fix}({node.WithoutTrivia().ToFullString()})").WithTriviaFrom(node));
|
||||||
|
result = result.ReplaceSyntaxTree(tree,
|
||||||
|
CSharpSyntaxTree.ParseText(SourceText.From(rewritten.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>调用里要包起来的路径参数(只取学生直接写的表达式;不含反斜杠的字符串常量不用包)</summary>
|
||||||
|
private static IEnumerable<ExpressionSyntax> PathArguments(IOperation? operation)
|
||||||
|
{
|
||||||
|
var (method, arguments) = operation switch
|
||||||
|
{
|
||||||
|
IInvocationOperation invocation => (invocation.TargetMethod, invocation.Arguments),
|
||||||
|
IObjectCreationOperation creation when creation.Constructor != null => (creation.Constructor, creation.Arguments),
|
||||||
|
_ => (null, default),
|
||||||
|
};
|
||||||
|
if (method == null || !HandlesFiles(method.ContainingType)) yield break;
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
{
|
||||||
|
if (argument.Parameter is not { } parameter || !IsPathName(parameter.Name)) continue;
|
||||||
|
var values = argument.ArgumentKind == ArgumentKind.ParamArray && argument.Value is IArrayCreationOperation { Initializer: { } init }
|
||||||
|
? init.ElementValues
|
||||||
|
: [argument.Value];
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (value.IsImplicit || value.Type?.SpecialType != SpecialType.System_String) continue;
|
||||||
|
if (value.ConstantValue is { HasValue: true, Value: string constant } && !constant.Contains('\\')) continue;
|
||||||
|
if (value.Syntax is ExpressionSyntax expression) yield return expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HandlesFiles(INamedTypeSymbol type)
|
||||||
|
{
|
||||||
|
var ns = type.ContainingNamespace?.ToDisplayString();
|
||||||
|
return ns == "System.IO" || (ns == "System.Drawing" && type.Name is "Image" or "Bitmap");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPathName(string name) =>
|
||||||
|
name.Contains("path", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("file", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("dir", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -56,9 +54,10 @@ internal static class StudentCompiler
|
|||||||
|
|
||||||
using var peStream = new MemoryStream();
|
using var peStream = new MemoryStream();
|
||||||
using var pdbStream = new MemoryStream();
|
using var pdbStream = new MemoryStream();
|
||||||
// 编译改写后的代码(旧版线程 API 换成平台实现),API 检查仍针对学生写的原始代码
|
// 编译改写后的代码(旧版线程 API 换成平台实现,DateTime.Now 换成虚拟时钟),API 检查仍针对学生写的原始代码
|
||||||
var emit = LegacyThreadRewriter.Rewrite(compilation).Emit(peStream, pdbStream,
|
var emit = PathRewriter.Rewrite(ClockRewriter.Rewrite(LegacyThreadRewriter.Rewrite(compilation))).Emit(peStream, pdbStream,
|
||||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb),
|
||||||
|
manifestResources: EmptyControlResources(compilation));
|
||||||
var denied = ApiPolicy.Check(compilation).ToList();
|
var denied = ApiPolicy.Check(compilation).ToList();
|
||||||
|
|
||||||
var diagnostics = new JsonArray();
|
var diagnostics = new JsonArray();
|
||||||
@@ -71,6 +70,39 @@ internal static class StudentCompiler
|
|||||||
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Lazy<byte[]> EmptyResources = new(() =>
|
||||||
|
{
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
using (var writer = new System.Resources.ResourceWriter(stream)) writer.Generate();
|
||||||
|
return stream.ToArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给学生工程里的每个窗体、控件类嵌一份空的 .resources:VS 生成的 Designer.cs 会用
|
||||||
|
/// ComponentResourceManager(typeof(窗体)).GetObject("…OcxState") 读窗体的 .resx(如 Windows Media Player 控件),
|
||||||
|
/// 平台没有 .resx,有了空资源时取到 null,而不是报"找不到资源"。
|
||||||
|
/// </summary>
|
||||||
|
private static List<ResourceDescription> EmptyControlResources(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
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<Microsoft.CodeAnalysis.CSharp.Syntax.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(EmptyResources.Value), isPublic: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
internal static JsonObject ToJson(Diagnostic diagnostic)
|
internal static JsonObject ToJson(Diagnostic diagnostic)
|
||||||
{
|
{
|
||||||
var span = diagnostic.Location.GetMappedLineSpan();
|
var span = diagnostic.Location.GetMappedLineSpan();
|
||||||
@@ -89,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;
|
||||||
@@ -104,6 +107,9 @@ internal static class StudentProgramHost
|
|||||||
baseDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "StudentApp", "bin", "Debug")).FullName;
|
baseDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "StudentApp", "bin", "Debug")).FullName;
|
||||||
}
|
}
|
||||||
baseDir = Path.TrimEndingDirectorySeparator(baseDir) + Path.DirectorySeparatorChar;
|
baseDir = Path.TrimEndingDirectorySeparator(baseDir) + Path.DirectorySeparatorChar;
|
||||||
|
// 程序目录下的 Resources 文件夹指向素材库:教材用 BaseDirectory + "//Resources//ring.mp3" 播放铃声
|
||||||
|
var resources = Path.Combine(baseDir, "Resources");
|
||||||
|
if (!Path.Exists(resources)) Directory.CreateSymbolicLink(resources, ProjectResources.Directory);
|
||||||
AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDir);
|
AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDir);
|
||||||
Directory.SetCurrentDirectory(baseDir);
|
Directory.SetCurrentDirectory(baseDir);
|
||||||
SimHost.AppBaseDirectory = baseDir;
|
SimHost.AppBaseDirectory = baseDir;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace System.Drawing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绘图面。平台的控件由浏览器绘制,学生程序不能自己画(没有 DrawLine 等方法);
|
||||||
|
/// 只为让 VS 生成的空 Paint 事件处理程序(panel1_Paint)能编译。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Graphics : IDisposable
|
||||||
|
{
|
||||||
|
private Graphics()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using Sim.Host;
|
||||||
|
|
||||||
|
namespace System.Windows.Forms
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ActiveX 控件的基类。VS 把 COM 控件拖到窗体上时生成 OcxState = resources.GetObject("…OcxState"),
|
||||||
|
/// 平台编译时给每个窗体嵌了空的资源,这里取到的是 null,不影响使用。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AxHost : Control, ISupportInitialize
|
||||||
|
{
|
||||||
|
protected AxHost(string clsid) { }
|
||||||
|
|
||||||
|
public State? OcxState { get; set; }
|
||||||
|
|
||||||
|
public void BeginInit() { }
|
||||||
|
public void EndInit() { }
|
||||||
|
|
||||||
|
public class State { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace WMPLib
|
||||||
|
{
|
||||||
|
public enum WMPPlayState
|
||||||
|
{
|
||||||
|
wmppsUndefined = 0, wmppsStopped = 1, wmppsPaused = 2, wmppsPlaying = 3, wmppsScanForward = 4, wmppsScanReverse = 5,
|
||||||
|
wmppsBuffering = 6, wmppsWaiting = 7, wmppsMediaEnded = 8, wmppsTransitioning = 9, wmppsReady = 10, wmppsReconnecting = 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>播放控制:axWindowsMediaPlayer1.Ctlcontrols.play()</summary>
|
||||||
|
public interface IWMPControls
|
||||||
|
{
|
||||||
|
void play();
|
||||||
|
void pause();
|
||||||
|
void stop();
|
||||||
|
double currentPosition { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>播放设置:axWindowsMediaPlayer1.settings.volume</summary>
|
||||||
|
public interface IWMPSettings
|
||||||
|
{
|
||||||
|
bool autoStart { get; set; }
|
||||||
|
int volume { get; set; }
|
||||||
|
bool mute { get; set; }
|
||||||
|
void setMode(string bstrMode, bool varfMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace AxWMPLib
|
||||||
|
{
|
||||||
|
using WMPLib;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows Media Player 控件(教材项目 7 的"广播"播放 ring.mp3)。和真的一样:设置 URL 后自动开始播放(settings.autoStart),
|
||||||
|
/// 也可以调用 Ctlcontrols.play()。声音在浏览器里播放;实训台上的广播喇叭随之亮起。只支持本机文件。
|
||||||
|
/// </summary>
|
||||||
|
[ClientType("AxWindowsMediaPlayer")]
|
||||||
|
public class AxWindowsMediaPlayer : AxHost
|
||||||
|
{
|
||||||
|
private string _url = "";
|
||||||
|
private WMPPlayState _playState = WMPPlayState.wmppsUndefined;
|
||||||
|
|
||||||
|
public AxWindowsMediaPlayer() : base("6bf52a52-394a-11d3-b153-00c04f79faa6")
|
||||||
|
{
|
||||||
|
Ctlcontrols = new PlayerControls(this);
|
||||||
|
settings = new Settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size DefaultSize => new(245, 240);
|
||||||
|
|
||||||
|
public string URL
|
||||||
|
{
|
||||||
|
get => _url;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_url = value ?? "";
|
||||||
|
if (settings.autoStart && _url.Length > 0) Play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IWMPControls Ctlcontrols { get; }
|
||||||
|
|
||||||
|
// 属性名和 COM 接口一样是小写
|
||||||
|
public IWMPSettings settings { get; }
|
||||||
|
|
||||||
|
public WMPPlayState playState => _playState;
|
||||||
|
|
||||||
|
public string uiMode { get; set; } = "full";
|
||||||
|
public bool stretchToFit { get; set; }
|
||||||
|
public bool enableContextMenu { get; set; } = true;
|
||||||
|
public string status => _playState == WMPPlayState.wmppsPlaying ? "正在播放" : "已停止";
|
||||||
|
|
||||||
|
public event EventHandler? PlayStateChange;
|
||||||
|
|
||||||
|
private void Play()
|
||||||
|
{
|
||||||
|
LegacyThreads.CheckPoint();
|
||||||
|
// 教材写 BaseDirectory + "//Resources//ring.mp3":路径里多余的斜杠在 Linux 上也能用
|
||||||
|
var path = _url.StartsWith("file:///", StringComparison.OrdinalIgnoreCase) ? new Uri(_url).LocalPath : _url;
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
SimHost.SendConsole($"Windows Media Player:找不到要播放的文件 {_url}\n");
|
||||||
|
SetState(WMPPlayState.wmppsStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = File.ReadAllBytes(path);
|
||||||
|
var mime = Path.GetExtension(path).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".wav" => "audio/wav",
|
||||||
|
".ogg" => "audio/ogg",
|
||||||
|
".m4a" or ".aac" => "audio/aac",
|
||||||
|
_ => "audio/mpeg",
|
||||||
|
};
|
||||||
|
var key = SimHost.DataKey(Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant(), data, mime);
|
||||||
|
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "play", ["key"] = key, ["name"] = Path.GetFileName(path) });
|
||||||
|
SetState(WMPPlayState.wmppsPlaying);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stop()
|
||||||
|
{
|
||||||
|
if (_playState != WMPPlayState.wmppsPlaying) return;
|
||||||
|
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "stop" });
|
||||||
|
SetState(WMPPlayState.wmppsStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(WMPPlayState state)
|
||||||
|
{
|
||||||
|
if (_playState == state) return;
|
||||||
|
_playState = state;
|
||||||
|
PlayStateChange?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void WriteProps(JsonObject props)
|
||||||
|
{
|
||||||
|
base.WriteProps(props);
|
||||||
|
props["URL"] = _url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlayerControls(AxWindowsMediaPlayer owner) : IWMPControls
|
||||||
|
{
|
||||||
|
public void play()
|
||||||
|
{
|
||||||
|
if (owner._playState != WMPPlayState.wmppsPlaying && owner._url.Length > 0) owner.Play();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void pause() => owner.Stop();
|
||||||
|
public void stop() => owner.Stop();
|
||||||
|
public double currentPosition { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Settings : IWMPSettings
|
||||||
|
{
|
||||||
|
public bool autoStart { get; set; } = true;
|
||||||
|
public int volume { get; set; } = 50;
|
||||||
|
public bool mute { get; set; }
|
||||||
|
public void setMode(string bstrMode, bool varfMode) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Data;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Sim.Host;
|
||||||
|
|
||||||
|
namespace System.Windows.Forms.DataVisualization.Charting;
|
||||||
|
|
||||||
|
public enum SeriesChartType
|
||||||
|
{
|
||||||
|
Point, FastPoint, Bubble, Line, Spline, StepLine, FastLine, Bar, StackedBar, StackedBar100, Column, StackedColumn, StackedColumn100,
|
||||||
|
Area, SplineArea, StackedArea, StackedArea100, Pie, Doughnut, Stock, Candlestick, Range, SplineRange, RangeBar, RangeColumn,
|
||||||
|
Radar, Polar, ErrorBar, BoxPlot, Renko, ThreeLineBreak, Kagi, PointAndFigure, Funnel, Pyramid,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChartValueType { Auto, Double, Single, Int32, Int64, UInt32, UInt64, String, DateTime, Date, Time, DateTimeOffset }
|
||||||
|
public enum Docking { Top, Right, Bottom, Left }
|
||||||
|
public enum MarkerStyle { None, Square, Circle, Diamond, Triangle, Cross, Star4, Star5, Star6, Star10 }
|
||||||
|
public enum ChartColorPalette { None, Bright, Grayscale, Excel, Light, Pastel, EarthTones, SemiTransparent, Berry, Chocolate, Fire, SeaGreen, BrightPastel }
|
||||||
|
public enum StringAlignment { Near, Center, Far }
|
||||||
|
|
||||||
|
/// <summary>图表里的元素(ChartArea、Legend、Series、Title):有名字,改动时通知所在的图表重画</summary>
|
||||||
|
public abstract class ChartNamedElement
|
||||||
|
{
|
||||||
|
private string _name = "";
|
||||||
|
|
||||||
|
internal Chart? Owner { get; set; }
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_name = value ?? "";
|
||||||
|
Changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? Tag { get; set; }
|
||||||
|
|
||||||
|
internal void Changed() => Owner?.Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ChartAreas、Legends、Series、Titles 共用:按序号或名字取,Add 一个对象或一个名字</summary>
|
||||||
|
public class ChartNamedElementCollection<T> : IEnumerable<T> where T : ChartNamedElement, new()
|
||||||
|
{
|
||||||
|
private readonly List<T> _items = new();
|
||||||
|
private readonly Chart _owner;
|
||||||
|
private readonly string _prefix;
|
||||||
|
|
||||||
|
internal ChartNamedElementCollection(Chart owner, string prefix)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count => _items.Count;
|
||||||
|
public T this[int index] => _items[index];
|
||||||
|
|
||||||
|
public T this[string name] =>
|
||||||
|
FindByName(name) ?? throw new ArgumentException($"在集合中找不到名为“{name}”的图表元素。");
|
||||||
|
|
||||||
|
public T? FindByName(string name) => _items.FirstOrDefault(i => i.Name == name);
|
||||||
|
|
||||||
|
public void Add(T item)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(item.Name)) item.Name = NextName();
|
||||||
|
item.Owner = _owner;
|
||||||
|
_items.Add(item);
|
||||||
|
_owner.Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Add(string name)
|
||||||
|
{
|
||||||
|
var item = new T { Name = name };
|
||||||
|
Add(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IndexOf(string name) => _items.FindIndex(i => i.Name == name);
|
||||||
|
public bool Remove(T item)
|
||||||
|
{
|
||||||
|
var removed = _items.Remove(item);
|
||||||
|
_owner.Invalidated();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveAt(int index)
|
||||||
|
{
|
||||||
|
_items.RemoveAt(index);
|
||||||
|
_owner.Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
_owner.Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NextName()
|
||||||
|
{
|
||||||
|
for (var i = 1; ; i++)
|
||||||
|
if (FindByName($"{_prefix}{i}") == null) return $"{_prefix}{i}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Axis
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public double Minimum { get; set; } = double.NaN;
|
||||||
|
public double Maximum { get; set; } = double.NaN;
|
||||||
|
public double Interval { get; set; }
|
||||||
|
public Grid MajorGrid { get; } = new();
|
||||||
|
public LabelStyle LabelStyle { get; } = new();
|
||||||
|
public bool IsStartedFromZero { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Grid
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public Color LineColor { get; set; } = Color.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LabelStyle
|
||||||
|
{
|
||||||
|
public string Format { get; set; } = "";
|
||||||
|
public int Angle { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChartArea : ChartNamedElement
|
||||||
|
{
|
||||||
|
public ChartArea() { }
|
||||||
|
public ChartArea(string name) => Name = name;
|
||||||
|
|
||||||
|
public Axis AxisX { get; } = new();
|
||||||
|
public Axis AxisY { get; } = new();
|
||||||
|
public Axis AxisX2 { get; } = new();
|
||||||
|
public Axis AxisY2 { get; } = new();
|
||||||
|
public Color BackColor { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Legend : ChartNamedElement
|
||||||
|
{
|
||||||
|
public Legend() { }
|
||||||
|
public Legend(string name) => Name = name;
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public Docking Docking { get; set; } = Docking.Right;
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public Color TitleForeColor { get; set; } = Color.Black;
|
||||||
|
public Color BackColor { get; set; }
|
||||||
|
public Color ForeColor { get; set; } = Color.Black;
|
||||||
|
public StringAlignment Alignment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Title : ChartNamedElement
|
||||||
|
{
|
||||||
|
private string _text = "";
|
||||||
|
|
||||||
|
public Title() { }
|
||||||
|
public Title(string text) => _text = text;
|
||||||
|
|
||||||
|
public string Text
|
||||||
|
{
|
||||||
|
get => _text;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_text = value ?? "";
|
||||||
|
Changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Font? Font { get; set; }
|
||||||
|
public Color ForeColor { get; set; } = Color.Black;
|
||||||
|
public Docking Docking { get; set; } = Docking.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DataPoint
|
||||||
|
{
|
||||||
|
public DataPoint() { }
|
||||||
|
|
||||||
|
public DataPoint(double xValue, double yValue)
|
||||||
|
{
|
||||||
|
XValue = xValue;
|
||||||
|
YValues = new[] { yValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
public double XValue { get; set; }
|
||||||
|
public double[] YValues { get; set; } = { 0 };
|
||||||
|
/// <summary>X 值是文字(如日期字符串)时显示的标签</summary>
|
||||||
|
public string AxisLabel { get; set; } = "";
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public Color Color { get; set; }
|
||||||
|
public bool IsEmpty { get; set; }
|
||||||
|
public string ToolTip { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DataPointCollection : IEnumerable<DataPoint>
|
||||||
|
{
|
||||||
|
private readonly List<DataPoint> _items = new();
|
||||||
|
private readonly Series _series;
|
||||||
|
|
||||||
|
internal DataPointCollection(Series series) => _series = series;
|
||||||
|
|
||||||
|
public int Count => _items.Count;
|
||||||
|
public DataPoint this[int index] => _items[index];
|
||||||
|
|
||||||
|
/// <summary>加一个点:X 是数字或日期时按数值,否则(如字符串)当作分类标签,和 WinForms 一样</summary>
|
||||||
|
public int AddXY(object? xValue, params object?[] yValue)
|
||||||
|
{
|
||||||
|
var point = new DataPoint { YValues = yValue.Select(ToDouble).ToArray() };
|
||||||
|
switch (xValue)
|
||||||
|
{
|
||||||
|
case null or DBNull:
|
||||||
|
break;
|
||||||
|
case DateTime d:
|
||||||
|
point.XValue = d.ToOADate();
|
||||||
|
point.AxisLabel = d.ToString(CultureInfo.CurrentCulture);
|
||||||
|
break;
|
||||||
|
case IConvertible c when xValue is not string:
|
||||||
|
point.XValue = c.ToDouble(CultureInfo.InvariantCulture);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
point.AxisLabel = Convert.ToString(xValue, CultureInfo.CurrentCulture) ?? "";
|
||||||
|
point.XValue = _items.Count + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_items.Add(point);
|
||||||
|
_series.Changed();
|
||||||
|
return _items.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int AddY(params object?[] yValue) => AddXY(_items.Count + 1, yValue);
|
||||||
|
|
||||||
|
public void Add(DataPoint point)
|
||||||
|
{
|
||||||
|
_items.Add(point);
|
||||||
|
_series.Changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
_series.Changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveAt(int index)
|
||||||
|
{
|
||||||
|
_items.RemoveAt(index);
|
||||||
|
_series.Changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按两组值绑定:xValues 和 yValues 一一对应</summary>
|
||||||
|
public void DataBindXY(IEnumerable xValues, params IEnumerable[] yValues)
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
var xs = xValues.Cast<object?>().ToList();
|
||||||
|
var ys = yValues.Select(y => y.Cast<object?>().ToList()).ToList();
|
||||||
|
for (var i = 0; i < xs.Count; i++) AddXY(xs[i], ys.Select(y => i < y.Count ? y[i] : null).ToArray());
|
||||||
|
_series.Changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DataBindY(params IEnumerable[] yValues) => DataBindXY(Enumerable.Range(1, yValues[0].Cast<object>().Count()).ToList(), yValues);
|
||||||
|
|
||||||
|
internal static double ToDouble(object? value) => value switch
|
||||||
|
{
|
||||||
|
null or DBNull => double.NaN,
|
||||||
|
DateTime d => d.ToOADate(),
|
||||||
|
IConvertible c => double.TryParse(Convert.ToString(c, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : double.NaN,
|
||||||
|
_ => double.NaN,
|
||||||
|
};
|
||||||
|
|
||||||
|
public IEnumerator<DataPoint> GetEnumerator() => _items.GetEnumerator();
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Series : ChartNamedElement
|
||||||
|
{
|
||||||
|
private SeriesChartType _chartType = SeriesChartType.Column;
|
||||||
|
private string _legendText = "";
|
||||||
|
private Color _color;
|
||||||
|
|
||||||
|
public Series()
|
||||||
|
{
|
||||||
|
Points = new DataPointCollection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Series(string name) : this() => Name = name;
|
||||||
|
|
||||||
|
public DataPointCollection Points { get; }
|
||||||
|
|
||||||
|
public SeriesChartType ChartType
|
||||||
|
{
|
||||||
|
get => _chartType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_chartType = value;
|
||||||
|
Changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ChartArea { get; set; } = "";
|
||||||
|
public string Legend { get; set; } = "";
|
||||||
|
|
||||||
|
public string LegendText
|
||||||
|
{
|
||||||
|
get => _legendText;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_legendText = value ?? "";
|
||||||
|
Changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string XValueMember { get; set; } = "";
|
||||||
|
public string YValueMembers { get; set; } = "";
|
||||||
|
public ChartValueType XValueType { get; set; }
|
||||||
|
public ChartValueType YValueType { get; set; }
|
||||||
|
public int YValuesPerPoint { get; set; } = 1;
|
||||||
|
public bool IsVisibleInLegend { get; set; } = true;
|
||||||
|
public bool IsValueShownAsLabel { get; set; }
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int BorderWidth { get; set; } = 1;
|
||||||
|
public MarkerStyle MarkerStyle { get; set; }
|
||||||
|
public int MarkerSize { get; set; } = 5;
|
||||||
|
public Font? Font { get; set; }
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public string ToolTip { get; set; } = "";
|
||||||
|
|
||||||
|
public Color Color
|
||||||
|
{
|
||||||
|
get => _color;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_color = value;
|
||||||
|
Changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图表(教材项目 8 显示最近 7 天的最高温和平均温)。和 WinForms 的 Chart 一样,设置 DataSource 后调用 DataBind()
|
||||||
|
/// 按各 Series 的 XValueMember / YValueMembers 取数据,也可以用 Points.AddXY 自己加点。浏览器用 Chart.js 画。
|
||||||
|
/// </summary>
|
||||||
|
public class Chart : Control, ISupportInitialize
|
||||||
|
{
|
||||||
|
private bool _initializing;
|
||||||
|
private bool _dirty;
|
||||||
|
|
||||||
|
public Chart()
|
||||||
|
{
|
||||||
|
ChartAreas = new ChartNamedElementCollection<ChartArea>(this, "ChartArea");
|
||||||
|
Legends = new ChartNamedElementCollection<Legend>(this, "Legend");
|
||||||
|
Series = new ChartNamedElementCollection<Series>(this, "Series");
|
||||||
|
Titles = new ChartNamedElementCollection<Title>(this, "Title");
|
||||||
|
BackColor = Color.White;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size DefaultSize => new(300, 300);
|
||||||
|
|
||||||
|
public ChartNamedElementCollection<ChartArea> ChartAreas { get; }
|
||||||
|
public ChartNamedElementCollection<Legend> Legends { get; }
|
||||||
|
public ChartNamedElementCollection<Series> Series { get; }
|
||||||
|
public ChartNamedElementCollection<Title> Titles { get; }
|
||||||
|
public object? DataSource { get; set; }
|
||||||
|
public ChartColorPalette Palette { get; set; } = ChartColorPalette.BrightPastel;
|
||||||
|
public Color BorderlineColor { get; set; }
|
||||||
|
public int BorderlineWidth { get; set; } = 1;
|
||||||
|
|
||||||
|
public void BeginInit() => _initializing = true;
|
||||||
|
|
||||||
|
public void EndInit()
|
||||||
|
{
|
||||||
|
_initializing = false;
|
||||||
|
Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按 DataSource 重新取数据:每个设置了 YValueMembers 的 Series 清空后,按行加点</summary>
|
||||||
|
public void DataBind()
|
||||||
|
{
|
||||||
|
var rows = Rows(DataSource);
|
||||||
|
foreach (var series in Series)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(series.YValueMembers)) continue;
|
||||||
|
series.Points.Clear();
|
||||||
|
var yMembers = series.YValueMembers.Split(',').Select(m => m.Trim()).ToArray();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var x = string.IsNullOrEmpty(series.XValueMember) ? null : Field(row, series.XValueMember);
|
||||||
|
series.Points.AddXY(x ?? series.Points.Count + 1, yMembers.Select(m => Field(row, m)).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invalidated();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<object> Rows(object? source) => source switch
|
||||||
|
{
|
||||||
|
null => new List<object>(),
|
||||||
|
DataSet set => set.Tables.Count > 0 ? set.Tables[0].Rows.Cast<object>().ToList() : new List<object>(),
|
||||||
|
DataTable table => table.Rows.Cast<object>().ToList(),
|
||||||
|
DataView view => view.Cast<object>().ToList(),
|
||||||
|
IEnumerable list => list.Cast<object>().ToList(),
|
||||||
|
_ => throw new ArgumentException("图表的 DataSource 要是 DataTable、DataView、DataSet 或列表。"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object? Field(object row, string member)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return row switch
|
||||||
|
{
|
||||||
|
DataRow r => r[member],
|
||||||
|
DataRowView v => v[member],
|
||||||
|
_ => row.GetType().GetProperty(member)?.GetValue(row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"数据源里没有名为“{member}”的列(检查 XValueMember / YValueMembers)。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>图表内容变了:攒到这一轮消息处理结束时再发给浏览器</summary>
|
||||||
|
internal void Invalidated()
|
||||||
|
{
|
||||||
|
if (_initializing || !IsAttached || _dirty) return;
|
||||||
|
_dirty = true;
|
||||||
|
SimHost.Ui.Post(() =>
|
||||||
|
{
|
||||||
|
_dirty = false;
|
||||||
|
NotifyChanged("Chart", ChartJson());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject ChartJson()
|
||||||
|
{
|
||||||
|
var series = new JsonArray();
|
||||||
|
foreach (var s in Series)
|
||||||
|
{
|
||||||
|
if (!s.Enabled) continue;
|
||||||
|
var points = new JsonArray();
|
||||||
|
foreach (var p in s.Points)
|
||||||
|
points.Add(new JsonObject { ["x"] = p.AxisLabel.Length > 0 ? p.AxisLabel : p.XValue, ["y"] = p.YValues.Length > 0 && !double.IsNaN(p.YValues[0]) ? p.YValues[0] : null });
|
||||||
|
series.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = s.Name,
|
||||||
|
["type"] = s.ChartType.ToString(),
|
||||||
|
["legend"] = s.LegendText.Length > 0 ? s.LegendText : s.Name,
|
||||||
|
["inLegend"] = s.IsVisibleInLegend && Legends.Count > 0 && Legends.Any(l => l.Enabled),
|
||||||
|
["color"] = PropValue.ColorCss(s.Color),
|
||||||
|
["width"] = s.BorderWidth,
|
||||||
|
["labels"] = s.IsValueShownAsLabel,
|
||||||
|
["points"] = points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var titles = new JsonArray();
|
||||||
|
foreach (var t in Titles) titles.Add(t.Text);
|
||||||
|
var area = ChartAreas.Count > 0 ? ChartAreas[0] : null;
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["series"] = series,
|
||||||
|
["titles"] = titles,
|
||||||
|
["legendDocking"] = Legends.Count > 0 ? Legends[0].Docking.ToString() : "Right",
|
||||||
|
["yMin"] = area != null && !double.IsNaN(area.AxisY.Minimum) ? area.AxisY.Minimum : null,
|
||||||
|
["yMax"] = area != null && !double.IsNaN(area.AxisY.Maximum) ? area.AxisY.Maximum : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void WriteProps(JsonObject props)
|
||||||
|
{
|
||||||
|
base.WriteProps(props);
|
||||||
|
props["Chart"] = ChartJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,21 @@ public class Panel : ScrollableControl
|
|||||||
public BorderStyle BorderStyle
|
public BorderStyle BorderStyle
|
||||||
{
|
{
|
||||||
get => _borderStyle;
|
get => _borderStyle;
|
||||||
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
|
set
|
||||||
|
{
|
||||||
|
if (SetProp(ref _borderStyle, value, nameof(BorderStyle))) PerformLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>边框占掉的宽度:单线 1 像素,立体 2 像素</summary>
|
||||||
|
public override Rectangle DisplayRectangle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var inset = _borderStyle switch { BorderStyle.FixedSingle => 1, BorderStyle.Fixed3D => 2, _ => 0 };
|
||||||
|
var rect = base.DisplayRectangle;
|
||||||
|
return new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - 2 * inset), Math.Max(0, rect.Height - 2 * inset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void WriteProps(JsonObject props)
|
protected override void WriteProps(JsonObject props)
|
||||||
@@ -58,4 +72,14 @@ public class GroupBox : Control
|
|||||||
protected override Size DefaultSize => new(200, 100);
|
protected override Size DefaultSize => new(200, 100);
|
||||||
|
|
||||||
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
|
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
|
||||||
|
|
||||||
|
/// <summary>分组框的边框和标题占掉的地方(宋体 9pt 时和 WinForms 一致)</summary>
|
||||||
|
public override Rectangle DisplayRectangle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var rect = base.DisplayRectangle;
|
||||||
|
return new Rectangle(rect.X + 3, rect.Y + 17, Math.Max(0, rect.Width - 6), Math.Max(0, rect.Height - 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,10 @@ public class Control : Component, IWin32Window
|
|||||||
private Font? _font;
|
private Font? _font;
|
||||||
private bool _autoSize;
|
private bool _autoSize;
|
||||||
private DockStyle _dock;
|
private DockStyle _dock;
|
||||||
|
private Padding _padding;
|
||||||
|
private int _layoutSuspendCount;
|
||||||
|
private Image? _backgroundImage;
|
||||||
|
private ImageLayout _backgroundImageLayout = ImageLayout.Tile;
|
||||||
private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left;
|
private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left;
|
||||||
private Control? _parent;
|
private Control? _parent;
|
||||||
|
|
||||||
@@ -131,7 +135,13 @@ public class Control : Component, IWin32Window
|
|||||||
public Size Size
|
public Size Size
|
||||||
{
|
{
|
||||||
get => _size;
|
get => _size;
|
||||||
set => SetProp(ref _size, value, nameof(Size));
|
set
|
||||||
|
{
|
||||||
|
if (!SetProp(ref _size, value, nameof(Size))) return;
|
||||||
|
// 大小变了:自己的停靠子控件重新排;自己是停靠的,父容器也要重新排
|
||||||
|
PerformLayout();
|
||||||
|
if (_dock != DockStyle.None) _parent?.PerformLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Left { get => _location.X; set => Location = new Point(value, _location.Y); }
|
public int Left { get => _location.X; set => Location = new Point(value, _location.Y); }
|
||||||
@@ -162,28 +172,48 @@ public class Control : Component, IWin32Window
|
|||||||
get => _visible;
|
get => _visible;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (SetProp(ref _visible, value, nameof(Visible))) OnVisibleChanged(EventArgs.Empty);
|
if (!SetProp(ref _visible, value, nameof(Visible))) return;
|
||||||
|
if (_dock != DockStyle.None) _parent?.PerformLayout();
|
||||||
|
OnVisibleChanged(EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +232,40 @@ public class Control : Component, IWin32Window
|
|||||||
public DockStyle Dock
|
public DockStyle Dock
|
||||||
{
|
{
|
||||||
get => _dock;
|
get => _dock;
|
||||||
set => SetProp(ref _dock, value, nameof(Dock));
|
set
|
||||||
|
{
|
||||||
|
if (SetProp(ref _dock, value, nameof(Dock))) _parent?.PerformLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Padding Padding
|
||||||
|
{
|
||||||
|
get => _padding;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_padding.Equals(value)) return;
|
||||||
|
_padding = value;
|
||||||
|
PerformLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>背景图片;BackgroundImageLayout 决定平铺(默认)、居中、拉伸或按比例缩放</summary>
|
||||||
|
public virtual Image? BackgroundImage
|
||||||
|
{
|
||||||
|
get => _backgroundImage;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
LegacyThreads.CheckPoint();
|
||||||
|
if (ReferenceEquals(_backgroundImage, value)) return;
|
||||||
|
_backgroundImage = value;
|
||||||
|
NotifyChanged(nameof(BackgroundImage), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual ImageLayout BackgroundImageLayout
|
||||||
|
{
|
||||||
|
get => _backgroundImageLayout;
|
||||||
|
set => SetProp(ref _backgroundImageLayout, value, nameof(BackgroundImageLayout));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AnchorStyles Anchor
|
public AnchorStyles Anchor
|
||||||
@@ -216,7 +279,6 @@ public class Control : Component, IWin32Window
|
|||||||
public bool TabStop { get; set; } = true;
|
public bool TabStop { get; set; } = true;
|
||||||
public object? Tag { get; set; }
|
public object? Tag { get; set; }
|
||||||
public Padding Margin { get; set; } = new(3);
|
public Padding Margin { get; set; } = new(3);
|
||||||
public Padding Padding { get; set; }
|
|
||||||
public ImeMode ImeMode { get; set; } = ImeMode.NoControl;
|
public ImeMode ImeMode { get; set; } = ImeMode.NoControl;
|
||||||
public virtual RightToLeft RightToLeft { get; set; } = RightToLeft.No;
|
public virtual RightToLeft RightToLeft { get; set; } = RightToLeft.No;
|
||||||
public virtual bool AllowDrop { get; set; }
|
public virtual bool AllowDrop { get; set; }
|
||||||
@@ -246,6 +308,8 @@ public class Control : Component, IWin32Window
|
|||||||
public event EventHandler? LostFocus;
|
public event EventHandler? LostFocus;
|
||||||
public event EventHandler? Enter;
|
public event EventHandler? Enter;
|
||||||
public event EventHandler? Leave;
|
public event EventHandler? Leave;
|
||||||
|
/// <summary>重绘。平台由浏览器绘制控件,不会触发这个事件(VS 双击 Panel 生成的空处理程序照常能编译)</summary>
|
||||||
|
public event PaintEventHandler? Paint;
|
||||||
|
|
||||||
protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e);
|
protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e);
|
||||||
protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e);
|
protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e);
|
||||||
@@ -256,9 +320,17 @@ 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 OnEnter(EventArgs e) => Enter?.Invoke(this, e);
|
protected virtual void OnEnter(EventArgs e) => Enter?.Invoke(this, e);
|
||||||
protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e);
|
protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e);
|
||||||
|
|
||||||
@@ -305,10 +377,65 @@ public class Control : Component, IWin32Window
|
|||||||
public void Refresh() { }
|
public void Refresh() { }
|
||||||
public void Invalidate() { }
|
public void Invalidate() { }
|
||||||
public void Update() { }
|
public void Update() { }
|
||||||
public void SuspendLayout() { }
|
public void SuspendLayout() => _layoutSuspendCount++;
|
||||||
public void ResumeLayout() { }
|
public void ResumeLayout() => ResumeLayout(true);
|
||||||
public void ResumeLayout(bool performLayout) { }
|
|
||||||
public void PerformLayout() { }
|
public void ResumeLayout(bool performLayout)
|
||||||
|
{
|
||||||
|
if (_layoutSuspendCount > 0) _layoutSuspendCount--;
|
||||||
|
if (performLayout) PerformLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 停靠布局(和 WinForms 一样):按 Controls 集合从后往前,Top / Bottom / Left / Right 依次贴边并占掉那一条,
|
||||||
|
/// Fill 占剩下的全部。VS 在 Designer.cs 里写的位置大小就是这样排出来的;代码里改停靠、大小、可见时重新排。
|
||||||
|
/// </summary>
|
||||||
|
public void PerformLayout()
|
||||||
|
{
|
||||||
|
if (_layoutSuspendCount > 0) return;
|
||||||
|
var rect = DisplayRectangle;
|
||||||
|
for (var i = Controls.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var child = Controls[i];
|
||||||
|
if (!child._visible || child._dock == DockStyle.None) continue;
|
||||||
|
var size = child._size;
|
||||||
|
Rectangle bounds;
|
||||||
|
switch (child._dock)
|
||||||
|
{
|
||||||
|
case DockStyle.Top:
|
||||||
|
bounds = new Rectangle(rect.X, rect.Y, rect.Width, size.Height);
|
||||||
|
rect = new Rectangle(rect.X, rect.Y + size.Height, rect.Width, Math.Max(0, rect.Height - size.Height));
|
||||||
|
break;
|
||||||
|
case DockStyle.Bottom:
|
||||||
|
bounds = new Rectangle(rect.X, rect.Bottom - size.Height, rect.Width, size.Height);
|
||||||
|
rect = new Rectangle(rect.X, rect.Y, rect.Width, Math.Max(0, rect.Height - size.Height));
|
||||||
|
break;
|
||||||
|
case DockStyle.Left:
|
||||||
|
bounds = new Rectangle(rect.X, rect.Y, size.Width, rect.Height);
|
||||||
|
rect = new Rectangle(rect.X + size.Width, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
|
||||||
|
break;
|
||||||
|
case DockStyle.Right:
|
||||||
|
bounds = new Rectangle(rect.Right - size.Width, rect.Y, size.Width, rect.Height);
|
||||||
|
rect = new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
bounds = rect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
child.SetBoundsFromLayout(bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>停靠布局给出的位置大小:不再反过来触发父容器的布局</summary>
|
||||||
|
private void SetBoundsFromLayout(Rectangle bounds)
|
||||||
|
{
|
||||||
|
SetProp(ref _location, bounds.Location, nameof(Location));
|
||||||
|
if (SetProp(ref _size, bounds.Size, nameof(Size))) PerformLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>容器里用来停靠子控件的区域:客户区去掉内边距(分组框、带边框的面板再去掉边框)</summary>
|
||||||
|
public virtual Rectangle DisplayRectangle =>
|
||||||
|
new(_padding.Left, _padding.Top, Math.Max(0, ClientSize.Width - _padding.Horizontal), Math.Max(0, ClientSize.Height - _padding.Vertical));
|
||||||
|
|
||||||
public bool InvokeRequired => !SimHost.Ui.IsUiThread;
|
public bool InvokeRequired => !SimHost.Ui.IsUiThread;
|
||||||
|
|
||||||
@@ -409,6 +536,8 @@ public class Control : Component, IWin32Window
|
|||||||
props["Font"] = PropValue.From(_font);
|
props["Font"] = PropValue.From(_font);
|
||||||
props["AutoSize"] = _autoSize;
|
props["AutoSize"] = _autoSize;
|
||||||
props["Dock"] = _dock.ToString();
|
props["Dock"] = _dock.ToString();
|
||||||
|
props["BackgroundImage"] = PropValue.From(_backgroundImage);
|
||||||
|
props["BackgroundImageLayout"] = _backgroundImageLayout.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。</summary>
|
/// <summary>浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。</summary>
|
||||||
@@ -459,6 +588,7 @@ public class Control : Component, IWin32Window
|
|||||||
value.Parent?.Controls.Remove(value);
|
value.Parent?.Controls.Remove(value);
|
||||||
_items.Add(value);
|
_items.Add(value);
|
||||||
value.SetParentInternal(Owner);
|
value.SetParentInternal(Owner);
|
||||||
|
if (value.Dock != DockStyle.None) Owner.PerformLayout();
|
||||||
if (Owner.IsAttached)
|
if (Owner.IsAttached)
|
||||||
{
|
{
|
||||||
SimHost.Send(new JsonObject
|
SimHost.Send(new JsonObject
|
||||||
@@ -481,6 +611,7 @@ public class Control : Component, IWin32Window
|
|||||||
if (value == null || !_items.Remove(value)) return;
|
if (value == null || !_items.Remove(value)) return;
|
||||||
var wasAttached = Owner.IsAttached;
|
var wasAttached = Owner.IsAttached;
|
||||||
value.SetParentInternal(null);
|
value.SetParentInternal(null);
|
||||||
|
if (value.Dock != DockStyle.None) Owner.PerformLayout();
|
||||||
if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId });
|
if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -626,8 +628,10 @@ public class DataGridView : Control, ISupportInitialize
|
|||||||
public event DataGridViewCellEventHandler? CellContentDoubleClick;
|
public event DataGridViewCellEventHandler? CellContentDoubleClick;
|
||||||
public event DataGridViewCellEventHandler? CellValueChanged;
|
public event DataGridViewCellEventHandler? CellValueChanged;
|
||||||
public event DataGridViewCellEventHandler? CellEndEdit;
|
public event DataGridViewCellEventHandler? CellEndEdit;
|
||||||
|
#pragma warning disable CS0067 // 只读表格没有当前单元格的概念,这两个事件不触发
|
||||||
public event DataGridViewCellEventHandler? CellEnter;
|
public event DataGridViewCellEventHandler? CellEnter;
|
||||||
public event DataGridViewCellEventHandler? RowEnter;
|
public event DataGridViewCellEventHandler? RowEnter;
|
||||||
|
#pragma warning restore CS0067
|
||||||
public event DataGridViewDataErrorEventHandler? DataError;
|
public event DataGridViewDataErrorEventHandler? DataError;
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
public event EventHandler? CurrentCellChanged;
|
public event EventHandler? CurrentCellChanged;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class DateTimePicker : Control
|
|||||||
public static readonly DateTime MinimumDateTime = new(1753, 1, 1);
|
public static readonly DateTime MinimumDateTime = new(1753, 1, 1);
|
||||||
public static readonly DateTime MaximumDateTime = new(9998, 12, 31);
|
public static readonly DateTime MaximumDateTime = new(9998, 12, 31);
|
||||||
|
|
||||||
private DateTime _value = DateTime.Now;
|
private DateTime _value = SimClock.Now;
|
||||||
private DateTimePickerFormat _format = DateTimePickerFormat.Long;
|
private DateTimePickerFormat _format = DateTimePickerFormat.Long;
|
||||||
private string? _customFormat;
|
private string? _customFormat;
|
||||||
private bool _showUpDown;
|
private bool _showUpDown;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public enum ComboBoxStyle { Simple, DropDown, DropDownList }
|
|||||||
|
|
||||||
public enum DockStyle { None, Top, Bottom, Left, Right, Fill }
|
public enum DockStyle { None, Top, Bottom, Left, Right, Fill }
|
||||||
|
|
||||||
|
public enum ImageLayout { None, Tile, Center, Stretch, Zoom }
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 }
|
public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 }
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,20 @@ public class FormClosedEventArgs : EventArgs
|
|||||||
}
|
}
|
||||||
|
|
||||||
public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e);
|
public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e);
|
||||||
|
|
||||||
|
/// <summary>重绘事件的参数。平台由浏览器绘制控件,不支持在 Graphics 上自己画</summary>
|
||||||
|
public class PaintEventArgs : EventArgs, IDisposable
|
||||||
|
{
|
||||||
|
public PaintEventArgs(System.Drawing.Graphics graphics, System.Drawing.Rectangle clipRect)
|
||||||
|
{
|
||||||
|
Graphics = graphics;
|
||||||
|
ClipRectangle = clipRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public System.Drawing.Graphics Graphics { get; }
|
||||||
|
public System.Drawing.Rectangle ClipRectangle { get; }
|
||||||
|
|
||||||
|
public void Dispose() => GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delegate void PaintEventHandler(object? sender, PaintEventArgs e);
|
||||||
@@ -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;
|
||||||
@@ -33,13 +35,16 @@ public class Form : ContainerControl
|
|||||||
public override Size ClientSize
|
public override Size ClientSize
|
||||||
{
|
{
|
||||||
get => _clientSize;
|
get => _clientSize;
|
||||||
set => SetProp(ref _clientSize, value, nameof(ClientSize));
|
set
|
||||||
|
{
|
||||||
|
if (SetProp(ref _clientSize, value, nameof(ClientSize))) PerformLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public new Size Size
|
public new Size Size
|
||||||
{
|
{
|
||||||
get => _clientSize + NonClientSize;
|
get => _formBorderStyle == FormBorderStyle.None ? _clientSize : _clientSize + NonClientSize;
|
||||||
set => ClientSize = value - NonClientSize;
|
set => ClientSize = _formBorderStyle == FormBorderStyle.None ? value : value - NonClientSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public FormBorderStyle FormBorderStyle
|
public FormBorderStyle FormBorderStyle
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Sim.Host;
|
||||||
|
|
||||||
|
namespace System.Windows.Forms;
|
||||||
|
|
||||||
|
public enum TickStyle { None, TopLeft, BottomRight, Both }
|
||||||
|
|
||||||
|
public enum Orientation { Horizontal, Vertical }
|
||||||
|
|
||||||
|
/// <summary>滑动条(教材项目 7 模拟空调温度、窗帘开合)</summary>
|
||||||
|
public class TrackBar : Control, ISupportInitialize
|
||||||
|
{
|
||||||
|
private int _minimum;
|
||||||
|
private int _maximum = 10;
|
||||||
|
private int _value;
|
||||||
|
private int _tickFrequency = 1;
|
||||||
|
private TickStyle _tickStyle = TickStyle.BottomRight;
|
||||||
|
private Orientation _orientation = Orientation.Horizontal;
|
||||||
|
private bool _initializing;
|
||||||
|
|
||||||
|
public TrackBar() => AutoSize = true;
|
||||||
|
|
||||||
|
protected override Size DefaultSize => new(104, 45);
|
||||||
|
|
||||||
|
public int Minimum
|
||||||
|
{
|
||||||
|
get => _minimum;
|
||||||
|
set => SetRange(value, Math.Max(value, _maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Maximum
|
||||||
|
{
|
||||||
|
get => _maximum;
|
||||||
|
set => SetRange(Math.Min(value, _minimum), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Value
|
||||||
|
{
|
||||||
|
get => _value;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!_initializing && (value < _minimum || value > _maximum))
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“Value”无效。“Value”应介于 'Minimum' 和 'Maximum' 之间。");
|
||||||
|
if (SetProp(ref _value, value, nameof(Value))) OnValueChanged(EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TickFrequency
|
||||||
|
{
|
||||||
|
get => _tickFrequency;
|
||||||
|
set => SetProp(ref _tickFrequency, value, nameof(TickFrequency));
|
||||||
|
}
|
||||||
|
|
||||||
|
public TickStyle TickStyle
|
||||||
|
{
|
||||||
|
get => _tickStyle;
|
||||||
|
set => SetProp(ref _tickStyle, value, nameof(TickStyle));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Orientation Orientation
|
||||||
|
{
|
||||||
|
get => _orientation;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _orientation) return;
|
||||||
|
SetProp(ref _orientation, value, nameof(Orientation));
|
||||||
|
Size = new Size(Height, Width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LargeChange { get; set; } = 5;
|
||||||
|
public int SmallChange { get; set; } = 1;
|
||||||
|
|
||||||
|
public event EventHandler? Scroll;
|
||||||
|
public event EventHandler? ValueChanged;
|
||||||
|
|
||||||
|
protected virtual void OnScroll(EventArgs e) => Scroll?.Invoke(this, e);
|
||||||
|
protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e);
|
||||||
|
|
||||||
|
public void SetRange(int minValue, int maxValue)
|
||||||
|
{
|
||||||
|
SetProp(ref _minimum, minValue, nameof(Minimum));
|
||||||
|
SetProp(ref _maximum, Math.Max(minValue, maxValue), nameof(Maximum));
|
||||||
|
if (_value < _minimum) Value = _minimum;
|
||||||
|
else if (_value > _maximum) Value = _maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Designer.cs 里 VS 生成 ((ISupportInitialize)trackBar1).BeginInit() / EndInit():期间先设 Value 再设范围也不报错
|
||||||
|
public void BeginInit() => _initializing = true;
|
||||||
|
|
||||||
|
public void EndInit()
|
||||||
|
{
|
||||||
|
_initializing = false;
|
||||||
|
if (_value < _minimum || _value > _maximum) Value = Math.Clamp(_value, _minimum, _maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用户拖动滑块、点轨道或按方向键:和 WinForms 一样先改 Value(ValueChanged),再触发 Scroll</summary>
|
||||||
|
protected override void OnUserAction(UserAction action)
|
||||||
|
{
|
||||||
|
if (action.Action != "scroll" || !int.TryParse(action.Value, out var value))
|
||||||
|
{
|
||||||
|
base.OnUserAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Value = Math.Clamp(value, _minimum, _maximum);
|
||||||
|
OnScroll(EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void WriteProps(JsonObject props)
|
||||||
|
{
|
||||||
|
base.WriteProps(props);
|
||||||
|
props["Minimum"] = _minimum;
|
||||||
|
props["Maximum"] = _maximum;
|
||||||
|
props["Value"] = _value;
|
||||||
|
props["TickFrequency"] = _tickFrequency;
|
||||||
|
props["TickStyle"] = _tickStyle.ToString();
|
||||||
|
props["Orientation"] = _orientation.ToString();
|
||||||
|
props["LargeChange"] = LargeChange;
|
||||||
|
props["SmallChange"] = SmallChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Sim.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 项目资源(Properties.Resources)的素材库。平台为学生工程生成的 Properties/Resources.Designer.cs 通过这里读取素材,
|
||||||
|
/// 和 VS 一样每次取图片都得到一个新的 Bitmap。
|
||||||
|
/// </summary>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public static class ProjectResources
|
||||||
|
{
|
||||||
|
/// <summary>素材文件所在目录(运行器目录下的 Resources/gallery)</summary>
|
||||||
|
public static string Directory { get; } = Path.Combine(Path.GetDirectoryName(typeof(ProjectResources).Assembly.Location)!, "Resources", "gallery");
|
||||||
|
|
||||||
|
public static Bitmap Image(string file) => (Bitmap)System.Drawing.Image.FromStream(new MemoryStream(Bytes(file)));
|
||||||
|
|
||||||
|
public static byte[] Bytes(string file) => File.ReadAllBytes(Path.Combine(Directory, Path.GetFileName(file)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Sim.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 虚拟时钟(实训台上可以拨时间、调倍速;教材项目 7 按时刻切换情景模式)。
|
||||||
|
/// 编译时把学生代码里的 DateTime.Now / Today / UtcNow 改写成这里的属性(见 Sim.Runner/ClockRewriter.cs)。
|
||||||
|
/// 没有拨过时就是真实时间。
|
||||||
|
/// </summary>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public static class SimClock
|
||||||
|
{
|
||||||
|
private sealed record Setting(DateTime Wall, long RealAt, double Speed);
|
||||||
|
|
||||||
|
private static volatile Setting? _setting;
|
||||||
|
|
||||||
|
public static DateTime Now
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var setting = _setting;
|
||||||
|
if (setting == null) return DateTime.Now;
|
||||||
|
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - setting.RealAt;
|
||||||
|
return setting.Wall.AddMilliseconds(elapsed * setting.Speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime Today => Now.Date;
|
||||||
|
|
||||||
|
public static DateTime UtcNow => Now.ToUniversalTime();
|
||||||
|
|
||||||
|
/// <summary>平台内部使用:应用服务器推送的 {"wall":"2024-01-16T06:40:00","realAt":…,"speed":60},null 恢复真实时间</summary>
|
||||||
|
public static void Update(JsonNode? clock)
|
||||||
|
{
|
||||||
|
if (clock is not JsonObject json
|
||||||
|
|| !DateTime.TryParseExact((string?)json["wall"], "yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var wall))
|
||||||
|
{
|
||||||
|
_setting = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setting = new Setting(DateTime.SpecifyKind(wall, DateTimeKind.Local), (long?)json["realAt"] ?? 0, (double?)json["speed"] ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,19 +86,23 @@ public static class SimHost
|
|||||||
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
|
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||||
|
|
||||||
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
|
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
|
||||||
public static string ImageKey(System.Drawing.Image image)
|
public static string ImageKey(System.Drawing.Image image) => DataKey(image.SimKey, image.Data, image.MimeType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图片、声音等数据在浏览器端的编号(key 通常是内容的哈希):第一次用到时先把数据发过去,之后只发编号。
|
||||||
|
/// </summary>
|
||||||
|
public static string DataKey(string key, byte[] data, string mime)
|
||||||
{
|
{
|
||||||
var key = image.SimKey;
|
|
||||||
bool isNew;
|
bool isNew;
|
||||||
lock (SentImages) isNew = SentImages.Add(key);
|
lock (SentImages) isNew = SentImages.Add(key);
|
||||||
if (isNew)
|
if (isNew)
|
||||||
{
|
{
|
||||||
Send(new JsonObject
|
Send(new JsonObject
|
||||||
{
|
{
|
||||||
["type"] = "image",
|
["type"] = "blob",
|
||||||
["key"] = key,
|
["key"] = key,
|
||||||
["mime"] = image.MimeType,
|
["mime"] = mime,
|
||||||
["data"] = Convert.ToBase64String(image.Data),
|
["data"] = Convert.ToBase64String(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return key;
|
return key;
|
||||||
@@ -130,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>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace Sim.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 写法的路径(教材里常见 BaseDirectory + "Camera\\")在 Linux 上要把反斜杠换成斜杠,否则反斜杠成了文件名的一部分。
|
||||||
|
/// 编译时把学生代码里传给文件、文件夹、图片读写方法的路径参数包上这个方法(见 Sim.Runner/PathRewriter.cs)。
|
||||||
|
/// </summary>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public static class WindowsPaths
|
||||||
|
{
|
||||||
|
public static string Fix(string path) => path?.Replace('\\', '/')!;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 项目资源素材库
|
||||||
|
|
||||||
|
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
|
||||||
|
|
||||||
|
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型;图片还有编号 key 和两倍图 hidpi,由 `build-gallery.sh` 写入)。`name.png` 是原尺寸,学生程序读到的就是它;`name.2x.png` 只给高分辨率屏幕的浏览器显示用,宽高仍按原尺寸。
|
||||||
|
- `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。
|
||||||
|
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG:name.png 是原尺寸(学生程序读到的图片,宽高和教材一致),
|
||||||
|
# name.2x.png 是两倍尺寸(只给高分辨率屏幕的浏览器显示用)。然后把每张图的编号(原尺寸 PNG 的 SHA-1,
|
||||||
|
# 和运行器给图片的编号相同)和两倍图的文件名写进 gallery/index.json。需要 rsvg-convert 和思源黑体等中文字体。
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
for f in svg/*.svg; do
|
||||||
|
name=$(basename "$f" .svg)
|
||||||
|
rsvg-convert "$f" -o "gallery/$name.png"
|
||||||
|
rsvg-convert -z 2 "$f" -o "gallery/$name.2x.png"
|
||||||
|
done
|
||||||
|
python3 - <<'PY'
|
||||||
|
import hashlib, json, os
|
||||||
|
items = json.load(open("gallery/index.json", encoding="utf-8"))
|
||||||
|
for item in items:
|
||||||
|
if item["kind"] != "image":
|
||||||
|
continue
|
||||||
|
item["key"] = hashlib.sha1(open(f"gallery/{item['file']}", "rb").read()).hexdigest()
|
||||||
|
hidpi = item["file"].replace(".png", ".2x.png")
|
||||||
|
if os.path.exists(f"gallery/{hidpi}"):
|
||||||
|
item["hidpi"] = hidpi
|
||||||
|
lines = ",\n".join(" " + json.dumps(i, ensure_ascii=False) for i in items)
|
||||||
|
open("gallery/index.json", "w", encoding="utf-8").write(f"[\n{lines}\n]\n")
|
||||||
|
PY
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +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": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" },
|
||||||
|
{
|
||||||
|
"name": "ico_title",
|
||||||
|
"file": "ico_title.png",
|
||||||
|
"label": "标题:智慧校园",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
|
||||||
|
"hidpi": "ico_title.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bg_home",
|
||||||
|
"file": "bg_home.png",
|
||||||
|
"label": "主界面背景:校园",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "e56bcc13f7128dec305b6012b255d63fb0f94450",
|
||||||
|
"hidpi": "bg_home.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_02",
|
||||||
|
"file": "icon_02.png",
|
||||||
|
"label": "图标:校园环境",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d",
|
||||||
|
"hidpi": "icon_02.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_03",
|
||||||
|
"file": "icon_03.png",
|
||||||
|
"label": "图标:校园安防",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "d085064a505544968278a85233b7d9ac23661169",
|
||||||
|
"hidpi": "icon_03.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_04",
|
||||||
|
"file": "icon_04.png",
|
||||||
|
"label": "图标:退出",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3",
|
||||||
|
"hidpi": "icon_04.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "day_01",
|
||||||
|
"file": "day_01.png",
|
||||||
|
"label": "路灯场景:亮灯",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "05065abdb4538906bd7c86e940ab5fb02d51469d",
|
||||||
|
"hidpi": "day_01.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "day_02",
|
||||||
|
"file": "day_02.png",
|
||||||
|
"label": "路灯场景:灭灯",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "470d523e90e188fe5c423d8cffeae6993d5528ff",
|
||||||
|
"hidpi": "day_02.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bg_from1",
|
||||||
|
"file": "bg_from1.png",
|
||||||
|
"label": "面板背景(宽)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "7a8c0a16fcab37b28a66e041024b25341cc69462",
|
||||||
|
"hidpi": "bg_from1.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bg_from2",
|
||||||
|
"file": "bg_from2.png",
|
||||||
|
"label": "面板背景(窄)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "52497df301d2b7e4eac0c297ce2316ef24863c47",
|
||||||
|
"hidpi": "bg_from2.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_monitor",
|
||||||
|
"file": "icon_monitor.png",
|
||||||
|
"label": "监控:无画面",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "23e6f1efc0f382c17d8a091b76531a3b5879349d",
|
||||||
|
"hidpi": "icon_monitor.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "panel_video_ctrl",
|
||||||
|
"file": "panel_video_ctrl.png",
|
||||||
|
"label": "监控:方向盘底座",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "396443a1d8c141942c0f447e0c69786cf6ad3350",
|
||||||
|
"hidpi": "panel_video_ctrl.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_up_normal",
|
||||||
|
"file": "btn_arrow_up_normal.png",
|
||||||
|
"label": "方向键:上",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "3c8b75ad465c8a61d0279facc233a0c64b62205a",
|
||||||
|
"hidpi": "btn_arrow_up_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_up_press",
|
||||||
|
"file": "btn_arrow_up_press.png",
|
||||||
|
"label": "方向键:上(按下)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "51e1b9926d2bd611e3fda74a01231b46a930c460",
|
||||||
|
"hidpi": "btn_arrow_up_press.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_down_normal",
|
||||||
|
"file": "btn_arrow_down_normal.png",
|
||||||
|
"label": "方向键:下",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "caf46f81c15aaca788462ebc47e58df299f097d9",
|
||||||
|
"hidpi": "btn_arrow_down_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_down_press",
|
||||||
|
"file": "btn_arrow_down_press.png",
|
||||||
|
"label": "方向键:下(按下)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "88c1bea8d6fad99b14658d8265a88b04bc595006",
|
||||||
|
"hidpi": "btn_arrow_down_press.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_left_normal",
|
||||||
|
"file": "btn_arrow_left_normal.png",
|
||||||
|
"label": "方向键:左",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "a38beb2373154d0474ad1715035aec5e2adf8c3f",
|
||||||
|
"hidpi": "btn_arrow_left_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_left_press",
|
||||||
|
"file": "btn_arrow_left_press.png",
|
||||||
|
"label": "方向键:左(按下)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "47bd7a8ffc077130a8ceca627383a4647729c638",
|
||||||
|
"hidpi": "btn_arrow_left_press.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_right_normal",
|
||||||
|
"file": "btn_arrow_right_normal.png",
|
||||||
|
"label": "方向键:右",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "e69e459f54d67e2c52128015bd6cfcf0e800a893",
|
||||||
|
"hidpi": "btn_arrow_right_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "btn_arrow_right_press",
|
||||||
|
"file": "btn_arrow_right_press.png",
|
||||||
|
"label": "方向键:右(按下)",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170",
|
||||||
|
"hidpi": "btn_arrow_right_press.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arrow_left_normal",
|
||||||
|
"file": "arrow_left_normal.png",
|
||||||
|
"label": "翻页:上一张",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "5a9036955f791f0c41ba52afffb5ecc640d137f1",
|
||||||
|
"hidpi": "arrow_left_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arrow_right_normal",
|
||||||
|
"file": "arrow_right_normal.png",
|
||||||
|
"label": "翻页:下一张",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9",
|
||||||
|
"hidpi": "arrow_right_normal.2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windowstate_closed_hover",
|
||||||
|
"file": "windowstate_closed_hover.png",
|
||||||
|
"label": "关闭按钮",
|
||||||
|
"kind": "image",
|
||||||
|
"key": "e361965dbd881dc21511208ce11f649517e8ddb6",
|
||||||
|
"hidpi": "windowstate_closed_hover.2x.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 55 KiB |