1282 lines
51 KiB
C#
1282 lines
51 KiB
C#
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<string, SoftwareControls> 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<EnrollOptions, Task>? RegisterRequested;
|
||
internal event Action? ReconnectRequested;
|
||
internal event Action? ResetIdentityRequested;
|
||
internal event Func<string, Task<NodeDataMigrationResult>>? 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.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<string>(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<string>(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<JobDisplaySnapshot> 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));
|
||
}
|
||
}
|
||
}
|