第 0 阶段:教材项目 2 任务 1 在网页中原样运行

- runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、
  GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、
  教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式)
- server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发
- web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏
- 端到端测试与 100 个程序并发的压力测试
- 教材逐任务摘要(docs/教材摘要)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:15:14 +08:00
commit 98be8331bc
71 files changed
+7919

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Collections;
using System.Drawing;
using System.Globalization;
using System.Text.Json.Nodes;
using System.Windows.Forms;
namespace Sim.Host;
/// <summary>把控件属性值转换成发给浏览器的 JSON(格式见 web/src/winforms/format.ts)</summary>
public static class PropValue
{
public static JsonNode? From(object? value) => value switch
{
null => null,
string s => JsonValueOf(s),
bool b => JsonValueOf(b),
char c => JsonValueOf(c == '\0' ? "" : c.ToString()),
int or long or short or byte => JsonValueOf(Convert.ToInt64(value, CultureInfo.InvariantCulture)),
float or double or decimal => JsonValueOf(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
Enum e => JsonValueOf(e.ToString()),
Point p => new JsonObject { ["x"] = p.X, ["y"] = p.Y },
Size s => new JsonObject { ["width"] = s.Width, ["height"] = s.Height },
Color c => ColorCss(c),
Font f => new JsonObject
{
["family"] = f.Name,
["size"] = f.SizeInPoints,
["bold"] = f.Bold,
["italic"] = f.Italic,
["underline"] = f.Underline,
["strikeout"] = f.Strikeout,
},
Padding p => new JsonObject { ["left"] = p.Left, ["top"] = p.Top, ["right"] = p.Right, ["bottom"] = p.Bottom },
IEnumerable list => ListOf(list),
_ => JsonValueOf(value.ToString() ?? ""),
};
/// <summary>未设置的颜色(Color.Empty)返回 null,表示沿用父控件的颜色</summary>
public static JsonNode? ColorCss(Color color)
{
if (color.IsEmpty) return null;
if (color.A == 0) return "transparent";
if (color.A < 255)
return $"rgba({color.R},{color.G},{color.B},{(color.A / 255.0).ToString("0.###", CultureInfo.InvariantCulture)})";
return $"#{color.R:x2}{color.G:x2}{color.B:x2}";
}
private static JsonArray ListOf(IEnumerable items)
{
var array = new JsonArray();
foreach (var item in items) array.Add(From(item is string or null ? item : item.ToString()));
return array;
}
private static JsonNode JsonValueOf<T>(T value) => JsonValue.Create(value)!;
}
+26
View File
@@ -0,0 +1,26 @@
// 运行器与服务器之间的消息格式,对应 shared/protocol.ts,两边修改时要同步。
// 运行器 → 服务器的消息在各处用 JsonObject 直接构造;这里只集中定义从服务器收到的动作。
using System.Text.Json.Nodes;
namespace Sim.Host;
/// <summary>浏览器上报的用户操作(shared/protocol.ts 中的 UserAction)</summary>
public sealed class UserAction
{
public required string Action { get; init; }
public string? Value { get; init; }
public int Index { get; init; }
public string Button { get; init; } = "left";
public int X { get; init; }
public int Y { get; init; }
public static UserAction FromJson(JsonObject json) => new()
{
Action = (string?)json["action"] ?? "",
Value = (string?)json["value"],
Index = (int?)json["index"] ?? -1,
Button = (string?)json["button"] ?? "left",
X = (int?)json["x"] ?? 0,
Y = (int?)json["y"] ?? 0,
};
}
+148
View File
@@ -0,0 +1,148 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Windows.Forms;
namespace Sim.Host;
/// <summary>
/// 运行器的全局状态:向服务器发消息、收集控件属性变化、登记控件、等待浏览器的应答。
/// </summary>
public static class SimHost
{
// 中文原样输出,不转义成 \uXXXX
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
private static readonly object WriteLock = new();
private static readonly List<JsonObject> PendingChanges = new();
private static readonly ConcurrentDictionary<string, Control> ControlsById = new();
private static readonly ConcurrentDictionary<int, PendingRequest> Requests = new();
private static TextWriter _output = TextWriter.Null;
private static int _nextControlId;
private static int _nextRequestId;
public static UiDispatcher Ui { get; } = new();
/// <summary>学生程序所在的程序集,用于筛选异常堆栈中属于学生代码的部分</summary>
public static Assembly? StudentAssembly { get; set; }
/// <summary>运行器启动时调用:协议消息写到真正的标准输出(学生的 Console 输出另行转发)</summary>
public static void Initialize(Stream stdout)
{
_output = new StreamWriter(stdout, new UTF8Encoding(false)) { AutoFlush = true };
}
// ---------- 发送消息 ----------
public static void Send(JsonObject message)
{
lock (WriteLock)
{
FlushChangesLocked();
_output.WriteLine(message.ToJsonString(JsonOptions));
}
}
/// <summary>记录一个已显示控件的属性变化;在界面线程上会合并到本轮消息处理结束时一起发送</summary>
public static void QueueChange(Control control, string prop, object? value)
{
var change = new JsonObject { ["id"] = control.SimId, ["prop"] = prop, ["value"] = PropValue.From(value) };
lock (WriteLock)
{
PendingChanges.Add(change);
if (!Ui.IsUiThread) FlushChangesLocked();
}
}
public static void FlushChanges()
{
lock (WriteLock) FlushChangesLocked();
}
private static void FlushChangesLocked()
{
if (PendingChanges.Count == 0) return;
var changes = new JsonArray();
foreach (var change in PendingChanges) changes.Add(change);
PendingChanges.Clear();
_output.WriteLine(new JsonObject { ["type"] = "patch", ["changes"] = changes }.ToJsonString(JsonOptions));
}
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
{
["type"] = "device",
["device"] = device,
["action"] = action,
["data"] = PropValue.From(data),
});
// ---------- 异常 ----------
public static void ReportException(Exception ex)
{
while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!;
Send(new JsonObject
{
["type"] = "exception",
["message"] = $"{ex.GetType().FullName}: {ex.Message}",
["detail"] = StudentStackTrace(ex),
});
}
/// <summary>只保留学生代码的堆栈行,去掉仿真库内部的调用</summary>
private static string StudentStackTrace(Exception ex)
{
var lines = (ex.StackTrace ?? "").Split('\n').Select(l => l.TrimEnd());
var studentLines = lines.Where(l => l.Contains(".cs:") && !l.Contains("/runtime/Sim.")).ToList();
return string.Join("\n", studentLines.Count > 0 ? studentLines : lines);
}
// ---------- 控件登记 ----------
public static string NewControlId() => Interlocked.Increment(ref _nextControlId).ToString();
public static void Register(Control control) => ControlsById[control.SimId] = control;
public static Control? FindControl(string id) => ControlsById.TryGetValue(id, out var control) ? control : null;
// ---------- 请求与应答(如 MessageBox 等待用户点按钮) ----------
/// <summary>
/// 向浏览器发出请求并阻塞等待应答。在界面线程上调用时会继续处理消息队列(和真实的模态对话框一样,Timer 照常触发)。
/// </summary>
public static JsonNode? Request(JsonObject message)
{
var id = Interlocked.Increment(ref _nextRequestId);
var pending = new PendingRequest();
Requests[id] = pending;
message["requestId"] = id;
Send(message);
if (Ui.IsUiThread) Ui.RunUntil(() => pending.Done.IsSet);
else pending.Done.Wait();
Requests.TryRemove(id, out _);
return pending.Result;
}
public static void CompleteRequest(int id, JsonNode? result)
{
if (!Requests.TryGetValue(id, out var pending)) return;
pending.Result = result;
pending.Done.Set();
Ui.Wake();
}
private sealed class PendingRequest
{
public ManualResetEventSlim Done { get; } = new();
public JsonNode? Result { get; set; }
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
namespace Sim.Host;
/// <summary>
/// 模拟 WinForms 的界面线程消息循环:所有用户操作、Timer.Tick、Invoke 都排队到界面线程上依次执行。
/// </summary>
public sealed class UiDispatcher
{
private readonly BlockingCollection<Action> _queue = new();
public Thread? UiThread { get; private set; }
public bool IsUiThread => Thread.CurrentThread == UiThread;
/// <summary>由运行器在启动学生程序的线程上调用一次</summary>
public void BindToCurrentThread() => UiThread = Thread.CurrentThread;
public void Post(Action action) => _queue.Add(action);
/// <summary>在界面线程上同步执行(Control.Invoke、SynchronizationContext.Send)</summary>
public void Send(Action action)
{
if (IsUiThread)
{
action();
return;
}
using var done = new ManualResetEventSlim();
Exception? error = null;
Post(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
finally { done.Set(); }
});
done.Wait();
if (error != null) throw new System.Reflection.TargetInvocationException(error);
}
/// <summary>
/// 运行消息循环,直到 <paramref name="done"/> 为真。可以嵌套:
/// Application.Run、ShowDialog、MessageBox.Show 都会在这里等待,期间 Timer 等照常执行。
/// </summary>
public void RunUntil(Func<bool> done)
{
while (!done())
{
if (_queue.TryTake(out var action, 50)) Execute(action);
}
}
/// <summary>处理队列里已有的消息后返回(Application.DoEvents)</summary>
public void DoEvents()
{
while (_queue.TryTake(out var action)) Execute(action);
}
/// <summary>唤醒等待中的消息循环,让它重新检查结束条件</summary>
public void Wake() => Post(static () => { });
private static void Execute(Action action)
{
try
{
action();
}
catch (Exception ex)
{
// 真实 WinForms 会弹出"未经处理的异常"对话框,点"继续"后程序接着运行,这里同样继续
SimHost.ReportException(ex);
}
finally
{
SimHost.FlushChanges();
}
}
}