视觉修改

This commit is contained in:
17860779768
2025-08-25 16:33:58 +08:00
commit 2e46747ba9
49 changed files with 11062 additions and 0 deletions

View 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; }
}
}
}

View 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();
}
}
}

View 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()
{
// 目前只持有结果字符串,无需释放资源
}
}
}

View 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);
}
}
}

View 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;
}
}
}

View 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();
}
}
}

View 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);
}
}
}

View 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;
}
}
}

View 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);
}
}
}
}

View 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();
}
}
}