导出 VS 工程:实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。导出的 zip 是教材那样的 VS 2012 窗体应用工程(.sln + .csproj,.NET Framework 4.5),在机房电脑上解压、双击 .sln 用 VS 打开接着改,按 F5 运行。导出前照机房环境编译一遍要放进 zip 的代码:C# 5(VS 2012 的编译器)、真机版设备类库,有错不导出;新版 C# 写法报 CS8026,按功能名给出改法(字符串插值改 string.Format、?. 改先判断 null、=> 改大括号等 12 种)。导出 EXE 仍按最新 C#。平台的 Properties/Resources.Designer.cs 换成 VS 生成的写法和 Resources.resx,图片放 Resources\;代码里按文件名用的素材(ring.mp3)复制到输出目录,运行时在 bin\Debug\Resources\;每个窗体带 .resx(媒体播放器 OcxState 用教材默认状态),SubType、DependentUpon 和 VS 一样;补上 AssemblyInfo、Settings、App.config。教材 dll 全放 lib\,只引用代码直接用到的(和教材工程一样),只带这些编译不过时引用全部;用到书上的 ledHelper.Close() 时带上 LedHelperCompat.cs。文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 文件名用 GBK,另附 Info-ZIP Unicode 文件名字段(0x7075),中文 Windows 自带解压和 7-Zip 都显示正确(ZipWriter.cs)。23 个示例都能导出,导出的工程在 mono 的 msbuild 里全部编译通过、输出目录和教材 bin\Debug 一样、程序能启动;VS 2012 还要在机房实测。接口 POST /api/export-project,代码有错时 422。export-exe.ts 改名 export.ts(服务器和网页各一个)。使用说明、设计方案、README、部署说明同步
Deploy / deploy (push) Failing after 1m8s
Deploy / deploy (push) Failing after 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c3ce31fe1
commit
2aa267839f
17 files changed
+1196
-67
No files matched your search
@@ -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)。
|
||||
|
||||
+2
-2
@@ -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 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
|
||||
|
||||
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
|
||||
|
||||
|
||||
+2
-1
@@ -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 编译"。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
|
||||
private static readonly (string Feature, string Fix)[] NewerFeatures =
|
||||
{
|
||||
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
|
||||
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
|
||||
("nameof", "nameof(x) 直接写成字符串 \"x\""),
|
||||
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
|
||||
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
|
||||
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
|
||||
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
|
||||
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
|
||||
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
|
||||
("default 字面量", "default 写成 default(类型),例如 default(int)"),
|
||||
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
|
||||
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
|
||||
};
|
||||
|
||||
private static string Vs2012(Diagnostic diagnostic)
|
||||
{
|
||||
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
|
||||
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
|
||||
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ namespace Sim.Runner;
|
||||
/// <summary>
|
||||
/// 常驻编译服务:从标准输入读取 {"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。
|
||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。
|
||||
/// name 是程序集名(exe 的名字),由服务器按工程取好。
|
||||
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
|
||||
/// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
|
||||
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
|
||||
/// </summary>
|
||||
private static JsonObject Export(string dir, string libs, string name)
|
||||
private static JsonObject Export(string dir, bool project, string libs, string name)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, string.IsNullOrWhiteSpace(name) ? "上位机程序" : name);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ internal static class ExeExporter
|
||||
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
|
||||
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
|
||||
private static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
|
||||
internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
|
||||
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
|
||||
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
|
||||
.ToList());
|
||||
@@ -69,9 +69,7 @@ internal static class ExeExporter
|
||||
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
|
||||
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<string>()
|
||||
.Where(name => !name.Contains(".2x.") && name != "index.json")
|
||||
.Where(name => sources.Any(s => s.Text.Contains(name + "\"")))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: new List<string>();
|
||||
var 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);
|
||||
}
|
||||
|
||||
/// <summary>代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序</summary>
|
||||
internal static List<string> ReferencedMedia(string galleryDir, IEnumerable<string> texts)
|
||||
{
|
||||
if (!Directory.Exists(galleryDir)) return new List<string>();
|
||||
var all = texts.ToList();
|
||||
return Directory.GetFiles(galleryDir)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.Where(name => !name.Contains(".2x.") && name != "index.json")
|
||||
.Where(name => all.Any(text => text.Contains(name + "\"")))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 教材 dll 的路径。目录或文件读不了时(服务器上拷进去的文件权限是 600,容器里的平台用户读不到)说明原因,
|
||||
/// 不把英文的系统报错直接给学生看。
|
||||
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
|
||||
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
|
||||
/// </summary>
|
||||
private static string[] TextbookLibs(string libsDir)
|
||||
internal static string[] TextbookLibs(string libsDir, string what)
|
||||
{
|
||||
var libs = ReadableLibs(libsDir, what);
|
||||
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
|
||||
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
|
||||
return libs;
|
||||
}
|
||||
|
||||
private static string[] ReadableLibs(string libsDir, string what)
|
||||
{
|
||||
if (!Directory.Exists(libsDir)) return Array.Empty<string>();
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。
|
||||
/// <list type="bullet">
|
||||
/// <item>先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。</item>
|
||||
/// <item>平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹;
|
||||
/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。</item>
|
||||
/// <item>教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。</item>
|
||||
/// <item>文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class ProjectExporter
|
||||
{
|
||||
public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics);
|
||||
|
||||
/// <summary>VS 2012 的编译器支持到 C# 5</summary>
|
||||
internal static readonly CSharpParseOptions Vs2012 = new(LanguageVersion.CSharp5);
|
||||
|
||||
private const string PlatformResourcesFile = "Properties/Resources.Designer.cs";
|
||||
private const string LedCompatFile = "LedHelperCompat.cs";
|
||||
private const string LedCompatClass = "LedHelperCompat";
|
||||
|
||||
private static readonly Regex ResourceMember = new(
|
||||
@"internal static (System\.Drawing\.Bitmap|byte\[\]) ([\p{L}\p{N}_]+) \{\s*get \{ return global::Sim\.Host\.ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\); \}");
|
||||
private static readonly Regex ResourcesNamespace = new(@"namespace\s+([\p{L}\p{N}_.]+)\.Properties\b");
|
||||
private static readonly Regex FirstNamespace = new(@"^\s*namespace\s+([\p{L}\p{N}_.]+)", RegexOptions.Multiline);
|
||||
|
||||
/// <summary>VS 2012 窗体应用模板默认的引用(教材工程都有这些)</summary>
|
||||
private static readonly string[] DefaultReferences =
|
||||
{
|
||||
"System", "System.Core", "System.Xml.Linq", "System.Data.DataSetExtensions", "Microsoft.CSharp",
|
||||
"System.Data", "System.Deployment", "System.Drawing", "System.Windows.Forms", "System.Xml",
|
||||
};
|
||||
|
||||
private const string BitmapType = "System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a";
|
||||
private const string BytesType = "System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
|
||||
private sealed record ResourceItem(string Name, bool Image, string File);
|
||||
|
||||
private sealed record Source(string Path, string Text);
|
||||
|
||||
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名)</summary>
|
||||
public static Result Export(string projectDir, string libsDir, string galleryDir, string name)
|
||||
{
|
||||
var libs = ExeExporter.TextbookLibs(libsDir, "导出工程");
|
||||
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||
.Select(path => new Source(Path.GetRelativePath(projectDir, path).Replace('\\', '/'), File.ReadAllText(path)))
|
||||
.ToList();
|
||||
|
||||
var platformResources = sources.FirstOrDefault(s => s.Path == PlatformResourcesFile)?.Text;
|
||||
var items = platformResources == null
|
||||
? new List<ResourceItem>()
|
||||
: ResourceMember.Matches(platformResources)
|
||||
.Select(m => new ResourceItem(m.Groups[2].Value, m.Groups[1].Value != "byte[]", Path.GetFileName(m.Groups[3].Value)))
|
||||
.ToList();
|
||||
var rootNamespace = RootNamespace(sources, platformResources, name);
|
||||
sources.RemoveAll(s => s.Path == PlatformResourcesFile);
|
||||
|
||||
// VS 模板里有、平台上没有的文件(Properties 文件夹由平台管理,学生建不了同名文件)
|
||||
var generated = new List<Source>
|
||||
{
|
||||
new("Properties/AssemblyInfo.cs", AssemblyInfo(name)),
|
||||
new("Properties/Resources.Designer.cs", ResourcesDesigner(rootNamespace, items)),
|
||||
new("Properties/Settings.Designer.cs", SettingsDesigner(rootNamespace)),
|
||||
};
|
||||
var compat = new Source(LedCompatFile, LedCompat);
|
||||
|
||||
// 照机房的环境编译一遍:C# 5、.NET Framework 4.5、真机版设备类库
|
||||
var trees = sources.Concat(generated).Append(compat)
|
||||
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), Vs2012, path: s.Path))
|
||||
.ToList();
|
||||
var libReferences = libs.ToDictionary(p => (MetadataReference)MetadataReference.CreateFromFile(p), p => p);
|
||||
var compilation = CSharpCompilation.Create(name, trees, ExeExporter.Framework.Value.Concat(libReferences.Keys), ExeExporter.Options);
|
||||
using (var pe = new MemoryStream())
|
||||
{
|
||||
var emit = compilation.Emit(pe);
|
||||
var diagnostics = new JsonArray();
|
||||
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||
{
|
||||
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||
}
|
||||
if (!emit.Success) return new Result(false, Array.Empty<byte>(), diagnostics);
|
||||
}
|
||||
|
||||
var studentTrees = trees.Take(sources.Count).ToList();
|
||||
var code = sources.Concat(generated).ToList();
|
||||
if (UsesLedCompat(compilation, studentTrees))
|
||||
{
|
||||
code.Add(compat);
|
||||
studentTrees.Add(trees[^1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
compilation = compilation.RemoveSyntaxTrees(trees[^1]);
|
||||
}
|
||||
|
||||
// 引用:和教材工程一样,教材 dll 只引用代码里直接用到的(其他的作为依赖由 VS 复制到输出目录),
|
||||
// 框架引用在 VS 模板默认的之外加上用到的(如图表控件)。只带这些引用编译不过时(用到的类型的基类在别的 dll 里等)引用全部教材 dll
|
||||
var direct = DirectlyUsedAssemblies(compilation, studentTrees);
|
||||
var frameworkReferences = DefaultReferences
|
||||
.Concat(ExeExporter.Framework.Value.OfType<PortableExecutableReference>()
|
||||
.Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType<string>()
|
||||
.Where(n => direct.Contains(n) && n != "mscorlib" && !DefaultReferences.Contains(n))
|
||||
.OrderBy(n => n, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
var textbookReferences = libs.Where(p => direct.Contains(Path.GetFileNameWithoutExtension(p))).ToList();
|
||||
var reduced = compilation.WithReferences(ExeExporter.Framework.Value.Concat(libReferences.Where(r => textbookReferences.Contains(r.Value)).Select(r => r.Key)));
|
||||
if (reduced.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) textbookReferences = libs.ToList();
|
||||
textbookReferences = textbookReferences.Select(p => Path.GetFileName(p)).ToList();
|
||||
|
||||
var forms = Forms(compilation, sources, studentTrees);
|
||||
var media = ExeExporter.ReferencedMedia(galleryDir, sources.Select(s => s.Text));
|
||||
var images = items.Select(i => i.File).Where(f => File.Exists(Path.Combine(galleryDir, f))).Distinct().ToList();
|
||||
|
||||
var zip = new ZipWriter(DateTime.UtcNow.AddHours(8));
|
||||
var root = $"{name}/";
|
||||
var dir = $"{name}/{name}/";
|
||||
zip.Add(root + name + ".sln", Text(Solution(name)));
|
||||
zip.Add(dir + name + ".csproj", Text(Project(name, rootNamespace, code, forms, frameworkReferences, textbookReferences, images, media)));
|
||||
zip.Add(dir + "App.config", Text(AppConfig));
|
||||
zip.Add(dir + "Properties/Settings.settings", Text(SettingsFile));
|
||||
zip.Add(dir + "Properties/Resources.resx", Text(Resx(items.Select(ResourceEntry))));
|
||||
foreach (var source in code) zip.Add(dir + source.Path, Text(source.Text));
|
||||
foreach (var form in forms.Where(f => f.Resx))
|
||||
{
|
||||
zip.Add(dir + Path.ChangeExtension(form.Path, ".resx"), Text(Resx(form.OcxKeys.Select(OcxEntry))));
|
||||
}
|
||||
foreach (var file in images.Union(media).OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
zip.Add(dir + "Resources/" + file, File.ReadAllBytes(Path.Combine(galleryDir, file)));
|
||||
}
|
||||
foreach (var lib in libs) zip.Add(dir + "lib/" + Path.GetFileName(lib), File.ReadAllBytes(lib));
|
||||
return new Result(true, zip.Finish(), new JsonArray());
|
||||
}
|
||||
|
||||
/// <summary>工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间</summary>
|
||||
private static string RootNamespace(List<Source> sources, string? platformResources, string name)
|
||||
{
|
||||
if (platformResources != null && ResourcesNamespace.Match(platformResources) is { Success: true } r) return r.Groups[1].Value;
|
||||
foreach (var source in sources.OrderBy(s => s.Path == "Program.cs" ? 0 : 1).ThenBy(s => s.Path, StringComparer.Ordinal))
|
||||
{
|
||||
var m = FirstNamespace.Match(source.Text);
|
||||
if (m.Success) return m.Groups[1].Value;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>代码里直接用到的类型、成员所在的程序集(程序集名)</summary>
|
||||
private static HashSet<string> DirectlyUsedAssemblies(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
void Add(ISymbol? symbol)
|
||||
{
|
||||
while (symbol is IArrayTypeSymbol array) symbol = array.ElementType;
|
||||
if (symbol?.ContainingAssembly is { } assembly) names.Add(assembly.Identity.Name);
|
||||
if (symbol is INamedTypeSymbol { IsGenericType: true } generic)
|
||||
{
|
||||
foreach (var argument in generic.TypeArguments) Add(argument);
|
||||
}
|
||||
}
|
||||
foreach (var tree in trees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var node in tree.GetRoot().DescendantNodes())
|
||||
{
|
||||
Add(model.GetSymbolInfo(node).Symbol);
|
||||
Add(model.GetTypeInfo(node).Type);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有)</summary>
|
||||
private static bool UsesLedCompat(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||
{
|
||||
foreach (var tree in trees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var invocation in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面</summary>
|
||||
private sealed record Form(string Path, string SubType, bool Resx, List<string> OcxKeys);
|
||||
|
||||
private static List<Form> Forms(CSharpCompilation compilation, List<Source> sources, List<SyntaxTree> trees)
|
||||
{
|
||||
var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form");
|
||||
var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl");
|
||||
var result = new List<Form>();
|
||||
for (var i = 0; i < sources.Count; i++)
|
||||
{
|
||||
var path = sources[i].Path;
|
||||
if (path.EndsWith(".Designer.cs")) continue;
|
||||
var designer = sources.FirstOrDefault(s => s.Path == path[..^3] + ".Designer.cs");
|
||||
var model = compilation.GetSemanticModel(trees[i]);
|
||||
var type = trees[i].GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
|
||||
.Select(c => model.GetDeclaredSymbol(c)).FirstOrDefault(t => t != null);
|
||||
string? subType = null;
|
||||
for (var t = type?.BaseType; t != null && subType == null; t = t.BaseType)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(t, form)) subType = "Form";
|
||||
else if (SymbolEqualityComparer.Default.Equals(t, userControl)) subType = "UserControl";
|
||||
}
|
||||
if (subType == null) continue;
|
||||
var keys = designer == null
|
||||
? new List<string>()
|
||||
: ExeExporter.OcxStateKey.Matches(designer.Text).Select(m => m.Groups[1].Value).Distinct().OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
result.Add(new Form(path, subType, designer != null, keys));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Project(string name, string rootNamespace, List<Source> code, List<Form> forms,
|
||||
List<string> frameworkReferences, List<string> textbookReferences, List<string> images, List<string> media)
|
||||
{
|
||||
static string Win(string path) => path.Replace('/', '\\');
|
||||
static string X(string text) => SecurityElement.Escape(text);
|
||||
|
||||
var s = new StringBuilder();
|
||||
s.Append($"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{ProjectGuid(name)}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>{X(rootNamespace)}</RootNamespace>
|
||||
<AssemblyName>{X(name)}</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
""");
|
||||
foreach (var lib in textbookReferences)
|
||||
{
|
||||
s.Append($"""
|
||||
<Reference Include="{X(Path.GetFileNameWithoutExtension(lib))}">
|
||||
<HintPath>lib\{X(lib)}</HintPath>
|
||||
</Reference>
|
||||
|
||||
""");
|
||||
}
|
||||
foreach (var reference in frameworkReferences) s.Append($" <Reference Include=\"{reference}\" />\n");
|
||||
s.Append(" </ItemGroup>\n <ItemGroup>\n");
|
||||
|
||||
var formPaths = forms.ToDictionary(f => f.Path);
|
||||
// 和 VS 一样 Form1.cs 在 Form1.Designer.cs 前面
|
||||
static string Order(string path) => path.EndsWith(".Designer.cs") ? path[..^12] + ".cs\u0001" : path;
|
||||
foreach (var source in code.Where(c => !c.Path.StartsWith("Properties/")).OrderBy(c => Order(c.Path), StringComparer.Ordinal))
|
||||
{
|
||||
var path = source.Path;
|
||||
if (formPaths.TryGetValue(path, out var form))
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <SubType>{form.SubType}</SubType>\n </Compile>\n");
|
||||
}
|
||||
else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs"))
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <DependentUpon>{X(Path.GetFileName(path[..^12] + ".cs"))}</DependentUpon>\n </Compile>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
s.Append($" <Compile Include=\"{X(Win(path))}\" />\n");
|
||||
}
|
||||
}
|
||||
s.Append(" <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n");
|
||||
foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal))
|
||||
{
|
||||
s.Append($" <EmbeddedResource Include=\"{X(Win(Path.ChangeExtension(form.Path, ".resx")))}\">\n <DependentUpon>{X(Path.GetFileName(form.Path))}</DependentUpon>\n </EmbeddedResource>\n");
|
||||
}
|
||||
s.Append("""
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
<SubType>Designer</SubType>
|
||||
</EmbeddedResource>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
<DesignTime>True</DesignTime>
|
||||
</Compile>
|
||||
<None Include="Properties\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||
</None>
|
||||
<Compile Include="Properties\Settings.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
</ItemGroup>
|
||||
|
||||
""");
|
||||
var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
if (files.Count > 0)
|
||||
{
|
||||
s.Append(" <ItemGroup>\n");
|
||||
foreach (var file in files)
|
||||
{
|
||||
// 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序
|
||||
s.Append(media.Contains(file)
|
||||
? $" <Content Include=\"Resources\\{X(file)}\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n"
|
||||
: $" <None Include=\"Resources\\{X(file)}\" />\n");
|
||||
}
|
||||
s.Append(" </ItemGroup>\n");
|
||||
}
|
||||
s.Append("""
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
""");
|
||||
return s.ToString();
|
||||
}
|
||||
|
||||
private static string Solution(string name) => $$"""
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 2012
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "{{name}}", "{{name}}\{{name}}.csproj", "{{ProjectGuid(name)}}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{{ProjectGuid(name)}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{{ProjectGuid(name)}}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{{ProjectGuid(name)}}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{{ProjectGuid(name)}}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
""";
|
||||
|
||||
/// <summary>同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程)</summary>
|
||||
private static string ProjectGuid(string name) => "{" + NameGuid("project:" + name).ToString().ToUpperInvariant() + "}";
|
||||
|
||||
private static Guid NameGuid(string text) => new(MD5.HashData(Encoding.UTF8.GetBytes(text)));
|
||||
|
||||
private static string AssemblyInfo(string name) => $$"""
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// 有关程序集的常规信息通过以下
|
||||
// 特性集控制。更改这些特性值可修改
|
||||
// 与程序集关联的信息。
|
||||
[assembly: AssemblyTitle({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||
[assembly: AssemblyCopyright("Copyright © {{DateTime.UtcNow.AddHours(8).Year}}")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// 将 ComVisible 设置为 false 使此程序集中的类型
|
||||
// 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型,
|
||||
// 则将该类型上的 ComVisible 特性设置为 true。
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID
|
||||
[assembly: Guid("{{NameGuid("assembly:" + name)}}")]
|
||||
|
||||
// 程序集的版本信息由下面四个值组成:
|
||||
//
|
||||
// 主版本
|
||||
// 次版本
|
||||
// 生成号
|
||||
// 修订号
|
||||
//
|
||||
// 可以指定所有这些值,也可以使用“生成号”和“修订号”的默认值,
|
||||
// 方法是按如下所示使用“*”:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
|
||||
""";
|
||||
|
||||
/// <summary>和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释)</summary>
|
||||
private static string ResourcesDesigner(string ns, List<ResourceItem> items)
|
||||
{
|
||||
var members = string.Concat(items.Select(item =>
|
||||
{
|
||||
var type = item.Image ? "System.Drawing.Bitmap" : "byte[]";
|
||||
var what = item.Image ? "System.Drawing.Bitmap" : "System.Byte[]";
|
||||
return $$"""
|
||||
|
||||
/// <summary>
|
||||
/// 查找 {{what}} 类型的本地化资源。
|
||||
/// </summary>
|
||||
internal static {{type}} {{item.Name}} {
|
||||
get {
|
||||
object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture);
|
||||
return (({{type}})(obj));
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
}));
|
||||
return $$"""
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// 此代码由工具生成。
|
||||
// 运行时版本:4.0.30319.42000
|
||||
//
|
||||
// 对此文件的更改可能会导致不正确的行为,并且如果
|
||||
// 重新生成代码,这些更改将会丢失。
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace {{ns}}.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 一个强类型的资源类,用于查找本地化的字符串等。
|
||||
/// </summary>
|
||||
// 此类是由 StronglyTypedResourceBuilder
|
||||
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
|
||||
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
|
||||
// (以 /str 作为命令选项),或重新生成 VS 项目。
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回此类使用的缓存的 ResourceManager 实例。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("{{ns}}.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用此强类型资源类,为所有资源查找
|
||||
/// 重写当前线程的 CurrentUICulture 属性。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
{{members}} }
|
||||
}
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
private static string SettingsDesigner(string ns) => $$"""
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace {{ns}}.Properties
|
||||
{
|
||||
|
||||
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
|
||||
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
|
||||
{
|
||||
|
||||
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||
|
||||
public static Settings Default
|
||||
{
|
||||
get
|
||||
{
|
||||
return defaultInstance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
private const string SettingsFile = """
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
|
||||
<Profiles>
|
||||
<Profile Name="(Default)" />
|
||||
</Profiles>
|
||||
<Settings />
|
||||
</SettingsFile>
|
||||
""";
|
||||
|
||||
private const string AppConfig = """
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
|
||||
</startup>
|
||||
</configuration>
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||
/// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。
|
||||
/// </summary>
|
||||
private const string LedCompat = """
|
||||
// 教材的设备类库里 LEDHelper 没有 Close 方法(LED 屏每次发送时才打开串口,发完就关),
|
||||
// 书上的代码调用了 ledHelper.Close(),这里补一个什么都不做的方法,让代码能编译。
|
||||
internal static class LedHelperCompat
|
||||
{
|
||||
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
private static readonly Lazy<string> ResxHeader = new(() =>
|
||||
{
|
||||
using var stream = typeof(ProjectExporter).Assembly.GetManifestResourceStream("ResxHeader.xml")!;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
return reader.ReadToEnd().TrimEnd('\n');
|
||||
});
|
||||
|
||||
private static string Resx(IEnumerable<string> entries)
|
||||
{
|
||||
var list = entries.ToList();
|
||||
var s = new StringBuilder(ResxHeader.Value).Append('\n');
|
||||
if (list.Any(e => e.Contains("ResXFileRef")))
|
||||
{
|
||||
s.Append(" <assembly alias=\"System.Windows.Forms\" name=\"System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\" />\n");
|
||||
}
|
||||
foreach (var entry in list) s.Append(entry);
|
||||
return s.Append("</root>").ToString();
|
||||
}
|
||||
|
||||
private static string ResourceEntry(ResourceItem item) =>
|
||||
$" <data name=\"{SecurityElement.Escape(item.Name)}\" type=\"System.Resources.ResXFileRef, System.Windows.Forms\">\n" +
|
||||
$" <value>..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}</value>\n" +
|
||||
" </data>\n";
|
||||
|
||||
/// <summary>媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符</summary>
|
||||
private static string OcxEntry(string key)
|
||||
{
|
||||
var data = ExeExporter.MediaPlayerOcxState;
|
||||
var lines = Enumerable.Range(0, (data.Length + 79) / 80).Select(i => " " + data.Substring(i * 80, Math.Min(80, data.Length - i * 80)));
|
||||
return $" <data name=\"{SecurityElement.Escape(key)}\" mimetype=\"application/x-microsoft.net.object.binary.base64\">\n" +
|
||||
" <value>\n" + string.Join("\n", lines) + "\n</value>\n </data>\n";
|
||||
}
|
||||
|
||||
/// <summary>文本文件:带 BOM 的 UTF-8,CRLF 换行</summary>
|
||||
private static byte[] Text(string text)
|
||||
{
|
||||
var crlf = text.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(crlf)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
@@ -28,6 +28,10 @@
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 写 zip 文件。文件名照中文 Windows 的习惯用 GBK 编码(资源管理器自带的解压、老版本的压缩软件都按 GBK 读,
|
||||
/// 用 UTF-8 时 Windows 7 等会显示乱码),另附 Info-ZIP 的 Unicode 文件名扩展字段(0x7075),
|
||||
/// 7-Zip、Linux 的 unzip 等认这个字段的软件在别的语言的系统上也能显示正确。
|
||||
/// GBK 表示不了的文件名改用 UTF-8 并设置 UTF-8 标志位。
|
||||
/// </summary>
|
||||
internal sealed class ZipWriter
|
||||
{
|
||||
private static readonly Encoding Gbk;
|
||||
private static readonly uint[] CrcTable = new uint[256];
|
||||
|
||||
static ZipWriter()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
Gbk = Encoding.GetEncoding(936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
||||
for (uint n = 0; n < 256; n++)
|
||||
{
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
|
||||
CrcTable[n] = c;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Entry(byte[] Name, byte[] Extra, ushort Flags, ushort Method, uint Crc, uint CompressedSize, uint Size, uint Offset);
|
||||
|
||||
private readonly MemoryStream output = new();
|
||||
private readonly List<Entry> entries = new();
|
||||
private readonly ushort dosTime;
|
||||
private readonly ushort dosDate;
|
||||
|
||||
/// <param name="time">文件的修改时间(机房电脑上解压后看到的时间)</param>
|
||||
public ZipWriter(DateTime time)
|
||||
{
|
||||
dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
|
||||
dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
|
||||
}
|
||||
|
||||
/// <summary>添加文件,path 用 / 分隔</summary>
|
||||
public void Add(string path, byte[] data)
|
||||
{
|
||||
byte[] name;
|
||||
byte[] extra = Array.Empty<byte>();
|
||||
ushort flags = 0;
|
||||
try
|
||||
{
|
||||
name = Gbk.GetBytes(path);
|
||||
var utf8 = Encoding.UTF8.GetBytes(path);
|
||||
if (utf8.Length != name.Length || !utf8.AsSpan().SequenceEqual(name))
|
||||
{
|
||||
// Unicode Path Extra Field:版本 1、GBK 文件名的 CRC、UTF-8 文件名
|
||||
extra = new byte[4 + 1 + 4 + utf8.Length];
|
||||
BitConverter.TryWriteBytes(extra.AsSpan(0), (ushort)0x7075);
|
||||
BitConverter.TryWriteBytes(extra.AsSpan(2), (ushort)(1 + 4 + utf8.Length));
|
||||
extra[4] = 1;
|
||||
BitConverter.TryWriteBytes(extra.AsSpan(5), Crc32(name));
|
||||
utf8.CopyTo(extra, 9);
|
||||
}
|
||||
}
|
||||
catch (EncoderFallbackException)
|
||||
{
|
||||
name = Encoding.UTF8.GetBytes(path);
|
||||
flags = 1 << 11;
|
||||
}
|
||||
|
||||
var crc = Crc32(data);
|
||||
var compressed = Deflate(data);
|
||||
var method = (ushort)8;
|
||||
if (compressed.Length >= data.Length)
|
||||
{
|
||||
compressed = data;
|
||||
method = 0;
|
||||
}
|
||||
|
||||
var entry = new Entry(name, extra, flags, method, crc, (uint)compressed.Length, (uint)data.Length, (uint)output.Position);
|
||||
entries.Add(entry);
|
||||
using var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
|
||||
w.Write(0x04034b50u);
|
||||
w.Write((ushort)20);
|
||||
w.Write(entry.Flags);
|
||||
w.Write(entry.Method);
|
||||
w.Write(dosTime);
|
||||
w.Write(dosDate);
|
||||
w.Write(entry.Crc);
|
||||
w.Write(entry.CompressedSize);
|
||||
w.Write(entry.Size);
|
||||
w.Write((ushort)entry.Name.Length);
|
||||
w.Write((ushort)entry.Extra.Length);
|
||||
w.Write(entry.Name);
|
||||
w.Write(entry.Extra);
|
||||
w.Write(compressed);
|
||||
}
|
||||
|
||||
/// <summary>写中央目录,返回整个 zip</summary>
|
||||
public byte[] Finish()
|
||||
{
|
||||
var start = (uint)output.Position;
|
||||
using (var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
foreach (var e in entries)
|
||||
{
|
||||
w.Write(0x02014b50u);
|
||||
w.Write((ushort)20); // 由 MS-DOS 兼容的系统生成:解压时不设置 Unix 权限
|
||||
w.Write((ushort)20);
|
||||
w.Write(e.Flags);
|
||||
w.Write(e.Method);
|
||||
w.Write(dosTime);
|
||||
w.Write(dosDate);
|
||||
w.Write(e.Crc);
|
||||
w.Write(e.CompressedSize);
|
||||
w.Write(e.Size);
|
||||
w.Write((ushort)e.Name.Length);
|
||||
w.Write((ushort)e.Extra.Length);
|
||||
w.Write((ushort)0); // 注释长度
|
||||
w.Write((ushort)0); // 磁盘号
|
||||
w.Write((ushort)0); // 内部属性
|
||||
w.Write(0u); // 外部属性
|
||||
w.Write(e.Offset);
|
||||
w.Write(e.Name);
|
||||
w.Write(e.Extra);
|
||||
}
|
||||
var size = (uint)output.Position - start;
|
||||
w.Write(0x06054b50u);
|
||||
w.Write((ushort)0);
|
||||
w.Write((ushort)0);
|
||||
w.Write((ushort)entries.Count);
|
||||
w.Write((ushort)entries.Count);
|
||||
w.Write(size);
|
||||
w.Write(start);
|
||||
w.Write((ushort)0);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Deflate(byte[] data)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var deflate = new DeflateStream(stream, CompressionLevel.Optimal, leaveOpen: true)) deflate.Write(data);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static uint Crc32(byte[] data)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in data) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
}
|
||||
+17
-7
@@ -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) => {
|
||||
/**
|
||||
* 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
|
||||
* (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
|
||||
*/
|
||||
const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
|
||||
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 exportExe(files, exeName(name))
|
||||
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)
|
||||
return new Response(result.exe, {
|
||||
const { ext, type } = EXPORTS[kind]
|
||||
return new Response(result.file, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.microsoft.portable-executable',
|
||||
'Content-Disposition': `attachment; filename="program.exe"; filename*=UTF-8''${encodeURIComponent(`${result.name}.exe`)}`,
|
||||
'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'])
|
||||
|
||||
@@ -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<CompileResult> {
|
||||
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
|
||||
}
|
||||
|
||||
private async request(payload: object): Promise<CompileResult> {
|
||||
await this.start()
|
||||
const id = this.nextId++
|
||||
|
||||
@@ -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<ExportResult> {
|
||||
export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
|
||||
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<Expo
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await Bun.write(target, file.content)
|
||||
}
|
||||
const result = await compileService.exportExe(dir, name)
|
||||
const result = kind === 'project' ? await compileService.exportProject(dir, name) : await compileService.exportExe(dir, name)
|
||||
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
|
||||
return { ok: true, exe: await Bun.file(join(dir, EXPORT_FILE)).bytes(), name }
|
||||
return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
+107
-3
@@ -740,12 +740,14 @@ describe('AI 评阅', () => {
|
||||
}, 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<ArrayBuffer>) {
|
||||
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
|
||||
let end = zip.length - 22
|
||||
while (view.getUint32(end, true) !== 0x06054b50) end--
|
||||
const count = view.getUint16(end + 10, true)
|
||||
let p = view.getUint32(end + 16, true)
|
||||
const files = new Map<string, Uint8Array<ArrayBuffer>>()
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(view.getUint32(p, true)).toBe(0x02014b50)
|
||||
const flags = view.getUint16(p + 8, true)
|
||||
const method = view.getUint16(p + 10, true)
|
||||
const compressedSize = view.getUint32(p + 20, true)
|
||||
const nameLength = view.getUint16(p + 28, true)
|
||||
const extraLength = view.getUint16(p + 30, true)
|
||||
const commentLength = view.getUint16(p + 32, true)
|
||||
const offset = view.getUint32(p + 42, true)
|
||||
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
|
||||
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
|
||||
expect(flags & 0x800).toBe(0)
|
||||
const name = new TextDecoder('gbk').decode(nameBytes)
|
||||
if (extra.length > 0) {
|
||||
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
|
||||
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
|
||||
} else {
|
||||
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
|
||||
}
|
||||
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
|
||||
const data = zip.subarray(local, local + compressedSize)
|
||||
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
|
||||
p += 46 + nameLength + extraLength + commentLength
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
test.skipIf(!hasLibs)(
|
||||
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
|
||||
async () => {
|
||||
const t = await teacher()
|
||||
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/zip')
|
||||
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
|
||||
const files = unzip(new Uint8Array(await response.arrayBuffer()))
|
||||
const text = (name: string) => {
|
||||
const bytes = files.get(name)
|
||||
expect(bytes, name).toBeDefined()
|
||||
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
|
||||
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
|
||||
const content = new TextDecoder().decode(bytes!.subarray(3))
|
||||
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
return content
|
||||
}
|
||||
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
|
||||
const csproj = text('寝室/寝室/寝室.csproj')
|
||||
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
|
||||
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
|
||||
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
|
||||
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
|
||||
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
|
||||
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
|
||||
expect(csproj).not.toContain('ICS.Models')
|
||||
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
|
||||
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
|
||||
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
|
||||
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
|
||||
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
|
||||
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
|
||||
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
|
||||
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
|
||||
expect(resources).not.toContain('Sim.Host')
|
||||
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
|
||||
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
|
||||
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
|
||||
|
||||
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
|
||||
const led = unzip(
|
||||
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
|
||||
)
|
||||
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
|
||||
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
|
||||
|
||||
// 机房的 VS 2012 只支持 C# 5
|
||||
const login = (await loadSample('p1-t1-登录'))!
|
||||
const newer = login.map((f) =>
|
||||
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
|
||||
)
|
||||
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
|
||||
expect(failed.status).toBe(422)
|
||||
const diagnostics = ((await failed.json()) as any).diagnostics
|
||||
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
|
||||
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
|
||||
// 导出 EXE 不受这个限制
|
||||
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
|
||||
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)
|
||||
})
|
||||
@@ -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<ExportKind, string> = { exe: 'exe', project: 'zip' }
|
||||
|
||||
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
|
||||
export async function downloadExe(files: SourceFile[]): Promise<Diagnostic[] | null> {
|
||||
export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise<Diagnostic[] | null> {
|
||||
let response: Response
|
||||
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
|
||||
@@ -168,16 +168,24 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3>8. 导出 EXE,在机房电脑上运行</h3>
|
||||
<h3>8. 导出 EXE 或 VS 工程,在机房电脑上运行</h3>
|
||||
<ul>
|
||||
<li>
|
||||
点工具栏的<b>导出 EXE</b>,下载一个
|
||||
点工具栏的<b>导出</b>→<b>导出 EXE</b>,下载一个
|
||||
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
|
||||
</li>
|
||||
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
|
||||
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
|
||||
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
|
||||
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
|
||||
<li>
|
||||
想在机房的 VS 里接着改,点<b>导出</b>→<b>导出 VS 工程</b>,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5
|
||||
运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。
|
||||
</li>
|
||||
<li>
|
||||
机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如
|
||||
<code>$"温度{t}"</code>)会弹出报错和改法,改好再导出。
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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<Diagnostic[]>([])
|
||||
const exportErrorsOpen = ref(false)
|
||||
const exportKind = ref<ExportKind>('exe')
|
||||
|
||||
async function exportExe() {
|
||||
const EXPORT_OPTIONS: DropdownOption[] = [
|
||||
{ key: 'exe', label: '导出 EXE', props: { title: '编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台' } },
|
||||
{ key: 'project', label: '导出 VS 工程', props: { title: '打包成 VS 2012 工程(.zip),在机房电脑上解压后用 VS 打开,接着改代码、按 F5 运行' } },
|
||||
]
|
||||
|
||||
const EXPORT_DONE: Record<ExportKind, string> = {
|
||||
exe: '已导出,在机房电脑上双击运行(要接真的实训台)',
|
||||
project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)',
|
||||
}
|
||||
|
||||
async function exportProgram(kind: ExportKind) {
|
||||
if (exporting.value) return
|
||||
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))
|
||||
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
|
||||
任务
|
||||
</NButton>
|
||||
<NButton size="small" :loading="exporting" title="编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台" @click="exportExe">
|
||||
导出 EXE
|
||||
</NButton>
|
||||
<NDropdown trigger="click" :options="EXPORT_OPTIONS" :disabled="exporting" @select="(key: ExportKind) => exportProgram(key)">
|
||||
<NButton size="small" :loading="exporting" title="导出到机房电脑上用:EXE 双击运行,VS 工程用 VS 打开">导出 ▾</NButton>
|
||||
</NDropdown>
|
||||
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||
@@ -657,7 +686,10 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
|
||||
</header>
|
||||
|
||||
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
|
||||
<p class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
|
||||
<p v-if="exportKind === 'project'" class="export-errors-note">
|
||||
导出时照机房电脑上的 VS 2012 编译(只支持 C# 5,用真机版设备类库)。点击报错跳到出错的代码。
|
||||
</p>
|
||||
<p v-else class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
|
||||
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
|
||||
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
|
||||
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
|
||||
|
||||
Reference in new issue
Block a user