Compare commits

...
49 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 f242308a3b 部署:update.sh 改由 deploy/update-bg.sh 在服务器上后台跑(setsid),SSH 断了它照样跑完。2026-11-03 开发机上 FlClash 切换配置重建了 TUN 网卡,SSH 正好断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。输出写到 /var/tmp/iot_sim-update.log,--follow <提交号> 接着看输出、退出码和 update.sh 一样,这个提交的部署还没开始时返回 75;上一次还在跑就等它结束。Gitea 的"起服务"ssh 返回 255 就 10 秒后重连 --follow,75 就重新开始,最多 6 次。GitHub 备用部署也改用 update-bg.sh。部署说明同步
Deploy / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:32:21 +08:00
xuyueandClaude Opus 5.5 2aa267839f 导出 VS 工程:实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。导出的 zip 是教材那样的 VS 2012 窗体应用工程(.sln + .csproj,.NET Framework 4.5),在机房电脑上解压、双击 .sln 用 VS 打开接着改,按 F5 运行。导出前照机房环境编译一遍要放进 zip 的代码:C# 5(VS 2012 的编译器)、真机版设备类库,有错不导出;新版 C# 写法报 CS8026,按功能名给出改法(字符串插值改 string.Format、?. 改先判断 null、=> 改大括号等 12 种)。导出 EXE 仍按最新 C#。平台的 Properties/Resources.Designer.cs 换成 VS 生成的写法和 Resources.resx,图片放 Resources\;代码里按文件名用的素材(ring.mp3)复制到输出目录,运行时在 bin\Debug\Resources\;每个窗体带 .resx(媒体播放器 OcxState 用教材默认状态),SubType、DependentUpon 和 VS 一样;补上 AssemblyInfo、Settings、App.config。教材 dll 全放 lib\,只引用代码直接用到的(和教材工程一样),只带这些编译不过时引用全部;用到书上的 ledHelper.Close() 时带上 LedHelperCompat.cs。文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 文件名用 GBK,另附 Info-ZIP Unicode 文件名字段(0x7075),中文 Windows 自带解压和 7-Zip 都显示正确(ZipWriter.cs)。23 个示例都能导出,导出的工程在 mono 的 msbuild 里全部编译通过、输出目录和教材 bin\Debug 一样、程序能启动;VS 2012 还要在机房实测。接口 POST /api/export-project,代码有错时 422。export-exe.ts 改名 export.ts(服务器和网页各一个)。使用说明、设计方案、README、部署说明同步
Deploy / deploy (push) Failing after 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:42:13 +08:00
xuyueandClaude Opus 5.5 4c3ce31fe1 部署:push origin 就由开发机编好、测过再传到服务器,服务器不再自己编,push 后一两分钟上线。照 OJ2 的做法,开发机上的 gitea-runner(和 OJ2 共用,标签 iot-deploy,一次只跑一个任务)跑 .gitea/workflows/deploy.yml:编 C# 运行时(Release)、网页和 server.js,跑服务端测试(用要上线的 Release 版运行时)、网页端测试和类型检查,产物 rsync 到服务器的 prebuilt/(按内容比、传完才换上、断了重试),服务器上 deploy/update.sh --prebuilt 用现成产物构建镜像,只剩几条 COPY。以前 GitHub 上测试约 5 分钟、服务器 2 核再编约一分钟(编的时候学生程序会卡),push 后 6 分钟多才上线。host 模式用开发机自己的 ssh 和 git 访问,不需要 secret。Dockerfile 加构建参数 ARTIFACTS(build 默认在镜像里编 / prebuilt 用现成产物),服务器自己编的能力保留;prebuilt/COMMIT 和要部署的提交对不上时 update.sh 直接失败。服务端测试同时跑:文件之间 --parallel,评分测试标 describe.concurrent(--max-concurrency=8),本机 4 分钟降到半分钟,连跑三遍都通过。GitHub 的 ci.yml 改成 PR 跑测试、手动触发才部署(服务器自己编),开发机不在时用,push 不再两边各部署一遍。顺手:update.sh 每次部署拉一下 caddy:2-alpine(up 不会自己拉,一直是第一次部署时那份);app 和 caddy 的容器日志限 10MB × 3。部署说明、README、设计方案同步
Deploy / deploy (push) Successful in 1m38s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:08:14 +08:00
xuyueandClaude Opus 5.5 77bfaba7ff 导出 EXE:服务器上的教材 dll 读不了(root 拷进去的文件是 600,容器里的平台用户读不到)时提示中文原因和改法,不再把英文的系统报错给学生看;部署说明补上 chmod 755 目录、644 文件
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:19:28 +08:00
xuyueandClaude Opus 5.5 5e5d29b2aa 导出 EXE:实验页工具栏"导出 EXE"把学生的程序编译成一个 exe,拷到机房电脑上双击就能控制真的实训台。编译服务照教材工程编译学生写的原始代码(.NET Framework 4.5 引用程序集,构建时只复制教材工程引用的 13 个到 netfx/;真机版设备类库),不做平台的仿真改写和 API 限制,和 VS 里一样。教材 dll(DeviceCtrlLibrary 及 API 下 7 个、WinFormsDeviceControlLibrary、媒体播放器的两个 Interop)作为资源嵌进 exe,模块初始化器注册 AssemblyResolve 从 exe 里加载,Program.cs 不用改;Properties.Resources 的图片、声音嵌进 exe,代码里按文件名用到的素材(项目 7 的 ring.mp3)启动时放到 exe 旁边的 Resources;媒体播放器的 OcxState 用教材 resx 的默认状态;书上的 ledHelper.Close() 在教材类库里没有,补一个空的扩展方法。23 个实验都能导出(约 0.9 MB),mono + Xvfb 里实测能启动、控件和图片正常。接口 POST /api/export-exe,代码有错时 422,实验页弹框列出报错、点击跳到代码。教材 dll 有版权、GitHub 仓库公开,不进仓库:本机放 textbook-libs/,服务器放 ~/IOT_SIM/textbook-libs/ 由 compose 只读挂载(deploy/README.md),没放时提示原因。使用说明、设计方案、README 同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:10:07 +08:00
xuyueandClaude Opus 5.5 46e0f40cce 实训台接线:串口要选对。接线做成数据(BoardState.wiring),默认按教材布局图 COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,实验可在 meta.json 的 wiring 里改:项目 7 的书上代码在一个串口上打开两个模块,这三个实验把 4150 并到 COM2 的 485 总线上。选错串口时返回值照反编译的真机版 dll(4017 全 null、4150 全 false,4150 的 OnOff 和 LED 推送返回成功但设备不动作,ZigBee 返回 false);串口没打开时照教材仿真版。Open / Close / 没打开就读写报告给服务器,实训台最上方用红字提示("程序在 COM2 上打开 ADAM-4150,它接在 COM3""ADAM-4150 的串口没有打开,程序却在读写它"),评分不通过时也附上。只读接线展示:鼠标移到实训台设备上,标题栏写出线路,对应通道、模块、串口高亮;串口服务器按接线列出设备,程序打开的口亮绿点;程序里串口下拉框的灰字按当前接线。打开实验时浏览器发 experiment 消息换接线,老师看学生提交时按作业的实验接线(提交接口返回 sampleId)。"4017"统一写成 ADAM-4017+
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
上课前逐个检查 23 个实验后的修改:补上三处评分漏洞(项目 3 任务 1、项目 4 任务 3 的关闭和项目 7 任务 2 的熄灯,程序什么都不做也能通过);评分说明的空格;任务说明按实测补充(项目 2 任务 1–3、项目 3 任务 2、项目 4 任务 2、项目 7 任务 1–3、项目 8 任务 5)。使用说明、设计方案同步,设计方案记下以后可能扩展"物联网设备安装与调试"课

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:40:57 +08:00
xuyueandClaude Opus 5.5 163e553de4 日期选择框兼容机房 Chrome 94:以前点下拉按钮靠 showPicker() 弹出日期框,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应,项目 6 任务 3、项目 8 任务 5 选不了日期。改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选)。运行时长日期和中文 Windows 一致,不带星期(Linux 的 zh-CN 长日期是"2026年10月28日星期三",日期框窄,星期被截掉)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:40:57 +08:00
xuyueandClaude Opus 5.5 9ed446f95a 实验页:程序运行时改了代码或设计器,停手 1.5 秒后自动重新运行,不用再按 F5。重启前先用边写边检查的接口检查整个工程,有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行";检查期间又改了、程序已停或换了工程时作废;停止后改动不会自动启动程序。使用说明、设计方案同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:15:46 +08:00
xuyueandClaude Opus 5.5 6a18bdf2e4 兼容机房的 Chrome 94:打包目标改成 Chrome / Edge 94;polyfills.ts 补 crypto.randomUUID(http 打开时没有)、structuredClone(只复制普通数据)、findLast / findLastIndex;vite.config.ts 加小插件,把代码块之间的 import("./X.js") 改成 /assets/ 开头的绝对路径(Chrome 94 有时相对网页地址解析,拿到首页 HTML 一片空白),改过的代码块换新文件名;容器查询改成 ResizeObserver 量宽度加 class(实训台三档布局、设计器窄时隐藏提示);:has() 改成 class(只读、禁用文本框灰底,没标题的分组框)。旧浏览器提示改成 94 以上,设计方案记录实测
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:58:25 +08:00
xuyueandClaude Opus 5.5 ab933d1896 学生窗体的密码框不再被浏览器自动填上平台的用户名和密码:设了 PasswordChar / UseSystemPasswordChar 的文本框改成普通文本框加 -webkit-text-security 遮成圆点,浏览器不把它当登录表单(不支持该属性的浏览器才退回密码框并标 new-password);文本框、下拉框、表格单元格编辑框都加 autocomplete="off",不弹以前输入过的内容。所有任务共用这个控件,项目 1 任务 1 的登录、添加用户窗体都覆盖到
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:57:28 +08:00
xuyueandClaude Opus 5.5 50dced4d37 串口下拉框去掉电脑自带的 COM1,只留实训台上的 COM2–COM5;展开时每个串口后面用灰字标出接的设备(ADAM-4017+、ADAM-4150、ZigBee 协调器、LED 屏),选中后的值还是 COM2 这样的串口名,和真机一致。项目 5 任务 3 跳过 COM1 的写法照书保留,任务说明补一句;使用说明、设计方案同步;按位置选串口的测试下标跟着改
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:41:18 +08:00
xuyueandClaude Opus 5.5 50946467f6 窗体显示和 WinForms 对齐:文本框、下拉框、列表框、日期选择默认白底黑字,不沿用父控件的颜色(项目 8 白字分组框里的文本框填了字看不见);下拉框的背景色能显示、展开的列表跟着下拉框的颜色和字体;分组框、面板禁用时里面的控件也禁用(变灰、不响应操作,Enabled 读出 false 并触发 EnabledChanged);禁用的控件设了颜色也画灰字;窗体、文本框等设透明背景色和 WinForms 一样报错,设计器里也不提供"透明";多行文本框按 ScrollBars 显示滚动条;标签的边框和 TextAlign、窗体的 ControlBox、复选框的不确定状态能显示;设计器里 Enabled = false 的控件和 VS 一样照常显示
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:41:46 +08:00
xuyueandClaude Opus 5.5 a2f03ee57f 实验页:编辑区和运行区之间的竖条上的小箭头换成竖排文字"收起运行区""展开运行区",竖条加宽到 22px
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:41:46 +08:00
xuyueandClaude Opus 5.5 e177753ad6 数据库页面:对象资源管理器加库和表的图标,表缩进挂在库下面(左边竖线),一眼分得清层级;标签页内容改成占标签栏下面剩下的高度,不再多出 39px 撑出整页滚动条;查询补全写了 FROM / JOIN 后只列用到的表的列。设计器画布窄时操作栏折行、不显示提示文字,不再撑出横向滚动条。使用说明的数据库截图同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:06:13 +08:00
xuyueandClaude Opus 5.5 f9a2739353 数据库页面的对象资源管理器简化成“库 → 表”两层:去掉“数据库”“表”“列”三层文件夹和 emoji;头部“+ 新建库”和刷新,每个库下面“+ 新建表”;点表名打开编辑前 200 行(已经开着就切过去),库和表右边的 ⋯ 和右键是同一个菜单(新建表 / 新建查询 / 删除数据库;编辑数据 / 查询前 1000 行 / 设计表结构 / 删除表),⋯ 打开的菜单出在按钮下面;鼠标停在表名上显示列。顶部去掉重复的“新建数据库”按钮;使用说明、数据库截图和设计方案同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:23:42 +08:00
xuyueandClaude Opus 5.5 b292d38ba7 查询窗口加自动补全:T-SQL 关键字、函数、数据类型(中文说明,只列服务器能执行的),当前数据库的表名和列名;FROM / JOIN 等后面敲空格弹出表名,"表名."或"别名."只列这张表的列,USE 后面列数据库;字符串和注释里不弹出
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:01:57 +08:00
xuyueandClaude Opus 5.5 d6e891cdb0 使用说明:截图点击后在页面上放大(按窗口大小,最多放大 2 倍),点任意处或按 Esc 关闭
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:53:54 +08:00
xuyueandClaude Opus 5.5 53005e69e4 使用说明配截图:学生部分 9 张(我的作业、实验页、任务面板、设计器、运行出错、实训台、自己检查、评分结果、数据库),老师部分 4 张(名单、布置作业、评阅、成绩);WebP 共约 350 KB,按需加载,点击看原图
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:01:38 +08:00
xuyueandClaude Opus 5.5 313acf2453 使用说明同步最近的改动:报错和运行出错显示在窗体下面、放大后可以停止、双击工具箱的放置规则、两盏照明灯、数据库页面默认用刚才的实验;常见问题加"只显示已停止"
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:49:31 +08:00
xuyueandClaude Opus 5.5 b7e7f77fb0 实训台:加 ADAM-4150 DO1 照明灯的卡片(项目 7 用);滑块松开后等服务器回传同样的值再换回,键盘方向键连按不再丢步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:43:16 +08:00
xuyue b3d6e1f88f update
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
2026-10-27 11:39:26 +08:00
xuyueandClaude Opus 5.5 f6024dc113 试用前走查的修改:报错看得见、学生找得到作业、运行区和设计器的若干体验问题
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 运行区:编译错误、运行异常、控制台输出和保存的图片移到窗体下面,不再被实训台挤成 0 高;缩放和放大按钮移到桌面上方,不压窗体的关闭按钮;放大后也能停止程序;点停止后清掉窗体
- 运行异常:常见 .NET 异常配中文说明,显示出错的文件、行号和方法,点击跳到代码
- 运行中的下拉列表挂到页面最外层,能伸出窗体和分组框(小窗体也能选到 COM4、COM5)
- 学生登录后进"我的作业";任务面板从工具栏下面开始,不挡导航;记住上次做的实验,从数据库页面返回、恢复数据时默认用它
- 代码页和设计页左栏都占 3/5,文件标签放不下时换行;实验选择框加宽;关闭粘性滚动
- 设计器:双击生成的事件方法带缩进;双击工具箱放进选中控件所在的容器并错开位置;新控件比容器宽时按比例缩小;无边框窗体不画标题栏
- 中文界面:代码编辑器(查找替换等)、属性窗口的颜色选项
- SQL:DATEADD、DATEDIFF、DATEPART 的日期单位(day、hour)解析成 IdentifierLiteral 时报"不支持",补上
- 项目 8 的预置环境数据改成建库那天之前的 7 天;p6-t3 评分项改为"按日期查询环境数据"
- 没有 AI 建议时不显示"全部采纳 AI 建议"和相关说明;导出 CSV 只给纯数字的文字加制表符

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:49:42 +08:00
xuyueandClaude Opus 5.5 da623fa648 任务名重新设计:用学生要做的程序命名,10 个字以内,去掉实现采集…数据和设备型号;项目 8 用模块:步骤写法;p6-t3 改为按日期查询环境数据,与内容一致
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:32:19 +08:00
xuyueandClaude Opus 5.5 e4c53f5e55 名单改为用户名、密码两列;去掉姓名前的班级代号,学生用姓名登录;别班已有同名学生时报错,不再转班
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:49:50 +08:00
xuyueandClaude Opus 5.5 61a32e900d 实验下拉菜单按项目分组;路线图更新到已上线、待一个班试用
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:39:40 +08:00
xuyueandClaude Opus 5.5 4ce800c77f compose 传入 SIM_GRADE_CONCURRENCY,.env.example 补上说明
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:13:30 +08:00
xuyueandClaude Opus 5.5 de277d3e9d 用 bun fmt 格式化全部代码和文档
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:33 +08:00
xuyueandClaude Opus 5.5 d4b8bf9241 任务说明补齐 23 份;设计器支持窗体改名
- 其余 21 个实验的 task.md:控件名照示例代码,"在平台上怎么试"和评分步骤一致,列出书上的印刷错误和控件名不一致
- 窗体改名:属性窗口里选中窗体可以改名称,类名、构造函数、new X()、Program.cs 和两个文件名一起改;方法名和字符串不变
- 11 个主窗体不叫 Form1 的实验,任务说明提示先改窗体名再双击生成 Load 事件
- 测试:表头只认分隔行上面那一行,数据行里出现"名称"不再被当成表头

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:55:39 +08:00
xuyueandClaude Opus 5.5 90b739a2c7 任务说明:实验页"任务"面板、布置作业时可改写;先写项目 3 任务 2、项目 6 任务 2 两份
- 每个实验一份 task.md(任务目标、控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试)
- 工具栏"评价项"改成"任务":上面是任务说明,下面是评价项和自己检查;不遮挡页面,第一次打开实验时自动打开
- 自己写的 Markdown 子集渲染,先转义全部 HTML(老师可以改写说明)
- 作业保存任务说明(新列 task),以前布置的作业用实验现在的说明
- 测试:任务说明表格里的控件名都在示例的 Designer.cs 里
- 修改作业时实验下拉框显示标题而不是编号

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:04:05 +08:00
xuyueandClaude Opus 5.5 3b9f635c35 使用说明页:学生和老师的操作步骤、上课前后注意事项、常见问题
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
/help 没登录也能看,老师登录后多出老师部分;登录页和各页顶栏在新标签页打开。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:00 +08:00
xuyueandClaude Opus 5.5 27fdc3615d 试用前排查:WebSocket 心跳与断线重连、登录失败按账号限制、旧浏览器提示
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 服务器 30 秒发一次 ping:Nginx Proxy Manager 默认 60 秒没数据就断开,窗体开着不操作程序会被结束
- 连接意外断开时提示、清掉失效的窗体、自动重连,连上后提示重新运行
- 登录失败:同一账号 10 次只锁这个账号,同一 IP 300 次,登录成功不再清零 IP 计数
- index.html:浏览器太旧时说明原因和解决办法,加载慢时提示稍等

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:05:05 +08:00
xuyueandClaude Opus 5.5 982b1297aa 试用前准备:全班模拟压测、评分并发 6→16、手动备份与恢复
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- test/classroom.ts:45 个虚拟学生走一遍登录、运行、自测、提交,统计每个环节等多久
- 评分并发可配置(SIM_GRADE_CONCURRENCY,默认 16),提交队列同时评的份数跟着走;
  2 核实测项目 8 任务 3 全班自测从约 4 分钟降到约 1.5 分钟
