namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form { private const int ContentWidth = 760; 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("清除本机身份并重新注册", 220); private readonly CheckBox startAtLogin = new() { Text = "登录 Windows 后自动启动节点", AutoSize = true, Margin = new Padding(0, 4, 0, 8), }; private readonly Label state = new() { AutoSize = true, Font = new Font("Microsoft YaHei UI", 13, FontStyle.Bold), Margin = new Padding(0, 2, 0, 5), }; private readonly Label detail = CreateBodyLabel(); private readonly Label identity = CreateBodyLabel(); private readonly Label capabilitySummary = CreateBodyLabel(); 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 System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 }; private readonly TableLayoutPanel registrationCard; private bool changingStartup; internal event Func? RegisterRequested; internal event Action? ReconnectRequested; internal event Action? ResetIdentityRequested; internal ConfigurationForm() { adapters = NodeAdapterRegistry.CreateDefault(jobInbox); Text = "zcbot Windows Node"; AutoScaleMode = AutoScaleMode.Dpi; ClientSize = new Size(840, 680); MinimumSize = new Size(820, 640); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Microsoft YaHei UI", 9); FormBorderStyle = FormBorderStyle.Sizable; BackColor = Color.FromArgb(241, 245, 249); var shell = new TableLayoutPanel { Dock = DockStyle.Fill, AutoScroll = true, ColumnCount = 3, RowCount = 1, Padding = new Padding(0, 22, 0, 22), 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 = 6, 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, 18), }; heading.Controls.Add(new Label { Text = "zcbot Windows Node", AutoSize = true, Font = new Font("Microsoft YaHei UI", 20, FontStyle.Bold), ForeColor = Color.FromArgb(15, 23, 42), }); heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点")); page.Controls.Add(heading); var statusCard = CreateCard(); statusCard.Controls.Add(CreateSectionTitle("节点")); statusCard.Controls.Add(state); statusCard.Controls.Add(detail); statusCard.Controls.Add(identity); statusCard.Controls.Add(CreateDivider()); foreach (var contract in NodeAdapterRegistry.InstalledContracts) { statusCard.Controls.Add(CreateCapabilityRow(contract.DisplayName, contract.Capability)); } statusCard.Controls.Add(capabilitySummary); var resetActions = CreateActions(); resetActions.Controls.Add(reconnect); resetActions.Controls.Add(resetIdentity); statusCard.Controls.Add(resetActions); page.Controls.Add(statusCard); 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); 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(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); FormClosing += (_, eventArgs) => { if (eventArgs.CloseReason == CloseReason.UserClosing) { eventArgs.Cancel = true; Hide(); } }; startAtLogin.Checked = StartupRegistration.IsEnabled; jobGrid.SelectionChanged += (_, _) => ShowSelectedJob(); jobRefreshTimer.Tick += (_, _) => RefreshJobs(); jobRefreshTimer.Start(); Disposed += (_, _) => jobRefreshTimer.Dispose(); RefreshJobs(); ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null); } internal void ApplyStatus(NodeStatus status, NodeConfig? config) { state.Text = status.State switch { NodeState.Online => "● 在线", NodeState.Connecting => "● 正在连接", NodeState.NotRegistered => "● 尚未注册", NodeState.AuthenticationRequired => "● 需要重新注册", NodeState.Offline => "● 离线", _ => "● 已停止", }; state.ForeColor = status.State switch { NodeState.Online => Color.ForestGreen, NodeState.Connecting => Color.DarkOrange, NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.Firebrick, _ => Color.DimGray, }; 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)); 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 softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion) ? "Origin 版本未知" : $"Origin {runtime.SoftwareVersion}"; var state = runtime.Health == "ready" ? "可用" : "不可用"; return $"{adapter.DisplayName}:{state} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n" + runtime.Detail; } 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, "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" => "失败", "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 TableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, Padding = new Padding(20, 17, 20, 17), Margin = new Padding(0, 0, 0, 14), BackColor = Color.White, }; 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 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 static Panel CreateDivider() => new() { Height = 1, Dock = DockStyle.Top, BackColor = Color.FromArgb(226, 232, 240), Margin = new Padding(0, 10, 0, 12), }; private static Panel CreateCapabilityRow(string title, string protocol) { var row = new Panel { AutoSize = false, Height = 36, Dock = DockStyle.Top, BackColor = Color.FromArgb(239, 246, 255), Margin = new Padding(0, 0, 0, 5), Padding = new Padding(11, 7, 11, 7), }; row.Controls.Add(new Label { Text = $"{title} · {protocol}", AutoSize = true, Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), ForeColor = Color.FromArgb(29, 78, 216), }); return row; } 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) { var button = new Button { Text = text, AutoSize = false, Size = new Size(width, 36), FlatStyle = FlatStyle.Flat, BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White, ForeColor = primary ? Color.White : Color.FromArgb(51, 65, 85), Margin = new Padding(0, 0, 10, 0), }; button.FlatAppearance.BorderColor = primary ? Color.FromArgb(37, 99, 235) : Color.FromArgb(203, 213, 225); return button; } private static FlowLayoutPanel CreateActions() => new() { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, 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)); } }