导出 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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:10:07 +08:00
1 parent 46e0f40cce
commit 5e5d29b2aa
19 files changed
+601 -10

No files matched your search

+1
View File
@@ -7,4 +7,5 @@
.git
docs
*.pdf
textbook-libs
.env
+2
View File
@@ -10,6 +10,8 @@ obj/
# 教材资料(体积大、有版权,不进仓库)
物联网编程与应用配套/
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
/textbook-libs/
*.pdf
# 编辑器
+2 -1
View File
@@ -73,6 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 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)。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 第 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)。
@@ -80,4 +81,4 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
尚未完成:
- 一个班试用。
- 书上的扩展练习("边想边做""自主探究")、导出 VS 工程、校园街景场景、故障注入题库(第 4 阶段)。
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
+2
View File
@@ -41,6 +41,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
+25
View File
@@ -106,6 +106,31 @@
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 导出 EXE 用的教材类库
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台。这些 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/
```
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
## 备份与恢复
平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行:
+3 -2
View File
@@ -58,7 +58,7 @@
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
**导入 VS 工程、导出 EXE**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件。
## 设备类库设计(兼容教材)
@@ -242,7 +242,7 @@ flowchart TB
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
P2 -->|"关口:一个班试用顺利"| P3
@@ -343,6 +343,7 @@ flowchart TB
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
- 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。
## 技术风险与待确认事项
+24 -1
View File
@@ -11,12 +11,14 @@ namespace Sim.Runner;
/// <summary>
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe。
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
/// </summary>
internal static class CompileServer
{
public const string AssemblyFile = "StudentApp.dll";
public const string PdbFile = "StudentApp.pdb";
public const string ExportFile = "export.exe";
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
@@ -36,7 +38,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["libs"] ?? "", (string?)export["name"] ?? "")
: Compile(dir);
}
catch (Exception ex)
{
@@ -66,6 +71,24 @@ internal static class CompileServer
};
}
/// <summary>
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。
/// name 是程序集名(exe 的名字),由服务器按工程取好。
/// </summary>
private static JsonObject Export(string dir, string libs, string name)
{
var watch = Stopwatch.StartNew();
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, string.IsNullOrWhiteSpace(name) ? "上位机程序" : name);
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
return new JsonObject
{
["type"] = "compiled",
["ok"] = result.Success,
["diagnostics"] = result.Diagnostics,
["ms"] = watch.ElapsedMilliseconds,
};
}
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
private static void WarmUp()
{
+275
View File
@@ -0,0 +1,275 @@
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>
private 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)\(""([^""]+)""\)");
private static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
private 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>
private 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 = Directory.Exists(libsDir) ? Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray() : Array.Empty<string>();
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
throw new InvalidOperationException("服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能导出 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 = Directory.Exists(galleryDir)
? 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
.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>
/// 每个窗体、控件类的 .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));
}
}
}
""";
}
+14
View File
@@ -14,6 +14,20 @@
<!-- 语言服务(代码补全、悬停提示):和 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>
<ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
+7 -3
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,
@@ -123,6 +121,12 @@ internal static class StudentCompiler
return json;
}
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
internal static IEnumerable<string> SourceFiles(string projectDir) =>
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal);
private static bool IsBuildOutput(string projectDir, string path)
{
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
+21
View File
@@ -27,6 +27,7 @@ import { config } from './config'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
import { SqlError, sqlService } from './sql-service'
import { exeName, exportExe } from './export-exe'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -129,6 +130,26 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
/** 导出 EXE:成功时返回 exe 文件;代码有错误时 422 和报错(照真机版类库编译,和 VS 里一样) */
app.post('/api/export-exe', async (c) => {
const { files, name } = await body<{ files: unknown; name: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
let result
try {
result = await exportExe(files, exeName(name))
} 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)
return new Response(result.exe, {
headers: {
'Content-Type': 'application/vnd.microsoft.portable-executable',
'Content-Disposition': `attachment; filename="program.exe"; filename*=UTF-8''${encodeURIComponent(`${result.name}.exe`)}`,
'Cache-Control': 'no-store',
},
})
})
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => {
+17 -2
View File
@@ -9,6 +9,9 @@ export interface CompileResult {
ms: number
}
/** 导出的 exe 在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile) */
export const EXPORT_FILE = 'export.exe'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
/**
@@ -22,11 +25,23 @@ class CompileService {
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
/** 编译工程目录;成功时程序集写到同一目录 */
async compile(dir: string): Promise<CompileResult> {
compile(dir: string): Promise<CompileResult> {
return this.request({ dir })
}
/**
* 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir),
* 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe)
*/
exportExe(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> {
await this.start()
const id = this.nextId++
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
this.proc!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`)
this.proc!.stdin.flush()
return result
}
+5
View File
@@ -13,6 +13,11 @@ export const config = {
/** 每次运行的临时工程目录放在这里 */
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
/**
* 导出 EXE 用的教材设备类库(真机版 DeviceCtrlLibrary.dll 及其 API 目录下的 dll、WinFormsDeviceControlLibrary.dll、
* 媒体播放器的 AxInterop.WMPLib.dll / Interop.WMPLib.dll,放在同一个目录里)。有版权,不进仓库;没有时不能导出
*/
textbookLibsDir: resolve(ROOT, env.SIM_TEXTBOOK_LIBS ?? 'textbook-libs'),
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
+33
View File
@@ -0,0 +1,33 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Diagnostic, SourceFile } from '@iot/shared'
import { compileService, EXPORT_FILE } from './compile-service'
import { config } from './config'
import { safeJoin } from './run-session'
export type ExportResult = { ok: true; exe: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
/** exe 的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function exeName(name: unknown) {
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
return cleaned || '上位机程序'
}
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
export async function exportExe(files: SourceFile[], name: string): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-'))
try {
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
const result = await compileService.exportExe(dir, name)
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
return { ok: true, exe: await Bun.file(join(dir, EXPORT_FILE)).bytes(), name }
} finally {
await rm(dir, { recursive: true, force: true })
}
}
+51
View File
@@ -1,5 +1,6 @@
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
import { createApp } from '../src/app'
@@ -738,3 +739,53 @@ describe('AI 评阅', () => {
expect((await t.post(`/api/teacher/assignments/${assignmentId}/adopt-ai`)).body).toEqual({ adopted: 0 })
}, 60000)
})
describe('导出 EXE', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
async function exportExe(browser: Browser, json: unknown) {
const response = await app.fetch(
new Request('http://localhost/api/export-exe', {
method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json),
}),
)
return response
}
test.skipIf(!hasLibs)(
'照真机版类库编译成 exe;书上的 ledHelper.Close() 也能编译;代码有错时返回报错',
async () => {
const t = await teacher()
const files = (await loadSample('p2-t3-LED推送模拟量'))!
const response = await exportExe(t, { files, name: 'LED推送 模拟量/..' })
expect(response.status).toBe(200)
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('LED推送模拟量.exe')}`)
const exe = new Uint8Array(await response.arrayBuffer())
expect(String.fromCharCode(exe[0]!, exe[1]!)).toBe('MZ')
// 教材 dll 嵌在 exe 里
expect(Buffer.from(exe).includes('IotSimExport.lib.DeviceCtrlLibrary.dll')).toBe(true)
const broken = files.map((f) => (f.path === 'frm_LED.cs' ? { ...f, content: f.content.replace('IsOpen = true;', 'IsOpen = true') } : f))
const failed = await exportExe(t, { files: broken, name: 'x' })
expect(failed.status).toBe(422)
expect(((await failed.json()) as any).diagnostics[0]).toMatchObject({ file: 'frm_LED.cs', severity: 'error' })
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher()
const saved = config.textbookLibsDir
config.textbookLibsDir = '/nonexistent/textbook-libs'
try {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库')
} finally {
config.textbookLibsDir = saved
}
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
}, 60000)
})
+11
View File
@@ -6,6 +6,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
/** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */
readonly data: Record<string, unknown> = {},
) {
super(message)
}
@@ -22,6 +24,15 @@ export async function api<T>(method: string, path: string, json?: unknown, init?
return data
}
/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */
export async function apiResponse(method: string, path: string, json?: unknown): Promise<Response> {
const response = await send(method, path, json, undefined)
if (response.ok) return response
const data = (await response.json().catch(() => ({}))) as Record<string, unknown> & { message?: string }
if (response.status === 401) apiHooks.onUnauthorized()
throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status, data)
}
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
const headers: Record<string, string> = {}
+31
View File
@@ -0,0 +1,31 @@
// 导出 EXE:服务器照教材工程(真机版设备类库)编译,下载一个 exe,在机房电脑上双击就能控制真的实训台。
import type { Diagnostic, SourceFile } from '@iot/shared'
import { apiResponse, ApiError } from '../api'
import { projectNamespace } from '../designer/templates'
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
export async function downloadExe(files: SourceFile[]): Promise<Diagnostic[] | null> {
let response: Response
try {
response = await apiResponse('POST', '/api/export-exe', { files, name: projectNamespace(files) })
} catch (error) {
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
throw error
}
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.exe`
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = name
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
return null
}
/** Content-Disposition 里的中文文件名(filename*=UTF-8''…) */
function fileName(header: string | null) {
const m = header && /filename\*=UTF-8''([^;]+)/i.exec(header)
return m ? decodeURIComponent(m[1]) : null
}
+12
View File
@@ -167,6 +167,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<a href="/help-img/db.webp" title="点击放大" @click.prevent="zoom"><img src="/help-img/db.webp" alt="" loading="lazy" /></a>
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
</figure>
<h3>8. 导出 EXE,在机房电脑上运行</h3>
<ul>
<li>
点工具栏的<b>导出 EXE</b>,下载一个
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
</li>
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
</ul>
</section>
<section v-if="currentUser?.role === 'teacher'" id="teacher">
+65 -1
View File
@@ -5,7 +5,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
@@ -25,6 +25,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { checkProject } from '../editor/language'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { downloadExe } from '../editor/export-exe'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
@@ -478,6 +479,35 @@ async function showDiagnostic(file: string, line: number, column: number) {
editorRef.value?.reveal(file, line, column)
}
// ---------- 导出 EXE:照真机版类库编译,在机房电脑上双击运行 ----------
const exporting = ref(false)
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
const exportErrors = ref<Diagnostic[]>([])
const exportErrorsOpen = ref(false)
async function exportExe() {
if (exporting.value) return
exporting.value = true
try {
const errors = await downloadExe(files.value.map((f) => ({ ...f })))
if (errors) {
exportErrors.value = errors
exportErrorsOpen.value = true
} else {
message.success('已导出,在机房电脑上双击运行(要接真的实训台)')
}
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
} finally {
exporting.value = false
}
}
function jumpToExportError(d: Diagnostic) {
exportErrorsOpen.value = false
void showDiagnostic(d.file, d.line, d.column)
}
function restoreOriginal() {
if (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
@@ -600,6 +630,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务
</NButton>
<NButton size="small" :loading="exporting" title="编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台" @click="exportExe">
导出 EXE
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -623,6 +656,15 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
<p class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<p v-if="exportErrors.length > 20" class="export-errors-note">还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。</p>
</NModal>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
@@ -794,6 +836,28 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
color: #dc2626;
}
.export-errors-note {
margin: 0 0 8px;
color: #6b7280;
font-size: 13px;
}
.export-error {
color: #b91c1c;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.export-error:hover {
text-decoration: underline;
}
.export-error .hint {
color: #2563eb;
padding-left: 1em;
}
.rerun-blocked {
color: #b45309;
font-size: 12px;