Compare commits
5
Commits
46e0f40cce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f242308a3b | ||
|
|
2aa267839f | ||
|
|
4c3ce31fe1 | ||
|
|
77bfaba7ff | ||
|
|
5e5d29b2aa |
No files matched your search
@@ -7,4 +7,5 @@
|
||||
.git
|
||||
docs
|
||||
*.pdf
|
||||
textbook-libs
|
||||
.env
|
||||
@@ -0,0 +1,135 @@
|
||||
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物、跑测试 → 产物 rsync 到服务器的 prebuilt/ →
|
||||
# 服务器上 deploy/update.sh --prebuilt(拉代码、用现成产物构建镜像、重启)。
|
||||
#
|
||||
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 iot-deploy;和 OJ2 共用一个 runner,一次只跑一个任务,
|
||||
# 两边的部署排队,不会同时挤服务器)。runner 的装法见 deploy/README.md「自动部署」。
|
||||
# - 服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满。
|
||||
# - 以前在 GitHub 上测试(约 5 分钟)再 SSH 到服务器编,push 后 6 分钟多才上线。
|
||||
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;
|
||||
# 急着上线又不在开发机旁时,用 GitHub 上手动触发的备用部署(.github/workflows/ci.yml,服务器自己编)。
|
||||
#
|
||||
# host 模式直接用开发机自己的东西:PATH 里的 bun / dotnet / bwrap、~/.ssh/config 里的 `debian`(root@服务器)
|
||||
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout。
|
||||
#
|
||||
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows。
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
|
||||
REMOTE_DIR: /root/IOT_SIM
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||
DOTNET_NOLOGO: '1'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: iot-deploy
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件(node_modules 留着,bun install 会对齐它)
|
||||
- name: 取代码
|
||||
env:
|
||||
SHA: ${{ gitea.sha }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
git init -q .
|
||||
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
|
||||
git checkout -q --force FETCH_HEAD
|
||||
git clean -ffdxq -e node_modules
|
||||
git log --oneline -1
|
||||
|
||||
# server.js 是开发机的 bun 打包的,到服务器上由镜像里的 bun(Dockerfile 里写死的版本)运行。
|
||||
# 打包出来是普通 JS,不像 OJ2 的单二进制那样把 bun 本身带过去,版本差一点一般没事,所以只提醒不拦。
|
||||
- name: 看 bun 版本
|
||||
run: |
|
||||
want=$(sed -nE 's|^FROM oven/bun:([0-9.]+).*|\1|p' Dockerfile | head -1)
|
||||
have=$(bun --version)
|
||||
echo "开发机 bun $have,镜像里 bun $want"
|
||||
[ "$have" = "$want" ] || echo "::warning::开发机的 bun 是 $have,镜像里是 $want,升级 Dockerfile 和 ci.yml 里的版本,或者确认没有用到新版本才有的功能"
|
||||
|
||||
- name: 装依赖
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: 编 C# 运行时
|
||||
run: dotnet build runtime/Runtime.slnx -c Release -v q
|
||||
|
||||
- name: 编网页、打包服务端
|
||||
run: |
|
||||
(cd web && bun run build)
|
||||
bun build server/src/index.ts --target=bun --outfile=server.js
|
||||
|
||||
# 有一项不过就不部署。服务端测试用上一步编的 Release 版运行时,测的就是要上线的那份。
|
||||
# --parallel 让 6 个测试文件同时跑;grading 里的测试标了 describe.concurrent,
|
||||
# --max-concurrency 限制同时跑几个(太多时定时器来不及触发会误判)。挨个跑约 4 分钟,这样半分钟左右
|
||||
- name: 测试
|
||||
env:
|
||||
RUNNER_DLL: runtime/Sim.Runner/bin/Release/net10.0/Sim.Runner.dll
|
||||
run: |
|
||||
(cd server && bun test --parallel --max-concurrency=8)
|
||||
(cd web && bun test src && bunx vue-tsc --noEmit)
|
||||
|
||||
# 目录结构就是 Dockerfile 里 artifacts-prebuilt 阶段要的;COMMIT 让服务器确认产物和要部署的代码是同一个提交
|
||||
- name: 收集产物
|
||||
env:
|
||||
SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
rm -rf prebuilt
|
||||
mkdir -p prebuilt/runtime prebuilt/web
|
||||
cp -a runtime/Sim.Runner/bin/Release/net10.0/. prebuilt/runtime/
|
||||
cp -a web/dist/. prebuilt/web/
|
||||
cp server.js prebuilt/
|
||||
echo "$SHA" > prebuilt/COMMIT
|
||||
du -sh prebuilt
|
||||
|
||||
# 只传产物,源码由服务器自己从 origin 拉(git.xuyue.cc 就在那台服务器上)。
|
||||
# -c 按内容比:每次编出来的 mtime 都是「刚才」,按 mtime 比会把没变的文件也传一遍
|
||||
# --delay-updates 全部传完才一起换上去,中途断了不会留下半新半旧的产物(反正 COMMIT 对不上也会被 update.sh 拦下)
|
||||
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用
|
||||
- name: 同步到服务器
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
|
||||
--compress --compress-choice=zstd \
|
||||
--timeout=60 --stats --human-readable \
|
||||
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||
prebuilt/ "$REMOTE:$REMOTE_DIR/prebuilt/" && exit 0
|
||||
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
|
||||
# 先把服务器上的代码切到这个提交再跑 update.sh:不然跑的是上一版的 update.sh,改了它要晚一次部署才生效。
|
||||
# 重启会结束正在运行的学生程序,上课时间尽量不要推送 main。
|
||||
#
|
||||
# update.sh 由 update-bg.sh 在服务器上后台跑,SSH 断了(开发机切代理、换网络)它照样跑完;
|
||||
# 这边 ssh 返回 255(连接出错)就重连,用 --follow 接着看、拿退出码。
|
||||
# 75 = 服务器上这个提交的部署还没开始(断在 git fetch 那里),重新开始。
|
||||
- name: 起服务
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" "$1"; }
|
||||
start="cd $REMOTE_DIR && git fetch --quiet origin && git reset --quiet --hard $SHA && deploy/update-bg.sh $SHA --prebuilt"
|
||||
follow="cd $REMOTE_DIR && deploy/update-bg.sh --follow $SHA"
|
||||
code=0; remote "$start" || code=$?
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
case $code in
|
||||
255) echo "::warning::和服务器的 SSH 断了,部署在服务器上接着跑,10 秒后重连接着看"
|
||||
sleep 10; code=0; remote "$follow" || code=$? ;;
|
||||
75) echo "::warning::服务器上的部署还没开始,重新开始"
|
||||
code=0; remote "$start" || code=$? ;;
|
||||
*) exit $code ;;
|
||||
esac
|
||||
done
|
||||
exit $code
|
||||
@@ -1,13 +1,13 @@
|
||||
# 每次推送和 PR 都跑测试;推送到 main 且测试通过后,SSH 到服务器执行 deploy/update.sh(服务器从 origin 拉代码、构建、重启)。
|
||||
# 部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
|
||||
# 只在 github.com 上运行;仓库同时推到 git.xuyue.cc,那边的 Gitea Actions 也会读这个目录,用 if 跳过。
|
||||
# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml),
|
||||
# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。
|
||||
# - PR:只跑测试。
|
||||
# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、
|
||||
# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
|
||||
# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -37,9 +37,10 @@ jobs:
|
||||
- name: 编译 C# 运行时
|
||||
run: bun run build:runtime
|
||||
|
||||
# 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步
|
||||
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
|
||||
working-directory: server
|
||||
run: bun test
|
||||
run: bun test --parallel --max-concurrency=8
|
||||
|
||||
- name: 网页端测试和类型检查
|
||||
working-directory: web
|
||||
@@ -49,7 +50,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
if: github.server_url == 'https://github.com' && github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
|
||||
timeout-minutes: 20
|
||||
@@ -99,4 +100,4 @@ jobs:
|
||||
|
||||
- name: 部署(在服务器上拉代码、构建、重启)
|
||||
if: steps.check.outputs.configured == 'true'
|
||||
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update.sh ${{ github.sha }}"
|
||||
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update-bg.sh ${{ github.sha }}"
|
||||
@@ -10,6 +10,8 @@ obj/
|
||||
|
||||
# 教材资料(体积大、有版权,不进仓库)
|
||||
物联网编程与应用配套/
|
||||
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
|
||||
/textbook-libs/
|
||||
*.pdf
|
||||
|
||||
# 编辑器
|
||||
@@ -25,3 +27,6 @@ obj/
|
||||
|
||||
# 服务器上 deploy/backup.sh 做的备份
|
||||
/backups/
|
||||
|
||||
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
|
||||
/prebuilt/
|
||||
+23
-3
@@ -1,5 +1,11 @@
|
||||
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
||||
# 构建:docker compose build;部署说明见 deploy/README.md
|
||||
#
|
||||
# 产物(C# 运行时、网页、server.js)有两种来源,由构建参数 ARTIFACTS 选:
|
||||
# build (默认)在镜像里编。只要有 docker 就能构建,但服务器只有 2 核,编一次约一分钟,期间学生程序会卡
|
||||
# prebuilt 用 prebuilt/ 里现成的产物(开发机上的 gitea-runner 编好、测过后 rsync 过来,见 .gitea/workflows/deploy.yml),
|
||||
# 镜像构建只剩几条 COPY。buildx 不会去跑用不到的 runtime-build / web-build 阶段
|
||||
ARG ARTIFACTS=build
|
||||
|
||||
# ---- C# 运行时(编译服务 + 仿真库) ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
||||
@@ -22,6 +28,20 @@ RUN cd web && bun run build
|
||||
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
||||
RUN bun build server/src/index.ts --target=bun --outfile=server.js
|
||||
|
||||
# ---- 产物:在镜像里编的 ----
|
||||
FROM scratch AS artifacts-build
|
||||
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ /runtime/
|
||||
COPY --from=web-build /src/web/dist/ /web/
|
||||
COPY --from=web-build /src/server.js /server.js
|
||||
|
||||
# ---- 产物:现成的(目录结构和上面一样,名字里不带 dist / bin,免得被 .dockerignore 挡掉) ----
|
||||
FROM scratch AS artifacts-prebuilt
|
||||
COPY prebuilt/runtime/ /runtime/
|
||||
COPY prebuilt/web/ /web/
|
||||
COPY prebuilt/server.js /server.js
|
||||
|
||||
FROM artifacts-${ARTIFACTS} AS artifacts
|
||||
|
||||
# ---- 运行镜像 ----
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
RUN apt-get update \
|
||||
@@ -29,9 +49,9 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
|
||||
WORKDIR /app
|
||||
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
|
||||
COPY --from=web-build /src/web/dist/ web/dist/
|
||||
COPY --from=web-build /src/server.js ./
|
||||
COPY --from=artifacts /runtime/ runtime/
|
||||
COPY --from=artifacts /web/ web/dist/
|
||||
COPY --from=artifacts /server.js ./
|
||||
COPY samples/ samples/
|
||||
ENV APP_ROOT=/app \
|
||||
RUNNER_DLL=runtime/Sim.Runner.dll \
|
||||
|
||||
@@ -38,7 +38,7 @@ BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学
|
||||
|
||||
## 部署
|
||||
|
||||
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。
|
||||
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 运行器的四种模式
|
||||
|
||||
@@ -73,11 +73,12 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
||||
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
|
||||
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。
|
||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
|
||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
||||
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
|
||||
|
||||
尚未完成:
|
||||
|
||||
- 一个班试用。
|
||||
- 书上的扩展练习("边想边做""自主探究")、导出 VS 工程、校园街景场景、故障注入题库(第 4 阶段)。
|
||||
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
|
||||
+14
-2
@@ -5,7 +5,11 @@ services:
|
||||
init-data:
|
||||
image: iot-sim:latest
|
||||
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
|
||||
build: .
|
||||
build: &build
|
||||
context: .
|
||||
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
|
||||
args:
|
||||
ARTIFACTS: ${ARTIFACTS:-build}
|
||||
user: root
|
||||
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
|
||||
volumes:
|
||||
@@ -15,8 +19,13 @@ services:
|
||||
|
||||
app:
|
||||
image: iot-sim:latest
|
||||
build: .
|
||||
build: *build
|
||||
restart: unless-stopped
|
||||
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
depends_on:
|
||||
init-data:
|
||||
condition: service_completed_successfully
|
||||
@@ -41,6 +50,8 @@ services:
|
||||
# 数据库(账号、班级、学生作品),重新部署不会丢
|
||||
volumes:
|
||||
- data:/data
|
||||
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
|
||||
- ./textbook-libs:/app/textbook-libs:ro
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -58,6 +69,7 @@ services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
logging: *logging
|
||||
depends_on: [app]
|
||||
expose:
|
||||
- 80
|
||||
|
||||
+68
-16
@@ -66,15 +66,35 @@
|
||||
|
||||
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
|
||||
|
||||
## 自动部署(GitHub Actions)
|
||||
## 自动部署(push origin 就部署)
|
||||
|
||||
`.github/workflows/ci.yml`:
|
||||
`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。
|
||||
|
||||
- 每次推送和 PR:编译 C# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。
|
||||
- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
|
||||
- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。
|
||||
- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。
|
||||
- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。
|
||||
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
|
||||
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
|
||||
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
|
||||
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
|
||||
- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。
|
||||
|
||||
为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。
|
||||
|
||||
**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。
|
||||
|
||||
### 部署 runner
|
||||
|
||||
和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。
|
||||
|
||||
host 模式直接用开发机自己的东西,所以**不需要配任何 secret**:
|
||||
|
||||
- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync;
|
||||
- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`);
|
||||
- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。
|
||||
|
||||
开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。
|
||||
|
||||
### 备用:GitHub 手动触发
|
||||
|
||||
`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update-bg.sh <提交号>`(后台跑 `update.sh`),**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。
|
||||
|
||||
一次性配置(在本机执行,需要已登录的 `gh`):
|
||||
|
||||
@@ -96,15 +116,47 @@
|
||||
|
||||
在服务器的 `~/IOT_SIM` 目录下:
|
||||
|
||||
| 做什么 | 命令 |
|
||||
| ------------ | --------------------------------------------------------------------------------------- |
|
||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
||||
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||
| 看日志 | `docker compose logs -f app` |
|
||||
| 看资源占用 | `docker stats` |
|
||||
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
|
||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||
| 做什么 | 命令 |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
|
||||
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||
| 看日志 | `docker compose logs -f app` |
|
||||
| 看资源占用 | `docker stats` |
|
||||
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
|
||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||
|
||||
## 导出 EXE、VS 工程用的教材类库
|
||||
|
||||
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
|
||||
|
||||
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
|
||||
|
||||
| 文件 | 在配套资料里的位置 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `DeviceCtrlLibrary.dll` | `公共库/设备控制类库/设备控制/`(**不是** `仿真/`) |
|
||||
| `ComLibrary.dll`、`ICS.Acquisition.dll`、`ICS.Common.dll`、`ICS.Models.dll`、`IPCameraDll.dll`、`LEDLibrary.dll`、`MyCamer.dll` | `公共库/设备控制类库/设备控制/API/` |
|
||||
| `WinFormsDeviceControlLibrary.dll` | `公共库/设备控件库/` |
|
||||
| `AxInterop.WMPLib.dll`、`Interop.WMPLib.dll`(媒体播放器,项目 7 任务 2) | `项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/` |
|
||||
|
||||
在本机(配套资料解压在 `~/下载/物联网编程与应用配套/`)执行:
|
||||
|
||||
```bash
|
||||
B=~/下载/物联网编程与应用配套/物联网编程与应用
|
||||
ssh 服务器 mkdir -p IOT_SIM/textbook-libs
|
||||
scp "$B"/公共库/设备控制类库/设备控制/DeviceCtrlLibrary.dll "$B"/公共库/设备控制类库/设备控制/API/*.dll \
|
||||
"$B"/公共库/设备控件库/WinFormsDeviceControlLibrary.dll \
|
||||
"$B"/项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/*Interop.WMPLib.dll 服务器:IOT_SIM/textbook-libs/
|
||||
```
|
||||
|
||||
容器里的平台用普通用户运行,目录和文件要让其他用户能读(用 root 拷的文件常常是 `-rw-------`,导出时会提示"权限不够"):
|
||||
|
||||
```bash
|
||||
chmod 755 ~/IOT_SIM/textbook-libs
|
||||
chmod 644 ~/IOT_SIM/textbook-libs/*.dll
|
||||
```
|
||||
|
||||
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
|
||||
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
|
||||
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
|
||||
# 这个提交的部署还没开始过时退出码 75
|
||||
#
|
||||
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
|
||||
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
|
||||
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
|
||||
set -euo pipefail
|
||||
|
||||
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||
main() {
|
||||
cd "$(dirname "$0")/.."
|
||||
local state=/var/tmp/iot_sim-update
|
||||
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
|
||||
|
||||
if [[ ${1:-} == --follow ]]; then
|
||||
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
|
||||
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
|
||||
echo "服务器上没有 $sha 的部署记录(还没开始)"
|
||||
exit 75
|
||||
fi
|
||||
follow
|
||||
fi
|
||||
|
||||
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
|
||||
|
||||
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
|
||||
local old
|
||||
old=$(cat $state.pid 2>/dev/null || true)
|
||||
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
|
||||
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
|
||||
while kill -0 "$old" 2>/dev/null; do sleep 2; done
|
||||
fi
|
||||
|
||||
rm -f $state.pid $state.exit
|
||||
echo "$sha" >$state.sha
|
||||
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
|
||||
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
|
||||
"$state" "$@" >$state.log 2>&1 </dev/null &
|
||||
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
|
||||
follow
|
||||
}
|
||||
|
||||
follow() {
|
||||
local pid code
|
||||
pid=$(cat $state.pid 2>/dev/null || true)
|
||||
if [[ -n $pid ]]; then
|
||||
# update 进程结束后 tail 自己退出
|
||||
tail -n +1 -f --pid="$pid" $state.log
|
||||
else
|
||||
cat $state.log 2>/dev/null || true
|
||||
fi
|
||||
code=$(cat $state.exit 2>/dev/null || true)
|
||||
if [[ -z $code ]]; then
|
||||
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
|
||||
exit 1
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+37
-5
@@ -1,14 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
|
||||
# GitHub Actions 测试通过后通过 SSH 调用它,也可以登录服务器手动执行。
|
||||
# 用法:deploy/update.sh [提交号,默认 origin/main]
|
||||
# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt]
|
||||
#
|
||||
# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。
|
||||
# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。
|
||||
# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner
|
||||
# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交,
|
||||
# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。
|
||||
#
|
||||
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
|
||||
set -euo pipefail
|
||||
|
||||
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||
main() {
|
||||
cd "$(dirname "$0")/.."
|
||||
local ref=${1:-origin/main}
|
||||
local ref=origin/main prebuilt=0 arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--prebuilt) prebuilt=1 ;;
|
||||
-*) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;;
|
||||
*) ref=$arg ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
|
||||
@@ -16,8 +29,7 @@ main() {
|
||||
fi
|
||||
|
||||
echo "== 拉取代码"
|
||||
# 代码从 origin(git.xuyue.cc)拉取。Actions 由推送到 GitHub 触发,
|
||||
# 两个仓库推送有先后,这个提交可能还没到 origin,多等一会儿
|
||||
# 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿
|
||||
for attempt in $(seq 10); do
|
||||
git fetch --quiet origin
|
||||
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
|
||||
@@ -33,7 +45,27 @@ main() {
|
||||
git reset --quiet --hard "$ref"
|
||||
git log --oneline -1
|
||||
|
||||
if [[ $prebuilt == 1 ]]; then
|
||||
local head built
|
||||
head=$(git rev-parse HEAD)
|
||||
built=$(cat prebuilt/COMMIT 2>/dev/null || true)
|
||||
if [[ $built != "$head" ]]; then
|
||||
echo "prebuilt/ 里的产物是 ${built:-(没有)} 编的,要部署的是 $head。"
|
||||
echo '重新跑一次开发机上的部署,或者去掉 --prebuilt 让服务器自己编'
|
||||
exit 1
|
||||
fi
|
||||
for f in prebuilt/runtime/Sim.Runner.dll prebuilt/web/index.html prebuilt/server.js; do
|
||||
[[ -e $f ]] || { echo "prebuilt/ 不完整,缺 $f"; exit 1; }
|
||||
done
|
||||
export ARTIFACTS=prebuilt
|
||||
echo "== 用开发机编好的产物($(du -sh prebuilt | cut -f1))"
|
||||
else
|
||||
export ARTIFACTS=build
|
||||
fi
|
||||
|
||||
echo "== 构建镜像并重启"
|
||||
# caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份
|
||||
docker compose pull --quiet caddy
|
||||
docker compose up -d --build --remove-orphans
|
||||
docker image prune -f >/dev/null
|
||||
|
||||
|
||||
+35
-33
@@ -58,7 +58,7 @@
|
||||
|
||||
**教材要求的运行能力**:多窗体(`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 里接着改。
|
||||
|
||||
## 设备类库设计(兼容教材)
|
||||
|
||||
@@ -154,37 +154,37 @@ IOT/
|
||||
|
||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||
|
||||
| 层 | 用途 | 选择 |
|
||||
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||
| 前端 | 代码编辑器 | Monaco Editor |
|
||||
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
||||
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
||||
| 前端 | 图表 | 计划:Chart.js |
|
||||
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||
| 后端 | 运行时 | Bun |
|
||||
| 后端 | Web 框架 | Hono |
|
||||
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
||||
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||
| 后端 | 参数校验 | 计划:Zod |
|
||||
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
|
||||
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
|
||||
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
||||
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
||||
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
||||
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
||||
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
|
||||
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
||||
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
|
||||
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||
| 层 | 用途 | 选择 |
|
||||
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||
| 前端 | 代码编辑器 | Monaco Editor |
|
||||
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
||||
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
||||
| 前端 | 图表 | 计划:Chart.js |
|
||||
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||
| 后端 | 运行时 | Bun |
|
||||
| 后端 | Web 框架 | Hono |
|
||||
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
||||
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||
| 后端 | 参数校验 | 计划:Zod |
|
||||
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
|
||||
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
|
||||
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
||||
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
||||
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
||||
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
||||
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
|
||||
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
||||
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
|
||||
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||
|
||||
## 安全与服务器资源
|
||||
|
||||
@@ -242,7 +242,7 @@ flowchart TB
|
||||
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
|
||||
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||
P2 -->|"关口:一个班试用顺利"| P3
|
||||
@@ -343,6 +343,8 @@ flowchart TB
|
||||
- 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 编译"。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace Sim.Runner;
|
||||
@@ -23,7 +24,32 @@ internal static class CompileHints
|
||||
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
||||
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
||||
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
||||
("CS8026", _) => Vs2012(diagnostic),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
|
||||
private static readonly (string Feature, string Fix)[] NewerFeatures =
|
||||
{
|
||||
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
|
||||
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
|
||||
("nameof", "nameof(x) 直接写成字符串 \"x\""),
|
||||
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
|
||||
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
|
||||
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
|
||||
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
|
||||
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
|
||||
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
|
||||
("default 字面量", "default 写成 default(类型),例如 default(int)"),
|
||||
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
|
||||
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
|
||||
};
|
||||
|
||||
private static string Vs2012(Diagnostic diagnostic)
|
||||
{
|
||||
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
|
||||
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
|
||||
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,16 @@ namespace Sim.Runner;
|
||||
/// <summary>
|
||||
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
||||
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
||||
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
|
||||
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
|
||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||
/// </summary>
|
||||
internal static class CompileServer
|
||||
{
|
||||
public const string AssemblyFile = "StudentApp.dll";
|
||||
public const string PdbFile = "StudentApp.pdb";
|
||||
public const string ExportFile = "export.exe";
|
||||
public const string ExportProjectFile = "export.zip";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
|
||||
@@ -36,7 +40,10 @@ internal static class CompileServer
|
||||
{
|
||||
var request = JsonNode.Parse(line)!.AsObject();
|
||||
id = request["id"]?.DeepClone();
|
||||
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
|
||||
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
|
||||
reply = request["export"] is JsonObject export
|
||||
? Export(dir, (string?)export["kind"] == "project", (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
|
||||
: Compile(dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -66,6 +73,38 @@ internal static class CompileServer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
|
||||
/// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
|
||||
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
|
||||
/// </summary>
|
||||
private static JsonObject Export(string dir, bool project, string libs, string name)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "上位机程序";
|
||||
bool ok;
|
||||
JsonArray diagnostics;
|
||||
if (project)
|
||||
{
|
||||
var result = ProjectExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
|
||||
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportProjectFile), result.Zip);
|
||||
(ok, diagnostics) = (result.Success, result.Diagnostics);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
|
||||
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
|
||||
(ok, diagnostics) = (result.Success, result.Diagnostics);
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "compiled",
|
||||
["ok"] = ok,
|
||||
["diagnostics"] = diagnostics,
|
||||
["ms"] = watch.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
||||
private static void WarmUp()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Emit;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
|
||||
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
|
||||
/// <list type="bullet">
|
||||
/// <item>编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。</item>
|
||||
/// <item>教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。</item>
|
||||
/// <item>Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class ExeExporter
|
||||
{
|
||||
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
|
||||
|
||||
private const string LibPrefix = "IotSimExport.lib.";
|
||||
private const string FilePrefix = "IotSimExport.file.";
|
||||
private const string MediaPrefix = "IotSimExport.media.";
|
||||
|
||||
/// <summary>
|
||||
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
|
||||
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
|
||||
/// </summary>
|
||||
internal const string MediaPlayerOcxState =
|
||||
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
|
||||
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
|
||||
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
|
||||
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
|
||||
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
|
||||
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
|
||||
"AAAL";
|
||||
|
||||
private const string AxHostStateType =
|
||||
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
|
||||
/// <summary>平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取</summary>
|
||||
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
|
||||
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
|
||||
|
||||
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
|
||||
internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
|
||||
|
||||
internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
||||
.WithPlatform(Platform.AnyCpu32BitPreferred)
|
||||
.WithOptimizationLevel(OptimizationLevel.Release)
|
||||
.WithNullableContextOptions(NullableContextOptions.Disable)
|
||||
.WithAllowUnsafe(false)
|
||||
.WithConcurrentBuild(true);
|
||||
|
||||
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
|
||||
internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
|
||||
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
|
||||
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
|
||||
.ToList());
|
||||
|
||||
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
|
||||
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
|
||||
{
|
||||
var libs = TextbookLibs(libsDir, "导出 EXE");
|
||||
|
||||
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
|
||||
.ToList();
|
||||
|
||||
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
|
||||
var files = new SortedSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < sources.Count; i++)
|
||||
{
|
||||
if (!sources[i].Text.Contains(PlatformResources)) continue;
|
||||
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
|
||||
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
|
||||
}
|
||||
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
|
||||
var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
|
||||
|
||||
var trees = sources
|
||||
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
|
||||
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
|
||||
.ToList();
|
||||
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
|
||||
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
|
||||
|
||||
var resources = new List<ResourceDescription>();
|
||||
foreach (var lib in libs)
|
||||
{
|
||||
var path = lib;
|
||||
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = Path.Combine(galleryDir, file);
|
||||
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
foreach (var file in media)
|
||||
{
|
||||
var path = Path.Combine(galleryDir, file);
|
||||
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
|
||||
|
||||
using var pe = new MemoryStream();
|
||||
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
|
||||
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
|
||||
|
||||
var diagnostics = new JsonArray();
|
||||
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
|
||||
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||
{
|
||||
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||
}
|
||||
return new Result(emit.Success, pe.ToArray(), diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序</summary>
|
||||
internal static List<string> ReferencedMedia(string galleryDir, IEnumerable<string> texts)
|
||||
{
|
||||
if (!Directory.Exists(galleryDir)) return new List<string>();
|
||||
var all = texts.ToList();
|
||||
return Directory.GetFiles(galleryDir)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.Where(name => !name.Contains(".2x.") && name != "index.json")
|
||||
.Where(name => all.Any(text => text.Contains(name + "\"")))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
|
||||
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
|
||||
/// </summary>
|
||||
internal static string[] TextbookLibs(string libsDir, string what)
|
||||
{
|
||||
var libs = ReadableLibs(libsDir, what);
|
||||
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
|
||||
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
|
||||
return libs;
|
||||
}
|
||||
|
||||
private static string[] ReadableLibs(string libsDir, string what)
|
||||
{
|
||||
if (!Directory.Exists(libsDir)) return Array.Empty<string>();
|
||||
string? current = null;
|
||||
try
|
||||
{
|
||||
var libs = Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||
foreach (var lib in libs)
|
||||
{
|
||||
current = lib;
|
||||
using var _ = File.OpenRead(lib);
|
||||
}
|
||||
return libs;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
var item = current == null ? "目录" : Path.GetFileName(current);
|
||||
throw new InvalidOperationException(
|
||||
$"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
|
||||
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
|
||||
/// </summary>
|
||||
private static List<ResourceDescription> ControlResources(CSharpCompilation compilation, HashSet<string> ocxKeys)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new System.Resources.ResourceWriter(stream))
|
||||
{
|
||||
var state = Convert.FromBase64String(MediaPlayerOcxState);
|
||||
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
|
||||
writer.Generate();
|
||||
}
|
||||
var data = stream.ToArray();
|
||||
|
||||
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
|
||||
var resources = new List<ResourceDescription>();
|
||||
if (control == null) return resources;
|
||||
var names = new HashSet<string>();
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
|
||||
{
|
||||
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
|
||||
var derives = false;
|
||||
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
|
||||
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
|
||||
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
|
||||
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
|
||||
/// </summary>
|
||||
private static string Bootstrap(IReadOnlyList<string> media) => $$"""
|
||||
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
|
||||
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
|
||||
/// </summary>
|
||||
internal static class IotSimExportLedCompat
|
||||
{
|
||||
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
|
||||
}
|
||||
|
||||
namespace IotSimExport
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
internal static class Bootstrap
|
||||
{
|
||||
private static readonly Dictionary<string, Assembly> Loaded = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[global::System.Runtime.CompilerServices.ModuleInitializer]
|
||||
internal static void Init()
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
|
||||
ExtractMedia();
|
||||
}
|
||||
|
||||
private static Assembly Resolve(object sender, ResolveEventArgs args)
|
||||
{
|
||||
var name = new AssemblyName(args.Name).Name;
|
||||
lock (Loaded)
|
||||
{
|
||||
Assembly assembly;
|
||||
if (Loaded.TryGetValue(name, out assembly)) return assembly;
|
||||
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
|
||||
if (bytes == null) return null;
|
||||
assembly = Assembly.Load(bytes);
|
||||
Loaded[name] = assembly;
|
||||
return assembly;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
|
||||
|
||||
private static void ExtractMedia()
|
||||
{
|
||||
foreach (var file in Media)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
|
||||
var path = Path.Combine(dir, file);
|
||||
if (File.Exists(path)) continue;
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EmbeddedFiles
|
||||
{
|
||||
internal static byte[] Read(string name)
|
||||
{
|
||||
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
|
||||
{
|
||||
if (stream == null) return null;
|
||||
var bytes = new byte[stream.Length];
|
||||
var read = 0;
|
||||
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
internal static global::System.Drawing.Bitmap Image(string file)
|
||||
{
|
||||
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
|
||||
}
|
||||
|
||||
internal static byte[] Bytes(string file)
|
||||
{
|
||||
return Read("{{FilePrefix}}" + Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。
|
||||
/// <list type="bullet">
|
||||
/// <item>先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。</item>
|
||||
/// <item>平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹;
|
||||
/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。</item>
|
||||
/// <item>教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。</item>
|
||||
/// <item>文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class ProjectExporter
|
||||
{
|
||||
public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics);
|
||||
|
||||
/// <summary>VS 2012 的编译器支持到 C# 5</summary>
|
||||
internal static readonly CSharpParseOptions Vs2012 = new(LanguageVersion.CSharp5);
|
||||
|
||||
private const string PlatformResourcesFile = "Properties/Resources.Designer.cs";
|
||||
private const string LedCompatFile = "LedHelperCompat.cs";
|
||||
private const string LedCompatClass = "LedHelperCompat";
|
||||
|
||||
private static readonly Regex ResourceMember = new(
|
||||
@"internal static (System\.Drawing\.Bitmap|byte\[\]) ([\p{L}\p{N}_]+) \{\s*get \{ return global::Sim\.Host\.ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\); \}");
|
||||
private static readonly Regex ResourcesNamespace = new(@"namespace\s+([\p{L}\p{N}_.]+)\.Properties\b");
|
||||
private static readonly Regex FirstNamespace = new(@"^\s*namespace\s+([\p{L}\p{N}_.]+)", RegexOptions.Multiline);
|
||||
|
||||
/// <summary>VS 2012 窗体应用模板默认的引用(教材工程都有这些)</summary>
|
||||
private static readonly string[] DefaultReferences =
|
||||
{
|
||||
"System", "System.Core", "System.Xml.Linq", "System.Data.DataSetExtensions", "Microsoft.CSharp",
|
||||
"System.Data", "System.Deployment", "System.Drawing", "System.Windows.Forms", "System.Xml",
|
||||
};
|
||||
|
||||
private const string BitmapType = "System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a";
|
||||
private const string BytesType = "System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
|
||||
private sealed record ResourceItem(string Name, bool Image, string File);
|
||||
|
||||
private sealed record Source(string Path, string Text);
|
||||
|
||||
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名)</summary>
|
||||
public static Result Export(string projectDir, string libsDir, string galleryDir, string name)
|
||||
{
|
||||
var libs = ExeExporter.TextbookLibs(libsDir, "导出工程");
|
||||
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||
.Select(path => new Source(Path.GetRelativePath(projectDir, path).Replace('\\', '/'), File.ReadAllText(path)))
|
||||
.ToList();
|
||||
|
||||
var platformResources = sources.FirstOrDefault(s => s.Path == PlatformResourcesFile)?.Text;
|
||||
var items = platformResources == null
|
||||
? new List<ResourceItem>()
|
||||
: ResourceMember.Matches(platformResources)
|
||||
.Select(m => new ResourceItem(m.Groups[2].Value, m.Groups[1].Value != "byte[]", Path.GetFileName(m.Groups[3].Value)))
|
||||
.ToList();
|
||||
var rootNamespace = RootNamespace(sources, platformResources, name);
|
||||
sources.RemoveAll(s => s.Path == PlatformResourcesFile);
|
||||
|
||||
// VS 模板里有、平台上没有的文件(Properties 文件夹由平台管理,学生建不了同名文件)
|
||||
var generated = new List<Source>
|
||||
{
|
||||
new("Properties/AssemblyInfo.cs", AssemblyInfo(name)),
|
||||
new("Properties/Resources.Designer.cs", ResourcesDesigner(rootNamespace, items)),
|
||||
new("Properties/Settings.Designer.cs", SettingsDesigner(rootNamespace)),
|
||||
};
|
||||
var compat = new Source(LedCompatFile, LedCompat);
|
||||
|
||||
// 照机房的环境编译一遍:C# 5、.NET Framework 4.5、真机版设备类库
|
||||
var trees = sources.Concat(generated).Append(compat)
|
||||
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), Vs2012, path: s.Path))
|
||||
.ToList();
|
||||
var libReferences = libs.ToDictionary(p => (MetadataReference)MetadataReference.CreateFromFile(p), p => p);
|
||||
var compilation = CSharpCompilation.Create(name, trees, ExeExporter.Framework.Value.Concat(libReferences.Keys), ExeExporter.Options);
|
||||
using (var pe = new MemoryStream())
|
||||
{
|
||||
var emit = compilation.Emit(pe);
|
||||
var diagnostics = new JsonArray();
|
||||
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||
{
|
||||
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||
}
|
||||
if (!emit.Success) return new Result(false, Array.Empty<byte>(), diagnostics);
|
||||
}
|
||||
|
||||
var studentTrees = trees.Take(sources.Count).ToList();
|
||||
var code = sources.Concat(generated).ToList();
|
||||
if (UsesLedCompat(compilation, studentTrees))
|
||||
{
|
||||
code.Add(compat);
|
||||
studentTrees.Add(trees[^1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
compilation = compilation.RemoveSyntaxTrees(trees[^1]);
|
||||
}
|
||||
|
||||
// 引用:和教材工程一样,教材 dll 只引用代码里直接用到的(其他的作为依赖由 VS 复制到输出目录),
|
||||
// 框架引用在 VS 模板默认的之外加上用到的(如图表控件)。只带这些引用编译不过时(用到的类型的基类在别的 dll 里等)引用全部教材 dll
|
||||
var direct = DirectlyUsedAssemblies(compilation, studentTrees);
|
||||
var frameworkReferences = DefaultReferences
|
||||
.Concat(ExeExporter.Framework.Value.OfType<PortableExecutableReference>()
|
||||
.Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType<string>()
|
||||
.Where(n => direct.Contains(n) && n != "mscorlib" && !DefaultReferences.Contains(n))
|
||||
.OrderBy(n => n, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
var textbookReferences = libs.Where(p => direct.Contains(Path.GetFileNameWithoutExtension(p))).ToList();
|
||||
var reduced = compilation.WithReferences(ExeExporter.Framework.Value.Concat(libReferences.Where(r => textbookReferences.Contains(r.Value)).Select(r => r.Key)));
|
||||
if (reduced.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) textbookReferences = libs.ToList();
|
||||
textbookReferences = textbookReferences.Select(p => Path.GetFileName(p)).ToList();
|
||||
|
||||
var forms = Forms(compilation, sources, studentTrees);
|
||||
var media = ExeExporter.ReferencedMedia(galleryDir, sources.Select(s => s.Text));
|
||||
var images = items.Select(i => i.File).Where(f => File.Exists(Path.Combine(galleryDir, f))).Distinct().ToList();
|
||||
|
||||
var zip = new ZipWriter(DateTime.UtcNow.AddHours(8));
|
||||
var root = $"{name}/";
|
||||
var dir = $"{name}/{name}/";
|
||||
zip.Add(root + name + ".sln", Text(Solution(name)));
|
||||
zip.Add(dir + name + ".csproj", Text(Project(name, rootNamespace, code, forms, frameworkReferences, textbookReferences, images, media)));
|
||||
zip.Add(dir + "App.config", Text(AppConfig));
|
||||
zip.Add(dir + "Properties/Settings.settings", Text(SettingsFile));
|
||||
zip.Add(dir + "Properties/Resources.resx", Text(Resx(items.Select(ResourceEntry))));
|
||||
foreach (var source in code) zip.Add(dir + source.Path, Text(source.Text));
|
||||
foreach (var form in forms.Where(f => f.Resx))
|
||||
{
|
||||
zip.Add(dir + Path.ChangeExtension(form.Path, ".resx"), Text(Resx(form.OcxKeys.Select(OcxEntry))));
|
||||
}
|
||||
foreach (var file in images.Union(media).OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
zip.Add(dir + "Resources/" + file, File.ReadAllBytes(Path.Combine(galleryDir, file)));
|
||||
}
|
||||
foreach (var lib in libs) zip.Add(dir + "lib/" + Path.GetFileName(lib), File.ReadAllBytes(lib));
|
||||
return new Result(true, zip.Finish(), new JsonArray());
|
||||
}
|
||||
|
||||
/// <summary>工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间</summary>
|
||||
private static string RootNamespace(List<Source> sources, string? platformResources, string name)
|
||||
{
|
||||
if (platformResources != null && ResourcesNamespace.Match(platformResources) is { Success: true } r) return r.Groups[1].Value;
|
||||
foreach (var source in sources.OrderBy(s => s.Path == "Program.cs" ? 0 : 1).ThenBy(s => s.Path, StringComparer.Ordinal))
|
||||
{
|
||||
var m = FirstNamespace.Match(source.Text);
|
||||
if (m.Success) return m.Groups[1].Value;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>代码里直接用到的类型、成员所在的程序集(程序集名)</summary>
|
||||
private static HashSet<string> DirectlyUsedAssemblies(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
void Add(ISymbol? symbol)
|
||||
{
|
||||
while (symbol is IArrayTypeSymbol array) symbol = array.ElementType;
|
||||
if (symbol?.ContainingAssembly is { } assembly) names.Add(assembly.Identity.Name);
|
||||
if (symbol is INamedTypeSymbol { IsGenericType: true } generic)
|
||||
{
|
||||
foreach (var argument in generic.TypeArguments) Add(argument);
|
||||
}
|
||||
}
|
||||
foreach (var tree in trees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var node in tree.GetRoot().DescendantNodes())
|
||||
{
|
||||
Add(model.GetSymbolInfo(node).Symbol);
|
||||
Add(model.GetTypeInfo(node).Type);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有)</summary>
|
||||
private static bool UsesLedCompat(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||
{
|
||||
foreach (var tree in trees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var invocation in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面</summary>
|
||||
private sealed record Form(string Path, string SubType, bool Resx, List<string> OcxKeys);
|
||||
|
||||
private static List<Form> Forms(CSharpCompilation compilation, List<Source> sources, List<SyntaxTree> trees)
|
||||
{
|
||||
var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form");
|
||||
var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl");
|
||||
var result = new List<Form>();
|
||||
for (var i = 0; i < sources.Count; i++)
|
||||
{
|
||||
var path = sources[i].Path;
|
||||
if (path.EndsWith(".Designer.cs")) continue;
|
||||
var designer = sources.FirstOrDefault(s => s.Path == path[..^3] + ".Designer.cs");
|
||||
var model = compilation.GetSemanticModel(trees[i]);
|
||||
var type = trees[i].GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
|
||||
.Select(c => model.GetDeclaredSymbol(c)).FirstOrDefault(t => t != null);
|
||||
string? subType = null;
|
||||
for (var t = type?.BaseType; t != null && subType == null; t = t.BaseType)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(t, form)) subType = "Form";
|
||||
else if (SymbolEqualityComparer.Default.Equals(t, userControl)) subType = "UserControl";
|
||||
}
|
||||
if (subType == null) continue;
|
||||
var keys = designer == null
|
||||
? new List<string>()
|
||||
: ExeExporter.OcxStateKey.Matches(designer.Text).Select(m => m.Groups[1].Value).Distinct().OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
result.Add(new Form(path, subType, designer != null, keys));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Project(string name, string rootNamespace, List<Source> code, List<Form> forms,
|
||||
List<string> frameworkReferences, List<string> textbookReferences, List<string> images, List<string> media)
|
||||
{
|
||||
static string Win(string path) => path.Replace('/', '\\');
|
||||
static string X(string text) => SecurityElement.Escape(text);
|
||||
|
||||
var s = new StringBuilder();
|
||||
s.Append($"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{ProjectGuid(name)}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>{X(rootNamespace)}</RootNamespace>
|
||||
<AssemblyName>{X(name)}</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
""");
|
||||
foreach (var lib in textbookReferences)
|
||||
{
|
||||
s.Append($"""
|
||||
<Reference Include="{X(Path.GetFileNameWithoutExtension(lib))}">
|
||||
<HintPath>lib\{X(lib)}</HintPath>
|
||||
</Reference>
|
||||
|
||||
""");
|
||||
}
|
||||
foreach (var reference in frameworkReferences) s.Append($" <Reference Include=\"{reference}\" />\n");
|
||||
s.Append(" </ItemGroup>\n <ItemGroup>\n");
|
||||
|
||||
var formPaths = forms.ToDictionary(f => f.Path);
|
||||
// 和 VS 一样 Form1.cs 在 Form1.Designer.cs 前面
|
||||
static string Order(string path) => path.EndsWith(".Designer.cs") ? path[..^12] + ".cs\u0001" : path;
|
||||
foreach (var source in code.Where(c => !c.Path.StartsWith("Properties/")).OrderBy(c => Order(c.Path), StringComparer.Ordinal))
|
||||
{
|
||||
var path = source.Path;
|
||||
if (formPaths.TryGetValue(path, out var form))
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <SubType>{form.SubType}</SubType>\n </Compile>\n");
|
||||
}
|
||||
else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs"))
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <DependentUpon>{X(Path.GetFileName(path[..^12] + ".cs"))}</DependentUpon>\n </Compile>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\" />\n");
|
||||
}
|
||||
}
|
||||
s.Append(" <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n");
|
||||
foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal))
|
||||
{
|
||||
s.Append($" <EmbeddedResource Include=\"{X(Win(Path.ChangeExtension(form.Path, ".resx")))}\">\n <DependentUpon>{X(Path.GetFileName(form.Path))}</DependentUpon>\n </EmbeddedResource>\n");
|
||||
}
|
||||
s.Append("""
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
<SubType>Designer</SubType>
|
||||
</EmbeddedResource>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
<DesignTime>True</DesignTime>
|
||||
</Compile>
|
||||
<None Include="Properties\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||
</None>
|
||||
<Compile Include="Properties\Settings.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
</ItemGroup>
|
||||
|
||||
""");
|
||||
var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
if (files.Count > 0)
|
||||
{
|
||||
s.Append(" <ItemGroup>\n");
|
||||
foreach (var file in files)
|
||||
{
|
||||
// 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序
|
||||
s.Append(media.Contains(file)
|
||||
? $" <Content Include=\"Resources\\{X(file)}\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n"
|
||||
: $" <None Include=\"Resources\\{X(file)}\" />\n");
|
||||
}
|
||||
s.Append(" </ItemGroup>\n");
|
||||
}
|
||||
s.Append("""
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
""");
|
||||
return s.ToString();
|
||||
}
|
||||
|
||||
private static string Solution(string name) => $$"""
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 2012
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "{{name}}", "{{name}}\{{name}}.csproj", "{{ProjectGuid(name)}}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{{ProjectGuid(name)}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{{ProjectGuid(name)}}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{{ProjectGuid(name)}}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{{ProjectGuid(name)}}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
""";
|
||||
|
||||
/// <summary>同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程)</summary>
|
||||
private static string ProjectGuid(string name) => "{" + NameGuid("project:" + name).ToString().ToUpperInvariant() + "}";
|
||||
|
||||
private static Guid NameGuid(string text) => new(MD5.HashData(Encoding.UTF8.GetBytes(text)));
|
||||
|
||||
private static string AssemblyInfo(string name) => $$"""
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// 有关程序集的常规信息通过以下
|
||||
// 特性集控制。更改这些特性值可修改
|
||||
// 与程序集关联的信息。
|
||||
[assembly: AssemblyTitle({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||
[assembly: AssemblyCopyright("Copyright © {{DateTime.UtcNow.AddHours(8).Year}}")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// 将 ComVisible 设置为 false 使此程序集中的类型
|
||||
// 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型,
|
||||
// 则将该类型上的 ComVisible 特性设置为 true。
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID
|
||||
[assembly: Guid("{{NameGuid("assembly:" + name)}}")]
|
||||
|
||||
// 程序集的版本信息由下面四个值组成:
|
||||
//
|
||||
// 主版本
|
||||
// 次版本
|
||||
// 生成号
|
||||
// 修订号
|
||||
//
|
||||
// 可以指定所有这些值,也可以使用“生成号”和“修订号”的默认值,
|
||||
// 方法是按如下所示使用“*”:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
|
||||
""";
|
||||
|
||||
/// <summary>和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释)</summary>
|
||||
private static string ResourcesDesigner(string ns, List<ResourceItem> items)
|
||||
{
|
||||
var members = string.Concat(items.Select(item =>
|
||||
{
|
||||
var type = item.Image ? "System.Drawing.Bitmap" : "byte[]";
|
||||
var what = item.Image ? "System.Drawing.Bitmap" : "System.Byte[]";
|
||||
return $$"""
|
||||
|
||||
/// <summary>
|
||||
/// 查找 {{what}} 类型的本地化资源。
|
||||
/// </summary>
|
||||
internal static {{type}} {{item.Name}} {
|
||||
get {
|
||||
object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture);
|
||||
return (({{type}})(obj));
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
}));
|
||||
return $$"""
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// 此代码由工具生成。
|
||||
// 运行时版本:4.0.30319.42000
|
||||
//
|
||||
// 对此文件的更改可能会导致不正确的行为,并且如果
|
||||
// 重新生成代码,这些更改将会丢失。
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace {{ns}}.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 一个强类型的资源类,用于查找本地化的字符串等。
|
||||
/// </summary>
|
||||
// 此类是由 StronglyTypedResourceBuilder
|
||||
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
|
||||
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
|
||||
// (以 /str 作为命令选项),或重新生成 VS 项目。
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回此类使用的缓存的 ResourceManager 实例。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("{{ns}}.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用此强类型资源类,为所有资源查找
|
||||
/// 重写当前线程的 CurrentUICulture 属性。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
{{members}} }
|
||||
}
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
private static string SettingsDesigner(string ns) => $$"""
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace {{ns}}.Properties
|
||||
{
|
||||
|
||||
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
|
||||
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
|
||||
{
|
||||
|
||||
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||
|
||||
public static Settings Default
|
||||
{
|
||||
get
|
||||
{
|
||||
return defaultInstance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
private const string SettingsFile = """
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
|
||||
<Profiles>
|
||||
<Profile Name="(Default)" />
|
||||
</Profiles>
|
||||
<Settings />
|
||||
</SettingsFile>
|
||||
""";
|
||||
|
||||
private const string AppConfig = """
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
|
||||
</startup>
|
||||
</configuration>
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||
/// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。
|
||||
/// </summary>
|
||||
private const string LedCompat = """
|
||||
// 教材的设备类库里 LEDHelper 没有 Close 方法(LED 屏每次发送时才打开串口,发完就关),
|
||||
// 书上的代码调用了 ledHelper.Close(),这里补一个什么都不做的方法,让代码能编译。
|
||||
internal static class LedHelperCompat
|
||||
{
|
||||
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
private static readonly Lazy<string> ResxHeader = new(() =>
|
||||
{
|
||||
using var stream = typeof(ProjectExporter).Assembly.GetManifestResourceStream("ResxHeader.xml")!;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
return reader.ReadToEnd().TrimEnd('\n');
|
||||
});
|
||||
|
||||
private static string Resx(IEnumerable<string> entries)
|
||||
{
|
||||
var list = entries.ToList();
|
||||
var s = new StringBuilder(ResxHeader.Value).Append('\n');
|
||||
if (list.Any(e => e.Contains("ResXFileRef")))
|
||||
{
|
||||
s.Append(" <assembly alias=\"System.Windows.Forms\" name=\"System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\" />\n");
|
||||
}
|
||||
foreach (var entry in list) s.Append(entry);
|
||||
return s.Append("</root>").ToString();
|
||||
}
|
||||
|
||||
private static string ResourceEntry(ResourceItem item) =>
|
||||
$" <data name=\"{SecurityElement.Escape(item.Name)}\" type=\"System.Resources.ResXFileRef, System.Windows.Forms\">\n" +
|
||||
$" <value>..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}</value>\n" +
|
||||
" </data>\n";
|
||||
|
||||
/// <summary>媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符</summary>
|
||||
private static string OcxEntry(string key)
|
||||
{
|
||||
var data = ExeExporter.MediaPlayerOcxState;
|
||||
var lines = Enumerable.Range(0, (data.Length + 79) / 80).Select(i => " " + data.Substring(i * 80, Math.Min(80, data.Length - i * 80)));
|
||||
return $" <data name=\"{SecurityElement.Escape(key)}\" mimetype=\"application/x-microsoft.net.object.binary.base64\">\n" +
|
||||
" <value>\n" + string.Join("\n", lines) + "\n</value>\n </data>\n";
|
||||
}
|
||||
|
||||
/// <summary>文本文件:带 BOM 的 UTF-8,CRLF 换行</summary>
|
||||
private static byte[] Text(string text)
|
||||
{
|
||||
var crlf = text.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(crlf)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
@@ -14,6 +14,24 @@
|
||||
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
|
||||
</ItemGroup>
|
||||
<!-- 导出 EXE:照教材工程(.NET Framework 4.5)编译学生代码,要用 4.5 的引用程序集(只供编译,不含实现)。
|
||||
只复制教材工程引用的那些(VS 窗体应用模板默认的引用加图表控件),放到输出目录的 netfx/ -->
|
||||
<ItemGroup>
|
||||
<PackageDownload Include="Microsoft.NETFramework.ReferenceAssemblies.net45" Version="[1.0.3]" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyNetFxReferences" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<NetFxDir>$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/</NetFxDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<NetFxReference Include="$(NetFxDir)mscorlib.dll;$(NetFxDir)System.dll;$(NetFxDir)System.Core.dll;$(NetFxDir)System.Data.dll;$(NetFxDir)System.Data.DataSetExtensions.dll;$(NetFxDir)System.Deployment.dll;$(NetFxDir)System.Drawing.dll;$(NetFxDir)System.Net.Http.dll;$(NetFxDir)System.Windows.Forms.dll;$(NetFxDir)System.Windows.Forms.DataVisualization.dll;$(NetFxDir)System.Xml.dll;$(NetFxDir)System.Xml.Linq.dll;$(NetFxDir)Microsoft.CSharp.dll" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||
|
||||
@@ -43,9 +43,7 @@ internal static class StudentCompiler
|
||||
public static Result Compile(string projectDir)
|
||||
{
|
||||
var parseOptions = ParseOptions;
|
||||
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(projectDir, path))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
var trees = SourceFiles(projectDir)
|
||||
.Select(path => CSharpSyntaxTree.ParseText(
|
||||
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
||||
parseOptions,
|
||||
@@ -123,6 +121,12 @@ internal static class StudentCompiler
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
|
||||
internal static IEnumerable<string> SourceFiles(string projectDir) =>
|
||||
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(projectDir, path))
|
||||
.OrderBy(path => path, StringComparer.Ordinal);
|
||||
|
||||
private static bool IsBuildOutput(string projectDir, string path)
|
||||
{
|
||||
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { config } from './config'
|
||||
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
|
||||
import { SqlError, sqlService } from './sql-service'
|
||||
import { type ExportKind, exportProgram, programName } from './export'
|
||||
import { serveStatic } from './static'
|
||||
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
|
||||
|
||||
@@ -129,6 +130,36 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
|
||||
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
|
||||
|
||||
/**
|
||||
* 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
|
||||
* (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
|
||||
*/
|
||||
const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
|
||||
exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' },
|
||||
project: { ext: 'zip', type: 'application/zip' },
|
||||
}
|
||||
for (const kind of ['exe', 'project'] as const) {
|
||||
app.post(`/api/export-${kind}`, async (c) => {
|
||||
const { files, name } = await body<{ files: unknown; name: unknown }>(c)
|
||||
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
|
||||
let result
|
||||
try {
|
||||
result = await exportProgram(files, programName(name), kind)
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 503)
|
||||
}
|
||||
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422)
|
||||
const { ext, type } = EXPORTS[kind]
|
||||
return new Response(result.file, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
|
||||
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
|
||||
app.post('/api/language', async (c) => {
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface CompileResult {
|
||||
ms: number
|
||||
}
|
||||
|
||||
/** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */
|
||||
export const EXPORT_FILE = 'export.exe'
|
||||
export const EXPORT_PROJECT_FILE = 'export.zip'
|
||||
|
||||
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
|
||||
|
||||
/**
|
||||
@@ -22,11 +26,31 @@ class CompileService {
|
||||
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
|
||||
|
||||
/** 编译工程目录;成功时程序集写到同一目录 */
|
||||
async compile(dir: string): Promise<CompileResult> {
|
||||
compile(dir: string): Promise<CompileResult> {
|
||||
return this.request({ dir })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir),
|
||||
* 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe)
|
||||
*/
|
||||
exportExe(dir: string, name: string): Promise<CompileResult> {
|
||||
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 VS 工程:照机房的 VS 2012(C# 5、.NET Framework 4.5、真机版设备类库)检查,
|
||||
* 打包成 zip 写到工程目录的 EXPORT_PROJECT_FILE。name 是工程名
|
||||
*/
|
||||
exportProject(dir: string, name: string): Promise<CompileResult> {
|
||||
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
|
||||
}
|
||||
|
||||
private async request(payload: object): Promise<CompileResult> {
|
||||
await this.start()
|
||||
const id = this.nextId++
|
||||
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
|
||||
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
|
||||
this.proc!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`)
|
||||
this.proc!.stdin.flush()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export const config = {
|
||||
/** 每次运行的临时工程目录放在这里 */
|
||||
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
|
||||
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
|
||||
/**
|
||||
* 导出 EXE 用的教材设备类库(真机版 DeviceCtrlLibrary.dll 及其 API 目录下的 dll、WinFormsDeviceControlLibrary.dll、
|
||||
* 媒体播放器的 AxInterop.WMPLib.dll / Interop.WMPLib.dll,放在同一个目录里)。有版权,不进仓库;没有时不能导出
|
||||
*/
|
||||
textbookLibsDir: resolve(ROOT, env.SIM_TEXTBOOK_LIBS ?? 'textbook-libs'),
|
||||
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
|
||||
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
|
||||
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
|
||||
// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Diagnostic, SourceFile } from '@iot/shared'
|
||||
import { compileService, EXPORT_FILE, EXPORT_PROJECT_FILE } from './compile-service'
|
||||
import { config } from './config'
|
||||
import { safeJoin } from './run-session'
|
||||
|
||||
export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
|
||||
|
||||
export type ExportKind = 'exe' | 'project'
|
||||
|
||||
/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
|
||||
export function programName(name: unknown) {
|
||||
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
|
||||
return cleaned || '上位机程序'
|
||||
}
|
||||
|
||||
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
|
||||
export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
|
||||
await mkdir(config.runsDir, { recursive: true })
|
||||
const dir = await mkdtemp(join(config.runsDir, 'export-'))
|
||||
try {
|
||||
for (const file of files) {
|
||||
const target = safeJoin(dir, file.path)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await Bun.write(target, file.content)
|
||||
}
|
||||
const result = kind === 'project' ? await compileService.exportProject(dir, name) : await compileService.exportExe(dir, name)
|
||||
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
|
||||
return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
|
||||
import { createApp } from '../src/app'
|
||||
@@ -738,3 +739,157 @@ describe('AI 评阅', () => {
|
||||
expect((await t.post(`/api/teacher/assignments/${assignmentId}/adopt-ai`)).body).toEqual({ adopted: 0 })
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('导出 EXE、VS 工程', () => {
|
||||
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
|
||||
|
||||
const exportExe = (browser: Browser, json: unknown) => exportRequest(browser, 'exe', json)
|
||||
|
||||
async function exportRequest(browser: Browser, kind: 'exe' | 'project', json: unknown) {
|
||||
const response = await app.fetch(
|
||||
new Request(`http://localhost/api/export-${kind}`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
|
||||
body: JSON.stringify(json),
|
||||
}),
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
test.skipIf(!hasLibs)(
|
||||
'照真机版类库编译成 exe;书上的 ledHelper.Close() 也能编译;代码有错时返回报错',
|
||||
async () => {
|
||||
const t = await teacher()
|
||||
const files = (await loadSample('p2-t3-LED推送模拟量'))!
|
||||
const response = await exportExe(t, { files, name: 'LED推送 模拟量/..' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('LED推送模拟量.exe')}`)
|
||||
const exe = new Uint8Array(await response.arrayBuffer())
|
||||
expect(String.fromCharCode(exe[0]!, exe[1]!)).toBe('MZ')
|
||||
// 教材 dll 嵌在 exe 里
|
||||
expect(Buffer.from(exe).includes('IotSimExport.lib.DeviceCtrlLibrary.dll')).toBe(true)
|
||||
|
||||
const broken = files.map((f) => (f.path === 'frm_LED.cs' ? { ...f, content: f.content.replace('IsOpen = true;', 'IsOpen = true') } : f))
|
||||
const failed = await exportExe(t, { files: broken, name: 'x' })
|
||||
expect(failed.status).toBe(422)
|
||||
expect(((await failed.json()) as any).diagnostics[0]).toMatchObject({ file: 'frm_LED.cs', severity: 'error' })
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
/** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */
|
||||
function unzip(zip: Uint8Array<ArrayBuffer>) {
|
||||
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
|
||||
let end = zip.length - 22
|
||||
while (view.getUint32(end, true) !== 0x06054b50) end--
|
||||
const count = view.getUint16(end + 10, true)
|
||||
let p = view.getUint32(end + 16, true)
|
||||
const files = new Map<string, Uint8Array<ArrayBuffer>>()
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(view.getUint32(p, true)).toBe(0x02014b50)
|
||||
const flags = view.getUint16(p + 8, true)
|
||||
const method = view.getUint16(p + 10, true)
|
||||
const compressedSize = view.getUint32(p + 20, true)
|
||||
const nameLength = view.getUint16(p + 28, true)
|
||||
const extraLength = view.getUint16(p + 30, true)
|
||||
const commentLength = view.getUint16(p + 32, true)
|
||||
const offset = view.getUint32(p + 42, true)
|
||||
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
|
||||
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
|
||||
expect(flags & 0x800).toBe(0)
|
||||
const name = new TextDecoder('gbk').decode(nameBytes)
|
||||
if (extra.length > 0) {
|
||||
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
|
||||
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
|
||||
} else {
|
||||
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
|
||||
}
|
||||
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
|
||||
const data = zip.subarray(local, local + compressedSize)
|
||||
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
|
||||
p += 46 + nameLength + extraLength + commentLength
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
test.skipIf(!hasLibs)(
|
||||
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
|
||||
async () => {
|
||||
const t = await teacher()
|
||||
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/zip')
|
||||
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
|
||||
const files = unzip(new Uint8Array(await response.arrayBuffer()))
|
||||
const text = (name: string) => {
|
||||
const bytes = files.get(name)
|
||||
expect(bytes, name).toBeDefined()
|
||||
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
|
||||
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
|
||||
const content = new TextDecoder().decode(bytes!.subarray(3))
|
||||
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
return content
|
||||
}
|
||||
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
|
||||
const csproj = text('寝室/寝室/寝室.csproj')
|
||||
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
|
||||
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
|
||||
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
|
||||
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
|
||||
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
|
||||
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
|
||||
expect(csproj).not.toContain('ICS.Models')
|
||||
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
|
||||
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
|
||||
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
|
||||
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
|
||||
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
|
||||
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
|
||||
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
|
||||
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
|
||||
expect(resources).not.toContain('Sim.Host')
|
||||
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
|
||||
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
|
||||
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
|
||||
|
||||
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
|
||||
const led = unzip(
|
||||
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
|
||||
)
|
||||
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
|
||||
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
|
||||
|
||||
// 机房的 VS 2012 只支持 C# 5
|
||||
const login = (await loadSample('p1-t1-登录'))!
|
||||
const newer = login.map((f) =>
|
||||
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
|
||||
)
|
||||
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
|
||||
expect(failed.status).toBe(422)
|
||||
const diagnostics = ((await failed.json()) as any).diagnostics
|
||||
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
|
||||
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
|
||||
// 导出 EXE 不受这个限制
|
||||
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
|
||||
const t = await teacher()
|
||||
const saved = config.textbookLibsDir
|
||||
config.textbookLibsDir = '/nonexistent/textbook-libs'
|
||||
try {
|
||||
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
|
||||
expect(response.status).toBe(503)
|
||||
expect(((await response.json()) as any).message).toContain('设备类库')
|
||||
const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
|
||||
expect(project.status).toBe(503)
|
||||
expect(((await project.json()) as any).message).toContain('不能导出工程')
|
||||
} finally {
|
||||
config.textbookLibsDir = saved
|
||||
}
|
||||
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
|
||||
expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400)
|
||||
}, 60000)
|
||||
})
|
||||
@@ -33,7 +33,9 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('参考答案全部通过', async () => {
|
||||
// 每个检查大部分时间在等学生程序的定时器,挨个跑要 3 分钟;测试之间各用各的临时目录,可以同时跑(30 秒左右)。
|
||||
// 同时跑的个数由 bun test 的 --max-concurrency 管,CI 里给的是 8:太多时 CPU 跟不上,定时器来不及触发会误判
|
||||
describe.concurrent('参考答案全部通过', async () => {
|
||||
const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort()
|
||||
test('每个示例都有评分规则', () => expect(ids.length).toBe(23))
|
||||
for (const id of ids) {
|
||||
@@ -49,7 +51,7 @@ describe('参考答案全部通过', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('改坏的代码', () => {
|
||||
describe.concurrent('改坏的代码', () => {
|
||||
test('编译不通过时所有项都不通过', async () => {
|
||||
const id = 'p2-t1-LED显示屏'
|
||||
const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' })
|
||||
|
||||
@@ -6,6 +6,8 @@ export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
/** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */
|
||||
readonly data: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
@@ -22,6 +24,15 @@ export async function api<T>(method: string, path: string, json?: unknown, init?
|
||||
return data
|
||||
}
|
||||
|
||||
/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */
|
||||
export async function apiResponse(method: string, path: string, json?: unknown): Promise<Response> {
|
||||
const response = await send(method, path, json, undefined)
|
||||
if (response.ok) return response
|
||||
const data = (await response.json().catch(() => ({}))) as Record<string, unknown> & { message?: string }
|
||||
if (response.status === 401) apiHooks.onUnauthorized()
|
||||
throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status, data)
|
||||
}
|
||||
|
||||
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
|
||||
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 导出:服务器照教材工程(真机版设备类库)编译,下载到机房电脑上用。
|
||||
// EXE:一个 exe,双击就能控制真的实训台;VS 工程:教材那样的 VS 2012 工程(zip),用 VS 打开接着改。
|
||||
import type { Diagnostic, SourceFile } from '@iot/shared'
|
||||
import { apiResponse, ApiError } from '../api'
|
||||
import { projectNamespace } from '../designer/templates'
|
||||
|
||||
export type ExportKind = 'exe' | 'project'
|
||||
|
||||
const EXT: Record<ExportKind, string> = { exe: 'exe', project: 'zip' }
|
||||
|
||||
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
|
||||
export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise<Diagnostic[] | null> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await apiResponse('POST', `/api/export-${kind}`, { files, name: projectNamespace(files) })
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
|
||||
throw error
|
||||
}
|
||||
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.${EXT[kind]}`
|
||||
const url = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = name
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Content-Disposition 里的中文文件名(filename*=UTF-8''…) */
|
||||
function fileName(header: string | null) {
|
||||
const m = header && /filename\*=UTF-8''([^;]+)/i.exec(header)
|
||||
return m ? decodeURIComponent(m[1]) : null
|
||||
}
|
||||
@@ -167,6 +167,26 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
<a href="/help-img/db.webp" title="点击放大" @click.prevent="zoom"><img src="/help-img/db.webp" alt="" loading="lazy" /></a>
|
||||
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3>8. 导出 EXE 或 VS 工程,在机房电脑上运行</h3>
|
||||
<ul>
|
||||
<li>
|
||||
点工具栏的<b>导出</b>→<b>导出 EXE</b>,下载一个
|
||||
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
|
||||
</li>
|
||||
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
|
||||
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
|
||||
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
|
||||
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
|
||||
<li>
|
||||
想在机房的 VS 里接着改,点<b>导出</b>→<b>导出 VS 工程</b>,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5
|
||||
运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。
|
||||
</li>
|
||||
<li>
|
||||
机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如
|
||||
<code>$"温度{t}"</code>)会弹出报错和改法,改好再导出。
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="currentUser?.role === 'teacher'" id="teacher">
|
||||
|
||||
@@ -4,8 +4,22 @@
|
||||
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui'
|
||||
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
|
||||
import {
|
||||
NButton,
|
||||
NDropdown,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NPopover,
|
||||
NSelect,
|
||||
NTab,
|
||||
NTabs,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DropdownOption,
|
||||
type SelectGroupOption,
|
||||
} from 'naive-ui'
|
||||
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
|
||||
import FormDesigner from '../designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from '../designer/history'
|
||||
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
|
||||
@@ -25,6 +39,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import { checkProject } from '../editor/language'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import { downloadExport, type ExportKind } from '../editor/export'
|
||||
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import RunArea from '../run/RunArea.vue'
|
||||
import { createRunSession } from '../run/session'
|
||||
@@ -478,6 +493,50 @@ async function showDiagnostic(file: string, line: number, column: number) {
|
||||
editorRef.value?.reveal(file, line, column)
|
||||
}
|
||||
|
||||
// ---------- 导出:照真机版类库编译,EXE 在机房电脑上双击运行,VS 工程用机房的 VS 2012 打开 ----------
|
||||
const exporting = ref(false)
|
||||
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
|
||||
const exportErrors = ref<Diagnostic[]>([])
|
||||
const exportErrorsOpen = ref(false)
|
||||
const exportKind = ref<ExportKind>('exe')
|
||||
|
||||
const EXPORT_OPTIONS: DropdownOption[] = [
|
||||
{ key: 'exe', label: '导出 EXE', props: { title: '编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台' } },
|
||||
{ key: 'project', label: '导出 VS 工程', props: { title: '打包成 VS 2012 工程(.zip),在机房电脑上解压后用 VS 打开,接着改代码、按 F5 运行' } },
|
||||
]
|
||||
|
||||
const EXPORT_DONE: Record<ExportKind, string> = {
|
||||
exe: '已导出,在机房电脑上双击运行(要接真的实训台)',
|
||||
project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)',
|
||||
}
|
||||
|
||||
async function exportProgram(kind: ExportKind) {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
exportKind.value = kind
|
||||
try {
|
||||
const errors = await downloadExport(
|
||||
kind,
|
||||
files.value.map((f) => ({ ...f })),
|
||||
)
|
||||
if (errors) {
|
||||
exportErrors.value = errors
|
||||
exportErrorsOpen.value = true
|
||||
} else {
|
||||
message.success(EXPORT_DONE[kind])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToExportError(d: Diagnostic) {
|
||||
exportErrorsOpen.value = false
|
||||
void showDiagnostic(d.file, d.line, d.column)
|
||||
}
|
||||
|
||||
function restoreOriginal() {
|
||||
if (props.assignmentId) void loadAssignment(true)
|
||||
else if (sampleId.value) void loadSample(sampleId.value, true)
|
||||
@@ -600,6 +659,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
|
||||
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
|
||||
任务
|
||||
</NButton>
|
||||
<NDropdown trigger="click" :options="EXPORT_OPTIONS" :disabled="exporting" @select="(key: ExportKind) => exportProgram(key)">
|
||||
<NButton size="small" :loading="exporting" title="导出到机房电脑上用:EXE 双击运行,VS 工程用 VS 打开">导出 ▾</NButton>
|
||||
</NDropdown>
|
||||
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||
@@ -623,6 +685,18 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
|
||||
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
|
||||
<p v-if="exportKind === 'project'" class="export-errors-note">
|
||||
导出时照机房电脑上的 VS 2012 编译(只支持 C# 5,用真机版设备类库)。点击报错跳到出错的代码。
|
||||
</p>
|
||||
<p v-else class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
|
||||
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
|
||||
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
|
||||
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
|
||||
</div>
|
||||
<p v-if="exportErrors.length > 20" class="export-errors-note">还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。</p>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-if="assignment && submission"
|
||||
v-model:show="resultOpen"
|
||||
@@ -794,6 +868,28 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.export-errors-note {
|
||||
margin: 0 0 8px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.export-error {
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.export-error:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.export-error .hint {
|
||||
color: #2563eb;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.rerun-blocked {
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
|
||||
Reference in new issue
Block a user