diff --git a/README.md b/README.md index b0e2967..ebf2834 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 - 代码编辑器(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)。 +- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 diff --git a/deploy/README.md b/deploy/README.md index af3d69d..2b24929 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -125,9 +125,9 @@ host 模式直接用开发机自己的东西,所以**不需要配任何 secret | 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" | | 重启 / 停止 | `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 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。 放进同一个目录(不分子目录)的文件,都在教材配套资料里: diff --git a/docs/设计方案.md b/docs/设计方案.md index 1a7efce..99b1ca2 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -58,7 +58,7 @@ **教材要求的运行能力**:多窗体(`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:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。 - 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。 +- 2026-11-03:**导出 VS 工程**。实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。讨论后定下:机房装的是 VS 2012;代码有错不让导出(和 EXE 一样);这次不做把工程导回平台。①检查:编译服务收到 `export.kind = "project"` 的请求时(`runtime/Sim.Runner/ProjectExporter.cs`),照机房的环境编译一遍要放进 zip 的代码:**C# 5**(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库。新版 C# 的写法报 CS8026,`CompileHints` 按报错里的功能名给出改法(字符串插值改 `string.Format`、`?.` 改先判断 null、`=>` 改大括号等 12 种)。导出 EXE 仍按最新的 C# 编译,不受影响。23 个示例都是 C# 5 的写法,全部能导出。②工程照教材的样子(VS 2012 窗体应用,`.sln` + `.csproj`,ToolsVersion 4.0):平台生成的 `Properties/Resources.Designer.cs` 从素材库取图,导出时换成 VS 的 ResXFileCodeGenerator 生成的写法和 `Resources.resx`(ResXFileRef 指向 `Resources\` 下的图片);代码里按文件名用到的素材(ring.mp3)设成复制到输出目录,运行时在 `bin\Debug\Resources\`,和教材一样;每个窗体、用户控件带 `.resx`(媒体播放器的 OcxState 用教材的默认状态),`SubType`、`DependentUpon` 和 VS 一样,VS 里能打开设计器;补上 AssemblyInfo、Settings、App.config。③引用和教材工程一样只加代码直接用到的教材 dll(一般是 DeviceCtrlLibrary 和 WinFormsDeviceControlLibrary,项目 7 寝室加媒体播放器的两个 Interop),全部教材 dll 放在工程的 `lib\`,HintPath 指过去,编译时 VS 把依赖一起复制到 `bin\Debug`;只带这些引用编译不过时引用全部。书上的 `ledHelper.Close()` 用到时带上 `LedHelperCompat.cs`。④文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 的文件名用 GBK(中文 Windows 资源管理器自带的解压、老的压缩软件都按 GBK 读,UTF-8 在 Windows 7 上会乱码),另附 Info-ZIP 的 Unicode 文件名字段(0x7075),7-Zip、Linux 的 unzip 在别的语言的系统上也显示正确(`ZipWriter.cs`)。⑤23 个示例导出的工程在 mono 的 msbuild 里全部编译通过,输出目录和教材工程的 `bin\Debug` 一样,程序能启动;VS 2012 还要在机房电脑上实测。接口 `POST /api/export-project`,返回 zip,代码有错时 422,弹框说明"照机房电脑上的 VS 2012 编译"。 ## 技术风险与待确认事项 diff --git a/runtime/Sim.Runner/CompileHints.cs b/runtime/Sim.Runner/CompileHints.cs index 9a77d17..5280f64 100644 --- a/runtime/Sim.Runner/CompileHints.cs +++ b/runtime/Sim.Runner/CompileHints.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.CodeAnalysis; namespace Sim.Runner; @@ -23,7 +24,32 @@ internal static class CompileHints ("CS1002", _) => "C# 每条语句末尾要有英文分号 ;", ("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。", ("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。", + ("CS8026", _) => Vs2012(diagnostic), _ => null, }; } + + /// 导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改 + 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}。"); + } } diff --git a/runtime/Sim.Runner/CompileServer.cs b/runtime/Sim.Runner/CompileServer.cs index a5760e8..7f1f039 100644 --- a/runtime/Sim.Runner/CompileServer.cs +++ b/runtime/Sim.Runner/CompileServer.cs @@ -11,7 +11,8 @@ namespace Sim.Runner; /// /// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程, /// 成功时把 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。 /// 编译器常驻,第一次之后的编译不再有加载和预热开销。 /// internal static class CompileServer @@ -19,6 +20,7 @@ internal static class CompileServer public const string AssemblyFile = "StudentApp.dll"; public const string PdbFile = "StudentApp.pdb"; public const string ExportFile = "export.exe"; + public const string ExportProjectFile = "export.zip"; private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; @@ -40,7 +42,7 @@ internal static class CompileServer id = request["id"]?.DeepClone(); var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir"); 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); } catch (Exception ex) @@ -72,19 +74,33 @@ internal static class CompileServer } /// - /// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。 - /// name 是程序集名(exe 的名字),由服务器按工程取好。 + /// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile; + /// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。 + /// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。 /// - 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 result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, string.IsNullOrWhiteSpace(name) ? "上位机程序" : name); - if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe); + if (string.IsNullOrWhiteSpace(name)) name = "上位机程序"; + bool ok; + JsonArray diagnostics; + if (project) + { + var result = ProjectExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name); + if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportProjectFile), result.Zip); + (ok, diagnostics) = (result.Success, result.Diagnostics); + } + else + { + var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name); + if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe); + (ok, diagnostics) = (result.Success, result.Diagnostics); + } return new JsonObject { ["type"] = "compiled", - ["ok"] = result.Success, - ["diagnostics"] = result.Diagnostics, + ["ok"] = ok, + ["diagnostics"] = diagnostics, ["ms"] = watch.ElapsedMilliseconds, }; } diff --git a/runtime/Sim.Runner/ExeExporter.cs b/runtime/Sim.Runner/ExeExporter.cs index 9f1a69d..783d57f 100644 --- a/runtime/Sim.Runner/ExeExporter.cs +++ b/runtime/Sim.Runner/ExeExporter.cs @@ -34,7 +34,7 @@ internal static class ExeExporter /// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。 /// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。 /// - private const string MediaPlayerOcxState = + internal const string MediaPlayerOcxState = "AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" + "LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" + "ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" + @@ -51,9 +51,9 @@ internal static class ExeExporter private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles."; 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) .WithOptimizationLevel(OptimizationLevel.Release) .WithNullableContextOptions(NullableContextOptions.Disable) @@ -61,7 +61,7 @@ internal static class ExeExporter .WithConcurrentBuild(true); /// .NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj) - private static readonly Lazy> Framework = new(() => + internal static readonly Lazy> Framework = new(() => Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll") .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) .ToList()); @@ -69,9 +69,7 @@ internal static class ExeExporter /// libsDir:教材 dll 所在目录;galleryDir:平台素材库 public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName) { - var libs = TextbookLibs(libsDir); - if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll")) - throw new InvalidOperationException("服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能导出 EXE,请联系老师"); + var libs = TextbookLibs(libsDir, "导出 EXE"); var sources = StudentCompiler.SourceFiles(projectDir) .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)); } // 代码里按文件名用到的素材(如 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 media = ReferencedMedia(galleryDir, sources.Select(s => s.Text)); var trees = sources .Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path)) @@ -135,11 +125,33 @@ internal static class ExeExporter return new Result(emit.Success, pe.ToArray(), diagnostics); } + /// 代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序 + internal static List ReferencedMedia(string galleryDir, IEnumerable texts) + { + if (!Directory.Exists(galleryDir)) return new List(); + var all = texts.ToList(); + return Directory.GetFiles(galleryDir) + .Select(Path.GetFileName) + .OfType() + .Where(name => !name.Contains(".2x.") && name != "index.json") + .Where(name => all.Any(text => text.Contains(name + "\""))) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + } + /// - /// 教材 dll 的路径。目录或文件读不了时(服务器上拷进去的文件权限是 600,容器里的平台用户读不到)说明原因, - /// 不把英文的系统报错直接给学生看。 + /// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时 + /// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。 /// - 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? current = null; @@ -155,9 +167,9 @@ internal static class ExeExporter } catch (UnauthorizedAccessException) { - var what = current == null ? "目录" : Path.GetFileName(current); + var item = current == null ? "目录" : Path.GetFileName(current); 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)"); } } diff --git a/runtime/Sim.Runner/ProjectExporter.cs b/runtime/Sim.Runner/ProjectExporter.cs new file mode 100644 index 0000000..ffd5503 --- /dev/null +++ b/runtime/Sim.Runner/ProjectExporter.cs @@ -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; + +/// +/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。 +/// +/// 先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。 +/// 平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹; +/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。 +/// 教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。 +/// 文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。 +/// +/// +internal static class ProjectExporter +{ + public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics); + + /// VS 2012 的编译器支持到 C# 5 + 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); + + /// VS 2012 窗体应用模板默认的引用(教材工程都有这些) + 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); + + /// libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名) + 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() + : 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 + { + 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(), 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() + .Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType() + .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()); + } + + /// 工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间 + private static string RootNamespace(List 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; + } + + /// 代码里直接用到的类型、成员所在的程序集(程序集名) + private static HashSet DirectlyUsedAssemblies(CSharpCompilation compilation, List trees) + { + var names = new HashSet(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; + } + + /// 学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有) + private static bool UsesLedCompat(CSharpCompilation compilation, List trees) + { + foreach (var tree in trees) + { + var model = compilation.GetSemanticModel(tree); + foreach (var invocation in tree.GetRoot().DescendantNodes().OfType()) + { + if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true; + } + } + return false; + } + + /// 窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面 + private sealed record Form(string Path, string SubType, bool Resx, List OcxKeys); + + private static List
Forms(CSharpCompilation compilation, List sources, List trees) + { + var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form"); + var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl"); + var result = new List(); + 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() + .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() + : 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 code, List forms, + List frameworkReferences, List textbookReferences, List images, List media) + { + static string Win(string path) => path.Replace('/', '\\'); + static string X(string text) => SecurityElement.Escape(text); + + var s = new StringBuilder(); + s.Append($""" + + + + + Debug + AnyCPU + {ProjectGuid(name)} + WinExe + Properties + {X(rootNamespace)} + {X(name)} + v4.5 + 512 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + """); + foreach (var lib in textbookReferences) + { + s.Append($""" + + lib\{X(lib)} + + + """); + } + foreach (var reference in frameworkReferences) s.Append($" \n"); + s.Append(" \n \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($" \n {form.SubType}\n \n"); + } + else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs")) + { + s.Append($" \n {X(Path.GetFileName(path[..^12] + ".cs"))}\n \n"); + } + else + { + s.Append($" \n"); + } + } + s.Append(" \n"); + foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal)) + { + s.Append($" \n {X(Path.GetFileName(form.Path))}\n \n"); + } + s.Append(""" + + ResXFileCodeGenerator + Resources.Designer.cs + Designer + + + True + Resources.resx + True + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + Settings.settings + True + + + + + + + """); + var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList(); + if (files.Count > 0) + { + s.Append(" \n"); + foreach (var file in files) + { + // 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序 + s.Append(media.Contains(file) + ? $" \n PreserveNewest\n \n" + : $" \n"); + } + s.Append(" \n"); + } + s.Append(""" + + + """); + 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 + + """; + + /// 同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程) + 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")] + + """; + + /// 和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释) + private static string ResourcesDesigner(string ns, List 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 $$""" + + /// + /// 查找 {{what}} 类型的本地化资源。 + /// + internal static {{type}} {{item.Name}} { + get { + object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture); + return (({{type}})(obj)); + } + } + + """; + })); + return $$""" + //------------------------------------------------------------------------------ + // + // 此代码由工具生成。 + // 运行时版本:4.0.30319.42000 + // + // 对此文件的更改可能会导致不正确的行为,并且如果 + // 重新生成代码,这些更改将会丢失。 + // + //------------------------------------------------------------------------------ + + namespace {{ns}}.Properties { + using System; + + + /// + /// 一个强类型的资源类,用于查找本地化的字符串等。 + /// + // 此类是由 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() { + } + + /// + /// 返回此类使用的缓存的 ResourceManager 实例。 + /// + [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; + } + } + + /// + /// 使用此强类型资源类,为所有资源查找 + /// 重写当前线程的 CurrentUICulture 属性。 + /// + [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) => $$""" + //------------------------------------------------------------------------------ + // + // 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. + // + //------------------------------------------------------------------------------ + + 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 = """ + + + + + + + + """; + + private const string AppConfig = """ + + + + + + + """; + + /// + /// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着), + /// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。 + /// + 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 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 entries) + { + var list = entries.ToList(); + var s = new StringBuilder(ResxHeader.Value).Append('\n'); + if (list.Any(e => e.Contains("ResXFileRef"))) + { + s.Append(" \n"); + } + foreach (var entry in list) s.Append(entry); + return s.Append("").ToString(); + } + + private static string ResourceEntry(ResourceItem item) => + $" \n" + + $" ..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}\n" + + " \n"; + + /// 媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符 + 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 $" \n" + + " \n" + string.Join("\n", lines) + "\n\n \n"; + } + + /// 文本文件:带 BOM 的 UTF-8,CRLF 换行 + 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(); + } +} diff --git a/runtime/Sim.Runner/ProjectTemplate/ResxHeader.xml b/runtime/Sim.Runner/ProjectTemplate/ResxHeader.xml new file mode 100644 index 0000000..e875d9a --- /dev/null +++ b/runtime/Sim.Runner/ProjectTemplate/ResxHeader.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + diff --git a/runtime/Sim.Runner/Sim.Runner.csproj b/runtime/Sim.Runner/Sim.Runner.csproj index 44d0a30..189d85f 100644 --- a/runtime/Sim.Runner/Sim.Runner.csproj +++ b/runtime/Sim.Runner/Sim.Runner.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/runtime/Sim.Runner/ZipWriter.cs b/runtime/Sim.Runner/ZipWriter.cs new file mode 100644 index 0000000..ce36fd2 --- /dev/null +++ b/runtime/Sim.Runner/ZipWriter.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Sim.Runner; + +/// +/// 写 zip 文件。文件名照中文 Windows 的习惯用 GBK 编码(资源管理器自带的解压、老版本的压缩软件都按 GBK 读, +/// 用 UTF-8 时 Windows 7 等会显示乱码),另附 Info-ZIP 的 Unicode 文件名扩展字段(0x7075), +/// 7-Zip、Linux 的 unzip 等认这个字段的软件在别的语言的系统上也能显示正确。 +/// GBK 表示不了的文件名改用 UTF-8 并设置 UTF-8 标志位。 +/// +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 entries = new(); + private readonly ushort dosTime; + private readonly ushort dosDate; + + /// 文件的修改时间(机房电脑上解压后看到的时间) + 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); + } + + /// 添加文件,path 用 / 分隔 + public void Add(string path, byte[] data) + { + byte[] name; + byte[] extra = Array.Empty(); + 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); + } + + /// 写中央目录,返回整个 zip + 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; + } +} diff --git a/server/src/app.ts b/server/src/app.ts index 4d48060..6fac5db 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -27,7 +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 { type ExportKind, exportProgram, programName } from './export' import { serveStatic } from './static' 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))) - /** 导出 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', - }, + /** + * 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错 + * (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5) + */ + const EXPORTS: Record = { + exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' }, + project: { ext: 'zip', type: 'application/zip' }, + } + for (const kind of ['exe', 'project'] as const) { + app.post(`/api/export-${kind}`, async (c) => { + const { files, name } = await body<{ files: unknown; name: unknown }>(c) + if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400) + let result + try { + result = await exportProgram(files, programName(name), kind) + } catch (error) { + return c.json({ message: error instanceof Error ? error.message : String(error) }, 503) + } + if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422) + const { ext, type } = EXPORTS[kind] + return new Response(result.file, { + headers: { + 'Content-Type': type, + 'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`, + 'Cache-Control': 'no-store', + }, + }) }) - }) + } // 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查 const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics']) diff --git a/server/src/compile-service.ts b/server/src/compile-service.ts index d29b5dd..c5e5c76 100644 --- a/server/src/compile-service.ts +++ b/server/src/compile-service.ts @@ -9,8 +9,9 @@ export interface CompileResult { ms: number } -/** 导出的 exe 在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile) */ +/** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */ export const EXPORT_FILE = 'export.exe' +export const EXPORT_PROJECT_FILE = 'export.zip' type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string } @@ -37,6 +38,14 @@ class CompileService { 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 { + return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } }) + } + private async request(payload: object): Promise { await this.start() const id = this.nextId++ diff --git a/server/src/export-exe.ts b/server/src/export.ts similarity index 54% rename from server/src/export-exe.ts rename to server/src/export.ts index 47ffe81..be21264 100644 --- a/server/src/export-exe.ts +++ b/server/src/export.ts @@ -1,21 +1,24 @@ // 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。 +// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。 import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { Diagnostic, SourceFile } from '@iot/shared' -import { compileService, EXPORT_FILE } from './compile-service' +import { compileService, EXPORT_FILE, EXPORT_PROJECT_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[] } +export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] } -/** exe 的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */ -export function exeName(name: unknown) { +export type ExportKind = 'exe' | 'project' + +/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */ +export function programName(name: unknown) { const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : '' return cleaned || '上位机程序' } /** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */ -export async function exportExe(files: SourceFile[], name: string): Promise { +export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise { await mkdir(config.runsDir, { recursive: true }) const dir = await mkdtemp(join(config.runsDir, 'export-')) try { @@ -24,9 +27,9 @@ export async function exportExe(files: SourceFile[], name: string): Promise { }, 60000) }) -describe('导出 EXE', () => { +describe('导出 EXE、VS 工程', () => { 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( - new Request('http://localhost/api/export-exe', { + new Request(`http://localhost/api/export-${kind}`, { method: 'POST', headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip }, body: JSON.stringify(json), @@ -775,6 +777,104 @@ describe('导出 EXE', () => { 60000, ) + /** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */ + function unzip(zip: Uint8Array) { + 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>() + 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('寝室情景模式') + expect(csproj).toContain('寝室') + expect(csproj).toContain('v4.5') + // 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录 + expect(csproj).toContain('lib\\DeviceCtrlLibrary.dll') + expect(csproj).toContain('lib\\AxInterop.WMPLib.dll') + expect(csproj).not.toContain('ICS.Models') + expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true) + expect(csproj).toContain('\r\n Form') + expect(csproj).toContain('\r\n PreserveNewest') + expect(csproj).toContain('') + expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true) + expect(text('寝室/寝室/Properties/Resources.resx')).toContain('..\\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('') + 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('') + + // 机房的 VS 2012 只支持 C# 5 + const login = (await loadSample('p1-t1-登录'))! + const newer = login.map((f) => + f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f, + ) + const failed = await exportRequest(t, 'project', { files: newer, name: '登录' }) + expect(failed.status).toBe(422) + const diagnostics = ((await failed.json()) as any).diagnostics + expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026']) + expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') }) + // 导出 EXE 不受这个限制 + expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200) + }, + 60000, + ) + test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => { const t = await teacher() const saved = config.textbookLibsDir @@ -783,9 +883,13 @@ describe('导出 EXE', () => { const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' }) expect(response.status).toBe(503) expect(((await response.json()) as any).message).toContain('设备类库') + const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' }) + expect(project.status).toBe(503) + expect(((await project.json()) as any).message).toContain('不能导出工程') } finally { config.textbookLibsDir = saved } expect((await exportExe(t, { files: 'x' })).status).toBe(400) + expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400) }, 60000) }) diff --git a/web/src/editor/export-exe.ts b/web/src/editor/export.ts similarity index 63% rename from web/src/editor/export-exe.ts rename to web/src/editor/export.ts index 54dee6c..252ff37 100644 --- a/web/src/editor/export-exe.ts +++ b/web/src/editor/export.ts @@ -1,18 +1,23 @@ -// 导出 EXE:服务器照教材工程(真机版设备类库)编译,下载一个 exe,在机房电脑上双击就能控制真的实训台。 +// 导出:服务器照教材工程(真机版设备类库)编译,下载到机房电脑上用。 +// EXE:一个 exe,双击就能控制真的实训台;VS 工程:教材那样的 VS 2012 工程(zip),用 VS 打开接着改。 import type { Diagnostic, SourceFile } from '@iot/shared' import { apiResponse, ApiError } from '../api' import { projectNamespace } from '../designer/templates' +export type ExportKind = 'exe' | 'project' + +const EXT: Record = { exe: 'exe', project: 'zip' } + /** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */ -export async function downloadExe(files: SourceFile[]): Promise { +export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise { let response: Response 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) { 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 name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.${EXT[kind]}` const url = URL.createObjectURL(await response.blob()) const link = document.createElement('a') link.href = url diff --git a/web/src/pages/HelpPage.vue b/web/src/pages/HelpPage.vue index a32c27f..77e9621 100644 --- a/web/src/pages/HelpPage.vue +++ b/web/src/pages/HelpPage.vue @@ -168,16 +168,24 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果
-

8. 导出 EXE,在机房电脑上运行

+

8. 导出 EXE 或 VS 工程,在机房电脑上运行

  • - 点工具栏的导出 EXE,下载一个 + 点工具栏的导出→导出 EXE,下载一个 exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制真的设备。
  • 导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。
  • 设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。
  • 项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。
  • 第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点更多信息→仍要运行。
  • +
  • + 想在机房的 VS 里接着改,点导出→导出 VS 工程,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5 + 运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。 +
  • +
  • + 机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如 + $"温度{t}")会弹出报错和改法,改好再导出。 +
diff --git a/web/src/pages/WorkbenchPage.vue b/web/src/pages/WorkbenchPage.vue index 7f172fc..f94fade 100644 --- a/web/src/pages/WorkbenchPage.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -4,7 +4,21 @@ // 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui' +import { + NButton, + NDropdown, + NInput, + NModal, + NPopconfirm, + NPopover, + NSelect, + NTab, + NTabs, + NTag, + useMessage, + type DropdownOption, + type SelectGroupOption, +} from 'naive-ui' import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared' import FormDesigner from '../designer/FormDesigner.vue' import { createDesignerHistory } from '../designer/history' @@ -25,7 +39,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 { downloadExport, type ExportKind } from '../editor/export' import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works' import RunArea from '../run/RunArea.vue' import { createRunSession } from '../run/session' @@ -479,22 +493,37 @@ async function showDiagnostic(file: string, line: number, column: number) { editorRef.value?.reveal(file, line, column) } -// ---------- 导出 EXE:照真机版类库编译,在机房电脑上双击运行 ---------- +// ---------- 导出:照真机版类库编译,EXE 在机房电脑上双击运行,VS 工程用机房的 VS 2012 打开 ---------- const exporting = ref(false) /** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */ const exportErrors = ref([]) const exportErrorsOpen = ref(false) +const exportKind = ref('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 = { + exe: '已导出,在机房电脑上双击运行(要接真的实训台)', + project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)', +} + +async function exportProgram(kind: ExportKind) { if (exporting.value) return exporting.value = true + exportKind.value = kind try { - const errors = await downloadExe(files.value.map((f) => ({ ...f }))) + const errors = await downloadExport( + kind, + files.value.map((f) => ({ ...f })), + ) if (errors) { exportErrors.value = errors exportErrorsOpen.value = true } else { - message.success('已导出,在机房电脑上双击运行(要接真的实训台)') + message.success(EXPORT_DONE[kind]) } } catch (error) { message.error(error instanceof Error ? error.message : String(error)) @@ -630,9 +659,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer)) 任务 - - 导出 EXE - + + 导出 ▾ +