项目 5 补齐:旧版线程 API(Suspend / Resume / Abort)与 ListBox

- 编译时把 Thread 的 Suspend / Resume / Abort、ThreadState、Sleep、new Thread 改写成平台的
  协作式实现:被挂起或终止的线程在 Sleep、后台改控件、调设备类库、弹提示框时停下或结束;
  ThreadStateException 等行为与 .NET Framework 一致;编辑器不再提示"已过时"
- ListBox:列表项、单选、SelectedIndexChanged 先于 Click、删除选中项后取消选中;
  ComboBox 与 ListBox 共用 ListControl 和列表项集合;设计器工具箱加"列表框"
- 评分规则的"包含"对列表框检查所有列表项
- 示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),
  参考答案修正书上直接 Abort 挂起线程、烟雾记成"发现火情"的问题
- 一直不换行的 Console 输出攒满 4096 字符先发出,避免占满内存
- 测试:旧版线程 API、ListBox 事件顺序、两个示例的端到端和评分测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 20:33:12 +08:00
1 parent 8a92354fe4
commit 91b39603a2
36 files changed
+1653 -105

No files matched your search

+4 -3
View File
@@ -66,14 +66,15 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
已完成: 已完成:
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。 - **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox,教材控件库的 Lamp、Alarm。
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1,共 10 个。网址带 `?sample=示例编号` 可直接打开指定示例。 - 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1–3,共 12 个。网址带 `?sample=示例编号` 可直接打开指定示例。
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
- 第 3 阶段(进行中):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`)。
尚未完成: 尚未完成:
- 教材项目 4–8 的其余任务(需要摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)、账号和班级、自动评测。 - 教材项目 4 任务 2 和项目 6–8(需要摄像头、数据库、TrackBar、PictureBox、图表)。
+8 -4
View File
@@ -166,7 +166,7 @@ IOT/
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` | | 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod | | 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS | | C# 运行时 | 平台 | .NET 10 LTS |
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;计划:编译时改写旧版线程 API | | C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 | | C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | | C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | | C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
@@ -211,7 +211,7 @@ IOT/
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 | | 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 | | 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 任务 1、3 已完成;任务 2 需摄像头 | | 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 任务 1、3 已完成;任务 2 需摄像头 |
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 任务 1 已完成;任务 2 需线程 API,任务 3 需 ListBox | | 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | | | 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | | | 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | | | 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | |
@@ -236,7 +236,7 @@ flowchart TB
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"] P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"] P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"] P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"] P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2 P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
@@ -276,6 +276,10 @@ flowchart TB
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。 - 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
- 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。 - 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。
**第 3 阶段进展**:
- 2026-10-22:项目 5 补齐(线程、ListBox)。旧版线程 API:编译时把 `thd.Suspend()` / `Resume()` / `Abort()`、`thd.ThreadState`、`Thread.Sleep`、`new Thread(...)` 改写成平台实现(只改语义上确实是 `System.Threading.Thread` 的调用,不换行,报错和异常的行号不变;编辑器里看到的仍是学生的原始代码,也不再提示"已过时")。新版 .NET 不能从外部停住线程,平台的实现是协作式的:被挂起或终止的线程在下一个检查点停下或结束(`Thread.Sleep`、从后台线程改控件属性、调用设备类库、弹出提示框),教材的线程方法每次循环都经过这些地方;休眠中被终止的线程立即醒来结束。行为与 .NET Framework 一致的地方:线程可以挂起自己、对没启动的线程 Abort 不报错、`Resume` 没挂起的线程和直接 `Abort` 挂起的线程抛出 ThreadStateException(书上任务 2 关闭窗体时直接 `thd.Abort()`,在真实环境中也会出这个错,参考答案改为先 `Resume` 再 `Abort`,报错信息里写明这样改)。ListBox:列表项、单选、SelectedIndexChanged 先于 Click、Text 是选中项、删除选中项后取消选中;设计器工具箱加"列表框";评分规则的"包含"对列表框检查所有列表项。新增示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),各有评分规则和端到端测试,共 12 个示例。
**其他改进**: **其他改进**:
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
@@ -291,7 +295,7 @@ flowchart TB
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) | | 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 | | 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 | | T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 |
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 编译时替换为平台实现的可暂停线程 | | 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 | | 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 | | 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms | | 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
@@ -79,6 +79,7 @@ public class ADAM4017Helper
/// </summary> /// </summary>
public ADAM4017ResModel SetData() public ADAM4017ResModel SetData()
{ {
LegacyThreads.CheckPoint();
var model = new ADAM4017ResModel(); var model = new ADAM4017ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model; if (string.IsNullOrWhiteSpace(_portName)) return model;
model.temperatureValue = Read(0); model.temperatureValue = Read(0);
@@ -110,6 +111,7 @@ public class ADAM4150Helper
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary> /// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
public ADAM4150ResModel SetData() public ADAM4150ResModel SetData()
{ {
LegacyThreads.CheckPoint();
var model = new ADAM4150ResModel(); var model = new ADAM4150ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model; if (string.IsNullOrWhiteSpace(_portName)) return model;
model.bodyInfraredValue = Read(0); model.bodyInfraredValue = Read(0);
+5 -2
View File
@@ -7,6 +7,9 @@ namespace Sim.Runner;
/// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary> /// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary>
internal sealed class ConsoleForwarder : TextWriter internal sealed class ConsoleForwarder : TextWriter
{ {
/// <summary>一直不换行的输出(如教材的 while (true) Console.Write("A"))攒到这么多字符就先发出去</summary>
private const int MaxBuffered = 4096;
private readonly StringBuilder _buffer = new(); private readonly StringBuilder _buffer = new();
public override Encoding Encoding => Encoding.UTF8; public override Encoding Encoding => Encoding.UTF8;
@@ -16,7 +19,7 @@ internal sealed class ConsoleForwarder : TextWriter
lock (_buffer) lock (_buffer)
{ {
_buffer.Append(value); _buffer.Append(value);
if (value == '\n') FlushBuffer(); if (value == '\n' || _buffer.Length >= MaxBuffered) FlushBuffer();
} }
} }
@@ -26,7 +29,7 @@ internal sealed class ConsoleForwarder : TextWriter
lock (_buffer) lock (_buffer)
{ {
_buffer.Append(value); _buffer.Append(value);
if (value.Contains('\n')) FlushBuffer(); if (value.Contains('\n') || _buffer.Length >= MaxBuffered) FlushBuffer();
} }
} }
+1
View File
@@ -244,6 +244,7 @@ internal static class LanguageServer
foreach (var diagnostic in compilation.GetDiagnostics() foreach (var diagnostic in compilation.GetDiagnostics()
.Concat(ApiPolicy.Check(compilation)) .Concat(ApiPolicy.Check(compilation))
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning && d.Location.IsInSource) .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning && d.Location.IsInSource)
.Where(d => !LegacyThreadRewriter.IsSupportedObsoleteWarning(d))
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1)) .OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
{ {
diagnostics.Add(StudentCompiler.ToJson(diagnostic)); diagnostics.Add(StudentCompiler.ToJson(diagnostic));
+121
View File
@@ -0,0 +1,121 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 编译前改写学生代码里的旧版线程 API,让教材项目 5 的 Suspend / Resume / Abort 能在新版 .NET 上运行:
/// <code>
/// new Thread(GetData) → new Thread(LegacyThreads.Wrap(new ThreadStart(GetData)))
/// thd.Suspend() / Resume() / Abort() → LegacyThreads.Suspend(thd) …
/// thd.ThreadState → LegacyThreads.GetThreadState(thd)
/// Thread.Sleep(2000) → LegacyThreads.Sleep(2000)
/// </code>
/// 只改写语义上确实是 System.Threading.Thread 成员的调用;改写不换行,报错和异常堆栈里的行号不变。
/// 学生在编辑器里看到的仍是原来的代码。
/// </summary>
internal static class LegacyThreadRewriter
{
private const string Target = "global::Sim.Host.LegacyThreads";
/// <summary>改写整个编译;没有需要改写的地方时原样返回</summary>
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
{
var result = compilation;
foreach (var tree in compilation.SyntaxTrees)
{
// 用到线程的文件一定会出现 Thread 这个词,其他文件不用做语义分析
if (!tree.GetText().ToString().Contains("Thread")) continue;
var rewriter = new Rewriter(compilation.GetSemanticModel(tree));
var root = rewriter.Visit(tree.GetRoot());
if (!rewriter.Changed) continue;
var text = SourceText.From(root.ToFullString(), Encoding.UTF8);
result = result.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(text, (CSharpParseOptions)tree.Options, tree.FilePath));
}
return result;
}
/// <summary>平台支持的过时线程 API 不再提示"已过时"(边写边检查时用)</summary>
public static bool IsSupportedObsoleteWarning(Diagnostic diagnostic)
{
if (diagnostic.Id is not ("CS0618" or "SYSLIB0006")) return false;
// 警告标在整个调用上,如 thd.Suspend()
var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? "";
return Regex.IsMatch(source, @"\b(Suspend|Resume|Abort)\b");
}
private static bool IsThread(ITypeSymbol? type) =>
type is { Name: "Thread", ContainingNamespace: { Name: "Threading", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } };
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
{
public bool Changed { get; private set; }
public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node)
{
var method = model.GetSymbolInfo(node).Symbol as IMethodSymbol;
var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!;
if (method == null || !IsThread(method.ContainingType)) return visited;
switch (method.Name)
{
case "Suspend" or "Resume" or "Abort" when !method.IsStatic && visited.Expression is MemberAccessExpressionSyntax access:
// thd.Abort(x) → LegacyThreads.Abort(thd, x)
var arguments = visited.ArgumentList.Arguments.Insert(0, SyntaxFactory.Argument(access.Expression.WithoutTrivia()));
Changed = true;
return SyntaxFactory.InvocationExpression(Member(method.Name), SyntaxFactory.ArgumentList(arguments))
.WithTriviaFrom(visited);
case "Sleep" when method.IsStatic:
Changed = true;
return visited.WithExpression(Member("Sleep").WithTriviaFrom(visited.Expression));
default:
return visited;
}
}
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
var symbol = model.GetSymbolInfo(node).Symbol;
var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node)!;
if (symbol is not IPropertySymbol { Name: "ThreadState", IsStatic: false } property || !IsThread(property.ContainingType)) return visited;
Changed = true;
return SyntaxFactory.InvocationExpression(
Member("GetThreadState"),
SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(visited.Expression.WithoutTrivia()))))
.WithTriviaFrom(visited);
}
public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) =>
WrapThreadStart(node, base.VisitObjectCreationExpression(node));
public override SyntaxNode? VisitImplicitObjectCreationExpression(ImplicitObjectCreationExpressionSyntax node) =>
WrapThreadStart(node, base.VisitImplicitObjectCreationExpression(node));
/// <summary>new Thread(start) 的第一个参数包一层 LegacyThreads.Wrap,线程被 Abort 时安静地结束</summary>
private SyntaxNode? WrapThreadStart(BaseObjectCreationExpressionSyntax original, SyntaxNode? visitedNode)
{
if (visitedNode is not BaseObjectCreationExpressionSyntax visited
|| model.GetSymbolInfo(original).Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor } constructor
|| !IsThread(constructor.ContainingType)
|| constructor.Parameters.Length == 0
|| visited.ArgumentList is not { Arguments.Count: > 0 } argumentList)
{
return visitedNode;
}
var first = argumentList.Arguments[0];
if (first.Expression.IsKind(SyntaxKind.NullLiteralExpression)) return visited;
var delegateType = constructor.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// new ThreadStart(方法名或 lambda):明确委托类型,Wrap 的重载就不会有歧义
var wrapped = SyntaxFactory.ParseExpression($"{Target}.Wrap(new {delegateType}({first.Expression.WithoutTrivia().ToFullString()}))");
Changed = true;
return visited.WithArgumentList(argumentList.WithArguments(
argumentList.Arguments.Replace(first, first.WithExpression(wrapped.WithTriviaFrom(first.Expression)))));
}
private static ExpressionSyntax Member(string name) => SyntaxFactory.ParseExpression($"{Target}.{name}");
}
}
+2 -1
View File
@@ -56,7 +56,8 @@ internal static class StudentCompiler
using var peStream = new MemoryStream(); using var peStream = new MemoryStream();
using var pdbStream = new MemoryStream(); using var pdbStream = new MemoryStream();
var emit = compilation.Emit(peStream, pdbStream, // 编译改写后的代码(旧版线程 API 换成平台实现),API 检查仍针对学生写的原始代码
var emit = LegacyThreadRewriter.Rewrite(compilation).Emit(peStream, pdbStream,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
var denied = ApiPolicy.Check(compilation).ToList(); var denied = ApiPolicy.Check(compilation).ToList();
+20 -83
View File
@@ -1,15 +1,11 @@
using System.Collections;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Sim.Host; using Sim.Host;
namespace System.Windows.Forms; namespace System.Windows.Forms;
public class ComboBox : Control public class ComboBox : ListControl
{ {
private int _selectedIndex = -1;
private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown; private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown;
public ComboBox() => Items = new ObjectCollection(this); public ComboBox() => Items = new ObjectCollection(this);
@@ -31,15 +27,15 @@ public class ComboBox : Control
set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle)); set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle));
} }
public int SelectedIndex public override int SelectedIndex
{ {
get => _selectedIndex; get => SelectedIndexValue;
set set
{ {
if (value < -1 || value >= Items.Count) if (value < -1 || value >= Items.Count)
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。"); throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。");
if (value == _selectedIndex) return; if (value == SelectedIndexValue) return;
SetProp(ref _selectedIndex, value, nameof(SelectedIndex)); SetProp(ref SelectedIndexValue, value, nameof(SelectedIndex));
// 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致) // 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致)
Text = value >= 0 ? Items[value]?.ToString() ?? "" : ""; Text = value >= 0 ? Items[value]?.ToString() ?? "" : "";
OnSelectedIndexChanged(EventArgs.Empty); OnSelectedIndexChanged(EventArgs.Empty);
@@ -49,19 +45,17 @@ public class ComboBox : Control
public object? SelectedItem public object? SelectedItem
{ {
get => _selectedIndex >= 0 ? Items[_selectedIndex] : null; get => SelectedIndexValue >= 0 ? Items[SelectedIndexValue] : null;
set => SelectedIndex = value == null ? -1 : Items.IndexOf(value); set => SelectedIndex = value == null ? -1 : Items.IndexOf(value);
} }
public string SelectedText { get; set; } = ""; public string SelectedText { get; set; } = "";
public event EventHandler? SelectedIndexChanged; public event EventHandler? SelectedIndexChanged;
public event EventHandler? SelectedValueChanged;
public event EventHandler? DropDown; public event EventHandler? DropDown;
public event EventHandler? DropDownClosed; public event EventHandler? DropDownClosed;
protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e); protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e);
protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e);
protected virtual void OnDropDown(EventArgs e) => DropDown?.Invoke(this, e); protected virtual void OnDropDown(EventArgs e) => DropDown?.Invoke(this, e);
protected virtual void OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e); protected virtual void OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e);
@@ -84,7 +78,7 @@ public class ComboBox : Control
case "text" when _dropDownStyle != ComboBoxStyle.DropDownList: case "text" when _dropDownStyle != ComboBoxStyle.DropDownList:
SetTextFromUser(action.Value ?? ""); SetTextFromUser(action.Value ?? "");
var match = Items.IndexOf(Text); var match = Items.IndexOf(Text);
if (match != _selectedIndex) _selectedIndex = match; if (match != SelectedIndexValue) SelectedIndexValue = match;
break; break;
default: default:
base.OnUserAction(action); base.OnUserAction(action);
@@ -92,85 +86,28 @@ public class ComboBox : Control
} }
} }
internal void ItemsChanged() internal override void ItemsChanged()
{ {
if (_selectedIndex >= Items.Count) _selectedIndex = -1; NotifyChanged(nameof(Items), ItemTexts(Items));
NotifyChanged(nameof(Items), Items.Select(i => i?.ToString() ?? "").ToArray()); NotifyChanged(nameof(SelectedIndex), SelectedIndexValue);
}
internal override void ItemsCleared()
{
SelectedIndex = -1;
ItemsChanged();
} }
protected override void WriteProps(JsonObject props) protected override void WriteProps(JsonObject props)
{ {
base.WriteProps(props); base.WriteProps(props);
props["Items"] = PropValue.From(Items.Select(i => i?.ToString() ?? "").ToArray()); props["Items"] = PropValue.From(ItemTexts(Items));
props["SelectedIndex"] = _selectedIndex; props["SelectedIndex"] = SelectedIndexValue;
props["DropDownStyle"] = _dropDownStyle.ToString(); props["DropDownStyle"] = _dropDownStyle.ToString();
} }
public class ObjectCollection : IList, IEnumerable<object?> public class ObjectCollection : ListItemCollection
{ {
private readonly List<object?> _items = new(); public ObjectCollection(ComboBox owner) : base(owner) { }
private readonly ComboBox _owner;
public ObjectCollection(ComboBox owner) => _owner = owner;
public int Count => _items.Count;
public bool IsReadOnly => false;
bool IList.IsFixedSize => false;
bool ICollection.IsSynchronized => false;
object ICollection.SyncRoot => this;
public object? this[int index]
{
get => _items[index];
set
{
_items[index] = value;
_owner.ItemsChanged();
}
}
public int Add(object? item)
{
if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。");
_items.Add(item);
_owner.ItemsChanged();
return _items.Count - 1;
}
public void AddRange(object[] items)
{
_items.AddRange(items);
_owner.ItemsChanged();
}
public void Insert(int index, object? item)
{
_items.Insert(index, item);
_owner.ItemsChanged();
}
public void Remove(object? item)
{
if (_items.Remove(item)) _owner.ItemsChanged();
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_owner.ItemsChanged();
}
public void Clear()
{
_items.Clear();
_owner.SelectedIndex = -1;
_owner.ItemsChanged();
}
public bool Contains(object? item) => _items.Contains(item);
public int IndexOf(object? item) => _items.IndexOf(item);
public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index);
public IEnumerator<object?> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
} }
} }
+6
View File
@@ -104,6 +104,7 @@ public class Control : Component, IWin32Window
get => _text; get => _text;
set set
{ {
LegacyThreads.CheckPoint();
value ??= ""; value ??= "";
if (_text == value) return; if (_text == value) return;
EnsureThreadAccess(); EnsureThreadAccess();
@@ -279,6 +280,9 @@ public class Control : Component, IWin32Window
OnClick(args); OnClick(args);
OnMouseClick(args); OnMouseClick(args);
break; break;
case "doubleClick":
OnDoubleClick(EventArgs.Empty);
break;
case "mouseDown": case "mouseDown":
OnMouseDown(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0)); OnMouseDown(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0));
break; break;
@@ -354,6 +358,8 @@ public class Control : Component, IWin32Window
/// <summary>修改字段并在需要时通知浏览器;返回值表示是否真的改变</summary> /// <summary>修改字段并在需要时通知浏览器;返回值表示是否真的改变</summary>
protected bool SetProp<T>(ref T field, T value, string prop) protected bool SetProp<T>(ref T field, T value, string prop)
{ {
// 后台线程改界面时响应 Suspend / Abort(教材的线程方法每次循环都会改控件)
LegacyThreads.CheckPoint();
if (EqualityComparer<T>.Default.Equals(field, value)) return false; if (EqualityComparer<T>.Default.Equals(field, value)) return false;
EnsureThreadAccess(); EnsureThreadAccess();
field = value; field = value;
+144
View File
@@ -0,0 +1,144 @@
using System.Drawing;
using System.Text.Json.Nodes;
using Sim.Host;
namespace System.Windows.Forms;
public enum SelectionMode { None, One, MultiSimple, MultiExtended }
/// <summary>列表框(教材项目 5 任务 3 显示历史灾情记录)。只支持单选。</summary>
public class ListBox : ListControl
{
private int _itemHeight = 12;
private BorderStyle _borderStyle = BorderStyle.Fixed3D;
public ListBox() => Items = new ObjectCollection(this);
protected override Size DefaultSize => new(120, 96);
public ObjectCollection Items { get; }
public bool FormattingEnabled { get; set; }
public bool IntegralHeight { get; set; } = true;
public bool HorizontalScrollbar { get; set; }
public bool ScrollAlwaysVisible { get; set; }
public bool MultiColumn { get; set; }
public bool Sorted { get; set; }
public SelectionMode SelectionMode { get; set; } = SelectionMode.One;
public int TopIndex { get; set; }
public int ItemHeight
{
get => _itemHeight;
set => SetProp(ref _itemHeight, value, nameof(ItemHeight));
}
public BorderStyle BorderStyle
{
get => _borderStyle;
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
}
public override int SelectedIndex
{
get => SelectedIndexValue;
set
{
if (value < -1 || value >= Items.Count)
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。");
if (!SetProp(ref SelectedIndexValue, value, nameof(SelectedIndex))) return;
OnSelectedIndexChanged(EventArgs.Empty);
OnSelectedValueChanged(EventArgs.Empty);
}
}
public object? SelectedItem
{
get => SelectedIndexValue >= 0 ? Items[SelectedIndexValue] : null;
set => SelectedIndex = value == null ? -1 : Items.IndexOf(value);
}
/// <summary>和 WinForms 一样,列表框的 Text 是选中项的文字;赋值时选中文字相同的项</summary>
public override string Text
{
get => GetItemText(SelectedItem);
set
{
var index = FindStringExact(value ?? "");
if (index >= 0) SelectedIndex = index;
}
}
public event EventHandler? SelectedIndexChanged;
protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e);
public void ClearSelected() => SelectedIndex = -1;
public void SetSelected(int index, bool value)
{
if (value) SelectedIndex = index;
else if (index == SelectedIndexValue) SelectedIndex = -1;
}
public bool GetSelected(int index) => index == SelectedIndexValue;
/// <summary>第一个以 s 开头的项(不区分大小写),没有时返回 -1</summary>
public int FindString(string s)
{
for (var i = 0; i < Items.Count; i++)
if (GetItemText(Items[i]).StartsWith(s, StringComparison.OrdinalIgnoreCase)) return i;
return -1;
}
public int FindStringExact(string s)
{
for (var i = 0; i < Items.Count; i++)
if (string.Equals(GetItemText(Items[i]), s, StringComparison.OrdinalIgnoreCase)) return i;
return -1;
}
public void BeginUpdate() { }
public void EndUpdate() { }
protected override void OnUserAction(UserAction action)
{
switch (action.Action)
{
case "select":
// 和 WinForms 一样,按下鼠标时先改变选中项,松开后才触发 Click
if (action.Index >= 0 && action.Index < Items.Count) SelectedIndex = action.Index;
base.OnUserAction(new UserAction { Action = "click", X = action.X, Y = action.Y });
break;
default:
base.OnUserAction(action);
break;
}
}
internal override void ItemsChanged()
{
NotifyChanged(nameof(Items), ItemTexts(Items));
NotifyChanged(nameof(SelectedIndex), SelectedIndexValue);
}
internal override void ItemsCleared()
{
SelectedIndex = -1;
ItemsChanged();
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["Items"] = PropValue.From(ItemTexts(Items));
props["SelectedIndex"] = SelectedIndexValue;
props["ItemHeight"] = _itemHeight;
props["BorderStyle"] = _borderStyle.ToString();
}
public class ObjectCollection : ListItemCollection
{
public ObjectCollection(ListBox owner) : base(owner) { }
}
}
+114
View File
@@ -0,0 +1,114 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace System.Windows.Forms;
/// <summary>ComboBox 和 ListBox 的共同基类:列表项、选中项</summary>
public abstract class ListControl : Control
{
/// <summary>选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged)</summary>
private protected int SelectedIndexValue = -1;
public abstract int SelectedIndex { get; set; }
public string DisplayMember { get; set; } = "";
public string ValueMember { get; set; } = "";
public event EventHandler? SelectedValueChanged;
protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e);
public string GetItemText(object? item) => item?.ToString() ?? "";
/// <summary>列表项变化后调用:把新的列表和选中项发给浏览器</summary>
internal abstract void ItemsChanged();
/// <summary>Items.Clear() 时调用:先取消选中,再刷新列表</summary>
internal abstract void ItemsCleared();
internal void ItemInserted(int index)
{
if (SelectedIndexValue >= index) SelectedIndexValue++;
ItemsChanged();
}
/// <summary>删掉的正是选中项时取消选中,删掉前面的项时选中项序号前移</summary>
internal void ItemRemoved(int index)
{
if (SelectedIndexValue == index) SelectedIndexValue = -1;
else if (SelectedIndexValue > index) SelectedIndexValue--;
ItemsChanged();
}
protected string[] ItemTexts(IEnumerable<object?> items) => items.Select(GetItemText).ToArray();
}
/// <summary>ComboBox.ObjectCollection 和 ListBox.ObjectCollection 共用的实现</summary>
public abstract class ListItemCollection : IList, IEnumerable<object?>
{
private readonly List<object?> _items = new();
private readonly ListControl _owner;
private protected ListItemCollection(ListControl owner) => _owner = owner;
public int Count => _items.Count;
public bool IsReadOnly => false;
bool IList.IsFixedSize => false;
bool ICollection.IsSynchronized => false;
object ICollection.SyncRoot => this;
public object? this[int index]
{
get => _items[index];
set
{
_items[index] = value;
_owner.ItemsChanged();
}
}
public int Add(object? item)
{
if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。");
_items.Add(item);
_owner.ItemsChanged();
return _items.Count - 1;
}
public void AddRange(object[] items)
{
_items.AddRange(items);
_owner.ItemsChanged();
}
public void Insert(int index, object? item)
{
_items.Insert(index, item);
_owner.ItemInserted(index);
}
public void Remove(object? item)
{
var index = _items.IndexOf(item);
if (index >= 0) RemoveAt(index);
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_owner.ItemRemoved(index);
}
public void Clear()
{
_items.Clear();
_owner.ItemsCleared();
}
public bool Contains(object? item) => _items.Contains(item);
public int IndexOf(object? item) => _items.IndexOf(item);
public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index);
public IEnumerator<object?> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
namespace Sim.Host;
/// <summary>
/// 教材项目 5 用到的旧版线程 API:Thread.Suspend / Resume / Abort 在新版 .NET 上会抛出 PlatformNotSupportedException。
/// 编译时把学生代码里对它们(以及 new Thread、Thread.Sleep、ThreadState)的调用改写成这里的方法(见 Sim.Runner/LegacyThreadRewriter.cs)。
/// <para>
/// 新版 .NET 不能从外部强行停住或中止一个线程,所以这里是"协作式"的:被挂起或中止的线程在下一个检查点停下或结束。
/// 检查点是 Thread.Sleep、从后台线程修改控件属性、调用设备类库、弹出提示框,
/// 教材里的线程方法每次循环都会经过它们,看到的效果和 .NET Framework 一致。
/// </para>
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class LegacyThreads
{
private sealed class State
{
public volatile bool Suspended;
public volatile bool AbortRequested;
}
// 线程少、操作不频繁,用一把锁保护所有线程的状态;挂起和休眠都在这把锁上等待
private static readonly object Gate = new();
private static readonly ConditionalWeakTable<Thread, State> States = new();
/// <summary>当前线程是学生用 new Thread 创建的线程时,指向它的状态</summary>
[ThreadStatic] private static State? _current;
private static State StateOf(Thread thread) => States.GetValue(thread, static _ => new State());
// ---------- 改写后的调用 ----------
/// <summary>new Thread(GetData) → new Thread(Wrap(new ThreadStart(GetData))):线程被中止时安静地结束</summary>
public static ThreadStart Wrap(ThreadStart start) => () => RunThread(() => start());
public static ParameterizedThreadStart Wrap(ParameterizedThreadStart start) => obj => RunThread(() => start(obj));
private static void RunThread(Action body)
{
var state = StateOf(Thread.CurrentThread);
_current = state;
try
{
// 启动前就被 Abort 的线程(.NET Framework 里启动后立即结束)
CheckPoint(state);
body();
}
catch (ThreadAbortedException)
{
// 和 .NET Framework 一样,被中止的线程结束时不算未处理的异常
}
}
public static void Suspend(Thread thread)
{
lock (Gate)
{
if ((thread.ThreadState & (ThreadState.Unstarted | ThreadState.Stopped)) != 0)
throw new ThreadStateException("线程没有在运行,无法挂起。");
StateOf(thread).Suspended = true;
}
// 线程挂起自己:立即停下,直到别的线程调用 Resume
if (thread == Thread.CurrentThread) CheckPoint(StateOf(thread));
}
public static void Resume(Thread thread)
{
lock (Gate)
{
var state = StateOf(thread);
if (!state.Suspended) throw new ThreadStateException("线程没有被挂起,无法继续(Resume 只能用在调用过 Suspend 的线程上)。");
state.Suspended = false;
Monitor.PulseAll(Gate);
}
}
public static void Abort(Thread thread) => Abort(thread, null);
public static void Abort(Thread thread, object? stateInfo)
{
lock (Gate)
{
if ((thread.ThreadState & ThreadState.Stopped) != 0) return;
var state = StateOf(thread);
state.AbortRequested = true;
// 正在 Thread.Sleep 的线程立即醒来结束
Monitor.PulseAll(Gate);
// .NET Framework 不能直接中止挂起的线程:抛出异常,等它被 Resume 后才结束
if (state.Suspended) throw new ThreadStateException("线程已挂起,不能直接终止。先调用 Resume() 让线程继续,再调用 Abort()。");
}
if (thread == Thread.CurrentThread) throw new ThreadAbortedException();
}
/// <summary>thd.ThreadState:在真实状态上加上挂起、请求中止</summary>
public static ThreadState GetThreadState(Thread thread)
{
var actual = thread.ThreadState;
if (!States.TryGetValue(thread, out var state) || (actual & ThreadState.Stopped) != 0) return actual;
var result = actual & (ThreadState.Background | ThreadState.Unstarted);
if (state.Suspended) result |= ThreadState.Suspended;
else if ((actual & ThreadState.Unstarted) == 0) result |= actual & ThreadState.WaitSleepJoin;
if (state.AbortRequested) result |= ThreadState.AbortRequested;
return result;
}
public static void Sleep(int millisecondsTimeout)
{
var state = _current;
if (state == null)
{
Thread.Sleep(millisecondsTimeout);
return;
}
if (millisecondsTimeout < Timeout.Infinite) throw new ArgumentOutOfRangeException(nameof(millisecondsTimeout), "休眠时间不能是负数(-1 表示一直休眠)。");
CheckPoint(state);
var deadline = Environment.TickCount64 + millisecondsTimeout;
lock (Gate)
{
// 休眠中被 Abort 时提前醒来;被 Suspend 的线程睡完后在检查点停下
while (!state.AbortRequested)
{
if (millisecondsTimeout == Timeout.Infinite)
{
Monitor.Wait(Gate);
continue;
}
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0) break;
Monitor.Wait(Gate, (int)remaining);
}
}
CheckPoint(state);
}
public static void Sleep(TimeSpan timeout) => Sleep((int)timeout.TotalMilliseconds);
// ---------- 检查点 ----------
/// <summary>平台内部使用:学生创建的线程在这里响应 Suspend 和 Abort(其他线程直接返回)</summary>
public static void CheckPoint()
{
var state = _current;
if (state != null) CheckPoint(state);
}
private static void CheckPoint(State state)
{
if (!state.Suspended && !state.AbortRequested) return;
lock (Gate)
{
while (state.Suspended) Monitor.Wait(Gate);
if (state.AbortRequested) throw new ThreadAbortedException();
}
}
}
/// <summary>线程被 Abort 时在线程里抛出(对应 .NET Framework 的 ThreadAbortException)</summary>
public sealed class ThreadAbortedException : Exception
{
public ThreadAbortedException() : base("正在中止线程。") { }
}
+11 -6
View File
@@ -77,13 +77,17 @@ public static class SimHost
public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text }); public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text });
public static void SendDevice(string device, string action, object? data) => Send(new JsonObject public static void SendDevice(string device, string action, object? data)
{ {
["type"] = "device", LegacyThreads.CheckPoint();
["device"] = device, Send(new JsonObject
["action"] = action, {
["data"] = PropValue.From(data), ["type"] = "device",
}); ["device"] = device,
["action"] = action,
["data"] = PropValue.From(data),
});
}
// ---------- 异常 ---------- // ---------- 异常 ----------
@@ -121,6 +125,7 @@ public static class SimHost
/// </summary> /// </summary>
public static JsonNode? Request(JsonObject message) public static JsonNode? Request(JsonObject message)
{ {
LegacyThreads.CheckPoint();
var id = Interlocked.Increment(ref _nextRequestId); var id = Interlocked.Increment(ref _nextRequestId);
var pending = new PendingRequest(); var pending = new PendingRequest();
Requests[id] = pending; Requests[id] = pending;
+146
View File
@@ -0,0 +1,146 @@
namespace 烟雾
{
partial class Form1
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
/// <summary>
/// 设计器支持所需的方法 - 不要
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
this.lbl_port = new System.Windows.Forms.Label();
this.com_port = new System.Windows.Forms.ComboBox();
this.btn_open = new System.Windows.Forms.Button();
this.groupBox1 = new System.Windows.Forms.GroupBox();
this.txt_even = new System.Windows.Forms.TextBox();
this.lbl_even = new System.Windows.Forms.Label();
this.txt_time = new System.Windows.Forms.TextBox();
this.lbl_time = new System.Windows.Forms.Label();
this.groupBox1.SuspendLayout();
this.SuspendLayout();
//
// lbl_port
//
this.lbl_port.AutoSize = true;
this.lbl_port.Location = new System.Drawing.Point(53, 21);
this.lbl_port.Name = "lbl_port";
this.lbl_port.Size = new System.Drawing.Size(29, 12);
this.lbl_port.TabIndex = 0;
this.lbl_port.Text = "串口";
//
// com_port
//
this.com_port.FormattingEnabled = true;
this.com_port.Location = new System.Drawing.Point(100, 18);
this.com_port.Name = "com_port";
this.com_port.Size = new System.Drawing.Size(121, 20);
this.com_port.TabIndex = 1;
//
// btn_open
//
this.btn_open.Location = new System.Drawing.Point(227, 16);
this.btn_open.Name = "btn_open";
this.btn_open.Size = new System.Drawing.Size(75, 23);
this.btn_open.TabIndex = 2;
this.btn_open.Text = "开启";
this.btn_open.UseVisualStyleBackColor = true;
this.btn_open.Click += new System.EventHandler(this.btn_open_Click);
//
// groupBox1
//
this.groupBox1.Controls.Add(this.txt_even);
this.groupBox1.Controls.Add(this.lbl_even);
this.groupBox1.Controls.Add(this.txt_time);
this.groupBox1.Controls.Add(this.lbl_time);
this.groupBox1.Location = new System.Drawing.Point(30, 63);
this.groupBox1.Name = "groupBox1";
this.groupBox1.Size = new System.Drawing.Size(286, 110);
this.groupBox1.TabIndex = 3;
this.groupBox1.TabStop = false;
this.groupBox1.Text = "数据显示";
//
// txt_even
//
this.txt_even.Location = new System.Drawing.Point(70, 70);
this.txt_even.Name = "txt_even";
this.txt_even.Size = new System.Drawing.Size(133, 21);
this.txt_even.TabIndex = 3;
//
// lbl_even
//
this.lbl_even.AutoSize = true;
this.lbl_even.Location = new System.Drawing.Point(23, 73);
this.lbl_even.Name = "lbl_even";
this.lbl_even.Size = new System.Drawing.Size(29, 12);
this.lbl_even.TabIndex = 2;
this.lbl_even.Text = "事件";
//
// txt_time
//
this.txt_time.Location = new System.Drawing.Point(70, 34);
this.txt_time.Name = "txt_time";
this.txt_time.Size = new System.Drawing.Size(133, 21);
this.txt_time.TabIndex = 1;
//
// lbl_time
//
this.lbl_time.AutoSize = true;
this.lbl_time.Location = new System.Drawing.Point(23, 37);
this.lbl_time.Name = "lbl_time";
this.lbl_time.Size = new System.Drawing.Size(29, 12);
this.lbl_time.TabIndex = 0;
this.lbl_time.Text = "时间";
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(342, 193);
this.Controls.Add(this.groupBox1);
this.Controls.Add(this.btn_open);
this.Controls.Add(this.com_port);
this.Controls.Add(this.lbl_port);
this.Name = "Form1";
this.Text = "烟雾";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing);
this.Load += new System.EventHandler(this.Form1_Load);
this.groupBox1.ResumeLayout(false);
this.groupBox1.PerformLayout();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label lbl_port;
private System.Windows.Forms.ComboBox com_port;
private System.Windows.Forms.Button btn_open;
private System.Windows.Forms.GroupBox groupBox1;
private System.Windows.Forms.TextBox txt_even;
private System.Windows.Forms.Label lbl_even;
private System.Windows.Forms.TextBox txt_time;
private System.Windows.Forms.Label lbl_time;
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.IO.Ports;
using DeviceCtrlLibrary;
using System.Threading;//引用线程命名空间
namespace 烟雾
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
bool IsOpen = false;
bool IsFirst = true;//记录线程是否第一次打开
Thread thd;//定义Thread线程
private void Form1_Load(object sender, EventArgs e)
{
//用 foreach 循环把所有可用的串口号加入列表
foreach (string item in SerialPort.GetPortNames())
{
com_port.Items.Add(item);
}
thd = new Thread(GetData);//实例化线程,绑定线程运行方法
Control.CheckForIllegalCrossThreadCalls = false;//允许跨线程调用
}
private void GetData()
{
//线程运行方法,不断循环运行
while (true)
{
if (DeviceCtrlHelper.adam4150Helper.SetData().smokeValue)//判断烟雾状态,true为发现烟雾灾情,false为正常状态
{
txt_time.Text = DateTime.Now.ToString();
txt_even.Text = "发现烟情";
}
else
{
txt_time.Text = DateTime.Now.ToString();
txt_even.Text = "正常";
}
Thread.Sleep(2000);//线程休眠2s
}
}
private void btn_open_Click(object sender, EventArgs e)
{
IsOpen = !IsOpen;
if (IsOpen)
{
DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);
btn_open.Text = "关闭";
if (IsFirst)
{
IsFirst = false;
thd.Start();//第一次打开时启动线程
}
else
{
thd.Resume();//继续已挂起的线程
}
}
else
{
DeviceCtrlHelper.adam4150Helper.Close();
btn_open.Text = "开启";
thd.Suspend();//挂起线程
}
}
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
//挂起的线程不能直接终止,要先让它继续
if (thd.ThreadState == ThreadState.Suspended)
{
thd.Resume();
}
thd.Abort();//终止线程
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace 烟雾
{
static class Program
{
/// <summary>
/// 应用程序的主入口点。
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。
# 格式说明见 server/src/grading/checks.ts
- title: 按钮能打开串口、开启线程,更改按钮文字
steps:
- select: { control: com_port, item: COM3 }
- click: btn_open
- expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" }
- expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 5s, desc: 显示当前时间 }
- title: 能利用线程实时显示烟雾传感器数据
steps:
- select: { control: com_port, item: COM3 }
- click: btn_open
- board: { smoke: true }
- expect: { control: txt_even, contains: 发现烟情, within: 5s }
- board: { smoke: false }
- expect: { control: txt_even, contains: 正常, within: 5s }
- title: 再点按钮能关闭串口、挂起线程,再次打开时线程继续
steps:
- select: { control: com_port, item: COM3 }
- click: btn_open
- expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 5s, desc: 显示当前时间 }
- click: btn_open
- expect: { control: btn_open, text: 开启, desc: 按钮文字变回"开启" }
- expect: { control: txt_time, unchanged: 5s, desc: 不再更新时间(线程应已挂起) }
- click: btn_open
- expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" }
- board: { smoke: true }
- expect: { control: txt_even, contains: 发现烟情, within: 5s, desc: 继续显示烟雾数据(线程应已继续) }
+4
View File
@@ -0,0 +1,4 @@
{
"title": "项目5 任务2 采集烟雾传感器数据",
"source": "教材书 p98–101,界面取自配套参考工程,控件名按书上表格修改;关闭窗体时先让挂起的线程继续再终止(书上直接 Abort 挂起的线程会出错)"
}
+241
View File
@@ -0,0 +1,241 @@
namespace 火焰烟雾
{
partial class Form1
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
/// <summary>
/// 设计器支持所需的方法 - 不要
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
this.led1 = new WinFormsDeviceControlLibrary.LED();
this.lbl_time = new System.Windows.Forms.Label();
this.txt_time = new System.Windows.Forms.TextBox();
this.txt_fire = new System.Windows.Forms.TextBox();
this.lbl_fire = new System.Windows.Forms.Label();
this.txt_smoke = new System.Windows.Forms.TextBox();
this.lbl_smoke = new System.Windows.Forms.Label();
this.lbl_Ledport = new System.Windows.Forms.Label();
this.com_ledPort = new System.Windows.Forms.ComboBox();
this.com_environmentPort = new System.Windows.Forms.ComboBox();
this.lbl_4150port = new System.Windows.Forms.Label();
this.btn_Open = new System.Windows.Forms.Button();
this.btn_get = new System.Windows.Forms.Button();
this.groupBox1 = new System.Windows.Forms.GroupBox();
this.OpenRecord = new System.Windows.Forms.Button();
this.groupBox2 = new System.Windows.Forms.GroupBox();
this.groupBox1.SuspendLayout();
this.groupBox2.SuspendLayout();
this.SuspendLayout();
//
// led1
//
this.led1.BackColor = System.Drawing.Color.Black;
this.led1.Location = new System.Drawing.Point(19, 0);
this.led1.Name = "led1";
this.led1.Size = new System.Drawing.Size(800, 130);
this.led1.TabIndex = 0;
//
// lbl_time
//
this.lbl_time.AutoSize = true;
this.lbl_time.Location = new System.Drawing.Point(62, 163);
this.lbl_time.Name = "lbl_time";
this.lbl_time.Size = new System.Drawing.Size(29, 12);
this.lbl_time.TabIndex = 1;
this.lbl_time.Text = "时间";
//
// txt_time
//
this.txt_time.Location = new System.Drawing.Point(109, 160);
this.txt_time.Name = "txt_time";
this.txt_time.Size = new System.Drawing.Size(174, 21);
this.txt_time.TabIndex = 2;
//
// txt_fire
//
this.txt_fire.Location = new System.Drawing.Point(109, 205);
this.txt_fire.Name = "txt_fire";
this.txt_fire.Size = new System.Drawing.Size(174, 21);
this.txt_fire.TabIndex = 4;
//
// lbl_fire
//
this.lbl_fire.AutoSize = true;
this.lbl_fire.Location = new System.Drawing.Point(62, 208);
this.lbl_fire.Name = "lbl_fire";
this.lbl_fire.Size = new System.Drawing.Size(29, 12);
this.lbl_fire.TabIndex = 3;
this.lbl_fire.Text = "火焰";
//
// txt_smoke
//
this.txt_smoke.Location = new System.Drawing.Point(109, 256);
this.txt_smoke.Name = "txt_smoke";
this.txt_smoke.Size = new System.Drawing.Size(174, 21);
this.txt_smoke.TabIndex = 6;
//
// lbl_smoke
//
this.lbl_smoke.AutoSize = true;
this.lbl_smoke.Location = new System.Drawing.Point(62, 259);
this.lbl_smoke.Name = "lbl_smoke";
this.lbl_smoke.Size = new System.Drawing.Size(29, 12);
this.lbl_smoke.TabIndex = 5;
this.lbl_smoke.Text = "烟雾";
//
// lbl_Ledport
//
this.lbl_Ledport.AutoSize = true;
this.lbl_Ledport.Location = new System.Drawing.Point(29, 26);
this.lbl_Ledport.Name = "lbl_Ledport";
this.lbl_Ledport.Size = new System.Drawing.Size(47, 12);
this.lbl_Ledport.TabIndex = 7;
this.lbl_Ledport.Text = "L E D";
//
// com_ledPort
//
this.com_ledPort.FormattingEnabled = true;
this.com_ledPort.Location = new System.Drawing.Point(88, 23);
this.com_ledPort.Name = "com_ledPort";
this.com_ledPort.Size = new System.Drawing.Size(121, 20);
this.com_ledPort.TabIndex = 8;
//
// com_environmentPort
//
this.com_environmentPort.FormattingEnabled = true;
this.com_environmentPort.Location = new System.Drawing.Point(88, 49);
this.com_environmentPort.Name = "com_environmentPort";
this.com_environmentPort.Size = new System.Drawing.Size(121, 20);
this.com_environmentPort.TabIndex = 10;
//
// lbl_4150port
//
this.lbl_4150port.AutoSize = true;
this.lbl_4150port.Location = new System.Drawing.Point(29, 52);
this.lbl_4150port.Name = "lbl_4150port";
this.lbl_4150port.Size = new System.Drawing.Size(53, 12);
this.lbl_4150port.TabIndex = 9;
this.lbl_4150port.Text = "环境采集";
//
// btn_Open
//
this.btn_Open.Location = new System.Drawing.Point(134, 85);
this.btn_Open.Name = "btn_Open";
this.btn_Open.Size = new System.Drawing.Size(75, 23);
this.btn_Open.TabIndex = 11;
this.btn_Open.Text = "开启";
this.btn_Open.UseVisualStyleBackColor = true;
this.btn_Open.Click += new System.EventHandler(this.btn_Open_Click);
//
// btn_get
//
this.btn_get.Location = new System.Drawing.Point(270, 20);
this.btn_get.Name = "btn_get";
this.btn_get.Size = new System.Drawing.Size(49, 117);
this.btn_get.TabIndex = 12;
this.btn_get.Text = "获取";
this.btn_get.UseVisualStyleBackColor = true;
this.btn_get.Click += new System.EventHandler(this.btn_get_Click);
//
// groupBox1
//
this.groupBox1.Controls.Add(this.OpenRecord);
this.groupBox1.Controls.Add(this.lbl_Ledport);
this.groupBox1.Controls.Add(this.btn_Open);
this.groupBox1.Controls.Add(this.com_ledPort);
this.groupBox1.Controls.Add(this.com_environmentPort);
this.groupBox1.Controls.Add(this.lbl_4150port);
this.groupBox1.Location = new System.Drawing.Point(447, 143);
this.groupBox1.Name = "groupBox1";
this.groupBox1.Size = new System.Drawing.Size(375, 150);
this.groupBox1.TabIndex = 13;
this.groupBox1.TabStop = false;
this.groupBox1.Text = "配置";
//
// OpenRecord
//
this.OpenRecord.Location = new System.Drawing.Point(256, 23);
this.OpenRecord.Name = "OpenRecord";
this.OpenRecord.Size = new System.Drawing.Size(99, 25);
this.OpenRecord.TabIndex = 13;
this.OpenRecord.Text = "历史灾情信息";
this.OpenRecord.UseVisualStyleBackColor = true;
this.OpenRecord.Click += new System.EventHandler(this.OpenRecord_Click);
//
// groupBox2
//
this.groupBox2.Controls.Add(this.btn_get);
this.groupBox2.Location = new System.Drawing.Point(19, 136);
this.groupBox2.Name = "groupBox2";
this.groupBox2.Size = new System.Drawing.Size(402, 157);
this.groupBox2.TabIndex = 14;
this.groupBox2.TabStop = false;
this.groupBox2.Text = "数据采集";
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(834, 315);
this.Controls.Add(this.txt_smoke);
this.Controls.Add(this.lbl_smoke);
this.Controls.Add(this.txt_fire);
this.Controls.Add(this.lbl_fire);
this.Controls.Add(this.txt_time);
this.Controls.Add(this.lbl_time);
this.Controls.Add(this.led1);
this.Controls.Add(this.groupBox1);
this.Controls.Add(this.groupBox2);
this.Name = "Form1";
this.Text = "火焰烟雾";
this.Load += new System.EventHandler(this.Form1_Load);
this.groupBox1.ResumeLayout(false);
this.groupBox1.PerformLayout();
this.groupBox2.ResumeLayout(false);
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private WinFormsDeviceControlLibrary.LED led1;
private System.Windows.Forms.Label lbl_time;
private System.Windows.Forms.TextBox txt_time;
private System.Windows.Forms.TextBox txt_fire;
private System.Windows.Forms.Label lbl_fire;
private System.Windows.Forms.TextBox txt_smoke;
private System.Windows.Forms.Label lbl_smoke;
private System.Windows.Forms.Label lbl_Ledport;
private System.Windows.Forms.ComboBox com_ledPort;
private System.Windows.Forms.ComboBox com_environmentPort;
private System.Windows.Forms.Label lbl_4150port;
private System.Windows.Forms.Button btn_Open;
private System.Windows.Forms.Button btn_get;
private System.Windows.Forms.GroupBox groupBox1;
private System.Windows.Forms.GroupBox groupBox2;
private System.Windows.Forms.Button OpenRecord;
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.IO.Ports;
using DeviceCtrlLibrary;
using DeviceCtrlLibrary.Model;//引用设备控制库下的Model命名空间
namespace 火焰烟雾
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
ADAM4150ResModel ResModel;
bool IsOpen = false;
string LED_MSG = "";//存储LED显示屏所需发送的文字信息
private void Form1_Load(object sender, EventArgs e)
{
com_environmentPort.Items.Clear();
com_ledPort.Items.Clear();
//将系统串口名依次加入下拉列表框中,由于本题使用串口服务器,所以COM1计算机串口可不加入串口列表
for (int i = 0; i < SerialPort.GetPortNames().Length; i++)
{
//判断计算机的默认串口,将其排除
if (SerialPort.GetPortNames()[i].ToUpper() == "COM1")
{
continue;//跳出此次循环
}
com_environmentPort.Items.Add(SerialPort.GetPortNames()[i].ToString());
com_ledPort.Items.Add(SerialPort.GetPortNames()[i].ToString());
}
}
private void btn_Open_Click(object sender, EventArgs e)
{
IsOpen = !IsOpen;
if (IsOpen)
{
DeviceCtrlHelper.adam4150Helper.Open(com_environmentPort.Text);
DeviceCtrlHelper.ledHelper.OpenLedCom(com_ledPort.Text);
btn_Open.Text = "关闭";
}
else
{
DeviceCtrlHelper.adam4150Helper.Close();
btn_Open.Text = "开启";
}
}
private void btn_get_Click(object sender, EventArgs e)
{
ResModel = DeviceCtrlHelper.adam4150Helper.SetData();//获取数字量采集器ADAM 4150的最新数据
LED_MSG = "";
txt_time.Text = DateTime.Now.ToString();
txt_fire.Text = "正常";
txt_smoke.Text = "正常";
if (ResModel.fireValue)
{
txt_fire.Text = "发现火情";
LED_MSG += "发现火情";
RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text;//记录灾情时间至数组
RecordClass.record[RecordClass.recoudeindex, 1] = " 发现火情";//记录灾情内容至数组
RecordClass.recoudeindex++;
}
if (ResModel.smokeValue)
{
txt_smoke.Text = "发现烟情";
LED_MSG += "发现烟情";
RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text;//记录灾情时间至数组
RecordClass.record[RecordClass.recoudeindex, 1] = " 发现烟情";//记录灾情内容至数组
RecordClass.recoudeindex++;
}
string ResStr = DeviceCtrlHelper.ledHelper.SendLedString(LED_MSG);
if (!(ResStr.IndexOf("Error") >= 0))
led1.setLedText(ResStr);//推送灾情信息至LED显示屏
}
private void OpenRecord_Click(object sender, EventArgs e)
{
Form2 fr2 = new Form2();
fr2.Show();
}
}
}
+60
View File
@@ -0,0 +1,60 @@
namespace 火焰烟雾
{
partial class Form2
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
/// <summary>
/// 设计器支持所需的方法 - 不要
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
this.lstbRecored = new System.Windows.Forms.ListBox();
this.SuspendLayout();
//
// lstbRecored
//
this.lstbRecored.FormattingEnabled = true;
this.lstbRecored.ItemHeight = 12;
this.lstbRecored.Location = new System.Drawing.Point(12, 26);
this.lstbRecored.Name = "lstbRecored";
this.lstbRecored.Size = new System.Drawing.Size(260, 148);
this.lstbRecored.TabIndex = 0;
//
// Form2
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(284, 185);
this.Controls.Add(this.lstbRecored);
this.Name = "Form2";
this.Text = "历史灾情记录";
this.Load += new System.EventHandler(this.Form2_Load);
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.ListBox lstbRecored;
}
}
+29
View File
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace 火焰烟雾
{
public partial class Form2 : Form
{
public Form2()
{
InitializeComponent();
}
private void Form2_Load(object sender, EventArgs e)
{
//把数组里记录的灾情信息逐条加入列表框
for (int i = 0; i < RecordClass.recoudeindex; i++)
{
lstbRecored.Items.Add(RecordClass.record[i, 0] + " " + RecordClass.record[i, 1]);
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace 火焰烟雾
{
static class Program
{
/// <summary>
/// 应用程序的主入口点。
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace 火焰烟雾
{
class RecordClass
{
public static string[,] record = new string[50, 2];//定义一个二维数组,用于存储灾情信息的时间和内容
public static int recoudeindex = 0;//用于记录灾情信息的索引位置
}
}
+40
View File
@@ -0,0 +1,40 @@
# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。
# 格式说明见 server/src/grading/checks.ts
- title: 能实时显示火焰、烟雾传感器数据
steps:
- select: { control: com_environmentPort, item: COM3 }
- select: { control: com_ledPort, item: COM5 }
- click: btn_Open
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
- board: { fire: true }
- click: btn_get
- expect: { control: txt_fire, contains: 发现火情 }
- expect: { control: txt_smoke, contains: 正常 }
- expect: { control: txt_time, matches: '\d{1,2}:\d{2}', desc: 显示当前时间 }
- board: { fire: false, smoke: true }
- click: btn_get
- expect: { control: txt_fire, contains: 正常 }
- expect: { control: txt_smoke, contains: 发现烟情 }
- title: 能正确推送灾情信息到 LED 屏
steps:
- select: { control: com_environmentPort, item: COM3 }
- select: { control: com_ledPort, item: COM5 }
- click: btn_Open
- board: { fire: true }
- click: btn_get
- expect: { led: 发现火情 }
- expect: { control: led1, ledText: 发现火情, desc: 窗体上的 LED 控件显示"发现火情" }
- title: 能利用数组存储灾情信息,在历史灾情窗体里显示
steps:
- select: { control: com_environmentPort, item: COM3 }
- select: { control: com_ledPort, item: COM5 }
- click: btn_Open
- board: { fire: true }
- click: btn_get
- board: { fire: false, smoke: true }
- click: btn_get
- click: OpenRecord
- expect: { control: lstbRecored, contains: 发现火情, desc: 列表里有"发现火情"的记录 }
- expect: { control: lstbRecored, contains: 发现烟情, desc: 列表里有"发现烟情"的记录 }
+4
View File
@@ -0,0 +1,4 @@
{
"title": "项目5 任务3 实时推送灾情信息",
"source": "教材书 p104–107,界面取自配套参考工程,控件名按书上表格修改;修正烟雾记成“发现火情”的错误,列表框按表5-7 命名为 lstbRecored"
}
+1
View File
@@ -19,6 +19,7 @@ export type Step =
export type Expectation = export type Expectation =
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯) */ /** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯) */
| { kind: 'prop'; control: string; prop: string; value: unknown } | { kind: 'prop'; control: string; prop: string; value: unknown }
/** 控件文字包含某段文字;列表框是任意一个列表项包含 */
| { kind: 'contains'; control: string; text: string } | { kind: 'contains'; control: string; text: string }
| { kind: 'matches'; control: string; pattern: RegExp; source: string } | { kind: 'matches'; control: string; pattern: RegExp; source: string }
/** 一段时间内控件文字不变(比如定时器已经停止) */ /** 一段时间内控件文字不变(比如定时器已经停止) */
+15 -4
View File
@@ -134,13 +134,20 @@ async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'ex
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait)) if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait))
} }
/** 列表框的 Text 只是选中的那一项,检查"包含"时看所有列表项 */
function listItems(node: ControlNode | undefined): string[] | undefined {
return node?.type === 'ListBox' ? ((node.props.Items as unknown[] | undefined) ?? []).map(String) : undefined
}
function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>): boolean { function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>): boolean {
const props = (name: string) => driver.find(name)?.props const props = (name: string) => driver.find(name)?.props
switch (e.kind) { switch (e.kind) {
case 'prop': case 'prop':
return props(e.control)?.[e.prop] === e.value return props(e.control)?.[e.prop] === e.value
case 'contains': case 'contains': {
return String(props(e.control)?.Text ?? '').includes(e.text) const items = listItems(driver.find(e.control))
return items ? items.some((item) => item.includes(e.text)) : String(props(e.control)?.Text ?? '').includes(e.text)
}
case 'matches': case 'matches':
return e.pattern.test(String(props(e.control)?.Text ?? '')) return e.pattern.test(String(props(e.control)?.Text ?? ''))
case 'led': case 'led':
@@ -168,8 +175,12 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的` if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
return `期望${prefix} ${e.control} 的 ${e.prop} 是 ${String(e.value)},实际是 ${String(actual)}` return `期望${prefix} ${e.control} 的 ${e.prop} 是 ${String(e.value)},实际是 ${String(actual)}`
} }
case 'contains': case 'contains': {
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(need(driver, e.control).props.Text)}` const node = need(driver, e.control)
const items = listItems(node)
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
}
case 'matches': case 'matches':
return `期望${prefix} ${e.control} ${desc ?? `的文字符合 ${e.pattern}`},实际是${quote(need(driver, e.control).props.Text)}` return `期望${prefix} ${e.control} ${desc ?? `的文字符合 ${e.pattern}`},实际是${quote(need(driver, e.control).props.Text)}`
case 'led': case 'led':
+7
View File
@@ -425,6 +425,13 @@ describe('语言服务', () => {
expect((await t.post('/api/language', { type: 'run', session: 'tab1' })).status).toBe(400) expect((await t.post('/api/language', { type: 'run', session: 'tab1' })).status).toBe(400)
expect((await new Browser().post('/api/language', { type: 'complete', session: 'tab1', files })).status).toBe(401) expect((await new Browser().post('/api/language', { type: 'complete', session: 'tab1', files })).status).toBe(401)
}, 60000) }, 60000)
test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => {
const t = await teacher()
const files = [{ path: 'Program.cs', content: 'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n' }]
const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab2', files })
expect(check.body.diagnostics).toEqual([])
}, 60000)
}) })
describe('AI 评阅', () => { describe('AI 评阅', () => {
+17 -1
View File
@@ -31,7 +31,7 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
describe('参考答案全部通过', async () => { describe('参考答案全部通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort()
test('每个示例都有评分规则', () => expect(ids.length).toBe(10)) test('每个示例都有评分规则', () => expect(ids.length).toBe(12))
for (const id of ids) { for (const id of ids) {
test(id, async () => { test(id, async () => {
const result = await grade(await prepare(id, id), await checksOf(id)) const result = await grade(await prepare(id, id), await checksOf(id))
@@ -80,6 +80,22 @@ describe('改坏的代码', () => {
expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从') expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从')
}, 60000) }, 60000)
test('关闭时忘了挂起线程', async () => {
const id = 'p5-t2-烟雾'
const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' })
const result = await grade(dir, await checksOf(id))
const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、挂起线程,再次打开时线程继续')!
expect(stop.passed).toBe(false)
expect(stop.message).toStartWith('期望 txt_time 不再更新时间(线程应已挂起),实际从')
}, 60000)
test('历史灾情窗体没有把数组里的记录加入列表框', async () => {
const id = 'p5-t3-推送灾情'
const dir = await prepare(id, 'listbox', { file: 'Form2.cs', from: 'lstbRecored.Items.Add(', to: 'Console.WriteLine(' })
const result = await grade(dir, await checksOf(id))
expect(result.results[2]).toMatchObject({ passed: false, message: '期望 lstbRecored 列表里有"发现火情"的记录,实际列表是空的' })
}, 60000)
test('报警灯接错了 DO 口', async () => { test('报警灯接错了 DO 口', async () => {
const id = 'p4-t3-报警灯' const id = 'p4-t3-报警灯'
const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' }) const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' })
+136
View File
@@ -335,6 +335,52 @@ test('项目5 任务1:有火焰时显示"发现火情!"', async () => {
await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000) await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000)
}, 30000) }, 30000)
test('项目5 任务2:线程实时显示烟雾数据;关闭时线程挂起、再打开时继续;关闭窗体后程序正常结束', async () => {
const { runner, id, form } = await startSample('p5-t2-烟雾')
// 改写后的代码不再用过时的 API,不应出现"已过时"警告
expect((await runner.waitFor('compiled')).diagnostics).toEqual([])
const changed = (name: string, match: (value: unknown) => boolean = () => true, timeout = 5000) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && match(c.value)), timeout)
const normal = changed('txt_even', (v) => v === '正常')
await openAdam4150(runner, id)
await normal
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, true, false] })
await changed('txt_even', (v) => v === '发现烟情')
// 关闭:线程挂起,时间不再更新
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
await expect(changed('txt_time', undefined, 3000)).rejects.toThrow('超时')
// 再打开:线程继续
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('txt_time', undefined, 3000)
// 挂起后关闭窗体:先 Resume 再 Abort,程序正常结束
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
runner.send({ type: 'action', id: form.id, action: 'close' })
expect((await runner.finished()).code).toBe(0)
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
}, 30000)
test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framework 一样报错,并提示先 Resume', async () => {
const dir = await prepareSample('p5-t2-烟雾')
const file = `${dir}/Form1.cs`
const code = await Bun.file(file).text()
await Bun.write(file, code.replace(/if \(thd\.ThreadState == ThreadState\.Suspended\)\s*\{\s*thd\.Resume\(\);\s*\}/, ''))
runner = await Runner.start(dir)
const { form } = await runner.waitFor('formShown')
const id = (name: string) => findByName(form, name).id
await openAdam4150(runner, id)
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === id('btn_open') && c.value === '开启'))
runner.send({ type: 'action', id: form.id, action: 'close' })
const error = await runner.next('exception')
expect(error.message).toContain('ThreadStateException')
expect(error.message).toContain('先调用 Resume()')
}, 30000)
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */ /** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
async function runMain(name: string, body: string, usings = '') { async function runMain(name: string, body: string, usings = '') {
const dir = resolve(ROOT, '.runs', `test-${name}`) const dir = resolve(ROOT, '.runs', `test-${name}`)
@@ -347,6 +393,96 @@ async function runMain(name: string, body: string, usings = '') {
return { runner, dir } return { runner, dir }
} }
test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠中被终止、ThreadState', async () => {
const { runner } = await runMain(
'legacy-threads',
`
int count = 0;
Thread worker = new Thread(() => { while (true) { count++; Thread.Sleep(20); } });
Console.WriteLine("state0=" + worker.ThreadState);
worker.Start();
Thread.Sleep(200);
worker.Suspend();
Thread.Sleep(100);
int frozen = count;
Console.WriteLine("state1=" + worker.ThreadState);
Thread.Sleep(200);
Console.WriteLine("frozen=" + (count == frozen));
try { worker.Abort(); } catch (ThreadStateException) { Console.WriteLine("abortSuspended=error"); }
worker.Resume();
Console.WriteLine("joined=" + worker.Join(1000) + "," + worker.ThreadState);
Thread self = null;
self = new Thread(() => { self.Suspend(); Console.WriteLine("self=resumed"); });
self.Start();
Thread.Sleep(100);
Console.WriteLine("selfState=" + self.ThreadState);
self.Resume();
self.Join(1000);
Thread sleeper = new Thread(new ThreadStart(Sleepy));
sleeper.Start();
Thread.Sleep(100);
try { sleeper.Resume(); } catch (ThreadStateException) { Console.WriteLine("resumeRunning=error"); }
sleeper.Abort();
Console.WriteLine("sleeperJoined=" + sleeper.Join(1000));
Thread unstarted = new Thread(Sleepy);
unstarted.Abort();
Console.WriteLine("unstarted=" + unstarted.ThreadState);
var param = new Thread(o => Console.WriteLine("param=" + o));
param.Start(42);
param.Join();
}
static void Sleepy()
{
Thread.Sleep(100000);
Console.WriteLine("sleeper=woke");`,
'using System.Threading;',
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
for (const line of [
'state0=Unstarted', 'state1=Suspended', 'frozen=True', 'abortSuspended=error', 'joined=True,Stopped',
'selfState=Suspended', 'self=resumed', 'resumeRunning=error', 'sleeperJoined=True', 'unstarted=Unstarted, AbortRequested', 'param=42',
]) {
expect(output).toContain(line)
}
expect(output).not.toContain('sleeper=woke')
}, 30000)
test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click,Text 是选中项;双击触发 DoubleClick', async () => {
const { runner } = await runMain(
'listbox',
`
var form = new Form();
var list = new ListBox { Name = "list" };
list.Items.AddRange(new object[] { "甲", "乙", "丙" });
list.SelectedIndexChanged += (s, e) => Console.WriteLine("changed=" + list.SelectedIndex + "," + list.Text);
list.Click += (s, e) => Console.WriteLine("click");
list.DoubleClick += (s, e) => { Console.WriteLine("double=" + list.SelectedItem); list.Items.RemoveAt(1); };
form.Controls.Add(list);
Application.Run(form);`,
'using System.Windows.Forms;',
)
const { form } = await runner.waitFor('formShown')
const list = findByName(form, 'list')
expect(list.props.Items).toEqual(['甲', '乙', '丙'])
runner.send({ type: 'action', id: list.id, action: 'select', index: 1 })
await runner.waitFor('console', (m) => m.text === 'click\n')
runner.send({ type: 'action', id: list.id, action: 'doubleClick' })
// 删掉选中的项后取消选中,浏览器收到新的列表
const patch = await runner.next('patch', (m) => m.changes.some((c) => c.prop === 'Items'))
expect(patch.changes).toContainEqual({ id: list.id, prop: 'SelectedIndex', value: -1 })
expect(patch.changes).toContainEqual({ id: list.id, prop: 'Items', value: ['甲', '丙'] })
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n')
}, 30000)
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => { test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
process.env.SIM_TEST_SECRET = '不该被看到' process.env.SIM_TEST_SECRET = '不该被看到'
const { runner } = await runMain( const { runner } = await runMain(
+1
View File
@@ -107,6 +107,7 @@ export type UserAction =
| { action: 'text'; value: string } | { action: 'text'; value: string }
| { action: 'dropDown' } | { action: 'dropDown' }
| { action: 'select'; index: number } | { action: 'select'; index: number }
| { action: 'doubleClick' }
| { action: 'mouseDown'; button: 'left' | 'right'; x: number; y: number } | { action: 'mouseDown'; button: 'left' | 'right'; x: number; y: number }
| { action: 'mouseUp'; button: 'left' | 'right'; x: number; y: number } | { action: 'mouseUp'; button: 'left' | 'right'; x: number; y: number }
| { action: 'close' } | { action: 'close' }
+20
View File
@@ -159,6 +159,26 @@ export const CONTROLS: ControlDef[] = [
events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }], events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }],
initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]], initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]],
}, },
{
type: `${FORMS}.ListBox`,
label: '列表框',
group: '常用控件',
namePrefix: 'listBox',
defaultEvent: 'SelectedIndexChanged',
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观',
},
],
events: [
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
{ name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT },
],
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
},
{ {
type: `${FORMS}.CheckBox`, type: `${FORMS}.CheckBox`,
label: '复选框', label: '复选框',
+1 -1
View File
@@ -63,7 +63,7 @@ export function previewTree(doc: DesignerDocument): ControlNode {
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) } const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑 // 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
props.Visible = true props.Visible = true
if (type === 'System.Windows.Forms.ComboBox') props.Items = getItems(doc, name).map((item) => { if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox') props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item) const v = parseValue(item)
return v.kind === 'string' ? v.value : item return v.kind === 'string' ? v.value : item
}) })
+24
View File
@@ -48,6 +48,9 @@ function selectItem(index: number) {
dropDownOpen.value = false dropDownOpen.value = false
act({ action: 'select', index }) act({ action: 'select', index })
} }
// ListBox
const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined) ?? 12}px`)
</script> </script>
<template> <template>
@@ -108,6 +111,27 @@ function selectItem(index: number) {
</ul> </ul>
</div> </div>
<!-- ListBox:点空白处只触发 Click,点某一项先选中再触发 Click -->
<div
v-else-if="node.type === 'ListBox'"
class="wf-control wf-listbox"
:class="[`wf-listbox-border-${p.BorderStyle ?? 'Fixed3D'}`, { 'wf-disabled': !enabled }]"
:style="style"
@click.self="act({ action: 'click' })"
>
<div
v-for="(item, index) in items"
:key="index"
class="wf-listbox-item"
:class="{ 'wf-listbox-selected': index === p.SelectedIndex }"
:style="{ height: itemHeight, lineHeight: itemHeight }"
@click="act({ action: 'select', index })"
@dblclick="act({ action: 'doubleClick' })"
>
{{ item }}
</div>
</div>
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) --> <!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
<div <div
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'" v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
+30
View File
@@ -151,6 +151,36 @@
color: #fff; color: #fff;
} }
.wf-listbox {
overflow-y: auto;
overflow-x: hidden;
background: #fff;
cursor: default;
}
.wf-listbox-border-Fixed3D {
border: 1px solid #828790;
}
.wf-listbox-border-FixedSingle {
border: 1px solid #646464;
}
.wf-listbox-item {
padding: 0 2px;
white-space: pre;
overflow: hidden;
}
.wf-listbox-selected {
background: #0078d7;
color: #fff;
}
.wf-disabled .wf-listbox-selected {
background: #bfbfbf;
}
.wf-check { .wf-check {
display: flex; display: flex;
align-items: center; align-items: center;