- 检查的默认等待 2 秒→5 秒,机器忙时不再误判正确的程序
- 删除学生时删除他的数据库(编号会重新用给新学生)
- deploy/backup.sh、deploy/restore.sh:手动备份到宿主机 backups/,恢复前先备份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:08:31 +08:00
xuyueandClaude Opus 5.5 e8058376dd 评阅页不显示项目资源类;编译、往返时间只给老师看
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 评阅页的代码标签不再列出平台生成的 Properties/Resources.Designer.cs(和实验页一致)
- 自由练习里编译、启动、往返时间只给老师显示,学生看不到这些开发用的数字

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:56:07 +08:00
xuyueandClaude Opus 5.5 c217eb5ca0 素材图片在高分辨率屏幕上清晰显示;项目资源对话框点整行勾选
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 素材库每张图另外渲染一份两倍尺寸(name.2x.png),build-gallery.sh 把图片编号(原尺寸 PNG 的
  SHA-1,和运行器给图片的编号相同)和两倍图写进 index.json
- 网页认出素材图片(按编号或素材网址),图片框用 srcset、背景用 image-set,系统缩放 150%、200%
  的屏幕上不再发虚;学生程序读到的仍是原尺寸图片,宽高、评分都不变
- 素材清单 index.json 每次向服务器确认(不再缓存一小时),部署新素材后立即生效
- 项目资源对话框:点整行就能勾选(原来只能点复选框,"试听"不会勾选);缩略图用灰色棋盘格底,
  透明背景上的白色图片(ico_title)也看得见

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:49:34 +08:00
xuyueandClaude Opus 5.5 f304fc1ffc 窄屏工具栏不换行;数据库页面提示更新
- 屏幕宽度不到 1600 时不显示编译、往返时间(给开发看的),平台名、保存状态、链接不换行,
  1366 宽的机房电脑上工具栏是一行
- 数据库页面的提示:运行要用数据库的实验时自动建库,"恢复实验初始数据"用来恢复成初始数据

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:44:04 +08:00
xuyueandClaude Opus 5.5 fe62d50881 设计大窗体:运行区可收起、设计器缩放;工具箱补图标
- 编辑区和运行区之间的竖条点一下收起运行区,代码和设计器占满宽度;点"运行"自动展开,浏览器记住选择
- 设计器缩放:适应宽度(默认,只缩小不放大)、100%、75%、50%;缩放时拖动、改大小、
  双击、拖入控件的坐标按比例换算(教材项目 8 的窗体有 1043×791)
- 工具箱给列表框、图片框、日期时间选择器、滑动条、数据表格、图表、媒体播放器补上图标

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:42:14 +08:00
xuyueandClaude Opus 5.5 f256b313f7 实验页"评价项"和自测:提交前自己检查,不计分
- 工具栏"评价项"打开侧栏,列出自动检查的功能项(作业显示布置时的规则和各项分值)
- "自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和原因;不计分、不留记录,
  自由练习和作业都能用;每人同时一次,结果保留到下一次,之后改过代码提示结果可能过时;
  和提交评分共用全站并发上限
- 实验的评价项接口学生也能用(老师布置作业的对话框改用它)
- 修正:运行区的窗体和"放大"按钮会盖住网页的抽屉和对话框

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:35:25 +08:00
xuyueandClaude Opus 5.5 aadb2c9e93 Bun 升级到 1.4.4(Docker 镜像和 CI)
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:27:17 +08:00
xuyueandClaude Opus 5.5 751c4c964d 修复 CI:评分操作控件前等它出现;README 更新待办
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 点击、输入、选择、选日期、点单元格前最多等 3 秒让控件出现:上一步刚打开的窗体
  (ShowDialog 前在 Load 里查数据库)在 CI 上来不及显示,p8-t5"按时间段查找"报找不到 dateTimePicker1
- README"尚未完成"去掉已完成的项目 8,列出试用和第 4 阶段的内容

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:24:41 +08:00
xuyueandClaude Opus 5.5 65a1b3a81e 项目 8 智慧校园管理平台:5 个任务的示例、自绘素材、WisdomCampus 数据库
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
- 示例 p8-t1…t5(代码逐个累加):主界面、校园环境界面、环境采集与分析(路灯手动 / 自动控制、
  LED 推送、自动推送火情、7 天温度图表)、校园安防(入侵提示、摄像机方向、拍照入库)、历史图片
  (按日期查询、左右切换),各有评分规则
- 按书补全参考工程里空着的"推送LED信息""自动控制",给"自动推送火情"绑定 CheckedChanged
  (书上第 23 步写成了 ch_autoLamp);任务 5 每次查询前清空 Keys
- 素材:项目 8 的 20 多张图片全部自绘,名字和尺寸与教材一致(project8.py 生成 SVG,
  build-gallery.sh 渲染成 PNG;源文件移到 svg/)
- 数据库 WisdomCampus 按教材的表结构建(EnvironmentData、SecurityData、Users),预置 7 天环境数据
- 测试:参考答案全部通过;没绑定"自动推送火情"、开灯换错背景图片时报出对应原因
- 文档:教材 8 个项目全部有示例,共 23 个

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:15:00 +08:00
xuyueandClaude Opus 5.5 b20db5721e 评分规则加可见、窗体、程序退出、素材图片、图表数据点检查
- visible:控件连同所在的容器都可见;window:某个窗体打开着 / 已关闭;exited:程序已退出
- image / background 可以写素材名(如 background: day_01),按图片内容的哈希核对是不是这张素材
- points:图表每个系列至少有几个数据点
- "点击"图片框时和浏览器一样先发鼠标按下、松开(教材项目 8 的方向键按下时转动摄像机)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:14:49 +08:00
xuyueandClaude Opus 5.5 10c50265b2 第一次运行实验时自动建好预置的数据库
- 运行时带上当前实验;实验有 database.sql 而学生还没有其中的库时,先建好这些库
  (已有的库不动,保留学生的数据);建库失败不影响运行
- 教材项目 8 要"附加"现成的 WisdomCampus 库,学生不用先去数据库页面恢复

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:14:49 +08:00
xuyueandClaude Opus 5.5 5ae07b851d 路径里的反斜杠在 Linux 上换成斜杠
- 教材写 AppDomain.CurrentDomain.BaseDirectory + "Camera\\",Linux 上反斜杠会成为文件名的一部分
- 编译时把传给 System.IO(File、Directory、Path、各种 Stream)和 Image / Bitmap 读写文件方法的
  路径参数包上 Sim.Host.WindowsPaths.Fix;只包语义上确实是这些方法里名字带 path / file / dir 的
  字符串参数,不换行,编辑器里看到的仍是原代码

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:14:49 +08:00
xuyueandClaude Opus 5.5 2cd0111967 图表(Chart):运行时、Chart.js 绘制、设计器的"系列和图例"
- System.Windows.Forms.DataVisualization.Charting:ChartAreas、Legends、Titles、Series
  (ChartType、Points.AddXY、图例文字等),DataSource + DataBind(DataTable / DataView / DataSet,
  按 XValueMember、YValueMembers 取列);数据变化时把各系列的点发给浏览器
- 浏览器用 Chart.js 画:SeriesChartType 对应折线、柱形、饼图等,WinForms 默认的 BrightPastel 配色,不要动画
- 设计器工具箱加"图表",和 VS 一样带 ChartArea1、Legend1、Series1;属性窗口"系列和图例"对话框
  编辑系列(名称、图表类型、图例、图例文字、X / Y 值字段)和图例,按 VS 的格式和顺序写回 Designer.cs,
  对话框不管的属性原样保留,教材项目 8 的图表读入再写出逐字节不变;删除图表时一起删掉局部变量;
  设计时用示例数据预览

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:14:49 +08:00
xuyueandClaude Opus 5.5 d60868d912 停靠布局、背景图片、无边框窗体;运行区按比例缩小、可放大
- Dock:和 WinForms 一样按 Controls 倒序依次停靠,扣掉 Panel 边框、GroupBox 标题占的地方;
  改大小、显示隐藏、加入移出控件时重新布局;SuspendLayout / ResumeLayout / PerformLayout
- 设计器里每次修改后按同样的规则排好停靠的控件(designer/layout.ts);属性窗口加"停靠"
- BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放):窗体、面板、图片框,
  设计器里从项目资源选;无边框窗体没有标题栏,大小不含边框
- 背景色沿用父控件:没设背景色的标签、面板、分组框等画父控件的背景色(不是背景图片),
  和 WinForms 一样;分组框标题处边框断开、透出背景;文本框的 BackColor 生效
- 图片框可以放子控件,把鼠标按下、松开发给程序
- 运行区:窗体比运行区大时整体按比例缩小并显示比例;"放大"占满整个网页(窗体在左、实训台在右),Esc 还原
- Paint 事件:VS 双击面板生成的空 panel1_Paint 能编译(平台由浏览器绘制,不触发)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:14:34 +08:00
xuyueandClaude Opus 5.5 42e258ddcd 实验页新建、删除类
- "+ 新建类":输入类名,可以带文件夹(SQLHelpers/SQLBLL),文件夹成为命名空间的一段,
  模板和 VS 2012 的类模板一样;检查重名、非法名字
- 自己建的类可以删除(窗体、Program.cs、项目资源类不能用这个删)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:28 +08:00
xuyueandClaude Opus 5.5 13c3e4c3ec 数据库:参数化的日期字符串比较时也转换格式
- 教材项目 8 用参数传 "2024/1/23  23:59:59"(日期和时间之间两个空格)做 BETWEEN,
  比较时用 tsql_cmp 把形如日期的字符串转成标准格式;日期和时间之间允许多个空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:28 +08:00
xuyueandClaude Opus 5.5 19d6872d80 清理运行时的编译警告
- 媒体播放器内部的 Controls 类改名 PlayerControls,不再隐藏 Control.Controls
- DataGridView 的 CellEnter、RowEnter 不触发,标明不报"从不使用"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:28 +08:00
xuyueandClaude Opus 5.5 eedf1aa2fb 项目 7 情景控制:虚拟时钟、项目资源、Windows Media Player、TrackBar
- 虚拟时钟:实训台时钟面板可拨时间、1×/10×/60× 倍速、恢复真实时间;编译时把
  DateTime.Now / Today / UtcNow 改写成 SimClock;评分规则加 clock 步骤
- 项目资源(Properties.Resources):平台素材库(自绘开关图片、合成门铃声),"项目资源"对话框勾选,
  平台生成 Properties/Resources.Designer.cs;设计器图片框的"图片"属性可选资源;程序目录下的 Resources
  指向素材库;未添加资源时给中文提示
- Windows Media Player(AxWMPLib):设置 URL 自动播放、Ctlcontrols.play/stop,浏览器里播放声音,
  实训台加广播喇叭;编译时给窗体嵌空资源,OcxState 取到 null 不报错;图片和声音统一按内容缓存(blob)
- TrackBar:拖动、点轨道、方向键,先 ValueChanged 再 Scroll;设计器加"滑动条""媒体播放器"
- 评分规则加 value、media 检查;示例项目 7 任务 1–3(修正书上开关灯判断写反、漏填串口列表)
- 修正:Properties/ 下的资源类不当作窗体;带命名空间的 System.AppDomain.CurrentDomain.BaseDirectory 放行
- 测试:虚拟时钟、TrackBar、项目 7 示例评分、作业起点带资源类

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:11:05 +08:00
374 changed files with 22576 additions and 1054 deletions

No files matched your search

