namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form { 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("注册并连接", primary: true); private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册"); private readonly CheckBox startAtLogin = new() { Text = "登录 Windows 后自动启动节点", AutoSize = false, Height = 32, Dock = DockStyle.Top, }; private readonly Label state = new() { AutoSize = true, Font = new Font("Microsoft YaHei UI", 12, FontStyle.Bold), }; private readonly Label detail = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) }; private readonly Label identity = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) }; private bool changingStartup; internal event Func? RegisterRequested; internal event Action? ResetIdentityRequested; internal ConfigurationForm() { Text = "zcbot Windows Node"; AutoScaleMode = AutoScaleMode.Dpi; ClientSize = new Size(760, 690); MinimumSize = new Size(720, 640); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Microsoft YaHei UI", 9); FormBorderStyle = FormBorderStyle.Sizable; BackColor = Color.FromArgb(245, 247, 250); var page = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(28, 24, 28, 24), AutoScroll = true, ColumnCount = 1, RowCount = 7, BackColor = BackColor, }; page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); Controls.Add(page); 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(148); statusCard.Controls.Add(CreateSectionTitle("节点状态")); statusCard.Controls.Add(state); statusCard.Controls.Add(detail); statusCard.Controls.Add(identity); page.Controls.Add(statusCard); var configCard = CreateCard(360); configCard.Controls.Add(CreateSectionTitle("注册配置")); configCard.Controls.Add(CreateHint( "首次连接需要管理员在 zcbot 管理端生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。")); AddField(configCard, "zcbot 服务地址", server, "本机测试通常使用 http://127.0.0.1:8765"); AddField(configCard, "节点名称", nodeName, "用于管理员识别这台 Windows 计算节点"); AddField(configCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;它不是日常登录密码,也不会被长期保存"); var actions = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, Dock = DockStyle.Fill, Margin = new Padding(0, 12, 0, 0), }; actions.Controls.Add(register); actions.Controls.Add(resetIdentity); configCard.Controls.Add(actions); page.Controls.Add(configCard); var runtimeCard = CreateCard(104); 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, 14, 4, 0); page.Controls.Add(securityNote); register.Click += async (_, _) => await RegisterAsync(); resetIdentity.Click += (_, _) => ResetIdentity(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); FormClosing += (_, eventArgs) => { if (eventArgs.CloseReason == CloseReason.UserClosing) { eventArgs.Cancel = true; Hide(); } }; startAtLogin.Checked = StartupRegistration.IsEnabled; 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 ? $"配置文件:{NodePaths.ForCurrentMachine().ConfigPath}" : $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}"; server.Enabled = config is null; nodeName.Enabled = config is null; enrollmentCode.Enabled = config is null; register.Enabled = config is null && status.State != NodeState.Connecting; resetIdentity.Visible = config is not null; if (config is not null) { server.Text = config.ServerUrl.AbsoluteUri.TrimEnd('/'); nodeName.Text = config.NodeName; enrollmentCode.Clear(); } } 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(int minimumHeight) { var card = new TableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, MinimumSize = new Size(0, minimumHeight), Dock = DockStyle.Top, ColumnCount = 1, Padding = new Padding(22, 18, 22, 18), 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 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 TextBox CreateTextBox(string text = "", bool usePassword = false) => new() { Text = text, UseSystemPasswordChar = usePassword, AutoSize = false, Height = 36, BorderStyle = BorderStyle.FixedSingle, Font = new Font("Segoe UI", 10), }; private static Button CreateButton(string text, bool primary = false) { var button = new Button { Text = text, AutoSize = false, Size = new Size(primary ? 128 : 220, 38), 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 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, 9, 0, 4), }); control.Dock = DockStyle.Top; control.Margin = new Padding(0, 0, 0, 2); layout.Controls.Add(control); layout.Controls.Add(CreateHint(hint)); } }