视觉修改
This commit is contained in:
		
							
								
								
									
										124
									
								
								Check.Main/Common/ConfigurationManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								Check.Main/Common/ConfigurationManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| using Check.Main.Camera; | ||||
| using Check.Main.Dispatch; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using System.Xml.Serialization; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 静态全局配置管理器,作为配置数据的“单一数据源”。 | ||||
|     /// 负责加载、保存和通知配置变更。 | ||||
|     /// </summary> | ||||
|     public static class ConfigurationManager | ||||
|     { | ||||
|         //    private static readonly string _configFilePath = Path.Combine(Application.StartupPath, "main_config.xml"); | ||||
|         //    private static ProcessConfig _currentConfig; | ||||
|         //    private static readonly object _lock = new object(); | ||||
|  | ||||
|         //    /// <summary> | ||||
|         //    /// 当配置通过 SaveChanges() 方法保存后触发。 | ||||
|         //    /// 其他模块(如 FormControlPanel)可以订阅此事件以响应配置变更。 | ||||
|         //    /// </summary> | ||||
|         //    public static event Action OnConfigurationChanged; | ||||
|  | ||||
|         //    /// <summary> | ||||
|         //    /// 静态构造函数在类首次被访问时自动执行,确保配置只被加载一次。 | ||||
|         //    /// </summary> | ||||
|         //    static ConfigurationManager() | ||||
|         //    { | ||||
|         //        Load(); | ||||
|         //    } | ||||
|  | ||||
|         //    /// <summary> | ||||
|         //    /// 获取对当前配置对象的直接引用。 | ||||
|         //    /// PropertyGrid 可以直接绑定到这个对象进行编辑。 | ||||
|         //    /// </summary> | ||||
|         //    /// <returns>当前的 ProcessConfig 实例。</returns> | ||||
|         //    public static ProcessConfig GetCurrentConfig() | ||||
|         //    { | ||||
|         //        return _currentConfig; | ||||
|         //    } | ||||
|  | ||||
|         //    /// <summary> | ||||
|         //    /// 从 XML 文件加载配置。如果文件不存在或加载失败,则创建一个新的默认配置。 | ||||
|         //    /// </summary> | ||||
|         //    private static void Load() | ||||
|         //    { | ||||
|         //        lock (_lock) | ||||
|         //        { | ||||
|         //            if (File.Exists(_configFilePath)) | ||||
|         //            { | ||||
|         //                try | ||||
|         //                { | ||||
|         //                    XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig)); | ||||
|         //                    using (var fs = new FileStream(_configFilePath, FileMode.Open, FileAccess.Read)) | ||||
|         //                    { | ||||
|         //                        _currentConfig = (ProcessConfig)serializer.Deserialize(fs); | ||||
|         //                        ThreadSafeLogger.Log("主配置文件加载成功。"); | ||||
|         //                    } | ||||
|         //                } | ||||
|         //                catch (Exception ex) | ||||
|         //                { | ||||
|         //                    ThreadSafeLogger.Log("加载主配置失败: " + ex.Message + " 将使用默认配置。"); | ||||
|         //                    _currentConfig = new ProcessConfig(); | ||||
|         //                } | ||||
|         //            } | ||||
|         //            else | ||||
|         //            { | ||||
|         //                ThreadSafeLogger.Log("未找到主配置文件,将创建新的默认配置。"); | ||||
|         //                _currentConfig = new ProcessConfig(); | ||||
|         //            } | ||||
|         //        } | ||||
|         //    } | ||||
|  | ||||
|         //    /// <summary> | ||||
|         //    /// 将当前配置保存到文件,并通知所有监听者配置已更改。 | ||||
|         //    /// 这是响应UI变化的推荐方法。 | ||||
|         //    /// </summary> | ||||
|         //    public static void SaveChanges() | ||||
|         //    { | ||||
|         //        lock (_lock) | ||||
|         //        { | ||||
|         //            try | ||||
|         //            { | ||||
|         //                XmlSerializer serializer = new XmlSerializer(typeof(ProcessConfig)); | ||||
|         //                using (var fs = new FileStream(_configFilePath, FileMode.Create, FileAccess.Write)) | ||||
|         //                { | ||||
|         //                    serializer.Serialize(fs, _currentConfig); | ||||
|         //                } | ||||
|         //            } | ||||
|         //            catch (Exception ex) | ||||
|         //            { | ||||
|         //                ThreadSafeLogger.Log("保存主配置失败: " + ex.Message); | ||||
|         //            } | ||||
|         //        } | ||||
|  | ||||
|         //        // 在锁之外触发事件,以避免监听者中的代码导致死锁 | ||||
|         //        ThreadSafeLogger.Log("配置已保存,正在触发 OnConfigurationChanged 事件..."); | ||||
|         //        OnConfigurationChanged?.Invoke(); | ||||
|         //    } | ||||
|         // 不再自己管理配置,而是直接从 ProductManager 获取 | ||||
|         public static ProcessConfig GetCurrentConfig() | ||||
|         { | ||||
|             return ProductManager.CurrentConfig; | ||||
|         } | ||||
|  | ||||
|         public static void SaveChanges() | ||||
|         { | ||||
|             ProductManager.SaveCurrentProductConfig(); | ||||
|         } | ||||
|  | ||||
|         // OnConfigurationChanged 事件现在由 ProductManager.OnProductChanged 替代 | ||||
|         // 如果其他地方还依赖这个事件,可以做一个桥接: | ||||
|         public static event Action OnConfigurationChanged | ||||
|         { | ||||
|             add { ProductManager.OnProductChanged += value; } | ||||
|             remove { ProductManager.OnProductChanged -= value; } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										236
									
								
								Check.Main/Common/EasyE5Options.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										236
									
								
								Check.Main/Common/EasyE5Options.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,236 @@ | ||||
| using NPOI.SS.Formula.Functions; | ||||
| using Sunny.UI; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Net.Sockets; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Text; | ||||
| using System.Text.RegularExpressions; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 汇川 Easy 系列 PLC 客户端 (Modbus TCP) | ||||
|     /// 支持 D/M/X/Y 地址读写,整数、浮点数、字符串 | ||||
|     /// </summary> | ||||
|     public class EasyPlcClient : IDisposable | ||||
|     { | ||||
|         private readonly TcpClient _tcpClient = new(); | ||||
|         private NetworkStream _stream; | ||||
|         private ushort _transactionId = 0; | ||||
|         private readonly SemaphoreSlim _lock = new(1, 1); | ||||
|  | ||||
|         private readonly string _ip; | ||||
|         private readonly int _port; | ||||
|         private readonly byte _slaveId; | ||||
|  | ||||
|         public EasyPlcClient(string ip, int port = 502, byte slaveId = 1) | ||||
|         { | ||||
|             _ip = ip; | ||||
|             _port = port; | ||||
|             _slaveId = slaveId; | ||||
|         } | ||||
|  | ||||
|         public async Task ConnectAsync() | ||||
|         { | ||||
|             await _tcpClient.ConnectAsync(_ip, _port); | ||||
|             _stream = _tcpClient.GetStream(); | ||||
|         } | ||||
|  | ||||
|         #region ---- 基础 Modbus ---- | ||||
|         private ushort GetTransactionId() => | ||||
|             unchecked((ushort)Interlocked.Increment(ref Unsafe.As<ushort, int>(ref _transactionId))); | ||||
|  | ||||
|         private async Task<byte[]> SendAndReceiveAsync(byte[] pdu, ushort startAddr, ushort length = 1) | ||||
|         { | ||||
|             ushort tid = GetTransactionId(); | ||||
|             ushort lengthField = (ushort)(pdu.Length + 1); | ||||
|             byte[] mbap = { | ||||
|                 (byte)(tid >> 8), (byte)tid, | ||||
|                 0x00, 0x00, // Protocol = 0 | ||||
|                 (byte)(lengthField >> 8), (byte)lengthField, | ||||
|                 _slaveId | ||||
|             }; | ||||
|  | ||||
|             byte[] adu = new byte[mbap.Length + pdu.Length]; | ||||
|             Buffer.BlockCopy(mbap, 0, adu, 0, mbap.Length); | ||||
|             Buffer.BlockCopy(pdu, 0, adu, mbap.Length, pdu.Length); | ||||
|  | ||||
|             await _lock.WaitAsync(); | ||||
|             try | ||||
|             { | ||||
|                 await _stream.WriteAsync(adu, 0, adu.Length); | ||||
|  | ||||
|                 byte[] header = new byte[7]; | ||||
|                 await _stream.ReadAsync(header, 0, 7); | ||||
|                 int respLength = (header[4] << 8) + header[5]; | ||||
|                 byte[] resp = new byte[respLength - 1]; | ||||
|                 await _stream.ReadAsync(resp, 0, resp.Length); | ||||
|  | ||||
|                 return resp; | ||||
|             } | ||||
|             finally { _lock.Release(); } | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         #region ---- 地址解析 ---- | ||||
|         private void ParseAddress(string address, out string area, out ushort offset) | ||||
|         { | ||||
|             var letters = Regex.Match(address, @"^[A-Za-z]+").Value.ToUpper(); | ||||
|             var digits = Regex.Match(address, @"\d+").Value; | ||||
|  | ||||
|             if (string.IsNullOrEmpty(letters) || string.IsNullOrEmpty(digits)) | ||||
|                 throw new ArgumentException($"地址 {address} 无效"); | ||||
|  | ||||
|             area = letters; | ||||
|             offset = (ushort)(ushort.Parse(digits) - 1); | ||||
|  | ||||
|             // 👉 注意:这里的偏移需根据你的 PLC Modbus 地址表调整 | ||||
|             // 例如 D 区可能是 40001 起,M 区可能是 00001 起 | ||||
|             // 目前假设 D -> Holding Register, M/Y -> Coil, X -> Discrete Input | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         #region ---- 整数读写 ---- | ||||
|         public async Task WriteAsync(string address, int value) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|  | ||||
|             switch (area) | ||||
|             { | ||||
|                 case "D": // 写寄存器 | ||||
|                     byte[] pdu = { | ||||
|                         0x06, | ||||
|                         (byte)(offset >> 8), (byte)offset, | ||||
|                         (byte)(value >> 8), (byte)value | ||||
|                     }; | ||||
|                     await SendAndReceiveAsync(pdu, offset); | ||||
|                     break; | ||||
|  | ||||
|                 case "M": | ||||
|                 case "Y": // 写单线圈 | ||||
|                     byte[] coil = { | ||||
|                         0x05, | ||||
|                         (byte)(offset >> 8), (byte)offset, | ||||
|                         (byte)(value != 0 ? 0xFF : 0x00), 0x00 | ||||
|                     }; | ||||
|                     await SendAndReceiveAsync(coil, offset); | ||||
|                     break; | ||||
|  | ||||
|                 default: | ||||
|                     throw new NotSupportedException($"写 {area} 区未支持"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<int> ReadAsync(string address) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|  | ||||
|             switch (area) | ||||
|             { | ||||
|                 case "D": // Holding Register | ||||
|                     byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 }; | ||||
|                     var resp = await SendAndReceiveAsync(pdu, offset); | ||||
|                     return (resp[1] << 8) + resp[2]; | ||||
|  | ||||
|                 case "M": | ||||
|                 case "Y": // Coils | ||||
|                     byte[] pdu1 = { 0x01, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 }; | ||||
|                     var resp1 = await SendAndReceiveAsync(pdu1, offset); | ||||
|                     return (resp1[2] & 0x01) != 0 ? 1 : 0; | ||||
|  | ||||
|                 case "X": // Inputs | ||||
|                     byte[] pdu2 = { 0x02, (byte)(offset >> 8), (byte)offset, 0x00, 0x01 }; | ||||
|                     var resp2 = await SendAndReceiveAsync(pdu2, offset); | ||||
|                     return (resp2[2] & 0x01) != 0 ? 1 : 0; | ||||
|  | ||||
|                 default: | ||||
|                     throw new NotSupportedException($"读 {area} 区未支持"); | ||||
|             } | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         #region ---- 浮点读写 (2寄存器) ---- | ||||
|         public async Task WriteFloatAsync(string address, float value) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|             if (area != "D") throw new NotSupportedException("浮点仅支持 D 区"); | ||||
|  | ||||
|             byte[] bytes = BitConverter.GetBytes(value); | ||||
|             if (BitConverter.IsLittleEndian) Array.Reverse(bytes); | ||||
|  | ||||
|             byte[] pdu = { | ||||
|                 0x10, | ||||
|                 (byte)(offset >> 8), (byte)offset, | ||||
|                 0x00, 0x02, | ||||
|                 0x04, | ||||
|                 bytes[0], bytes[1], bytes[2], bytes[3] | ||||
|             }; | ||||
|             await SendAndReceiveAsync(pdu, offset, 2); | ||||
|         } | ||||
|  | ||||
|         public async Task<float> ReadFloatAsync(string address) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|             if (area != "D") throw new NotSupportedException("浮点仅支持 D 区"); | ||||
|  | ||||
|             byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, 0x00, 0x02 }; | ||||
|             var resp = await SendAndReceiveAsync(pdu, offset, 2); | ||||
|  | ||||
|             byte[] data = { resp[1], resp[2], resp[3], resp[4] }; | ||||
|             if (BitConverter.IsLittleEndian) Array.Reverse(data); | ||||
|  | ||||
|             return BitConverter.ToSingle(data, 0); | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         #region ---- 字符串读写 ---- | ||||
|         public async Task WriteStringAsync(string address, string value, int maxLength) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|             if (area != "D") throw new NotSupportedException("字符串仅支持 D 区"); | ||||
|  | ||||
|             byte[] strBytes = Encoding.ASCII.GetBytes(value); | ||||
|             if (strBytes.Length > maxLength) Array.Resize(ref strBytes, maxLength); | ||||
|  | ||||
|             // 每寄存器2字节 | ||||
|             int regCount = (strBytes.Length + 1) / 2; | ||||
|             byte[] data = new byte[regCount * 2]; | ||||
|             Array.Copy(strBytes, data, strBytes.Length); | ||||
|  | ||||
|             byte[] pdu = new byte[7 + data.Length]; | ||||
|             pdu[0] = 0x10; | ||||
|             pdu[1] = (byte)(offset >> 8); pdu[2] = (byte)offset; | ||||
|             pdu[3] = (byte)(regCount >> 8); pdu[4] = (byte)regCount; | ||||
|             pdu[5] = (byte)(data.Length); | ||||
|             Buffer.BlockCopy(data, 0, pdu, 6, data.Length); | ||||
|  | ||||
|             await SendAndReceiveAsync(pdu, offset, (ushort)regCount); | ||||
|         } | ||||
|  | ||||
|         public async Task<string> ReadStringAsync(string address, int length) | ||||
|         { | ||||
|             ParseAddress(address, out string area, out ushort offset); | ||||
|             if (area != "D") throw new NotSupportedException("字符串仅支持 D 区"); | ||||
|  | ||||
|             int regCount = (length + 1) / 2; | ||||
|             byte[] pdu = { 0x03, (byte)(offset >> 8), (byte)offset, (byte)(regCount >> 8), (byte)regCount }; | ||||
|             var resp = await SendAndReceiveAsync(pdu, offset, (ushort)regCount); | ||||
|  | ||||
|             byte[] data = new byte[resp[0]]; | ||||
|             Array.Copy(resp, 1, data, 0, data.Length); | ||||
|  | ||||
|             return Encoding.ASCII.GetString(data).TrimEnd('\0'); | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             _stream?.Dispose(); | ||||
|             _tcpClient?.Close(); | ||||
|             _lock?.Dispose(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								Check.Main/Common/ImageData.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								Check.Main/Common/ImageData.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     public class ImageData : IDisposable | ||||
|     { | ||||
|         public long ProductId { get; } | ||||
|         public int CameraIndex { get; } | ||||
|         public Bitmap Image { get; } | ||||
|  | ||||
|         public ImageData(long productId, int cameraIndex, Bitmap image) | ||||
|         { | ||||
|             ProductId = productId; | ||||
|             CameraIndex = cameraIndex; | ||||
|             Image = image; | ||||
|         } | ||||
|         public void Dispose() => Image?.Dispose(); | ||||
|     } | ||||
|  | ||||
|     // ProductAssembly.cs - 用于组装一个完整的产品 | ||||
|     public class ProductAssembly : IDisposable | ||||
|     { | ||||
|         public long ProductId { get; } | ||||
|         private readonly int _expectedCameraCount; | ||||
|         // 使用 ConcurrentDictionary 保证线程安全 | ||||
|         private readonly ConcurrentDictionary<int, string> _results = new ConcurrentDictionary<int, string>(); | ||||
|  | ||||
|         public ProductAssembly(long productId, int expectedCameraCount) | ||||
|         { | ||||
|             ProductId = productId; | ||||
|             _expectedCameraCount = expectedCameraCount; | ||||
|         } | ||||
|  | ||||
|         // 添加一个相机的检测结果 | ||||
|         public bool AddResult(int cameraIndex, string result) | ||||
|         { | ||||
|             _results.TryAdd(cameraIndex, result); | ||||
|             return _results.Count == _expectedCameraCount; | ||||
|         } | ||||
|  | ||||
|         // 获取最终的聚合结果 | ||||
|         public string GetFinalResult() | ||||
|         { | ||||
|             // 如果任何一个结果包含 "NG",则最终结果为 "NG" | ||||
|             return _results.Values.Any(r => r.Contains("NG")) ? "NG" : "OK"; | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             // 目前只持有结果字符串,无需释放资源 | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										114
									
								
								Check.Main/Common/ModelSelectionConverter.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								Check.Main/Common/ModelSelectionConverter.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using static System.ComponentModel.TypeConverter; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 一个自定义的 TypeConverter,用于在 PropertyGrid 中为模型选择提供一个下拉列表。 | ||||
|     /// </summary> | ||||
|     public class ModelSelectionConverter : Int32Converter // 我们转换的是整数 (ModelId) | ||||
|     { | ||||
|         // 告诉 PropertyGrid,我们支持从一个标准值集合中进行选择 | ||||
|         public override bool GetStandardValuesSupported(ITypeDescriptorContext context) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // 告诉 PropertyGrid,我们只允许用户从列表中选择,不允许手动输入 | ||||
|         public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // 这是最核心的方法:提供标准值的列表 | ||||
|         public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 // 从中央配置管理器获取所有已配置的模型 | ||||
|                 var modelSettings = ConfigurationManager.GetCurrentConfig().ModelSettings; | ||||
|  | ||||
|                 if (modelSettings == null || modelSettings.Count == 0) | ||||
|                 { | ||||
|                     // 如果没有模型,返回一个包含默认值 "0"(代表“无”)的列表 | ||||
|                     return new StandardValuesCollection(new[] { 0 }); | ||||
|                 } | ||||
|  | ||||
|                 // 使用LINQ从模型列表中提取所有模型的ID | ||||
|                 // .Prepend(0) 在列表开头添加一个 "0",代表“未选择”的选项 | ||||
|                 var modelIds = modelSettings.Select(m => m.Id).Prepend(0).ToArray(); | ||||
|  | ||||
|                 return new StandardValuesCollection(modelIds); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 // 如果在获取过程中发生任何异常,返回一个安全的默认值 | ||||
|                 return new StandardValuesCollection(new[] { 0 }); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // --- 为了让下拉列表显示模型名称而不是ID,我们还需要重写以下方法 --- | ||||
|  | ||||
|         public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) | ||||
|         { | ||||
|             return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); | ||||
|         } | ||||
|  | ||||
|         public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) | ||||
|         { | ||||
|             return destinationType == typeof(string) || base.CanConvertTo(context, destinationType); | ||||
|         } | ||||
|  | ||||
|         // 将模型ID转换为其对应的名称字符串,以便在UI上显示 | ||||
|         public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) | ||||
|         { | ||||
|             if (destinationType == typeof(string) && value is int modelId) | ||||
|             { | ||||
|                 if (modelId == 0) | ||||
|                 { | ||||
|                     return "未选择"; // 对ID为0的特殊处理 | ||||
|                 } | ||||
|  | ||||
|                 // 查找ID对应的模型名称 | ||||
|                 var model = ConfigurationManager.GetCurrentConfig() | ||||
|                                                .ModelSettings? | ||||
|                                                .FirstOrDefault(m => m.Id == modelId); | ||||
|  | ||||
|                 // 返回 "名称 (ID)" 的格式,更清晰 | ||||
|                 return model != null ? $"{model.Name} (ID: {model.Id})" : "未知模型"; | ||||
|             } | ||||
|             return base.ConvertTo(context, culture, value, destinationType); | ||||
|         } | ||||
|  | ||||
|         // 将用户在UI上选择的名称字符串,转换回其对应的模型ID,以便保存 | ||||
|         public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) | ||||
|         { | ||||
|             if (value is string displayString) | ||||
|             { | ||||
|                 if (displayString == "未选择") | ||||
|                 { | ||||
|                     return 0; | ||||
|                 } | ||||
|  | ||||
|                 // 从 "名称 (ID: X)" 格式中解析出ID | ||||
|                 if (displayString.Contains("(ID: ") && displayString.EndsWith(")")) | ||||
|                 { | ||||
|                     var startIndex = displayString.IndexOf("(ID: ") + 5; | ||||
|                     var endIndex = displayString.Length - 1; | ||||
|                     var idString = displayString.Substring(startIndex, endIndex - startIndex); | ||||
|                     if (int.TryParse(idString, out int modelId)) | ||||
|                     { | ||||
|                         return modelId; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return base.ConvertFrom(context, culture, value); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										161
									
								
								Check.Main/Common/ProcessConfig.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								Check.Main/Common/ProcessConfig.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | ||||
| using Check.Main.Camera; | ||||
| using Check.Main.Infer; | ||||
| using Check.Main.UI; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Drawing.Design; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using System.Windows.Forms.Design; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     public class ProcessConfig | ||||
|     { | ||||
|         private string _logPath = Path.Combine(Application.StartupPath, "Logs"); | ||||
|         private List<CameraSettings> _cameraSettings = new List<CameraSettings>(); | ||||
|         private List<ModelSettings> _modelaSettings = new List<ModelSettings>(); | ||||
|  | ||||
|  | ||||
|         [Category("常规设置"), DisplayName("日志路径"), Description("设置日志文件的保存目录。")] | ||||
|         [Editor(typeof(FolderNameEditor), typeof(UITypeEditor))] // 使用内置的文件夹选择器 | ||||
|         public string LogPath | ||||
|         { | ||||
|             get => _logPath; | ||||
|             set => _logPath = value; | ||||
|         } | ||||
|  | ||||
|         [Category("核心配置"), DisplayName("相机配置"), Description("点击 '...' 按钮来配置所有相机。")] | ||||
|         [Editor(typeof(CameraConfigEditor), typeof(UITypeEditor))] // 【关键】指定使用我们自定义的编辑器 | ||||
|         [TypeConverter(typeof(CameraConfigConverter))]           // 【关键】指定使用我们自定义的类型转换器来显示文本 | ||||
|         public List<CameraSettings> CameraSettings | ||||
|         { | ||||
|             get => _cameraSettings; | ||||
|             set => _cameraSettings = value; | ||||
|         } | ||||
|  | ||||
|         [Category("核心配置"), DisplayName("模型配置"), Description("点击 '...' 按钮来配置所有模型。")] | ||||
|         [Editor(typeof(ModelConfigEditor), typeof(UITypeEditor))] // 【关键】指定使用我们自定义的编辑器 | ||||
|         [TypeConverter(typeof(ModelConfigConverter))]           // 【关键】指定使用我们自定义的类型转换器来显示文本 | ||||
|         public List<ModelSettings> ModelSettings | ||||
|         { | ||||
|             get => _modelaSettings; | ||||
|             set => _modelaSettings = value; | ||||
|         } | ||||
|     } | ||||
|     /// <summary> | ||||
|     /// 自定义TypeConverter,用于在PropertyGrid中自定义显示相机列表的文本。 | ||||
|     /// </summary> | ||||
|     public class CameraConfigConverter : TypeConverter | ||||
|     { | ||||
|         public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) | ||||
|         { | ||||
|             // 声明可以将值转换为字符串 | ||||
|             return destinationType == typeof(string) || base.CanConvertTo(context, destinationType); | ||||
|         } | ||||
|  | ||||
|         public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType) | ||||
|         { | ||||
|             // 如果目标类型是字符串,并且值是相机设置列表 | ||||
|             if (destinationType == typeof(string) && value is List<CameraSettings> list) | ||||
|             { | ||||
|                 // 返回自定义的描述性文本 | ||||
|                 return $"已配置 {list.Count} 个相机"; | ||||
|             } | ||||
|             return base.ConvertTo(context, culture, value, destinationType); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// 自定义TypeConverter,用于在PropertyGrid中自定义显示相机列表的文本。 | ||||
|     /// </summary> | ||||
|     public class ModelConfigConverter : TypeConverter | ||||
|     { | ||||
|         public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) | ||||
|         { | ||||
|             // 声明可以将值转换为字符串 | ||||
|             return destinationType == typeof(string) || base.CanConvertTo(context, destinationType); | ||||
|         } | ||||
|  | ||||
|         public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType) | ||||
|         { | ||||
|             // 如果目标类型是字符串,并且值是相机设置列表 | ||||
|             if (destinationType == typeof(string) && value is List<ModelSettings> list) | ||||
|             { | ||||
|                 // 返回自定义的描述性文本 | ||||
|                 return $"已配置 {list.Count} 个模型"; | ||||
|             } | ||||
|             return base.ConvertTo(context, culture, value, destinationType); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /// <summary> | ||||
|     /// 自定义UITypeEditor,用于在点击 "..." 时打开相机配置窗体。 | ||||
|     /// </summary> | ||||
|     public class CameraConfigEditor : UITypeEditor | ||||
|     { | ||||
|         // 1. 设置编辑样式为模态对话框 | ||||
|         public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context) | ||||
|         { | ||||
|             return UITypeEditorEditStyle.Modal; | ||||
|         } | ||||
|  | ||||
|         // 2. 重写编辑方法 | ||||
|         public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value) | ||||
|         { | ||||
|             // 'value' 就是当前属性的值,即 List<CameraSettings> | ||||
|             var settingsList = value as List<CameraSettings>; | ||||
|  | ||||
|             // 使用 FrmCamConfig 作为对话框进行编辑 | ||||
|             // 我们需要给 FrmCamConfig 添加一个新的构造函数 | ||||
|             using (var camConfigForm = new FrmCamConfig(settingsList)) | ||||
|             { | ||||
|                 // 以对话框形式显示窗体 | ||||
|                 if (camConfigForm.ShowDialog() == DialogResult.OK) | ||||
|                 { | ||||
|                     // 如果用户点击了“确定”,返回修改后的新列表 | ||||
|                     return camConfigForm._settingsList; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 如果用户点击了“取消”或直接关闭了窗口,返回原始值,不做任何更改 | ||||
|             return value; | ||||
|         } | ||||
|     } | ||||
|     /// <summary> | ||||
|     /// 【新增】自定义UITypeEditor,用于在点击 "..." 时打开模型配置窗体。 | ||||
|     /// </summary> | ||||
|     public class ModelConfigEditor : UITypeEditor | ||||
|     { | ||||
|         // 1. 设置编辑样式为模态对话框 | ||||
|         public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context) | ||||
|         { | ||||
|             return UITypeEditorEditStyle.Modal; | ||||
|         } | ||||
|  | ||||
|         // 2. 重写编辑方法 | ||||
|         public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value) | ||||
|         { | ||||
|             // 'value' 就是当前属性的值,即 List<CameraSettings> | ||||
|             var settingsList = value as List<ModelSettings>; | ||||
|  | ||||
|             // 使用ModelEditor 作为对话框进行编辑 | ||||
|             // 我们需要给 FrmCamConfig 添加一个新的构造函数 | ||||
|             using (var modelConfigForm = new ModelListEditor(settingsList)) | ||||
|             { | ||||
|                 // 以对话框形式显示窗体 | ||||
|                 if (modelConfigForm.ShowDialog() == DialogResult.OK) | ||||
|                 { | ||||
|                     // 如果用户点击了“确定”,返回修改后的新列表 | ||||
|                     return modelConfigForm._settingsList; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 如果用户点击了“取消”或直接关闭了窗口,返回原始值,不做任何更改 | ||||
|             return value; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								Check.Main/Common/ProcessingCompletedEventArgs.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								Check.Main/Common/ProcessingCompletedEventArgs.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| using SkiaSharp; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     public class ProcessingCompletedEventArgs : EventArgs, IDisposable | ||||
|     { | ||||
|         public int CameraIndex { get; } | ||||
|         public long ProductId { get; } | ||||
|         public SKImage ResultImage { get; } // 【修改】携带已绘制好结果的SKImage | ||||
|  | ||||
|         public ProcessingCompletedEventArgs(int cameraIndex, long productId, SKImage resultImage) | ||||
|         { | ||||
|             CameraIndex = cameraIndex; | ||||
|             ProductId = productId; | ||||
|             ResultImage = resultImage; | ||||
|         } | ||||
|  | ||||
|         // 实现 IDisposable 以确保SKImage资源被释放 | ||||
|         public void Dispose() | ||||
|         { | ||||
|             ResultImage?.Dispose(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										80
									
								
								Check.Main/Common/StaticMethod.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								Check.Main/Common/StaticMethod.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 一个自定义的类型转换器,用于让PropertyGrid等控件显示枚举成员的Description特性,而不是成员名。 | ||||
|     /// </summary> | ||||
|     public class EnumDescriptionTypeConverter : EnumConverter | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// 构造函数,传入枚举类型。 | ||||
|         /// </summary> | ||||
|         /// <param name="type">要进行转换的枚举类型。</param> | ||||
|         public EnumDescriptionTypeConverter(Type type) : base(type) | ||||
|         { | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 重写此方法,将枚举值转换为其描述文本。 | ||||
|         /// 这是从 "数据" -> "UI显示" 的过程。 | ||||
|         /// </summary> | ||||
|         public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) | ||||
|         { | ||||
|             // 如果目标类型是字符串,并且我们有一个有效的枚举值 | ||||
|             if (destinationType == typeof(string) && value != null) | ||||
|             { | ||||
|                 // 获取枚举值的字段信息 | ||||
|                 FieldInfo fi = value.GetType().GetField(value.ToString()); | ||||
|                 if (fi != null) | ||||
|                 { | ||||
|                     // 查找该字段上的DescriptionAttribute | ||||
|                     var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false); | ||||
|  | ||||
|                     // 如果找到了Description特性,就返回它的描述文本;否则,返回基类的默认行为(即成员名) | ||||
|                     return ((attributes.Length > 0) && (!String.IsNullOrEmpty(attributes[0].Description))) | ||||
|                            ? attributes[0].Description | ||||
|                            : value.ToString(); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 对于其他所有情况,使用基类的默认转换逻辑 | ||||
|             return base.ConvertTo(context, culture, value, destinationType); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 重写此方法,将描述文本转换回其对应的枚举值。 | ||||
|         /// 这是从 "UI显示" -> "数据" 的过程。 | ||||
|         /// </summary> | ||||
|         public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) | ||||
|         { | ||||
|             // 如果传入的值是字符串 | ||||
|             if (value is string) | ||||
|             { | ||||
|                 // 遍历枚举类型的所有成员 | ||||
|                 foreach (FieldInfo fi in EnumType.GetFields()) | ||||
|                 { | ||||
|                     // 查找该成员上的DescriptionAttribute | ||||
|                     var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false); | ||||
|  | ||||
|                     // 如果找到了Description,并且它的描述文本与传入的字符串匹配 | ||||
|                     if ((attributes.Length > 0) && (attributes[0].Description == (string)value)) | ||||
|                     { | ||||
|                         // 返回这个字段(成员)对应的枚举值 | ||||
|                         return Enum.Parse(EnumType, fi.Name); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 如果没有找到匹配的描述,或者传入的不是字符串,使用基类的默认转换逻辑 | ||||
|             return base.ConvertFrom(context, culture, value); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										71
									
								
								Check.Main/Common/StatisticsData.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								Check.Main/Common/StatisticsData.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 封装了所有生产统计数据的模型。 | ||||
|     /// 实现了INotifyPropertyChanged,未来可用于数据绑定。 | ||||
|     /// </summary> | ||||
|     public class StatisticsData : INotifyPropertyChanged | ||||
|     { | ||||
|         private int _goodCount; | ||||
|         private int _ngCount; | ||||
|  | ||||
|         public int GoodCount | ||||
|         { | ||||
|             get => _goodCount; | ||||
|             private set { _goodCount = value; OnPropertyChanged(nameof(GoodCount)); OnPropertyChanged(nameof(TotalCount)); OnPropertyChanged(nameof(YieldRate)); } | ||||
|         } | ||||
|  | ||||
|         public int NgCount | ||||
|         { | ||||
|             get => _ngCount; | ||||
|             private set { _ngCount = value; OnPropertyChanged(nameof(NgCount)); OnPropertyChanged(nameof(TotalCount)); OnPropertyChanged(nameof(YieldRate)); } | ||||
|         } | ||||
|  | ||||
|         public int TotalCount => GoodCount + NgCount; | ||||
|  | ||||
|         public double YieldRate => TotalCount == 0 ? 0 : (double)GoodCount / TotalCount; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 根据检测结果更新统计数据。 | ||||
|         /// </summary> | ||||
|         public void UpdateWithResult(bool isOk) | ||||
|         { | ||||
|             if (isOk) GoodCount++; | ||||
|             else NgCount++; | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 重置所有统计数据。 | ||||
|         /// </summary> | ||||
|         public void Reset() | ||||
|         { | ||||
|             GoodCount = 0; | ||||
|             NgCount = 0; | ||||
|         } | ||||
|  | ||||
|         public event PropertyChangedEventHandler PropertyChanged; | ||||
|         protected void OnPropertyChanged(string propertyName) | ||||
|         { | ||||
|             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// 用于在检测完成事件中传递结果的事件参数。 | ||||
|     /// </summary> | ||||
|     public class DetectionResultEventArgs : EventArgs | ||||
|     { | ||||
|         public bool IsOK { get; } | ||||
|         public DetectionResultEventArgs(bool isOk) | ||||
|         { | ||||
|             IsOK = isOk; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										82
									
								
								Check.Main/Common/StatisticsExporter.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Check.Main/Common/StatisticsExporter.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| using Check.Main.Camera; | ||||
| using NPOI.SS.UserModel; | ||||
| using NPOI.XSSF.UserModel; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using System.Windows.Forms; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     public static class StatisticsExporter | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// 将给定的统计数据导出到指定的Excel文件路径。 | ||||
|         /// </summary> | ||||
|         /// <param name="data">要导出的统计数据对象。</param> | ||||
|         /// <param name="customFileName">自定义的文件名部分(如 "Reset" 或 "Shutdown")。</param> | ||||
|         public static void ExportToExcel(StatisticsData data, string customFileName) | ||||
|         { | ||||
|             if (data.TotalCount == 0) return; // 如果没有数据,则不导出 | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 string directory = Path.Combine(Application.StartupPath, "Statistics"); | ||||
|                 Directory.CreateDirectory(directory); // 确保文件夹存在 | ||||
|  | ||||
|                 string fileName = $"Statistics_{customFileName}_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.xlsx"; | ||||
|                 string filePath = Path.Combine(directory, fileName); | ||||
|  | ||||
|                 // 创建工作簿和工作表 | ||||
|                 IWorkbook workbook = new XSSFWorkbook(); | ||||
|                 ISheet sheet = workbook.CreateSheet("生产统计"); | ||||
|  | ||||
|                 // --- 创建样式 (可选,但能让表格更好看) --- | ||||
|                 IFont boldFont = workbook.CreateFont(); | ||||
|                 boldFont.IsBold = true; | ||||
|                 ICellStyle headerStyle = workbook.CreateCellStyle(); | ||||
|                 headerStyle.SetFont(boldFont); | ||||
|  | ||||
|                 // --- 创建表头 --- | ||||
|                 IRow headerRow = sheet.CreateRow(0); | ||||
|                 headerRow.CreateCell(0).SetCellValue("项目"); | ||||
|                 headerRow.CreateCell(1).SetCellValue("数值"); | ||||
|                 headerRow.GetCell(0).CellStyle = headerStyle; | ||||
|                 headerRow.GetCell(1).CellStyle = headerStyle; | ||||
|  | ||||
|                 // --- 填充数据 --- | ||||
|                 sheet.CreateRow(1).CreateCell(0).SetCellValue("良品数 (OK)"); | ||||
|                 sheet.GetRow(1).CreateCell(1).SetCellValue(data.GoodCount); | ||||
|  | ||||
|                 sheet.CreateRow(2).CreateCell(0).SetCellValue("不良品数 (NG)"); | ||||
|                 sheet.GetRow(2).CreateCell(1).SetCellValue(data.NgCount); | ||||
|  | ||||
|                 sheet.CreateRow(3).CreateCell(0).SetCellValue("产品总数"); | ||||
|                 sheet.GetRow(3).CreateCell(1).SetCellValue(data.TotalCount); | ||||
|  | ||||
|                 sheet.CreateRow(4).CreateCell(0).SetCellValue("良率 (Yield)"); | ||||
|                 sheet.GetRow(4).CreateCell(1).SetCellValue(data.YieldRate.ToString("P2")); | ||||
|  | ||||
|                 // 自动调整列宽 | ||||
|                 sheet.AutoSizeColumn(0); | ||||
|                 sheet.AutoSizeColumn(1); | ||||
|  | ||||
|                 // --- 写入文件 --- | ||||
|                 using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write)) | ||||
|                 { | ||||
|                     workbook.Write(fs); | ||||
|                 } | ||||
|  | ||||
|                 ThreadSafeLogger.Log($"统计数据已成功导出到: {filePath}"); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 ThreadSafeLogger.Log($"[ERROR] 导出统计数据失败: {ex.Message}"); | ||||
|                 MessageBox.Show($"导出统计数据失败: {ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										112
									
								
								Check.Main/Common/ThreadSafeLogger.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								Check.Main/Common/ThreadSafeLogger.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace Check.Main.Common | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 一个线程安全的、基于队列的日志记录器。 | ||||
|     /// 使用一个独立的后台线程来处理文件写入,避免业务线程阻塞。 | ||||
|     /// </summary> | ||||
|     public static class ThreadSafeLogger | ||||
|     { | ||||
|         // 使用线程安全的队列作为日志消息的缓冲区 | ||||
|         private static readonly BlockingCollection<string> _logQueue = new BlockingCollection<string>(); | ||||
|  | ||||
|         // 日志写入线程 | ||||
|         private static Thread _logWriterThread; | ||||
|  | ||||
|         private static StreamWriter _logFileWriter; | ||||
|  | ||||
|         // 事件,用于将格式化后的日志消息广播给UI等监听者 | ||||
|         public static event Action<string> OnLogMessage; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 初始化日志记录器,启动后台写入线程。 | ||||
|         /// </summary> | ||||
|         public static void Initialize() | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 string logDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); | ||||
|                 Directory.CreateDirectory(logDirectory); | ||||
|  | ||||
|                 string logFileName = $"Log_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt"; | ||||
|                 string logFilePath = Path.Combine(logDirectory, logFileName); | ||||
|  | ||||
|                 _logFileWriter = new StreamWriter(logFilePath, append: true, encoding: Encoding.UTF8) { AutoFlush = true }; | ||||
|  | ||||
|                 // 创建并启动后台线程 | ||||
|                 _logWriterThread = new Thread(ProcessLogQueue) | ||||
|                 { | ||||
|                     IsBackground = true, // 设置为后台线程,这样主程序退出时它会自动终止 | ||||
|                     Name = "LogWriterThread" | ||||
|                 }; | ||||
|                 _logWriterThread.Start(); | ||||
|  | ||||
|                 Log("日志系统已初始化。"); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 // 如果初始化失败,尝试通过事件通知UI | ||||
|                 OnLogMessage?.Invoke($"[CRITICAL] 日志系统初始化失败: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 将一条日志消息添加到队列中。这个方法是线程安全的,且执行速度非常快。 | ||||
|         /// </summary> | ||||
|         /// <param name="message">原始日志消息。</param> | ||||
|         public static void Log(string message) | ||||
|         { | ||||
|             string formattedMessage = $"[{DateTime.Now:HH:mm:ss.fff}] {message}"; | ||||
|             _logQueue.Add(formattedMessage); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 后台线程的工作方法。它会持续不断地从队列中取出消息并处理。 | ||||
|         /// </summary> | ||||
|         private static void ProcessLogQueue() | ||||
|         { | ||||
|             // GetConsumingEnumerable会阻塞等待,直到有新的项加入队列或队列被标记为已完成 | ||||
|             foreach (string message in _logQueue.GetConsumingEnumerable()) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     // 1. 写入文件 | ||||
|                     _logFileWriter?.WriteLine(message); | ||||
|  | ||||
|                     // 2. 触发事件,通知UI | ||||
|                     OnLogMessage?.Invoke(message); | ||||
|                 } | ||||
|                 catch | ||||
|                 { | ||||
|                     // 忽略在日志线程本身发生的写入错误 | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 关闭日志记录器,释放资源。 | ||||
|         /// </summary> | ||||
|         public static void Shutdown() | ||||
|         { | ||||
|             Log("日志系统正在关闭..."); | ||||
|  | ||||
|             // 标记队列不再接受新的项目。这会让ProcessLogQueue中的循环在处理完所有剩余项后自然结束。 | ||||
|             _logQueue.CompleteAdding(); | ||||
|  | ||||
|             // 等待日志线程处理完所有剩余的日志,最多等待2秒 | ||||
|             _logWriterThread?.Join(2000); | ||||
|  | ||||
|             // 关闭文件流 | ||||
|             _logFileWriter?.Close(); | ||||
|             _logFileWriter?.Dispose(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user