+1
View File
@@ -7,4 +7,5 @@
.git
docs
*.pdf
textbook-libs
.env
+2
View File
@@ -19,3 +19,5 @@ SIM_HEAP_LIMIT_MB=128
SIM_CPU_LIMIT_SECONDS=120
# 每次运行最长时间(分钟)
SIM_MAX_RUN_MINUTES=60
# 全站同时跑几个评分检查项(提交评分和"自己检查"共用);2 核服务器不要超过 24,CPU 不够会误判
SIM_GRADE_CONCURRENCY=16
+135
View File
@@ -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
+11 -10
View File
@@ -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:
@@ -19,7 +19,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.3
bun-version: 1.4.4
- uses: actions/setup-dotnet@v6
with:
@@ -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 }}"
+8
View File
@@ -10,6 +10,8 @@ obj/
# 教材资料(体积大、有版权,不进仓库)
物联网编程与应用配套/
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
/textbook-libs/
*.pdf
# 编辑器
@@ -22,3 +24,9 @@ obj/
# 本机开发的数据库
/data/
# 服务器上 deploy/backup.sh 做的备份
/backups/
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
/prebuilt/
+3
View File
@@ -0,0 +1,3 @@
# bun fmt 不格式化的文件
# 书上的摘录,保持原样
docs/教材摘要/
+25 -5
View File
@@ -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
@@ -8,7 +14,7 @@ COPY runtime/ runtime/
RUN dotnet build runtime/Runtime.slnx -c Release
# ---- 网页和服务端 ----
FROM oven/bun:1.4.3 AS web-build
FROM oven/bun:1.4.4 AS web-build
WORKDIR /src
COPY package.json bun.lock ./
COPY shared/package.json shared/
@@ -22,16 +28,30 @@ 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 \
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=oven/bun:1.4.3 /usr/local/bin/bun /usr/local/bin/bun
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
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 \
+12 -9
View File
@@ -6,7 +6,7 @@
## 目录
| 目录 | 内容 |
|---|---|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
@@ -33,11 +33,12 @@ cd server
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学生登录、运行、自测、提交,统计每个环节等多久
```
## 部署
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。
## 运行器的四种模式
@@ -55,7 +56,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
本机实测(16 核、28 GB):
| 指标 | 结果 |
|---|---|
| ------------------------ | ----------------------------------------------------------- |
| 编译(编译服务预热后) | 约 10–300 ms |
| 启动到窗体显示 | 约 100 ms |
| 单次操作往返 | 约 15 ms |
@@ -67,15 +68,17 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
已完成:
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox,教材控件库的 Lamp、Alarm。
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3,共 15 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox、DataGridView、DateTimePicker、TrackBar、Windows Media Player、Chart(图表),教材控件库的 Lamp、Alarm;Panel 停靠布局、背景图片、无边框窗体。
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3、项目 7 任务 1–3、项目 8 任务 1–5,共 23 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
- 第 3 阶段(进行中):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
- 导出 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)。
尚未完成:
- 教材项目 7–8(需要 TrackBar、资源图片、图表、虚拟时钟、无边框窗体等)。
- 一个班试用。
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
+6 -1
View File
@@ -23,6 +23,7 @@
"name": "@iot/web",
"dependencies": {
"@iot/shared": "workspace:*",
"chart.js": "^4.6.1",
"modern-screenshot": "^4.7.0",
"monaco-editor": "^0.57.0",
"naive-ui": "^2.41.0",
@@ -115,6 +116,8 @@
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -217,6 +220,8 @@
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"chart.js": ["chart.js@4.6.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g=="],
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -251,7 +256,7 @@
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"modern-screenshot": ["modern-screenshot@4.7.0", "", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
"modern-screenshot": ["modern-screenshot@4.7.0", "https://registry.npmjs.com/modern-screenshot/-/modern-screenshot-4.7.0.tgz", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
+5
View File
@@ -0,0 +1,5 @@
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
[fmt]
printWidth = 160
semi = false
singleQuote = true
+18 -5
View File
@@ -5,18 +5,27 @@ services:
init-data:
image: iot-sim:latest
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
build: .
build: &build
context: .
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
args:
ARTIFACTS: ${ARTIFACTS:-build}
user: root
command: ["sh", "-c", "chown -R \"$$APP_UID\" /data"]
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
volumes:
- data:/data
restart: "no"
restart: 'no'
networks: [default]
app:
image: iot-sim:latest
build: .
build: *build
restart: unless-stopped
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
driver: json-file
options:
max-size: 10m
max-file: '3'
depends_on:
init-data:
condition: service_completed_successfully
@@ -33,6 +42,7 @@ services:
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
SIM_GRADE_CONCURRENCY: ${SIM_GRADE_CONCURRENCY:-16}
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
read_only: true
tmpfs:
@@ -40,6 +50,8 @@ services:
# 数据库(账号、班级、学生作品),重新部署不会丢
volumes:
- data:/data
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
- ./textbook-libs:/app/textbook-libs:ro
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
@@ -51,12 +63,13 @@ services:
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
mem_limit: ${APP_MEM_LIMIT:-4g}
pids_limit: 4096
expose: ["3000"]
expose: ['3000']
networks: [default]
caddy:
image: caddy:2-alpine
restart: unless-stopped
logging: *logging
depends_on: [app]
expose:
- 80
+78 -10
View File
@@ -66,15 +66,35 @@
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
## 自动部署(GitHub Actions)
## 自动部署(push origin 就部署)
`.github/workflows/ci.yml`:
`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。
- 每次推送和 PR:编译 C# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。
- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。
- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。
- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。
为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。
**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。
### 部署 runner
和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。
host 模式直接用开发机自己的东西,所以**不需要配任何 secret**:
- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync;
- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`);
- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。
开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。
### 备用:GitHub 手动触发
`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update-bg.sh <提交号>`(后台跑 `update.sh`),**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。
一次性配置(在本机执行,需要已登录的 `gh`):
@@ -97,20 +117,67 @@
在服务器的 `~/IOT_SIM` 目录下:
| 做什么 | 命令 |
|---|---|
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
| 看资源占用 | `docker stats` |
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 导出 EXE、VS 工程用的教材类库
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
| 文件 | 在配套资料里的位置 |
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `DeviceCtrlLibrary.dll` | `公共库/设备控制类库/设备控制/`(**不是** `仿真/`) |
| `ComLibrary.dll`、`ICS.Acquisition.dll`、`ICS.Common.dll`、`ICS.Models.dll`、`IPCameraDll.dll`、`LEDLibrary.dll`、`MyCamer.dll` | `公共库/设备控制类库/设备控制/API/` |
| `WinFormsDeviceControlLibrary.dll` | `公共库/设备控件库/` |
| `AxInterop.WMPLib.dll`、`Interop.WMPLib.dll`(媒体播放器,项目 7 任务 2) | `项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/` |
在本机(配套资料解压在 `~/下载/物联网编程与应用配套/`)执行:
```bash
B=~/下载/物联网编程与应用配套/物联网编程与应用
ssh 服务器 mkdir -p IOT_SIM/textbook-libs
scp "$B"/公共库/设备控制类库/设备控制/DeviceCtrlLibrary.dll "$B"/公共库/设备控制类库/设备控制/API/*.dll \
"$B"/公共库/设备控件库/WinFormsDeviceControlLibrary.dll \
"$B"/项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/*Interop.WMPLib.dll 服务器:IOT_SIM/textbook-libs/
```
容器里的平台用普通用户运行,目录和文件要让其他用户能读(用 root 拷的文件常常是 `-rw-------`,导出时会提示"权限不够"):
```bash
chmod 755 ~/IOT_SIM/textbook-libs
chmod 644 ~/IOT_SIM/textbook-libs/*.dll
```
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
## 备份与恢复
平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行:
```bash
deploy/backup.sh # 备份到 backups/<日期时间>/,平台运行中也可以,学生不受影响
deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部数据
```
- 备份包括账号、班级、学生作品、作业、提交、评阅(`iot-sim.db`)和每个学生自己的数据库(`databases/`)。一个班一学期的数据估计两三百 MB,主要是提交时的界面截图。
- 备份放在宿主机的 `~/IOT_SIM/backups/` 里,不在 Docker 数据卷里,误删数据卷也还在;**要防服务器硬盘坏,把这个目录拷到别的机器上**。
- 建议在部署大的新版本前、学期中间、期末导出成绩后各备份一次。
- 恢复会先自动备份现在的数据(恢复错了还能换回来),然后停止平台约十几秒、替换数据、重新启动;正在运行的学生程序会结束,尽量不在上课时恢复。
## 可调参数
写在 `.env` 里,改完 `docker compose up -d` 生效。
| 变量 | 默认 | 说明 |
|---|---|---|
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
| `TEACHER_USERNAME` | `admin` | 老师账号 |
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
@@ -120,3 +187,4 @@
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 在服务器上执行:备份平台的全部数据(账号、班级、学生作品、作业、提交、评阅,以及每个学生自己的数据库)
# 到 backups/<日期时间>/。备份在宿主机上,不在 Docker 数据卷里,数据卷被删也还在;要防硬盘坏,把它拷到别的机器上。
# 平台运行中也可以备份,学生不受影响。恢复见 deploy/restore.sh。
# 用法:deploy/backup.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p backups
name=$(date +%Y%m%d-%H%M%S)
# 同一秒里做了两次(如 restore.sh 恢复前先备份)时,等到下一秒,不和已有的备份重名
while [[ -e backups/$name ]]; do
sleep 1
name=$(date +%Y%m%d-%H%M%S)
done
inside=/data/.backup-$name
# 在容器里导出(SQLite 一致读取,不会备份出写了一半的库),再拷到宿主机
docker compose exec -T app bun server.js backup "$inside" >/dev/null
docker compose cp "app:$inside" "backups/$name" 2>/dev/null
docker compose exec -T app rm -rf "$inside"
echo "已备份到 $(pwd)/backups/$name($(du -sh "backups/$name" | cut -f1))"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# 在服务器上执行:用 deploy/backup.sh 做的备份替换平台现在的全部数据。
# 替换前先自动备份现在的数据(恢复错了还能换回来);恢复时平台停止约十几秒,正在运行的学生程序会结束。
# 用法:deploy/restore.sh backups/<日期时间>
set -euo pipefail
cd "$(dirname "$0")/.."
dir=${1:?用法:deploy/restore.sh backups/<日期时间>}
if [[ ! -f $dir/iot-sim.db ]]; then
echo "$dir 不是备份目录(里面应该有 iot-sim.db)"
exit 1
fi
read -r -p "用 $dir 替换平台现在的全部数据?现在的数据会先备份。[y/N] " answer
[[ $answer == y || $answer == Y ]] || exit 1
echo "== 备份现在的数据"
deploy/backup.sh
echo "== 停止平台、替换数据"
docker compose stop app
# 旧库的 -wal、-shm 文件一定要删掉,否则会被当成新库的未写完的日志,把恢复的库弄坏
docker compose run --rm --no-deps -T -v "$(realpath "$dir"):/restore:ro" app \
sh -c 'rm -rf /data/iot-sim.db /data/iot-sim.db-wal /data/iot-sim.db-shm /data/databases && cp -r /restore/. /data/'
echo "== 启动平台"
docker compose up -d app
echo "已恢复到 $dir"
+63
View File
@@ -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
View File
@@ -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
+70 -20
View File
@@ -15,7 +15,7 @@
## 已确定的关键决策
| 事项 | 决定 | 理由 |
| --- | --- | --- |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 学生编程语言 | C# | 课程要求 |
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
@@ -51,20 +51,21 @@
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM1–COM5,保留"选串口、点打开"的操作步骤)。
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
**导入 VS 工程、导出 EXE 和 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件;也能导出成教材那样的 VS 2012 工程(zip),在机房的 VS 里接着改。
## 设备类库设计(兼容教材)
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
| 对象 | 主要成员 | 平台仿真行为 |
| --- | --- | --- |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
@@ -97,7 +98,10 @@ private void btn_Send_Click(object sender, EventArgs e)
兼容细节:
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
- 4017 和 4150 允许打开同一个串口;允许重复调用 `Open`。
- **串口要选对**(按实训台接线):接线按教材布局图,ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,即 COM2 4017+、COM3 4150、COM4 ZigBee 协调器、COM5 LED 屏(`shared/protocol.ts` 的 `DEFAULT_WIRING`)。实验可以在 `meta.json` 的 `wiring` 里改:项目 7 的书上代码在一个串口上打开两个模块(书上截图是用仿真版 dll 截的,选哪个口都行),这三个实验把 4150 并到 COM2 的 485 总线上(两个模块地址不同,真机上也能这样接)。
- 在没接这台设备的串口上打开它,返回值照真机版 dll(已反编译核对):`Open` 不报错;4017 的 `SetData()` 全是 null、4150 的全是 false;4150 的 `OnOff` 只管发命令、不等回应,返回 true 但继电器不动作;ZigBee 的 `OnOff` 收不到回应,返回 false;LED 的 `SendLedString` 原样返回文字但屏上不显示。真机上这只是"没反应",平台另外在实训台最上方用红字提示"程序在 COM2 上打开 ADAM-4150,它接在 COM3",评分不通过时也附上这句。
- 串口没打开(或已经 `Close`)时程序还在读数据、控制设备:返回值照教材仿真版 dll(4150 的 `SetData()` 返回各项为 false 的对象,不是真机版的 null;书上代码是照仿真版写的,返回 null 会让学生看到一个指不出原因的空引用异常),实训台同样用红字提示"ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)",评分说明也附上。学生忘了点"打开"时一眼能看出原因。
- 允许重复调用 `Open`(项目 7 任务 3 在定时器里每秒打开一次)。
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
- 摄像头事件在非界面线程触发,与真实行为一致。
@@ -151,7 +155,7 @@ IOT/
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
| 层 | 用途 | 选择 |
| --- | --- | --- |
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
| 前端 | 组件库(网站外壳) | Naive UI |
@@ -179,7 +183,7 @@ IOT/
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
## 安全与服务器资源
@@ -207,15 +211,15 @@ IOT/
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
| 项目 | 教材内容 | 平台需要支持 | 状态 |
| --- | --- | --- | --- |
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 |
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
@@ -224,7 +228,7 @@ IOT/
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
老师不写代码也能修改或新增实验。
@@ -235,10 +239,10 @@ IOT/
```mermaid
flowchart TB
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、图表、虚拟时钟"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
P2 -->|"关口:一个班试用顺利"| P3
@@ -247,10 +251,14 @@ flowchart TB
并行事项:尽早办理 ICP 备案(需数周)。
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
**可能的扩展:物联网设备安装与调试课**(2026-10-28 讨论,还没定开不开)。那门课的核心是接线(传感器信号线、电源、485 的 A/B 线)、用厂家工具配置设备(ADAM 地址、波特率、4017+ 量程,ZigBee 网络号)、用串口调试助手收发报文、排故,和本课"不学协议、不接线"正好相反。仿真替代不了拧螺丝,但适合预习、在家练、接错不烧设备,尤其是老师设故障让学生找的**排故练习**(和"故障注入题库"是一回事)。现在为它做的准备是把接线做成数据(`BoardState.wiring`),本课用实验预设的接线、只读显示;将来那门课在同一份数据上让学生改接线,再加上通道接线、电源、配置工具。开工前要先拿到那门课的教材和实训台资料。
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
| 指标 | 结果 |
| --- | --- |
| ---------------------- | ---------------------------------------------------- |
| 编译(编译服务预热后) | 10–300 ms |
| 点"运行"到窗体出现 | 约 100 ms |
| 单次操作往返 | 约 15 ms |
@@ -287,28 +295,70 @@ flowchart TB
- **SqlClient 仿冒**(`runtime/Sim.Data`):SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter,DataSet、DataTable、DataView 直接用 .NET 自带的。连接字符串只按 Initial Catalog 打开学生自己的同名库(服务器名、账号密码不检查,书上的长服务器名、"." 都能连上),库不存在时报 4060。
- **T-SQL 转写**:ScriptDom(SQL Server 2008 语法)解析后按白名单逐个节点转写成 SQLite 语句,不认识的语法报"平台暂不支持",生成的 SQL 里只有平台写出的关键字和 tsql_ 函数。表用 STRICT 模式保存,原始的 T-SQL 列类型记在元数据表里,读出时还原成 SQL Server 返回的 .NET 类型(int、DateTime、bool……);varchar(n) 超长、类型不对、NULL、重复主键都报和 SQL Server 一样的错误号和中文信息。`insert into 表 values (...)` 不写列名时跳过自增列(照书上写时值的顺序和列顺序不一致,数据会存进错的列,和真实情况一样);日期统一存成 yyyy-MM-dd HH:mm:ss,书上拼接的 `DateTime.Now.ToString()`(2024/1/16 9:05:03)在插入和 BETWEEN 比较时都转换;支持 TOP(含 UPDATE / DELETE TOP)、CONVERT 样式 120 等、year / getdate / dateadd / datediff、`+` 按类型连接或相加、参数化查询、@@IDENTITY、多条语句。SqlCommandBuilder 根据带中文别名的查询生成 UPDATE。
- **DataGridView**:绑定 DataView / DataTable / DataSet,自动生成列;在单元格里修改的值写回 DataTable,配合 SqlDataAdapter.Update 保存;当前行 ▶、新增行 *、选中行、CurrentRow.Cells[0].Value;类型转换失败时和 WinForms 一样触发 DataError。**DateTimePicker**:Long / Short / Time / Custom 格式与中文 Windows 一致,弹出浏览器的日期选择框。设计器工具箱加"数据表格""日期时间选择器"。
- **数据库页面**(代替 SSMS,`/database`):对象资源管理器(库 → 表 → 列,右键新建数据库、新建表、设计、选择前 1000 行、编辑前 200 行、删除);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。
- **数据库页面**(代替 SSMS,`/database`):对象资源管理器(只有库 → 表两层,比 SSMS 简单:“+ 新建库”“+ 新建表”,点表名编辑前 200 行,⋯ 和右键是同一个菜单:查询前 1000 行、设计表结构、删除;鼠标停在表名上显示列);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。
- **评分**:每个实验可以带 `database.sql`,评分时每个检查项用一份新建的实验库,不碰学生自己的数据;评分规则加"点 / 修改表格单元格""在日期时间选择器里选日期""表格里有 / 没有某行"。新增示例项目 6 任务 2、3(任务 1 在数据库页面上完成),共 15 个。
- 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。
- 2026-10-23:项目 7 情景控制(3 个任务),共 18 个示例。
- **虚拟时钟**:实训台加时钟面板,默认真实时间,可以拨到任意日期时刻、选 1×/10×/60× 倍速、一键恢复真实时间;时钟状态由服务器保存(`BoardState.clock`),运行器按它算当前时间。编译时把学生代码里的 `DateTime.Now / Today / UtcNow` 改写成 `SimClock`(和线程 API 的改写一样不换行、编辑器里看到的是原代码)。任务 2 按 6:45 起床、7:30 上课、21:00 回寝、22:00 熄灯切换,任务 3 按预约时刻开启,都靠它来测试。评分规则加 `clock` 步骤(拨到今天的某个时刻,可设倍速)。
- **项目资源**(`Properties.Resources`):只用平台素材库(自绘的开关图片、ffmpeg 合成的门铃声,名字和教材一致,`runtime/Sim.WinForms/Resources/gallery`)。实验页"项目资源"对话框勾选素材(图片看缩略图、声音可试听),平台生成 `Properties/Resources.Designer.cs`(图片是 Bitmap、声音是 byte[],用法和 VS 的强类型资源类一样,不在标签页里显示);设计器里图片框的"图片"属性可以从项目资源里选,写进 Designer.cs 的是 `global::命名空间.Properties.Resources.名字`,预览直接显示素材。学生程序的程序目录下有 Resources 文件夹指向素材库(书上用 `BaseDirectory + "//Resources//ring.mp3"`)。没添加资源就用时给中文提示。
- **Windows Media Player**(`AxWMPLib.AxWindowsMediaPlayer`):设置 URL 后和真的一样自动播放,`Ctlcontrols.play() / stop()`;声音在浏览器里播放,实训台上加"广播"喇叭,播放时亮起。VS 为它生成的 `ComponentResourceManager(...).GetObject("…OcxState")`:编译时给每个窗体嵌一份空资源,取到 null 不报错。图片、声音数据统一按内容哈希缓存(`blob` 消息),每份只传一次。
- **TrackBar**:拖动、点轨道(LargeChange)、方向键(SmallChange),先 ValueChanged 再 Scroll,竖向时最小值在下面;设计器工具箱加"滑动条""媒体播放器"。评分规则加 `value`(滑动条的值)、`media`(播放了某个文件)检查。
- 示例:书上任务 1 手动开关灯的判断写反(参考工程是对的),任务 3 漏了填充串口列表,示例里都已改正;任务 3 的"每 5 分钟整点拍照"依赖定时器正好在 0 秒触发,自动评分不检查这一项,由老师评阅。编译检查修正:`System.AppDomain.CurrentDomain.BaseDirectory`(带命名空间)也放行。
- 2026-10-23:项目 8 智慧校园管理平台(5 个任务,每个任务一个示例,代码逐个累加),至此教材 8 个项目全部完成,共 23 个示例。
- **新建类**:实验页"+ 新建类",可以带文件夹(`SQLHelpers/SQLBLL`),文件夹成为命名空间的一段,模板和 VS 2012 一样;可以删除自己建的类。
- **停靠布局**:Dock(上、下、左、右、填充)和 WinForms 一样按 Controls 倒序依次停靠,Panel 的边框、GroupBox 的标题占的地方扣掉;窗体改大小、控件显示隐藏、加入移出时重新布局。设计器里改了停靠或大小也立即按同样的规则排好。
- **背景图片**:BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放),窗体、面板、图片框都可以设,设计器里从项目资源选。**背景色沿用父控件**:没设置背景色的标签、面板、分组框等画父控件的背景色(不是父控件的背景图片),和 WinForms 一样,所以书上要把放在背景图上的控件设成 Transparent。分组框的标题处边框断开、透出背景(书上分组框放在深色背景上、标题是白字)。文本框的 BackColor 生效。
- **无边框窗体**:FormBorderStyle = None 时没有标题栏;**运行区自动缩放**:窗体比运行区大时(项目 8 的窗体有 1043×791)整体按比例缩小,右上角显示缩放比例;"放大"让运行区占满整个网页(窗体在左、实训台在右),Esc 还原。
- **图表**(`System.Windows.Forms.DataVisualization.Charting.Chart`):ChartAreas、Legends、Series(AddXY、Points、ChartType 等)、DataSource + DataBind(DataTable / DataView / DataSet,按 XValueMember、YValueMembers 取列),浏览器用 Chart.js 画,配色是 WinForms 默认的 BrightPastel。设计器工具箱加"图表"(和 VS 一样带 ChartArea1、Legend1、Series1),属性窗口"系列和图例"打开对话框编辑系列(名称、图表类型、图例、图例文字、X / Y 值字段)和图例,按 VS 的格式和顺序写回 Designer.cs,对话框不管的属性原样保留。
- **路径的反斜杠**:书上写 `BaseDirectory + "Camera\\"`,Linux 上反斜杠会成为文件名的一部分。编译时把传给 System.IO(File、Directory、Path、各种 Stream)和 Image / Bitmap 读写文件方法的路径参数包上 `WindowsPaths.Fix`,反斜杠换成斜杠(和线程、时钟的改写一样不换行、编辑器里看到的是原代码)。
- **其他**:Paint 事件(VS 双击面板生成的空 `panel1_Paint` 能编译,平台不触发);图片框把鼠标按下、松开发给程序(书上方向键按下时转动摄像机、换成按下的图片);数据库里参数化的日期字符串比较时也转换格式(书上拼出的 `"2024/1/23 23:59:59"` 有两个空格)。
- **素材**:项目 8 用到的 20 多张图片(标题、校园背景、三个入口图标、亮灯 / 灭灯的路灯场景、面板背景、监控图标、方向盘和方向键、翻页箭头、关闭按钮)全部自绘,名字和尺寸与教材一致(`runtime/Sim.WinForms/Resources/project8.py` 生成 SVG,`build-gallery.sh` 渲染成 PNG)。
- **数据库 WisdomCampus**:平台按教材的表结构建(EnvironmentData、SecurityData、Users),预置 7 天环境数据,"数据分析"的图表一打开就有内容。学生第一次运行带预置数据库的实验时自动建好自己还没有的库(已有的库不动)。
- **评分规则**新增:`visible`(连同所在容器都可见)、`window`(某个窗体打开着 / 已关闭)、`exited`(程序已退出)、`image` / `background` 写素材名(如 `background: day_01`,按图片内容核对显示的是不是这张素材)、`points`(图表每个系列的数据点数);"点击"图片框时和浏览器一样先发鼠标按下、松开。
- **示例**:书上任务 3 第 23 步把"自动推送火情"的处理写到了 ch_autoLamp 上,参考工程里"推送LED信息""自动控制"是空方法、"自动推送火情"没有绑定事件,示例按书的意思补全;任务 5 每次查询前清空 Keys(书上没清空,重复查询时照片和时间对不上)。任务 2 只做界面,为了能看效果加上了打开、返回窗体。
**其他改进**:
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
- 2026-10-25:一个班试用前的准备。
- **全班模拟**(`server/test/classroom.ts`):45 个虚拟学生通过 HTTP / WebSocket 依次登录、打开作业、边写边检查、运行、点按钮、自己检查、提交,每个环节在 10 秒内陆续开始,统计每人等了多久。服务器限制在 2 核、用 Release 版运行时:登录、打开、检查、运行到窗体出现、点按钮都在 1.5 秒内(项目 8 第一次运行要建 WisdomCampus 库,约 5 秒)。
- **评分并发 6 → 16**:评分时 CPU 只用了两三成,慢在并发上限。45 人同时自测项目 8 任务 3,最后一人原来要等约 4 分钟,现在约 1.5 分钟;项目 3 任务 2 从约 1.5 分钟降到半分钟。并发 24 时 CPU 接近 80%,定时器来不及触发,参考答案被误判,所以停在 16(可用 `SIM_GRADE_CONCURRENCY` 调整)。提交评分队列同时评的份数跟着并发上限走,不再固定为 3 份。
- **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。
- **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。
- **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。
- **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。
- **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。
- **旧浏览器提示**:网页按 Chrome / Edge 94 以上打包(机房电脑是 Chrome 94,见 2026-10-28),更旧的浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
- **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板,随后补齐其余 21 份(23 个实验全部有说明)。
- 2026-10-26:**窗体改名**。23 个示例里 11 个的主窗体不叫 Form1(Login、frm_analog、frm_LED、frm_control、Main),学生的空白工程却只有 Form1,以前属性窗口改不了窗体名(项目 8 任务 2 的评分还要找 Main 窗体)。现在选中窗体可以改"名称":和 VS 一样,类名、构造函数、各文件里的 `new X()` 和 X 类型的变量都改(跳过字符串和注释),Designer.cs 里窗体的块头注释和 `this.Name` 也改,两个文件跟着改名;事件处理方法名(如 Form1_Load)和窗体标题不变。改名不进设计器的撤销历史。任务说明里提示先改名再双击窗体生成 Load 事件。
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
- 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。
- 2026-11-03:**导出 VS 工程**。实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。讨论后定下:机房装的是 VS 2012;代码有错不让导出(和 EXE 一样);这次不做把工程导回平台。①检查:编译服务收到 `export.kind = "project"` 的请求时(`runtime/Sim.Runner/ProjectExporter.cs`),照机房的环境编译一遍要放进 zip 的代码:**C# 5**(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库。新版 C# 的写法报 CS8026,`CompileHints` 按报错里的功能名给出改法(字符串插值改 `string.Format`、`?.` 改先判断 null、`=>` 改大括号等 12 种)。导出 EXE 仍按最新的 C# 编译,不受影响。23 个示例都是 C# 5 的写法,全部能导出。②工程照教材的样子(VS 2012 窗体应用,`.sln` + `.csproj`,ToolsVersion 4.0):平台生成的 `Properties/Resources.Designer.cs` 从素材库取图,导出时换成 VS 的 ResXFileCodeGenerator 生成的写法和 `Resources.resx`(ResXFileRef 指向 `Resources\` 下的图片);代码里按文件名用到的素材(ring.mp3)设成复制到输出目录,运行时在 `bin\Debug\Resources\`,和教材一样;每个窗体、用户控件带 `.resx`(媒体播放器的 OcxState 用教材的默认状态),`SubType`、`DependentUpon` 和 VS 一样,VS 里能打开设计器;补上 AssemblyInfo、Settings、App.config。③引用和教材工程一样只加代码直接用到的教材 dll(一般是 DeviceCtrlLibrary 和 WinFormsDeviceControlLibrary,项目 7 寝室加媒体播放器的两个 Interop),全部教材 dll 放在工程的 `lib\`,HintPath 指过去,编译时 VS 把依赖一起复制到 `bin\Debug`;只带这些引用编译不过时引用全部。书上的 `ledHelper.Close()` 用到时带上 `LedHelperCompat.cs`。④文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 的文件名用 GBK(中文 Windows 资源管理器自带的解压、老的压缩软件都按 GBK 读,UTF-8 在 Windows 7 上会乱码),另附 Info-ZIP 的 Unicode 文件名字段(0x7075),7-Zip、Linux 的 unzip 在别的语言的系统上也显示正确(`ZipWriter.cs`)。⑤23 个示例导出的工程在 mono 的 msbuild 里全部编译通过,输出目录和教材工程的 `bin\Debug` 一样,程序能启动;VS 2012 还要在机房电脑上实测。接口 `POST /api/export-project`,返回 zip,代码有错时 422,弹框说明"照机房电脑上的 VS 2012 编译"。
## 技术风险与待确认事项
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
| 风险 | 担心什么 | 应对 |
| --- | --- | --- |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
@@ -316,7 +366,7 @@ flowchart TB
**待确认**
- [ ] 串口是否要求接对:目前学生选任意 COM 口都能控制设备;是否在部分实验中要求按实训台接线(COM2 4017、COM3 4150、COM4 ZigBee、COM5 LED)
- [x] 串口要求接对:按实训台接线(COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,项目 7 的 4150 并在 COM2),选错和真机一样没反应,实训台上提示
- [ ] 虚拟实训台的画面细节(目前为第一版)
- [x] 第一个场景:智慧校园(和教材一致)
- [x] 设备写法:兼容教材类库
+5 -1
View File
@@ -1,7 +1,11 @@
{
"name": "iot-sim",
"private": true,
"workspaces": ["shared", "server", "web"],
"workspaces": [
"shared",
"server",
"web"
],
"scripts": {
"dev:server": "bun run --cwd server dev",
"dev:web": "bun run --cwd web dev",
+1
View File
@@ -44,6 +44,7 @@ internal static class Functions
c.CreateFunction<object?, object?>("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true);
c.CreateFunction<object?, object?, object?>("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true);
c.CreateFunction<object?, object?>("tsql_sqrt", x => x is null ? null : Math.Sqrt(ToDouble(x)), isDeterministic: true);
c.CreateFunction<object?, object?>("tsql_cmp", v => v is string s && Values.LooksLikeDate(s) ? Values.NormalizeDateLiteral(s) : v, isDeterministic: true);
c.CreateFunction("tsql_newid", () => Guid.NewGuid().ToString().ToUpperInvariant());
c.CreateFunction("tsql_rand", () => Random.Shared.NextDouble());
}
+10 -1
View File
@@ -802,9 +802,10 @@ internal sealed class Translator
}
}
/// <summary>DATEADD(day, ...) 的第一个参数是关键字,解析成列名</summary>
/// <summary>DATEADD(day, ...) 的第一个参数是关键字:ScriptDom 解析成 IdentifierLiteral(旧版本是列名)</summary>
private static string DatePartName(ScalarExpression expression) => expression switch
{
IdentifierLiteral i => i.Value,
ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value,
StringLiteral s => s.Value,
_ => throw SqlException.NotSupported("这种 datepart 写法"),
@@ -911,6 +912,14 @@ internal sealed class Translator
_sql.Append(Values.Quote(Values.NormalizeDateLiteral(s.Value)));
return;
}
// 参数的值执行时才知道:是日期样子的字符串时同样转成标准格式(教材 BETWEEN @time_start AND @time_end 传的是字符串)
if (expression is VariableReference)
{
_sql.Append("tsql_cmp(");
Expression(expression);
_sql.Append(')');
return;
}
Expression(expression);
}
}
+5 -2
View File
@@ -17,13 +17,16 @@ internal static class Values
};
/// <summary>形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T]\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$");
private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}((\s+|T)\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$");
/// <summary>日期和时间之间多余的空格(教材拼出的 "2024/1/23 23:59:59")</summary>
private static readonly Regex Spaces = new(@"\s+");
public static bool LooksLikeDate(string text) => DateLike.IsMatch(text.Trim());
/// <summary>按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03)</summary>
public static bool TryParseDateTime(string text, out DateTime value) =>
DateTime.TryParseExact(text.Trim(), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value);
DateTime.TryParseExact(Spaces.Replace(text.Trim(), " "), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value);
public static string FormatDateTime(DateTime value) =>
value.Millisecond == 0 && value.Ticks % TimeSpan.TicksPerMillisecond == 0
+4 -1
View File
@@ -30,7 +30,10 @@ await rm(framesDir, { recursive: true, force: true })
await mkdir(framesDir, { recursive: true })
await mkdir(work, { recursive: true })
try {
for (const [flag, svg] of [[0, scene], [1, intruder]] as const) {
for (const [flag, svg] of [
[0, scene],
[1, intruder],
] as const) {
const svgPath = join(work, `scene-${flag}.svg`)
const pngPath = join(work, `scene-${flag}.png`)
await Bun.write(svgPath, svg)
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DeviceCtrlLibrary.Model;
using Sim.Host;
@@ -7,7 +8,8 @@ namespace DeviceCtrlLibrary;
/// <summary>
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数,
/// 并且和真机版一样,设备要在它接的那个串口上打开才有反应(接线见 TrainingBoard)。
/// </summary>
public class DeviceCtrlHelper
{
@@ -20,6 +22,38 @@ public class DeviceCtrlHelper
public static IPCameraHelper ipCameraHelper = new();
}
/// <summary>
/// 告诉实训台程序在哪个串口上打开、关闭了设备,以及串口没打开时还在读写设备(网页上据此提示)。
/// 没变就不发:书上有在定时器里反复 Open 的写法;没打开时的读写每次打开之后最多报一次
/// </summary>
internal static class PortReport
{
private static readonly HashSet<string> Unopened = new();
public static string? Open(string device, string? current, string? portName)
{
lock (Unopened) Unopened.Remove(device);
if (current != portName) SimHost.SendDevice(device, "open", portName ?? "");
return portName;
}
public static string? Close(string device, string? current)
{
if (current is not null) SimHost.SendDevice(device, "close", null);
return null;
}
/// <summary>串口没打开(portName 为空)时返回 true,并报告一次</summary>
public static bool NotOpen(string device, string? portName)
{
if (!string.IsNullOrWhiteSpace(portName)) return false;
bool first;
lock (Unopened) first = Unopened.Add(device);
if (first) SimHost.SendDevice(device, "unopened", null);
return true;
}
}
public class SerialPortHelper
{
private string? _portName;
@@ -48,18 +82,21 @@ public class LEDHelper
private const string SendFailed = "Error:发送失败";
private string? _portName;
public void OpenLedCom(string portName) => _portName = portName;
public void OpenLedCom(string portName) => _portName = PortReport.Open("led", _portName, portName);
/// <summary>成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"</summary>
/// <summary>
/// 成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"。
/// 打开的串口上没接 LED 屏时和真机一样也原样返回(真机只管发出去、不等回应),只是屏上不显示
/// </summary>
public string SendLedString(string sendString)
{
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
SimHost.SendDevice("led", "text", sendString);
if (PortReport.NotOpen("led", _portName)) return SendFailed;
if (TrainingBoard.Reaches("led", _portName)) SimHost.SendDevice("led", "text", sendString);
return sendString;
}
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
public void Close() => _portName = null;
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留它让书上代码能编译</summary>
public void Close() => _portName = PortReport.Close("led", _portName);
}
public class ADAM4017Helper
@@ -70,19 +107,19 @@ public class ADAM4017Helper
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
{
_portName = PortName;
_portName = PortReport.Open("adam4017", _portName, PortName);
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
}
/// <summary>
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
/// 串口未打开时所有属性为 null,与教材仿真版一致。
/// 串口未打开时所有属性为 null,与教材仿真版一致;打开的串口上没接 4017+ 时收不到回应,和真机版一样也全是 null。
/// </summary>
public ADAM4017ResModel SetData()
{
LegacyThreads.CheckPoint();
var model = new ADAM4017ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
if (PortReport.NotOpen("adam4017", _portName) || !TrainingBoard.Reaches("adam4017", _portName)) return model;
model.temperatureValue = Read(0);
model.humidityValue = Read(1);
model.lightValue = Read(2);
@@ -95,7 +132,7 @@ public class ADAM4017Helper
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("adam4017", _portName);
}
public class ADAM4150Helper
@@ -105,16 +142,16 @@ public class ADAM4150Helper
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
{
_portName = PortName;
_portName = PortReport.Open("adam4150", _portName, PortName);
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
}
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开、或打开的串口上没接 4150(收不到回应)时全部为 false</summary>
public ADAM4150ResModel SetData()
{
LegacyThreads.CheckPoint();
var model = new ADAM4150ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
if (PortReport.NotOpen("adam4150", _portName) || !TrainingBoard.Reaches("adam4150", _portName)) return model;
model.bodyInfraredValue = Read(0);
model.fireValue = Read(1);
model.smokeValue = Read(2);
@@ -126,31 +163,35 @@ public class ADAM4150Helper
/// <summary>
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物);
/// 真机版只管发出命令、不等回应,所以打开的串口上没接 4150 时也返回 true,只是继电器不动作。
/// </summary>
public bool OnOff(int DO, bool IsOpen)
{
if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false;
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
if (DO < 0 || DO > 4 || PortReport.NotOpen("adam4150", _portName)) return false;
if (TrainingBoard.Reaches("adam4150", _portName)) SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("adam4150", _portName);
}
public class ZigBeeHelper
{
private string? _portName;
public void Open(string PortName) => _portName = PortName;
public void Open(string PortName) => _portName = PortReport.Open("zigbee", _portName, PortName);
/// <summary>控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false</summary>
/// <summary>
/// 控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false。
/// 真机版要等协调器回应,打开的串口上没接协调器时收不到回应,也返回 false
/// </summary>
public bool OnOff(int RelayNum, bool IsOpen)
{
if (RelayNum < 0 || RelayNum > 1 || string.IsNullOrWhiteSpace(_portName)) return false;
if (RelayNum < 0 || RelayNum > 1 || PortReport.NotOpen("zigbee", _portName) || !TrainingBoard.Reaches("zigbee", _portName)) return false;
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
public void Close() => _portName = PortReport.Close("zigbee", _portName);
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace DeviceCtrlLibrary;
@@ -15,6 +16,14 @@ public static class TrainingBoard
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
private static bool[] _digital = new bool[7];
// 接线:设备 → 串口(教材布局图的接法,随实验而定,见 shared/protocol.ts 的 DEFAULT_WIRING)
private static readonly Dictionary<string, string> Wiring = new()
{
["adam4017"] = "COM2",
["adam4150"] = "COM3",
["zigbee"] = "COM4",
["led"] = "COM5",
};
static TrainingBoard()
{
@@ -22,18 +31,32 @@ public static class TrainingBoard
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
}
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…]}</summary>
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…,"wiring":{…}}(clock、wiring 不带时表示不变)</summary>
public static void Update(JsonObject json)
{
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
lock (Lock)
{
if (json["analog"] is JsonArray analog)
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
if (json["digital"] is JsonArray digital)
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
if (json["wiring"] is JsonObject wiring)
foreach (var (device, port) in wiring)
if (Wiring.ContainsKey(device) && port is not null) Wiring[device] = (string)port!;
}
}
/// <summary>
/// 程序打开的串口上有没有接这台设备。和真机一样,串口打开错了不报错,只是设备收不到命令、也不回应。
/// Windows 的串口名不分大小写
/// </summary>
internal static bool Reaches(string device, string? portName)
{
if (string.IsNullOrWhiteSpace(portName)) return false;
lock (Lock) return Wiring.TryGetValue(device, out var port) && string.Equals(port, portName.Trim(), StringComparison.OrdinalIgnoreCase);
}
internal static int ReadAnalog(int channel)
{
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
+3 -1
View File
@@ -64,7 +64,9 @@ internal static class ApiPolicy
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
// var 推断出的类型在初始化表达式里已经检查过
if (name is IdentifierNameSyntax { IsVar: true }) continue;
if (name.Parent is MemberAccessExpressionSyntax m && m.Expression == name && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
// 类型名只是用来限定成员(AppDomain.CurrentDomain、System.AppDomain.CurrentDomain):检查后面的成员就够了
ExpressionSyntax qualifier = name.Parent is MemberAccessExpressionSyntax inner && inner.Name == name ? inner : name;
if (qualifier.Parent is MemberAccessExpressionSyntax m && m.Expression == qualifier && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
var symbol = model.GetSymbolInfo(name).Symbol;
if (symbol == null) continue;
+69
View File
@@ -0,0 +1,69 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 编译前把学生代码里的 DateTime.Now / Today / UtcNow 改写成虚拟时钟 Sim.Host.SimClock(实训台上可以拨时间、调倍速,
/// 教材项目 7 按 6:45 起床、22:00 熄灯等时刻切换情景模式)。只改写语义上确实是 System.DateTime 这三个属性的地方;
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
/// </summary>
internal static class ClockRewriter
{
private const string Target = "global::Sim.Host.SimClock";
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
{
var result = compilation;
foreach (var tree in compilation.SyntaxTrees)
{
var text = tree.GetText().ToString();
if (!text.Contains("Now") && !text.Contains("Today")) continue;
var rewriter = new Rewriter(compilation.GetSemanticModel(tree));
var root = rewriter.Visit(tree.GetRoot());
if (!rewriter.Changed) continue;
result = result.ReplaceSyntaxTree(tree,
CSharpSyntaxTree.ParseText(SourceText.From(root.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
}
return result;
}
private static string? Replacement(ISymbol? symbol) =>
symbol is IPropertySymbol { IsStatic: true, Name: "Now" or "Today" or "UtcNow" } property
&& property.ContainingType.SpecialType == SpecialType.System_DateTime
? $"{Target}.{property.Name}"
: null;
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
{
public bool Changed { get; private set; }
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
// DateTime.Now、System.DateTime.Now
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
{
Changed = true;
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
}
return base.VisitMemberAccessExpression(node);
}
public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node)
{
// using static System.DateTime; 之后直接写 Now
if (node.Parent is not MemberAccessExpressionSyntax access || access.Expression == node)
{
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
{
Changed = true;
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
}
}
return base.VisitIdentifierName(node);
}
}
}
+28
View File
@@ -1,3 +1,4 @@
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Sim.Runner;
@@ -17,11 +18,38 @@ internal static class CompileHints
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
$"“{name}”像是类型和名字之间少了一个空格。",
("CS0234", var s) when s.Contains("Properties") => "要用 Properties.Resources,先点代码上方的“项目资源”,添加需要的图片或声音。",
("CS0117", var s) when s.Contains("Resources.") => $"项目资源里还没有“{s[(s.LastIndexOf('.') + 1)..]}”:点代码上方的“项目资源”添加(名字要和代码里一致)。",
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
("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}。");
}
}
+40 -1
View File
@@ -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()
{
+313
View File
@@ -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));
}
}
}
""";
}
+78
View File
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 编译前把传给 System.IO(File、Directory、Path、FileStream、StreamWriter 等)和 Image / Bitmap 读写文件方法的路径参数
/// 包上 Sim.Host.WindowsPaths.Fix(...):教材按 Windows 写 AppDomain.CurrentDomain.BaseDirectory + "Camera\\",
/// 在 Linux 上反斜杠要换成斜杠。只包语义上确实是这些方法里名字带 path / file / dir 的字符串参数;
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
/// </summary>
internal static class PathRewriter
{
private const string Fix = "global::Sim.Host.WindowsPaths.Fix";
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
{
var result = compilation;
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
var targets = root.DescendantNodes()
.Where(n => n is InvocationExpressionSyntax or BaseObjectCreationExpressionSyntax)
.SelectMany(n => PathArguments(model.GetOperation(n)))
.ToHashSet();
if (targets.Count == 0) continue;
var rewritten = root.ReplaceNodes(targets, (_, node) =>
SyntaxFactory.ParseExpression($"{Fix}({node.WithoutTrivia().ToFullString()})").WithTriviaFrom(node));
result = result.ReplaceSyntaxTree(tree,
CSharpSyntaxTree.ParseText(SourceText.From(rewritten.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
}
return result;
}
/// <summary>调用里要包起来的路径参数(只取学生直接写的表达式;不含反斜杠的字符串常量不用包)</summary>
private static IEnumerable<ExpressionSyntax> PathArguments(IOperation? operation)
{
var (method, arguments) = operation switch
{
IInvocationOperation invocation => (invocation.TargetMethod, invocation.Arguments),
IObjectCreationOperation creation when creation.Constructor != null => (creation.Constructor, creation.Arguments),
_ => (null, default),
};
if (method == null || !HandlesFiles(method.ContainingType)) yield break;
foreach (var argument in arguments)
{
if (argument.Parameter is not { } parameter || !IsPathName(parameter.Name)) continue;
var values = argument.ArgumentKind == ArgumentKind.ParamArray && argument.Value is IArrayCreationOperation { Initializer: { } init }
? init.ElementValues
: [argument.Value];
foreach (var value in values)
{
if (value.IsImplicit || value.Type?.SpecialType != SpecialType.System_String) continue;
if (value.ConstantValue is { HasValue: true, Value: string constant } && !constant.Contains('\\')) continue;
if (value.Syntax is ExpressionSyntax expression) yield return expression;
}
}
}
private static bool HandlesFiles(INamedTypeSymbol type)
{
var ns = type.ContainingNamespace?.ToDisplayString();
return ns == "System.IO" || (ns == "System.Drawing" && type.Name is "Image" or "Bitmap");
}
private static bool IsPathName(string name) =>
name.Contains("path", StringComparison.OrdinalIgnoreCase)
|| name.Contains("file", StringComparison.OrdinalIgnoreCase)
|| name.Contains("dir", StringComparison.OrdinalIgnoreCase);
}
+625
View 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>
+18
View File
@@ -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" />
+44 -6
View File
@@ -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,
@@ -56,9 +54,10 @@ internal static class StudentCompiler
using var peStream = new MemoryStream();
using var pdbStream = new MemoryStream();
// 编译改写后的代码(旧版线程 API 换成平台实现),API 检查仍针对学生写的原始代码
var emit = LegacyThreadRewriter.Rewrite(compilation).Emit(peStream, pdbStream,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
// 编译改写后的代码(旧版线程 API 换成平台实现,DateTime.Now 换成虚拟时钟),API 检查仍针对学生写的原始代码
var emit = PathRewriter.Rewrite(ClockRewriter.Rewrite(LegacyThreadRewriter.Rewrite(compilation))).Emit(peStream, pdbStream,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb),
manifestResources: EmptyControlResources(compilation));
var denied = ApiPolicy.Check(compilation).ToList();
var diagnostics = new JsonArray();
@@ -71,6 +70,39 @@ internal static class StudentCompiler
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
}
private static readonly Lazy<byte[]> EmptyResources = new(() =>
{
var stream = new MemoryStream();
using (var writer = new System.Resources.ResourceWriter(stream)) writer.Generate();
return stream.ToArray();
});
/// <summary>
/// 给学生工程里的每个窗体、控件类嵌一份空的 .resources:VS 生成的 Designer.cs 会用
/// ComponentResourceManager(typeof(窗体)).GetObject("…OcxState") 读窗体的 .resx(如 Windows Media Player 控件),
/// 平台没有 .resx,有了空资源时取到 null,而不是报"找不到资源"。
/// </summary>
private static List<ResourceDescription> EmptyControlResources(CSharpCompilation compilation)
{
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
var resources = new List<ResourceDescription>();
if (control == null) return resources;
var names = new HashSet<string>();
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>())
{
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
var derives = false;
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(EmptyResources.Value), isPublic: true));
}
}
return resources;
}
internal static JsonObject ToJson(Diagnostic diagnostic)
{
var span = diagnostic.Location.GetMappedLineSpan();
@@ -89,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('\\', '/');
+7 -1
View File
@@ -28,10 +28,13 @@ internal static class StudentProgramHost
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出。
// Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是),这里改成 Windows 的写法
// Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是);长日期后面多了星期(2024年1月16日星期二),
// 中文 Windows 的长日期不带星期(日期选择框的长日期格式窄,带上星期会被截掉)。这里都改成 Windows 的写法
var zhCN = (CultureInfo)CultureInfo.GetCultureInfo("zh-CN").Clone();
zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss";
zhCN.DateTimeFormat.ShortTimePattern = "H:mm";
zhCN.DateTimeFormat.LongDatePattern = "yyyy'年'M'月'd'日'";
zhCN.DateTimeFormat.FullDateTimePattern = "yyyy'年'M'月'd'日' H:mm:ss";
zhCN = CultureInfo.ReadOnly(zhCN);
CultureInfo.DefaultThreadCurrentCulture = zhCN;
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
@@ -104,6 +107,9 @@ internal static class StudentProgramHost
baseDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "StudentApp", "bin", "Debug")).FullName;
}
baseDir = Path.TrimEndingDirectorySeparator(baseDir) + Path.DirectorySeparatorChar;
// 程序目录下的 Resources 文件夹指向素材库:教材用 BaseDirectory + "//Resources//ring.mp3" 播放铃声
var resources = Path.Combine(baseDir, "Resources");
if (!Path.Exists(resources)) Directory.CreateSymbolicLink(resources, ProjectResources.Directory);
AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDir);
Directory.SetCurrentDirectory(baseDir);
SimHost.AppBaseDirectory = baseDir;
+155
View File
@@ -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;
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace System.Drawing;
/// <summary>
/// 绘图面。平台的控件由浏览器绘制,学生程序不能自己画(没有 DrawLine 等方法);
/// 只为让 VS 生成的空 Paint 事件处理程序(panel1_Paint)能编译。
/// </summary>
public sealed class Graphics : IDisposable
{
private Graphics()
{
}
public void Dispose() => GC.SuppressFinalize(this);
}
@@ -0,0 +1,166 @@
using System;
using System.ComponentModel;
using System.Drawing;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using System.Windows.Forms;
using Sim.Host;
namespace System.Windows.Forms
{
/// <summary>
/// ActiveX 控件的基类。VS 把 COM 控件拖到窗体上时生成 OcxState = resources.GetObject("…OcxState"),
/// 平台编译时给每个窗体嵌了空的资源,这里取到的是 null,不影响使用。
/// </summary>
public abstract class AxHost : Control, ISupportInitialize
{
protected AxHost(string clsid) { }
public State? OcxState { get; set; }
public void BeginInit() { }
public void EndInit() { }
public class State { }
}
}
namespace WMPLib
{
public enum WMPPlayState
{
wmppsUndefined = 0, wmppsStopped = 1, wmppsPaused = 2, wmppsPlaying = 3, wmppsScanForward = 4, wmppsScanReverse = 5,
wmppsBuffering = 6, wmppsWaiting = 7, wmppsMediaEnded = 8, wmppsTransitioning = 9, wmppsReady = 10, wmppsReconnecting = 11,
}
/// <summary>播放控制:axWindowsMediaPlayer1.Ctlcontrols.play()</summary>
public interface IWMPControls
{
void play();
void pause();
void stop();
double currentPosition { get; set; }
}
/// <summary>播放设置:axWindowsMediaPlayer1.settings.volume</summary>
public interface IWMPSettings
{
bool autoStart { get; set; }
int volume { get; set; }
bool mute { get; set; }
void setMode(string bstrMode, bool varfMode);
}
}
namespace AxWMPLib
{
using WMPLib;
/// <summary>
/// Windows Media Player 控件(教材项目 7 的"广播"播放 ring.mp3)。和真的一样:设置 URL 后自动开始播放(settings.autoStart),
/// 也可以调用 Ctlcontrols.play()。声音在浏览器里播放;实训台上的广播喇叭随之亮起。只支持本机文件。
/// </summary>
[ClientType("AxWindowsMediaPlayer")]
public class AxWindowsMediaPlayer : AxHost
{
private string _url = "";
private WMPPlayState _playState = WMPPlayState.wmppsUndefined;
public AxWindowsMediaPlayer() : base("6bf52a52-394a-11d3-b153-00c04f79faa6")
{
Ctlcontrols = new PlayerControls(this);
settings = new Settings();
}
protected override Size DefaultSize => new(245, 240);
public string URL
{
get => _url;
set
{
_url = value ?? "";
if (settings.autoStart && _url.Length > 0) Play();
}
}
public IWMPControls Ctlcontrols { get; }
// 属性名和 COM 接口一样是小写
public IWMPSettings settings { get; }
public WMPPlayState playState => _playState;
public string uiMode { get; set; } = "full";
public bool stretchToFit { get; set; }
public bool enableContextMenu { get; set; } = true;
public string status => _playState == WMPPlayState.wmppsPlaying ? "正在播放" : "已停止";
public event EventHandler? PlayStateChange;
private void Play()
{
LegacyThreads.CheckPoint();
// 教材写 BaseDirectory + "//Resources//ring.mp3":路径里多余的斜杠在 Linux 上也能用
var path = _url.StartsWith("file:///", StringComparison.OrdinalIgnoreCase) ? new Uri(_url).LocalPath : _url;
if (!File.Exists(path))
{
SimHost.SendConsole($"Windows Media Player:找不到要播放的文件 {_url}\n");
SetState(WMPPlayState.wmppsStopped);
return;
}
var data = File.ReadAllBytes(path);
var mime = Path.GetExtension(path).ToLowerInvariant() switch
{
".wav" => "audio/wav",
".ogg" => "audio/ogg",
".m4a" or ".aac" => "audio/aac",
_ => "audio/mpeg",
};
var key = SimHost.DataKey(Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant(), data, mime);
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "play", ["key"] = key, ["name"] = Path.GetFileName(path) });
SetState(WMPPlayState.wmppsPlaying);
}
private void Stop()
{
if (_playState != WMPPlayState.wmppsPlaying) return;
SimHost.Send(new JsonObject { ["type"] = "media", ["id"] = SimId, ["action"] = "stop" });
SetState(WMPPlayState.wmppsStopped);
}
private void SetState(WMPPlayState state)
{
if (_playState == state) return;
_playState = state;
PlayStateChange?.Invoke(this, EventArgs.Empty);
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["URL"] = _url;
}
private sealed class PlayerControls(AxWindowsMediaPlayer owner) : IWMPControls
{
public void play()
{
if (owner._playState != WMPPlayState.wmppsPlaying && owner._url.Length > 0) owner.Play();
}
public void pause() => owner.Stop();
public void stop() => owner.Stop();
public double currentPosition { get; set; }
}
private sealed class Settings : IWMPSettings
{
public bool autoStart { get; set; } = true;
public int volume { get; set; } = 50;
public bool mute { get; set; }
public void setMode(string bstrMode, bool varfMode) { }
}
}
}
+481
View File
@@ -0,0 +1,481 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using Sim.Host;
namespace System.Windows.Forms.DataVisualization.Charting;
public enum SeriesChartType
{
Point, FastPoint, Bubble, Line, Spline, StepLine, FastLine, Bar, StackedBar, StackedBar100, Column, StackedColumn, StackedColumn100,
Area, SplineArea, StackedArea, StackedArea100, Pie, Doughnut, Stock, Candlestick, Range, SplineRange, RangeBar, RangeColumn,
Radar, Polar, ErrorBar, BoxPlot, Renko, ThreeLineBreak, Kagi, PointAndFigure, Funnel, Pyramid,
}
public enum ChartValueType { Auto, Double, Single, Int32, Int64, UInt32, UInt64, String, DateTime, Date, Time, DateTimeOffset }
public enum Docking { Top, Right, Bottom, Left }
public enum MarkerStyle { None, Square, Circle, Diamond, Triangle, Cross, Star4, Star5, Star6, Star10 }
public enum ChartColorPalette { None, Bright, Grayscale, Excel, Light, Pastel, EarthTones, SemiTransparent, Berry, Chocolate, Fire, SeaGreen, BrightPastel }
public enum StringAlignment { Near, Center, Far }
/// <summary>图表里的元素(ChartArea、Legend、Series、Title):有名字,改动时通知所在的图表重画</summary>
public abstract class ChartNamedElement
{
private string _name = "";
internal Chart? Owner { get; set; }
public string Name
{
get => _name;
set
{
_name = value ?? "";
Changed();
}
}
public object? Tag { get; set; }
internal void Changed() => Owner?.Invalidated();
}
/// <summary>ChartAreas、Legends、Series、Titles 共用:按序号或名字取,Add 一个对象或一个名字</summary>
public class ChartNamedElementCollection<T> : IEnumerable<T> where T : ChartNamedElement, new()
{
private readonly List<T> _items = new();
private readonly Chart _owner;
private readonly string _prefix;
internal ChartNamedElementCollection(Chart owner, string prefix)
{
_owner = owner;
_prefix = prefix;
}
public int Count => _items.Count;
public T this[int index] => _items[index];
public T this[string name] =>
FindByName(name) ?? throw new ArgumentException($"在集合中找不到名为“{name}”的图表元素。");
public T? FindByName(string name) => _items.FirstOrDefault(i => i.Name == name);
public void Add(T item)
{
if (string.IsNullOrEmpty(item.Name)) item.Name = NextName();
item.Owner = _owner;
_items.Add(item);
_owner.Invalidated();
}
public T Add(string name)
{
var item = new T { Name = name };
Add(item);
return item;
}
public int IndexOf(string name) => _items.FindIndex(i => i.Name == name);
public bool Remove(T item)
{
var removed = _items.Remove(item);
_owner.Invalidated();
return removed;
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_owner.Invalidated();
}
public void Clear()
{
_items.Clear();
_owner.Invalidated();
}
private string NextName()
{
for (var i = 1; ; i++)
if (FindByName($"{_prefix}{i}") == null) return $"{_prefix}{i}";
}
public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class Axis
{
public string Title { get; set; } = "";
public double Minimum { get; set; } = double.NaN;
public double Maximum { get; set; } = double.NaN;
public double Interval { get; set; }
public Grid MajorGrid { get; } = new();
public LabelStyle LabelStyle { get; } = new();
public bool IsStartedFromZero { get; set; } = true;
}
public class Grid
{
public bool Enabled { get; set; } = true;
public Color LineColor { get; set; } = Color.Black;
}
public class LabelStyle
{
public string Format { get; set; } = "";
public int Angle { get; set; }
}
public class ChartArea : ChartNamedElement
{
public ChartArea() { }
public ChartArea(string name) => Name = name;
public Axis AxisX { get; } = new();
public Axis AxisY { get; } = new();
public Axis AxisX2 { get; } = new();
public Axis AxisY2 { get; } = new();
public Color BackColor { get; set; }
}
public class Legend : ChartNamedElement
{
public Legend() { }
public Legend(string name) => Name = name;
public bool Enabled { get; set; } = true;
public Docking Docking { get; set; } = Docking.Right;
public string Title { get; set; } = "";
public Color TitleForeColor { get; set; } = Color.Black;
public Color BackColor { get; set; }
public Color ForeColor { get; set; } = Color.Black;
public StringAlignment Alignment { get; set; }
}
public class Title : ChartNamedElement
{
private string _text = "";
public Title() { }
public Title(string text) => _text = text;
public string Text
{
get => _text;
set
{
_text = value ?? "";
Changed();
}
}
public Font? Font { get; set; }
public Color ForeColor { get; set; } = Color.Black;
public Docking Docking { get; set; } = Docking.Top;
}
public class DataPoint
{
public DataPoint() { }
public DataPoint(double xValue, double yValue)
{
XValue = xValue;
YValues = new[] { yValue };
}
public double XValue { get; set; }
public double[] YValues { get; set; } = { 0 };
/// <summary>X 值是文字(如日期字符串)时显示的标签</summary>
public string AxisLabel { get; set; } = "";
public string Label { get; set; } = "";
public Color Color { get; set; }
public bool IsEmpty { get; set; }
public string ToolTip { get; set; } = "";
}
public class DataPointCollection : IEnumerable<DataPoint>
{
private readonly List<DataPoint> _items = new();
private readonly Series _series;
internal DataPointCollection(Series series) => _series = series;
public int Count => _items.Count;
public DataPoint this[int index] => _items[index];
/// <summary>加一个点:X 是数字或日期时按数值,否则(如字符串)当作分类标签,和 WinForms 一样</summary>
public int AddXY(object? xValue, params object?[] yValue)
{
var point = new DataPoint { YValues = yValue.Select(ToDouble).ToArray() };
switch (xValue)
{
case null or DBNull:
break;
case DateTime d:
point.XValue = d.ToOADate();
point.AxisLabel = d.ToString(CultureInfo.CurrentCulture);
break;
case IConvertible c when xValue is not string:
point.XValue = c.ToDouble(CultureInfo.InvariantCulture);
break;
default:
point.AxisLabel = Convert.ToString(xValue, CultureInfo.CurrentCulture) ?? "";
point.XValue = _items.Count + 1;
break;
}
_items.Add(point);
_series.Changed();
return _items.Count - 1;
}
public int AddY(params object?[] yValue) => AddXY(_items.Count + 1, yValue);
public void Add(DataPoint point)
{
_items.Add(point);
_series.Changed();
}
public void Clear()
{
_items.Clear();
_series.Changed();
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_series.Changed();
}
/// <summary>按两组值绑定:xValues 和 yValues 一一对应</summary>
public void DataBindXY(IEnumerable xValues, params IEnumerable[] yValues)
{
_items.Clear();
var xs = xValues.Cast<object?>().ToList();
var ys = yValues.Select(y => y.Cast<object?>().ToList()).ToList();
for (var i = 0; i < xs.Count; i++) AddXY(xs[i], ys.Select(y => i < y.Count ? y[i] : null).ToArray());
_series.Changed();
}
public void DataBindY(params IEnumerable[] yValues) => DataBindXY(Enumerable.Range(1, yValues[0].Cast<object>().Count()).ToList(), yValues);
internal static double ToDouble(object? value) => value switch
{
null or DBNull => double.NaN,
DateTime d => d.ToOADate(),
IConvertible c => double.TryParse(Convert.ToString(c, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : double.NaN,
_ => double.NaN,
};
public IEnumerator<DataPoint> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class Series : ChartNamedElement
{
private SeriesChartType _chartType = SeriesChartType.Column;
private string _legendText = "";
private Color _color;
public Series()
{
Points = new DataPointCollection(this);
}
public Series(string name) : this() => Name = name;
public DataPointCollection Points { get; }
public SeriesChartType ChartType
{
get => _chartType;
set
{
_chartType = value;
Changed();
}
}
public string ChartArea { get; set; } = "";
public string Legend { get; set; } = "";
public string LegendText
{
get => _legendText;
set
{
_legendText = value ?? "";
Changed();
}
}
public string XValueMember { get; set; } = "";
public string YValueMembers { get; set; } = "";
public ChartValueType XValueType { get; set; }
public ChartValueType YValueType { get; set; }
public int YValuesPerPoint { get; set; } = 1;
public bool IsVisibleInLegend { get; set; } = true;
public bool IsValueShownAsLabel { get; set; }
public bool Enabled { get; set; } = true;
public int BorderWidth { get; set; } = 1;
public MarkerStyle MarkerStyle { get; set; }
public int MarkerSize { get; set; } = 5;
public Font? Font { get; set; }
public string Label { get; set; } = "";
public string ToolTip { get; set; } = "";
public Color Color
{
get => _color;
set
{
_color = value;
Changed();
}
}
}
/// <summary>
/// 图表(教材项目 8 显示最近 7 天的最高温和平均温)。和 WinForms 的 Chart 一样,设置 DataSource 后调用 DataBind()
/// 按各 Series 的 XValueMember / YValueMembers 取数据,也可以用 Points.AddXY 自己加点。浏览器用 Chart.js 画。
/// </summary>
public class Chart : Control, ISupportInitialize
{
private bool _initializing;
private bool _dirty;
public Chart()
{
ChartAreas = new ChartNamedElementCollection<ChartArea>(this, "ChartArea");
Legends = new ChartNamedElementCollection<Legend>(this, "Legend");
Series = new ChartNamedElementCollection<Series>(this, "Series");
Titles = new ChartNamedElementCollection<Title>(this, "Title");
BackColor = Color.White;
}
protected override Size DefaultSize => new(300, 300);
public ChartNamedElementCollection<ChartArea> ChartAreas { get; }
public ChartNamedElementCollection<Legend> Legends { get; }
public ChartNamedElementCollection<Series> Series { get; }
public ChartNamedElementCollection<Title> Titles { get; }
public object? DataSource { get; set; }
public ChartColorPalette Palette { get; set; } = ChartColorPalette.BrightPastel;
public Color BorderlineColor { get; set; }
public int BorderlineWidth { get; set; } = 1;
public void BeginInit() => _initializing = true;
public void EndInit()
{
_initializing = false;
Invalidated();
}
/// <summary>按 DataSource 重新取数据:每个设置了 YValueMembers 的 Series 清空后,按行加点</summary>
public void DataBind()
{
var rows = Rows(DataSource);
foreach (var series in Series)
{
if (string.IsNullOrEmpty(series.YValueMembers)) continue;
series.Points.Clear();
var yMembers = series.YValueMembers.Split(',').Select(m => m.Trim()).ToArray();
foreach (var row in rows)
{
var x = string.IsNullOrEmpty(series.XValueMember) ? null : Field(row, series.XValueMember);
series.Points.AddXY(x ?? series.Points.Count + 1, yMembers.Select(m => Field(row, m)).ToArray());
}
}
Invalidated();
}
private static List<object> Rows(object? source) => source switch
{
null => new List<object>(),
DataSet set => set.Tables.Count > 0 ? set.Tables[0].Rows.Cast<object>().ToList() : new List<object>(),
DataTable table => table.Rows.Cast<object>().ToList(),
DataView view => view.Cast<object>().ToList(),
IEnumerable list => list.Cast<object>().ToList(),
_ => throw new ArgumentException("图表的 DataSource 要是 DataTable、DataView、DataSet 或列表。"),
};
private static object? Field(object row, string member)
{
try
{
return row switch
{
DataRow r => r[member],
DataRowView v => v[member],
_ => row.GetType().GetProperty(member)?.GetValue(row),
};
}
catch (ArgumentException)
{
throw new ArgumentException($"数据源里没有名为“{member}”的列(检查 XValueMember / YValueMembers)。");
}
}
/// <summary>图表内容变了:攒到这一轮消息处理结束时再发给浏览器</summary>
internal void Invalidated()
{
if (_initializing || !IsAttached || _dirty) return;
_dirty = true;
SimHost.Ui.Post(() =>
{
_dirty = false;
NotifyChanged("Chart", ChartJson());
});
}
private JsonObject ChartJson()
{
var series = new JsonArray();
foreach (var s in Series)
{
if (!s.Enabled) continue;
var points = new JsonArray();
foreach (var p in s.Points)
points.Add(new JsonObject { ["x"] = p.AxisLabel.Length > 0 ? p.AxisLabel : p.XValue, ["y"] = p.YValues.Length > 0 && !double.IsNaN(p.YValues[0]) ? p.YValues[0] : null });
series.Add(new JsonObject
{
["name"] = s.Name,
["type"] = s.ChartType.ToString(),
["legend"] = s.LegendText.Length > 0 ? s.LegendText : s.Name,
["inLegend"] = s.IsVisibleInLegend && Legends.Count > 0 && Legends.Any(l => l.Enabled),
["color"] = PropValue.ColorCss(s.Color),
["width"] = s.BorderWidth,
["labels"] = s.IsValueShownAsLabel,
["points"] = points,
});
}
var titles = new JsonArray();
foreach (var t in Titles) titles.Add(t.Text);
var area = ChartAreas.Count > 0 ? ChartAreas[0] : null;
return new JsonObject
{
["series"] = series,
["titles"] = titles,
["legendDocking"] = Legends.Count > 0 ? Legends[0].Docking.ToString() : "Right",
["yMin"] = area != null && !double.IsNaN(area.AxisY.Minimum) ? area.AxisY.Minimum : null,
["yMax"] = area != null && !double.IsNaN(area.AxisY.Maximum) ? area.AxisY.Maximum : null,
};
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["Chart"] = ChartJson();
}
}
+25 -1
View File
@@ -43,7 +43,21 @@ public class Panel : ScrollableControl
public BorderStyle BorderStyle
{
get => _borderStyle;
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
set
{
if (SetProp(ref _borderStyle, value, nameof(BorderStyle))) PerformLayout();
}
}
/// <summary>边框占掉的宽度:单线 1 像素,立体 2 像素</summary>
public override Rectangle DisplayRectangle
{
get
{
var inset = _borderStyle switch { BorderStyle.FixedSingle => 1, BorderStyle.Fixed3D => 2, _ => 0 };
var rect = base.DisplayRectangle;
return new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - 2 * inset), Math.Max(0, rect.Height - 2 * inset));
}
}
protected override void WriteProps(JsonObject props)
@@ -58,4 +72,14 @@ public class GroupBox : Control
protected override Size DefaultSize => new(200, 100);
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
/// <summary>分组框的边框和标题占掉的地方(宋体 9pt 时和 WinForms 一致)</summary>
public override Rectangle DisplayRectangle
{
get
{
var rect = base.DisplayRectangle;
return new Rectangle(rect.X + 3, rect.Y + 17, Math.Max(0, rect.Width - 6), Math.Max(0, rect.Height - 20));
}
}
}
+146 -15
View File
@@ -11,7 +11,7 @@ namespace System.Windows.Forms;
/// <summary>
/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值,只把显式设置的值发给浏览器。
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值(文本框等的颜色除外,见 OwnBackColor),只把显式设置的值发给浏览器。
/// </summary>
public class Control : Component, IWin32Window
{
@@ -28,6 +28,10 @@ public class Control : Component, IWin32Window
private Font? _font;
private bool _autoSize;
private DockStyle _dock;
private Padding _padding;
private int _layoutSuspendCount;
private Image? _backgroundImage;
private ImageLayout _backgroundImageLayout = ImageLayout.Tile;
private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left;
private Control? _parent;
@@ -131,7 +135,13 @@ public class Control : Component, IWin32Window
public Size Size
{
get => _size;
set => SetProp(ref _size, value, nameof(Size));
set
{
if (!SetProp(ref _size, value, nameof(Size))) return;
// 大小变了:自己的停靠子控件重新排;自己是停靠的,父容器也要重新排
PerformLayout();
if (_dock != DockStyle.None) _parent?.PerformLayout();
}
}
public int Left { get => _location.X; set => Location = new Point(value, _location.Y); }
@@ -162,28 +172,48 @@ public class Control : Component, IWin32Window
get => _visible;
set
{
if (SetProp(ref _visible, value, nameof(Visible))) OnVisibleChanged(EventArgs.Empty);
if (!SetProp(ref _visible, value, nameof(Visible))) return;
if (_dock != DockStyle.None) _parent?.PerformLayout();
OnVisibleChanged(EventArgs.Empty);
}
}
/// <summary>和 WinForms 一样:父控件(分组框、面板)禁用时子控件也是禁用的,读出 false、不响应操作;浏览器按同样的规则画成灰色</summary>
public bool Enabled
{
get => _enabled;
get => _enabled && (_parent?.Enabled ?? true);
set
{
if (SetProp(ref _enabled, value, nameof(Enabled))) OnEnabledChanged(EventArgs.Empty);
var old = Enabled;
SetProp(ref _enabled, value, nameof(Enabled));
if (Enabled != old) OnEnabledChanged(EventArgs.Empty);
}
}
/// <summary>
/// 没有显式设置时控件自己的颜色;null 表示沿用父控件。文本框、列表框、下拉框、日期选择是白底黑字(SystemColors.Window / WindowText),
/// 不沿用父控件:分组框设成白字时,里面的文本框还是黑字。
/// </summary>
private protected virtual Color? OwnBackColor => null;
private protected virtual Color? OwnForeColor => null;
/// <summary>能不能把 BackColor 设成透明(含半透明):窗体、文本框、列表框、下拉框、日期选择、数据表格不行,和 WinForms 一样报错</summary>
private protected virtual bool SupportsTransparentBackColor => true;
public virtual Color BackColor
{
get => !_backColor.IsEmpty ? _backColor : _parent?.BackColor ?? DefaultBackColor;
set => SetProp(ref _backColor, value, nameof(BackColor));
get => !_backColor.IsEmpty ? _backColor : OwnBackColor ?? _parent?.BackColor ?? DefaultBackColor;
set
{
if (!value.IsEmpty && value.A < 255 && !SupportsTransparentBackColor) throw new ArgumentException("控件不支持透明的背景色。");
SetProp(ref _backColor, value, nameof(BackColor));
}
}
public virtual Color ForeColor
{
get => !_foreColor.IsEmpty ? _foreColor : _parent?.ForeColor ?? DefaultForeColor;
get => !_foreColor.IsEmpty ? _foreColor : OwnForeColor ?? _parent?.ForeColor ?? DefaultForeColor;
set => SetProp(ref _foreColor, value, nameof(ForeColor));
}
@@ -202,7 +232,40 @@ public class Control : Component, IWin32Window
public DockStyle Dock
{
get => _dock;
set => SetProp(ref _dock, value, nameof(Dock));
set
{
if (SetProp(ref _dock, value, nameof(Dock))) _parent?.PerformLayout();
}
}
public Padding Padding
{
get => _padding;
set
{
if (_padding.Equals(value)) return;
_padding = value;
PerformLayout();
}
}
/// <summary>背景图片;BackgroundImageLayout 决定平铺(默认)、居中、拉伸或按比例缩放</summary>
public virtual Image? BackgroundImage
{
get => _backgroundImage;
set
{
LegacyThreads.CheckPoint();
if (ReferenceEquals(_backgroundImage, value)) return;
_backgroundImage = value;
NotifyChanged(nameof(BackgroundImage), value);
}
}
public virtual ImageLayout BackgroundImageLayout
{
get => _backgroundImageLayout;
set => SetProp(ref _backgroundImageLayout, value, nameof(BackgroundImageLayout));
}
public AnchorStyles Anchor
@@ -216,7 +279,6 @@ public class Control : Component, IWin32Window
public bool TabStop { get; set; } = true;
public object? Tag { get; set; }
public Padding Margin { get; set; } = new(3);
public Padding Padding { get; set; }
public ImeMode ImeMode { get; set; } = ImeMode.NoControl;
public virtual RightToLeft RightToLeft { get; set; } = RightToLeft.No;
public virtual bool AllowDrop { get; set; }
@@ -246,6 +308,8 @@ public class Control : Component, IWin32Window
public event EventHandler? LostFocus;
public event EventHandler? Enter;
public event EventHandler? Leave;
/// <summary>重绘。平台由浏览器绘制控件,不会触发这个事件(VS 双击 Panel 生成的空处理程序照常能编译)</summary>
public event PaintEventHandler? Paint;
protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e);
protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e);
@@ -256,9 +320,17 @@ public class Control : Component, IWin32Window
protected virtual void OnMouseLeave(EventArgs e) => MouseLeave?.Invoke(this, e);
protected virtual void OnTextChanged(EventArgs e) => TextChanged?.Invoke(this, e);
protected virtual void OnVisibleChanged(EventArgs e) => VisibleChanged?.Invoke(this, e);
protected virtual void OnEnabledChanged(EventArgs e) => EnabledChanged?.Invoke(this, e);
protected virtual void OnEnabledChanged(EventArgs e)
{
EnabledChanged?.Invoke(this, e);
// 自己没被禁用的子控件实际状态也跟着变,同样触发 EnabledChanged(WinForms 的 OnParentEnabledChanged)
for (var i = 0; i < Controls.Count; i++)
if (Controls[i]._enabled) Controls[i].OnEnabledChanged(e);
}
protected virtual void OnGotFocus(EventArgs e) => GotFocus?.Invoke(this, e);
protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e);
protected virtual void OnPaint(PaintEventArgs e) => Paint?.Invoke(this, e);
protected virtual void OnEnter(EventArgs e) => Enter?.Invoke(this, e);
protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e);
@@ -305,10 +377,65 @@ public class Control : Component, IWin32Window
public void Refresh() { }
public void Invalidate() { }
public void Update() { }
public void SuspendLayout() { }
public void ResumeLayout() { }
public void ResumeLayout(bool performLayout) { }
public void PerformLayout() { }
public void SuspendLayout() => _layoutSuspendCount++;
public void ResumeLayout() => ResumeLayout(true);
public void ResumeLayout(bool performLayout)
{
if (_layoutSuspendCount > 0) _layoutSuspendCount--;
if (performLayout) PerformLayout();
}
/// <summary>
/// 停靠布局(和 WinForms 一样):按 Controls 集合从后往前,Top / Bottom / Left / Right 依次贴边并占掉那一条,
/// Fill 占剩下的全部。VS 在 Designer.cs 里写的位置大小就是这样排出来的;代码里改停靠、大小、可见时重新排。
/// </summary>
public void PerformLayout()
{
if (_layoutSuspendCount > 0) return;
var rect = DisplayRectangle;
for (var i = Controls.Count - 1; i >= 0; i--)
{
var child = Controls[i];
if (!child._visible || child._dock == DockStyle.None) continue;
var size = child._size;
Rectangle bounds;
switch (child._dock)
{
case DockStyle.Top:
bounds = new Rectangle(rect.X, rect.Y, rect.Width, size.Height);
rect = new Rectangle(rect.X, rect.Y + size.Height, rect.Width, Math.Max(0, rect.Height - size.Height));
break;
case DockStyle.Bottom:
bounds = new Rectangle(rect.X, rect.Bottom - size.Height, rect.Width, size.Height);
rect = new Rectangle(rect.X, rect.Y, rect.Width, Math.Max(0, rect.Height - size.Height));
break;
case DockStyle.Left:
bounds = new Rectangle(rect.X, rect.Y, size.Width, rect.Height);
rect = new Rectangle(rect.X + size.Width, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
break;
case DockStyle.Right:
bounds = new Rectangle(rect.Right - size.Width, rect.Y, size.Width, rect.Height);
rect = new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
break;
default:
bounds = rect;
break;
}
child.SetBoundsFromLayout(bounds);
}
}
/// <summary>停靠布局给出的位置大小:不再反过来触发父容器的布局</summary>
private void SetBoundsFromLayout(Rectangle bounds)
{
SetProp(ref _location, bounds.Location, nameof(Location));
if (SetProp(ref _size, bounds.Size, nameof(Size))) PerformLayout();
}
/// <summary>容器里用来停靠子控件的区域:客户区去掉内边距(分组框、带边框的面板再去掉边框)</summary>
public virtual Rectangle DisplayRectangle =>
new(_padding.Left, _padding.Top, Math.Max(0, ClientSize.Width - _padding.Horizontal), Math.Max(0, ClientSize.Height - _padding.Vertical));
public bool InvokeRequired => !SimHost.Ui.IsUiThread;
@@ -409,6 +536,8 @@ public class Control : Component, IWin32Window
props["Font"] = PropValue.From(_font);
props["AutoSize"] = _autoSize;
props["Dock"] = _dock.ToString();
props["BackgroundImage"] = PropValue.From(_backgroundImage);
props["BackgroundImageLayout"] = _backgroundImageLayout.ToString();
}
/// <summary>浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。</summary>
@@ -459,6 +588,7 @@ public class Control : Component, IWin32Window
value.Parent?.Controls.Remove(value);
_items.Add(value);
value.SetParentInternal(Owner);
if (value.Dock != DockStyle.None) Owner.PerformLayout();
if (Owner.IsAttached)
{
SimHost.Send(new JsonObject
@@ -481,6 +611,7 @@ public class Control : Component, IWin32Window
if (value == null || !_items.Remove(value)) return;
var wasAttached = Owner.IsAttached;
value.SetParentInternal(null);
if (value.Dock != DockStyle.None) Owner.PerformLayout();
if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId });
}
@@ -468,6 +468,8 @@ public class DataGridView : Control, ISupportInitialize
private bool _initializing;
private bool _rowsDirty;
private protected override bool SupportsTransparentBackColor => false;
public DataGridView()
{
Columns = new DataGridViewColumnCollection(this);
@@ -626,8 +628,10 @@ public class DataGridView : Control, ISupportInitialize
public event DataGridViewCellEventHandler? CellContentDoubleClick;
public event DataGridViewCellEventHandler? CellValueChanged;
public event DataGridViewCellEventHandler? CellEndEdit;
#pragma warning disable CS0067 // 只读表格没有当前单元格的概念,这两个事件不触发
public event DataGridViewCellEventHandler? CellEnter;
public event DataGridViewCellEventHandler? RowEnter;
#pragma warning restore CS0067
public event DataGridViewDataErrorEventHandler? DataError;
public event EventHandler? SelectionChanged;
public event EventHandler? CurrentCellChanged;
+5 -1
View File
@@ -13,7 +13,7 @@ public class DateTimePicker : Control
public static readonly DateTime MinimumDateTime = new(1753, 1, 1);
public static readonly DateTime MaximumDateTime = new(9998, 12, 31);
private DateTime _value = DateTime.Now;
private DateTime _value = SimClock.Now;
private DateTimePickerFormat _format = DateTimePickerFormat.Long;
private string? _customFormat;
private bool _showUpDown;
@@ -22,6 +22,10 @@ public class DateTimePicker : Control
protected override Size DefaultSize => new(200, 21);
private protected override Color? OwnBackColor => SystemColors.Window;
private protected override Color? OwnForeColor => SystemColors.WindowText;
private protected override bool SupportsTransparentBackColor => false;
public DateTime Value
{
get => _value;
+2
View File
@@ -40,6 +40,8 @@ public enum ComboBoxStyle { Simple, DropDown, DropDownList }
public enum DockStyle { None, Top, Bottom, Left, Right, Fill }
public enum ImageLayout { None, Tile, Center, Stretch, Zoom }
[Flags]
public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 }
+17
View File
@@ -41,3 +41,20 @@ public class FormClosedEventArgs : EventArgs
}
public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e);
/// <summary>重绘事件的参数。平台由浏览器绘制控件,不支持在 Graphics 上自己画</summary>
public class PaintEventArgs : EventArgs, IDisposable
{
public PaintEventArgs(System.Drawing.Graphics graphics, System.Drawing.Rectangle clipRect)
{
Graphics = graphics;
ClipRectangle = clipRect;
}
public System.Drawing.Graphics Graphics { get; }
public System.Drawing.Rectangle ClipRectangle { get; }
public void Dispose() => GC.SuppressFinalize(this);
}
public delegate void PaintEventHandler(object? sender, PaintEventArgs e);
+8 -3
View File
@@ -23,6 +23,8 @@ public class Form : ContainerControl
private bool _modal;
private DialogResult _dialogResult;
private protected override bool SupportsTransparentBackColor => false;
public Form()
{
base.Visible = false;
@@ -33,13 +35,16 @@ public class Form : ContainerControl
public override Size ClientSize
{
get => _clientSize;
set => SetProp(ref _clientSize, value, nameof(ClientSize));
set
{
if (SetProp(ref _clientSize, value, nameof(ClientSize))) PerformLayout();
}
}
public new Size Size
{
get => _clientSize + NonClientSize;
set => ClientSize = value - NonClientSize;
get => _formBorderStyle == FormBorderStyle.None ? _clientSize : _clientSize + NonClientSize;
set => ClientSize = _formBorderStyle == FormBorderStyle.None ? value : value - NonClientSize;
}
public FormBorderStyle FormBorderStyle
@@ -1,5 +1,6 @@
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace System.Windows.Forms;
@@ -10,6 +11,10 @@ public abstract class ListControl : Control
/// <summary>选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged)</summary>
private protected int SelectedIndexValue = -1;
private protected override Color? OwnBackColor => SystemColors.Window;
private protected override Color? OwnForeColor => SystemColors.WindowText;
private protected override bool SupportsTransparentBackColor => false;
public abstract int SelectedIndex { get; set; }
public string DisplayMember { get; set; } = "";
+4
View File
@@ -10,6 +10,10 @@ public abstract class TextBoxBase : Control
private bool _readOnly;
private BorderStyle _borderStyle = BorderStyle.Fixed3D;
private protected override Color? OwnBackColor => _readOnly ? SystemColors.Control : SystemColors.Window;
private protected override Color? OwnForeColor => SystemColors.WindowText;
private protected override bool SupportsTransparentBackColor => false;
public bool Multiline
{
get => _multiline;
+123
View File
@@ -0,0 +1,123 @@
using System.ComponentModel;
using System.Drawing;
using System.Text.Json.Nodes;
using Sim.Host;
namespace System.Windows.Forms;
public enum TickStyle { None, TopLeft, BottomRight, Both }
public enum Orientation { Horizontal, Vertical }
/// <summary>滑动条(教材项目 7 模拟空调温度、窗帘开合)</summary>
public class TrackBar : Control, ISupportInitialize
{
private int _minimum;
private int _maximum = 10;
private int _value;
private int _tickFrequency = 1;
private TickStyle _tickStyle = TickStyle.BottomRight;
private Orientation _orientation = Orientation.Horizontal;
private bool _initializing;
public TrackBar() => AutoSize = true;
protected override Size DefaultSize => new(104, 45);
public int Minimum
{
get => _minimum;
set => SetRange(value, Math.Max(value, _maximum));
}
public int Maximum
{
get => _maximum;
set => SetRange(Math.Min(value, _minimum), value);
}
public int Value
{
get => _value;
set
{
if (!_initializing && (value < _minimum || value > _maximum))
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“Value”无效。“Value”应介于 'Minimum' 和 'Maximum' 之间。");
if (SetProp(ref _value, value, nameof(Value))) OnValueChanged(EventArgs.Empty);
}
}
public int TickFrequency
{
get => _tickFrequency;
set => SetProp(ref _tickFrequency, value, nameof(TickFrequency));
}
public TickStyle TickStyle
{
get => _tickStyle;
set => SetProp(ref _tickStyle, value, nameof(TickStyle));
}
public Orientation Orientation
{
get => _orientation;
set
{
if (value == _orientation) return;
SetProp(ref _orientation, value, nameof(Orientation));
Size = new Size(Height, Width);
}
}
public int LargeChange { get; set; } = 5;
public int SmallChange { get; set; } = 1;
public event EventHandler? Scroll;
public event EventHandler? ValueChanged;
protected virtual void OnScroll(EventArgs e) => Scroll?.Invoke(this, e);
protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e);
public void SetRange(int minValue, int maxValue)
{
SetProp(ref _minimum, minValue, nameof(Minimum));
SetProp(ref _maximum, Math.Max(minValue, maxValue), nameof(Maximum));
if (_value < _minimum) Value = _minimum;
else if (_value > _maximum) Value = _maximum;
}
// Designer.cs 里 VS 生成 ((ISupportInitialize)trackBar1).BeginInit() / EndInit():期间先设 Value 再设范围也不报错
public void BeginInit() => _initializing = true;
public void EndInit()
{
_initializing = false;
if (_value < _minimum || _value > _maximum) Value = Math.Clamp(_value, _minimum, _maximum);
}
/// <summary>用户拖动滑块、点轨道或按方向键:和 WinForms 一样先改 Value(ValueChanged),再触发 Scroll</summary>
protected override void OnUserAction(UserAction action)
{
if (action.Action != "scroll" || !int.TryParse(action.Value, out var value))
{
base.OnUserAction(action);
return;
}
Value = Math.Clamp(value, _minimum, _maximum);
OnScroll(EventArgs.Empty);
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["Minimum"] = _minimum;
props["Maximum"] = _maximum;
props["Value"] = _value;
props["TickFrequency"] = _tickFrequency;
props["TickStyle"] = _tickStyle.ToString();
props["Orientation"] = _orientation.ToString();
props["LargeChange"] = LargeChange;
props["SmallChange"] = SmallChange;
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel;
using System.Drawing;
using System.IO;
namespace Sim.Host;
/// <summary>
/// 项目资源(Properties.Resources)的素材库。平台为学生工程生成的 Properties/Resources.Designer.cs 通过这里读取素材,
/// 和 VS 一样每次取图片都得到一个新的 Bitmap。
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class ProjectResources
{
/// <summary>素材文件所在目录(运行器目录下的 Resources/gallery)</summary>
public static string Directory { get; } = Path.Combine(Path.GetDirectoryName(typeof(ProjectResources).Assembly.Location)!, "Resources", "gallery");
public static Bitmap Image(string file) => (Bitmap)System.Drawing.Image.FromStream(new MemoryStream(Bytes(file)));
public static byte[] Bytes(string file) => File.ReadAllBytes(Path.Combine(Directory, Path.GetFileName(file)));
}
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json.Nodes;
namespace Sim.Host;
/// <summary>
/// 虚拟时钟(实训台上可以拨时间、调倍速;教材项目 7 按时刻切换情景模式)。
/// 编译时把学生代码里的 DateTime.Now / Today / UtcNow 改写成这里的属性(见 Sim.Runner/ClockRewriter.cs)。
/// 没有拨过时就是真实时间。
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class SimClock
{
private sealed record Setting(DateTime Wall, long RealAt, double Speed);
private static volatile Setting? _setting;
public static DateTime Now
{
get
{
var setting = _setting;
if (setting == null) return DateTime.Now;
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - setting.RealAt;
return setting.Wall.AddMilliseconds(elapsed * setting.Speed);
}
}
public static DateTime Today => Now.Date;
public static DateTime UtcNow => Now.ToUniversalTime();
/// <summary>平台内部使用:应用服务器推送的 {"wall":"2024-01-16T06:40:00","realAt":…,"speed":60},null 恢复真实时间</summary>
public static void Update(JsonNode? clock)
{
if (clock is not JsonObject json
|| !DateTime.TryParseExact((string?)json["wall"], "yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var wall))
{
_setting = null;
return;
}
_setting = new Setting(DateTime.SpecifyKind(wall, DateTimeKind.Local), (long?)json["realAt"] ?? 0, (double?)json["speed"] ?? 1);
}
}
+55 -9
View File
@@ -86,19 +86,23 @@ public static class SimHost
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
public static string ImageKey(System.Drawing.Image image)
public static string ImageKey(System.Drawing.Image image) => DataKey(image.SimKey, image.Data, image.MimeType);
/// <summary>
/// 图片、声音等数据在浏览器端的编号(key 通常是内容的哈希):第一次用到时先把数据发过去,之后只发编号。
/// </summary>
public static string DataKey(string key, byte[] data, string mime)
{
var key = image.SimKey;
bool isNew;
lock (SentImages) isNew = SentImages.Add(key);
if (isNew)
{
Send(new JsonObject
{
["type"] = "image",
["type"] = "blob",
["key"] = key,
["mime"] = image.MimeType,
["data"] = Convert.ToBase64String(image.Data),
["mime"] = mime,
["data"] = Convert.ToBase64String(data),
});
}
return key;
@@ -130,12 +134,54 @@ public static class SimHost
public static void ReportException(Exception ex)
{
while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!;
Send(new JsonObject
var type = ex.GetType().FullName;
// 仿真库自己抛的异常信息已经是中文;.NET 自带的是英文,常见的几种配上中文说明,原文放在括号里
var message = HasChinese(ex.Message) ? $"{ex.Message}({type})"
: ExplainException(ex) is { } explained ? $"{explained}({type}: {ex.Message})"
: $"{type}: {ex.Message}";
var frame = StudentFrame(ex);
var result = new JsonObject
{
["type"] = "exception",
["message"] = $"{ex.GetType().FullName}: {ex.Message}",
["detail"] = StudentStackTrace(ex),
});
["message"] = message,
["detail"] = frame is { } f ? $"出错位置:{f.File} 第 {f.Line} 行({f.Method} 方法)" : StudentStackTrace(ex),
};
if (frame is { } at)
{
result["file"] = at.File;
result["line"] = at.Line;
}
Send(result);
}
private static bool HasChinese(string text) => text.Any(c => c >= '\u4e00' && c <= '\u9fff');
private static string? ExplainException(Exception ex) => ex switch
{
FormatException => "文字转换成数字或日期时格式不对,比如文本框是空的,或者里面不是数字",
NullReferenceException => "用到的对象是 null:变量还没有赋值,或者对象、窗体还没有 new 出来",
IndexOutOfRangeException => "数组的下标超出了范围:下标从 0 开始,最大是长度减 1",
ArgumentOutOfRangeException => "下标或参数超出了范围,比如列表、字符串的下标太大,或者下拉框还没有选中",
DivideByZeroException => "整数除以了 0",
OverflowException => "数字太大或太小,超出了这个类型能表示的范围",
InvalidCastException => "类型转换失败,这个对象不能转换成要求的类型",
KeyNotFoundException => "字典里没有这个键",
FileNotFoundException => "找不到这个文件",
DirectoryNotFoundException => "找不到这个文件夹",
_ => null,
};
/// <summary>堆栈里离出错点最近的一行学生代码:文件名、行号、方法名</summary>
private static (string File, int Line, string Method)? StudentFrame(Exception ex)
{
foreach (var frame in new System.Diagnostics.StackTrace(ex, true).GetFrames())
{
var file = frame.GetFileName();
var line = frame.GetFileLineNumber();
if (file == null || line <= 0 || file.Contains("/runtime/Sim.")) continue;
return (Path.GetFileName(file), line, frame.GetMethod()?.Name ?? "");
}
return null;
}
/// <summary>只保留学生代码的堆栈行,去掉仿真库内部的调用</summary>
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel;
namespace Sim.Host;
/// <summary>
/// Windows 写法的路径(教材里常见 BaseDirectory + "Camera\\")在 Linux 上要把反斜杠换成斜杠,否则反斜杠成了文件名的一部分。
/// 编译时把学生代码里传给文件、文件夹、图片读写方法的路径参数包上这个方法(见 Sim.Runner/PathRewriter.cs)。
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class WindowsPaths
{
public static string Fix(string path) => path?.Replace('\\', '/')!;
}
+3 -3
View File
@@ -8,8 +8,8 @@ namespace System.IO.Ports;
/// </summary>
public class SerialPort : Component
{
/// <summary>COM1 是电脑自带串口,COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏</summary>
private static readonly string[] VirtualPorts = { "COM1", "COM2", "COM3", "COM4", "COM5" };
/// <summary>COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏,不提供电脑自带的 COM1</summary>
private static readonly string[] VirtualPorts = { "COM2", "COM3", "COM4", "COM5" };
public SerialPort() { }
@@ -19,7 +19,7 @@ public class SerialPort : Component
public static string[] GetPortNames() => (string[])VirtualPorts.Clone();
public string PortName { get; set; } = "COM1";
public string PortName { get; set; } = "COM2";
public int BaudRate { get; set; } = 9600;
public int DataBits { get; set; } = 8;
public int ReadTimeout { get; set; } = -1;
+7
View File
@@ -0,0 +1,7 @@
# 项目资源素材库
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型;图片还有编号 key 和两倍图 hidpi,由 `build-gallery.sh` 写入)。`name.png` 是原尺寸,学生程序读到的就是它;`name.2x.png` 只给高分辨率屏幕的浏览器显示用,宽高仍按原尺寸。
- `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
@@ -0,0 +1,23 @@
#!/bin/sh
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG:name.png 是原尺寸(学生程序读到的图片,宽高和教材一致),
# name.2x.png 是两倍尺寸(只给高分辨率屏幕的浏览器显示用)。然后把每张图的编号(原尺寸 PNG 的 SHA-1,
# 和运行器给图片的编号相同)和两倍图的文件名写进 gallery/index.json。需要 rsvg-convert 和思源黑体等中文字体。
cd "$(dirname "$0")"
for f in svg/*.svg; do
name=$(basename "$f" .svg)
rsvg-convert "$f" -o "gallery/$name.png"
rsvg-convert -z 2 "$f" -o "gallery/$name.2x.png"
done
python3 - <<'PY'
import hashlib, json, os
items = json.load(open("gallery/index.json", encoding="utf-8"))
for item in items:
if item["kind"] != "image":
continue
item["key"] = hashlib.sha1(open(f"gallery/{item['file']}", "rb").read()).hexdigest()
hidpi = item["file"].replace(".png", ".2x.png")
if os.path.exists(f"gallery/{hidpi}"):
item["hidpi"] = hidpi
lines = ",\n".join(" " + json.dumps(i, ensure_ascii=False) for i in items)
open("gallery/index.json", "w", encoding="utf-8").write(f"[\n{lines}\n]\n")
PY
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1,195 @@
[
{
"name": "switch_on",
"file": "switch_on.png",
"label": "开关:开",
"kind": "image",
"key": "6e660e1ff6fc2ff3167919bc386c300112bbce72",
"hidpi": "switch_on.2x.png"
},
{
"name": "switch_off",
"file": "switch_off.png",
"label": "开关:关",
"kind": "image",
"key": "5d748b614084e878d6189d22f27813477cb8f4d9",
"hidpi": "switch_off.2x.png"
},
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" },
{
"name": "ico_title",
"file": "ico_title.png",
"label": "标题:智慧校园",
"kind": "image",
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
"hidpi": "ico_title.2x.png"
},
{
"name": "bg_home",
"file": "bg_home.png",
"label": "主界面背景:校园",
"kind": "image",
"key": "e56bcc13f7128dec305b6012b255d63fb0f94450",
"hidpi": "bg_home.2x.png"
},
{
"name": "icon_02",
"file": "icon_02.png",
"label": "图标:校园环境",
"kind": "image",
"key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d",
"hidpi": "icon_02.2x.png"
},
{
"name": "icon_03",
"file": "icon_03.png",
"label": "图标:校园安防",
"kind": "image",
"key": "d085064a505544968278a85233b7d9ac23661169",
"hidpi": "icon_03.2x.png"
},
{
"name": "icon_04",
"file": "icon_04.png",
"label": "图标:退出",
"kind": "image",
"key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3",
"hidpi": "icon_04.2x.png"
},
{
"name": "day_01",
"file": "day_01.png",
"label": "路灯场景:亮灯",
"kind": "image",
"key": "05065abdb4538906bd7c86e940ab5fb02d51469d",
"hidpi": "day_01.2x.png"
},
{
"name": "day_02",
"file": "day_02.png",
"label": "路灯场景:灭灯",
"kind": "image",
"key": "470d523e90e188fe5c423d8cffeae6993d5528ff",
"hidpi": "day_02.2x.png"
},
{
"name": "bg_from1",
"file": "bg_from1.png",
"label": "面板背景(宽)",
"kind": "image",
"key": "7a8c0a16fcab37b28a66e041024b25341cc69462",
"hidpi": "bg_from1.2x.png"
},
{
"name": "bg_from2",
"file": "bg_from2.png",
"label": "面板背景(窄)",
"kind": "image",
"key": "52497df301d2b7e4eac0c297ce2316ef24863c47",
"hidpi": "bg_from2.2x.png"
},
{
"name": "icon_monitor",
"file": "icon_monitor.png",
"label": "监控:无画面",
"kind": "image",
"key": "23e6f1efc0f382c17d8a091b76531a3b5879349d",
"hidpi": "icon_monitor.2x.png"
},
{
"name": "panel_video_ctrl",
"file": "panel_video_ctrl.png",
"label": "监控:方向盘底座",
"kind": "image",
"key": "396443a1d8c141942c0f447e0c69786cf6ad3350",
"hidpi": "panel_video_ctrl.2x.png"
},
{
"name": "btn_arrow_up_normal",
"file": "btn_arrow_up_normal.png",
"label": "方向键:上",
"kind": "image",
"key": "3c8b75ad465c8a61d0279facc233a0c64b62205a",
"hidpi": "btn_arrow_up_normal.2x.png"
},
{
"name": "btn_arrow_up_press",
"file": "btn_arrow_up_press.png",
"label": "方向键:上(按下)",
"kind": "image",
"key": "51e1b9926d2bd611e3fda74a01231b46a930c460",
"hidpi": "btn_arrow_up_press.2x.png"
},
{
"name": "btn_arrow_down_normal",
"file": "btn_arrow_down_normal.png",
"label": "方向键:下",
"kind": "image",
"key": "caf46f81c15aaca788462ebc47e58df299f097d9",
"hidpi": "btn_arrow_down_normal.2x.png"
},
{
"name": "btn_arrow_down_press",
"file": "btn_arrow_down_press.png",
"label": "方向键:下(按下)",
"kind": "image",
"key": "88c1bea8d6fad99b14658d8265a88b04bc595006",
"hidpi": "btn_arrow_down_press.2x.png"
},
{
"name": "btn_arrow_left_normal",
"file": "btn_arrow_left_normal.png",
"label": "方向键:左",
"kind": "image",
"key": "a38beb2373154d0474ad1715035aec5e2adf8c3f",
"hidpi": "btn_arrow_left_normal.2x.png"
},
{
"name": "btn_arrow_left_press",
"file": "btn_arrow_left_press.png",
"label": "方向键:左(按下)",
"kind": "image",
"key": "47bd7a8ffc077130a8ceca627383a4647729c638",
"hidpi": "btn_arrow_left_press.2x.png"
},
{
"name": "btn_arrow_right_normal",
"file": "btn_arrow_right_normal.png",
"label": "方向键:右",
"kind": "image",
"key": "e69e459f54d67e2c52128015bd6cfcf0e800a893",
"hidpi": "btn_arrow_right_normal.2x.png"
},
{
"name": "btn_arrow_right_press",
"file": "btn_arrow_right_press.png",
"label": "方向键:右(按下)",
"kind": "image",
"key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170",
"hidpi": "btn_arrow_right_press.2x.png"
},
{
"name": "arrow_left_normal",
"file": "arrow_left_normal.png",
"label": "翻页:上一张",
"kind": "image",
"key": "5a9036955f791f0c41ba52afffb5ecc640d137f1",
"hidpi": "arrow_left_normal.2x.png"
},
{
"name": "arrow_right_normal",
"file": "arrow_right_normal.png",
"label": "翻页:下一张",
"kind": "image",
"key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9",
"hidpi": "arrow_right_normal.2x.png"
},
{
"name": "windowstate_closed_hover",
"file": "windowstate_closed_hover.png",
"label": "关闭按钮",
"kind": "image",
"key": "e361965dbd881dc21511208ce11f649517e8ddb6",
"hidpi": "windowstate_closed_hover.2x.png"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.
Loaded 100 of 374 files, more files were not shown because too many files have changed in this diff. Show more