diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f0f31..047f305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ - 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。 -- Windows Node 主窗口重新整理为更宽、更清晰的桌面控制台:节点概览、专业软件、本机任务和运行设置改用独立标签页,节点状态、能力概览和维护操作分层展示;专业软件使用独立配置行和通栏地址框,Origin、ANSYS、Blender 均可手工选择位置,软件卡的操作按钮在高 DPI 下也会完整显示;Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。 +- Windows Node 主窗口改为简洁的单层自适应布局:移除固定宽度与重复卡片边框,窗口缩放时内容和页签会随可用宽度调整,专业软件的状态与操作也会在窄窗口中自然换行;能力概览按软件归并,不再为同一软件或同名能力重复显示名称。Origin、ANSYS、Blender 均可手工选择位置,Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。 + +- Windows Node 从托盘退出时会保护已有任务:可以停止接单并等待任务完成后退出,也可以经二次确认取消任务并终止对应的软件进程,不再直接关闭节点而留下状态不明的任务。 - Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。 diff --git a/PROGRESS.md b/PROGRESS.md index 5930120..fdfb9d1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -20,9 +20,11 @@ --- ## 已完成关键能力 +- **08-21 / Unreleased / Windows Node 任务安全退出**:托盘退出在存在活动任务时提供等待完成、取消任务或返回三种选择;默认等待路径先断开调度并复用连接循环既有的任务/上传收尾屏障,取消路径二次确认后同时取消输入下载与 adapter 进程树、持久化 `NODE_EXIT_CANCELLED` 终态,最终都等待连接任务结束后才关闭消息循环,等待期间禁用重复操作与重连,避免孤儿 Worker、并发恢复和重启误判。Windows Node、专业软件节点与合同共 68 项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 + - **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1,目标节点为 Blender 5.2 LTS;`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传,GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验;90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。 -- **08-21 / Unreleased / Windows Node 软件地址选择与 Origin 检测修正**:专业软件改为带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。默认窗口扩大为桌面控制台比例,节点状态改为独立状态徽标,能力概览只保留软件与 adapter 的可扫读摘要,完整错误继续通过诊断复制提供。Windows Node 源码与 Origin Worker 专项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 +- **08-21 / Unreleased / Windows Node 软件地址选择、Origin 检测与自适应界面**:专业软件保留一层带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。主窗口移除固定宽度居中容器和普通内容卡片边框,改为单层全宽页面;页签宽度跟随窗口调整,软件状态与操作分行并允许按钮自然换行,能力概览按软件与能力名称归并旧、新 adapter 的重复文案。Windows Node 22 项源码 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 - **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;三种软件均可由 UI 选择自定义位置,清除后按各自的环境变量、COM、卸载注册表、PATH 或标准目录自动检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime,安装只使用内置 runtime 定义与固定 requirements,拒绝活动任务期间替换,并先在临时目录完成后再切换;配置后的路径由 Host 作为固定子进程环境传给 Worker,任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT,发布包直接运行 EXE,当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。 diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index a8ff0d4..ef44751 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -165,8 +165,8 @@ class WindowsNodeSourceTests(unittest.TestCase): software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split( "private void RefreshSoftwareCards", 1 )[0] - self.assertIn("actions.AutoSizeMode = AutoSizeMode.GrowAndShrink", software_panel) - self.assertIn("actions.WrapContents = false", software_panel) + self.assertIn("panel.SetColumnSpan(status, 2)", software_panel) + self.assertIn("panel.SetColumnSpan(actions, 2)", software_panel) self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form) self.assertIn("item.HasActiveJobs", form) for runtime_id in ('"origin"', '"ansys"', '"blender"'): @@ -187,17 +187,19 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_configuration_window_is_resizable_and_dpi_safe(self) -> None: form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn("ClientSize = new Size(1320, 820)", form) - self.assertIn("MinimumSize = new Size(1040, 700)", form) + self.assertIn("ClientSize = new Size(1080, 720)", form) + self.assertIn("MinimumSize = new Size(760, 560)", form) self.assertIn("FormBorderStyle.Sizable", form) self.assertIn("AutoScaleMode.Dpi", form) self.assertIn("AutoScroll = true", form) self.assertNotIn("MaximumSize = new Size(410", form) self.assertIn("注册码默认 10 分钟有效", form) self.assertIn("成功注册一次后立即失效", form) - self.assertIn("CreateCard", form) + self.assertIn("CreateSection", form) self.assertIn("注册并连接", form) - self.assertIn("ContentWidth = 1180", form) + self.assertNotIn("ContentWidth", form) + self.assertIn("new Padding(24, 20, 24, 20)", form) + self.assertIn("new ColumnStyle(SizeType.Percent, 100)", form) self.assertIn('AddTab(tabs, "节点概览")', form) self.assertIn('AddTab(tabs, "专业软件")', form) self.assertIn('AddTab(tabs, "本机任务")', form) @@ -205,15 +207,21 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("SizeMode = TabSizeMode.Fixed", form) self.assertIn("new NavigationTabControl", form) self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form) + self.assertIn("Appearance = TabAppearance.FlatButtons", form) + self.assertIn("Math.Clamp(ClientSize.Width / TabCount, 96, 140)", form) self.assertIn("tab.Controls.Add(content)", form) self.assertIn("CreateSubsectionTitle(\"能力概览\")", form) self.assertIn("panel.SetColumnSpan(path, 2)", form) + self.assertIn("panel.SetColumnSpan(status, 2)", form) + self.assertIn("panel.SetColumnSpan(actions, 2)", form) self.assertIn("BorderedTableLayoutPanel", form) self.assertIn( 'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form ) - self.assertIn('$"{softwareName} {runtime.SoftwareVersion}"', form) - self.assertNotIn('$"Origin {runtime.SoftwareVersion}"', form) + self.assertIn("FormatCapabilitySummary(adapters.All)", form) + self.assertIn("GroupBy(entry => entry.SoftwareName", form) + self.assertIn("GroupBy(entry => entry.CapabilityName", form) + self.assertIn("ShortCapabilityName(adapter.DisplayName, softwareName)", form) self.assertIn('CreateButton("立即重连", 112, primary: true)', form) self.assertIn("ReconnectRequested?.Invoke()", form) self.assertIn("registrationCard.Visible = !registered", form) @@ -228,6 +236,23 @@ class WindowsNodeSourceTests(unittest.TestCase): tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8") self.assertIn("form.ReconnectRequested += RestartConnection", tray) + def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> None: + tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8") + connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + + self.assertIn("private async Task ExitNodeAsync()", tray) + self.assertIn("if (exiting || config is null", tray) + self.assertIn("ReadJobSnapshots()", tray) + self.assertIn("MessageBoxButtons.YesNoCancel", tray) + self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray) + self.assertIn("tray.ContextMenuStrip.Enabled = false", tray) + self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();")) + self.assertIn("internal void CancelActiveJobsForExit()", connection) + self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection) + self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection) + self.assertIn('"NODE_EXIT_CANCELLED"', connection) + self.assertIn("catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)", connection) + def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None: inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8") connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") @@ -344,8 +369,8 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner) self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner) form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn('Adapter {adapter.AdapterVersion}', form) - self.assertIn('$"{softwareName} 版本未知"', form) + self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form) + self.assertIn('$"{group.Key} · 版本未知"', form) self.assertIn( "&& !jobInbox.HasPendingJobs", connection, @@ -398,7 +423,8 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("ReportRecoverableJobsAsync", connection) self.assertIn("ConcurrentDictionary jobPipelines", connection) self.assertIn("StartJobPipeline(socket, acceptedJob)", connection) - self.assertIn("inputDownloader.DownloadAsync(job, CancellationToken.None)", connection) + self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection) + self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection) self.assertIn('stage = "uploading_outputs"', connection) self.assertIn('stage = "software_running"', connection) self.assertNotIn('stage = "origin_running"', connection) diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs index f94d2b4..17c6be2 100644 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -8,8 +8,6 @@ namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form { - private const int ContentWidth = 1180; - 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); @@ -76,25 +74,23 @@ internal sealed class ConfigurationForm : Form adapters = NodeAdapterRegistry.CreateDefault(jobInbox); Text = "zcbot Windows Node"; AutoScaleMode = AutoScaleMode.Dpi; - ClientSize = new Size(1320, 820); - MinimumSize = new Size(1040, 700); + ClientSize = new Size(1080, 720); + MinimumSize = new Size(760, 560); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Microsoft YaHei UI", 9); FormBorderStyle = FormBorderStyle.Sizable; - BackColor = Color.FromArgb(246, 248, 252); + BackColor = Color.White; var shell = new TableLayoutPanel { Dock = DockStyle.Fill, - AutoScroll = true, - ColumnCount = 3, + ColumnCount = 1, RowCount = 1, - Padding = new Padding(0, 28, 0, 28), + Padding = new Padding(24, 20, 24, 20), 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)); + shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + shell.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); Controls.Add(shell); var page = new TableLayoutPanel @@ -107,28 +103,20 @@ internal sealed class ConfigurationForm : Form page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); page.RowStyles.Add(new RowStyle(SizeType.AutoSize)); page.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - shell.Controls.Add(page, 1, 0); + shell.Controls.Add(page, 0, 0); var heading = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Top, ColumnCount = 1, - Margin = new Padding(4, 0, 4, 16), + Margin = new Padding(2, 0, 2, 14), }; 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), + Font = new Font("Microsoft YaHei UI", 18, FontStyle.Bold), ForeColor = Color.FromArgb(15, 23, 42), }); var headingHint = CreateHint("连接本机科研软件与 zcbot,安全执行受控任务"); @@ -141,8 +129,8 @@ internal sealed class ConfigurationForm : Form Dock = DockStyle.Fill, Margin = new Padding(0), Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), - ItemSize = new Size(142, 38), - Padding = new Point(18, 6), + ItemSize = new Size(128, 38), + Padding = new Point(14, 6), SizeMode = TabSizeMode.Fixed, }; var overviewPage = AddTab(tabs, "节点概览"); @@ -151,7 +139,7 @@ internal sealed class ConfigurationForm : Form var settingsPage = AddTab(tabs, "运行设置"); page.Controls.Add(tabs, 0, 1); - var statusCard = CreateCard(); + var statusCard = CreateSection(); var statusHeader = new TableLayoutPanel { AutoSize = true, @@ -176,7 +164,7 @@ internal sealed class ConfigurationForm : Form statusCard.Controls.Add(resetActions); overviewPage.Controls.Add(statusCard); - var softwareCard = CreateCard(); + var softwareCard = CreateSection(); softwareCard.Controls.Add(CreateSectionTitle("专业软件")); softwareCard.Controls.Add(CreateHint( "只需配置这台电脑实际拥有的软件。应用位置和运行环境彼此独立,不会影响其他软件。")); @@ -186,7 +174,8 @@ internal sealed class ConfigurationForm : Form } softwarePage.Controls.Add(softwareCard); - ansysAcceptanceCard = CreateCard(); + ansysAcceptanceCard = CreateSection(); + ansysAcceptanceCard.Controls.Add(CreateDivider()); ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收")); ansysAcceptanceCard.Controls.Add(CreateHint( "执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face;先验证完整进程树取消,再连续求解 20 次并检查结果与进程释放。")); @@ -198,7 +187,7 @@ internal sealed class ConfigurationForm : Form ansysAcceptanceCard.Controls.Add(acceptanceActions); softwarePage.Controls.Add(ansysAcceptanceCard); - var jobsCard = CreateCard(); + var jobsCard = CreateSection(); jobsCard.Controls.Add(CreateSectionTitle("本机任务")); jobsCard.Controls.Add(CreateHint( "仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。")); @@ -207,7 +196,8 @@ internal sealed class ConfigurationForm : Form jobsCard.Controls.Add(jobDetail); jobsPage.Controls.Add(jobsCard); - registrationCard = CreateCard(); + registrationCard = CreateSection(); + registrationCard.Controls.Add(CreateDivider()); registrationCard.Controls.Add(CreateSectionTitle("首次注册")); registrationCard.Controls.Add(CreateHint( "从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。")); @@ -219,7 +209,7 @@ internal sealed class ConfigurationForm : Form registrationCard.Controls.Add(registerActions); overviewPage.Controls.Add(registrationCard); - var runtimeCard = CreateCard(); + var runtimeCard = CreateSection(); runtimeCard.Controls.Add(CreateSectionTitle("运行设置")); runtimeCard.Controls.Add(startAtLogin); AddField( @@ -313,7 +303,7 @@ internal sealed class ConfigurationForm : Form : $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}"; capabilitySummary.Text = config is null ? "注册后启用" - : string.Join("\n", adapters.All.Select(FormatAdapterStatus)); + : FormatCapabilitySummary(adapters.All); copyDiagnostics.Enabled = config is not null; UpdateAnsysAcceptanceStatus(); @@ -330,18 +320,48 @@ internal sealed class ConfigurationForm : Form } } - private static string FormatAdapterStatus(INodeAdapter adapter) + private static string FormatCapabilitySummary(IEnumerable values) { - 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}"; + var entries = values.Select(adapter => + { + var runtime = adapter.DetectRuntime(); + var softwareName = runtime.Software == "OriginPro" ? "Origin" : runtime.Software; + return new CapabilitySummaryEntry( + softwareName, + runtime.SoftwareVersion, + ShortCapabilityName(adapter.DisplayName, softwareName), + runtime.Health == "ready"); + }).ToArray(); + return string.Join("\n\n", entries + .GroupBy(entry => entry.SoftwareName, StringComparer.Ordinal) + .Select(group => + { + var versions = group + .Select(entry => entry.SoftwareVersion) + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var heading = versions.Length == 0 + ? $"{group.Key} · 版本未知" + : $"{group.Key} {string.Join(" / ", versions)}"; + var capabilities = group + .GroupBy(entry => entry.CapabilityName, StringComparer.Ordinal) + .Select(capability => + { + var state = capability.All(entry => entry.Ready) + ? "可用" + : capability.Any(entry => entry.Ready) ? "部分可用" : "需配置"; + return $" {capability.Key} {state}"; + }); + return string.Join("\n", capabilities.Prepend(heading)); + })); } + private static string ShortCapabilityName(string displayName, string softwareName) => + displayName.StartsWith(softwareName + " ", StringComparison.OrdinalIgnoreCase) + ? displayName[(softwareName.Length + 1)..] + : displayName; + private Control CreateSoftwarePanel(SoftwareDefinition definition) { var panel = new BorderedTableLayoutPanel @@ -350,8 +370,8 @@ internal sealed class ConfigurationForm : Form AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 2, - Padding = new Padding(16, 14, 16, 14), - Margin = new Padding(0, 10, 0, 0), + Padding = new Padding(14, 12, 14, 12), + Margin = new Padding(0, 8, 0, 0), BackColor = Color.FromArgb(248, 250, 252), BorderColor = Color.FromArgb(226, 232, 240), }; @@ -374,15 +394,12 @@ internal sealed class ConfigurationForm : Form 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); + status.Dock = DockStyle.Fill; + status.Margin = new Padding(0, 3, 0, 0); panel.Controls.Add(status, 0, 2); + panel.SetColumnSpan(status, 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); + actions.Margin = new Padding(0, 10, 0, 0); var automatic = CreateButton("自动检测", 92); Button? choose = null; if (definition.PathKind != SoftwarePathKind.None) @@ -393,7 +410,8 @@ internal sealed class ConfigurationForm : Form var install = CreateButton("安装 / 更新环境", 150, primary: true); actions.Controls.Add(automatic); actions.Controls.Add(install); - panel.Controls.Add(actions, 1, 2); + panel.Controls.Add(actions, 0, 3); + panel.SetColumnSpan(actions, 2); var controls = new SoftwareControls(path, status, automatic, choose, install); softwareControls.Add(definition.Id, controls); automatic.Click += (_, _) => ResetSoftwareLocation(definition); @@ -563,7 +581,7 @@ internal sealed class ConfigurationForm : Form { capabilitySummary.Text = currentConfig is null ? "注册后启用" - : string.Join("\n", adapters.All.Select(FormatAdapterStatus)); + : FormatCapabilitySummary(adapters.All); } private void CopyDiagnostics() @@ -954,21 +972,20 @@ internal sealed class ConfigurationForm : Form } } - private static TableLayoutPanel CreateCard() + private static TableLayoutPanel CreateSection() { - var card = new BorderedTableLayoutPanel + var section = new TableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, - Padding = new Padding(22, 20, 22, 20), - Margin = new Padding(0, 0, 0, 16), + Padding = new Padding(4, 16, 4, 12), + Margin = new Padding(0), BackColor = Color.White, - BorderColor = Color.FromArgb(226, 232, 240), }; - card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - return card; + section.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + return section; } private static TableLayoutPanel AddTab(TabControl tabs, string text) @@ -977,8 +994,8 @@ internal sealed class ConfigurationForm : Form { Text = text, AutoScroll = true, - BackColor = Color.FromArgb(246, 248, 252), - Padding = new Padding(18, 20, 18, 20), + BackColor = Color.White, + Padding = new Padding(12, 0, 12, 0), UseVisualStyleBackColor = false, }; var content = new TableLayoutPanel @@ -1019,7 +1036,6 @@ internal sealed class ConfigurationForm : Form AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105), - MaximumSize = new Size(ContentWidth - 40, 0), Margin = new Padding(0, 2, 0, 5), }; @@ -1299,6 +1315,12 @@ internal sealed class ConfigurationForm : Form Button? Choose, Button Install); + private sealed record CapabilitySummaryEntry( + string SoftwareName, + string? SoftwareVersion, + string CapabilityName, + bool Ready); + private sealed class BorderedTableLayoutPanel : TableLayoutPanel { internal Color BorderColor = Color.FromArgb(226, 232, 240); @@ -1325,10 +1347,22 @@ internal sealed class ConfigurationForm : Form { internal NavigationTabControl() { + Appearance = TabAppearance.FlatButtons; DrawMode = TabDrawMode.OwnerDrawFixed; + HotTrack = true; SetStyle(ControlStyles.OptimizedDoubleBuffer, true); } + protected override void OnResize(EventArgs eventArgs) + { + base.OnResize(eventArgs); + if (TabCount == 0) + { + return; + } + ItemSize = new Size(Math.Clamp(ClientSize.Width / TabCount, 96, 140), 38); + } + protected override void OnDrawItem(DrawItemEventArgs eventArgs) { var selected = eventArgs.Index == SelectedIndex; diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs index 82dee3e..66b6470 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs @@ -19,6 +19,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? private readonly JobOutputUploader outputUploader = new(config); private readonly ConcurrentDictionary jobPipelines = new(); private readonly ConcurrentDictionary exportPipelines = new(); + private readonly CancellationTokenSource forcedStop = new(); private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine()); private static readonly TimeSpan[] Backoff = [ @@ -88,6 +89,19 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } + internal void CancelActiveJobsForExit() + { + forcedStop.Cancel(); + foreach (var job in jobInbox.ReadRecoverableJobs().Where(item => item.Terminal is null)) + { + adapters.Find(job.Capability)?.Cancel(job.JobId); + if (!jobPipelines.ContainsKey(job.JobId)) + { + WriteExitCancellation(job); + } + } + } + private async Task ConnectOnceAsync(CancellationToken cancellationToken) { using var socket = new ClientWebSocket(); @@ -400,7 +414,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? if (job.Terminal is null) { jobInbox.WriteState(job, "downloading_inputs", 0, "正在下载并校验输入文件"); - await inputDownloader.DownloadAsync(job, CancellationToken.None); + await inputDownloader.DownloadAsync(job, forcedStop.Token); var afterDownload = jobInbox.ReadRecoverableJobs() .Single(item => item.JobId == job.JobId); if (afterDownload.Terminal is JsonElement cancelledTerminal) @@ -492,6 +506,20 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? ? "结果已上传并由云端确认" : "预览已上传,工程保存在本机工作区"); } + catch (OperationCanceledException) when (forcedStop.IsCancellationRequested) + { + var current = jobInbox.ReadRecoverableJobs() + .Single(item => item.JobId == job.JobId); + if (current.Terminal is null) + { + WriteExitCancellation(current); + } + if (jobInbox.ReadRecoverableJobs() + .Single(item => item.JobId == job.JobId).Terminal is JsonElement terminal) + { + await TrySendAsync(socket, "job_terminal", terminal); + } + } catch (Exception exception) when ( exception is HttpRequestException or IOException @@ -527,6 +555,13 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } + private void WriteExitCancellation(RecoverableJob job) + { + const string detail = "Cancelled by the local administrator while exiting the node."; + jobInbox.WriteTerminal(job, "cancelled", "NODE_EXIT_CANCELLED", detail); + jobInbox.WriteState(job, "cancelled", 0, "本机管理员退出节点并取消了任务"); + } + private async Task TrySendTerminalAsync(ClientWebSocket socket, RecoverableJob job) { var terminal = jobInbox.ReadRecoverableJobs() diff --git a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs index c23a473..540aeab 100644 --- a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs +++ b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs @@ -6,9 +6,12 @@ internal sealed class TrayApplicationContext : ApplicationContext private readonly ConfigurationForm form; private readonly NotifyIcon tray; private readonly ToolStripMenuItem statusItem; + private readonly ToolStripMenuItem exitItem; private CancellationTokenSource? connectionStop; private Task? connectionTask; + private NodeConnectionLoop? connectionLoop; private NodeConfig? config; + private bool exiting; private NodeStatus current = NodeStatus.Create(NodeState.NotRegistered, "尚未注册"); internal TrayApplicationContext(NodeConfigStore store) @@ -31,7 +34,9 @@ internal sealed class TrayApplicationContext : ApplicationContext menu.Items.Add("打开配置", null, (_, _) => ShowConfiguration()); menu.Items.Add("立即重连", null, (_, _) => RestartConnection()); menu.Items.Add(new ToolStripSeparator()); - menu.Items.Add("退出", null, (_, _) => ExitNode()); + exitItem = new ToolStripMenuItem("退出"); + exitItem.Click += async (_, _) => await ExitNodeAsync(); + menu.Items.Add(exitItem); tray = new NotifyIcon { @@ -95,7 +100,7 @@ internal sealed class TrayApplicationContext : ApplicationContext private void StartConnection() { - if (config is null || connectionTask is { IsCompleted: false }) + if (exiting || config is null || connectionTask is { IsCompleted: false }) { return; } @@ -106,10 +111,11 @@ internal sealed class TrayApplicationContext : ApplicationContext private async Task RunConnectionAsync(NodeConfig nodeConfig, CancellationToken cancellationToken) { + var loop = new NodeConnectionLoop(nodeConfig, status => PostStatus(status)); + connectionLoop = loop; try { - await new NodeConnectionLoop(nodeConfig, status => PostStatus(status)) - .RunAsync(cancellationToken); + await loop.RunAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -123,6 +129,13 @@ internal sealed class TrayApplicationContext : ApplicationContext { PostStatus(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}")); } + finally + { + if (ReferenceEquals(connectionLoop, loop)) + { + connectionLoop = null; + } + } } private void RestartConnection() @@ -203,9 +216,63 @@ internal sealed class TrayApplicationContext : ApplicationContext form.Activate(); } - private void ExitNode() + private async Task ExitNodeAsync() { + if (exiting) + { + return; + } + var activeJobs = new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory) + .ReadJobSnapshots() + .Count(item => item.IsActive); + var cancelJobs = false; + if (activeJobs > 0) + { + var choice = MessageBox.Show( + $"当前有 {activeJobs} 个本机任务尚未结束。\n\n" + + "选择“是”:停止接收新任务,等待现有任务完成后退出。\n" + + "选择“否”:取消现有任务并退出。\n" + + "选择“取消”:返回,不退出。", + "退出 zcbot Windows Node", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button1); + if (choice == DialogResult.Cancel) + { + return; + } + cancelJobs = choice == DialogResult.No; + if (cancelJobs && MessageBox.Show( + "取消任务会终止正在运行的专业软件进程,未完成结果不会上传。确认取消任务并退出?", + "确认取消任务", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2) != DialogResult.Yes) + { + return; + } + } + exiting = true; + exitItem.Enabled = false; + if (tray.ContextMenuStrip is not null) + { + tray.ContextMenuStrip.Enabled = false; + } + form.Enabled = false; + UpdateStatus(NodeStatus.Create( + NodeState.Stopped, + cancelJobs + ? "正在取消本机任务并退出…" + : activeJobs > 0 ? "等待本机任务完成后退出…" : "正在退出…")); + if (cancelJobs) + { + connectionLoop?.CancelActiveJobsForExit(); + } connectionStop?.Cancel(); + if (connectionTask is not null) + { + await connectionTask; + } tray.Visible = false; ExitThread(); }