diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f05baa9..5249587 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,9 @@ "Bash(sed -i '' '/System.Console.WriteLine/d' ViewModels/Device/DeviceHostViewModel.cs)", "Bash(sed -i '' '/System.Console.WriteLine/d' Views/Device/DeviceHostListView.axaml.cs)", "Bash(git status *)", - "Bash(dotnet /Users/wenxiansheng/.nuget/packages/avalonia/11.1.5/lib/netstandard2.0/Avalonia.Base.dll type list)" + "Bash(dotnet /Users/wenxiansheng/.nuget/packages/avalonia/11.1.5/lib/netstandard2.0/Avalonia.Base.dll type list)", + "Bash(dotnet new *)", + "Bash(dotnet add *)" ] } } diff --git a/Sln.Wcs.HikRoBotServer/Program.cs b/Sln.Wcs.HikRoBotServer/Program.cs new file mode 100644 index 0000000..632e77f --- /dev/null +++ b/Sln.Wcs.HikRoBotServer/Program.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using Com.Ctrip.Framework.Apollo; +using Sln.Wcs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using NeoSmart.Caching.Sqlite; +using Newtonsoft.Json; +using Sln.Wcs.HikRoBotAdapter.Domain.Dto.GbTaskSubmit; +using Sln.Wcs.HikRoBotAdapter.Service; +using Sln.Wcs.HikRoBotDispatcher; +using Sln.Wcs.Model.Domain; +using Sln.Wcs.Repository; +using Sln.Wcs.Repository.service; +using Sln.Wcs.Serilog; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Serialization.NewtonsoftJson; + +var builder = WebApplication.CreateBuilder(args); +var basePath = AppContext.BaseDirectory; + +// ---- 配置 ---- +var localConfig = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + +var apolloConfigSection = localConfig.GetSection("apollo"); +builder.Services.AddSingleton(localConfig); + +var configProvider = new UpdateableConfigProvider(); +configProvider.Set("PLC参数", ""); + +var apolloConfig = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddApollo(apolloConfigSection) + .AddDefault() + .Add(configProvider) + .Build(); + +builder.Services.Remove(new ServiceDescriptor(typeof(IConfiguration), localConfig)); +builder.Services.AddSingleton(apolloConfig); + +// ---- DI 扫描 ---- +var assemblies = new[] +{ + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Common.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Cache.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Repository.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HikRoBotSdk.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HikRoBotAdapter.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HikRoBotDispatcher.dll")), +}; + +builder.Services.Scan(scan => scan.FromAssemblies(assemblies) + .AddClasses().AsImplementedInterfaces().AsSelf().WithTransientLifetime()); + +builder.Services.AddSingleton(typeof(SerilogHelper)); +builder.Services.AddSqlSugarSetup(); +builder.Services.AddFusionCache() + .WithSerializer(new FusionCacheNewtonsoftJsonSerializer()) + .WithDistributedCache(new SqliteCache(new SqliteCacheOptions { CachePath = apolloConfig["cachePath"]! })); + +// ---- Swagger ---- +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "HikRobot Engine API", Version = "v1" })); + +var app = builder.Build(); + +// ---- 启动初始化 ---- +var sp = app.Services; +sp.UseSerilogExtensions(); +var log = sp.GetRequiredService(); +log.Info($"HikRoBotServer 启动, 日志:{apolloConfig["logPath"]}"); + +app.UseSwagger(); +app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "HikRobot Engine v1"); c.RoutePrefix = "swagger"; }); + +// ---- HikRoBotDispatchHub.ReciveTask ---- +var hub = sp.GetRequiredService(); +var api = app.MapGroup("/api"); + +// Hub 方法: ReciveTask +api.MapPost("/task/receive", (ReceiveTaskRequest req) => +{ + var detail = new LiveTaskDetail + { + taskCode = req.TaskCode, deviceType = 1, taskStatus = 1, + startPoint = req.StartPoint, endPoint = req.EndPoint + }; + hub.ReciveTask(detail); + return Results.Ok(new { success = true }); +}); + +api.MapGet("/health", () => Results.Ok(new { time = DateTime.Now, status = "ok" })); + +log.Info("HikRoBotServer 就绪: http://localhost:5200/swagger"); +app.Run(); + +record ReceiveTaskRequest(string TaskCode, string StartPoint, string EndPoint); diff --git a/Sln.Wcs.HikRoBotServer/Sln.Wcs.HikRoBotServer.csproj b/Sln.Wcs.HikRoBotServer/Sln.Wcs.HikRoBotServer.csproj new file mode 100644 index 0000000..04eb07f --- /dev/null +++ b/Sln.Wcs.HikRoBotServer/Sln.Wcs.HikRoBotServer.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/Sln.Wcs.HikRoBotServer/UpdateableConfigProvider.cs b/Sln.Wcs.HikRoBotServer/UpdateableConfigProvider.cs new file mode 100644 index 0000000..388c678 --- /dev/null +++ b/Sln.Wcs.HikRoBotServer/UpdateableConfigProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Configuration; + +namespace Sln.Wcs; + +internal class UpdateableConfigProvider : ConfigurationProvider, IConfigurationSource +{ + public new void Set(string key, string value) + { + base.Set(key, value); + OnReload(); + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) => this; +} diff --git a/Sln.Wcs.HikRoBotServer/appsettings.Development.json b/Sln.Wcs.HikRoBotServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Sln.Wcs.HikRoBotServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Sln.Wcs.HikRoBotServer/appsettings.json b/Sln.Wcs.HikRoBotServer/appsettings.json new file mode 100644 index 0000000..aa175c1 --- /dev/null +++ b/Sln.Wcs.HikRoBotServer/appsettings.json @@ -0,0 +1,18 @@ +{ + "exclude": [ + "**/bin", + "**/bower_components", + "**/jspm_packages", + "**/node_modules", + "**/obj", + "**/platforms" + ], + "apollo": { + "AppId": "SlnWcs", + "Env": "DEV", + "MetaServer": "http://119.45.202.115:4320", //配置网址 4310 + "ConfigServer": [ + "http://119.45.202.115:4320" + ] + } +} \ No newline at end of file diff --git a/Sln.Wcs.HoistServer/Program.cs b/Sln.Wcs.HoistServer/Program.cs new file mode 100644 index 0000000..8a3f008 --- /dev/null +++ b/Sln.Wcs.HoistServer/Program.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using Com.Ctrip.Framework.Apollo; +using Sln.Wcs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using NeoSmart.Caching.Sqlite; +using Newtonsoft.Json; +using Sln.Wcs.HoistDispatcher; +using Sln.Wcs.Model.Domain; +using Sln.Wcs.Plc; +using Sln.Wcs.Repository; +using Sln.Wcs.Repository.service; +using Sln.Wcs.Serilog; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Serialization.NewtonsoftJson; + +var builder = WebApplication.CreateBuilder(args); +var basePath = AppContext.BaseDirectory; + +// ---- 配置 ---- +var localConfig = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + +var apolloConfigSection = localConfig.GetSection("apollo"); +builder.Services.AddSingleton(localConfig); + +var configProvider = new UpdateableConfigProvider(); +configProvider.Set("PLC参数", ""); + +var apolloConfig = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddApollo(apolloConfigSection) + .AddDefault() + .Add(configProvider) + .Build(); + +builder.Services.Remove(new ServiceDescriptor(typeof(IConfiguration), localConfig)); +builder.Services.AddSingleton(apolloConfig); + +// ---- DI 扫描 ---- +var assemblies = new[] +{ + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Common.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Cache.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Repository.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.Plc.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HoistSdk.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HoistAdapter.dll")), + Assembly.LoadFrom(Path.Combine(basePath, "Sln.Wcs.HoistDispatcher.dll")), +}; + +builder.Services.Scan(scan => scan.FromAssemblies(assemblies) + .AddClasses().AsImplementedInterfaces().AsSelf().WithTransientLifetime()); + +builder.Services.AddSingleton(typeof(SerilogHelper)); +builder.Services.AddSqlSugarSetup(); +builder.Services.AddPlcSetup(); +builder.Services.AddFusionCache() + .WithSerializer(new FusionCacheNewtonsoftJsonSerializer()) + .WithDistributedCache(new SqliteCache(new SqliteCacheOptions { CachePath = apolloConfig["cachePath"]! })); + +// ---- Swagger ---- +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Hoist Engine API", Version = "v1" })); + +var app = builder.Build(); + +// ---- 启动初始化 ---- +var sp = app.Services; +sp.UseSerilogExtensions(); +var log = sp.GetRequiredService(); +log.Info($"HoistServer 启动, 日志:{apolloConfig["logPath"]}"); + +var deviceInfoService = sp.GetRequiredService(); +var list = deviceInfoService.GetDeviceInfos(x => x.isFlag == 1).ToList(); +configProvider.Set("PLC参数", JsonConvert.SerializeObject(list)); +log.Info($"PLC参数已加载, 共{list.Count}台设备"); + +app.UseSwagger(); +app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "Hoist Engine v1"); c.RoutePrefix = "swagger"; }); + +// ---- HoistDispatchHub 四个方法 ---- +var hub = sp.GetRequiredService(); +var api = app.MapGroup("/api"); + +// 1. ReceivePallet +api.MapPost("/hoist/receive-pallet", (ReceivePalletRequest req, IBaseDeviceInfoService devSvc) => +{ + var device = devSvc.Query(x => x.deviceType == 2 && x.isFlag == 1) + .FirstOrDefault(d => d.hostCode == req.HostCode); + if (device == null) return Results.Ok(new { success = false, msg = "设备不存在" }); + + var detail = new LiveTaskDetail + { + taskCode = req.TaskCode, palletBarcode = req.PalletBarcode, + startPoint = req.StartPoint, endPoint = req.EndPoint, + deviceType = 2, taskStatus = 1 + }; + hub.ReceivePallet(detail, device); + return Results.Ok(new { success = true }); +}); + +// 2. TaskRun +api.MapPost("/hoist/task-run", (TaskRunRequest req, IBaseDeviceInfoService devSvc) => +{ + var device = devSvc.Query(x => x.deviceType == 2 && x.isFlag == 1) + .FirstOrDefault(d => d.hostCode == req.HostCode); + if (device == null) return Results.Ok(new { success = false, msg = "设备不存在" }); + device.deviceSerialNo = req.SerialNo; + hub.TaskRun(device); + return Results.Ok(new { success = true }); +}); + +// 3. TaskDispatch +api.MapPost("/task/dispatch", (TaskDispatchRequest req, IBaseDeviceInfoService devSvc) => +{ + var device = devSvc.Query(x => x.deviceType == 2 && x.isFlag == 1) + .FirstOrDefault(d => d.hostCode == req.HostCode); + if (device == null) return Results.Ok(new { success = false, msg = "设备不存在" }); + + var detail = new LiveTaskDetail + { + taskCode = req.TaskCode, startPoint = req.StartPoint, endPoint = req.EndPoint, + deviceType = 2, taskStatus = 1 + }; + hub.TaskDispatch(device, detail); + return Results.Ok(new { success = true }); +}); + +// 4. GetFreeHoistAsync +api.MapGet("/hoist/free", async (string hostCode) => +{ + var d = await hub.GetFreeHoistAsync(hostCode); + return Results.Ok(new { found = d != null, d?.deviceCode, d?.deviceName }); +}); + +api.MapGet("/health", () => Results.Ok(new { time = DateTime.Now, status = "ok" })); + +log.Info("HoistServer 就绪: http://localhost:5100/swagger"); +app.Run(); + +record ReceivePalletRequest(string HostCode, int SerialNo, string TaskCode, string PalletBarcode, string StartPoint, string EndPoint); +record TaskRunRequest(string HostCode, int SerialNo); +record TaskDispatchRequest(string HostCode, int SerialNo, string TaskCode, string StartPoint, string EndPoint); diff --git a/Sln.Wcs.HoistServer/Sln.Wcs.HoistServer.csproj b/Sln.Wcs.HoistServer/Sln.Wcs.HoistServer.csproj new file mode 100644 index 0000000..06217e5 --- /dev/null +++ b/Sln.Wcs.HoistServer/Sln.Wcs.HoistServer.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/Sln.Wcs.HoistServer/UpdateableConfigProvider.cs b/Sln.Wcs.HoistServer/UpdateableConfigProvider.cs new file mode 100644 index 0000000..388c678 --- /dev/null +++ b/Sln.Wcs.HoistServer/UpdateableConfigProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Configuration; + +namespace Sln.Wcs; + +internal class UpdateableConfigProvider : ConfigurationProvider, IConfigurationSource +{ + public new void Set(string key, string value) + { + base.Set(key, value); + OnReload(); + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) => this; +} diff --git a/Sln.Wcs.HoistServer/appsettings.Development.json b/Sln.Wcs.HoistServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Sln.Wcs.HoistServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Sln.Wcs.HoistServer/appsettings.json b/Sln.Wcs.HoistServer/appsettings.json new file mode 100644 index 0000000..aa175c1 --- /dev/null +++ b/Sln.Wcs.HoistServer/appsettings.json @@ -0,0 +1,18 @@ +{ + "exclude": [ + "**/bin", + "**/bower_components", + "**/jspm_packages", + "**/node_modules", + "**/obj", + "**/platforms" + ], + "apollo": { + "AppId": "SlnWcs", + "Env": "DEV", + "MetaServer": "http://119.45.202.115:4320", //配置网址 4310 + "ConfigServer": [ + "http://119.45.202.115:4320" + ] + } +} \ No newline at end of file diff --git a/Sln.Wcs.UI/App.axaml.cs b/Sln.Wcs.UI/App.axaml.cs index 9566c07..f4f4453 100644 --- a/Sln.Wcs.UI/App.axaml.cs +++ b/Sln.Wcs.UI/App.axaml.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using NeoSmart.Caching.Sqlite; using Sln.Wcs.Repository; using Sln.Wcs.Serilog; +using Sln.Wcs.UI.Services; using Sln.Wcs.UI.ViewModels; using Sln.Wcs.UI.Views; using ZiggyCreatures.Caching.Fusion; @@ -92,6 +93,10 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); + // 引擎进程管理 + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); // ---- 启动后初始化 ---- diff --git a/Sln.Wcs.UI/Services/AgvEngineProcessService.cs b/Sln.Wcs.UI/Services/AgvEngineProcessService.cs new file mode 100644 index 0000000..38f7b42 --- /dev/null +++ b/Sln.Wcs.UI/Services/AgvEngineProcessService.cs @@ -0,0 +1,131 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Sln.Wcs.UI.Services; + +public interface IAgvEngineProcessService +{ + bool IsRunning { get; } + string StatusText { get; } + string? LastError { get; } + event Action? OutputReceived; + event Action? StateChanged; + Task StartAsync(); + void Stop(); +} + +public class AgvEngineProcessService : IAgvEngineProcessService +{ + private Process? _process; + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(3) }; + private const string BaseUrl = "http://localhost:5200"; + private readonly StringBuilder _outputBuffer = new(); + + public bool IsRunning { get; private set; } + public string StatusText => IsRunning ? "运行中" : "已停止"; + public string? LastError { get; private set; } + public event Action? OutputReceived; + public event Action? StateChanged; + + public async Task StartAsync() + { + if (IsRunning) return; + LastError = null; + _outputBuffer.Clear(); + + var dllPath = Path.Combine(AppContext.BaseDirectory, "Sln.Wcs.HikRoBotServer.dll"); + if (!File.Exists(dllPath)) + throw new InvalidOperationException($"找不到 {dllPath}"); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{dllPath}\" --urls {BaseUrl}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + EnableRaisingEvents = true, + }; + + _process.OutputDataReceived += (_, e) => + { + if (e.Data != null) { _outputBuffer.AppendLine(e.Data); OutputReceived?.Invoke(e.Data); } + }; + _process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) { _outputBuffer.AppendLine(e.Data); OutputReceived?.Invoke(e.Data); } + }; + + _process.Exited += (_, _) => + { + IsRunning = false; + StateChanged?.Invoke(); + _process?.Dispose(); + _process = null; + }; + + _process.Start(); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + for (int i = 0; i < 40; i++) + { + await Task.Delay(500); + if (_process.HasExited) + { + LastError = _outputBuffer.ToString(); + _process.Dispose(); + _process = null; + throw new InvalidOperationException($"进程异常退出:\n{LastError}"); + } + if (await HealthCheckAsync()) + { + IsRunning = true; + StateChanged?.Invoke(); + return; + } + } + + LastError = _outputBuffer.ToString(); + Stop(); + throw new InvalidOperationException($"启动超时 (20s):\n{LastError}"); + } + + public void Stop() + { + if (_process == null) return; + IsRunning = false; + + try + { + if (!_process.HasExited) + { + _process.Kill(); + _process.WaitForExit(3000); + } + _process.Dispose(); + } + catch { } + finally { _process = null; } + + StateChanged?.Invoke(); + } + + private async Task HealthCheckAsync() + { + try + { + var res = await _http.GetAsync($"{BaseUrl}/api/health"); + return res.IsSuccessStatusCode; + } + catch { return false; } + } +} diff --git a/Sln.Wcs.UI/Services/EngineProcessService.cs b/Sln.Wcs.UI/Services/EngineProcessService.cs new file mode 100644 index 0000000..e6355b5 --- /dev/null +++ b/Sln.Wcs.UI/Services/EngineProcessService.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Sln.Wcs.UI.Services; + +public interface IEngineProcessService +{ + bool IsRunning { get; } + string StatusText { get; } + string? LastError { get; } + event Action? OutputReceived; + event Action? StateChanged; + Task StartAsync(); + void Stop(); +} + +public class EngineProcessService : IEngineProcessService +{ + private Process? _process; + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(3) }; + private const string BaseUrl = "http://localhost:5100"; + private readonly StringBuilder _outputBuffer = new(); + + public bool IsRunning { get; private set; } + public string StatusText => IsRunning ? "运行中" : "已停止"; + public string? LastError { get; private set; } + public event Action? OutputReceived; + public event Action? StateChanged; + + public async Task StartAsync() + { + if (IsRunning) return; + LastError = null; + _outputBuffer.Clear(); + + var dllPath = Path.Combine(AppContext.BaseDirectory, "Sln.Wcs.HoistServer.dll"); + if (!File.Exists(dllPath)) + throw new InvalidOperationException($"找不到 {dllPath}"); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{dllPath}\" --urls {BaseUrl}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + EnableRaisingEvents = true, + }; + + _process.OutputDataReceived += (_, e) => + { + if (e.Data != null) { _outputBuffer.AppendLine(e.Data); OutputReceived?.Invoke(e.Data); } + }; + _process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) { _outputBuffer.AppendLine(e.Data); OutputReceived?.Invoke(e.Data); } + }; + + _process.Exited += (_, _) => + { + IsRunning = false; + StateChanged?.Invoke(); + _process?.Dispose(); + _process = null; + }; + + _process.Start(); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + // 等待健康检查,最多 20 秒 + for (int i = 0; i < 40; i++) + { + await Task.Delay(500); + if (_process.HasExited) + { + LastError = _outputBuffer.ToString(); + _process.Dispose(); + _process = null; + throw new InvalidOperationException($"进程异常退出:\n{LastError}"); + } + if (await HealthCheckAsync()) + { + IsRunning = true; + StateChanged?.Invoke(); + return; + } + } + + LastError = _outputBuffer.ToString(); + Stop(); + throw new InvalidOperationException($"启动超时 (20s):\n{LastError}"); + } + + public void Stop() + { + if (_process == null) return; + IsRunning = false; + + try + { + if (!_process.HasExited) + { + _process.Kill(); + _process.WaitForExit(3000); + } + _process.Dispose(); + } + catch { } + finally { _process = null; } + + StateChanged?.Invoke(); + } + + private async Task HealthCheckAsync() + { + try + { + var res = await _http.GetAsync($"{BaseUrl}/api/health"); + return res.IsSuccessStatusCode; + } + catch { return false; } + } +} diff --git a/Sln.Wcs.UI/Sln.Wcs.UI.csproj b/Sln.Wcs.UI/Sln.Wcs.UI.csproj index fee3f5c..3d45452 100644 --- a/Sln.Wcs.UI/Sln.Wcs.UI.csproj +++ b/Sln.Wcs.UI/Sln.Wcs.UI.csproj @@ -37,8 +37,23 @@ + + + + + + + + + + + + + + + PreserveNewest diff --git a/Sln.Wcs.UI/ViewModels/SystemMonitorViewModel.cs b/Sln.Wcs.UI/ViewModels/SystemMonitorViewModel.cs index e2415f7..e155a42 100644 --- a/Sln.Wcs.UI/ViewModels/SystemMonitorViewModel.cs +++ b/Sln.Wcs.UI/ViewModels/SystemMonitorViewModel.cs @@ -4,28 +4,51 @@ using System.IO; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Sln.Wcs.UI.Services; namespace Sln.Wcs.UI.ViewModels; public partial class SystemMonitorViewModel : ObservableObject { + private readonly IEngineProcessService _hoistEngine; + private readonly IAgvEngineProcessService _agvEngine; + [ObservableProperty] private ObservableCollection _logs = new(); [ObservableProperty] private int _logCount; - [ObservableProperty] - private string _filterText = string.Empty; - [ObservableProperty] private bool _autoScroll = true; + // ---- Hoist ---- + [ObservableProperty] private bool _isHoistRunning; + [ObservableProperty] private string _hoistStatusText = "已停止"; + public string HoistStatusColor => IsHoistRunning ? "#00E676" : "#FF5252"; + + // ---- AGV ---- + [ObservableProperty] private bool _isAgvRunning; + [ObservableProperty] private string _agvStatusText = "已停止"; + public string AgvStatusColor => IsAgvRunning ? "#00E676" : "#FF5252"; + private const int MaxLogs = 5000; - public SystemMonitorViewModel() + public SystemMonitorViewModel(IEngineProcessService hoistEngine, IAgvEngineProcessService agvEngine) { - // 安装控制台输出拦截器 + _hoistEngine = hoistEngine; + _agvEngine = agvEngine; + + _hoistEngine.StateChanged += () => RefreshHoist(); + _hoistEngine.OutputReceived += msg => AddLog($"[提升机] {msg}"); + _agvEngine.StateChanged += () => RefreshAgv(); + _agvEngine.OutputReceived += msg => AddLog($"[AGV] {msg}"); + + StartHoistCommand = new AsyncRelayCommand(StartHoistAsync, () => !IsHoistRunning); + StopHoistCommand = new RelayCommand(StopHoist, () => IsHoistRunning); + StartAgvCommand = new AsyncRelayCommand(StartAgvAsync, () => !IsAgvRunning); + StopAgvCommand = new RelayCommand(StopAgv, () => IsAgvRunning); + var original = Console.Out; var writer = new LogTextWriter(original, entry => { @@ -39,18 +62,61 @@ public partial class SystemMonitorViewModel : ObservableObject Console.SetOut(writer); } - [RelayCommand] - private void Clear() + private void RefreshHoist() { - Logs.Clear(); - LogCount = 0; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + IsHoistRunning = _hoistEngine.IsRunning; + HoistStatusText = _hoistEngine.StatusText; + OnPropertyChanged(nameof(HoistStatusColor)); + StartHoistCommand.NotifyCanExecuteChanged(); + StopHoistCommand.NotifyCanExecuteChanged(); + }); } - [RelayCommand] - private void ToggleAutoScroll() + private void RefreshAgv() { - AutoScroll = !AutoScroll; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + IsAgvRunning = _agvEngine.IsRunning; + AgvStatusText = _agvEngine.StatusText; + OnPropertyChanged(nameof(AgvStatusColor)); + StartAgvCommand.NotifyCanExecuteChanged(); + StopAgvCommand.NotifyCanExecuteChanged(); + }); } + + // ---- Hoist Commands ---- + public IAsyncRelayCommand StartHoistCommand { get; } + public IRelayCommand StopHoistCommand { get; } + + private async System.Threading.Tasks.Task StartHoistAsync() + { + try { AddLog("正在启动 HoistServer..."); await _hoistEngine.StartAsync(); AddLog("HoistServer 启动成功"); } + catch (Exception ex) { AddLog($"Hoist 启动失败: {ex.Message}", "ERROR"); if (_hoistEngine.LastError != null) AddLog(_hoistEngine.LastError, "ERROR"); } + } + private void StopHoist() { _hoistEngine.Stop(); AddLog("HoistServer 已停止"); } + + // ---- AGV Commands ---- + public IAsyncRelayCommand StartAgvCommand { get; } + public IRelayCommand StopAgvCommand { get; } + + private async System.Threading.Tasks.Task StartAgvAsync() + { + try { AddLog("正在启动 HikRoBotServer..."); await _agvEngine.StartAsync(); AddLog("HikRoBotServer 启动成功"); } + catch (Exception ex) { AddLog($"AGV 启动失败: {ex.Message}", "ERROR"); if (_agvEngine.LastError != null) AddLog(_agvEngine.LastError, "ERROR"); } + } + private void StopAgv() { _agvEngine.Stop(); AddLog("HikRoBotServer 已停止"); } + + private void AddLog(string msg, string level = "INFO") + { + Logs.Add(new LogEntry { Time = DateTime.Now, Message = msg, Level = level }); + if (Logs.Count > MaxLogs) Logs.RemoveAt(0); + LogCount = Logs.Count; + } + + [RelayCommand] private void Clear() { Logs.Clear(); LogCount = 0; } + [RelayCommand] private void ToggleAutoScroll() { AutoScroll = !AutoScroll; } } public class LogEntry @@ -61,52 +127,14 @@ public class LogEntry public string Level { get; set; } = "INFO"; } -/// -/// 拦截 Console.WriteLine 输出,同时写入原始输出和 ObservableCollection -/// internal class LogTextWriter : TextWriter { private readonly TextWriter _original; private readonly Action _onWrite; private readonly StringBuilder _buffer = new(); - - public LogTextWriter(TextWriter original, Action onWrite) - { - _original = original; - _onWrite = onWrite; - } - + public LogTextWriter(TextWriter o, Action cb) { _original = o; _onWrite = cb; } public override Encoding Encoding => Encoding.UTF8; - - public override void Write(char value) - { - _original.Write(value); - if (value == '\n') - { - FlushBuffer(); - } - else if (value != '\r') - { - _buffer.Append(value); - } - } - - public override void WriteLine(string? message) - { - _original.WriteLine(message); - if (message != null) - { - _onWrite(new LogEntry { Time = DateTime.Now, Message = message, Level = "INFO" }); - } - } - - private void FlushBuffer() - { - var msg = _buffer.ToString(); - _buffer.Clear(); - if (msg.Length > 0) - { - _onWrite(new LogEntry { Time = DateTime.Now, Message = msg, Level = "INFO" }); - } - } + public override void Write(char v) { _original.Write(v); if (v == '\n') Flush(); else if (v != '\r') _buffer.Append(v); } + public override void WriteLine(string? m) { _original.WriteLine(m); if (m != null) _onWrite(new LogEntry { Time = DateTime.Now, Message = m, Level = "INFO" }); } + private void Flush() { var m = _buffer.ToString(); _buffer.Clear(); if (m.Length > 0) _onWrite(new LogEntry { Time = DateTime.Now, Message = m, Level = "INFO" }); } } diff --git a/Sln.Wcs.UI/Views/SystemMonitorView.axaml b/Sln.Wcs.UI/Views/SystemMonitorView.axaml index 8fc12be..3623a89 100644 --- a/Sln.Wcs.UI/Views/SystemMonitorView.axaml +++ b/Sln.Wcs.UI/Views/SystemMonitorView.axaml @@ -1,12 +1,48 @@ - + + + + + + + + + + + +