diff --git a/.dockerignore b/.dockerignore index 02960e0..1dbb7f8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,5 @@ .git docs *.pdf +textbook-libs .env diff --git a/.gitignore b/.gitignore index 3734b4b..03f0ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ obj/ # 教材资料(体积大、有版权,不进仓库) 物联网编程与应用配套/ +# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器 +/textbook-libs/ *.pdf # 编辑器 diff --git a/README.md b/README.md index 5effa39..17f215d 100644 --- a/README.md +++ b/README.md @@ -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 阶段)。 diff --git a/compose.yaml b/compose.yaml index 7db62f8..d9df98f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/deploy/README.md b/deploy/README.md index 64316cf..9894b96 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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` 下手动执行: diff --git a/docs/设计方案.md b/docs/设计方案.md index f867092..1b619d7 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -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)虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔部署(沙箱、Docker Compose、GitHub Actions)✔"] P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔一个班试用(2026-10-26 已部署上线,待试用)"] P3["阶段 3 · 扩展(功能已完成 2026-10-23)教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔教材 8 个项目共 23 个示例 ✔、任务说明 ✔"] - P4["阶段 4 · 进阶(之后)校园街景场景、故障注入题库、导出 VS 工程可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"] + P4["阶段 4 · 进阶(之后)校园街景场景、故障注入题库可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"] 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 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。 ## 技术风险与待确认事项 diff --git a/runtime/Sim.Runner/CompileServer.cs b/runtime/Sim.Runner/CompileServer.cs index eecaf44..a5760e8 100644 --- a/runtime/Sim.Runner/CompileServer.cs +++ b/runtime/Sim.Runner/CompileServer.cs @@ -11,12 +11,14 @@ namespace Sim.Runner; /// /// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程, /// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。 +/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe。 /// 编译器常驻,第一次之后的编译不再有加载和预热开销。 /// 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 }; } + /// + /// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。 + /// name 是程序集名(exe 的名字),由服务器按工程取好。 + /// + 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, + }; + } + /// 启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等 private static void WarmUp() { diff --git a/runtime/Sim.Runner/ExeExporter.cs b/runtime/Sim.Runner/ExeExporter.cs new file mode 100644 index 0000000..97fd722 --- /dev/null +++ b/runtime/Sim.Runner/ExeExporter.cs @@ -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; + +/// +/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe, +/// 在机房的 Windows 上双击就能运行,控制真的实训台。 +/// +/// 编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。 +/// 教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。 +/// Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。 +/// +/// +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."; + + /// + /// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。 + /// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。 + /// + 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"; + + /// 平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取 + 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); + + /// .NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj) + private static readonly Lazy> Framework = new(() => + Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll") + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToList()); + + /// libsDir:教材 dll 所在目录;galleryDir:平台素材库 + 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(); + 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(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() + .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(); + + 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(); + 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); + } + + /// + /// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx, + /// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。 + /// + private static List ControlResources(CSharpCompilation compilation, HashSet 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(); + if (control == null) return resources; + var names = new HashSet(); + foreach (var tree in compilation.SyntaxTrees) + { + var model = compilation.GetSemanticModel(tree); + foreach (var declaration in tree.GetRoot().DescendantNodes().OfType()) + { + 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; + } + + /// + /// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持), + /// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。 + /// + private static string Bootstrap(IReadOnlyList 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 { } + } + + /// + /// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着), + /// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。 + /// + 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 Loaded = new Dictionary(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)); + } + } + } + """; +} diff --git a/runtime/Sim.Runner/Sim.Runner.csproj b/runtime/Sim.Runner/Sim.Runner.csproj index 5e3ebe3..44d0a30 100644 --- a/runtime/Sim.Runner/Sim.Runner.csproj +++ b/runtime/Sim.Runner/Sim.Runner.csproj @@ -14,6 +14,20 @@ + + + + + + + $(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/ + + + + + + diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs index d138a1d..ecc2837 100644 --- a/runtime/Sim.Runner/StudentCompiler.cs +++ b/runtime/Sim.Runner/StudentCompiler.cs @@ -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; } + /// 学生工程里的源文件(不含 bin、obj 下的),按路径排序 + internal static IEnumerable 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('\\', '/'); diff --git a/server/src/app.ts b/server/src/app.ts index 2c8a1da..4d48060 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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) => { diff --git a/server/src/compile-service.ts b/server/src/compile-service.ts index 2b82a9b..d29b5dd 100644 --- a/server/src/compile-service.ts +++ b/server/src/compile-service.ts @@ -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 void; reject: (e: Error) => void }>() /** 编译工程目录;成功时程序集写到同一目录 */ - async compile(dir: string): Promise { + compile(dir: string): Promise { + return this.request({ dir }) + } + + /** + * 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir), + * 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe) + */ + exportExe(dir: string, name: string): Promise { + return this.request({ dir, export: { libs: config.textbookLibsDir, name } }) + } + + private async request(payload: object): Promise { await this.start() const id = this.nextId++ const result = new Promise((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 } diff --git a/server/src/config.ts b/server/src/config.ts index d94a974..5ff483b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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 就用 */ diff --git a/server/src/export-exe.ts b/server/src/export-exe.ts new file mode 100644 index 0000000..47ffe81 --- /dev/null +++ b/server/src/export-exe.ts @@ -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 { + 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 }) + } +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 214f544..9ee686f 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -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) +}) diff --git a/web/src/api.ts b/web/src/api.ts index 1f79147..0ec365a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -6,6 +6,8 @@ export class ApiError extends Error { constructor( message: string, readonly status: number, + /** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */ + readonly data: Record = {}, ) { super(message) } @@ -22,6 +24,15 @@ export async function api(method: string, path: string, json?: unknown, init? return data } +/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */ +export async function apiResponse(method: string, path: string, json?: unknown): Promise { + const response = await send(method, path, json, undefined) + if (response.ok) return response + const data = (await response.json().catch(() => ({}))) as Record & { 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 { const headers: Record = {} diff --git a/web/src/editor/export-exe.ts b/web/src/editor/export-exe.ts new file mode 100644 index 0000000..54dee6c --- /dev/null +++ b/web/src/editor/export-exe.ts @@ -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 { + 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 +} diff --git a/web/src/pages/HelpPage.vue b/web/src/pages/HelpPage.vue index c09b248..a32c27f 100644 --- a/web/src/pages/HelpPage.vue +++ b/web/src/pages/HelpPage.vue @@ -167,6 +167,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) 数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果 + + 8. 导出 EXE,在机房电脑上运行 + + + 点工具栏的导出 EXE,下载一个 + exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制真的设备。 + + 导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。 + 设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。 + 项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。 + 第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点更多信息→仍要运行。 + diff --git a/web/src/pages/WorkbenchPage.vue b/web/src/pages/WorkbenchPage.vue index 0865280..7f172fc 100644 --- a/web/src/pages/WorkbenchPage.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -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([]) +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)) 任务 + + 导出 EXE + {{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }} @@ -623,6 +656,15 @@ onBeforeUnmount(() => clearTimeout(rerunTimer)) 退出 + + 导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。 + + {{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }} + 提示:{{ d.hint }} + + 还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。 + + 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;
导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。
还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。