using System.Diagnostics; namespace Dada.Supervisor; internal sealed class SupervisorForm : Form { private readonly NotifyIcon trayIcon; private readonly Label statusTitle; private readonly Label statusDetail; private readonly Label apiStatus; private readonly Label workerStatus; private readonly Label storageStatus; private readonly FlowLayoutPanel actions; private readonly ToolStripMenuItem openMenuItem; private readonly ToolStripMenuItem diagnosticsMenuItem; private readonly ToolStripMenuItem restartMenuItem; private readonly string correlationId = $"SUP-{Guid.NewGuid():N}"[..12].ToUpperInvariant(); private SupervisorState state; internal SupervisorForm(SupervisorState initialState = SupervisorState.Starting) { state = initialState; AutoScaleMode = AutoScaleMode.Dpi; BackColor = Color.FromArgb(246, 247, 249); ClientSize = new Size(480, 480); Font = new Font("Segoe UI", 9F); FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; ShowInTaskbar = true; StartPosition = FormStartPosition.CenterScreen; Text = "Dada"; var header = new Panel { BackColor = Color.FromArgb(24, 28, 34), Dock = DockStyle.Top, Height = 126, Padding = new Padding(28, 20, 28, 16) }; var brand = new Label { AutoSize = true, Font = new Font("Segoe UI Semibold", 12F), ForeColor = Color.White, Location = new Point(28, 18), Text = "DADA" }; statusTitle = new Label { AutoEllipsis = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), ForeColor = Color.White, Location = new Point(28, 49), Size = new Size(420, 32) }; statusDetail = new Label { Font = new Font("Microsoft YaHei UI", 9F), ForeColor = Color.FromArgb(181, 188, 198), Location = new Point(28, 82), Size = new Size(420, 38) }; header.Controls.AddRange([brand, statusTitle, statusDetail]); var componentPanel = new TableLayoutPanel { BackColor = Color.White, ColumnCount = 2, Dock = DockStyle.Top, Height = 172, Padding = new Padding(28, 20, 28, 12), RowCount = 3, }; componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 62)); componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 38)); componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33)); componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33)); componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 34)); apiStatus = AddStatusRow(componentPanel, 0, "本机 API", ApiStatus(initialState)); workerStatus = AddStatusRow(componentPanel, 1, "后台 Worker", WorkerStatus(initialState)); storageStatus = AddStatusRow(componentPanel, 2, "本地数据", StorageStatus(initialState)); actions = new FlowLayoutPanel { BackColor = Color.FromArgb(246, 247, 249), Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(24, 24, 20, 16), WrapContents = true, }; Controls.Add(actions); Controls.Add(componentPanel); Controls.Add(header); var menu = new ContextMenuStrip(); openMenuItem = new ToolStripMenuItem("打开 Dada", null, (_, _) => OpenProduct()); menu.Items.Add(openMenuItem); menu.Items.Add("运行状态", null, (_, _) => RestoreWindow()); diagnosticsMenuItem = new ToolStripMenuItem("打开诊断", null, (_, _) => ShowDiagnostics()); menu.Items.Add(diagnosticsMenuItem); restartMenuItem = new ToolStripMenuItem("重新启动服务", null, (_, _) => { SetState(SupervisorState.Starting); RestartRequested?.Invoke(); }); menu.Items.Add(restartMenuItem); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add("退出 Dada", null, (_, _) => Close()); trayIcon = new NotifyIcon { ContextMenuStrip = menu, Icon = SystemIcons.Application, Text = TrayText(initialState), Visible = !SystemInformation.UserInteractive ? false : true, }; trayIcon.DoubleClick += (_, _) => RestoreWindow(); FormClosing += (_, _) => trayIcon.Visible = false; SetState(initialState); } internal event Action? RestartRequested; internal IReadOnlyList TrayMenuLabels => trayIcon.ContextMenuStrip?.Items .OfType() .Select(item => item.Text ?? string.Empty) .ToArray() ?? []; internal void SetState(SupervisorState nextState) { state = nextState; (statusTitle.Text, statusDetail.Text) = nextState switch { SupervisorState.Starting => ("正在启动本机服务", "请稍候,Dada 正在检查 API、Worker 和本地数据。"), SupervisorState.Ready => ("Dada 已就绪", "本机服务运行正常。"), SupervisorState.StartupFailed => ("Dada 启动失败", $"服务未能启动 · {DateTimeOffset.Now:HH:mm:ss} · 关联 ID {correlationId}\n诊断信息已移除敏感内容。"), SupervisorState.PortInUse => ("Dada 无法使用固定端口启动", "固定端口 43121 已被占用,Dada 不会改用其他端口。"), SupervisorState.ApiDegraded => ("本机 API 不可用", "网页暂时无法使用,可打开诊断或重新启动服务。"), SupervisorState.WorkerDegraded => ("后台服务暂时不可用", "可继续查看、下载、编辑和删除;暂不能创建新任务。"), SupervisorState.StorageFull => ("本地存储空间已满", "可继续查看和导出;清理空间后再创建新内容。"), SupervisorState.StorageUnavailable => ("本地数据暂时不可用", "请恢复已配置的数据目录,然后重试。"), SupervisorState.DiagnosticsRunning => ("正在运行诊断", "正在检查本机服务状态。"), SupervisorState.DiagnosticsReady => ("诊断已完成", "可复制已脱敏的诊断结果。"), _ => throw new ArgumentOutOfRangeException(nameof(nextState)), }; trayIcon.Text = TrayText(nextState); SetStatusLabel(apiStatus, ApiStatus(nextState)); SetStatusLabel(workerStatus, WorkerStatus(nextState)); SetStatusLabel(storageStatus, StorageStatus(nextState)); var allowed = SupervisorActions.For(nextState); openMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenProduct); diagnosticsMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenDiagnostics); restartMenuItem.Visible = allowed.Contains(SupervisorAction.RestartServices); RenderActions(); } internal void RestoreWindow() { Show(); WindowState = FormWindowState.Normal; Activate(); } internal void SetBrowserLaunchFailure() { if (state == SupervisorState.Ready) { statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。"; } } protected override void Dispose(bool disposing) { if (disposing) trayIcon.Dispose(); base.Dispose(disposing); } private void RenderActions() { actions.SuspendLayout(); actions.Controls.Clear(); foreach (var action in SupervisorActions.For(state)) { var button = new Button { AutoSize = false, BackColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.FromArgb(28, 32, 38) : Color.White, FlatStyle = FlatStyle.Flat, ForeColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.White : Color.FromArgb(33, 37, 43), Height = 38, Margin = new Padding(4), Text = ActionLabel(action), Width = action switch { SupervisorAction.OpenChrome => 132, SupervisorAction.OpenEdge => 122, SupervisorAction.OpenDiagnostics => 122, SupervisorAction.CheckExistingInstance => 132, SupervisorAction.RestartServices => 122, _ => 112, }, }; button.FlatAppearance.BorderColor = Color.FromArgb(215, 219, 225); button.Click += (_, _) => InvokeAction(action); actions.Controls.Add(button); } actions.ResumeLayout(); } private void InvokeAction(SupervisorAction action) { switch (action) { case SupervisorAction.OpenProduct: OpenProduct(); break; case SupervisorAction.OpenChrome: SupportedBrowserLauncher.OpenChrome(LoopbackEndpoint.ProductUri); break; case SupervisorAction.OpenEdge: SupportedBrowserLauncher.OpenEdge(LoopbackEndpoint.ProductUri); break; case SupervisorAction.OpenDiagnostics: case SupervisorAction.CheckExistingInstance: ShowDiagnostics(); break; case SupervisorAction.Retry: case SupervisorAction.RestartServices: SetState(SupervisorState.Starting); RestartRequested?.Invoke(); break; case SupervisorAction.Exit: Close(); break; } } internal static bool OpenProductInSupportedBrowser() => SupportedBrowserLauncher.OpenDefaultSupported(LoopbackEndpoint.ProductUri); private static void OpenProduct() => OpenProductInSupportedBrowser(); private void ShowDiagnostics() { using var diagnostics = new DiagnosticsForm(state); diagnostics.ShowDialog(this); } private static Label AddStatusRow(TableLayoutPanel panel, int row, string label, string status) { panel.Controls.Add(new Label { Anchor = AnchorStyles.Left, AutoSize = true, Font = new Font("Microsoft YaHei UI", 10F), Text = label }, 0, row); var statusLabel = new Label { Anchor = AnchorStyles.Right, AutoSize = true, Font = new Font("Microsoft YaHei UI", 9F), Text = status }; SetStatusLabel(statusLabel, status); panel.Controls.Add(statusLabel, 1, row); return statusLabel; } private static void SetStatusLabel(Label label, string status) { label.Text = status; label.ForeColor = status is "可用" or "正常" ? Color.FromArgb(28, 122, 76) : Color.FromArgb(180, 86, 32); } private static string ApiStatus(SupervisorState value) => value is SupervisorState.ApiDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常"; private static string WorkerStatus(SupervisorState value) => value is SupervisorState.WorkerDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常"; private static string TrayText(SupervisorState value) => value switch { SupervisorState.Starting => "Dada - 正在启动", SupervisorState.Ready => "Dada - 运行正常", SupervisorState.ApiDegraded => "Dada - API 不可用", SupervisorState.WorkerDegraded => "Dada - Worker 不可用", SupervisorState.StorageFull => "Dada - 存储空间已满", SupervisorState.StorageUnavailable => "Dada - 本地数据不可用", SupervisorState.PortInUse => "Dada - 固定端口被占用", SupervisorState.StartupFailed => "Dada - 启动失败", _ => "Dada - 运行诊断", }; private static string StorageStatus(SupervisorState value) => value switch { SupervisorState.StorageFull => "已满", SupervisorState.StorageUnavailable => "不可用", _ => "可用", }; private static string ActionLabel(SupervisorAction action) => action switch { SupervisorAction.OpenProduct => "打开 Dada", SupervisorAction.OpenChrome => "使用 Chrome 打开", SupervisorAction.OpenEdge => "使用 Edge 打开", SupervisorAction.OpenDiagnostics => "打开诊断", SupervisorAction.CheckExistingInstance => "检查已有 Dada", SupervisorAction.Retry => "重试", SupervisorAction.RestartServices => "重新启动服务", SupervisorAction.Exit => "退出 Dada", _ => throw new ArgumentOutOfRangeException(nameof(action)), }; } internal sealed class DiagnosticsForm : Form { internal string CopyPayload { get; } internal DiagnosticsForm(SupervisorState state) { BackColor = Color.White; ClientSize = new Size(760, 440); Font = new Font("Microsoft YaHei UI", 9F); StartPosition = FormStartPosition.CenterParent; Text = "Dada 运行诊断"; var title = new Label { AutoSize = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), Location = new Point(28, 24), Text = "运行诊断" }; var detail = new Label { ForeColor = Color.FromArgb(91, 97, 105), Location = new Point(30, 62), Size = new Size(700, 42), Text = "诊断仅包含服务状态、固定端口、目录可用性和凭据是否已配置,不包含凭据值或用户内容。" }; var checks = new ListView { Location = new Point(30, 116), Size = new Size(700, 250), View = View.Details, FullRowSelect = true }; checks.Columns.Add("组件", 170); checks.Columns.Add("状态", 110); checks.Columns.Add("检查结果", 390); var report = DiagnosticReport.Create( state, [ state == SupervisorState.StorageUnavailable ? DiagnosticCheck.Fail("log_writable", "log_write_failed") : DiagnosticCheck.Pass("log_writable", "log_ready"), DiagnosticCheck.Pass("fixed_port", "loopback_only"), ], new DiagnosticStorageSummary(0, 0, 0), []); CopyPayload = System.Text.Json.JsonSerializer.Serialize(report, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); checks.Items.Add(new ListViewItem(["Supervisor", "正常", StateLabel(state)])); checks.Items.Add(new ListViewItem(["固定端口", "43121", "仅绑定 127.0.0.1"])); checks.Items.Add(new ListViewItem(["凭据", "已脱敏", "仅显示是否已配置"])); var copy = new Button { Location = new Point(592, 382), Size = new Size(138, 36), Text = "复制脱敏结果" }; copy.Click += (_, _) => Clipboard.SetText(CopyPayload); Controls.AddRange([title, detail, checks, copy]); } private static string StateLabel(SupervisorState state) => state switch { SupervisorState.Starting => "正在启动", SupervisorState.Ready => "运行正常", SupervisorState.StartupFailed => "启动失败", SupervisorState.PortInUse => "固定端口被占用", SupervisorState.ApiDegraded => "API 不可用", SupervisorState.WorkerDegraded => "Worker 不可用", SupervisorState.StorageFull => "存储空间已满", SupervisorState.StorageUnavailable => "本地数据不可用", SupervisorState.DiagnosticsRunning => "诊断中", SupervisorState.DiagnosticsReady => "诊断完成", _ => "未知", }; }