using System.Diagnostics; using System.Runtime.InteropServices; using System.Security; using System.Text; using System.Text.Json; namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form { private const int ContentWidth = 1020; private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765"); private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant()); private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true); private readonly Button register = CreateButton("注册并连接", 128, primary: true); private readonly Button reconnect = CreateButton("立即重连", 112, primary: true); private readonly Button resetIdentity = CreateButton("清除本机身份", 132, danger: true); private readonly Button copyDiagnostics = CreateButton("复制诊断信息", 132); private readonly TextBox dataRoot = CreateTextBox(); private readonly Button changeDataRoot = CreateButton("更改并迁移", 124, primary: true); private readonly Button openDataRoot = CreateButton("打开目录", 104); private readonly Label dataRootStatus = CreateBodyLabel(); private readonly Button runAnsysAcceptance = CreateButton("运行内置基准验收", 180, primary: true); private readonly Button enableAnsysGate = CreateButton("开启 ANSYS 执行门", 164); private readonly CheckBox startAtLogin = new() { Text = "登录 Windows 后自动启动节点", AutoSize = true, Margin = new Padding(0, 4, 0, 8), }; private readonly Label state = new() { AutoSize = true, Anchor = AnchorStyles.Right, Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), Margin = new Padding(12, 2, 0, 2), Padding = new Padding(10, 5, 10, 5), }; private readonly Label detail = CreateBodyLabel(); private readonly Label identity = CreateBodyLabel(); private readonly Label capabilitySummary = CreateBodyLabel(); private readonly Label ansysAcceptanceStatus = CreateBodyLabel(); private readonly ProgressBar ansysAcceptanceProgress = new() { Style = ProgressBarStyle.Marquee, MarqueeAnimationSpeed = 30, Height = 8, Dock = DockStyle.Top, Visible = false, Margin = new Padding(0, 8, 0, 5), }; private readonly Label jobSummary = CreateBodyLabel(); private readonly Label jobDetail = CreateBodyLabel(); private readonly DataGridView jobGrid = CreateJobGrid(); private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory); private readonly NodeAdapterRegistry adapters; private readonly Dictionary softwareControls = new(StringComparer.Ordinal); private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 }; private readonly TableLayoutPanel registrationCard; private readonly TableLayoutPanel ansysAcceptanceCard; private bool changingStartup; private NodeConfig? currentConfig; private string? passedAcceptanceReport; private CancellationTokenSource? acceptanceStop; private CancellationTokenSource? runtimeInstallStop; private bool dataRootMigrationInProgress; internal event Func? RegisterRequested; internal event Action? ReconnectRequested; internal event Action? ResetIdentityRequested; internal event Func>? DataRootMigrationRequested; internal ConfigurationForm() { adapters = NodeAdapterRegistry.CreateDefault(jobInbox); Text = "zcbot Windows Node"; AutoScaleMode = AutoScaleMode.Dpi; ClientSize = new Size(1100, 760); MinimumSize = new Size(900, 680); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Microsoft YaHei UI", 9); FormBorderStyle = FormBorderStyle.Sizable; BackColor = Color.FromArgb(246, 248, 252); var shell = new TableLayoutPanel { Dock = DockStyle.Fill, AutoScroll = true, ColumnCount = 3, RowCount = 1, Padding = new Padding(0, 28, 0, 28), BackColor = BackColor, }; shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); shell.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, ContentWidth)); shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); Controls.Add(shell); var page = new TableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, RowCount = 8, BackColor = BackColor, }; page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); shell.Controls.Add(page, 1, 0); var heading = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Top, ColumnCount = 1, Margin = new Padding(4, 0, 4, 22), }; heading.Controls.Add(new Label { Text = "LOCAL EXECUTION NODE", AutoSize = true, Font = new Font("Segoe UI", 8, FontStyle.Bold), ForeColor = Color.FromArgb(37, 99, 235), Margin = new Padding(1, 0, 0, 5), }); heading.Controls.Add(new Label { Text = "zcbot Windows Node", AutoSize = true, Font = new Font("Microsoft YaHei UI", 23, FontStyle.Bold), ForeColor = Color.FromArgb(15, 23, 42), }); var headingHint = CreateHint("连接本机科研软件与 zcbot,安全执行受控任务"); headingHint.Font = new Font("Microsoft YaHei UI", 10); heading.Controls.Add(headingHint); page.Controls.Add(heading); var statusCard = CreateCard(); var statusHeader = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Top, ColumnCount = 2, Margin = new Padding(0, 0, 0, 8), }; statusHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); statusHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); statusHeader.Controls.Add(CreateSectionTitle("节点状态"), 0, 0); statusHeader.Controls.Add(state, 1, 0); statusCard.Controls.Add(statusHeader); statusCard.Controls.Add(detail); statusCard.Controls.Add(identity); statusCard.Controls.Add(CreateDivider()); statusCard.Controls.Add(CreateSubsectionTitle("能力概览")); statusCard.Controls.Add(capabilitySummary); var resetActions = CreateActions(); resetActions.Controls.Add(reconnect); resetActions.Controls.Add(copyDiagnostics); resetActions.Controls.Add(resetIdentity); statusCard.Controls.Add(resetActions); page.Controls.Add(statusCard); var softwareCard = CreateCard(); softwareCard.Controls.Add(CreateSectionTitle("专业软件")); softwareCard.Controls.Add(CreateHint( "只需配置这台电脑实际拥有的软件。应用位置和运行环境彼此独立,不会影响其他软件。")); foreach (var definition in SoftwareRuntimeManager.Definitions) { softwareCard.Controls.Add(CreateSoftwarePanel(definition)); } page.Controls.Add(softwareCard); ansysAcceptanceCard = CreateCard(); ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收")); ansysAcceptanceCard.Controls.Add(CreateHint( "执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face;先验证完整进程树取消,再连续求解 20 次并检查结果与进程释放。")); ansysAcceptanceCard.Controls.Add(ansysAcceptanceStatus); ansysAcceptanceCard.Controls.Add(ansysAcceptanceProgress); var acceptanceActions = CreateActions(); acceptanceActions.Controls.Add(runAnsysAcceptance); acceptanceActions.Controls.Add(enableAnsysGate); ansysAcceptanceCard.Controls.Add(acceptanceActions); page.Controls.Add(ansysAcceptanceCard); var jobsCard = CreateCard(); jobsCard.Controls.Add(CreateSectionTitle("本机任务")); jobsCard.Controls.Add(CreateHint( "仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。")); jobsCard.Controls.Add(jobSummary); jobsCard.Controls.Add(jobGrid); jobsCard.Controls.Add(jobDetail); page.Controls.Add(jobsCard); registrationCard = CreateCard(); registrationCard.Controls.Add(CreateSectionTitle("首次注册")); registrationCard.Controls.Add(CreateHint( "从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。")); AddField(registrationCard, "zcbot 服务地址", server, "云端填写站点根地址;本机测试使用 http://127.0.0.1:8765"); AddField(registrationCard, "节点名称", nodeName, "必须与注册码限定的节点名称完全一致"); AddField(registrationCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;仅用于首次注册,不会长期保存"); var registerActions = CreateActions(); registerActions.Controls.Add(register); registrationCard.Controls.Add(registerActions); page.Controls.Add(registrationCard); var runtimeCard = CreateCard(); runtimeCard.Controls.Add(CreateSectionTitle("运行设置")); runtimeCard.Controls.Add(startAtLogin); AddField( runtimeCard, "数据目录", dataRoot, "保存节点身份、任务、Workspace 和受管 runtime;迁移只支持本机固定磁盘。环境变量优先于界面设置。"); var dataRootActions = CreateActions(); dataRootActions.Controls.Add(changeDataRoot); dataRootActions.Controls.Add(openDataRoot); runtimeCard.Controls.Add(dataRootActions); runtimeCard.Controls.Add(dataRootStatus); runtimeCard.Controls.Add(CreateHint( "关闭此窗口后,节点仍在系统托盘运行。右键托盘图标可以立即重连或退出。")); page.Controls.Add(runtimeCard); var securityNote = CreateHint( "安全说明:Node Token 由 Windows DPAPI 加密保存,不在界面显示,也不会写入日志。"); securityNote.Margin = new Padding(4, 8, 4, 0); page.Controls.Add(securityNote); register.Click += async (_, _) => await RegisterAsync(); reconnect.Click += (_, _) => ReconnectRequested?.Invoke(); resetIdentity.Click += (_, _) => ResetIdentity(); copyDiagnostics.Click += (_, _) => CopyDiagnostics(); runAnsysAcceptance.Click += async (_, _) => await RunAnsysAcceptanceAsync(); enableAnsysGate.Click += (_, _) => EnableAnsysGate(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); changeDataRoot.Click += async (_, _) => await ChangeDataRootAsync(); openDataRoot.Click += (_, _) => OpenDataRoot(); FormClosing += (_, eventArgs) => { if (eventArgs.CloseReason == CloseReason.UserClosing) { eventArgs.Cancel = true; Hide(); } }; startAtLogin.Checked = StartupRegistration.IsEnabled; dataRoot.ReadOnly = true; RefreshDataRoot(); jobGrid.SelectionChanged += (_, _) => ShowSelectedJob(); jobRefreshTimer.Tick += (_, _) => RefreshJobs(); jobRefreshTimer.Start(); Disposed += (_, _) => { acceptanceStop?.Cancel(); acceptanceStop?.Dispose(); runtimeInstallStop?.Cancel(); runtimeInstallStop?.Dispose(); jobRefreshTimer.Dispose(); }; var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2"); ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true; enableAnsysGate.Enabled = false; UpdateAnsysAcceptanceStatus(); RefreshSoftwareCards(); RefreshJobs(); ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null); } internal void ApplyStatus(NodeStatus status, NodeConfig? config) { currentConfig = config; state.Text = status.State switch { NodeState.Online => "● 在线", NodeState.Connecting => "● 正在连接", NodeState.NotRegistered => "● 尚未注册", NodeState.AuthenticationRequired => "● 需要重新注册", NodeState.Offline => "● 离线", _ => "● 已停止", }; state.ForeColor = status.State switch { NodeState.Online => Color.FromArgb(21, 128, 61), NodeState.Connecting => Color.FromArgb(180, 83, 9), NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(185, 28, 28), _ => Color.FromArgb(71, 85, 105), }; state.BackColor = status.State switch { NodeState.Online => Color.FromArgb(240, 253, 244), NodeState.Connecting => Color.FromArgb(255, 251, 235), NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(254, 242, 242), _ => Color.FromArgb(241, 245, 249), }; detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}"; identity.Text = config is null ? "尚未建立节点身份" : $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}"; capabilitySummary.Text = config is null ? "注册后启用" : string.Join("\n", adapters.All.Select(FormatAdapterStatus)); copyDiagnostics.Enabled = config is not null; UpdateAnsysAcceptanceStatus(); var registered = config is not null; registrationCard.Visible = !registered; reconnect.Visible = registered; resetIdentity.Visible = registered; register.Enabled = !registered && status.State != NodeState.Connecting; if (registered) { server.Text = config!.ServerUrl.AbsoluteUri.TrimEnd('/'); nodeName.Text = config.NodeName; enrollmentCode.Clear(); } } private static string FormatAdapterStatus(INodeAdapter adapter) { var runtime = adapter.DetectRuntime(); var softwareName = runtime.Software == "OriginPro" ? "Origin" : runtime.Software; var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion) ? $"{softwareName} 版本未知" : $"{softwareName} {runtime.SoftwareVersion}"; var state = runtime.Health == "ready" ? "可用" : "需配置"; return $"{adapter.DisplayName} {state}\n" + $"{softwareVersion} · Adapter {adapter.AdapterVersion}"; } private Control CreateSoftwarePanel(SoftwareDefinition definition) { var panel = new BorderedTableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 2, Padding = new Padding(16, 14, 16, 14), Margin = new Padding(0, 10, 0, 0), BackColor = Color.FromArgb(248, 250, 252), BorderColor = Color.FromArgb(226, 232, 240), }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); var title = new Label { Text = definition.DisplayName, AutoSize = true, Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), ForeColor = Color.FromArgb(15, 23, 42), Margin = new Padding(0, 0, 0, 4), }; panel.Controls.Add(title, 0, 0); var path = CreateTextBox(); path.ReadOnly = true; path.Dock = DockStyle.Fill; path.Margin = new Padding(0, 7, 0, 10); path.BackColor = Color.White; panel.Controls.Add(path, 0, 1); panel.SetColumnSpan(path, 2); var status = CreateBodyLabel(); status.Anchor = AnchorStyles.Left; status.Margin = new Padding(0, 3, 16, 0); panel.Controls.Add(status, 0, 2); var actions = CreateActions(); actions.Anchor = AnchorStyles.Right; actions.Dock = DockStyle.None; actions.AutoSizeMode = AutoSizeMode.GrowAndShrink; actions.WrapContents = false; actions.Margin = new Padding(18, 0, 0, 0); var automatic = CreateButton("自动检测", 92); Button? choose = null; if (definition.PathKind != SoftwarePathKind.None) { choose = CreateButton("选择位置", 92); actions.Controls.Add(choose); } var install = CreateButton("安装 / 更新环境", 150, primary: true); actions.Controls.Add(automatic); actions.Controls.Add(install); panel.Controls.Add(actions, 1, 2); var controls = new SoftwareControls(path, status, automatic, choose, install); softwareControls.Add(definition.Id, controls); automatic.Click += (_, _) => ResetSoftwareLocation(definition); if (choose is not null) { choose.Click += (_, _) => ChooseSoftwareLocation(definition); } install.Click += async (_, _) => await InstallSoftwareRuntimeAsync(definition); return panel; } private void RefreshSoftwareCards() { foreach (var definition in SoftwareRuntimeManager.Definitions) { RefreshSoftwareCard(definition); } } private void RefreshSoftwareCard(SoftwareDefinition definition) { var controls = softwareControls[definition.Id]; var location = SoftwareRuntimeManager.ResolveLocation(definition); controls.Path.Text = location.Path ?? "未检测到应用位置"; controls.Path.ForeColor = location.Available ? Color.FromArgb(51, 65, 85) : Color.Firebrick; var runtime = Path.Combine( NodePaths.ForCurrentMachine().RootDirectory, "runtimes", definition.RuntimeId, "Scripts", "python.exe"); var runtimeState = File.Exists(runtime) ? "运行环境已安装" : "运行环境尚未安装"; controls.Status.Text = location.Available ? $"{runtimeState} · {location.Source}" : $"{runtimeState} · {location.Detail}"; controls.Status.ForeColor = location.Available ? Color.FromArgb(71, 85, 105) : Color.Firebrick; controls.Install.Enabled = location.Available && runtimeInstallStop is null; controls.Automatic.Enabled = runtimeInstallStop is null; if (controls.Choose is not null) controls.Choose.Enabled = runtimeInstallStop is null; } private void ResetSoftwareLocation(SoftwareDefinition definition) { try { SoftwareRuntimeManager.ClearConfiguredPath(definition); adapters.InvalidateRuntime(definition.RuntimeId); RefreshSoftwareCard(definition); RefreshCapabilitySummary(); } catch (Exception exception) when ( exception is IOException or SecurityException or UnauthorizedAccessException) { MessageBox.Show(exception.Message, "自动检测失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void ChooseSoftwareLocation(SoftwareDefinition definition) { string? selected = null; if (definition.PathKind == SoftwarePathKind.Executable) { using var dialog = new OpenFileDialog { Title = $"选择 {definition.DisplayName} 可执行文件", Filter = $"{definition.RequiredRelativePath}|{definition.RequiredRelativePath}|可执行文件 (*.exe)|*.exe", CheckFileExists = true, Multiselect = false, }; if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.FileName; } else if (definition.PathKind == SoftwarePathKind.Directory) { using var dialog = new FolderBrowserDialog { Description = $"选择 {definition.DisplayName} 安装根目录", UseDescriptionForTitle = true, ShowNewFolderButton = false, }; if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.SelectedPath; } if (selected is null) return; try { SoftwareRuntimeManager.SaveConfiguredPath(definition, selected); adapters.InvalidateRuntime(definition.RuntimeId); RefreshSoftwareCard(definition); RefreshCapabilitySummary(); } catch (Exception exception) when ( exception is ArgumentException or IOException or InvalidDataException or InvalidOperationException or SecurityException or UnauthorizedAccessException) { MessageBox.Show(exception.Message, "应用位置无效", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private async Task InstallSoftwareRuntimeAsync(SoftwareDefinition definition) { if (adapters.All.Any(item => definition.RuntimeId.Equals(item.RuntimeId, StringComparison.Ordinal) && item.HasActiveJobs)) { MessageBox.Show( $"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。", "暂不能安装", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var answer = MessageBox.Show( $"将为 {definition.DisplayName} 创建独立 Python 3.12 运行环境并安装固定依赖。是否继续?", "安装专业软件运行环境", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (answer != DialogResult.Yes) return; runtimeInstallStop = new CancellationTokenSource(); RefreshSoftwareCards(); var controls = softwareControls[definition.Id]; controls.Status.ForeColor = Color.DarkOrange; var progress = new Progress(line => controls.Status.Text = line); try { var result = await SoftwareRuntimeManager.InstallRuntimeAsync( definition, progress, runtimeInstallStop.Token); adapters.InvalidateRuntime(definition.RuntimeId); controls.Status.ForeColor = Color.ForestGreen; controls.Status.Text = result.Detail; MessageBox.Show( $"{result.Detail}\n{result.RuntimePath}", "安装完成", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (OperationCanceledException) { controls.Status.ForeColor = Color.DimGray; controls.Status.Text = "安装已取消,原运行环境保持不变。"; } catch (Exception exception) when ( exception is IOException or InvalidDataException or InvalidOperationException or SecurityException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { controls.Status.ForeColor = Color.Firebrick; controls.Status.Text = $"安装失败:{exception.Message}"; MessageBox.Show(exception.Message, "安装失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { runtimeInstallStop.Dispose(); runtimeInstallStop = null; RefreshSoftwareCards(); RefreshCapabilitySummary(); } } private void RefreshCapabilitySummary() { capabilitySummary.Text = currentConfig is null ? "注册后启用" : string.Join("\n", adapters.All.Select(FormatAdapterStatus)); } private void CopyDiagnostics() { if (currentConfig is null) { return; } try { Clipboard.SetText(BuildDiagnosticText(currentConfig)); MessageBox.Show( "诊断信息已复制,可直接粘贴到 Codex 对话。内容不包含 Node Token。", "已复制", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (ExternalException exception) { MessageBox.Show( $"无法写入剪贴板:{exception.Message}", "复制失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private string BuildDiagnosticText(NodeConfig config) { var paths = NodePaths.ForCurrentMachine(); var builder = new StringBuilder() .AppendLine("zcbot Windows Node diagnostics") .AppendLine($"Node: {config.NodeName}") .AppendLine($"Node ID: {config.NodeId}") .AppendLine($"Server: {config.ServerUrl}") .AppendLine($"Node version: {Application.ProductVersion}") .AppendLine($"OS: {Environment.OSVersion.VersionString}") .AppendLine($"Data root: {paths.RootDirectory}") .AppendLine($"ANSYS gate: {AnsysGateState()}"); foreach (var adapter in adapters.All.OrderBy(item => item.Capability, StringComparer.Ordinal)) { try { var runtime = adapter.DetectRuntime(); builder.AppendLine() .AppendLine($"Capability: {adapter.Capability}") .AppendLine($"Adapter: {adapter.AdapterVersion}") .AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}") .AppendLine($"Health: {runtime.Health}") .AppendLine($"Detail: {runtime.Detail}") .AppendLine($"Runtime: {adapter.RuntimePath}") .AppendLine($"Contract: {adapter.ContractPath}") .AppendLine($"Contract SHA-256: {adapter.ContractSha256}") .AppendLine( $"Workspace protocol: {(adapter.WorkspaceStateFilename is null ? "disabled" : "v1")}") .AppendLine( $"Workspace state: {adapter.WorkspaceStateFilename ?? "none"}"); } catch (Exception exception) when ( exception is IOException or InvalidOperationException or UnauthorizedAccessException) { builder.AppendLine() .AppendLine($"Capability: {adapter.Capability}") .AppendLine($"Adapter: {adapter.AdapterVersion}") .AppendLine($"Diagnostic error: {exception.Message}"); } } return builder.ToString(); } private async Task RunAnsysAcceptanceAsync() { var adapter = adapters.Find("ansys.mechanical.static_structural@v2"); if (adapter?.SupportsLocalAcceptance != true) { MessageBox.Show( "当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。", "无法验收", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } using var outputDialog = new FolderBrowserDialog { Description = "选择保存 ANSYS 验收报告的父目录", UseDescriptionForTitle = true, ShowNewFolderButton = true, }; if (outputDialog.ShowDialog(this) != DialogResult.OK) { return; } var answer = MessageBox.Show( "程序会使用内置标准试件执行一次进程树取消和连续 20 次真实静力求解,期间会获取并释放 ANSYS 许可证,可能耗时较长。确认开始?", "开始 ANSYS 真机验收", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); if (answer != DialogResult.Yes) { return; } var workRoot = Path.Combine( outputDialog.SelectedPath, $"zcbot-ansys-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}"); acceptanceStop?.Dispose(); acceptanceStop = new CancellationTokenSource(); runAnsysAcceptance.Enabled = false; enableAnsysGate.Enabled = false; ansysAcceptanceProgress.Visible = true; ansysAcceptanceStatus.ForeColor = Color.DarkOrange; ansysAcceptanceStatus.Text = "正在启动真机验收…"; var progress = new Progress(line => { ansysAcceptanceStatus.Text = line; }); try { var result = await adapter.RunLocalAcceptanceAsync( workRoot, progress, acceptanceStop.Token); passedAcceptanceReport = result.Passed ? result.ReportPath : null; enableAnsysGate.Enabled = result.Passed && AnsysGateState() != "enabled"; ansysAcceptanceStatus.ForeColor = result.Passed ? Color.ForestGreen : Color.Firebrick; ansysAcceptanceStatus.Text = result.Passed ? $"验收通过。报告:{result.ReportPath}\n请确认许可证管理端席位已释放,再开启执行门。" : $"验收未通过:{result.Detail}\n报告:{result.ReportPath}"; } catch (OperationCanceledException) { passedAcceptanceReport = null; ansysAcceptanceStatus.ForeColor = Color.DimGray; ansysAcceptanceStatus.Text = "验收已停止,执行门保持关闭。"; } catch (Exception exception) when ( exception is IOException or InvalidDataException or InvalidOperationException or JsonException or UnauthorizedAccessException) { passedAcceptanceReport = null; ansysAcceptanceStatus.ForeColor = Color.Firebrick; ansysAcceptanceStatus.Text = $"验收启动或执行失败:{exception.Message}"; } finally { ansysAcceptanceProgress.Visible = false; runAnsysAcceptance.Enabled = true; } } private void EnableAnsysGate() { if (passedAcceptanceReport is null || !AcceptanceReportPassed(passedAcceptanceReport)) { MessageBox.Show( "没有可验证的本次验收通过报告,执行门不会开启。", "无法开启", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var answer = MessageBox.Show( "请先在许可证管理端确认正常求解和取消后的席位均已释放。开启后需要完全退出并重新启动 Node 才会进入实际调度。确认开启机器级执行门?", "开启 ANSYS 执行门", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); if (answer != DialogResult.Yes) { return; } try { Environment.SetEnvironmentVariable( "ZCBOT_ANSYS_242_VALIDATED", "1", EnvironmentVariableTarget.Machine); enableAnsysGate.Enabled = false; ansysAcceptanceStatus.ForeColor = Color.ForestGreen; ansysAcceptanceStatus.Text = "机器级执行门已写入。请从托盘退出 Node 后重新启动;重启后 ANSYS 应显示可用。"; } catch (Exception exception) when ( exception is SecurityException or UnauthorizedAccessException) { MessageBox.Show( $"写入机器级环境变量需要管理员权限:{exception.Message}\n请退出 Node,以管理员身份启动后再次点击。", "权限不足", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private static bool AcceptanceReportPassed(string path) { try { using var document = JsonDocument.Parse(File.ReadAllBytes(path)); return document.RootElement.TryGetProperty("passed", out var passed) && passed.ValueKind == JsonValueKind.True; } catch (Exception exception) when ( exception is IOException or JsonException or UnauthorizedAccessException) { return false; } } private void UpdateAnsysAcceptanceStatus() { if (!ansysAcceptanceCard.Visible || ansysAcceptanceProgress.Visible) { return; } var gate = AnsysGateState(); if (passedAcceptanceReport is not null && AcceptanceReportPassed(passedAcceptanceReport)) { ansysAcceptanceStatus.ForeColor = Color.ForestGreen; ansysAcceptanceStatus.Text = gate == "enabled" ? "当前执行门:已开启。请完全退出并重新启动 Node,使新环境生效。" : $"验收通过。报告:{passedAcceptanceReport}\n请确认许可证管理端席位已释放,再开启执行门。"; enableAnsysGate.Enabled = gate != "enabled"; return; } ansysAcceptanceStatus.ForeColor = gate == "enabled" ? Color.ForestGreen : Color.FromArgb(71, 85, 105); ansysAcceptanceStatus.Text = gate == "enabled" ? "当前执行门:已开启。Node 重启并且 probe 正常后可接收 ANSYS 任务。" : "当前执行门:关闭。安装成功不代表验收通过,请先运行本页验收。"; enableAnsysGate.Enabled = false; } private static string AnsysGateState() => Environment.GetEnvironmentVariable( "ZCBOT_ANSYS_242_VALIDATED", EnvironmentVariableTarget.Machine) == "1" ? "enabled" : "disabled"; private void RefreshJobs() { Guid? selectedId = jobGrid.SelectedRows.Count > 0 && jobGrid.SelectedRows[0].Tag is JobDisplaySnapshot selected ? selected.JobId : null; IReadOnlyList snapshots; try { snapshots = jobInbox.ReadJobSnapshots(); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException) { jobSummary.Text = $"无法读取本机任务:{exception.Message}"; return; } jobGrid.Rows.Clear(); DataGridViewRow? rowToSelect = null; foreach (var snapshot in snapshots) { var index = jobGrid.Rows.Add( FormatJobStage(snapshot.Stage), snapshot.Title, snapshot.InputFilename, FormatProgress(snapshot), snapshot.AcceptedAt.LocalDateTime.ToString("MM-dd HH:mm"), snapshot.JobId.ToString("N")[..8]); var row = jobGrid.Rows[index]; row.Tag = snapshot; row.DefaultCellStyle.ForeColor = snapshot.Stage switch { "failed" => Color.Firebrick, "cloud_terminal" => Color.Firebrick, "cancelled" => Color.DimGray, "succeeded" => Color.ForestGreen, _ => Color.FromArgb(30, 41, 59), }; if (snapshot.JobId == selectedId) { rowToSelect = row; } } var active = snapshots.Count(item => item.IsActive); jobSummary.Text = snapshots.Count == 0 ? "暂无本机任务" : $"活动任务 {active} 个 · 最近记录 {snapshots.Count} 条"; if (rowToSelect is not null) { rowToSelect.Selected = true; jobGrid.CurrentCell = rowToSelect.Cells[0]; } else if (jobGrid.Rows.Count > 0) { jobGrid.Rows[0].Selected = true; jobGrid.CurrentCell = jobGrid.Rows[0].Cells[0]; } else { jobDetail.Text = "选择任务后可查看执行阶段、更新时间和完整 Job ID。"; } ShowSelectedJob(); } private void ShowSelectedJob() { if (jobGrid.SelectedRows.Count == 0 || jobGrid.SelectedRows[0].Tag is not JobDisplaySnapshot snapshot) { return; } jobDetail.Text = string.Join("\n", [ $"{FormatJobStage(snapshot.Stage)} · {snapshot.Detail}", $"能力:{snapshot.Capability}", $"Job ID:{snapshot.JobId}", $"更新时间:{snapshot.UpdatedAt.LocalDateTime:yyyy-MM-dd HH:mm:ss}", ]); } private static string FormatProgress(JobDisplaySnapshot snapshot) => snapshot.Stage == "software_running" ? $"已执行 {FormatElapsed(DateTimeOffset.UtcNow - snapshot.UpdatedAt)}" : $"{snapshot.Progress}%"; private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalHours >= 1 ? $"{(int)elapsed.TotalHours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}" : $"{elapsed.Minutes:00}:{elapsed.Seconds:00}"; private static string FormatJobStage(string stage) => stage switch { "accepted" => "等待处理", "downloading_inputs" => "下载输入", "ready_to_run" => "准备执行", "software_running" => "软件执行中", "uploading_outputs" => "上传结果", "succeeded" => "成功", "failed" => "失败", "cloud_terminal" => "云端已终止", "cancelled" => "已取消", _ => stage, }; private void ResetIdentity() { var answer = MessageBox.Show( "这会删除本机加密身份,随后需要使用新注册码重新注册。请先在管理后台删除或禁用云端旧节点。是否继续?", "清除本机节点身份", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); if (answer == DialogResult.Yes) { ResetIdentityRequested?.Invoke(); } } private async Task RegisterAsync() { if (RegisterRequested is null) { return; } try { register.Enabled = false; detail.Text = "正在注册…"; var options = EnrollOptions.Parse([ "--server", server.Text, "--name", nodeName.Text, "--code", enrollmentCode.Text]); await RegisterRequested(options); } catch (Exception exception) when (exception is NodeConfigurationException or HttpRequestException) { MessageBox.Show(exception.Message, "注册失败", MessageBoxButtons.OK, MessageBoxIcon.Error); register.Enabled = true; } } private void ToggleStartup() { if (changingStartup) { return; } try { StartupRegistration.SetEnabled(startAtLogin.Checked); } catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) { MessageBox.Show(exception.Message, "自动启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); changingStartup = true; try { startAtLogin.Checked = StartupRegistration.IsEnabled; } finally { changingStartup = false; } } } private static TableLayoutPanel CreateCard() { var card = new BorderedTableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, Padding = new Padding(22, 20, 22, 20), Margin = new Padding(0, 0, 0, 16), BackColor = Color.White, BorderColor = Color.FromArgb(226, 232, 240), }; card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); return card; } private static Label CreateSectionTitle(string text) => new() { Text = text, AutoSize = true, Font = new Font("Microsoft YaHei UI", 11, FontStyle.Bold), ForeColor = Color.FromArgb(30, 41, 59), Margin = new Padding(0, 0, 0, 8), }; private static Label CreateSubsectionTitle(string text) => new() { Text = text, AutoSize = true, Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), ForeColor = Color.FromArgb(51, 65, 85), Margin = new Padding(0, 0, 0, 7), }; private static Label CreateBodyLabel() => new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105), MaximumSize = new Size(ContentWidth - 40, 0), Margin = new Padding(0, 2, 0, 5), }; private static Label CreateHint(string text) => new() { Text = text, AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(100, 116, 139), Margin = new Padding(0, 2, 0, 4), }; private static DataGridView CreateJobGrid() { var grid = new DataGridView { Height = 238, Dock = DockStyle.Top, Margin = new Padding(0, 8, 0, 8), BackgroundColor = Color.White, BorderStyle = BorderStyle.FixedSingle, AllowUserToAddRows = false, AllowUserToDeleteRows = false, AllowUserToResizeRows = false, AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None, ColumnHeadersHeight = 34, EnableHeadersVisualStyles = false, MultiSelect = false, ReadOnly = true, RowHeadersVisible = false, RowTemplate = { Height = 34 }, SelectionMode = DataGridViewSelectionMode.FullRowSelect, }; grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(241, 245, 249); grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(51, 65, 85); grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(219, 234, 254); grid.DefaultCellStyle.SelectionForeColor = Color.FromArgb(30, 64, 175); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "状态", Width = 105, }); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "任务", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, MinimumWidth = 170, }); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "输入", Width = 125, }); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "进度", Width = 95, }); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "接收时间", Width = 100, }); grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Job ID", Width = 76, }); return grid; } private void RefreshDataRoot() { var selection = NodeDataRootSettings.Resolve(); dataRoot.Text = selection.RootDirectory; changeDataRoot.Enabled = !selection.IsEnvironmentManaged && !dataRootMigrationInProgress; dataRootStatus.Text = selection.IsEnvironmentManaged ? $"由机器环境变量 {NodeDataRootSettings.EnvironmentVariableName} 管理,界面不可修改。" : selection.Source == "user" ? "使用当前 Windows 专用账号保存的自定义目录。" : "使用默认目录。"; } private async Task ChangeDataRootAsync() { if (DataRootMigrationRequested is null) { return; } try { if (jobInbox.HasPendingJobs) { MessageBox.Show( "本机仍有未完成任务,暂不能迁移数据目录。请等待任务结束后重试。", "暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { MessageBox.Show( $"无法确认本机任务状态,数据目录不会迁移:{exception.Message}", "暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } using var dialog = new FolderBrowserDialog { Description = "选择新的 zcbot Windows Node 数据目录(必须为空)", UseDescriptionForTitle = true, SelectedPath = dataRoot.Text, ShowNewFolderButton = true, }; if (dialog.ShowDialog(this) != DialogResult.OK) { return; } string target; try { target = NodeDataRootSettings.Normalize(dialog.SelectedPath); } catch (NodeConfigurationException exception) { MessageBox.Show(exception.Message, "目录无效", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (string.Equals(target, dataRoot.Text, StringComparison.OrdinalIgnoreCase)) { MessageBox.Show("所选目录与当前数据目录相同。", "无需迁移", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var answer = MessageBox.Show( $"Node 将先停止接收任务,把现有数据复制并校验到:\n{target}\n\n" + "校验成功后切换目录并重启;旧目录不会自动删除。是否继续?", "迁移数据目录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); if (answer != DialogResult.Yes) { return; } try { dataRootMigrationInProgress = true; changeDataRoot.Enabled = false; dataRootStatus.Text = "正在停止调度并复制、校验数据,请勿退出 Node…"; var result = await DataRootMigrationRequested(target); MessageBox.Show( $"数据目录迁移完成:{result.FileCount} 个文件,{FormatBytes(result.TotalBytes)}。\n" + $"新目录:{result.TargetDirectory}\n\n旧目录仍保留:{result.SourceDirectory}\n" + "确认新节点运行正常后,可由管理员手工清理旧目录。Node 现在将重启。", "迁移完成", MessageBoxButtons.OK, MessageBoxIcon.Information); Application.Restart(); } catch (Exception exception) when ( exception is IOException or NodeConfigurationException or SecurityException or UnauthorizedAccessException) { dataRootMigrationInProgress = false; RefreshDataRoot(); MessageBox.Show( $"数据目录未切换,原目录继续有效:{exception.Message}", "迁移失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OpenDataRoot() { try { Directory.CreateDirectory(dataRoot.Text); var startInfo = new ProcessStartInfo { FileName = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), UseShellExecute = false, }; startInfo.ArgumentList.Add(dataRoot.Text); Process.Start(startInfo); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException or InvalidOperationException) { MessageBox.Show(exception.Message, "无法打开数据目录", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private static string FormatBytes(long bytes) => bytes >= 1024L * 1024 * 1024 ? $"{bytes / (1024d * 1024 * 1024):F2} GiB" : bytes >= 1024L * 1024 ? $"{bytes / (1024d * 1024):F1} MiB" : $"{bytes / 1024d:F1} KiB"; private static Panel CreateDivider() => new() { Height = 1, Dock = DockStyle.Top, BackColor = Color.FromArgb(226, 232, 240), Margin = new Padding(0, 10, 0, 12), }; private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new() { Text = text, UseSystemPasswordChar = usePassword, AutoSize = false, Height = 34, BorderStyle = BorderStyle.FixedSingle, Font = new Font("Segoe UI", 10), }; private static Button CreateButton( string text, int width, bool primary = false, bool danger = false) { var button = new Button { Text = text, AutoSize = false, Size = new Size(width, 38), FlatStyle = FlatStyle.Flat, BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White, ForeColor = primary ? Color.White : danger ? Color.FromArgb(185, 28, 28) : Color.FromArgb(51, 65, 85), Font = new Font("Microsoft YaHei UI", 9), Margin = new Padding(0, 0, 10, 0), Cursor = Cursors.Hand, }; button.FlatAppearance.BorderColor = primary ? Color.FromArgb(37, 99, 235) : danger ? Color.FromArgb(254, 202, 202) : Color.FromArgb(203, 213, 225); button.FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(29, 78, 216) : danger ? Color.FromArgb(254, 242, 242) : Color.FromArgb(248, 250, 252); return button; } private static FlowLayoutPanel CreateActions() => new() { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = true, Dock = DockStyle.Fill, Margin = new Padding(0, 10, 0, 0), }; private static void AddField( TableLayoutPanel layout, string label, Control control, string hint) { layout.Controls.Add(new Label { Text = label, AutoSize = true, Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), ForeColor = Color.FromArgb(51, 65, 85), Margin = new Padding(0, 8, 0, 4), }); control.Dock = DockStyle.Top; control.Margin = new Padding(0, 0, 0, 2); layout.Controls.Add(control); layout.Controls.Add(CreateHint(hint)); } private sealed record SoftwareControls( TextBox Path, Label Status, Button Automatic, Button? Choose, Button Install); private sealed class BorderedTableLayoutPanel : TableLayoutPanel { internal Color BorderColor = Color.FromArgb(226, 232, 240); internal BorderedTableLayoutPanel() { DoubleBuffered = true; } protected override void OnPaint(PaintEventArgs eventArgs) { base.OnPaint(eventArgs); using var pen = new Pen(BorderColor); eventArgs.Graphics.DrawRectangle( pen, 0, 0, Math.Max(0, ClientSize.Width - 1), Math.Max(0, ClientSize.Height - 1)); } } }