diff --git a/README.md b/README.md
index 4fdde48..97db155 100644
--- a/README.md
+++ b/README.md
@@ -66,14 +66,15 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
已完成:
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
-- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。
-- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1,共 10 个。网址带 `?sample=示例编号` 可直接打开指定示例。
+- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox,教材控件库的 Lamp、Alarm。
+- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1–3,共 12 个。网址带 `?sample=示例编号` 可直接打开指定示例。
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 第 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、图表)。
diff --git a/docs/设计方案.md b/docs/设计方案.md
index 4da315d..7fd7958 100644
--- a/docs/设计方案.md
+++ b/docs/设计方案.md
@@ -166,7 +166,7 @@ IOT/
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS |
-| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;计划:编译时改写旧版线程 API |
+| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
@@ -211,7 +211,7 @@ IOT/
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
| 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 | |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | |
@@ -236,7 +236,7 @@ flowchart TB
P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔
评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
- P3["阶段 3 · 扩展(下学期)
教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
+ P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"]
P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
@@ -276,6 +276,10 @@ flowchart TB
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
- 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。
@@ -291,7 +295,7 @@ flowchart TB
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
| 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` 等少量类型,只传递和保存图片 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs
index 9364640..cf346b5 100644
--- a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs
+++ b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs
@@ -79,6 +79,7 @@ public class ADAM4017Helper
///
public ADAM4017ResModel SetData()
{
+ LegacyThreads.CheckPoint();
var model = new ADAM4017ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
model.temperatureValue = Read(0);
@@ -110,6 +111,7 @@ public class ADAM4150Helper
/// 读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false
public ADAM4150ResModel SetData()
{
+ LegacyThreads.CheckPoint();
var model = new ADAM4150ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
model.bodyInfraredValue = Read(0);
diff --git a/runtime/Sim.Runner/ConsoleForwarder.cs b/runtime/Sim.Runner/ConsoleForwarder.cs
index 72982d2..df9d9fd 100644
--- a/runtime/Sim.Runner/ConsoleForwarder.cs
+++ b/runtime/Sim.Runner/ConsoleForwarder.cs
@@ -7,6 +7,9 @@ namespace Sim.Runner;
/// 把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)
internal sealed class ConsoleForwarder : TextWriter
{
+ /// 一直不换行的输出(如教材的 while (true) Console.Write("A"))攒到这么多字符就先发出去
+ private const int MaxBuffered = 4096;
+
private readonly StringBuilder _buffer = new();
public override Encoding Encoding => Encoding.UTF8;
@@ -16,7 +19,7 @@ internal sealed class ConsoleForwarder : TextWriter
lock (_buffer)
{
_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)
{
_buffer.Append(value);
- if (value.Contains('\n')) FlushBuffer();
+ if (value.Contains('\n') || _buffer.Length >= MaxBuffered) FlushBuffer();
}
}
diff --git a/runtime/Sim.Runner/LanguageServer.cs b/runtime/Sim.Runner/LanguageServer.cs
index 3f5436c..69edc07 100644
--- a/runtime/Sim.Runner/LanguageServer.cs
+++ b/runtime/Sim.Runner/LanguageServer.cs
@@ -244,6 +244,7 @@ internal static class LanguageServer
foreach (var diagnostic in compilation.GetDiagnostics()
.Concat(ApiPolicy.Check(compilation))
.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))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
diff --git a/runtime/Sim.Runner/LegacyThreadRewriter.cs b/runtime/Sim.Runner/LegacyThreadRewriter.cs
new file mode 100644
index 0000000..1f8ba2b
--- /dev/null
+++ b/runtime/Sim.Runner/LegacyThreadRewriter.cs
@@ -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;
+
+///
+/// 编译前改写学生代码里的旧版线程 API,让教材项目 5 的 Suspend / Resume / Abort 能在新版 .NET 上运行:
+///
+/// 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)
+///
+/// 只改写语义上确实是 System.Threading.Thread 成员的调用;改写不换行,报错和异常堆栈里的行号不变。
+/// 学生在编辑器里看到的仍是原来的代码。
+///
+internal static class LegacyThreadRewriter
+{
+ private const string Target = "global::Sim.Host.LegacyThreads";
+
+ /// 改写整个编译;没有需要改写的地方时原样返回
+ 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;
+ }
+
+ /// 平台支持的过时线程 API 不再提示"已过时"(边写边检查时用)
+ 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));
+
+ /// new Thread(start) 的第一个参数包一层 LegacyThreads.Wrap,线程被 Abort 时安静地结束
+ 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}");
+ }
+}
diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs
index 5b596f8..3fd6a87 100644
--- a/runtime/Sim.Runner/StudentCompiler.cs
+++ b/runtime/Sim.Runner/StudentCompiler.cs
@@ -56,7 +56,8 @@ internal static class StudentCompiler
using var peStream = 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));
var denied = ApiPolicy.Check(compilation).ToList();
diff --git a/runtime/Sim.WinForms/Forms/ComboBox.cs b/runtime/Sim.WinForms/Forms/ComboBox.cs
index 495b2a5..e37c6ee 100644
--- a/runtime/Sim.WinForms/Forms/ComboBox.cs
+++ b/runtime/Sim.WinForms/Forms/ComboBox.cs
@@ -1,15 +1,11 @@
-using System.Collections;
-using System.Collections.Generic;
using System.Drawing;
-using System.Linq;
using System.Text.Json.Nodes;
using Sim.Host;
namespace System.Windows.Forms;
-public class ComboBox : Control
+public class ComboBox : ListControl
{
- private int _selectedIndex = -1;
private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown;
public ComboBox() => Items = new ObjectCollection(this);
@@ -31,15 +27,15 @@ public class ComboBox : Control
set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle));
}
- public int SelectedIndex
+ public override int SelectedIndex
{
- get => _selectedIndex;
+ get => SelectedIndexValue;
set
{
if (value < -1 || value >= Items.Count)
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。");
- if (value == _selectedIndex) return;
- SetProp(ref _selectedIndex, value, nameof(SelectedIndex));
+ if (value == SelectedIndexValue) return;
+ SetProp(ref SelectedIndexValue, value, nameof(SelectedIndex));
// 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致)
Text = value >= 0 ? Items[value]?.ToString() ?? "" : "";
OnSelectedIndexChanged(EventArgs.Empty);
@@ -49,19 +45,17 @@ public class ComboBox : Control
public object? SelectedItem
{
- get => _selectedIndex >= 0 ? Items[_selectedIndex] : null;
+ get => SelectedIndexValue >= 0 ? Items[SelectedIndexValue] : null;
set => SelectedIndex = value == null ? -1 : Items.IndexOf(value);
}
public string SelectedText { get; set; } = "";
public event EventHandler? SelectedIndexChanged;
- public event EventHandler? SelectedValueChanged;
public event EventHandler? DropDown;
public event EventHandler? DropDownClosed;
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 OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e);
@@ -84,7 +78,7 @@ public class ComboBox : Control
case "text" when _dropDownStyle != ComboBoxStyle.DropDownList:
SetTextFromUser(action.Value ?? "");
var match = Items.IndexOf(Text);
- if (match != _selectedIndex) _selectedIndex = match;
+ if (match != SelectedIndexValue) SelectedIndexValue = match;
break;
default:
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), Items.Select(i => i?.ToString() ?? "").ToArray());
+ 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(Items.Select(i => i?.ToString() ?? "").ToArray());
- props["SelectedIndex"] = _selectedIndex;
+ props["Items"] = PropValue.From(ItemTexts(Items));
+ props["SelectedIndex"] = SelectedIndexValue;
props["DropDownStyle"] = _dropDownStyle.ToString();
}
- public class ObjectCollection : IList, IEnumerable