- 运行区:编译错误、运行异常、控制台输出和保存的图片移到窗体下面,不再被实训台挤成 0 高;缩放和放大按钮移到桌面上方,不压窗体的关闭按钮;放大后也能停止程序;点停止后清掉窗体 - 运行异常:常见 .NET 异常配中文说明,显示出错的文件、行号和方法,点击跳到代码 - 运行中的下拉列表挂到页面最外层,能伸出窗体和分组框(小窗体也能选到 COM4、COM5) - 学生登录后进"我的作业";任务面板从工具栏下面开始,不挡导航;记住上次做的实验,从数据库页面返回、恢复数据时默认用它 - 代码页和设计页左栏都占 3/5,文件标签放不下时换行;实验选择框加宽;关闭粘性滚动 - 设计器:双击生成的事件方法带缩进;双击工具箱放进选中控件所在的容器并错开位置;新控件比容器宽时按比例缩小;无边框窗体不画标题栏 - 中文界面:代码编辑器(查找替换等)、属性窗口的颜色选项 - SQL:DATEADD、DATEDIFF、DATEPART 的日期单位(day、hour)解析成 IdentifierLiteral 时报"不支持",补上 - 项目 8 的预置环境数据改成建库那天之前的 7 天;p6-t3 评分项改为"按日期查询环境数据" - 没有 AI 建议时不显示"全部采纳 AI 建议"和相关说明;导出 CSV 只给纯数字的文字加制表符 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
9.6 KiB
C#
236 lines
9.6 KiB
C#
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));
|
||
}
|
||
|
||
// ---------- 图片 ----------
|
||
|
||
private static readonly HashSet<string> SentImages = new();
|
||
|
||
/// <summary>
|
||
/// 学生程序的"程序所在目录"(AppDomain.CurrentDomain.BaseDirectory),可写,程序结束后清掉。
|
||
/// 运行器启动时设定;保存的图片按相对这里的路径显示在网页的照片列表里。
|
||
/// </summary>
|
||
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||
|
||
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
|
||
public static string ImageKey(System.Drawing.Image image) => DataKey(image.SimKey, image.Data, image.MimeType);
|
||
|
||
/// <summary>
|
||
/// 图片、声音等数据在浏览器端的编号(key 通常是内容的哈希):第一次用到时先把数据发过去,之后只发编号。
|
||
/// </summary>
|
||
public static string DataKey(string key, byte[] data, string mime)
|
||
{
|
||
bool isNew;
|
||
lock (SentImages) isNew = SentImages.Add(key);
|
||
if (isNew)
|
||
{
|
||
Send(new JsonObject
|
||
{
|
||
["type"] = "blob",
|
||
["key"] = key,
|
||
["mime"] = mime,
|
||
["data"] = Convert.ToBase64String(data),
|
||
});
|
||
}
|
||
return key;
|
||
}
|
||
|
||
/// <summary>学生程序用 Image.Save 保存了图片:网页的照片列表里显示出来</summary>
|
||
public static void ImageSaved(string fullPath, System.Drawing.Image image)
|
||
{
|
||
var path = fullPath.StartsWith(AppBaseDirectory, StringComparison.Ordinal) ? fullPath[AppBaseDirectory.Length..] : fullPath;
|
||
Send(new JsonObject { ["type"] = "imageSaved", ["path"] = path, ["image"] = ImageKey(image) });
|
||
}
|
||
|
||
public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text });
|
||
|
||
public static void SendDevice(string device, string action, object? data)
|
||
{
|
||
LegacyThreads.CheckPoint();
|
||
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!;
|
||
var type = ex.GetType().FullName;
|
||
// 仿真库自己抛的异常信息已经是中文;.NET 自带的是英文,常见的几种配上中文说明,原文放在括号里
|
||
var message = HasChinese(ex.Message) ? $"{ex.Message}({type})"
|
||
: ExplainException(ex) is { } explained ? $"{explained}({type}: {ex.Message})"
|
||
: $"{type}: {ex.Message}";
|
||
var frame = StudentFrame(ex);
|
||
var result = new JsonObject
|
||
{
|
||
["type"] = "exception",
|
||
["message"] = message,
|
||
["detail"] = frame is { } f ? $"出错位置:{f.File} 第 {f.Line} 行({f.Method} 方法)" : StudentStackTrace(ex),
|
||
};
|
||
if (frame is { } at)
|
||
{
|
||
result["file"] = at.File;
|
||
result["line"] = at.Line;
|
||
}
|
||
Send(result);
|
||
}
|
||
|
||
private static bool HasChinese(string text) => text.Any(c => c >= '\u4e00' && c <= '\u9fff');
|
||
|
||
private static string? ExplainException(Exception ex) => ex switch
|
||
{
|
||
FormatException => "文字转换成数字或日期时格式不对,比如文本框是空的,或者里面不是数字",
|
||
NullReferenceException => "用到的对象是 null:变量还没有赋值,或者对象、窗体还没有 new 出来",
|
||
IndexOutOfRangeException => "数组的下标超出了范围:下标从 0 开始,最大是长度减 1",
|
||
ArgumentOutOfRangeException => "下标或参数超出了范围,比如列表、字符串的下标太大,或者下拉框还没有选中",
|
||
DivideByZeroException => "整数除以了 0",
|
||
OverflowException => "数字太大或太小,超出了这个类型能表示的范围",
|
||
InvalidCastException => "类型转换失败,这个对象不能转换成要求的类型",
|
||
KeyNotFoundException => "字典里没有这个键",
|
||
FileNotFoundException => "找不到这个文件",
|
||
DirectoryNotFoundException => "找不到这个文件夹",
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>堆栈里离出错点最近的一行学生代码:文件名、行号、方法名</summary>
|
||
private static (string File, int Line, string Method)? StudentFrame(Exception ex)
|
||
{
|
||
foreach (var frame in new System.Diagnostics.StackTrace(ex, true).GetFrames())
|
||
{
|
||
var file = frame.GetFileName();
|
||
var line = frame.GetFileLineNumber();
|
||
if (file == null || line <= 0 || file.Contains("/runtime/Sim.")) continue;
|
||
return (Path.GetFileName(file), line, frame.GetMethod()?.Name ?? "");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
LegacyThreads.CheckPoint();
|
||
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; }
|
||
}
|
||
}
|