导出 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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 12:42:13 +08:00
1 parent 4c3ce31fe1
commit 2aa267839f
17 files changed
+1209 -80

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。 - 导出 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 测试、编好再传到服务器)。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 - 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
+2 -2
View File
@@ -125,9 +125,9 @@ host 模式直接用开发机自己的东西,所以**不需要配任何 secret
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" | | 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` | | 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 导出 EXE 用的教材类库 ## 导出 EXE、VS 工程用的教材类库
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。 实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
放进同一个目录(不分子目录)的文件,都在教材配套资料里: 放进同一个目录(不分子目录)的文件,都在教材配套资料里:
+2 -1
View File
@@ -58,7 +58,7 @@
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。 **教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
**导入 VS 工程、导出 EXE**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件。 **导入 VS 工程、导出 EXE 和 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件;也能导出成教材那样的 VS 2012 工程(zip),在机房的 VS 里接着改。
## 设备类库设计(兼容教材) ## 设备类库设计(兼容教材)
@@ -344,6 +344,7 @@ flowchart TB
- 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:**实训台接线**。讨论"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-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-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 编译"。
## 技术风险与待确认事项 ## 技术风险与待确认事项
+26
View File
@@ -1,3 +1,4 @@
using System.Linq;
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis;
namespace Sim.Runner; namespace Sim.Runner;
@@ -23,7 +24,32 @@ internal static class CompileHints
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;", ("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。", ("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。", ("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
("CS8026", _) => Vs2012(diagnostic),
_ => null, _ => null,
}; };
} }
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
private static readonly (string Feature, string Fix)[] NewerFeatures =
{
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
("nameof", "nameof(x) 直接写成字符串 \"x\""),
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
("default 字面量", "default 写成 default(类型),例如 default(int)"),
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
};
private static string Vs2012(Diagnostic diagnostic)
{
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
}
} }
+25 -9
View File
@@ -11,7 +11,8 @@ namespace Sim.Runner;
/// <summary> /// <summary>
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程, /// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。 /// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe。 /// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。 /// 编译器常驻,第一次之后的编译不再有加载和预热开销。
/// </summary> /// </summary>
internal static class CompileServer internal static class CompileServer
@@ -19,6 +20,7 @@ internal static class CompileServer
public const string AssemblyFile = "StudentApp.dll"; public const string AssemblyFile = "StudentApp.dll";
public const string PdbFile = "StudentApp.pdb"; public const string PdbFile = "StudentApp.pdb";
public const string ExportFile = "export.exe"; public const string ExportFile = "export.exe";
public const string ExportProjectFile = "export.zip";
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
@@ -40,7 +42,7 @@ internal static class CompileServer
id = request["id"]?.DeepClone(); id = request["id"]?.DeepClone();
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir"); var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
reply = request["export"] is JsonObject export reply = request["export"] is JsonObject export
? Export(dir, (string?)export["libs"] ?? "", (string?)export["name"] ?? "") ? Export(dir, (string?)export["kind"] == "project", (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
: Compile(dir); : Compile(dir);
} }
catch (Exception ex) catch (Exception ex)
@@ -72,19 +74,33 @@ internal static class CompileServer
} }
/// <summary> /// <summary>
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。 /// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
/// name 是程序集名(exe 的名字),由服务器按工程取好。 /// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
/// </summary> /// </summary>
private static JsonObject Export(string dir, string libs, string name) private static JsonObject Export(string dir, bool project, string libs, string name)
{ {
var watch = Stopwatch.StartNew(); var watch = Stopwatch.StartNew();
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, string.IsNullOrWhiteSpace(name) ? "上位机程序" : name); if (string.IsNullOrWhiteSpace(name)) name = "上位机程序";
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe); 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 return new JsonObject
{ {
["type"] = "compiled", ["type"] = "compiled",
["ok"] = result.Success, ["ok"] = ok,
["diagnostics"] = result.Diagnostics, ["diagnostics"] = diagnostics,
["ms"] = watch.ElapsedMilliseconds, ["ms"] = watch.ElapsedMilliseconds,
}; };
} }
+33 -21
View File
@@ -34,7 +34,7 @@ internal static class ExeExporter
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。 /// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。 /// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
/// </summary> /// </summary>
private const string MediaPlayerOcxState = internal const string MediaPlayerOcxState =
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" + "AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" + "LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" + "ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
@@ -51,9 +51,9 @@ internal static class ExeExporter
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles."; private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)"); private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
private static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)"); internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
private static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication) internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
.WithPlatform(Platform.AnyCpu32BitPreferred) .WithPlatform(Platform.AnyCpu32BitPreferred)
.WithOptimizationLevel(OptimizationLevel.Release) .WithOptimizationLevel(OptimizationLevel.Release)
.WithNullableContextOptions(NullableContextOptions.Disable) .WithNullableContextOptions(NullableContextOptions.Disable)
@@ -61,7 +61,7 @@ internal static class ExeExporter
.WithConcurrentBuild(true); .WithConcurrentBuild(true);
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary> /// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
private static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() => internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll") Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
.ToList()); .ToList());
@@ -69,9 +69,7 @@ internal static class ExeExporter
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary> /// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName) public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
{ {
var libs = TextbookLibs(libsDir); var libs = TextbookLibs(libsDir, "导出 EXE");
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
throw new InvalidOperationException("服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能导出 EXE,请联系老师");
var sources = StudentCompiler.SourceFiles(projectDir) var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path))) .Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
@@ -86,15 +84,7 @@ internal static class ExeExporter
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources)); sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
} }
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹 // 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
var media = Directory.Exists(galleryDir) var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
? Directory.GetFiles(galleryDir)
.Select(Path.GetFileName)
.OfType<string>()
.Where(name => !name.Contains(".2x.") && name != "index.json")
.Where(name => sources.Any(s => s.Text.Contains(name + "\"")))
.OrderBy(name => name, StringComparer.Ordinal)
.ToList()
: new List<string>();
var trees = sources var trees = sources
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path)) .Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
@@ -135,11 +125,33 @@ internal static class ExeExporter
return new Result(emit.Success, pe.ToArray(), diagnostics); 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> /// <summary>
/// 教材 dll 的路径。目录或文件读不了时(服务器上拷进去的文件权限是 600,容器里的平台用户读不到)说明原因, /// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
/// 不把英文的系统报错直接给学生看。 /// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
/// </summary> /// </summary>
private static string[] TextbookLibs(string libsDir) 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>(); if (!Directory.Exists(libsDir)) return Array.Empty<string>();
string? current = null; string? current = null;
@@ -155,9 +167,9 @@ internal static class ExeExporter
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
var what = current == null ? "目录" : Path.GetFileName(current); var item = current == null ? "目录" : Path.GetFileName(current);
throw new InvalidOperationException( throw new InvalidOperationException(
$"服务器上的教材设备类库读不了({what} 的权限不够),不能导出 EXE,请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)"); $"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
} }
} }
+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>
+4
View File
@@ -28,6 +28,10 @@
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" /> <Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target> </Target>
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
<ItemGroup>
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" /> <ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" /> <ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
+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;
}
}
+29 -19
View File
@@ -27,7 +27,7 @@ import { config } from './config'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases' import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample, loadTask } from './samples' import { listSamples, loadChecks, loadSample, loadTask } from './samples'
import { SqlError, sqlService } from './sql-service' import { SqlError, sqlService } from './sql-service'
import { exeName, exportExe } from './export-exe' import { type ExportKind, exportProgram, programName } from './export'
import { serveStatic } from './static' import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works' import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -130,25 +130,35 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id))) app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
/** 导出 EXE:成功时返回 exe 文件;代码有错误时 422 和报错(照真机版类库编译,和 VS 里一样) */ /**
app.post('/api/export-exe', async (c) => { * 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
const { files, name } = await body<{ files: unknown; name: unknown }>(c) * (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400) */
let result const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
try { exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' },
result = await exportExe(files, exeName(name)) project: { ext: 'zip', type: 'application/zip' },
} catch (error) { }
return c.json({ message: error instanceof Error ? error.message : String(error) }, 503) for (const kind of ['exe', 'project'] as const) {
} app.post(`/api/export-${kind}`, async (c) => {
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422) const { files, name } = await body<{ files: unknown; name: unknown }>(c)
return new Response(result.exe, { if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
headers: { let result
'Content-Type': 'application/vnd.microsoft.portable-executable', try {
'Content-Disposition': `attachment; filename="program.exe"; filename*=UTF-8''${encodeURIComponent(`${result.name}.exe`)}`, result = await exportProgram(files, programName(name), kind)
'Cache-Control': 'no-store', } catch (error) {
}, return c.json({ message: error instanceof Error ? error.message : String(error) }, 503)
}
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422)
const { ext, type } = EXPORTS[kind]
return new Response(result.file, {
headers: {
'Content-Type': type,
'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`,
'Cache-Control': 'no-store',
},
})
}) })
}) }
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查 // 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics']) const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
+10 -1
View File
@@ -9,8 +9,9 @@ export interface CompileResult {
ms: number ms: number
} }
/** 导出的 exe 在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile) */ /** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */
export const EXPORT_FILE = 'export.exe' export const EXPORT_FILE = 'export.exe'
export const EXPORT_PROJECT_FILE = 'export.zip'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string } type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
@@ -37,6 +38,14 @@ class CompileService {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } }) return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
} }
/**
* 导出 VS 工程:照机房的 VS 2012(C# 5、.NET Framework 4.5、真机版设备类库)检查,
* 打包成 zip 写到工程目录的 EXPORT_PROJECT_FILE。name 是工程名
*/
exportProject(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> { private async request(payload: object): Promise<CompileResult> {
await this.start() await this.start()
const id = this.nextId++ const id = this.nextId++
@@ -1,21 +1,24 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。 // 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。
import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import type { Diagnostic, SourceFile } from '@iot/shared' import type { Diagnostic, SourceFile } from '@iot/shared'
import { compileService, EXPORT_FILE } from './compile-service' import { compileService, EXPORT_FILE, EXPORT_PROJECT_FILE } from './compile-service'
import { config } from './config' import { config } from './config'
import { safeJoin } from './run-session' import { safeJoin } from './run-session'
export type ExportResult = { ok: true; exe: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] } export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
/** exe 的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */ export type ExportKind = 'exe' | 'project'
export function exeName(name: unknown) {
/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function programName(name: unknown) {
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : '' const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
return cleaned || '上位机程序' return cleaned || '上位机程序'
} }
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */ /** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
export async function exportExe(files: SourceFile[], name: string): Promise<ExportResult> { export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true }) await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-')) const dir = await mkdtemp(join(config.runsDir, 'export-'))
try { try {
@@ -24,9 +27,9 @@ export async function exportExe(files: SourceFile[], name: string): Promise<Expo
await mkdir(dirname(target), { recursive: true }) await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content) await Bun.write(target, file.content)
} }
const result = await compileService.exportExe(dir, name) const result = kind === 'project' ? await compileService.exportProject(dir, name) : await compileService.exportExe(dir, name)
if (!result.ok) return { ok: false, diagnostics: result.diagnostics } if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
return { ok: true, exe: await Bun.file(join(dir, EXPORT_FILE)).bytes(), name } return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
} finally { } finally {
await rm(dir, { recursive: true, force: true }) await rm(dir, { recursive: true, force: true })
} }
+107 -3
View File
@@ -740,12 +740,14 @@ describe('AI 评阅', () => {
}, 60000) }, 60000)
}) })
describe('导出 EXE', () => { describe('导出 EXE、VS 工程', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`) const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
async function exportExe(browser: Browser, json: unknown) { const exportExe = (browser: Browser, json: unknown) => exportRequest(browser, 'exe', json)
async function exportRequest(browser: Browser, kind: 'exe' | 'project', json: unknown) {
const response = await app.fetch( const response = await app.fetch(
new Request('http://localhost/api/export-exe', { new Request(`http://localhost/api/export-${kind}`, {
method: 'POST', method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip }, headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json), body: JSON.stringify(json),
@@ -775,6 +777,104 @@ describe('导出 EXE', () => {
60000, 60000,
) )
/** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */
function unzip(zip: Uint8Array<ArrayBuffer>) {
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
let end = zip.length - 22
while (view.getUint32(end, true) !== 0x06054b50) end--
const count = view.getUint16(end + 10, true)
let p = view.getUint32(end + 16, true)
const files = new Map<string, Uint8Array<ArrayBuffer>>()
for (let i = 0; i < count; i++) {
expect(view.getUint32(p, true)).toBe(0x02014b50)
const flags = view.getUint16(p + 8, true)
const method = view.getUint16(p + 10, true)
const compressedSize = view.getUint32(p + 20, true)
const nameLength = view.getUint16(p + 28, true)
const extraLength = view.getUint16(p + 30, true)
const commentLength = view.getUint16(p + 32, true)
const offset = view.getUint32(p + 42, true)
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
expect(flags & 0x800).toBe(0)
const name = new TextDecoder('gbk').decode(nameBytes)
if (extra.length > 0) {
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
} else {
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
}
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
const data = zip.subarray(local, local + compressedSize)
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
p += 46 + nameLength + extraLength + commentLength
}
return files
}
test.skipIf(!hasLibs)(
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
async () => {
const t = await teacher()
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
const files = unzip(new Uint8Array(await response.arrayBuffer()))
const text = (name: string) => {
const bytes = files.get(name)
expect(bytes, name).toBeDefined()
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
const content = new TextDecoder().decode(bytes!.subarray(3))
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
return content
}
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
const csproj = text('寝室/寝室/寝室.csproj')
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
expect(csproj).not.toContain('ICS.Models')
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
expect(resources).not.toContain('Sim.Host')
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
const led = unzip(
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
)
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
// 机房的 VS 2012 只支持 C# 5
const login = (await loadSample('p1-t1-登录'))!
const newer = login.map((f) =>
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
)
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
expect(failed.status).toBe(422)
const diagnostics = ((await failed.json()) as any).diagnostics
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
// 导出 EXE 不受这个限制
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => { test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher() const t = await teacher()
const saved = config.textbookLibsDir const saved = config.textbookLibsDir
@@ -783,9 +883,13 @@ describe('导出 EXE', () => {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' }) const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503) expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库') expect(((await response.json()) as any).message).toContain('设备类库')
const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(project.status).toBe(503)
expect(((await project.json()) as any).message).toContain('不能导出工程')
} finally { } finally {
config.textbookLibsDir = saved config.textbookLibsDir = saved
} }
expect((await exportExe(t, { files: 'x' })).status).toBe(400) expect((await exportExe(t, { files: 'x' })).status).toBe(400)
expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400)
}, 60000) }, 60000)
}) })
@@ -1,18 +1,23 @@
// 导出 EXE:服务器照教材工程(真机版设备类库)编译,下载一个 exe,在机房电脑上双击就能控制真的实训台。 // 导出:服务器照教材工程(真机版设备类库)编译,下载到机房电脑上用。
// EXE:一个 exe,双击就能控制真的实训台;VS 工程:教材那样的 VS 2012 工程(zip),用 VS 打开接着改。
import type { Diagnostic, SourceFile } from '@iot/shared' import type { Diagnostic, SourceFile } from '@iot/shared'
import { apiResponse, ApiError } from '../api' import { apiResponse, ApiError } from '../api'
import { projectNamespace } from '../designer/templates' import { projectNamespace } from '../designer/templates'
export type ExportKind = 'exe' | 'project'
const EXT: Record<ExportKind, string> = { exe: 'exe', project: 'zip' }
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */ /** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
export async function downloadExe(files: SourceFile[]): Promise<Diagnostic[] | null> { export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise<Diagnostic[] | null> {
let response: Response let response: Response
try { try {
response = await apiResponse('POST', '/api/export-exe', { files, name: projectNamespace(files) }) response = await apiResponse('POST', `/api/export-${kind}`, { files, name: projectNamespace(files) })
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[] if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
throw error throw error
} }
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.exe` const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.${EXT[kind]}`
const url = URL.createObjectURL(await response.blob()) const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
+10 -2
View File
@@ -168,16 +168,24 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption> <figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
</figure> </figure>
<h3>8. 导出 EXE,在机房电脑上运行</h3> <h3>8. 导出 EXE 或 VS 工程,在机房电脑上运行</h3>
<ul> <ul>
<li> <li>
点工具栏的<b>导出 EXE</b>,下载一个 点工具栏的<b>导出</b>→<b>导出 EXE</b>,下载一个
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。 exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
</li> </li>
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li> <li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li> <li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li> <li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li> <li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
<li>
想在机房的 VS 里接着改,点<b>导出</b>→<b>导出 VS 工程</b>,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5
运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。
</li>
<li>
机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如
<code>$"温度{t}"</code>)会弹出报错和改法,改好再导出。
</li>
</ul> </ul>
</section> </section>
+42 -10
View File
@@ -4,7 +4,21 @@
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。 // 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui' import {
NButton,
NDropdown,
NInput,
NModal,
NPopconfirm,
NPopover,
NSelect,
NTab,
NTabs,
NTag,
useMessage,
type DropdownOption,
type SelectGroupOption,
} from 'naive-ui'
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared' import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue' import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history' import { createDesignerHistory } from '../designer/history'
@@ -25,7 +39,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { checkProject } from '../editor/language' import { checkProject } from '../editor/language'
import { api } from '../api' import { api } from '../api'
import { currentUser, logout } from '../auth' import { currentUser, logout } from '../auth'
import { downloadExe } from '../editor/export-exe' import { downloadExport, type ExportKind } from '../editor/export'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works' import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue' import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session' import { createRunSession } from '../run/session'
@@ -479,22 +493,37 @@ async function showDiagnostic(file: string, line: number, column: number) {
editorRef.value?.reveal(file, line, column) editorRef.value?.reveal(file, line, column)
} }
// ---------- 导出 EXE:照真机版类库编译,在机房电脑上双击运行 ---------- // ---------- 导出:照真机版类库编译,EXE 在机房电脑上双击运行,VS 工程用机房的 VS 2012 打开 ----------
const exporting = ref(false) const exporting = ref(false)
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */ /** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
const exportErrors = ref<Diagnostic[]>([]) const exportErrors = ref<Diagnostic[]>([])
const exportErrorsOpen = ref(false) const exportErrorsOpen = ref(false)
const exportKind = ref<ExportKind>('exe')
async function exportExe() { const EXPORT_OPTIONS: DropdownOption[] = [
{ key: 'exe', label: '导出 EXE', props: { title: '编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台' } },
{ key: 'project', label: '导出 VS 工程', props: { title: '打包成 VS 2012 工程(.zip),在机房电脑上解压后用 VS 打开,接着改代码、按 F5 运行' } },
]
const EXPORT_DONE: Record<ExportKind, string> = {
exe: '已导出,在机房电脑上双击运行(要接真的实训台)',
project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)',
}
async function exportProgram(kind: ExportKind) {
if (exporting.value) return if (exporting.value) return
exporting.value = true exporting.value = true
exportKind.value = kind
try { try {
const errors = await downloadExe(files.value.map((f) => ({ ...f }))) const errors = await downloadExport(
kind,
files.value.map((f) => ({ ...f })),
)
if (errors) { if (errors) {
exportErrors.value = errors exportErrors.value = errors
exportErrorsOpen.value = true exportErrorsOpen.value = true
} else { } else {
message.success('已导出,在机房电脑上双击运行(要接真的实训台)') message.success(EXPORT_DONE[kind])
} }
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : String(error)) message.error(error instanceof Error ? error.message : String(error))
@@ -630,9 +659,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen"> <NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务 任务
</NButton> </NButton>
<NButton size="small" :loading="exporting" title="编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台" @click="exportExe"> <NDropdown trigger="click" :options="EXPORT_OPTIONS" :disabled="exporting" @select="(key: ExportKind) => exportProgram(key)">
导出 EXE <NButton size="small" :loading="exporting" title="导出到机房电脑上用:EXE 双击运行,VS 工程用 VS 打开">导出 ▾</NButton>
</NButton> </NDropdown>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal"> <NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger> <template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton> <NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -657,7 +686,10 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
</header> </header>
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)"> <NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
<p class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p> <p v-if="exportKind === 'project'" class="export-errors-note">
导出时照机房电脑上的 VS 2012 编译(只支持 C# 5,用真机版设备类库)。点击报错跳到出错的代码。
</p>
<p v-else class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)"> <div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }} {{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div> <div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>