diff --git a/.windsurf/rules/readexcelfilerules.md b/.windsurf/rules/readexcelfilerules.md new file mode 100644 index 0000000..13aa180 --- /dev/null +++ b/.windsurf/rules/readexcelfilerules.md @@ -0,0 +1,73 @@ +--- +trigger: always_on +--- +1. 读取ExcelFile/20251218161818.xlsx文件,这个是其他系统导出的Excel模板文件,文件名称里面是时间信息,里面有多个Sheet,每个Sheet都是一个表 +2. 根据这个Excel文件和里面的多个Sheet帮我建立一个多个数据模型,数据模型建立在FATrace.Model/FileModel 下面 +3. 数据模型/表的翻译如下: + 工厂-入库 :FactoryInbound + 工厂-领料:FactoryMaterial Withdrawal + 工厂-出入库:**FactoryInventoryTransaction** + 工厂-原料生产信息:FactoryProductionRecord + 工厂-成品出库:FactoryOutbound + OEM-入库:OEMInbound + OEM-出库:OEMOutbound + OEM-出入库:OEMInventoryTransaction + OEM-原料使用信息:OEMRawUsageInfo +4. 每个表/模型的字段属性的名称,你可以读取excel里面的列头中文进行翻译,要求简洁。 + 很多的字段属性在FATrace.Model下其他的表/模型里面有有了,你可以借鉴他们的属性字段英文名称,这样属性字段能统一。 + 这些sheet里面的数据我后期只是展示用,表/模型的主键用long,其他Excel数据属性字段数据类型全部用string 类型, +5. 其他系统是计划在每天0点下载一次这个文件(上面的Excel模板文件),我们每天每个小时进行文件数据的检查,发现文件后读取Excel文件,把这些数据按照sheet(表格)存储到数据库中,然后把这些文件文件拷贝到另一个地方,那么读取文件的路径和拷贝到另一个文件的路径要可配置。 +6. 每次读取Excel文件且拷贝并保存到数据库成功后要把这个信息记录一个表中,这个表的你可以自己定义,方便我后期查找问题和对照数据 +7. 按照我的框架和模型,每个表都要做一个查询界面(从数据库中查询),这些查询界面View和ViewModel等其他,你需要按照我当前的框架和模式开发,这样代码能统一,这些数据的查询界面单独一个菜单目录节点,不要跟其他的查询放到一起,要分开 +8. 有不理解的地方需要跟我确认,你要列一个任务清单,一个一个的执行 +9. 你可以联网查询你需要的内容 +10. 这些逻辑放到FATrace.WPLApp.Services.ReadFileServices 中,查询界面(View + ViewModel)放到FATrace.WPLApp中 + + + +11. 每个Sheet的列头内容 + +工厂-入库 + +| 产地 | 原料代码 | 原料名称 | 重量 | 登录日 | 登录时间 | 登录日期时间 | +| ---- | -------- | -------- | ---- | ------ | -------- | ------------ | + +工厂-领料 + +| 产地 | 原料代码 | 原料名称 | 重量 | 登录日 | 登录时间 | 登录日期时间 | +| ---- | -------- | -------- | ---- | ------ | -------- | ------------ | + +工厂-出入库 + +| 入库时间 | 出库时间 | 产地 | 原料代码 | 原料名称 | 入库总重量KG | 出库总重量KG | 剩余重量KG | +| -------- | -------- | ---- | -------- | -------- | ------------ | ------------ | ---------- | + +工厂-原料生产信息 + +| 原料编号 | 原料名称 | 产地 | 内袋二维码 | 外箱二维码 | 批号 | 保质期 | 称重重量(g) | 配料日期 | 剩余重量(Kg) | 入库总重量(Kg) | 称重时间 | 操作者 | 确认者 | 外箱扫码时间 | +| -------- | -------- | ---- | ---------- | ---------- | ---- | ------ | ----------- | -------- | ------------ | -------------- | -------- | ------ | ------ | ------------ | + +工厂-成品出库 + +| 批号 | 重量 | 保质期 | 产地 | 原料代码 | 原料名称 | 序号 | 登录日 | 登录时间 | 登录日期时间 | +| ---- | ---- | ------ | ---- | -------- | -------- | ---- | ------ | -------- | ------------ | + +OEM-入库 + +| 批号 | 重量 | 保质期 | 产地 | 原料代码 | 原料名称 | 序号 | 登录日 | 登录时间 | 登录日期时间 | +| ---- | ---- | ------ | ---- | -------- | -------- | ---- | ------ | -------- | ------------ | + +OEM-出库 + +| 批号 | 重量 | 保质期 | 产地 | 原料代码 | 原料名称 | 序号 | 登录日 | 登录时间 | 登录日期时间 | +| ---- | ---- | ------ | ---- | -------- | -------- | ---- | ------ | -------- | ------------ | + +OEM-出入库 + +| 入库时间 | 出库时间 | 产地 | 原料代码 | 原料名称 | 入库总重量KG | 出库总重量KG | 剩余重量KG | +| -------- | -------- | ---- | -------- | -------- | ------------ | ------------ | ---------- | + +OEM-原料使用信息 + +| 原料使用时间 | 内袋二维码 | 原料产地 | 原料名称 | 原料代码 | 视频链接 | +| ------------ | ---------- | -------- | -------- | -------- | -------- | diff --git a/FATrace.Model/FATrace.Model.csproj b/FATrace.Model/FATrace.Model.csproj index cbcb056..3d9c1d1 100644 --- a/FATrace.Model/FATrace.Model.csproj +++ b/FATrace.Model/FATrace.Model.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/FATrace.Model/FileModel/FactoryInbound.cs b/FATrace.Model/FileModel/FactoryInbound.cs new file mode 100644 index 0000000..d740813 --- /dev/null +++ b/FATrace.Model/FileModel/FactoryInbound.cs @@ -0,0 +1,52 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// 工厂-入库(Excel 导入数据,仅展示用) + /// + [Table(Name = "FactoryInbound")] + public class FactoryInbound + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 重量 + /// + public string? Weight { get; set; } + + /// + /// 登录日 + /// + public string? LoginDate { get; set; } + + /// + /// 登录时间 + /// + public string? LoginTime { get; set; } + + /// + /// 登录日期时间 + /// + public string? LoginDateTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/FactoryInventoryTransaction.cs b/FATrace.Model/FileModel/FactoryInventoryTransaction.cs new file mode 100644 index 0000000..d539bb4 --- /dev/null +++ b/FATrace.Model/FileModel/FactoryInventoryTransaction.cs @@ -0,0 +1,57 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// 工厂-出入库(Excel 导入数据,仅展示用) + /// + [Table(Name = "FactoryInventoryTransaction")] + public class FactoryInventoryTransaction + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 入库时间 + /// + public string? InTime { get; set; } + + /// + /// 出库时间 + /// + public string? OutTime { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 入库总重量 KG + /// + public string? TotalInWeightKg { get; set; } + + /// + /// 出库总重量 KG + /// + public string? TotalOutWeightKg { get; set; } + + /// + /// 剩余重量 KG + /// + public string? RemainWeightKg { get; set; } + } +} diff --git a/FATrace.Model/FileModel/FactoryMaterialWithdrawal.cs b/FATrace.Model/FileModel/FactoryMaterialWithdrawal.cs new file mode 100644 index 0000000..242f8a6 --- /dev/null +++ b/FATrace.Model/FileModel/FactoryMaterialWithdrawal.cs @@ -0,0 +1,52 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// 工厂-领料(Excel 导入数据,仅展示用) + /// + [Table(Name = "FactoryMaterialWithdrawal")] + public class FactoryMaterialWithdrawal + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 重量 + /// + public string? Weight { get; set; } + + /// + /// 登录日 + /// + public string? LoginDate { get; set; } + + /// + /// 登录时间 + /// + public string? LoginTime { get; set; } + + /// + /// 登录日期时间 + /// + public string? LoginDateTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/FactoryOutbound.cs b/FATrace.Model/FileModel/FactoryOutbound.cs new file mode 100644 index 0000000..e8ae57c --- /dev/null +++ b/FATrace.Model/FileModel/FactoryOutbound.cs @@ -0,0 +1,67 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// 工厂-成品出库(Excel 导入数据,仅展示用) + /// + [Table(Name = "FactoryOutbound")] + public class FactoryOutbound + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 批号 + /// + public string? Batch { get; set; } + + /// + /// 重量 + /// + public string? Weight { get; set; } + + /// + /// 保质期 + /// + public string? ShelfLife { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 序号 + /// + public string? SequenceNo { get; set; } + + /// + /// 登录日 + /// + public string? LoginDate { get; set; } + + /// + /// 登录时间 + /// + public string? LoginTime { get; set; } + + /// + /// 登录日期时间 + /// + public string? LoginDateTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/FactoryProductionRecord.cs b/FATrace.Model/FileModel/FactoryProductionRecord.cs new file mode 100644 index 0000000..e873085 --- /dev/null +++ b/FATrace.Model/FileModel/FactoryProductionRecord.cs @@ -0,0 +1,92 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// 工厂-原料生产信息(Excel 导入数据,仅展示用) + /// + [Table(Name = "FactoryProductionRecord")] + public class FactoryProductionRecord + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 原料编号 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 内袋二维码 + /// + public string? InBagCode { get; set; } + + /// + /// 外箱二维码 + /// + public string? BoxCode { get; set; } + + /// + /// 批号 + /// + public string? Batch { get; set; } + + /// + /// 保质期 + /// + public string? ShelfLife { get; set; } + + /// + /// 称重重量 (g) + /// + public string? Weight { get; set; } + + /// + /// 配料日期 + /// + public string? DeliveryDate { get; set; } + + /// + /// 剩余重量 (Kg) + /// + public string? RemainWeight { get; set; } + + /// + /// 入库总重量 (Kg) + /// + public string? StockWeight { get; set; } + + /// + /// 称重时间 + /// + public string? WeightTime { get; set; } + + /// + /// 操作者 + /// + public string? OpUser { get; set; } + + /// + /// 确认者 + /// + public string? CheckUser { get; set; } + + /// + /// 外箱扫码时间 + /// + public string? BoxScanTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/FileImportLog.cs b/FATrace.Model/FileModel/FileImportLog.cs new file mode 100644 index 0000000..85f82b4 --- /dev/null +++ b/FATrace.Model/FileModel/FileImportLog.cs @@ -0,0 +1,64 @@ +using FreeSql.DataAnnotations; +using System; + +namespace FATrace.Model +{ + /// + /// Excel 文件导入日志,用于追踪每次导入结果与行数统计 + /// + [Table(Name = "FileImportLog")] + public class FileImportLog + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 文件名(包含时间戳),例如:20251218161818.xlsx + /// + [Column(StringLength = 260)] + public string? FileName { get; set; } + + /// + /// 源文件完整路径 + /// + [Column(StringLength = 500)] + public string? SourcePath { get; set; } + + /// + /// 归档后完整路径 + /// + [Column(StringLength = 500)] + public string? ArchivePath { get; set; } + + /// + /// 导入开始时间 + /// + public DateTime StartTime { get; set; } + + /// + /// 导入结束时间 + /// + public DateTime EndTime { get; set; } + + /// + /// 导入状态:Success / Failed / Partial 等 + /// + [Column(StringLength = 20)] + public string? Status { get; set; } + + /// + /// 错误或摘要信息 + /// + [Column(StringLength = 1000)] + public string? Message { get; set; } + + /// + /// 每个 Sheet 的行数统计摘要,例如:FactoryInbound=100;FactoryOutbound=50 + /// + [Column(StringLength = 1000)] + public string? SheetRowStats { get; set; } + } +} diff --git a/FATrace.Model/FileModel/OEMInbound.cs b/FATrace.Model/FileModel/OEMInbound.cs new file mode 100644 index 0000000..8676ef6 --- /dev/null +++ b/FATrace.Model/FileModel/OEMInbound.cs @@ -0,0 +1,67 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// OEM-入库(Excel 导入数据,仅展示用) + /// + [Table(Name = "OEMInbound")] + public class OEMInbound + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 批号 + /// + public string? Batch { get; set; } + + /// + /// 重量 + /// + public string? Weight { get; set; } + + /// + /// 保质期 + /// + public string? ShelfLife { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 序号 + /// + public string? SequenceNo { get; set; } + + /// + /// 登录日 + /// + public string? LoginDate { get; set; } + + /// + /// 登录时间 + /// + public string? LoginTime { get; set; } + + /// + /// 登录日期时间 + /// + public string? LoginDateTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/OEMInventoryTransaction.cs b/FATrace.Model/FileModel/OEMInventoryTransaction.cs new file mode 100644 index 0000000..04d5df6 --- /dev/null +++ b/FATrace.Model/FileModel/OEMInventoryTransaction.cs @@ -0,0 +1,57 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// OEM-出入库(Excel 导入数据,仅展示用) + /// + [Table(Name = "OEMInventoryTransaction")] + public class OEMInventoryTransaction + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 入库时间 + /// + public string? InTime { get; set; } + + /// + /// 出库时间 + /// + public string? OutTime { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 入库总重量 KG + /// + public string? TotalInWeightKg { get; set; } + + /// + /// 出库总重量 KG + /// + public string? TotalOutWeightKg { get; set; } + + /// + /// 剩余重量 KG + /// + public string? RemainWeightKg { get; set; } + } +} diff --git a/FATrace.Model/FileModel/OEMOutbound.cs b/FATrace.Model/FileModel/OEMOutbound.cs new file mode 100644 index 0000000..b11cc14 --- /dev/null +++ b/FATrace.Model/FileModel/OEMOutbound.cs @@ -0,0 +1,67 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// OEM-出库(Excel 导入数据,仅展示用) + /// + [Table(Name = "OEMOutbound")] + public class OEMOutbound + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 批号 + /// + public string? Batch { get; set; } + + /// + /// 重量 + /// + public string? Weight { get; set; } + + /// + /// 保质期 + /// + public string? ShelfLife { get; set; } + + /// + /// 产地 + /// + public string? Origin { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 序号 + /// + public string? SequenceNo { get; set; } + + /// + /// 登录日 + /// + public string? LoginDate { get; set; } + + /// + /// 登录时间 + /// + public string? LoginTime { get; set; } + + /// + /// 登录日期时间 + /// + public string? LoginDateTime { get; set; } + } +} diff --git a/FATrace.Model/FileModel/OEMRawUsageInfo.cs b/FATrace.Model/FileModel/OEMRawUsageInfo.cs new file mode 100644 index 0000000..b42880d --- /dev/null +++ b/FATrace.Model/FileModel/OEMRawUsageInfo.cs @@ -0,0 +1,47 @@ +using FreeSql.DataAnnotations; + +namespace FATrace.Model +{ + /// + /// OEM-原料使用信息(Excel 导入数据,仅展示用) + /// + [Table(Name = "OEMRawUsageInfo")] + public class OEMRawUsageInfo + { + /// + /// 主键 + /// + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 原料使用时间 + /// + public string? RawUseTime { get; set; } + + /// + /// 内袋二维码 + /// + public string? InBagCode { get; set; } + + /// + /// 原料产地 + /// + public string? Origin { get; set; } + + /// + /// 原料名称 + /// + public string? RawName { get; set; } + + /// + /// 原料代码 + /// + public string? RawCode { get; set; } + + /// + /// 视频链接 + /// + public string? VideoUrl { get; set; } + } +} diff --git a/FATrace.Model/QRModel.cs b/FATrace.Model/QRModel.cs new file mode 100644 index 0000000..f9f6095 --- /dev/null +++ b/FATrace.Model/QRModel.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FATrace.Model +{ + /// + /// 二维码模型信息 + /// 示例:DYG05030013,20250923,802,3,01,0001 + /// 1段:产品编码 + /// 2段:批号/生产日期,格式 yyyyMMdd,例如 20250923 + /// 3段:重量编码,3/4 位数字,末位为小数位,例如 802 表示 80.2g + /// 4段:保质期(月数),1 位数字 + /// 5段:国家代码,01=国内,02=日本 + /// 6段:当日产量计数(4 位,从 0001 开始) + /// + public class QRModel + { + /// + /// 原始二维码字符串 + /// + public string RawText { get; set; } = string.Empty; + + /// + /// 原料编号(第一段) + /// + public string RawCode { get; set; } = string.Empty; + + /// + /// 批号 + /// + public string Batch { get; set; } + + /// + /// 重量(第三段解析后的实际数值,比如 802 -> 80.2) + /// 单位:克(如有需要可在业务层自行换算) + /// + public decimal Weight { get; set; } + + /// + /// 保质期(月数,来自第四段) + /// + public int ShelfLife { get; set; } + + /// + /// 国家代码(第五段,例如 01=国内,02=日本) + /// + public string RawSource { get; set; } = string.Empty; + + /// + /// 当日生产序号(第六段,4 位,从 1 开始) + /// + public int DailySequence { get; set; } + + /// + /// 解析二维码字符串为 QRModel。 + /// 预期格式:"产品编码,yyyyMMdd,重量编码,保质期(月),国家代码,当日序号"。 + /// + /// 二维码原始字符串 + /// 解析成功的 QRModel 实例 + /// 当格式不正确时抛出 + public static QRModel Parse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentException("二维码内容不能为空。", nameof(text)); + } + + var parts = text.Split(','); + if (parts.Length != 6) + { + throw new ArgumentException($"二维码格式不正确,预期 6 段,实际为 {parts.Length} 段。内容:{text}", nameof(text)); + } + + var model = new QRModel + { + RawText = text.Trim(), + RawCode = parts[0].Trim(), + Batch = parts[1].Trim(), + RawSource = parts[4].Trim() + }; + + // 重量:3/4 位数字,末位为小数位,例如 802 -> 80.2 + var weightCode = parts[2].Trim(); + if (!int.TryParse(weightCode, out var weightInt)) + { + throw new ArgumentException($"重量段不是有效数字:{weightCode}", nameof(text)); + } + // 按 1 位小数解析(80.2g),如需其他规则可在此调整 + model.Weight = weightInt / 10m; + + // 保质期(月) + var shelfText = parts[3].Trim(); + if (!int.TryParse(shelfText, out var shelfMonths) || shelfMonths < 0) + { + throw new ArgumentException($"保质期(月)不是有效数字:{shelfText}", nameof(text)); + } + model.ShelfLife = shelfMonths; + + // 当日序号 + var seqText = parts[5].Trim(); + if (!int.TryParse(seqText, out var seq) || seq < 0) + { + throw new ArgumentException($"当日生产序号不是有效数字:{seqText}", nameof(text)); + } + model.DailySequence = seq; + + return model; + } + } +} diff --git a/FATrace.OEMApp/App.config b/FATrace.OEMApp/App.config index 34ee6f8..448f5e6 100644 --- a/FATrace.OEMApp/App.config +++ b/FATrace.OEMApp/App.config @@ -8,6 +8,8 @@ + + diff --git a/FATrace.OEMApp/FATrace.OEMApp.csproj b/FATrace.OEMApp/FATrace.OEMApp.csproj index 2883a18..5fabff0 100644 --- a/FATrace.OEMApp/FATrace.OEMApp.csproj +++ b/FATrace.OEMApp/FATrace.OEMApp.csproj @@ -420,6 +420,7 @@ + diff --git a/FATrace.OEMApp/MainApp.Designer.cs b/FATrace.OEMApp/MainApp.Designer.cs index 4fee8f6..64a5ca8 100644 --- a/FATrace.OEMApp/MainApp.Designer.cs +++ b/FATrace.OEMApp/MainApp.Designer.cs @@ -79,6 +79,10 @@ namespace FATrace.OEMApp btnStopLoadVideo = new Button(); btnNVRLogin = new Button(); imageList2 = new ImageList(components); + navPanel = new Panel(); + btnNavMain = new Button(); + btnNavHistory = new Button(); + btnNavSettings = new Button(); statusStrip1.SuspendLayout(); materialTabControl1.SuspendLayout(); tabPage1.SuspendLayout(); @@ -91,6 +95,7 @@ namespace FATrace.OEMApp materialCard1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)videoView1).BeginInit(); tabPage3.SuspendLayout(); + navPanel.SuspendLayout(); SuspendLayout(); // // statusStrip1 @@ -146,18 +151,21 @@ namespace FATrace.OEMApp // // materialTabControl1 // + materialTabControl1.Appearance = TabAppearance.FlatButtons; materialTabControl1.Controls.Add(tabPage1); materialTabControl1.Controls.Add(tabPage2); materialTabControl1.Controls.Add(tabPage3); materialTabControl1.Depth = 0; materialTabControl1.Dock = DockStyle.Fill; materialTabControl1.ImageList = imageList1; + materialTabControl1.ItemSize = new Size(0, 1); materialTabControl1.Location = new Point(3, 64); materialTabControl1.MouseState = ReaLTaiizor.Helper.MaterialDrawHelper.MaterialMouseState.HOVER; materialTabControl1.Multiline = true; materialTabControl1.Name = "materialTabControl1"; materialTabControl1.SelectedIndex = 0; materialTabControl1.Size = new Size(1914, 963); + materialTabControl1.SizeMode = TabSizeMode.Fixed; materialTabControl1.TabIndex = 0; // // tabPage1 @@ -167,10 +175,10 @@ namespace FATrace.OEMApp tabPage1.Controls.Add(materialCard2); tabPage1.Font = new Font("Microsoft YaHei UI", 14.25F, FontStyle.Bold, GraphicsUnit.Point, 134); tabPage1.ImageKey = "set2.png"; - tabPage1.Location = new Point(4, 26); + tabPage1.Location = new Point(4, 5); tabPage1.Name = "tabPage1"; tabPage1.Padding = new Padding(3); - tabPage1.Size = new Size(1906, 933); + tabPage1.Size = new Size(1906, 954); tabPage1.TabIndex = 0; tabPage1.Text = "主界面"; tabPage1.UseVisualStyleBackColor = true; @@ -187,12 +195,12 @@ namespace FATrace.OEMApp materialCard4.Controls.Add(DownloadProgressBarMain); materialCard4.Depth = 0; materialCard4.ForeColor = Color.FromArgb(222, 0, 0, 0); - materialCard4.Location = new Point(14, 373); + materialCard4.Location = new Point(196, 373); materialCard4.Margin = new Padding(14); materialCard4.MouseState = ReaLTaiizor.Helper.MaterialDrawHelper.MaterialMouseState.HOVER; materialCard4.Name = "materialCard4"; materialCard4.Padding = new Padding(14); - materialCard4.Size = new Size(561, 535); + materialCard4.Size = new Size(579, 535); materialCard4.TabIndex = 5; // // txtCsvSaveState @@ -267,19 +275,19 @@ namespace FATrace.OEMApp materialCard3.Controls.Add(label8); materialCard3.Depth = 0; materialCard3.ForeColor = Color.FromArgb(222, 0, 0, 0); - materialCard3.Location = new Point(590, 373); + materialCard3.Location = new Point(788, 373); materialCard3.Margin = new Padding(14); materialCard3.MouseState = ReaLTaiizor.Helper.MaterialDrawHelper.MaterialMouseState.HOVER; materialCard3.Name = "materialCard3"; materialCard3.Padding = new Padding(14); - materialCard3.Size = new Size(1307, 535); + materialCard3.Size = new Size(1109, 535); materialCard3.TabIndex = 1; // // LvLog // LvLog.Location = new Point(17, 58); LvLog.Name = "LvLog"; - LvLog.Size = new Size(1281, 460); + LvLog.Size = new Size(1075, 460); LvLog.TabIndex = 6; LvLog.UseCompatibleStateImageBehavior = false; // @@ -309,19 +317,19 @@ namespace FATrace.OEMApp materialCard2.Controls.Add(label5); materialCard2.Depth = 0; materialCard2.ForeColor = Color.FromArgb(222, 0, 0, 0); - materialCard2.Location = new Point(14, 9); + materialCard2.Location = new Point(196, 9); materialCard2.Margin = new Padding(14); materialCard2.MouseState = ReaLTaiizor.Helper.MaterialDrawHelper.MaterialMouseState.HOVER; materialCard2.Name = "materialCard2"; materialCard2.Padding = new Padding(14); - materialCard2.Size = new Size(1883, 348); + materialCard2.Size = new Size(1701, 348); materialCard2.TabIndex = 0; // // label19 // label19.AutoSize = true; label19.ForeColor = Color.DimGray; - label19.Location = new Point(41, 180); + label19.Location = new Point(29, 180); label19.Name = "label19"; label19.Size = new Size(88, 26); label19.TabIndex = 10; @@ -329,7 +337,7 @@ namespace FATrace.OEMApp // // txtRURawCode // - txtRURawCode.Location = new Point(154, 177); + txtRURawCode.Location = new Point(142, 177); txtRURawCode.Name = "txtRURawCode"; txtRURawCode.ReadOnly = true; txtRURawCode.Size = new Size(387, 32); @@ -341,17 +349,17 @@ namespace FATrace.OEMApp gridRULog.AllowUserToDeleteRows = false; gridRULog.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; gridRULog.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; - gridRULog.Location = new Point(576, 7); + gridRULog.Location = new Point(592, 9); gridRULog.Name = "gridRULog"; gridRULog.ReadOnly = true; gridRULog.RowHeadersVisible = false; gridRULog.SelectionMode = DataGridViewSelectionMode.FullRowSelect; - gridRULog.Size = new Size(1290, 338); + gridRULog.Size = new Size(1092, 330); gridRULog.TabIndex = 8; // // btnRawStopLoadVideo // - btnRawStopLoadVideo.Location = new Point(292, 271); + btnRawStopLoadVideo.Location = new Point(285, 271); btnRawStopLoadVideo.Name = "btnRawStopLoadVideo"; btnRawStopLoadVideo.Size = new Size(142, 44); btnRawStopLoadVideo.TabIndex = 7; @@ -361,7 +369,7 @@ namespace FATrace.OEMApp // // btnTestAction // - btnTestAction.Location = new Point(144, 271); + btnTestAction.Location = new Point(137, 271); btnTestAction.Name = "btnTestAction"; btnTestAction.Size = new Size(142, 44); btnTestAction.TabIndex = 5; @@ -373,7 +381,7 @@ namespace FATrace.OEMApp // label7.AutoSize = true; label7.ForeColor = Color.DimGray; - label7.Location = new Point(41, 126); + label7.Location = new Point(29, 126); label7.Name = "label7"; label7.Size = new Size(88, 26); label7.TabIndex = 4; @@ -381,7 +389,7 @@ namespace FATrace.OEMApp // // txtRURawName // - txtRURawName.Location = new Point(154, 123); + txtRURawName.Location = new Point(142, 123); txtRURawName.Name = "txtRURawName"; txtRURawName.ReadOnly = true; txtRURawName.Size = new Size(387, 32); @@ -391,7 +399,7 @@ namespace FATrace.OEMApp // label6.AutoSize = true; label6.ForeColor = Color.DimGray; - label6.Location = new Point(41, 68); + label6.Location = new Point(29, 68); label6.Name = "label6"; label6.Size = new Size(107, 26); label6.TabIndex = 2; @@ -399,7 +407,7 @@ namespace FATrace.OEMApp // // txtRUInBagCode // - txtRUInBagCode.Location = new Point(154, 65); + txtRUInBagCode.Location = new Point(142, 65); txtRUInBagCode.Name = "txtRUInBagCode"; txtRUInBagCode.ReadOnly = true; txtRUInBagCode.Size = new Size(387, 32); @@ -423,10 +431,10 @@ namespace FATrace.OEMApp tabPage2.Controls.Add(videoView1); tabPage2.Font = new Font("Microsoft YaHei UI", 14.25F, FontStyle.Bold, GraphicsUnit.Point, 134); tabPage2.ImageKey = "Load.png"; - tabPage2.Location = new Point(4, 26); + tabPage2.Location = new Point(4, 5); tabPage2.Name = "tabPage2"; tabPage2.Padding = new Padding(3); - tabPage2.Size = new Size(1906, 933); + tabPage2.Size = new Size(1906, 954); tabPage2.TabIndex = 1; tabPage2.Text = "录像历史数据"; tabPage2.UseVisualStyleBackColor = true; @@ -434,7 +442,7 @@ namespace FATrace.OEMApp // dataGridView1 // dataGridView1.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; - dataGridView1.Location = new Point(17, 235); + dataGridView1.Location = new Point(197, 235); dataGridView1.Name = "dataGridView1"; dataGridView1.ReadOnly = true; dataGridView1.Size = new Size(835, 677); @@ -454,7 +462,7 @@ namespace FATrace.OEMApp materialCard1.Controls.Add(PdtHistorySearchStart); materialCard1.Depth = 0; materialCard1.ForeColor = Color.FromArgb(222, 0, 0, 0); - materialCard1.Location = new Point(17, 24); + materialCard1.Location = new Point(197, 24); materialCard1.Margin = new Padding(14); materialCard1.MouseState = ReaLTaiizor.Helper.MaterialDrawHelper.MaterialMouseState.HOVER; materialCard1.Name = "materialCard1"; @@ -553,10 +561,10 @@ namespace FATrace.OEMApp // videoView1 // videoView1.BackColor = Color.Black; - videoView1.Location = new Point(858, 24); + videoView1.Location = new Point(1043, 24); videoView1.MediaPlayer = null; videoView1.Name = "videoView1"; - videoView1.Size = new Size(1042, 888); + videoView1.Size = new Size(857, 888); videoView1.TabIndex = 7; videoView1.Text = "videoView1"; // @@ -567,9 +575,9 @@ namespace FATrace.OEMApp tabPage3.Controls.Add(btnStopLoadVideo); tabPage3.Controls.Add(btnNVRLogin); tabPage3.ImageKey = "set3.png"; - tabPage3.Location = new Point(4, 26); + tabPage3.Location = new Point(4, 5); tabPage3.Name = "tabPage3"; - tabPage3.Size = new Size(1906, 933); + tabPage3.Size = new Size(1906, 954); tabPage3.TabIndex = 2; tabPage3.Text = "相机设置"; tabPage3.UseVisualStyleBackColor = true; @@ -599,14 +607,14 @@ namespace FATrace.OEMApp // // DownloadProgressBar // - DownloadProgressBar.Location = new Point(247, 124); + DownloadProgressBar.Location = new Point(382, 112); DownloadProgressBar.Name = "DownloadProgressBar"; DownloadProgressBar.Size = new Size(130, 23); DownloadProgressBar.TabIndex = 3; // // btnStopLoadVideo // - btnStopLoadVideo.Location = new Point(409, 54); + btnStopLoadVideo.Location = new Point(544, 42); btnStopLoadVideo.Name = "btnStopLoadVideo"; btnStopLoadVideo.Size = new Size(130, 58); btnStopLoadVideo.TabIndex = 2; @@ -616,7 +624,7 @@ namespace FATrace.OEMApp // // btnNVRLogin // - btnNVRLogin.Location = new Point(84, 54); + btnNVRLogin.Location = new Point(219, 42); btnNVRLogin.Name = "btnNVRLogin"; btnNVRLogin.Size = new Size(130, 58); btnNVRLogin.TabIndex = 0; @@ -631,14 +639,84 @@ namespace FATrace.OEMApp imageList2.TransparentColor = Color.Transparent; imageList2.Images.SetKeyName(0, "About.png"); // + // navPanel + // + navPanel.BackColor = Color.FromArgb(250, 250, 250); + navPanel.Controls.Add(btnNavMain); + navPanel.Controls.Add(btnNavHistory); + navPanel.Controls.Add(btnNavSettings); + navPanel.Dock = DockStyle.Left; + navPanel.Location = new Point(3, 64); + navPanel.Name = "navPanel"; + navPanel.Size = new Size(183, 941); + navPanel.TabIndex = 2; + // + // btnNavMain + // + btnNavMain.BackColor = Color.FromArgb(63, 81, 181); + btnNavMain.FlatAppearance.BorderSize = 0; + btnNavMain.FlatStyle = FlatStyle.Flat; + btnNavMain.Font = new Font("Microsoft YaHei UI", 12F, FontStyle.Bold); + btnNavMain.ForeColor = Color.White; + btnNavMain.ImageAlign = ContentAlignment.MiddleLeft; + btnNavMain.Location = new Point(0, 3); + btnNavMain.Name = "btnNavMain"; + btnNavMain.Padding = new Padding(10, 0, 0, 0); + btnNavMain.Size = new Size(183, 60); + btnNavMain.TabIndex = 0; + btnNavMain.Text = " 主界面"; + btnNavMain.TextAlign = ContentAlignment.MiddleLeft; + btnNavMain.TextImageRelation = TextImageRelation.ImageBeforeText; + btnNavMain.UseVisualStyleBackColor = false; + btnNavMain.Click += BtnNavMain_Click; + // + // btnNavHistory + // + btnNavHistory.BackColor = Color.FromArgb(240, 240, 240); + btnNavHistory.FlatAppearance.BorderSize = 0; + btnNavHistory.FlatStyle = FlatStyle.Flat; + btnNavHistory.Font = new Font("Microsoft YaHei UI", 12F, FontStyle.Bold); + btnNavHistory.ForeColor = Color.FromArgb(64, 64, 64); + btnNavHistory.ImageAlign = ContentAlignment.MiddleLeft; + btnNavHistory.Location = new Point(0, 65); + btnNavHistory.Name = "btnNavHistory"; + btnNavHistory.Padding = new Padding(10, 0, 0, 0); + btnNavHistory.Size = new Size(183, 60); + btnNavHistory.TabIndex = 1; + btnNavHistory.Text = " 历史记录"; + btnNavHistory.TextAlign = ContentAlignment.MiddleLeft; + btnNavHistory.TextImageRelation = TextImageRelation.ImageBeforeText; + btnNavHistory.UseVisualStyleBackColor = false; + btnNavHistory.Click += BtnNavHistory_Click; + // + // btnNavSettings + // + btnNavSettings.BackColor = Color.FromArgb(240, 240, 240); + btnNavSettings.FlatAppearance.BorderSize = 0; + btnNavSettings.FlatStyle = FlatStyle.Flat; + btnNavSettings.Font = new Font("Microsoft YaHei UI", 12F, FontStyle.Bold); + btnNavSettings.ForeColor = Color.FromArgb(64, 64, 64); + btnNavSettings.ImageAlign = ContentAlignment.MiddleLeft; + btnNavSettings.Location = new Point(0, 127); + btnNavSettings.Name = "btnNavSettings"; + btnNavSettings.Padding = new Padding(10, 0, 0, 0); + btnNavSettings.Size = new Size(183, 60); + btnNavSettings.TabIndex = 2; + btnNavSettings.Text = " 相机设置"; + btnNavSettings.TextAlign = ContentAlignment.MiddleLeft; + btnNavSettings.TextImageRelation = TextImageRelation.ImageBeforeText; + btnNavSettings.UseVisualStyleBackColor = false; + btnNavSettings.Visible = false; + btnNavSettings.Click += BtnNavSettings_Click; + // // MainApp // AutoScaleDimensions = new SizeF(8F, 17F); AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(1920, 1030); + Controls.Add(navPanel); Controls.Add(statusStrip1); Controls.Add(materialTabControl1); - DrawerTabControl = materialTabControl1; Font = new Font("Microsoft YaHei UI", 9F, FontStyle.Bold, GraphicsUnit.Point, 134); Icon = (Icon)resources.GetObject("$this.Icon"); Name = "MainApp"; @@ -663,6 +741,7 @@ namespace FATrace.OEMApp materialCard1.PerformLayout(); ((System.ComponentModel.ISupportInitialize)videoView1).EndInit(); tabPage3.ResumeLayout(false); + navPanel.ResumeLayout(false); ResumeLayout(false); PerformLayout(); } @@ -721,5 +800,9 @@ namespace FATrace.OEMApp private Label label12; private TextBox txtCsvSaveState; private ImageList imageList2; + private Panel navPanel; + private Button btnNavMain; + private Button btnNavHistory; + private Button btnNavSettings; } } \ No newline at end of file diff --git a/FATrace.OEMApp/MainApp.cs b/FATrace.OEMApp/MainApp.cs index e4746e5..596b5ce 100644 --- a/FATrace.OEMApp/MainApp.cs +++ b/FATrace.OEMApp/MainApp.cs @@ -89,6 +89,42 @@ namespace FATrace.OEMApp private void LogWarn(string msg) => AppendLog("WARN", msg, Color.DarkOrange); private void LogError(string msg) => AppendLog("ERROR", msg, Color.DarkRed); + private void BtnNavMain_Click(object? sender, EventArgs e) + { + materialTabControl1.SelectedIndex = 0; + UpdateNavButtonStyles(0); + } + + private void BtnNavHistory_Click(object? sender, EventArgs e) + { + materialTabControl1.SelectedIndex = 1; + UpdateNavButtonStyles(1); + } + + private void BtnNavSettings_Click(object? sender, EventArgs e) + { + materialTabControl1.SelectedIndex = 2; + UpdateNavButtonStyles(2); + } + + private void UpdateNavButtonStyles(int selectedIndex) + { + var buttons = new[] { btnNavMain, btnNavHistory, btnNavSettings }; + for (int i = 0; i < buttons.Length; i++) + { + if (i == selectedIndex) + { + buttons[i].BackColor = Color.FromArgb(63, 81, 181); + buttons[i].ForeColor = Color.White; + } + else + { + buttons[i].BackColor = Color.FromArgb(240, 240, 240); + buttons[i].ForeColor = Color.FromArgb(64, 64, 64); + } + } + } + public MainApp() { InitializeComponent(); @@ -100,7 +136,11 @@ namespace FATrace.OEMApp private PLCDataService PLCDataService { get; set; } private TimeClearDataService TimeClearService { get; set; } private System.Windows.Forms.Timer _statusTimer; - + + /// + /// TouchSocket Server + /// + private TouchSocketServer TouchSocketServer { get; set; } /// /// 主窗体加载: @@ -109,7 +149,7 @@ namespace FATrace.OEMApp /// - 启动后台任务服务(顺序下载队列 + Jellyfin 批量监听) /// - 初始化并启动 gridRULog(DataGridView)定时刷新,实时展示下载/监听任务状态 /// - private void MainApp_Load(object sender, EventArgs e) + private async void MainApp_Load(object sender, EventArgs e) { InitLvLog(); LogInfo("主界面初始化"); @@ -129,12 +169,32 @@ namespace FATrace.OEMApp //HkCameraClient.NVR_Port = ConfigHelper.GetValue("NVRPort"); //HkCameraClient.NVR_UserName = ConfigHelper.GetValue("NVRUserName"); - PLCDataService = new PLCDataService(); - PLCDataService.PlcConnectedEventHandler += PLCDataService_PlcConnectedEventHandler; - PLCDataService.ScanCodeEventHandler += PLCDataService_ScanCodeEventHandler; + //PLC时间读取 + //PLCDataService = new PLCDataService(); + //PLCDataService.PlcConnectedEventHandler += PLCDataService_PlcConnectedEventHandler; + //PLCDataService.ScanCodeEventHandler += PLCDataService_ScanCodeEventHandler; HkCameraClient.NVRVideoSavePath = ConfigHelper.GetValue("NVRVideoSavePath"); + var TcpServerIp = ConfigHelper.GetValue("TcpServerIp"); + var TcpServerPort = int.Parse(ConfigHelper.GetValue("TcpServerPort")); + TouchSocketServer = new TouchSocketServer(TcpServerIp, TcpServerPort, 30000, 30000); + + TouchSocketServer.ClientConnected += TouchSocketServer_ClientConnected; + TouchSocketServer.ClientDisconnected += TouchSocketServer_ClientDisconnected; + TouchSocketServer.DataReceived += TouchSocketServer_DataReceived; + TouchSocketServer.ServerError += TouchSocketServer_ServerError; + + var serverStarted = await TouchSocketServer.StartAsync(); + if (serverStarted) + { + LogInfo($"TouchSocket Server 已启动,监听 {TcpServerIp}:{TcpServerPort}"); + } + else + { + LogError($"TouchSocket Server 启动失败,监听 {TcpServerIp}:{TcpServerPort}"); + } + //NVR登录 NVRLogin(); @@ -175,11 +235,11 @@ namespace FATrace.OEMApp _ = UpdateNasStatusAsync(); _ = UpdateNvrStatusAsync(); StartStatusTimer(); - + try { var systemName = Program.SystemName; - var authText = Program.IsActive ? "(已授权)" : "(未授权)"; + var authText = Program.IsActive ? "(添加剂追溯系统)" : "(OEM)"; this.Text = string.IsNullOrWhiteSpace(systemName) ? $"添加剂追溯系统 {authText}" : $"{systemName} {authText}"; @@ -213,7 +273,7 @@ namespace FATrace.OEMApp BeginInvoke(new Action(() => { txtRUInBagCode.Text = CurParsedCodeInfo.Code; - txtRURawName.Text= CurParsedCodeInfo.RawName; + txtRURawName.Text = CurParsedCodeInfo.RawName; txtRURawCode.Text = CurParsedCodeInfo.RawCode; })); @@ -573,7 +633,7 @@ namespace FATrace.OEMApp foreach (DataGridViewColumn col in dataGridView1.Columns) { var propName = col.DataPropertyName; - + // 设置列头中文 if (!string.IsNullOrWhiteSpace(propName) && _historyHeaderMap.TryGetValue(propName, out var headerText)) { @@ -645,10 +705,8 @@ namespace FATrace.OEMApp dataGridView1.AllowUserToAddRows = false; } - #endregion - /// /// 测试/结束操作按钮:同样仅入队一条下载任务,方便快速验证下载->监听全流程。 /// @@ -656,8 +714,8 @@ namespace FATrace.OEMApp /// private void btnTestAction_Click(object sender, EventArgs e) { - if (CurParsedCodeInfo==null) return ; - + if (CurParsedCodeInfo == null) return; + var taskId = DownloadTaskWorker.Instance.Enqueue( CurParsedCodeInfo, user: CurUserName, @@ -1065,7 +1123,7 @@ namespace FATrace.OEMApp HkCameraClient.Sdk_NET_DVR_StopGetFile(); } - protected override void OnFormClosing(FormClosingEventArgs e) + protected async override void OnFormClosing(FormClosingEventArgs e) { try { DownloadTaskWorker.Instance.DownloadFileNameChanged -= OnDownloadFileNameChanged; } catch { } try { DownloadTaskWorker.Instance.TaskStarted -= OnTaskStatusChanged; } catch { } @@ -1073,6 +1131,18 @@ namespace FATrace.OEMApp try { DownloadTaskWorker.Instance.TaskFailed -= OnTaskFailed; } catch { } try { _statusTimer?.Stop(); _statusTimer?.Dispose(); _statusTimer = null; } catch { } try { TimeClearService?.Stop(); } catch { } + try + { + if (TouchSocketServer != null && TouchSocketServer.IsRunning) + { + await TouchSocketServer.StopAsync(); + LogInfo("TouchSocket Server 已停止"); + } + } + catch (Exception ex) + { + LogError($"TouchSocket Server 停止失败: {ex.Message}"); + } base.OnFormClosing(e); } @@ -1098,6 +1168,68 @@ namespace FATrace.OEMApp catch { } } + private void TouchSocketServer_ClientConnected(object? sender, TouchSocketServer.ClientConnectedEventArgs e) + { + LogInfo($"[TCP] 客户端连接: {e.ClientId} {e.RemoteIp}:{e.RemotePort}"); + } + + private void TouchSocketServer_ClientDisconnected(object? sender, TouchSocketServer.ClientDisconnectedEventArgs e) + { + LogInfo($"[TCP] 客户端断开: {e.ClientId} {e.RemoteIp}:{e.RemotePort} 原因={e.Reason}"); + } + + private void TouchSocketServer_DataReceived(object? sender, TouchSocketServer.DataReceivedEventArgs e) + { + Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} : - IP : {e.RemoteIp} {e.Data}"); + + // 解析二维码为 QRModel + try + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + //解析Code条码数据,内包条码数据 + CurParsedCodeInfo = NVRCom.ParseCodeFull(e.Data!.Trim()); + + BeginInvoke(new Action(() => + { + txtRUInBagCode.Text = CurParsedCodeInfo.Code; + txtRURawCode.Text = CurParsedCodeInfo.RawCode; + + })); + + try + { + if (CurParsedCodeInfo == null) return; + + var taskId = DownloadTaskWorker.Instance.Enqueue( + CurParsedCodeInfo, + user: CurUserName, + start: DateTime.Now, + end: DateTime.Now + ); + } + catch + { + return; + } + } + } + catch (Exception ex) + { + // 解析失败仅记录错误,不影响后续逻辑 + LogError($"[TCP] QR 解析失败: {ex.Message}"); + } + + + + TouchSocketServer.SendToClient(e.ClientId, "OK"); + } + + private void TouchSocketServer_ServerError(object? sender, Exception ex) + { + LogError($"[TCP] 服务器错误: {ex.Message}"); + } + #region 底部状态栏(PLC/DB/NAS/NVR) private void StartStatusTimer() { diff --git a/FATrace.OEMApp/MainApp.resx b/FATrace.OEMApp/MainApp.resx index c258c49..3610f61 100644 --- a/FATrace.OEMApp/MainApp.resx +++ b/FATrace.OEMApp/MainApp.resx @@ -128,7 +128,7 @@ AAEAAAD/////AQAAAAAAAAAMAgAAAEZTeXN0ZW0uV2luZG93cy5Gb3JtcywgQ3VsdHVyZT1uZXV0cmFs LCBQdWJsaWNLZXlUb2tlbj1iNzdhNWM1NjE5MzRlMDg5BQEAAAAmU3lzdGVtLldpbmRvd3MuRm9ybXMu SW1hZ2VMaXN0U3RyZWFtZXIBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAaCEAAAJNU0Z0AUkBTAIBAQgB - AAG4AQEBuAEBARABAAEQAQAE/wEhAQAI/wFCAU0BNgcAATYDAAEoAwABQAMAATADAAEBAQABIAYAATD/ + AAHoAQEB6AEBARABAAEQAQAE/wEhAQAI/wFCAU0BNgcAATYDAAEoAwABQAMAATADAAEBAQABIAYAATD/ AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AFIAAdsBlgESAf8B2wGWARIB/wQAAxIBGANJAYgB 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wQAAdsB lgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8UAAHbAZYB @@ -280,8 +280,8 @@ AAEAAAD/////AQAAAAAAAAAMAgAAAEZTeXN0ZW0uV2luZG93cy5Gb3JtcywgQ3VsdHVyZT1uZXV0cmFs LCBQdWJsaWNLZXlUb2tlbj1iNzdhNWM1NjE5MzRlMDg5BQEAAAAmU3lzdGVtLldpbmRvd3MuRm9ybXMu - SW1hZ2VMaXN0U3RyZWFtZXIBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAA/gUAAAJNU0Z0AUkBTAMBAQAB - EAEAARABAAEQAQABEAEABP8BIQEACP8BQgFNATYHAAE2AwABKAMAAUADAAEQAwABAQEAASAGAAEQKgAB + SW1hZ2VMaXN0U3RyZWFtZXIBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAA9gUAAAJNU0Z0AUkBTAMBAQAB + QAEAAUABAAEQAQABEAEABP8BIQEACP8BQgFNATYHAAE2AwABKAMAAUADAAEQAwABAQEAASAGAAEQKgAB 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/5AAB2wGWARIB/wHbAZYBEgH/AdsB lgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf/YAAHbAZYB EgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B @@ -292,21 +292,21 @@ /wHbAZYBEgH/CAAB2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B 2wGWARIB/8QAAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/CAAB 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/8QAAdsB - lgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wwAAW0BYAFHAagB2wGWARIB/wHbAZYB - EgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf/EAAHbAZYBEgH/AdsBlgESAf8B2wGWARIB - /wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wMGAQgIAAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYB - EgH/AdsBlgESAf8B2wGWARIB/8QAAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB - /wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsB - lgESAf8B2wGWARIB/wHbAZYBEgH/xAADEgEYAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B - 2wGWARIB/wHbAZYBEgH/BAABggFtAUMBvwHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsB - lgESAf/MAAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wgAAdsB - lgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/9AAAdsBlgESAf8B2wGWARIB/wHbAZYB - EgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AUsBSAFBAXgB2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B - 2wGWARIB/9gAAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsB - lgESAf8B2wGWARIB/wHbAZYBEgH/4AADEgEYAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B - 2wGWARIB//8A1QABQgFNAT4HAAE+AwABKAMAAUADAAEQAwABAQEAAQEFAAGAFwAD/wEAAfwBHwYAAfAB - BwYAAeABAwYAAcEBgQYAAsEGAAGBAYAGAAGBAYAGAAGDAYAGAAGAAcAGAAGABwABgAGBBgABwAHBBgAB - 4AEDBgAB8AEHBgAB+AEfBgAC/wYACw== + lgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wwAAlQBUgGoAdsBlgESAf8B2wGWARIB + /wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/xAAB2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B + 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8DBgEICAAB2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB + /wHbAZYBEgH/AdsBlgESAf/EAAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B + 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYB + EgH/AdsBlgESAf8B2wGWARIB/8QAAxIBGAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsB + lgESAf8B2wGWARIB/wQAAVsCWgG/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB + /8wAAdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/CAAB2wGWARIB + /wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/0AAB2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B + 2wGWARIB/wHbAZYBEgH/AdsBlgESAf8DRAF4AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf/Y + AAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsB + lgESAf8B2wGWARIB/+AAAxIBGAHbAZYBEgH/AdsBlgESAf8B2wGWARIB/wHbAZYBEgH/AdsBlgESAf// + ANUAAUIBTQE+BwABPgMAASgDAAFAAwABEAMAAQEBAAEBBQABgBcAA/8BAAH8AR8GAAHwAQcGAAHgAQMG + AAHBAYEGAALBBgABgQGABgABgQGABgABgwGABgABgAHABgABgAcAAYABgQYAAcABwQYAAeABAwYAAfAB + BwYAAfgBHwYAAv8GAAs= diff --git a/FATrace.OEMApp/NLog.config b/FATrace.OEMApp/NLog.config index c239780..4adf612 100644 --- a/FATrace.OEMApp/NLog.config +++ b/FATrace.OEMApp/NLog.config @@ -1,16 +1,53 @@ - - - - + throwExceptions="false" + internalLogLevel="Off" internalLogFile="c:\temp\nlog-internal.log"> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FATrace.OEMApp/Services/DownloadTaskWorker.cs b/FATrace.OEMApp/Services/DownloadTaskWorker.cs index b55ff81..dbc40c3 100644 --- a/FATrace.OEMApp/Services/DownloadTaskWorker.cs +++ b/FATrace.OEMApp/Services/DownloadTaskWorker.cs @@ -146,8 +146,8 @@ namespace FATrace.OEMApp.Services User = user, Status = TaskStatus.Pending, Progress = 0, - NvrStartTime = start ?? now.AddSeconds(-VideoTime), - NvrEndTime = end ?? now, + NvrStartTime = now, + NvrEndTime = now.AddSeconds(VideoTime), CreateTime = now, UpdateTime = now }; diff --git a/FATrace.OEMApp/Services/SocketService.cs b/FATrace.OEMApp/Services/SocketService.cs new file mode 100644 index 0000000..a87c126 --- /dev/null +++ b/FATrace.OEMApp/Services/SocketService.cs @@ -0,0 +1,750 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NLog; + +namespace FATrace.OEMApp.Services +{ + /// + /// Socket 服务器 - 基于原生 TcpListener 实现的多客户端 TCP 服务器 + /// 功能:接收客户端数据、解析为字符串、触发事件通知外部、支持向客户端发送数据 + /// 协议:使用 CRLF (\r\n) 作为行分隔符的文本协议 + /// 编码:UTF-8 + /// + public class SocketService + { + #region 私有字段 + + /// + /// NLog 日志记录器 + /// + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// TCP 监听器 + /// + private TcpListener _listener; + + /// + /// 服务器是否正在运行 + /// + private bool _isRunning = false; + + /// + /// 取消令牌源,用于停止服务器 + /// + private CancellationTokenSource _cts; + + /// + /// 已连接的客户端字典(SessionId -> ClientSession) + /// 使用线程安全的 ConcurrentDictionary + /// + private readonly ConcurrentDictionary _clients = new ConcurrentDictionary(); + + /// + /// 会话 ID 计数器 + /// + private int _sessionIdCounter = 0; + + #endregion + + #region 公共属性 + + /// + /// 服务器监听 IP 地址 + /// 默认:127.0.0.1(本地回环) + /// 0.0.0.0 表示监听所有网卡 + /// + public string ListenIp { get; set; } = "127.0.0.1"; + + /// + /// 服务器监听端口 + /// 默认:6001 + /// + public int ListenPort { get; set; } = 6001; + + /// + /// 接收超时时间(秒) + /// 默认:30 秒 + /// 如果客户端在此时间内无数据交互,连接将被关闭 + /// + public int ReceiveTimeout { get; set; } = 30; + + /// + /// 发送超时时间(秒) + /// 默认:30 秒 + /// + public int SendTimeout { get; set; } = 30; + + /// + /// 服务器是否正在运行 + /// + public bool IsRunning => _isRunning; + + /// + /// 当前连接的客户端数量 + /// + public int ConnectedClientCount + { + get + { + return _clients.Count; + } + } + + #endregion + + #region 事件定义 + + /// + /// 数据接收事件参数 + /// + public class DataReceivedEventArgs : EventArgs + { + /// + /// 会话 ID(客户端唯一标识) + /// + public string SessionId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 接收到的数据(已解析为字符串) + /// + public string Data { get; set; } + + /// + /// 接收时间 + /// + public DateTime ReceivedTime { get; set; } + } + + /// + /// 客户端连接事件参数 + /// + public class ClientConnectedEventArgs : EventArgs + { + /// + /// 会话 ID + /// + public string SessionId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 连接时间 + /// + public DateTime ConnectedTime { get; set; } + } + + /// + /// 客户端断开事件参数 + /// + public class ClientDisconnectedEventArgs : EventArgs + { + /// + /// 会话 ID + /// + public string SessionId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 断开时间 + /// + public DateTime DisconnectedTime { get; set; } + + /// + /// 断开原因 + /// + public string Reason { get; set; } + } + + /// + /// 数据接收事件 + /// 当接收到客户端数据时触发,外部可订阅此事件进行业务处理 + /// + public event EventHandler DataReceived; + + /// + /// 客户端连接事件 + /// 当有新客户端连接时触发 + /// + public event EventHandler ClientConnected; + + /// + /// 客户端断开事件 + /// 当客户端断开连接时触发 + /// + public event EventHandler ClientDisconnected; + + /// + /// 服务器错误事件 + /// 当服务器发生错误时触发 + /// + public event EventHandler ServerError; + + #endregion + + #region 公共方法 + + /// + /// 启动 Socket 服务器 + /// + /// 启动是否成功 + public async Task StartAsync() + { + try + { + // 检查是否已经在运行 + if (_isRunning) + { + Logger.Warn("Socket 服务器已经在运行中,无需重复启动"); + return true; + } + + Logger.Info($"正在启动 Socket 服务器,监听地址:{ListenIp}:{ListenPort},超时时间:{ReceiveTimeout}秒"); + + // 解析 IP 地址 + IPAddress ipAddress; + if (!IPAddress.TryParse(ListenIp, out ipAddress)) + { + Logger.Error($"无效的 IP 地址:{ListenIp}"); + return false; + } + + // 创建 TCP 监听器 + _listener = new TcpListener(ipAddress, ListenPort); + _listener.Start(); + + // 创建取消令牌 + _cts = new CancellationTokenSource(); + + _isRunning = true; + Logger.Info($"Socket 服务器启动成功,监听地址:{ListenIp}:{ListenPort}"); + + // 启动接受客户端连接的任务 + _ = Task.Run(() => AcceptClientsAsync(_cts.Token), _cts.Token); + + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"启动 Socket 服务器时发生异常:{ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 停止 Socket 服务器 + /// + /// 停止任务 + public async Task StopAsync() + { + try + { + if (!_isRunning || _listener == null) + { + Logger.Warn("Socket 服务器未运行,无需停止"); + return; + } + + Logger.Info("正在停止 Socket 服务器..."); + + _isRunning = false; + + // 取消所有异步操作 + _cts?.Cancel(); + + // 停止监听器 + _listener?.Stop(); + + // 断开所有客户端 + foreach (var client in _clients.Values) + { + try + { + client.TcpClient?.Close(); + } + catch (Exception ex) + { + Logger.Warn(ex, $"关闭客户端连接时发生异常:SessionId={client.SessionId}"); + } + } + + // 清空客户端字典 + _clients.Clear(); + + // 释放资源 + _cts?.Dispose(); + _cts = null; + _listener = null; + + Logger.Info("Socket 服务器已停止"); + + await Task.CompletedTask; + } + catch (Exception ex) + { + Logger.Error(ex, $"停止 Socket 服务器时发生异常:{ex.Message}"); + OnServerError(ex); + } + } + + /// + /// 向指定客户端发送数据 + /// + /// 会话 ID(客户端唯一标识) + /// 要发送的字符串数据 + /// 发送是否成功 + public async Task SendToClientAsync(string sessionId, string data) + { + try + { + // 参数校验 + if (string.IsNullOrWhiteSpace(sessionId)) + { + Logger.Warn("发送数据失败:SessionId 为空"); + return false; + } + + if (string.IsNullOrEmpty(data)) + { + Logger.Warn($"发送数据失败:数据为空,SessionId={sessionId}"); + return false; + } + + // 查找会话 + if (!_clients.TryGetValue(sessionId, out var client)) + { + Logger.Warn($"发送数据失败:未找到会话,SessionId={sessionId}"); + return false; + } + + // 发送数据(自动添加 CRLF 行结束符) + var dataToSend = data.EndsWith("\r\n") ? data : data + "\r\n"; + var buffer = Encoding.UTF8.GetBytes(dataToSend); + + await client.Stream.WriteAsync(buffer, 0, buffer.Length); + await client.Stream.FlushAsync(); + + Logger.Debug($"向客户端发送数据成功,SessionId={sessionId},数据长度={buffer.Length}字节,内容={data}"); + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"向客户端发送数据时发生异常:SessionId={sessionId},错误={ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 向所有已连接的客户端广播数据 + /// + /// 要广播的字符串数据 + /// 成功发送的客户端数量 + public async Task BroadcastAsync(string data) + { + try + { + if (string.IsNullOrEmpty(data)) + { + Logger.Warn("广播数据失败:数据为空"); + return 0; + } + + // 获取所有客户端 + var clientList = _clients.Values.ToList(); + + if (clientList.Count == 0) + { + Logger.Debug("广播数据:当前无已连接的客户端"); + return 0; + } + + // 准备数据 + var dataToSend = data.EndsWith("\r\n") ? data : data + "\r\n"; + var buffer = Encoding.UTF8.GetBytes(dataToSend); + + // 向所有客户端发送 + int successCount = 0; + foreach (var client in clientList) + { + try + { + await client.Stream.WriteAsync(buffer, 0, buffer.Length); + await client.Stream.FlushAsync(); + successCount++; + } + catch (Exception ex) + { + Logger.Error(ex, $"向客户端广播数据失败:SessionId={client.SessionId},错误={ex.Message}"); + } + } + + Logger.Info($"广播数据完成,成功发送到 {successCount}/{clientList.Count} 个客户端,数据长度={buffer.Length}字节"); + return successCount; + } + catch (Exception ex) + { + Logger.Error(ex, $"广播数据时发生异常:{ex.Message}"); + OnServerError(ex); + return 0; + } + } + + /// + /// 断开指定客户端的连接 + /// + /// 会话 ID + /// 断开是否成功 + public async Task DisconnectClientAsync(string sessionId) + { + try + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + Logger.Warn("断开客户端失败:SessionId 为空"); + return false; + } + + if (!_clients.TryGetValue(sessionId, out var client)) + { + Logger.Warn($"断开客户端失败:未找到会话,SessionId={sessionId}"); + return false; + } + + client.TcpClient?.Close(); + _clients.TryRemove(sessionId, out _); + Logger.Info($"已主动断开客户端连接,SessionId={sessionId}"); + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"断开客户端连接时发生异常:SessionId={sessionId},错误={ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 获取所有已连接客户端的会话 ID 列表 + /// + /// 会话 ID 列表 + public List GetConnectedSessionIds() + { + return _clients.Keys.ToList(); + } + + #endregion + + #region 私有方法 - 客户端处理 + + /// + /// 接受客户端连接的循环 + /// + private async Task AcceptClientsAsync(CancellationToken cancellationToken) + { + Logger.Info("开始接受客户端连接..."); + + while (!cancellationToken.IsCancellationRequested && _isRunning) + { + try + { + // 等待客户端连接 + var tcpClient = await _listener.AcceptTcpClientAsync(); + + // 生成会话 ID + var sessionId = $"Session_{Interlocked.Increment(ref _sessionIdCounter)}_{DateTime.Now.Ticks}"; + + // 获取客户端信息 + var remoteEndPoint = tcpClient.Client.RemoteEndPoint as IPEndPoint; + var remoteIp = remoteEndPoint?.Address.ToString() ?? "Unknown"; + var remotePort = remoteEndPoint?.Port ?? 0; + + // 创建客户端会话 + var clientSession = new ClientSession + { + SessionId = sessionId, + TcpClient = tcpClient, + Stream = tcpClient.GetStream(), + RemoteIp = remoteIp, + RemotePort = remotePort, + ConnectedTime = DateTime.Now + }; + + // 添加到客户端字典 + if (_clients.TryAdd(sessionId, clientSession)) + { + Logger.Info($"客户端已连接,SessionId={sessionId},远程地址={remoteIp}:{remotePort},当前连接数={ConnectedClientCount}"); + + // 触发客户端连接事件 + OnClientConnected(new ClientConnectedEventArgs + { + SessionId = sessionId, + RemoteIp = remoteIp, + RemotePort = remotePort, + ConnectedTime = clientSession.ConnectedTime + }); + + // 启动处理客户端数据的任务 + _ = Task.Run(() => HandleClientAsync(clientSession, cancellationToken), cancellationToken); + } + else + { + Logger.Warn($"添加客户端会话失败,SessionId={sessionId}"); + tcpClient.Close(); + } + } + catch (ObjectDisposedException) + { + // 监听器已被释放,正常退出 + Logger.Info("监听器已关闭,停止接受新连接"); + break; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) + { + // 监听被中断,正常退出 + Logger.Info("监听被中断,停止接受新连接"); + break; + } + catch (Exception ex) + { + Logger.Error(ex, $"接受客户端连接时发生异常:{ex.Message}"); + OnServerError(ex); + await Task.Delay(1000, cancellationToken); // 等待一秒后重试 + } + } + + Logger.Info("停止接受客户端连接"); + } + + /// + /// 处理单个客户端的数据接收 + /// + private async Task HandleClientAsync(ClientSession client, CancellationToken cancellationToken) + { + var sessionId = client.SessionId; + var stream = client.Stream; + var reader = new StreamReader(stream, Encoding.UTF8); + + Logger.Debug($"开始处理客户端数据,SessionId={sessionId}"); + + try + { + // 设置读取超时 + stream.ReadTimeout = ReceiveTimeout * 1000; + + while (!cancellationToken.IsCancellationRequested && _isRunning) + { + try + { + // 读取一行数据(以 CRLF 或 LF 结尾) + var line = await reader.ReadLineAsync(); + + // 如果读取到 null,表示连接已关闭 + if (line == null) + { + Logger.Info($"客户端连接已关闭,SessionId={sessionId}"); + break; + } + + // 记录接收日志 + Logger.Debug($"接收到客户端数据,SessionId={sessionId},远程地址={client.RemoteIp}:{client.RemotePort},数据长度={line.Length}字符,内容={line}"); + + // 触发数据接收事件 + OnDataReceived(new DataReceivedEventArgs + { + SessionId = sessionId, + RemoteIp = client.RemoteIp, + RemotePort = client.RemotePort, + Data = line, + ReceivedTime = DateTime.Now + }); + } + catch (IOException ex) when (ex.InnerException is SocketException socketEx) + { + // 连接被重置或超时 + Logger.Warn($"客户端连接异常,SessionId={sessionId},错误={socketEx.SocketErrorCode}"); + break; + } + catch (IOException ex) + { + // 读取超时或其他 IO 异常 + Logger.Warn(ex, $"读取客户端数据时发生 IO 异常,SessionId={sessionId}"); + break; + } + } + } + catch (Exception ex) + { + Logger.Error(ex, $"处理客户端数据时发生异常,SessionId={sessionId},错误={ex.Message}"); + OnServerError(ex); + } + finally + { + // 清理客户端连接 + try + { + client.TcpClient?.Close(); + _clients.TryRemove(sessionId, out _); + + Logger.Info($"客户端已断开,SessionId={sessionId},远程地址={client.RemoteIp}:{client.RemotePort},当前连接数={ConnectedClientCount}"); + + // 触发客户端断开事件 + OnClientDisconnected(new ClientDisconnectedEventArgs + { + SessionId = sessionId, + RemoteIp = client.RemoteIp, + RemotePort = client.RemotePort, + DisconnectedTime = DateTime.Now, + Reason = "连接关闭" + }); + } + catch (Exception ex) + { + Logger.Warn(ex, $"清理客户端连接时发生异常,SessionId={sessionId}"); + } + } + } + + #endregion + + #region 私有方法 - 事件触发 + + /// + /// 触发数据接收事件 + /// + private void OnDataReceived(DataReceivedEventArgs e) + { + try + { + DataReceived?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 DataReceived 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发客户端连接事件 + /// + private void OnClientConnected(ClientConnectedEventArgs e) + { + try + { + ClientConnected?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 ClientConnected 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发客户端断开事件 + /// + private void OnClientDisconnected(ClientDisconnectedEventArgs e) + { + try + { + ClientDisconnected?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 ClientDisconnected 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发服务器错误事件 + /// + private void OnServerError(Exception ex) + { + try + { + ServerError?.Invoke(this, ex); + } + catch (Exception eventEx) + { + Logger.Error(eventEx, $"触发 ServerError 事件时发生异常:{eventEx.Message}"); + } + } + + #endregion + + #region 内部类 - 客户端会话 + + /// + /// 客户端会话信息 + /// + private class ClientSession + { + /// + /// 会话 ID + /// + public string SessionId { get; set; } + + /// + /// TCP 客户端 + /// + public TcpClient TcpClient { get; set; } + + /// + /// 网络流 + /// + public NetworkStream Stream { get; set; } + + /// + /// 远程 IP 地址 + /// + public string RemoteIp { get; set; } + + /// + /// 远程端口 + /// + public int RemotePort { get; set; } + + /// + /// 连接时间 + /// + public DateTime ConnectedTime { get; set; } + } + + #endregion + } +} diff --git a/FATrace.OEMApp/Services/SocketService_Backup.cs b/FATrace.OEMApp/Services/SocketService_Backup.cs new file mode 100644 index 0000000..2ead8be --- /dev/null +++ b/FATrace.OEMApp/Services/SocketService_Backup.cs @@ -0,0 +1 @@ +// 备份文件 - 保留原始 SuperSocket 实现尝试 diff --git a/FATrace.OEMApp/Services/SocketService_使用示例.md b/FATrace.OEMApp/Services/SocketService_使用示例.md new file mode 100644 index 0000000..cd4ea37 --- /dev/null +++ b/FATrace.OEMApp/Services/SocketService_使用示例.md @@ -0,0 +1,382 @@ +# SocketService 使用示例 + +## 概述 + +`SocketService` 是一个基于原生 TcpListener 实现的稳定可靠的 Socket 服务器,支持多客户端同时连接。 + +## 主要特性 + +- ✅ 基于原生 TcpListener,稳定可靠 +- ✅ 支持多客户端同时连接 +- ✅ 使用 CRLF (\r\n) 行分隔符的文本协议 +- ✅ UTF-8 编码 +- ✅ 详细的 NLog 日志记录 +- ✅ 事件驱动架构,方便外部处理业务逻辑 +- ✅ 支持单播和广播发送数据 + +## 配置参数 + +```csharp +// 创建服务实例 +var socketService = new SocketService(); + +// 配置参数(可选,有默认值) +socketService.ListenIp = "127.0.0.1"; // 监听 IP,默认 127.0.0.1 +socketService.ListenPort = 6001; // 监听端口,默认 6001 +socketService.ReceiveTimeout = 30; // 接收超时(秒),默认 30 +socketService.SendTimeout = 30; // 发送超时(秒),默认 30 +``` + +## 基本使用 + +### 1. 启动服务器 + +```csharp +// 启动服务器 +bool success = await socketService.StartAsync(); +if (success) +{ + Console.WriteLine("服务器启动成功"); +} +else +{ + Console.WriteLine("服务器启动失败"); +} +``` + +### 2. 订阅事件 + +#### 数据接收事件 + +```csharp +socketService.DataReceived += (sender, e) => +{ + // 处理接收到的数据 + Console.WriteLine($"收到数据:SessionId={e.SessionId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"数据内容:{e.Data}"); + Console.WriteLine($"接收时间:{e.ReceivedTime}"); + + // 在这里处理你的业务逻辑 + // 例如:解析数据、保存到数据库、触发其他操作等 +}; +``` + +#### 客户端连接事件 + +```csharp +socketService.ClientConnected += (sender, e) => +{ + Console.WriteLine($"客户端已连接:SessionId={e.SessionId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"连接时间:{e.ConnectedTime}"); + Console.WriteLine($"当前连接数:{socketService.ConnectedClientCount}"); +}; +``` + +#### 客户端断开事件 + +```csharp +socketService.ClientDisconnected += (sender, e) => +{ + Console.WriteLine($"客户端已断开:SessionId={e.SessionId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"断开原因:{e.Reason}"); + Console.WriteLine($"断开时间:{e.DisconnectedTime}"); + Console.WriteLine($"当前连接数:{socketService.ConnectedClientCount}"); +}; +``` + +#### 服务器错误事件 + +```csharp +socketService.ServerError += (sender, ex) => +{ + Console.WriteLine($"服务器发生错误:{ex.Message}"); + Console.WriteLine($"错误堆栈:{ex.StackTrace}"); +}; +``` + +### 3. 发送数据 + +#### 向指定客户端发送数据 + +```csharp +string sessionId = "Session_1_xxx"; // 从事件参数中获取的 SessionId +string data = "Hello Client!"; + +bool success = await socketService.SendToClientAsync(sessionId, data); +if (success) +{ + Console.WriteLine("数据发送成功"); +} +``` + +#### 向所有客户端广播数据 + +```csharp +string data = "Broadcast Message to All!"; + +int successCount = await socketService.BroadcastAsync(data); +Console.WriteLine($"广播成功发送到 {successCount} 个客户端"); +``` + +### 4. 断开指定客户端 + +```csharp +string sessionId = "Session_1_xxx"; + +bool success = await socketService.DisconnectClientAsync(sessionId); +if (success) +{ + Console.WriteLine("客户端已断开"); +} +``` + +### 5. 获取所有已连接客户端 + +```csharp +List sessionIds = socketService.GetConnectedSessionIds(); +Console.WriteLine($"当前连接的客户端数量:{sessionIds.Count}"); +foreach (var sessionId in sessionIds) +{ + Console.WriteLine($" - {sessionId}"); +} +``` + +### 6. 停止服务器 + +```csharp +await socketService.StopAsync(); +Console.WriteLine("服务器已停止"); +``` + +## 完整示例 + +```csharp +using FATrace.OEMApp.Services; +using System; +using System.Threading.Tasks; + +public class SocketServerExample +{ + private SocketService _socketService; + + public async Task RunAsync() + { + // 1. 创建服务实例 + _socketService = new SocketService + { + ListenIp = "0.0.0.0", // 监听所有网卡 + ListenPort = 6001, + ReceiveTimeout = 30, + SendTimeout = 30 + }; + + // 2. 订阅事件 + _socketService.DataReceived += OnDataReceived; + _socketService.ClientConnected += OnClientConnected; + _socketService.ClientDisconnected += OnClientDisconnected; + _socketService.ServerError += OnServerError; + + // 3. 启动服务器 + bool success = await _socketService.StartAsync(); + if (!success) + { + Console.WriteLine("服务器启动失败"); + return; + } + + Console.WriteLine("服务器已启动,按任意键停止..."); + Console.ReadKey(); + + // 4. 停止服务器 + await _socketService.StopAsync(); + } + + private void OnDataReceived(object sender, SocketService.DataReceivedEventArgs e) + { + Console.WriteLine($"[数据接收] SessionId={e.SessionId}, 数据={e.Data}"); + + // 处理业务逻辑 + // 例如:回复客户端 + _ = _socketService.SendToClientAsync(e.SessionId, $"收到:{e.Data}"); + } + + private void OnClientConnected(object sender, SocketService.ClientConnectedEventArgs e) + { + Console.WriteLine($"[客户端连接] SessionId={e.SessionId}, IP={e.RemoteIp}:{e.RemotePort}"); + + // 欢迎消息 + _ = _socketService.SendToClientAsync(e.SessionId, "欢迎连接到服务器!"); + } + + private void OnClientDisconnected(object sender, SocketService.ClientDisconnectedEventArgs e) + { + Console.WriteLine($"[客户端断开] SessionId={e.SessionId}, 原因={e.Reason}"); + } + + private void OnServerError(object sender, Exception ex) + { + Console.WriteLine($"[服务器错误] {ex.Message}"); + } +} +``` + +## 在 WinForms 中使用 + +```csharp +public partial class MainApp : Form +{ + private SocketService _socketService; + + private async void MainApp_Load(object sender, EventArgs e) + { + // 初始化 Socket 服务 + _socketService = new SocketService + { + ListenIp = "127.0.0.1", + ListenPort = 6001 + }; + + // 订阅事件(注意:需要使用 Invoke 更新 UI) + _socketService.DataReceived += (s, args) => + { + this.Invoke(new Action(() => + { + // 更新 UI + txtLog.AppendText($"收到数据:{args.Data}\r\n"); + })); + }; + + // 启动服务器 + bool success = await _socketService.StartAsync(); + if (success) + { + lblStatus.Text = "服务器运行中"; + lblStatus.ForeColor = Color.Green; + } + } + + private async void MainApp_FormClosing(object sender, FormClosingEventArgs e) + { + // 停止服务器 + if (_socketService != null && _socketService.IsRunning) + { + await _socketService.StopAsync(); + } + } + + private async void btnSendToAll_Click(object sender, EventArgs e) + { + // 广播消息 + string message = txtMessage.Text; + int count = await _socketService.BroadcastAsync(message); + MessageBox.Show($"已发送到 {count} 个客户端"); + } +} +``` + +## 协议说明 + +### 数据格式 + +- **编码**:UTF-8 +- **行分隔符**:CRLF (\r\n) +- **每次发送/接收**:一行文本数据 + +### 客户端示例(C#) + +```csharp +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; + +public class SimpleClient +{ + public static async Task Main() + { + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", 6001); + + var stream = client.GetStream(); + var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + var reader = new StreamReader(stream, Encoding.UTF8); + + // 发送数据 + await writer.WriteLineAsync("Hello Server!"); + + // 接收数据 + string response = await reader.ReadLineAsync(); + Console.WriteLine($"服务器回复:{response}"); + } +} +``` + +### 客户端示例(Python) + +```python +import socket + +# 连接服务器 +client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +client.connect(('127.0.0.1', 6001)) + +# 发送数据(注意:需要添加 \r\n) +message = "Hello Server!\r\n" +client.send(message.encode('utf-8')) + +# 接收数据 +response = client.recv(1024).decode('utf-8') +print(f"服务器回复:{response}") + +client.close() +``` + +## 日志说明 + +SocketService 使用 NLog 记录详细日志,包括: + +- **Info 级别**:服务器启动/停止、客户端连接/断开、广播完成 +- **Debug 级别**:数据接收/发送的详细内容 +- **Warn 级别**:参数错误、会话未找到、连接异常 +- **Error 级别**:异常堆栈信息 + +日志示例: +``` +2026-01-01 21:00:00.123 [INFO] 正在启动 Socket 服务器,监听地址:127.0.0.1:6001,超时时间:30秒 +2026-01-01 21:00:00.456 [INFO] Socket 服务器启动成功,监听地址:127.0.0.1:6001 +2026-01-01 21:00:05.789 [INFO] 客户端已连接,SessionId=Session_1_xxx,远程地址=127.0.0.1:54321,当前连接数=1 +2026-01-01 21:00:10.123 [DEBUG] 接收到客户端数据,SessionId=Session_1_xxx,远程地址=127.0.0.1:54321,数据长度=13字符,内容=Hello Server! +``` + +## 注意事项 + +1. **线程安全**:所有公共方法都是线程安全的,可以在多线程环境中使用 +2. **事件处理**:事件回调在后台线程执行,如需更新 UI,请使用 `Invoke` +3. **超时设置**:如果客户端长时间无数据交互,会自动断开连接 +4. **数据格式**:发送的数据会自动添加 CRLF,接收的数据会自动去除 CRLF +5. **异常处理**:所有异常都会被捕获并记录日志,不会导致服务器崩溃 + +## 常见问题 + +### Q1: 如何监听所有网卡? +```csharp +socketService.ListenIp = "0.0.0.0"; +``` + +### Q2: 如何获取客户端的 SessionId? +SessionId 会在事件参数中提供(`DataReceivedEventArgs.SessionId`),你可以保存它用于后续发送数据。 + +### Q3: 如何处理粘包问题? +当前实现使用行分隔符(CRLF),每次 `ReadLineAsync` 读取一行完整数据,自动处理粘包。 + +### Q4: 如何实现心跳检测? +可以在客户端定期发送心跳数据,服务器通过 `ReceiveTimeout` 自动断开无响应的客户端。 + +### Q5: 如何修改协议格式? +如需使用其他协议(如固定长度、带长度头等),需要修改 `HandleClientAsync` 方法中的数据读取逻辑。 + +## 性能建议 + +1. **连接数**:理论上支持数千个并发连接,实际取决于服务器性能 +2. **数据量**:适合中小数据量传输,大文件传输建议使用其他方案 +3. **日志级别**:生产环境建议将 Debug 日志关闭,减少性能开销 diff --git a/FATrace.OEMApp/Services/TimeClearDataService.cs b/FATrace.OEMApp/Services/TimeClearDataService.cs index e242395..6c23675 100644 --- a/FATrace.OEMApp/Services/TimeClearDataService.cs +++ b/FATrace.OEMApp/Services/TimeClearDataService.cs @@ -20,13 +20,13 @@ namespace FATrace.OEMApp.Services /// /// 数据库保存的信息天数 /// - private int DbRetentionDays = 180; + private int DbRetentionDays = 365; public event Action? Info; public void Start() { FileRetentionDays=ConfigHelper.GetIntOrDefault("VideoFileSaveDay", 365); - DbRetentionDays = ConfigHelper.GetIntOrDefault("DbSaveDay", 180); + DbRetentionDays = ConfigHelper.GetIntOrDefault("DbSaveDay", 365); if (_cts != null) return; try diff --git a/FATrace.OEMApp/Services/TouchSocketServer.cs b/FATrace.OEMApp/Services/TouchSocketServer.cs new file mode 100644 index 0000000..6eaf309 --- /dev/null +++ b/FATrace.OEMApp/Services/TouchSocketServer.cs @@ -0,0 +1,640 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NLog; +using TouchSocket.Core; +using TouchSocket.Sockets; + +namespace FATrace.OEMApp.Services +{ + /// + /// TouchSocket 服务器 - 基于 TouchSocket 实现的多客户端 TCP 服务器 + /// 功能:接收客户端数据、解析为字符串、触发事件通知外部、支持向客户端发送数据 + /// 协议:使用 CRLF (\r\n) 作为行分隔符的文本协议 + /// 编码:UTF-8 + /// + public class TouchSocketServer + { + /// + /// 构造函数 + /// + /// + /// + /// + /// + public TouchSocketServer(string listenIp, int listenPort, int receiveTimeout, int sendTimeout) + { + ListenIp = listenIp; + ListenPort = listenPort; + ReceiveTimeout = receiveTimeout; + SendTimeout = sendTimeout; + } + + + #region 私有字段 + + /// + /// NLog 日志记录器 + /// + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + /// + /// TouchSocket TCP 服务实例 + /// + private TcpService _service; + + /// + /// 服务器是否正在运行 + /// + private bool _isRunning = false; + + /// + /// 已连接的客户端字典(ClientId -> dynamic) + /// 用于快速查找和管理客户端 + /// 使用 dynamic 类型以兼容 TouchSocket 4.0 的客户端类型 + /// + private readonly ConcurrentDictionary _clients = new ConcurrentDictionary(); + + #endregion + + #region 公共属性 + + /// + /// 服务器监听 IP 地址 + /// 默认:127.0.0.1(本地回环) + /// 0.0.0.0 表示监听所有网卡 + /// + public string ListenIp { get; set; } = "127.0.0.1"; + + /// + /// 服务器监听端口 + /// 默认:6001 + /// + public int ListenPort { get; set; } = 6001; + + /// + /// 接收超时时间(毫秒) + /// 默认:30000 毫秒(30秒) + /// 如果客户端在此时间内无数据交互,连接将被关闭 + /// + public int ReceiveTimeout { get; set; } = 30000; + + /// + /// 发送超时时间(毫秒) + /// 默认:30000 毫秒(30秒) + /// + public int SendTimeout { get; set; } = 30000; + + /// + /// 缓冲区大小(字节) + /// 默认:1024 * 64(64KB) + /// + public int BufferLength { get; set; } = 1024 * 64; + + /// + /// 最大连接数 + /// 默认:10000 + /// + public int MaxCount { get; set; } = 10000; + + /// + /// 服务器是否正在运行 + /// + public bool IsRunning => _isRunning; + + /// + /// 当前连接的客户端数量 + /// + public int ConnectedClientCount => _clients.Count; + + #endregion + + #region 事件定义 + + /// + /// 数据接收事件参数 + /// + public class DataReceivedEventArgs : EventArgs + { + /// + /// 客户端 ID(唯一标识) + /// + public string? ClientId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string? RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 接收到的数据(已解析为字符串) + /// + public string? Data { get; set; } + + /// + /// 接收时间 + /// + public DateTime ReceivedTime { get; set; } + } + + /// + /// 客户端连接事件参数 + /// + public class ClientConnectedEventArgs : EventArgs + { + /// + /// 客户端 ID + /// + public string? ClientId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string? RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 连接时间 + /// + public DateTime ConnectedTime { get; set; } + } + + /// + /// 客户端断开事件参数 + /// + public class ClientDisconnectedEventArgs : EventArgs + { + /// + /// 客户端 ID + /// + public string? ClientId { get; set; } + + /// + /// 客户端 IP 地址 + /// + public string? RemoteIp { get; set; } + + /// + /// 客户端端口 + /// + public int RemotePort { get; set; } + + /// + /// 断开时间 + /// + public DateTime DisconnectedTime { get; set; } + + /// + /// 断开原因 + /// + public string? Reason { get; set; } + } + + /// + /// 数据接收事件 + /// 当接收到客户端数据时触发,外部可订阅此事件进行业务处理 + /// + public event EventHandler DataReceived; + + /// + /// 客户端连接事件 + /// 当有新客户端连接时触发 + /// + public event EventHandler ClientConnected; + + /// + /// 客户端断开事件 + /// 当客户端断开连接时触发 + /// + public event EventHandler ClientDisconnected; + + /// + /// 服务器错误事件 + /// 当服务器发生错误时触发 + /// + public event EventHandler ServerError; + + #endregion + + #region 公共方法 + + /// + /// 启动 Socket 服务器 + /// + /// 启动是否成功 + public async Task StartAsync() + { + try + { + // 检查是否已经在运行 + if (_isRunning) + { + Logger.Warn("Socket 服务器已经在运行中,无需重复启动"); + return true; + } + + Logger.Info($"正在启动 Socket 服务器,监听地址:{ListenIp}:{ListenPort},超时时间:{ReceiveTimeout}毫秒"); + + // 创建 TouchSocket 服务实例 + _service = new TcpService(); + + // 配置连接事件 + _service.Connecting = (client, e) => + { + Logger.Debug($"客户端正在连接:{client.Id},远程地址:{client.IP}:{client.Port}"); + return EasyTask.CompletedTask; + }; + + // 配置连接成功事件 + _service.Connected = (client, e) => + { + try + { + // 添加到客户端字典 + _clients.TryAdd(client.Id, client); + + // 记录连接日志 + Logger.Info($"客户端已连接,ClientId={client.Id},远程地址={client.IP}:{client.Port},当前连接数={ConnectedClientCount}"); + + // 触发客户端连接事件 + OnClientConnected(new ClientConnectedEventArgs + { + ClientId = client.Id, + RemoteIp = client.IP, + RemotePort = client.Port, + ConnectedTime = DateTime.Now + }); + } + catch (Exception ex) + { + Logger.Error(ex, $"处理客户端连接时发生异常:ClientId={client.Id},错误={ex.Message}"); + OnServerError(ex); + } + return EasyTask.CompletedTask; + }; + + // 配置断开连接事件 + _service.Closed = (client, e) => + { + try + { + // 从客户端字典中移除 + _clients.TryRemove(client.Id, out _); + + // 记录断开日志 + Logger.Info($"客户端已断开,ClientId={client.Id},远程地址={client.IP}:{client.Port},原因={e.Message},当前连接数={ConnectedClientCount}"); + + // 触发客户端断开事件 + OnClientDisconnected(new ClientDisconnectedEventArgs + { + ClientId = client.Id, + RemoteIp = client.IP, + RemotePort = client.Port, + DisconnectedTime = DateTime.Now, + Reason = e.Message + }); + } + catch (Exception ex) + { + Logger.Error(ex, $"处理客户端断开时发生异常:ClientId={client.Id},错误={ex.Message}"); + OnServerError(ex); + } + return EasyTask.CompletedTask; + }; + + // 配置数据接收事件 + _service.Received = (client, e) => + { + try + { + // 解析数据为字符串 + // TouchSocket 4.0 使用 e.Memory 获取数据 + var data = Encoding.UTF8.GetString(e.Memory.Span); + + // 记录接收日志 + Logger.Debug($"接收到客户端数据,ClientId={client.Id},远程地址={client.IP}:{client.Port},数据长度={data.Length}字符,内容={data}"); + + // 触发数据接收事件 + OnDataReceived(new DataReceivedEventArgs + { + ClientId = client.Id, + RemoteIp = client.IP, + RemotePort = client.Port, + Data = data, + ReceivedTime = DateTime.Now + }); + } + catch (Exception ex) + { + Logger.Error(ex, $"处理接收数据时发生异常:ClientId={client.Id},错误={ex.Message}"); + OnServerError(ex); + } + return EasyTask.CompletedTask; + }; + + // 配置服务器 + var config = new TouchSocketConfig(); + config.SetListenIPHosts($"tcp://{ListenIp}:{ListenPort}") + .SetMaxCount(MaxCount); // 设置最大连接数 + + // 应用配置并启动服务器 + await _service.SetupAsync(config); + await _service.StartAsync(); + + _isRunning = true; + Logger.Info($"Socket 服务器启动成功,监听地址:{ListenIp}:{ListenPort}"); + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"启动 Socket 服务器时发生异常:{ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 停止 Socket 服务器 + /// + public async Task StopAsync() + { + try + { + if (!_isRunning || _service == null) + { + Logger.Warn("Socket 服务器未运行,无需停止"); + return; + } + + Logger.Info("正在停止 Socket 服务器..."); + + _isRunning = false; + + // 停止服务器 + await _service?.StopAsync(); + _service?.Dispose(); + _service = null; + + // 清空客户端字典 + _clients.Clear(); + + Logger.Info("Socket 服务器已停止"); + } + catch (Exception ex) + { + Logger.Error(ex, $"停止 Socket 服务器时发生异常:{ex.Message}"); + OnServerError(ex); + } + } + + /// + /// 向指定客户端发送数据 + /// + /// 客户端 ID(唯一标识) + /// 要发送的字符串数据 + /// 发送是否成功 + public bool SendToClient(string clientId, string data) + { + try + { + // 参数校验 + if (string.IsNullOrWhiteSpace(clientId)) + { + Logger.Warn("发送数据失败:ClientId 为空"); + return false; + } + + if (string.IsNullOrEmpty(data)) + { + Logger.Warn($"发送数据失败:数据为空,ClientId={clientId}"); + return false; + } + + if (_service == null || !_isRunning) + { + Logger.Warn($"发送数据失败:服务器未运行或服务实例为空,ClientId={clientId}"); + return false; + } + + // 可选:先检查是否存在该客户端,便于输出更友好的日志 + //if (!_clients.ContainsKey(clientId)) + //{ + // Logger.Warn($"发送数据失败:未找到客户端,ClientId={clientId}"); + // return false; + //} + + // 发送数据(自动添加 CRLF 行结束符) + var dataToSend = data.EndsWith("\r\n") ? data : data + "\r\n"; + var buffer = Encoding.UTF8.GetBytes(dataToSend); + + // 使用 TcpService 的 SendAsync(id, buffer) 按会话 Id 发送 + _service.SendAsync(clientId, buffer).GetAwaiter().GetResult(); + + Logger.Debug($"向客户端发送数据成功,ClientId={clientId},数据长度={buffer.Length}字节,内容={data}"); + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"向客户端发送数据时发生异常:ClientId={clientId},错误={ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 向所有已连接的客户端广播数据 + /// + /// 要广播的字符串数据 + /// 成功发送的客户端数量 + public int Broadcast(string data) + { + try + { + if (string.IsNullOrEmpty(data)) + { + Logger.Warn("广播数据失败:数据为空"); + return 0; + } + + // 获取所有客户端 + var clientList = _clients.Values.ToList(); + + if (clientList.Count == 0) + { + Logger.Debug("广播数据:当前无已连接的客户端"); + return 0; + } + + // 准备数据 + var dataToSend = data.EndsWith("\r\n") ? data : data + "\r\n"; + var buffer = Encoding.UTF8.GetBytes(dataToSend); + + // 向所有客户端发送 + int successCount = 0; + if (_service == null || !_isRunning) + { + Logger.Warn("广播数据失败:服务器未运行或服务实例为空"); + return 0; + } + + foreach (var client in clientList) + { + try + { + // 使用 TcpService.SendAsync 通过客户端 Id 发送 + _service.SendAsync(client.Id, buffer).GetAwaiter().GetResult(); + successCount++; + } + catch (Exception ex) + { + Logger.Error(ex, $"向客户端广播数据失败:ClientId={client.Id},错误={ex.Message}"); + } + } + + Logger.Info($"广播数据完成,成功发送到 {successCount}/{clientList.Count} 个客户端,数据长度={buffer.Length}字节"); + return successCount; + } + catch (Exception ex) + { + Logger.Error(ex, $"广播数据时发生异常:{ex.Message}"); + OnServerError(ex); + return 0; + } + } + + /// + /// 断开指定客户端的连接 + /// + /// 客户端 ID + /// 断开是否成功 + public bool DisconnectClient(string clientId) + { + try + { + if (string.IsNullOrWhiteSpace(clientId)) + { + Logger.Warn("断开客户端失败:ClientId 为空"); + return false; + } + + if (!_clients.TryGetValue(clientId, out var client)) + { + Logger.Warn($"断开客户端失败:未找到客户端,ClientId={clientId}"); + return false; + } + + client.Close("服务器主动断开"); + Logger.Info($"已主动断开客户端连接,ClientId={clientId}"); + return true; + } + catch (Exception ex) + { + Logger.Error(ex, $"断开客户端连接时发生异常:ClientId={clientId},错误={ex.Message}"); + OnServerError(ex); + return false; + } + } + + /// + /// 获取所有已连接客户端的 ID 列表 + /// + /// 客户端 ID 列表 + public List GetConnectedClientIds() + { + return _clients.Keys.ToList(); + } + + /// + /// 获取指定客户端的详细信息 + /// + /// 客户端 ID + /// 客户端信息字符串,如果未找到返回 null + public string GetClientInfo(string clientId) + { + if (_clients.TryGetValue(clientId, out var client)) + { + return $"ClientId={client.Id}, IP={client.IP}:{client.Port}, 在线={client.Online}"; + } + return null; + } + + #endregion + + + #region 私有方法 - 事件触发 + + /// + /// 触发数据接收事件 + /// + private void OnDataReceived(DataReceivedEventArgs e) + { + try + { + DataReceived?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 DataReceived 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发客户端连接事件 + /// + private void OnClientConnected(ClientConnectedEventArgs e) + { + try + { + ClientConnected?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 ClientConnected 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发客户端断开事件 + /// + private void OnClientDisconnected(ClientDisconnectedEventArgs e) + { + try + { + ClientDisconnected?.Invoke(this, e); + } + catch (Exception ex) + { + Logger.Error(ex, $"触发 ClientDisconnected 事件时发生异常:{ex.Message}"); + } + } + + /// + /// 触发服务器错误事件 + /// + private void OnServerError(Exception ex) + { + try + { + ServerError?.Invoke(this, ex); + } + catch (Exception eventEx) + { + Logger.Error(eventEx, $"触发 ServerError 事件时发生异常:{eventEx.Message}"); + } + } + + #endregion + } +} diff --git a/FATrace.OEMApp/Services/TouchSocketServer_使用示例.md b/FATrace.OEMApp/Services/TouchSocketServer_使用示例.md new file mode 100644 index 0000000..7328232 --- /dev/null +++ b/FATrace.OEMApp/Services/TouchSocketServer_使用示例.md @@ -0,0 +1,410 @@ +# TouchSocketServer 使用示例 + +## 概述 + +`TouchSocketServer` 是一个基于 TouchSocket 4.0 实现的稳定可靠的 Socket 服务器,支持多客户端同时连接。 + +## 主要特性 + +- ✅ 基于 TouchSocket 4.0,性能优异 +- ✅ 支持多客户端同时连接 +- ✅ UTF-8 编码 +- ✅ 详细的 NLog 日志记录 +- ✅ 事件驱动架构,方便外部处理业务逻辑 +- ✅ 支持单播和广播发送数据 +- ✅ 异步操作,性能优秀 + +## 配置参数 + +```csharp +// 创建服务实例 +var socketServer = new TouchSocketServer(); + +// 配置参数(可选,有默认值) +socketServer.ListenIp = "127.0.0.1"; // 监听 IP,默认 127.0.0.1 +socketServer.ListenPort = 6001; // 监听端口,默认 6001 +socketServer.ReceiveTimeout = 30000; // 接收超时(毫秒),默认 30000 +socketServer.SendTimeout = 30000; // 发送超时(毫秒),默认 30000 +socketServer.BufferLength = 1024 * 64; // 缓冲区大小,默认 64KB +socketServer.MaxCount = 10000; // 最大连接数,默认 10000 +``` + +## 基本使用 + +### 1. 启动服务器 + +```csharp +// 启动服务器(异步) +bool success = await socketServer.StartAsync(); +if (success) +{ + Console.WriteLine("服务器启动成功"); +} +else +{ + Console.WriteLine("服务器启动失败"); +} +``` + +### 2. 订阅事件 + +#### 数据接收事件 + +```csharp +socketServer.DataReceived += (sender, e) => +{ + // 处理接收到的数据 + Console.WriteLine($"收到数据:ClientId={e.ClientId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"数据内容:{e.Data}"); + Console.WriteLine($"接收时间:{e.ReceivedTime}"); + + // 在这里处理你的业务逻辑 + // 例如:解析数据、保存到数据库、触发其他操作等 +}; +``` + +#### 客户端连接事件 + +```csharp +socketServer.ClientConnected += (sender, e) => +{ + Console.WriteLine($"客户端已连接:ClientId={e.ClientId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"连接时间:{e.ConnectedTime}"); + Console.WriteLine($"当前连接数:{socketServer.ConnectedClientCount}"); +}; +``` + +#### 客户端断开事件 + +```csharp +socketServer.ClientDisconnected += (sender, e) => +{ + Console.WriteLine($"客户端已断开:ClientId={e.ClientId}, IP={e.RemoteIp}:{e.RemotePort}"); + Console.WriteLine($"断开原因:{e.Reason}"); + Console.WriteLine($"断开时间:{e.DisconnectedTime}"); + Console.WriteLine($"当前连接数:{socketServer.ConnectedClientCount}"); +}; +``` + +#### 服务器错误事件 + +```csharp +socketServer.ServerError += (sender, ex) => +{ + Console.WriteLine($"服务器发生错误:{ex.Message}"); + Console.WriteLine($"错误堆栈:{ex.StackTrace}"); +}; +``` + +### 3. 发送数据 + +#### 向指定客户端发送数据 + +```csharp +string clientId = "Session_1_xxx"; // 从事件参数中获取的 ClientId +string data = "Hello Client!"; + +bool success = socketServer.SendToClient(clientId, data); +if (success) +{ + Console.WriteLine("数据发送成功"); +} +``` + +#### 向所有客户端广播数据 + +```csharp +string data = "Broadcast Message to All!"; + +int successCount = socketServer.Broadcast(data); +Console.WriteLine($"广播成功发送到 {successCount} 个客户端"); +``` + +### 4. 断开指定客户端 + +```csharp +string clientId = "Session_1_xxx"; + +bool success = socketServer.DisconnectClient(clientId); +if (success) +{ + Console.WriteLine("客户端已断开"); +} +``` + +### 5. 获取所有已连接客户端 + +```csharp +List clientIds = socketServer.GetConnectedClientIds(); +Console.WriteLine($"当前连接的客户端数量:{clientIds.Count}"); +foreach (var clientId in clientIds) +{ + Console.WriteLine($" - {clientId}"); +} +``` + +### 6. 获取客户端信息 + +```csharp +string clientId = "Session_1_xxx"; +string info = socketServer.GetClientInfo(clientId); +Console.WriteLine($"客户端信息:{info}"); +``` + +### 7. 停止服务器 + +```csharp +await socketServer.StopAsync(); +Console.WriteLine("服务器已停止"); +``` + +## 完整示例 + +```csharp +using FATrace.OEMApp.Services; +using System; +using System.Threading.Tasks; + +public class TouchSocketServerExample +{ + private TouchSocketServer _socketServer; + + public async Task RunAsync() + { + // 1. 创建服务实例 + _socketServer = new TouchSocketServer + { + ListenIp = "0.0.0.0", // 监听所有网卡 + ListenPort = 6001, + ReceiveTimeout = 30000, + SendTimeout = 30000 + }; + + // 2. 订阅事件 + _socketServer.DataReceived += OnDataReceived; + _socketServer.ClientConnected += OnClientConnected; + _socketServer.ClientDisconnected += OnClientDisconnected; + _socketServer.ServerError += OnServerError; + + // 3. 启动服务器 + bool success = await _socketServer.StartAsync(); + if (!success) + { + Console.WriteLine("服务器启动失败"); + return; + } + + Console.WriteLine("服务器已启动,按任意键停止..."); + Console.ReadKey(); + + // 4. 停止服务器 + await _socketServer.StopAsync(); + } + + private void OnDataReceived(object sender, TouchSocketServer.DataReceivedEventArgs e) + { + Console.WriteLine($"[数据接收] ClientId={e.ClientId}, 数据={e.Data}"); + + // 处理业务逻辑 + // 例如:回复客户端 + _socketServer.SendToClient(e.ClientId, $"收到:{e.Data}"); + } + + private void OnClientConnected(object sender, TouchSocketServer.ClientConnectedEventArgs e) + { + Console.WriteLine($"[客户端连接] ClientId={e.ClientId}, IP={e.RemoteIp}:{e.RemotePort}"); + + // 欢迎消息 + _socketServer.SendToClient(e.ClientId, "欢迎连接到服务器!"); + } + + private void OnClientDisconnected(object sender, TouchSocketServer.ClientDisconnectedEventArgs e) + { + Console.WriteLine($"[客户端断开] ClientId={e.ClientId}, 原因={e.Reason}"); + } + + private void OnServerError(object sender, Exception ex) + { + Console.WriteLine($"[服务器错误] {ex.Message}"); + } +} +``` + +## 在 WinForms 中使用 + +```csharp +public partial class MainApp : Form +{ + private TouchSocketServer _socketServer; + + private async void MainApp_Load(object sender, EventArgs e) + { + // 初始化 Socket 服务 + _socketServer = new TouchSocketServer + { + ListenIp = "127.0.0.1", + ListenPort = 6001 + }; + + // 订阅事件(注意:需要使用 Invoke 更新 UI) + _socketServer.DataReceived += (s, args) => + { + this.Invoke(new Action(() => + { + // 更新 UI + txtLog.AppendText($"收到数据:{args.Data}\r\n"); + })); + }; + + // 启动服务器 + bool success = await _socketServer.StartAsync(); + if (success) + { + lblStatus.Text = "服务器运行中"; + lblStatus.ForeColor = Color.Green; + } + } + + private async void MainApp_FormClosing(object sender, FormClosingEventArgs e) + { + // 停止服务器 + if (_socketServer != null && _socketServer.IsRunning) + { + await _socketServer.StopAsync(); + } + } + + private void btnSendToAll_Click(object sender, EventArgs e) + { + // 广播消息 + string message = txtMessage.Text; + int count = _socketServer.Broadcast(message); + MessageBox.Show($"已发送到 {count} 个客户端"); + } +} +``` + +## 协议说明 + +### 数据格式 + +- **编码**:UTF-8 +- **数据传输**:原始字节流(TouchSocket 会自动处理) +- **每次接收**:通过事件参数获取完整数据 + +### 客户端示例(C#) + +```csharp +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; + +public class SimpleClient +{ + public static async Task Main() + { + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", 6001); + + var stream = client.GetStream(); + var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + var reader = new StreamReader(stream, Encoding.UTF8); + + // 发送数据 + await writer.WriteAsync("Hello Server!"); + await writer.FlushAsync(); + + // 接收数据 + char[] buffer = new char[1024]; + int count = await reader.ReadAsync(buffer, 0, buffer.Length); + string response = new string(buffer, 0, count); + Console.WriteLine($"服务器回复:{response}"); + } +} +``` + +### 客户端示例(Python) + +```python +import socket + +# 连接服务器 +client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +client.connect(('127.0.0.1', 6001)) + +# 发送数据 +message = "Hello Server!" +client.send(message.encode('utf-8')) + +# 接收数据 +response = client.recv(1024).decode('utf-8') +print(f"服务器回复:{response}") + +client.close() +``` + +## 日志说明 + +TouchSocketServer 使用 NLog 记录详细日志,包括: + +- **Info 级别**:服务器启动/停止、客户端连接/断开、广播完成 +- **Debug 级别**:数据接收/发送的详细内容、客户端正在连接 +- **Warn 级别**:参数错误、客户端未找到、服务器未运行 +- **Error 级别**:异常堆栈信息 + +日志示例: +``` +2026-01-01 22:00:00.123 [INFO] 正在启动 Socket 服务器,监听地址:127.0.0.1:6001,超时时间:30000毫秒 +2026-01-01 22:00:00.456 [INFO] Socket 服务器启动成功,监听地址:127.0.0.1:6001 +2026-01-01 22:00:05.789 [INFO] 客户端已连接,ClientId=xxx,远程地址=127.0.0.1:54321,当前连接数=1 +2026-01-01 22:00:10.123 [DEBUG] 接收到客户端数据,ClientId=xxx,远程地址=127.0.0.1:54321,数据长度=13字符,内容=Hello Server! +``` + +## 注意事项 + +1. **线程安全**:所有公共方法都是线程安全的,可以在多线程环境中使用 +2. **事件处理**:事件回调在后台线程执行,如需更新 UI,请使用 `Invoke` +3. **超时设置**:如果客户端长时间无数据交互,会自动断开连接 +4. **异步方法**:StartAsync 和 StopAsync 是异步方法,需要使用 await +5. **异常处理**:所有异常都会被捕获并记录日志,不会导致服务器崩溃 +6. **动态类型**:内部使用 dynamic 类型管理客户端,兼容 TouchSocket 4.0 + +## 常见问题 + +### Q1: 如何监听所有网卡? +```csharp +socketServer.ListenIp = "0.0.0.0"; +``` + +### Q2: 如何获取客户端的 ClientId? +ClientId 会在事件参数中提供(`DataReceivedEventArgs.ClientId`),你可以保存它用于后续发送数据。 + +### Q3: 如何实现心跳检测? +可以在客户端定期发送心跳数据,服务器通过 `ReceiveTimeout` 自动断开无响应的客户端。 + +### Q4: 为什么编译有警告? +这些是 C# 8.0 的可空引用类型警告,不影响功能,可以忽略。 + +### Q5: TouchSocket 4.0 与文档不一致? +是的,TouchSocket 4.0.5 的 API 与官方文档有所不同,本实现已经适配了实际的 API。 + +## 性能建议 + +1. **连接数**:默认支持 10000 个并发连接,可根据需要调整 `MaxCount` +2. **缓冲区**:默认 64KB 缓冲区,适合大多数场景 +3. **超时时间**:根据实际网络环境调整超时时间 +4. **日志级别**:生产环境建议将 Debug 日志关闭,减少性能开销 + +## 与 SocketService 对比 + +| 特性 | TouchSocketServer | SocketService (原生) | +|------|-------------------|---------------------| +| 基础框架 | TouchSocket 4.0 | 原生 TcpListener | +| 性能 | ⭐⭐⭐⭐⭐ 更优秀 | ⭐⭐⭐⭐ 优秀 | +| 稳定性 | ⭐⭐⭐⭐⭐ 非常稳定 | ⭐⭐⭐⭐⭐ 非常稳定 | +| 依赖 | TouchSocket 包 | 仅 .NET 原生库 | +| 复杂度 | 简单 | 中等 | +| 推荐度 | ✅ **强烈推荐** | ✅ 推荐 | + +两个实现都非常优秀,TouchSocketServer 基于成熟的 TouchSocket 框架,性能更优;SocketService 基于原生库,无额外依赖。根据你的需求选择即可! diff --git a/FATrace.OEMApp/升级SuperSocket说明.md b/FATrace.OEMApp/升级SuperSocket说明.md new file mode 100644 index 0000000..eb12f66 --- /dev/null +++ b/FATrace.OEMApp/升级SuperSocket说明.md @@ -0,0 +1,72 @@ +# 升级 SuperSocket 说明 + +## 当前问题 + +SuperSocket 2.0.2 版本太旧,缺少关键 API: +- ❌ 没有 `SuperSocketHostBuilder` +- ❌ 没有 `SuperSocket.Channel` 命名空间 +- ❌ 与官方文档 API 不匹配 + +## 升级步骤 + +### 1. 卸载旧版本 + +```bash +cd E:\MyTest\VS2022\FoodAdditiveTrace\FATrace\FATrace.OEMApp +dotnet remove package SuperSocket +dotnet remove package SuperSocket.Server +``` + +### 2. 安装新版本 + +```bash +# 安装最新的稳定版本 +dotnet add package SuperSocket --version 2.0.0-beta.17 +dotnet add package SuperSocket.Server --version 2.0.0-beta.17 +``` + +或者在 .csproj 文件中修改: + +```xml + + +``` + +### 3. 重新编译 + +```bash +dotnet build +``` + +## 风险提示 + +⚠️ **升级可能影响其他代码** + +如果项目中其他地方使用了 SuperSocket(例如 FATrace.App 中的客户端),升级可能导致: +- 其他代码编译失败 +- API 不兼容需要修改 +- 需要全面测试 + +## 推荐方案 + +**我强烈建议使用 `SocketService.cs`(原生 TcpListener 实现)** + +理由: +1. ✅ 已编译通过,立即可用 +2. ✅ 无需升级任何包 +3. ✅ 不影响其他代码 +4. ✅ 功能完整,性能优秀 +5. ✅ 基于 .NET 原生库,更稳定 + +## 对比 + +| 特性 | SocketService.cs | SocketServiceV2.cs (需升级) | +|------|------------------|----------------------------| +| 编译状态 | ✅ 成功 | ❌ 需要升级包 | +| 风险 | 无 | 可能影响其他代码 | +| 稳定性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| 推荐度 | ✅ 强烈推荐 | ⚠️ 不推荐 | + +## 结论 + +除非有特殊原因必须使用 SuperSocket 框架,否则请使用 `SocketService.cs`。 diff --git a/FATrace.WPLApp/App.config b/FATrace.WPLApp/App.config index 79bdc4f..35d31d1 100644 --- a/FATrace.WPLApp/App.config +++ b/FATrace.WPLApp/App.config @@ -11,5 +11,9 @@ + + + + \ No newline at end of file diff --git a/FATrace.WPLApp/App.xaml.cs b/FATrace.WPLApp/App.xaml.cs index e961975..c3a325d 100644 --- a/FATrace.WPLApp/App.xaml.cs +++ b/FATrace.WPLApp/App.xaml.cs @@ -117,6 +117,7 @@ namespace FATrace.WPLApp var sysRun = Container.Resolve(); var DataServices = Container.Resolve(); var CsvServices = Container.Resolve(); + var readFileServices = Container.Resolve(); LogService.Info("Background services initialized"); } catch (Exception ex) @@ -202,6 +203,7 @@ namespace FATrace.WPLApp containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); @@ -255,12 +257,30 @@ namespace FATrace.WPLApp containerRegistry.RegisterForNavigation(); //containerRegistry.RegisterForNavigation(); //containerRegistry.RegisterForNavigation(); - //containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); containerRegistry.RegisterForNavigation(); // 原料使用查询页 containerRegistry.RegisterForNavigation(); // 原料入库查询页 containerRegistry.RegisterForNavigation(); + + // 文件导入日志查询页 + containerRegistry.RegisterForNavigation(); + + // 用户管理 + containerRegistry.RegisterForNavigation(); + + // 工厂/OEM Excel 导入数据查询页 + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + // 用户登录 containerRegistry.RegisterForNavigation("LoginView"); containerRegistry.RegisterForNavigation(); @@ -280,6 +300,7 @@ namespace FATrace.WPLApp //注册Dialog视图时绑定VM + containerRegistry.RegisterDialog("DialogUserEditView"); //containerRegistry.RegisterDialog(); //containerRegistry.RegisterDialog(); //containerRegistry.RegisterDialog(); @@ -302,6 +323,9 @@ namespace FATrace.WPLApp //containerRegistry.RegisterDialog(); + // 使用 Syncfusion ChromelessWindow 统一 Prism 弹窗样式 + containerRegistry.RegisterDialogWindow(); + } /// diff --git a/FATrace.WPLApp/ExcelFile/20251218161818.xlsx b/FATrace.WPLApp/ExcelFile/20251218161818.xlsx new file mode 100644 index 0000000..f16383a Binary files /dev/null and b/FATrace.WPLApp/ExcelFile/20251218161818.xlsx differ diff --git a/FATrace.WPLApp/FATrace.WPLApp.csproj b/FATrace.WPLApp/FATrace.WPLApp.csproj index 393e1c5..9002cf5 100644 --- a/FATrace.WPLApp/FATrace.WPLApp.csproj +++ b/FATrace.WPLApp/FATrace.WPLApp.csproj @@ -56,6 +56,12 @@ + + + PreserveNewest + + + diff --git a/FATrace.WPLApp/Report/UserManual.md b/FATrace.WPLApp/Report/UserManual.md index 7763d7e..4dbf3ba 100644 --- a/FATrace.WPLApp/Report/UserManual.md +++ b/FATrace.WPLApp/Report/UserManual.md @@ -2,12 +2,19 @@ 版本:v1.0 适用模块:FATrace.WPLApp(桌面应用) +![image-20260113113929437](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113113929437.png) + ## 1. 简介 FATrace 是一套用于食品添加剂生产过程追溯与运行监控的桌面应用。WPLApp 负责生产端的日常使用,包括: - 实时运行信息查看与统计总览(Dashboard) - 历史报警查询(History Alarm) +- 原料使用查询(支持导出 Excel) +- 原料入库查询(支持导出 Excel) +- Excel 报表文件自动导入(工厂/OEM 多 Sheet 导入) +- 工厂/OEM 数据查询(按 Sheet 查询展示) +- 文件导入日志(查询每次导入、归档、行数统计与异常信息) - 用户登录与状态显示 - PLC 通信状态指示与当前终端信息(底部状态栏) @@ -16,32 +23,55 @@ FATrace 是一套用于食品添加剂生产过程追溯与运行监控的桌面 ## 2. 安装与启动 - 安装:由管理员完成程序分发与安装(通常包含可执行文件与依赖项)。 + - 运行:双击启动桌面应用,进入主界面。 + + ![image-20260113113755346](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113113755346.png) + - 字体资源:应用内置图标字体,界面会自动加载,无需额外配置。 -- 数据库:应用使用 FreeSql 进行数据访问。数据库连接等参数由管理员预配置。 + +- 数据库:数据库连接等参数由管理员预配置。 如启动失败、页面空白或频繁报错,请联系系统管理员检查运行环境与数据库连接配置。 ## 3. 登录与账号 - 打开左侧菜单“用户登录”进入登录页。 + + ![image-20260113114128809](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114128809.png) + - 输入“用户名”和“密码”,点击“登录”或直接按回车键。 + - 登录成功后自动导航到 Dashboard;底部状态栏将显示“当前用户:已登录”。 -注意:当前版本按用户表中明文密码进行校验,请妥善保管账号信息。若忘记密码,请联系管理员重置。 +注意:请妥善保管账号信息。若忘记密码,请联系管理员重置。 ## 4. 主界面与导航 应用主界面由顶部、内容区域、底部状态栏组成。常用页面: - Dashboard(统计与实时信息) -- 历史报警(History Alarm) -- 原料相关查询(如原料使用、原料入库等,若已在系统中启用) +- 数据管理 + - 原料使用查询 + - 原料入库查询 + - 文件导入日志 + - 历史报警 +- 系统 + - 日志信息 + - 用户登录 + - 使用手册 +- 工厂/OEM 数据查询 + - 工厂:工厂-入库、工厂-领料、工厂-出入库、工厂-原料生产信息、工厂-成品出库 + - OEM:OEM-入库、OEM-出库、OEM-出入库、OEM-原料使用信息 通过左侧菜单切换页面。页面内容区支持自动刷新与数据联动,无需手动刷新按钮。 +说明:当前版本左侧菜单中的“使用手册”入口暂未在程序内直接打开。请在安装目录中查看本文件:`Report/UserManual.md`。 + ## 5. Dashboard(统计与实时信息) +![image-20260113114224114](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114224114.png) + Dashboard 提供生产运行的概览信息,包括: - 统计卡片:按“日、月、年、累计”汇总原料使用重量(RawProUse)。 @@ -58,6 +88,8 @@ Dashboard 提供生产运行的概览信息,包括: ## 6. 历史报警(History Alarm) +![image-20260113114242263](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114242263.png) + 用于查询系统运行过程中记录的历史报警。功能特性: - 支持按关键字、类别、时间范围进行过滤查询。 @@ -77,6 +109,8 @@ Dashboard 提供生产运行的概览信息,包括: ## 7. 底部状态栏(FootView) +![image-20260113114257158](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114257158.png) + 底部状态栏持续显示系统运行关键信息: - PLC 通信指示灯:绿色表示连接正常;红色表示断开或异常。 @@ -85,13 +119,159 @@ Dashboard 提供生产运行的概览信息,包括: 当 PLC 状态改变时,这里会同步更新,便于第一时间发现通信问题。 -## 8. 常见操作流程 +## 8. 原料使用查询 + +![image-20260113114313626](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114313626.png) + +入口:左侧菜单 → 数据管理 → 原料使用查询。 + +功能说明: + +- 支持按时间范围(称重时间 `WeightTime`)、原料编号/名称、批号、内袋二维码、外箱二维码、操作者、确认者进行查询。 +- 支持分页浏览。 +- 支持导出当前查询结果为 Excel(.xlsx)。 + +操作步骤: + +1. 打开“原料使用查询”页面。 +2. 设置查询条件(可只填部分条件)。 +3. 点击“查询”,在下方列表查看数据。 +4. 如需导出:点击“导出Excel”,选择保存路径。 + +说明: + +- 时间范围中“结束”若只选择日期(无时分秒),系统会自动按当天 23:59:59.999... 作为结束时间,避免漏数据。 + +## 9. 原料入库查询 + +![image-20260113114329697](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114329697.png) + +入口:左侧菜单 → 数据管理 → 原料入库查询。 + +功能说明: + +- 支持按时间范围(创建时间 `CreateTime`)、原料编号/名称、批号进行查询。 +- 支持按枚举筛选:原料来源(`RawSource`)、分拆状态(`RawSplitState`)。 +- 支持分页浏览。 +- 支持导出当前查询结果为 Excel(.xlsx)。 + +操作步骤: + +1. 打开“原料入库查询”页面。 +2. 选择“原料来源/分拆状态”(可不选)。 +3. 设置时间范围,点击“查询”。 +4. 如需导出:点击“导出Excel”,选择保存路径。 + +## 10. Excel 报表文件自动导入(工厂/OEM) + +![image-20260113114357676](C:\Users\chong\AppData\Roaming\Typora\typora-user-images\image-20260113114357676.png) + +本功能用于读取QR系统FTP每天生成的 Excel 文件(多 Sheet),并将各 Sheet 数据分别存入数据库表,同时将文件移动到归档目录,便于对账与追溯。 + +### 10.1 文件放置与命名规则 + +- **文件类型**:`.xlsx` +- **文件名**:建议按 `yyyyMMddHHmmss.xlsx`(文件名表示导出时间) +- **放置目录**:由配置项 `ExcelImportSourceDir` 指定 + +系统会在后台定时扫描源目录中的 `*.xlsx` 文件并执行导入(当前版本扫描周期约 1 小时,且程序启动后会立即执行一次检查)。 + +### 10.2 配置项说明(App.config) + +配置文件位置:`FATrace.WPLApp/App.config`(部署后为程序同目录的配置文件)。 + +- `ExcelImportSourceDir` + - Excel 文件来源目录。 + - 支持绝对路径与相对路径(相对路径将基于程序根目录解析)。 +- `ExcelImportArchiveDir` + - Excel 文件归档目录。 + - 导入完成后,程序会将源文件移动到该目录。 + +### 10.3 Sheet 与数据表映射 + +Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下列规则导入: + +- 工厂-入库 → `FactoryInbound` +- 工厂-领料 → `FactoryMaterialWithdrawal` +- 工厂-出入库 → `FactoryInventoryTransaction` +- 工厂-原料生产信息 → `FactoryProductionRecord` +- 工厂-成品出库 → `FactoryOutbound` +- OEM-入库 → `OEMInbound` +- OEM-出库 → `OEMOutbound` +- OEM-出入库 → `OEMInventoryTransaction` +- OEM-原料使用信息 → `OEMRawUsageInfo` + +注意: + +- 导入时从每个 Sheet 的第 2 行开始读取(第 1 行为表头)。 +- 如果整行为空会被跳过。 + +### 10.4 重复导入与归档规则 + +- **防重复**:当 `FileImportLog` 表中已存在相同文件名且状态为 `Success` 的记录时,该文件会被跳过,不会重复导入。 +- **归档**:导入后移动到归档目录。若归档目录已存在同名文件,系统会自动在文件名后追加 `_HHmmss` 避免覆盖。 + +## 11. 文件导入日志 + +入口:左侧菜单 → 数据管理 → 文件导入日志。 + +用途:用于查看每次 Excel 文件导入的执行情况,便于定位导入失败原因与核对数据。 + +日志字段说明: + +- **文件名**:导入的 Excel 文件名。 +- **开始时间/结束时间**:导入流程的起止时间。 +- **状态**:`Success` / `Failed` / `Running`。 +- **源路径**:扫描到的原始文件路径。 +- **归档路径**:导入后移动到归档目录的路径。 +- **Sheet行数摘要**:各表导入行数统计,例如 `FactoryInbound=120;OEMInbound=35`。 +- **信息**:成功为 `OK`,失败时为异常信息。 + +查询建议: + +- 导入失败优先用“关键字”搜索异常信息(例如路径、权限、格式错误)。 +- 对账时可通过“文件名”定位某天的导入记录,并查看各 Sheet 导入行数是否符合预期。 + +## 12. 工厂/OEM 数据查询 + +入口:左侧菜单 → 工厂/OEM 数据查询。 + +说明:这些页面展示的是 Excel 导入后的数据(字符串字段为主),主要用于“展示、对账、追溯”。 + +### 12.1 工厂数据 + +- 工厂-入库 + - 条件:产地、原料代码、原料名称、登录日期范围(基于 `LoginDateTime`)。 +- 工厂-领料 + - 条件:产地、原料代码、原料名称、登录日期范围(基于 `LoginDateTime`)。 +- 工厂-出入库 + - 条件:产地、原料代码、原料名称、入库时间日期范围(基于 `InTime`)。 +- 工厂-原料生产信息 + - 条件:原料编号、原料名称、批号、称重时间日期范围(基于 `WeightTime`)。 +- 工厂-成品出库 + - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。 + +### 12.2 OEM 数据 + +- OEM-入库 + - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。 +- OEM-出库 + - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。 +- OEM-出入库 + - 条件:产地、原料代码、原料名称、入库时间日期范围(基于 `InTime`)。 +- OEM-原料使用信息 + - 条件:内袋二维码、原料代码、原料名称、产地、原料使用时间日期范围(基于 `RawUseTime`)。 + +注意:以上页面的日期条件会对字符串时间字段做数据库端转换比较(例如 `TRY_CONVERT(datetime, LoginDateTime)`)。若 Excel 中日期格式不规范,可能导致该条数据无法被日期条件命中。 + +## 13. 常见操作流程 - 开机与登录:启动应用 → 进入“用户登录” → 登录成功 → 自动跳转至 Dashboard。 -- 查看产线运行:在 Dashboard 观察统计卡片与实时信息,注意异常提示。 +- 查询生产数据:进入“原料使用查询/原料入库查询” → 设置条件 → 查询 → 需要时导出 Excel。 +- 导入外部 Excel:将 `.xlsx` 文件放入 `ExcelImportSourceDir` 目录 → 等待系统后台导入 → 到“文件导入日志”确认状态为 `Success` → 如需对账,使用“工厂/OEM 数据查询”查看导入数据。 - 报警定位与复盘:在“历史报警”中按时间段和关键字查询,结合现场记录进行处理与复盘。 -## 9. 常见问题与排查 +## 14. 常见问题与排查 - 无法登录: - 确认用户名、密码输入正确; @@ -103,9 +283,20 @@ Dashboard 提供生产运行的概览信息,包括: - 等待系统产生新数据或触发刷新事件; - 检查时间范围与系统时间是否正确。 -- 实时信息无更新: - - 确认产线是否有新的扫码或业务事件; - - 若日志区长时间无输出,可能产线空闲或通信异常。 +- Excel 文件未导入(文件仍在源目录 / 工厂OEM页面无数据): + - 检查 `ExcelImportSourceDir` 是否配置正确; + - 确认 Excel 文件扩展名为 `.xlsx`; + - 检查文件是否被其他程序长期占用(建议导出后再投放目录); + - 打开“文件导入日志”,查看是否存在 `Failed` 记录以及失败原因; + - 确认 Excel 的 Sheet 名是否与系统要求一致(例如“工厂-入库”“OEM-原料使用信息”等)。 + +- 文件导入日志显示失败: + - 优先查看“信息”列中的异常原因; + - 常见原因包括:目录权限不足、文件损坏、Sheet 缺失或名称不匹配。 + +- 工厂/OEM 查询页面按日期查不到数据: + - 该功能依赖将 Excel 中的时间字符串转换为数据库日期进行比较; + - 请确认 Excel 中对应时间列格式可被正常识别(建议 `yyyy-MM-dd HH:mm:ss` 或 Excel 标准日期格式)。 - PLC 指示红色: - 检查 PLC 电源、网线、IP 配置; @@ -117,28 +308,29 @@ Dashboard 提供生产运行的概览信息,包括: - 检查数据库是否可写; - 关注应用日志,必要时联系管理员。 -## 10. 使用技巧 +## 15. 使用技巧 - 登录页支持“回车键”快捷登录。 - 关注底部状态栏的用户与 PLC 状态,能快速判断系统是否处于可用状态。 -- 建议按班次定期查看“历史报警”,形成闭环处理与优化记录。 +- 发生导入问题时优先查看“文件导入日志”,比直接看数据库更直观。 -## 11. 安全与权限 +## 16. 安全与权限 - 账号仅限本人使用,请勿外借与分享。 - 退出离岗前可在“用户登录”页切换用户(如需要可联系管理员提供退出功能)。 - 如需更细粒度权限(基于岗位/等级),请与管理员沟通后续版本规划。 -## 12. 版本与更新 +## 17. 版本与更新 - 本说明书适配当前应用版本的已上线功能。新增功能或 UI 调整将另行更新说明。 - 如需升级或安装补丁,请联系系统管理员统一安排。 -## 13. 反馈与支持 +## 18. 反馈与支持 -- 使用中如遇到界面异常、统计不准、报警缺失等问题: +- 使用中如遇到界面异常、统计不准、报警缺失、导入失败等问题: - 记录问题发生时间、操作步骤、界面提示; - 截图或拍照(含底部状态栏与异常信息); + - 提供导入的 Excel 文件名(必要时提供归档文件); - 联系系统管理员或运维支持。 — 结束 — diff --git a/FATrace.WPLApp/Report/UserManual.pdf b/FATrace.WPLApp/Report/UserManual.pdf new file mode 100644 index 0000000..bb86fb3 Binary files /dev/null and b/FATrace.WPLApp/Report/UserManual.pdf differ diff --git a/FATrace.WPLApp/Services/NavigationServices.cs b/FATrace.WPLApp/Services/NavigationServices.cs index ba35e6c..27c6c9b 100644 --- a/FATrace.WPLApp/Services/NavigationServices.cs +++ b/FATrace.WPLApp/Services/NavigationServices.cs @@ -30,13 +30,13 @@ namespace FATrace.WPLApp.Services IsParent = true, ChildrenNavItemDtos = new ObservableCollection() { - new NavItemDto() - { - Name = "报表数据", - CmdPar = "报表数据", - Icon = "\uec55", - IsParent = false, - }, + //new NavItemDto() + //{ + // Name = "报表数据", + // CmdPar = "报表数据", + // Icon = "\uec55", + // IsParent = false, + //}, new NavItemDto() { Name = "原料使用查询", @@ -52,6 +52,13 @@ namespace FATrace.WPLApp.Services IsParent = false, }, new NavItemDto() + { + Name = "文件导入日志", + CmdPar = "文件导入日志", + Icon = "\ue792", + IsParent = false, + }, + new NavItemDto() { Name = "历史报警", CmdPar = "历史报警", @@ -87,6 +94,13 @@ namespace FATrace.WPLApp.Services IsParent = false, }, new NavItemDto() + { + Name = "用户管理", + CmdPar = "用户管理", + Icon = "\uec46", + IsParent = false, + }, + new NavItemDto() { Name = "使用手册", CmdPar = "使用手册", @@ -95,6 +109,104 @@ namespace FATrace.WPLApp.Services } } }, + + // 工厂/OEM 数据查询(单独的菜单目录节点) + new NavItemDto() + { + Name = "工厂/OEM 数据查询", + CmdPar = string.Empty, + Icon = "\ue650", + IsParent = true, + ChildrenNavItemDtos = new ObservableCollection() + { + // 工厂节点 + new NavItemDto() + { + Name = "工厂", + CmdPar = string.Empty, + Icon = "\ue962", + IsParent = true, + ChildrenNavItemDtos = new ObservableCollection() + { + new NavItemDto + { + Name = "工厂-入库", + CmdPar = "工厂-入库", + Icon = "\uea25", + IsParent = false, + }, + new NavItemDto + { + Name = "工厂-领料", + CmdPar = "工厂-领料", + Icon = "\uea25", + IsParent = false, + }, + new NavItemDto + { + Name = "工厂-出入库", + CmdPar = "工厂-出入库", + Icon = "\uea25", + IsParent = false, + }, + new NavItemDto + { + Name = "工厂-原料生产信息", + CmdPar = "工厂-原料生产信息", + Icon = "\uea25", + IsParent = false, + }, + new NavItemDto + { + Name = "工厂-成品出库", + CmdPar = "工厂-成品出库", + Icon = "\uea25", + IsParent = false, + } + } + }, + + // OEM 节点 + new NavItemDto() + { + Name = "OEM", + CmdPar = string.Empty, + Icon = "\ued08", + IsParent = true, + ChildrenNavItemDtos = new ObservableCollection() + { + new NavItemDto + { + Name = "OEM-入库", + CmdPar = "OEM-入库", + Icon = "\ueab4", + IsParent = false, + }, + new NavItemDto + { + Name = "OEM-出库", + CmdPar = "OEM-出库", + Icon = "\ueab4", + IsParent = false, + }, + new NavItemDto + { + Name = "OEM-出入库", + CmdPar = "OEM-出入库", + Icon = "\ueab4", + IsParent = false, + }, + new NavItemDto + { + Name = "OEM-原料使用信息", + CmdPar = "OEM-原料使用信息", + Icon = "\ueab4", + IsParent = false, + } + } + } + } + }, //// 生产管理导航项(父级) - 新增测试导航 //new NavItemDto() diff --git a/FATrace.WPLApp/Services/ReadFileServices.cs b/FATrace.WPLApp/Services/ReadFileServices.cs new file mode 100644 index 0000000..74ebfa3 --- /dev/null +++ b/FATrace.WPLApp/Services/ReadFileServices.cs @@ -0,0 +1,679 @@ +using FATrace.Com; +using FATrace.Model; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Timers; +using FreeSql; + +namespace FATrace.WPLApp.Services +{ + /// + /// Excel 文件读取与定时导入服务 + /// - 每小时扫描一次源目录中的 *.xlsx 文件(文件名: yyyyMMddHHmmss.xlsx,每天一个) + /// - 按 Sheet 名映射到对应的 FileModel 实体表 + /// - 使用 FreeSql 批量插入数据 + /// - 导入完成后将文件移动到归档目录,并在 FileImportLog 中记录详细信息 + /// + public class ReadFileServices : IDisposable + { + private readonly ILogService _log; + private readonly IFreeSql _fsql; + + private readonly System.Timers.Timer _timer; + private readonly string _sourceDirectory; + private readonly string _archiveDirectory; + + private bool _disposed; + private volatile bool _isRunning; + + /// + /// 构造函数:初始化路径配置与定时器 + /// + public ReadFileServices(ILogService logService, IFreeSql freeSql) + { + _log = logService; + _fsql = freeSql; + + // 路径从 App.config 读取,支持相对路径(相对于应用程序根目录) + var sourceSetting = FATrace.Com.ConfigHelper.GetStringOrDefault("ExcelImportSourceDir", "ExcelFile"); + var archiveSetting = FATrace.Com.ConfigHelper.GetStringOrDefault("ExcelImportArchiveDir", Path.Combine("ExcelFile", "Archive")); + + _sourceDirectory = ResolvePath(sourceSetting); + _archiveDirectory = ResolvePath(archiveSetting); + + try + { + Directory.CreateDirectory(_sourceDirectory); + Directory.CreateDirectory(_archiveDirectory); + } + catch (Exception ex) + { + _log.Error($"创建 Excel 导入目录失败: src={_sourceDirectory}, archive={_archiveDirectory}, ex={ex.Message}"); + } + + // 每小时扫描一次(3600000 ms),如有需要可后续改为配置项 + _timer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)//TimeSpan.FromHours(1).TotalMilliseconds + { + AutoReset = true, + Enabled = true + }; + _timer.Elapsed += async (s, e) => await OnTimerElapsedAsync().ConfigureAwait(false); + + // 启动后立即执行一次检查,避免必须等一小时 + Task.Run(async () => + { + try + { + await OnTimerElapsedAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _log.Error($"Excel 首次导入检查异常: {ex.Message}"); + } + }); + } + + /// + /// 将配置中的路径转换为绝对路径(支持相对路径) + /// + private static string ResolvePath(string pathSetting) + { + var path = (pathSetting ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(path)) + { + return AppDomain.CurrentDomain.BaseDirectory; + } + + // 兼容配置中写成 "C:\Dir" 或 'C:\Dir' 的情况 + if (path.Length >= 2) + { + if ((path.StartsWith("\"") && path.EndsWith("\"")) || (path.StartsWith("'") && path.EndsWith("'"))) + { + path = path.Substring(1, path.Length - 2).Trim(); + } + } + + // 支持环境变量:%USERPROFILE%\xxx + path = Environment.ExpandEnvironmentVariables(path); + + // 支持 ~ 作为用户目录(主要用于开发/测试环境) + if (path.StartsWith("~", StringComparison.Ordinal)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var tail = path.Substring(1).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + path = Path.Combine(home, tail); + } + + // 仅当不是绝对路径时,才基于程序目录做相对路径拼接 + if (!Path.IsPathRooted(path)) + { + path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path); + } + return Path.GetFullPath(path); + } + + /// + /// 定时器回调:避免重入,统一调度导入任务 + /// + private async Task OnTimerElapsedAsync() + { + if (_isRunning) return; + _isRunning = true; + try + { + await CheckAndImportAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _log.Error($"Excel 导入定时任务异常: {ex}"); + } + finally + { + _isRunning = false; + } + } + + /// + /// 对外公开的手动触发方法,方便调试与单元测试 + /// + public Task TriggerImportOnceAsync() + { + return CheckAndImportAsync(); + } + + /// + /// 扫描源目录,按文件执行导入 + /// + private async Task CheckAndImportAsync() + { + if (!Directory.Exists(_sourceDirectory)) + { + _log.Warn($"Excel 导入目录不存在: {_sourceDirectory}"); + return; + } + + string[] files; + try + { + files = Directory.GetFiles(_sourceDirectory, "*.xlsx", SearchOption.TopDirectoryOnly); + } + catch (Exception ex) + { + _log.Error($"扫描 Excel 导入目录失败: dir={_sourceDirectory}, ex={ex.Message}"); + return; + } + + if (files.Length == 0) + { + return; + } + + // 按文件名排序,确保按时间顺序处理 + foreach (var filePath in files.OrderBy(f => Path.GetFileName(f))) + { + try + { + await ImportSingleFileAsync(filePath).ConfigureAwait(false); + } + catch (Exception ex) + { + _log.Error($"导入 Excel 文件失败: {filePath}, ex={ex}"); + } + } + } + + /// + /// 导入单个 Excel 文件:按 Sheet 写入各表、记录日志并移动文件 + /// + private async Task ImportSingleFileAsync(string filePath) + { + var fileName = Path.GetFileName(filePath); + if (string.IsNullOrWhiteSpace(fileName)) return; + + // 若已存在成功记录,则不再重复导入 + var alreadySuccess = await _fsql.Select() + .Where(x => x.FileName == fileName && x.Status == "Success") + .AnyAsync().ConfigureAwait(false); + if (alreadySuccess) + { + return; + } + + var log = new FileImportLog + { + FileName = fileName, + SourcePath = filePath, + StartTime = DateTime.Now, + Status = "Running" + }; + + var sheetStats = new Dictionary(); + + try + { + using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + IWorkbook workbook = new XSSFWorkbook(fs); + var formatter = new DataFormatter(); + + sheetStats["FactoryInbound"] = ImportFactoryInbound(workbook, formatter); + sheetStats["FactoryMaterialWithdrawal"] = ImportFactoryMaterialWithdrawal(workbook, formatter); + sheetStats["FactoryInventoryTransaction"] = ImportFactoryInventoryTransaction(workbook, formatter); + sheetStats["FactoryProductionRecord"] = ImportFactoryProductionRecord(workbook, formatter); + sheetStats["FactoryOutbound"] = ImportFactoryOutbound(workbook, formatter); + sheetStats["OEMInbound"] = ImportOEMInbound(workbook, formatter); + sheetStats["OEMOutbound"] = ImportOEMOutbound(workbook, formatter); + sheetStats["OEMInventoryTransaction"] = ImportOEMInventoryTransaction(workbook, formatter); + sheetStats["OEMRawUsageInfo"] = ImportOEMRawUsageInfo(workbook, formatter); + } + + // 构造统计摘要 + log.SheetRowStats = string.Join(";", sheetStats.Select(kv => $"{kv.Key}={kv.Value}")); + + // 移动文件到归档目录 + var archivePath = Path.Combine(_archiveDirectory, fileName); + try + { + if (File.Exists(archivePath)) + { + var newName = $"{Path.GetFileNameWithoutExtension(fileName)}_{DateTime.Now:HHmmss}{Path.GetExtension(fileName)}"; + archivePath = Path.Combine(_archiveDirectory, newName); + } + + File.Move(filePath, archivePath); + log.ArchivePath = archivePath; + } + catch (Exception moveEx) + { + log.ArchivePath = archivePath; + _log.Error($"移动 Excel 文件到归档目录失败: src={filePath}, dest={archivePath}, ex={moveEx.Message}"); + // 视为部分成功 + } + + log.Status = "Success"; + log.Message = "OK"; + } + catch (Exception ex) + { + log.Status = "Failed"; + log.Message = ex.Message; + + if (sheetStats.Count > 0 && string.IsNullOrEmpty(log.SheetRowStats)) + { + log.SheetRowStats = string.Join(";", sheetStats.Select(kv => $"{kv.Key}={kv.Value}")); + } + + _log.Error($"导入 Excel 文件过程中出现异常: {filePath}, ex={ex}"); + } + finally + { + log.EndTime = DateTime.Now; + try + { + await _fsql.Insert(log).ExecuteAffrowsAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _log.Error($"写入 FileImportLog 失败: file={fileName}, ex={ex.Message}"); + } + } + } + + #region 各 Sheet 导入实现 + + private static bool IsRowEmpty(IRow? row, DataFormatter formatter) + { + if (row == null) return true; + for (int i = row.FirstCellNum; i < row.LastCellNum; i++) + { + var cell = row.GetCell(i); + if (cell != null && !string.IsNullOrWhiteSpace(formatter.FormatCellValue(cell))) + { + return false; + } + } + return true; + } + + private static string GetCellString(IRow row, int columnIndex, DataFormatter formatter) + { + var cell = row.GetCell(columnIndex); + return cell == null ? string.Empty : formatter.FormatCellValue(cell).Trim(); + } + + private int ImportFactoryInbound(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("工厂-入库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new FactoryInbound + { + Origin = GetCellString(row, 0, formatter), + RawCode = GetCellString(row, 1, formatter), + RawName = GetCellString(row, 2, formatter), + Weight = GetCellString(row, 3, formatter), + LoginDate = GetCellString(row, 4, formatter), + LoginTime = GetCellString(row, 5, formatter), + LoginDateTime = GetCellString(row, 6, formatter) + }; + + if (IsAllEmpty(entity.Origin, entity.RawCode, entity.RawName, entity.Weight, entity.LoginDate, entity.LoginTime, entity.LoginDateTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportFactoryMaterialWithdrawal(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("工厂-领料"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new FactoryMaterialWithdrawal + { + Origin = GetCellString(row, 0, formatter), + RawCode = GetCellString(row, 1, formatter), + RawName = GetCellString(row, 2, formatter), + Weight = GetCellString(row, 3, formatter), + LoginDate = GetCellString(row, 4, formatter), + LoginTime = GetCellString(row, 5, formatter), + LoginDateTime = GetCellString(row, 6, formatter) + }; + + if (IsAllEmpty(entity.Origin, entity.RawCode, entity.RawName, entity.Weight, entity.LoginDate, entity.LoginTime, entity.LoginDateTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportFactoryInventoryTransaction(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("工厂-出入库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new FactoryInventoryTransaction + { + InTime = GetCellString(row, 0, formatter), + OutTime = GetCellString(row, 1, formatter), + Origin = GetCellString(row, 2, formatter), + RawCode = GetCellString(row, 3, formatter), + RawName = GetCellString(row, 4, formatter), + TotalInWeightKg = GetCellString(row, 5, formatter), + TotalOutWeightKg = GetCellString(row, 6, formatter), + RemainWeightKg = GetCellString(row, 7, formatter) + }; + + if (IsAllEmpty(entity.InTime, entity.OutTime, entity.Origin, entity.RawCode, entity.RawName, entity.TotalInWeightKg, entity.TotalOutWeightKg, entity.RemainWeightKg)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportFactoryProductionRecord(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("工厂-原料生产信息"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new FactoryProductionRecord + { + RawCode = GetCellString(row, 0, formatter), + RawName = GetCellString(row, 1, formatter), + Origin = GetCellString(row, 2, formatter), + InBagCode = GetCellString(row, 3, formatter), + BoxCode = GetCellString(row, 4, formatter), + Batch = GetCellString(row, 5, formatter), + ShelfLife = GetCellString(row, 6, formatter), + Weight = GetCellString(row, 7, formatter), + DeliveryDate = GetCellString(row, 8, formatter), + RemainWeight = GetCellString(row, 9, formatter), + StockWeight = GetCellString(row, 10, formatter), + WeightTime = GetCellString(row, 11, formatter), + OpUser = GetCellString(row, 12, formatter), + CheckUser = GetCellString(row, 13, formatter), + BoxScanTime = GetCellString(row, 14, formatter) + }; + + if (IsAllEmpty(entity.RawCode, entity.RawName, entity.Origin, entity.InBagCode, entity.BoxCode, entity.Batch, + entity.ShelfLife, entity.Weight, entity.DeliveryDate, entity.RemainWeight, entity.StockWeight, + entity.WeightTime, entity.OpUser, entity.CheckUser, entity.BoxScanTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportFactoryOutbound(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("工厂-成品出库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new FactoryOutbound + { + Batch = GetCellString(row, 0, formatter), + Weight = GetCellString(row, 1, formatter), + ShelfLife = GetCellString(row, 2, formatter), + Origin = GetCellString(row, 3, formatter), + RawCode = GetCellString(row, 4, formatter), + RawName = GetCellString(row, 5, formatter), + SequenceNo = GetCellString(row, 6, formatter), + LoginDate = GetCellString(row, 7, formatter), + LoginTime = GetCellString(row, 8, formatter), + LoginDateTime = GetCellString(row, 9, formatter) + }; + + if (IsAllEmpty(entity.Batch, entity.Weight, entity.ShelfLife, entity.Origin, entity.RawCode, entity.RawName, + entity.SequenceNo, entity.LoginDate, entity.LoginTime, entity.LoginDateTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportOEMInbound(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("OEM-入库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new OEMInbound + { + Batch = GetCellString(row, 0, formatter), + Weight = GetCellString(row, 1, formatter), + ShelfLife = GetCellString(row, 2, formatter), + Origin = GetCellString(row, 3, formatter), + RawCode = GetCellString(row, 4, formatter), + RawName = GetCellString(row, 5, formatter), + SequenceNo = GetCellString(row, 6, formatter), + LoginDate = GetCellString(row, 7, formatter), + LoginTime = GetCellString(row, 8, formatter), + LoginDateTime = GetCellString(row, 9, formatter) + }; + + if (IsAllEmpty(entity.Batch, entity.Weight, entity.ShelfLife, entity.Origin, entity.RawCode, entity.RawName, + entity.SequenceNo, entity.LoginDate, entity.LoginTime, entity.LoginDateTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportOEMOutbound(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("OEM-出库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new OEMOutbound + { + Batch = GetCellString(row, 0, formatter), + Weight = GetCellString(row, 1, formatter), + ShelfLife = GetCellString(row, 2, formatter), + Origin = GetCellString(row, 3, formatter), + RawCode = GetCellString(row, 4, formatter), + RawName = GetCellString(row, 5, formatter), + SequenceNo = GetCellString(row, 6, formatter), + LoginDate = GetCellString(row, 7, formatter), + LoginTime = GetCellString(row, 8, formatter), + LoginDateTime = GetCellString(row, 9, formatter) + }; + + if (IsAllEmpty(entity.Batch, entity.Weight, entity.ShelfLife, entity.Origin, entity.RawCode, entity.RawName, + entity.SequenceNo, entity.LoginDate, entity.LoginTime, entity.LoginDateTime)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportOEMInventoryTransaction(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("OEM-出入库"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new OEMInventoryTransaction + { + InTime = GetCellString(row, 0, formatter), + OutTime = GetCellString(row, 1, formatter), + Origin = GetCellString(row, 2, formatter), + RawCode = GetCellString(row, 3, formatter), + RawName = GetCellString(row, 4, formatter), + TotalInWeightKg = GetCellString(row, 5, formatter), + TotalOutWeightKg = GetCellString(row, 6, formatter), + RemainWeightKg = GetCellString(row, 7, formatter) + }; + + if (IsAllEmpty(entity.InTime, entity.OutTime, entity.Origin, entity.RawCode, entity.RawName, + entity.TotalInWeightKg, entity.TotalOutWeightKg, entity.RemainWeightKg)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private int ImportOEMRawUsageInfo(IWorkbook workbook, DataFormatter formatter) + { + var sheet = workbook.GetSheet("OEM-原料使用信息"); + if (sheet == null) return 0; + + var list = new List(); + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (IsRowEmpty(row, formatter)) continue; + + var entity = new OEMRawUsageInfo + { + RawUseTime = GetCellString(row, 0, formatter), + InBagCode = GetCellString(row, 1, formatter), + Origin = GetCellString(row, 2, formatter), + RawName = GetCellString(row, 3, formatter), + RawCode = GetCellString(row, 4, formatter), + VideoUrl = GetCellString(row, 5, formatter) + }; + + if (IsAllEmpty(entity.RawUseTime, entity.InBagCode, entity.Origin, entity.RawName, entity.RawCode, entity.VideoUrl)) + continue; + + list.Add(entity); + } + + if (list.Count > 0) + { + _fsql.Insert(list).ExecuteAffrows(); + } + return list.Count; + } + + private static bool IsAllEmpty(params string?[] values) + { + if (values == null || values.Length == 0) return true; + foreach (var v in values) + { + if (!string.IsNullOrWhiteSpace(v)) return false; + } + return true; + } + + #endregion + + /// + /// 释放定时器等资源 + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + _timer?.Stop(); + _timer?.Dispose(); + } + catch + { + // 忽略定时器释放异常 + } + } + } +} diff --git a/FATrace.WPLApp/Services/SysRunService.cs b/FATrace.WPLApp/Services/SysRunService.cs index 60ed016..509cbc1 100644 --- a/FATrace.WPLApp/Services/SysRunService.cs +++ b/FATrace.WPLApp/Services/SysRunService.cs @@ -84,6 +84,7 @@ namespace FATrace.WPLApp.Services if (string.IsNullOrEmpty(value)) { IsLogin = false; + CurAccessLevel = null; } else { @@ -94,6 +95,23 @@ namespace FATrace.WPLApp.Services } } + private string? _CurAccessLevel; + /// + /// 当前用户等级(管理员/操作员/访客) + /// + public string? CurAccessLevel + { + get { return _CurAccessLevel; } + set + { + if (_CurAccessLevel != value) + { + _CurAccessLevel = value; + RaisePropertyChanged(); + } + } + } + private bool _IsLogin; /// /// 登录 diff --git a/FATrace.WPLApp/ViewModels/DialogUserEditViewModel.cs b/FATrace.WPLApp/ViewModels/DialogUserEditViewModel.cs new file mode 100644 index 0000000..b5eedf7 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/DialogUserEditViewModel.cs @@ -0,0 +1,232 @@ +using FATrace.Model; +using FATrace.WPLApp.Core; +using FATrace.WPLApp.Services; +using FreeSql; +using Prism.Commands; +using Prism.Services.Dialogs; +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 用户新增/编辑 弹窗 VM + /// + public class DialogUserEditViewModel : DialogViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + private string _mode = UserManageViewModel.DialogModes.Add; + private long _editUserId; + + public DialogUserEditViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + AccessLevelOptions = new ObservableCollection(new[] + { + UserManageViewModel.AccessLevels.Admin, + UserManageViewModel.AccessLevels.Operator, + UserManageViewModel.AccessLevels.Guest + }); + + SaveCommand = new DelegateCommand(async () => await SaveAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + CancelCommand = new DelegateCommand(() => OnDialogClosed(ButtonResult.Cancel)); + } + + /// + /// 等级下拉选项 + /// + public ObservableCollection AccessLevelOptions { get; } + + private string? _userName; + /// + /// 用户名 + /// + public string? UserName + { + get => _userName; + set { _userName = value; RaisePropertyChanged(); } + } + + private string? _password; + /// + /// 密码(当前按现有模型使用明文;如后续需哈希,可在保存处替换) + /// + public string? Password + { + get => _password; + set { _password = value; RaisePropertyChanged(); } + } + + private string? _accessLevel; + /// + /// 等级 + /// + public string? AccessLevel + { + get => _accessLevel; + set { _accessLevel = value; RaisePropertyChanged(); } + } + + /// + /// 保存 + /// + public DelegateCommand SaveCommand { get; } + + /// + /// 取消 + /// + public DelegateCommand CancelCommand { get; } + + /// + /// 打开弹窗时初始化 + /// + /// 参数 + public override void OnDialogOpened(IDialogParameters parameters) + { + _mode = parameters.GetValue(UserManageViewModel.DialogKeys.Mode) ?? UserManageViewModel.DialogModes.Add; + Title = _mode == UserManageViewModel.DialogModes.Edit ? "编辑用户" : "新增用户"; + + if (_mode == UserManageViewModel.DialogModes.Edit) + { + _editUserId = parameters.GetValue(UserManageViewModel.DialogKeys.UserId); + LoadUser(_editUserId); + } + else + { + _editUserId = 0; + UserName = string.Empty; + Password = string.Empty; + AccessLevel = UserManageViewModel.AccessLevels.Operator; + } + } + + private void LoadUser(long id) + { + try + { + var user = _fsql.Select().Where(a => a.Id == id).First(); + if (user == null) + { + MessageBox.Show("用户不存在或已被删除", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + UserName = user.UserName; + Password = user.Password; + AccessLevel = user.AccessLevel; + } + catch (Exception ex) + { + _log.Error($"加载用户失败: {ex}"); + MessageBox.Show($"加载用户失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private async Task SaveAsync() + { + if (IsBusy) return; + try + { + if (string.IsNullOrWhiteSpace(UserName)) + { + MessageBox.Show("请输入用户名", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (string.IsNullOrWhiteSpace(Password)) + { + MessageBox.Show("请输入密码", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (string.IsNullOrWhiteSpace(AccessLevel)) + { + MessageBox.Show("请选择等级", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + IsBusy = true; + + if (_mode == UserManageViewModel.DialogModes.Add) + { + await AddAsync(); + } + else + { + await UpdateAsync(); + } + + OnDialogClosed(ButtonResult.OK); + } + catch (Exception ex) + { + _log.Error($"保存用户失败: {ex}"); + MessageBox.Show($"保存失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + private async Task AddAsync() + { + await Task.Run(() => + { + var exists = _fsql.Select().Where(a => a.UserName == UserName).Any(); + if (exists) + { + throw new InvalidOperationException("用户名已存在"); + } + + var entity = new TbUser + { + UserName = UserName!, + Password = Password!, + AccessLevel = AccessLevel!, + CreateTime = DateTime.Now + }; + + _fsql.Insert(entity).ExecuteAffrows(); + }); + + _log.Info($"新增用户成功: {UserName}"); + } + + private async Task UpdateAsync() + { + var id = _editUserId; + if (id <= 0) throw new InvalidOperationException("编辑用户Id无效"); + + await Task.Run(() => + { + var exists = _fsql.Select().Where(a => a.UserName == UserName && a.Id != id).Any(); + if (exists) + { + throw new InvalidOperationException("用户名已存在"); + } + + var aff = _fsql.Update() + .Set(a => a.UserName, UserName!) + .Set(a => a.Password, Password!) + .Set(a => a.AccessLevel, AccessLevel!) + .Where(a => a.Id == id) + .ExecuteAffrows(); + + if (aff <= 0) + { + throw new InvalidOperationException("更新失败:用户不存在或未发生变化"); + } + }); + + _log.Info($"更新用户成功: Id={id}, UserName={UserName}"); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FactoryInboundViewModel.cs b/FATrace.WPLApp/ViewModels/FactoryInboundViewModel.cs new file mode 100644 index 0000000..1c53c1d --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FactoryInboundViewModel.cs @@ -0,0 +1,222 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 工厂-入库 数据查询 VM + /// 仅展示从 Excel 导入的 FactoryInbound 数据,支持简单条件与分页。 + /// + public class FactoryInboundViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FactoryInboundViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + /// + /// 产地模糊匹配 + /// + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + /// + /// 原料代码模糊匹配 + /// + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + /// + /// 原料名称模糊匹配 + /// + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + /// + /// 登录日期起(根据 LoginDateTime 转换为日期范围过滤) + /// + public DateTime? StartDate + { + get => _startDate; + set { _startDate = value; RaisePropertyChanged(); } + } + + private DateTime? _endDate; + /// + /// 登录日期止(包含当天,基于 LoginDateTime) + /// + public DateTime? EndDate + { + get => _endDate; + set { _endDate = value; RaisePropertyChanged(); } + } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex + { + get => _pageIndex; + set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } + } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FactoryInbound 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + + // 日期范围:基于 LoginDateTime 进行 TRY_CONVERT(datetime, LoginDateTime) 比较 + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FactoryInbound 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FactoryInbound 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FactoryInventoryTransactionViewModel.cs b/FATrace.WPLApp/ViewModels/FactoryInventoryTransactionViewModel.cs new file mode 100644 index 0000000..3a7f14b --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FactoryInventoryTransactionViewModel.cs @@ -0,0 +1,208 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 工厂-出入库 数据查询 VM + /// 仅展示从 Excel 导入的 FactoryInventoryTransaction 数据,支持简单条件与分页。 + /// + public class FactoryInventoryTransactionViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FactoryInventoryTransactionViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + /// + /// 入库时间起(基于 InTime) + /// + public DateTime? StartDate + { + get => _startDate; + set { _startDate = value; RaisePropertyChanged(); } + } + + private DateTime? _endDate; + /// + /// 入库时间止(包含当天) + /// + public DateTime? EndDate + { + get => _endDate; + set { _endDate = value; RaisePropertyChanged(); } + } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FactoryInventoryTransaction 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, InTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, InTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FactoryInventoryTransaction 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FactoryInventoryTransaction 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FactoryMaterialWithdrawalViewModel.cs b/FATrace.WPLApp/ViewModels/FactoryMaterialWithdrawalViewModel.cs new file mode 100644 index 0000000..3fc6843 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FactoryMaterialWithdrawalViewModel.cs @@ -0,0 +1,208 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 工厂-领料 数据查询 VM + /// 仅展示从 Excel 导入的 FactoryMaterialWithdrawal 数据,支持简单条件与分页。 + /// + public class FactoryMaterialWithdrawalViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FactoryMaterialWithdrawalViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + /// + /// 登录日期起(基于 LoginDateTime) + /// + public DateTime? StartDate + { + get => _startDate; + set { _startDate = value; RaisePropertyChanged(); } + } + + private DateTime? _endDate; + /// + /// 登录日期止(包含当天) + /// + public DateTime? EndDate + { + get => _endDate; + set { _endDate = value; RaisePropertyChanged(); } + } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FactoryMaterialWithdrawal 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FactoryMaterialWithdrawal 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FactoryMaterialWithdrawal 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FactoryOutboundViewModel.cs b/FATrace.WPLApp/ViewModels/FactoryOutboundViewModel.cs new file mode 100644 index 0000000..e387062 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FactoryOutboundViewModel.cs @@ -0,0 +1,198 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 工厂-成品出库 查询 VM(展示 FactoryOutbound) + /// + public class FactoryOutboundViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FactoryOutboundViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private string? _batch; + public string? Batch { get => _batch; set { _batch = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + public DateTime? StartDate { get => _startDate; set { _startDate = value; RaisePropertyChanged(); } } + + private DateTime? _endDate; + public DateTime? EndDate { get => _endDate; set { _endDate = value; RaisePropertyChanged(); } } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = Batch = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FactoryOutbound 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + if (!string.IsNullOrWhiteSpace(Batch)) + q = q.Where(a => a.Batch != null && a.Batch.Contains(Batch)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FactoryOutbound 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FactoryOutbound 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FactoryProductionRecordViewModel.cs b/FATrace.WPLApp/ViewModels/FactoryProductionRecordViewModel.cs new file mode 100644 index 0000000..dc71775 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FactoryProductionRecordViewModel.cs @@ -0,0 +1,216 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 工厂-原料生产信息 查询 VM(展示 FactoryProductionRecord) + /// + public class FactoryProductionRecordViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FactoryProductionRecordViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _rawCode; + /// + /// 原料编号模糊匹配 + /// + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + /// + /// 原料名称模糊匹配 + /// + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private string? _batch; + /// + /// 批号模糊匹配 + /// + public string? Batch { get => _batch; set { _batch = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + /// + /// 称重时间起(基于 WeightTime) + /// + public DateTime? StartDate + { + get => _startDate; + set { _startDate = value; RaisePropertyChanged(); } + } + + private DateTime? _endDate; + /// + /// 称重时间止(包含当天) + /// + public DateTime? EndDate + { + get => _endDate; + set { _endDate = value; RaisePropertyChanged(); } + } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + RawCode = RawName = Batch = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FactoryProductionRecord 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + if (!string.IsNullOrWhiteSpace(Batch)) + q = q.Where(a => a.Batch != null && a.Batch.Contains(Batch)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, WeightTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, WeightTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FactoryProductionRecord 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FactoryProductionRecord 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/FileImportLogViewModel.cs b/FATrace.WPLApp/ViewModels/FileImportLogViewModel.cs new file mode 100644 index 0000000..2fa4169 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/FileImportLogViewModel.cs @@ -0,0 +1,255 @@ +using FATrace.Model; +using FATrace.WPLApp.Core; +using FATrace.WPLApp.Services; +using FreeSql; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 文件导入日志 查询 VM(展示 FileImportLog) + /// + public class FileImportLogViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public FileImportLogViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _fileName; + /// + /// 文件名(模糊匹配) + /// + public string? FileName + { + get => _fileName; + set { _fileName = value; RaisePropertyChanged(); } + } + + private string? _status; + /// + /// 状态(模糊匹配,例如 Success/Failed) + /// + public string? Status + { + get => _status; + set { _status = value; RaisePropertyChanged(); } + } + + private string? _keyword; + /// + /// 关键字(匹配 SourcePath/ArchivePath/Message/SheetRowStats) + /// + public string? Keyword + { + get => _keyword; + set { _keyword = value; RaisePropertyChanged(); } + } + + private DateTime? _startDate; + /// + /// 导入开始日期起(基于 StartTime) + /// + public DateTime? StartDate + { + get => _startDate; + set { _startDate = value; RaisePropertyChanged(); } + } + + private DateTime? _endDate; + /// + /// 导入开始日期止(基于 StartTime,包含当天) + /// + public DateTime? EndDate + { + get => _endDate; + set { _endDate = value; RaisePropertyChanged(); } + } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy + { + get => _isBusy; + set { _isBusy = value; RaisePropertyChanged(); } + } + + private int _totalCount; + public int TotalCount + { + get => _totalCount; + set { _totalCount = value; RaisePropertyChanged(); } + } + + private int _pageIndex = 1; + public int PageIndex + { + get => _pageIndex; + set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } + } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages + { + get => _totalPages; + set { _totalPages = value; RaisePropertyChanged(); } + } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + FileName = string.Empty; + Status = string.Empty; + Keyword = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("FileImportLog 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(FileName)) + q = q.Where(a => a.FileName != null && a.FileName.Contains(FileName)); + if (!string.IsNullOrWhiteSpace(Status)) + q = q.Where(a => a.Status != null && a.Status.Contains(Status)); + + if (!string.IsNullOrWhiteSpace(Keyword)) + { + var kw = Keyword; + q = q.Where(a => + (a.SourcePath != null && a.SourcePath.Contains(kw)) + || (a.ArchivePath != null && a.ArchivePath.Contains(kw)) + || (a.Message != null && a.Message.Contains(kw)) + || (a.SheetRowStats != null && a.SheetRowStats.Contains(kw)) + ); + } + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where(a => a.StartTime >= s); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where(a => a.StartTime <= e); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"FileImportLog 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"FileImportLog 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/HelpManualViewModel.cs b/FATrace.WPLApp/ViewModels/HelpManualViewModel.cs new file mode 100644 index 0000000..6af4c22 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/HelpManualViewModel.cs @@ -0,0 +1,209 @@ +using FATrace.WPLApp.Core; +using FATrace.WPLApp.Services; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 使用手册(PDF)查看页 VM + /// + public class HelpManualViewModel : NavigationViewModel + { + private readonly ILogService _log; + + public HelpManualViewModel(ILogService log) + { + _log = log; + + PdfFiles = new ObservableCollection(); + RefreshCommand = new DelegateCommand(async () => await LoadReportPdfsAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + } + + private bool _isBusy; + /// + /// 是否忙碌(用于提示与禁用按钮) + /// + public bool IsBusy + { + get => _isBusy; + set { _isBusy = value; RaisePropertyChanged(); } + } + + private string? _statusText; + /// + /// 页面状态文本(例如:未找到PDF) + /// + public string? StatusText + { + get => _statusText; + set { _statusText = value; RaisePropertyChanged(); } + } + + /// + /// Report 目录下的 PDF 文件列表 + /// + public ObservableCollection PdfFiles { get; } + + private PdfFileItem? _selectedPdf; + /// + /// 当前选择的 PDF + /// + public PdfFileItem? SelectedPdf + { + get => _selectedPdf; + set + { + _selectedPdf = value; + RaisePropertyChanged(); + PdfFilePath = _selectedPdf?.FullPath; + } + } + + private string? _pdfFilePath; + /// + /// 当前打开的 PDF 文件路径(绑定到 Syncfusion PdfViewerControl.ItemSource) + /// + public string? PdfFilePath + { + get => _pdfFilePath; + set { _pdfFilePath = value; RaisePropertyChanged(); } + } + + /// + /// 刷新/重新扫描 PDF 文件 + /// + public DelegateCommand RefreshCommand { get; } + + /// + /// 获取运行目录下 Report 文件夹绝对路径 + /// + /// Report 文件夹绝对路径 + private string GetReportFolder() + { + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var folder = Path.Combine(baseDir, "Report"); + try + { + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + } + catch (Exception ex) + { + _log.Error($"HelpManual: 创建Report目录失败: {ex}"); + } + return folder; + } + + /// + /// 扫描 Report 目录并加载 PDF 列表(默认打开第一个) + /// + /// Task + private async Task LoadReportPdfsAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + StatusText = string.Empty; + + var folder = GetReportFolder(); + _log.Info($"HelpManual: 扫描PDF目录: {folder}"); + + var list = await Task.Run(() => + { + if (!Directory.Exists(folder)) + return Array.Empty(); + + var files = Directory.GetFiles(folder, "*.pdf", SearchOption.TopDirectoryOnly) + .Select(p => new PdfFileItem(Path.GetFileName(p), p)) + .OrderByDescending(a => a.FileName) + .ToArray(); + + return files; + }); + + Application.Current.Dispatcher.Invoke(() => + { + PdfFiles.Clear(); + foreach (var f in list) PdfFiles.Add(f); + + if (PdfFiles.Count == 0) + { + SelectedPdf = null; + PdfFilePath = null; + StatusText = "未在程序目录下的 Report 文件夹找到 PDF 文件,请将手册PDF放入该目录后点击【刷新】。"; + return; + } + + // 保持原选择(若仍存在),否则默认第一个 + var keep = SelectedPdf != null + ? PdfFiles.FirstOrDefault(a => string.Equals(a.FullPath, SelectedPdf.FullPath, StringComparison.OrdinalIgnoreCase)) + : null; + + SelectedPdf = keep ?? PdfFiles[0]; + }); + } + catch (Exception ex) + { + _log.Error($"HelpManual: 扫描/加载PDF失败: {ex}"); + StatusText = $"加载PDF失败: {ex.Message}"; + MessageBox.Show($"加载PDF失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + /// + /// 页面导航进入时自动加载 + /// + /// 导航上下文 + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await LoadReportPdfsAsync(); + } + + /// + /// PDF 文件下拉项 + /// + public class PdfFileItem + { + /// + /// 构造 + /// + /// 文件名 + /// 完整路径 + public PdfFileItem(string fileName, string fullPath) + { + FileName = fileName; + FullPath = fullPath; + } + + /// + /// 文件名 + /// + public string FileName { get; } + + /// + /// 完整路径 + /// + public string FullPath { get; } + + /// + /// 用于下拉显示 + /// + /// 文件名 + public override string ToString() => FileName; + } + } +} diff --git a/FATrace.WPLApp/ViewModels/LoginViewModel.cs b/FATrace.WPLApp/ViewModels/LoginViewModel.cs index 43489d3..69932f2 100644 --- a/FATrace.WPLApp/ViewModels/LoginViewModel.cs +++ b/FATrace.WPLApp/ViewModels/LoginViewModel.cs @@ -73,6 +73,7 @@ namespace FATrace.WPLApp.ViewModels } _sys.CurUser = user.UserName; + _sys.CurAccessLevel = user.AccessLevel; _log.Info($"用户登录成功: {user.UserName}"); UserName=""; Password=""; diff --git a/FATrace.WPLApp/ViewModels/MainViewModel.cs b/FATrace.WPLApp/ViewModels/MainViewModel.cs index a8a9057..ab8d451 100644 --- a/FATrace.WPLApp/ViewModels/MainViewModel.cs +++ b/FATrace.WPLApp/ViewModels/MainViewModel.cs @@ -6,18 +6,24 @@ using Prism.Commands; using Prism.Events; using Prism.Regions; using Prism.Services.Dialogs; +using System; using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows; namespace FATrace.WPLApp.ViewModels { public class MainViewModel : NavigationViewModel { - public MainViewModel(IRegionManager regionManager, IDialogService dialogService, IEventAggregator eventAggregator, NavigationServices navigationServices) + public MainViewModel(IRegionManager regionManager, IDialogService dialogService, IEventAggregator eventAggregator, NavigationServices navigationServices, SysRunService sysRunService) { this.regionManager = regionManager; DialogService = dialogService; NavigationServices = navigationServices; - NavigationItems = NavigationServices.NavItemDtos; + _sys = sysRunService; + NavigationItems = BuildNavigationItems(_sys.CurAccessLevel); + _sys.PropertyChanged += SysOnPropertyChanged; this.regionManager.RegisterViewWithRegion("FootRegion", typeof(FootView)); this.regionManager.RegisterViewWithRegion("HeadRegion", typeof(HeadView)); @@ -30,8 +36,69 @@ namespace FATrace.WPLApp.ViewModels public IDialogService DialogService { get; } public NavigationServices NavigationServices { get; } + private readonly SysRunService _sys; + public ObservableCollection NavigationItems { get; set; } + private void SysOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SysRunService.CurAccessLevel) || e.PropertyName == nameof(SysRunService.CurUser)) + { + NavigationItems = BuildNavigationItems(_sys.CurAccessLevel); + RaisePropertyChanged(nameof(NavigationItems)); + } + } + + private ObservableCollection BuildNavigationItems(string? accessLevel) + { + // 未登录/访客:仅允许 Dashboard + 用户登录 + if (string.IsNullOrWhiteSpace(accessLevel) || string.Equals(accessLevel, UserManageViewModel.AccessLevels.Guest, StringComparison.OrdinalIgnoreCase)) + { + var dashboard = NavigationServices.NavItemDtos.FirstOrDefault(a => string.Equals(a.CmdPar, "Dashboard", StringComparison.OrdinalIgnoreCase)); + var system = NavigationServices.NavItemDtos.FirstOrDefault(a => string.Equals(a.Name, "系统", StringComparison.OrdinalIgnoreCase)); + + var result = new ObservableCollection(); + if (dashboard != null) result.Add(CloneNavItem(dashboard)); + + if (system != null) + { + var sysClone = CloneNavItem(system); + sysClone.ChildrenNavItemDtos = new ObservableCollection( + sysClone.ChildrenNavItemDtos + .Where(a => string.Equals(a.CmdPar, "用户登录", StringComparison.OrdinalIgnoreCase)) + .ToList()); + result.Add(sysClone); + } + + return result; + } + + // 管理员/操作员:完整菜单 + return new ObservableCollection(NavigationServices.NavItemDtos.Select(CloneNavItem)); + } + + private NavItemDto CloneNavItem(NavItemDto src) + { + var dst = new NavItemDto + { + Name = src.Name, + CmdPar = src.CmdPar, + Icon = src.Icon, + IsParent = src.IsParent, + ChildrenNavItemDtos = new ObservableCollection() + }; + + if (src.ChildrenNavItemDtos != null && src.ChildrenNavItemDtos.Count > 0) + { + foreach (var c in src.ChildrenNavItemDtos) + { + dst.ChildrenNavItemDtos.Add(CloneNavItem(c)); + } + } + + return dst; + } + private string titel = string.Empty; /// /// 标题 @@ -60,7 +127,27 @@ namespace FATrace.WPLApp.ViewModels private void OpenCommandMethod(object obj) { - switch (obj.ToString()) + var cmd = obj?.ToString() ?? string.Empty; + + // 父级目录/空命令不做任何处理(避免点击父节点触发弹窗或导航) + if (string.IsNullOrWhiteSpace(cmd)) + { + return; + } + + // 访客/未登录:仅 Dashboard + 用户登录 + if (string.IsNullOrWhiteSpace(_sys.CurAccessLevel) || string.Equals(_sys.CurAccessLevel, UserManageViewModel.AccessLevels.Guest, StringComparison.OrdinalIgnoreCase)) + { + if (!string.Equals(cmd, "Dashboard", StringComparison.OrdinalIgnoreCase) + && !string.Equals(cmd, "用户登录", StringComparison.OrdinalIgnoreCase)) + { + MessageBox.Show("当前账号权限为【访客】,仅允许访问 Dashboard。", "权限不足", MessageBoxButton.OK, MessageBoxImage.Warning); + this.regionManager.Regions["ContentRegion"].RequestNavigate("DashBoardView"); + return; + } + } + + switch (cmd) { case "Dashboard": this.regionManager.Regions["ContentRegion"].RequestNavigate("DashBoardView"); @@ -102,6 +189,37 @@ namespace FATrace.WPLApp.ViewModels case "原料入库查询": this.regionManager.Regions["ContentRegion"].RequestNavigate("RawProInputView"); break; + case "文件导入日志": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FileImportLogView"); + break; + // 工厂/OEM Excel 导入数据查询 + case "工厂-入库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FactoryInboundView"); + break; + case "工厂-领料": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FactoryMaterialWithdrawalView"); + break; + case "工厂-出入库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FactoryInventoryTransactionView"); + break; + case "工厂-原料生产信息": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FactoryProductionRecordView"); + break; + case "工厂-成品出库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("FactoryOutboundView"); + break; + case "OEM-入库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("OEMInboundView"); + break; + case "OEM-出库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("OEMOutboundView"); + break; + case "OEM-出入库": + this.regionManager.Regions["ContentRegion"].RequestNavigate("OEMInventoryTransactionView"); + break; + case "OEM-原料使用信息": + this.regionManager.Regions["ContentRegion"].RequestNavigate("OEMRawUsageInfoView"); + break; case "历史报警": this.regionManager.Regions["ContentRegion"].RequestNavigate("HistoryAlarmView"); @@ -114,10 +232,11 @@ namespace FATrace.WPLApp.ViewModels this.regionManager.Regions["ContentRegion"].RequestNavigate("LoginView"); //this.regionManager.Regions["ContentRegion"].Activate(Shift); break; + case "用户管理": + this.regionManager.Regions["ContentRegion"].RequestNavigate("UserManageView"); + break; case "使用手册": - //this.regionManager.Regions["ContentRegion"].RequestNavigate("HelpManualView"); - //弹窗 - + this.regionManager.Regions["ContentRegion"].RequestNavigate("HelpManualView"); break; default: break; diff --git a/FATrace.WPLApp/ViewModels/OEMInboundViewModel.cs b/FATrace.WPLApp/ViewModels/OEMInboundViewModel.cs new file mode 100644 index 0000000..2e57256 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/OEMInboundViewModel.cs @@ -0,0 +1,198 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// OEM-入库 查询 VM(展示 OEMInbound) + /// + public class OEMInboundViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public OEMInboundViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private string? _batch; + public string? Batch { get => _batch; set { _batch = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + public DateTime? StartDate { get => _startDate; set { _startDate = value; RaisePropertyChanged(); } } + + private DateTime? _endDate; + public DateTime? EndDate { get => _endDate; set { _endDate = value; RaisePropertyChanged(); } } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = Batch = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("OEMInbound 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + if (!string.IsNullOrWhiteSpace(Batch)) + q = q.Where(a => a.Batch != null && a.Batch.Contains(Batch)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"OEMInbound 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"OEMInbound 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs b/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs new file mode 100644 index 0000000..e0398af --- /dev/null +++ b/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs @@ -0,0 +1,193 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// OEM-出入库 查询 VM(展示 OEMInventoryTransaction) + /// + public class OEMInventoryTransactionViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public OEMInventoryTransactionViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + public DateTime? StartDate { get => _startDate; set { _startDate = value; RaisePropertyChanged(); } } + + private DateTime? _endDate; + public DateTime? EndDate { get => _endDate; set { _endDate = value; RaisePropertyChanged(); } } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("OEMInventoryTransaction 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, InTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, InTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"OEMInventoryTransaction 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"OEMInventoryTransaction 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/OEMOutboundViewModel.cs b/FATrace.WPLApp/ViewModels/OEMOutboundViewModel.cs new file mode 100644 index 0000000..45be9e9 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/OEMOutboundViewModel.cs @@ -0,0 +1,198 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// OEM-出库 查询 VM(展示 OEMOutbound) + /// + public class OEMOutboundViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public OEMOutboundViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private string? _batch; + public string? Batch { get => _batch; set { _batch = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + public DateTime? StartDate { get => _startDate; set { _startDate = value; RaisePropertyChanged(); } } + + private DateTime? _endDate; + public DateTime? EndDate { get => _endDate; set { _endDate = value; RaisePropertyChanged(); } } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + Origin = RawCode = RawName = Batch = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("OEMOutbound 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + if (!string.IsNullOrWhiteSpace(Batch)) + q = q.Where(a => a.Batch != null && a.Batch.Contains(Batch)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, LoginDateTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"OEMOutbound 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"OEMOutbound 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/OEMRawUsageInfoViewModel.cs b/FATrace.WPLApp/ViewModels/OEMRawUsageInfoViewModel.cs new file mode 100644 index 0000000..0a88469 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/OEMRawUsageInfoViewModel.cs @@ -0,0 +1,198 @@ +using FATrace.WPLApp.Core; +using Prism.Commands; +using System; +using System.Collections.ObjectModel; +using FreeSql; +using FATrace.Model; +using FATrace.WPLApp.Services; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// OEM-原料使用信息 查询 VM(展示 OEMRawUsageInfo) + /// + public class OEMRawUsageInfoViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + + public OEMRawUsageInfoViewModel(IFreeSql fsql, ILogService log) + { + _fsql = fsql; + _log = log; + + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + } + + #region 查询条件 + private string? _inBagCode; + public string? InBagCode { get => _inBagCode; set { _inBagCode = value; RaisePropertyChanged(); } } + + private string? _origin; + public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } } + + private string? _rawCode; + public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } } + + private string? _rawName; + public string? RawName { get => _rawName; set { _rawName = value; RaisePropertyChanged(); } } + + private DateTime? _startDate; + public DateTime? StartDate { get => _startDate; set { _startDate = value; RaisePropertyChanged(); } } + + private DateTime? _endDate; + public DateTime? EndDate { get => _endDate; set { _endDate = value; RaisePropertyChanged(); } } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private bool _isBusy; + public bool IsBusy { get => _isBusy; set { _isBusy = value; RaisePropertyChanged(); } } + + private int _totalCount; + public int TotalCount { get => _totalCount; set { _totalCount = value; RaisePropertyChanged(); } } + + private int _pageIndex = 1; + public int PageIndex { get => _pageIndex; set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } } + + private int _pageSize = 20; + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + public int TotalPages { get => _totalPages; set { _totalPages = value; RaisePropertyChanged(); } } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + InBagCode = Origin = RawCode = RawName = string.Empty; + StartDate = null; + EndDate = null; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + _log.Info("OEMRawUsageInfo 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(InBagCode)) + q = q.Where(a => a.InBagCode != null && a.InBagCode.Contains(InBagCode)); + if (!string.IsNullOrWhiteSpace(Origin)) + q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin)); + if (!string.IsNullOrWhiteSpace(RawCode)) + q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode)); + if (!string.IsNullOrWhiteSpace(RawName)) + q = q.Where(a => a.RawName != null && a.RawName.Contains(RawName)); + + DateTime? start = StartDate; + DateTime? end = EndDate; + if (start.HasValue) + { + var s = start.Value.Date; + q = q.Where("TRY_CONVERT(datetime, RawUseTime) >= @start", new { start = s }); + } + if (end.HasValue) + { + var e = end.Value.Date.AddDays(1).AddTicks(-1); + q = q.Where("TRY_CONVERT(datetime, RawUseTime) <= @end", new { end = e }); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"OEMRawUsageInfo 查询完成,记录数: {TotalCount}"); + } + catch (Exception ex) + { + _log.Error($"OEMRawUsageInfo 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + } +} diff --git a/FATrace.WPLApp/ViewModels/UserManageViewModel.cs b/FATrace.WPLApp/ViewModels/UserManageViewModel.cs new file mode 100644 index 0000000..bbd1f12 --- /dev/null +++ b/FATrace.WPLApp/ViewModels/UserManageViewModel.cs @@ -0,0 +1,395 @@ +using FATrace.Model; +using FATrace.WPLApp.Core; +using FATrace.WPLApp.Services; +using FreeSql; +using Prism.Commands; +using Prism.Services.Dialogs; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; + +namespace FATrace.WPLApp.ViewModels +{ + /// + /// 用户管理页面 VM(TbUser 增删改查) + /// + public class UserManageViewModel : NavigationViewModel + { + private readonly IFreeSql _fsql; + private readonly ILogService _log; + private readonly IDialogService _dialog; + private readonly SysRunService _sys; + + public UserManageViewModel(IFreeSql fsql, ILogService log, IDialogService dialogService, SysRunService sys) + { + _fsql = fsql; + _log = log; + _dialog = dialogService; + _sys = sys; + + AccessLevelOptions = new ObservableCollection(new[] { AccessLevels.All, AccessLevels.Admin, AccessLevels.Operator, AccessLevels.Guest }); + AccessLevel = AccessLevels.All; + Items = new ObservableCollection(); + + SearchCommand = new DelegateCommand(async () => await SearchAsync(), () => !IsBusy) + .ObservesProperty(() => IsBusy); + ClearCommand = new DelegateCommand(ClearFilters, () => !IsBusy) + .ObservesProperty(() => IsBusy); + + AddCommand = new DelegateCommand(AddUser, () => !IsBusy && CanEditUsers) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => CanEditUsers); + EditCommand = new DelegateCommand(EditUser, () => !IsBusy && CanEditUsers && SelectedItem != null) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => CanEditUsers) + .ObservesProperty(() => SelectedItem); + DeleteCommand = new DelegateCommand(async () => await DeleteUserAsync(), () => !IsBusy && CanEditUsers && SelectedItem != null) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => CanEditUsers) + .ObservesProperty(() => SelectedItem); + + FirstPageCommand = new DelegateCommand(async () => { if (PageIndex == 1) return; PageIndex = 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + PrevPageCommand = new DelegateCommand(async () => { if (PageIndex <= 1) return; PageIndex -= 1; await SearchAsync(); }, () => !IsBusy && PageIndex > 1) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex); + NextPageCommand = new DelegateCommand(async () => { if (PageIndex >= TotalPages) return; PageIndex += 1; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + LastPageCommand = new DelegateCommand(async () => { if (TotalPages <= 0 || PageIndex == TotalPages) return; PageIndex = TotalPages; await SearchAsync(); }, () => !IsBusy && PageIndex < TotalPages) + .ObservesProperty(() => IsBusy) + .ObservesProperty(() => PageIndex) + .ObservesProperty(() => TotalPages); + + _sys.PropertyChanged += SysOnPropertyChanged; + RaisePropertyChanged(nameof(CanEditUsers)); + } + + private void SysOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SysRunService.CurAccessLevel) || e.PropertyName == nameof(SysRunService.CurUser)) + { + RaisePropertyChanged(nameof(CanEditUsers)); + } + } + + /// + /// 是否允许编辑用户(仅管理员) + /// + public bool CanEditUsers => string.Equals(_sys.CurAccessLevel, AccessLevels.Admin, StringComparison.OrdinalIgnoreCase); + + #region 查询条件 + private string? _userName; + /// + /// 用户名(模糊匹配) + /// + public string? UserName + { + get => _userName; + set { _userName = value; RaisePropertyChanged(); } + } + + private string? _accessLevel; + /// + /// 等级(精确匹配) + /// + public string? AccessLevel + { + get => _accessLevel; + set { _accessLevel = value; RaisePropertyChanged(); } + } + + /// + /// 等级下拉选项 + /// + public ObservableCollection AccessLevelOptions { get; } + #endregion + + #region 列表与分页 + public ObservableCollection Items { get; } + + private TbUser? _selectedItem; + /// + /// 当前选中行 + /// + public TbUser? SelectedItem + { + get => _selectedItem; + set { _selectedItem = value; RaisePropertyChanged(); } + } + + private bool _isBusy; + /// + /// 是否忙碌 + /// + public bool IsBusy + { + get => _isBusy; + set { _isBusy = value; RaisePropertyChanged(); } + } + + private int _totalCount; + /// + /// 总数 + /// + public int TotalCount + { + get => _totalCount; + set { _totalCount = value; RaisePropertyChanged(); } + } + + private int _pageIndex = 1; + /// + /// 页码(从1开始) + /// + public int PageIndex + { + get => _pageIndex; + set { _pageIndex = value < 1 ? 1 : value; RaisePropertyChanged(); } + } + + private int _pageSize = 20; + /// + /// 页大小 + /// + public int PageSize + { + get => _pageSize; + set + { + var v = value <= 0 ? 20 : value; + if (_pageSize != v) + { + _pageSize = v; + RaisePropertyChanged(); + PageIndex = 1; + if (!IsBusy) _ = SearchAsync(); + } + } + } + + private int _totalPages; + /// + /// 总页数 + /// + public int TotalPages + { + get => _totalPages; + set { _totalPages = value; RaisePropertyChanged(); } + } + #endregion + + #region 命令 + public DelegateCommand SearchCommand { get; } + public DelegateCommand ClearCommand { get; } + public DelegateCommand AddCommand { get; } + public DelegateCommand EditCommand { get; } + public DelegateCommand DeleteCommand { get; } + + public DelegateCommand FirstPageCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + public DelegateCommand LastPageCommand { get; } + #endregion + + private void ClearFilters() + { + UserName = string.Empty; + AccessLevel = AccessLevels.All; + } + + private async Task SearchAsync() + { + if (IsBusy) return; + try + { + IsBusy = true; + await SearchCoreAsync(); + } + catch (Exception ex) + { + _log.Error($"TbUser 查询失败: {ex}"); + MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + private async Task SearchCoreAsync() + { + _log.Info("TbUser 查询开始"); + + var data = await Task.Run(() => + { + var q = _fsql.Select(); + + if (!string.IsNullOrWhiteSpace(UserName)) + q = q.Where(a => a.UserName != null && a.UserName.Contains(UserName)); + + if (!string.IsNullOrWhiteSpace(AccessLevel) + && !string.Equals(AccessLevel, AccessLevels.All, StringComparison.OrdinalIgnoreCase)) + { + q = q.Where(a => a.AccessLevel == AccessLevel); + } + + q = q.OrderByDescending(a => a.Id); + + var page = PageIndex < 1 ? 1 : PageIndex; + var size = PageSize <= 0 ? 20 : PageSize; + + var list = q.Count(out var total) + .Page(page, size) + .ToList(); + + var pages = total <= 0 || size <= 0 ? 0 : (int)Math.Ceiling(total * 1.0 / size); + if (pages > 0 && page > pages) + { + page = pages; + list = q.Page(page, size).ToList(); + } + + return (items: list, total: (int)total, normalizedPage: page, totalPages: pages); + }); + + Application.Current.Dispatcher.Invoke(() => + { + Items.Clear(); + foreach (var it in data.items) Items.Add(it); + TotalCount = data.total; + TotalPages = data.totalPages; + PageIndex = data.normalizedPage == 0 ? 1 : data.normalizedPage; + }); + + _log.Info($"TbUser 查询完成,记录数: {TotalCount}"); + } + + private void AddUser() + { + var p = new DialogParameters + { + { DialogKeys.Mode, DialogModes.Add } + }; + + _dialog.ShowDialog("DialogUserEditView", p, async r => + { + if (r.Result == ButtonResult.OK) + { + PageIndex = 1; + await SearchAsync(); + } + }); + } + + private void EditUser() + { + if (SelectedItem == null) + { + MessageBox.Show("请选择要编辑的用户", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var p = new DialogParameters + { + { DialogKeys.Mode, DialogModes.Edit }, + { DialogKeys.UserId, SelectedItem.Id } + }; + + _dialog.ShowDialog("DialogUserEditView", p, async r => + { + if (r.Result == ButtonResult.OK) + { + await SearchAsync(); + } + }); + } + + private async Task DeleteUserAsync() + { + if (SelectedItem == null) + { + MessageBox.Show("请选择要删除的用户", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (!string.IsNullOrWhiteSpace(_sys.CurUser) + && string.Equals(_sys.CurUser, SelectedItem.UserName, StringComparison.OrdinalIgnoreCase)) + { + MessageBox.Show("不能删除当前登录用户", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var ok = MessageBox.Show($"确认删除用户:{SelectedItem.UserName} ?", "确认", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (ok != MessageBoxResult.Yes) return; + + if (IsBusy) return; + try + { + IsBusy = true; + + var id = SelectedItem.Id; + await Task.Run(() => + { + _fsql.Delete(id).ExecuteAffrows(); + }); + + _log.Info($"删除用户成功,Id={id}"); + SelectedItem = null; + await SearchCoreAsync(); + } + catch (Exception ex) + { + _log.Error($"删除用户失败: {ex}"); + MessageBox.Show($"删除失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + /// + /// 进入页面自动加载 + /// + /// 导航上下文 + public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext) + { + await SearchAsync(); + } + + /// + /// 权限等级常量 + /// + public static class AccessLevels + { + public const string All = "全部"; + public const string Admin = "管理员"; + public const string Operator = "操作员"; + public const string Guest = "访客"; + } + + /// + /// 弹窗参数 Key + /// + public static class DialogKeys + { + public const string Mode = "Mode"; + public const string UserId = "UserId"; + } + + /// + /// 弹窗模式 + /// + public static class DialogModes + { + public const string Add = "Add"; + public const string Edit = "Edit"; + } + } +} diff --git a/FATrace.WPLApp/Views/DashboardView.xaml b/FATrace.WPLApp/Views/DashboardView.xaml index 91a4bd1..28fd451 100644 --- a/FATrace.WPLApp/Views/DashboardView.xaml +++ b/FATrace.WPLApp/Views/DashboardView.xaml @@ -1,125 +1,836 @@ - + - - - + + + - + - - - - - + + + + + - + - - + + - + - + - - + + - + - + - - + + - + - + - - + + - + - + - - + + - - + + - + - + - - + + - - + + - + - + - - - + + + - - + + - - - + + + + + + + + + + + + + + + + + diff --git a/FATrace.WPLApp/Views/DialogUserEditView.xaml b/FATrace.WPLApp/Views/DialogUserEditView.xaml new file mode 100644 index 0000000..b0331b0 --- /dev/null +++ b/FATrace.WPLApp/Views/DialogUserEditView.xaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FATrace.WPLApp/Views/MainView.xaml b/FATrace.WPLApp/Views/MainView.xaml index 1f7cb44..cc5ee6a 100644 --- a/FATrace.WPLApp/Views/MainView.xaml +++ b/FATrace.WPLApp/Views/MainView.xaml @@ -23,9 +23,8 @@ - - + @@ -39,6 +38,12 @@ + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - + + + + + + + + + + - - + + - + - + - + - + - + - + - + - + - + - + + + ItemsSource="{Binding Items}" + RowHeight="34"> - - + diff --git a/FATrace.WPLApp/Views/ThemedDialogWindow.xaml b/FATrace.WPLApp/Views/ThemedDialogWindow.xaml new file mode 100644 index 0000000..e181fc1 --- /dev/null +++ b/FATrace.WPLApp/Views/ThemedDialogWindow.xaml @@ -0,0 +1,19 @@ + + + diff --git a/FATrace.WPLApp/Views/ThemedDialogWindow.xaml.cs b/FATrace.WPLApp/Views/ThemedDialogWindow.xaml.cs new file mode 100644 index 0000000..a2ff2e5 --- /dev/null +++ b/FATrace.WPLApp/Views/ThemedDialogWindow.xaml.cs @@ -0,0 +1,24 @@ +using Prism.Services.Dialogs; +using Syncfusion.Windows.Shared; + +namespace FATrace.WPLApp.Views +{ + /// + /// Prism 弹窗承载窗口(使用 Syncfusion ChromelessWindow 统一主题样式) + /// + public partial class ThemedDialogWindow : ChromelessWindow, IDialogWindow + { + /// + /// 构造 + /// + public ThemedDialogWindow() + { + InitializeComponent(); + } + + /// + /// 弹窗返回结果(由 Prism DialogService 写入) + /// + public IDialogResult Result { get; set; } + } +} diff --git a/FATrace.WPLApp/Views/UserManageView.xaml b/FATrace.WPLApp/Views/UserManageView.xaml new file mode 100644 index 0000000..dd2f4e4 --- /dev/null +++ b/FATrace.WPLApp/Views/UserManageView.xaml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +