diff --git a/FATrace.App/HelpManual/SOPManual.md b/FATrace.App/HelpManual/SOPManual.md
new file mode 100644
index 0000000..bf9bff3
--- /dev/null
+++ b/FATrace.App/HelpManual/SOPManual.md
@@ -0,0 +1,360 @@
+ # FATrace.App 用户操作手册(SOP)
+
+ ## 1. 文档说明
+
+ - **适用范围**
+ 称重客户端,用于原料入库、称重打印、消耗记录入库与历史数据查询。
+ - **目标读者**
+ 产线操作员、确认者/班组长、IT 运维。
+ - **术语约定**
+ - **入库重量/剩余重量**:单位为 **Kg**。
+ - **称重重量**:单位为 **g**。
+ - **内袋二维码**:打印在内袋标签上的二维码内容(系统生成)。
+
+ ## 2. 系统概述
+
+ 称重客户端用于在产线对特定原料进行:
+
+ - **原料入库建档**:录入批号、保质期、入库重量(Kg),形成一条“分拆中”的原料记录。
+
+ - **实时称重 + 打印内袋二维码**:从称重仪表获取稳定重量(g),生成二维码并调用斑马打印机打印标签。
+
+ - **消耗记录入库**:每次打印成功后写入使用记录,并更新该原料剩余重量。
+
+ - **历史数据查询**:按日期范围、原料名称/编码关键字、批号查询消耗明细。
+
+ 
+
+ ## 3. 运行环境与依赖
+
+ - **操作系统**
+ Windows 10/11。
+
+ - **网络与设备**
+
+ - SQL Server 数据库(称重服务器)
+ - 称重仪表
+ - 斑马打印机
+
+
+
+ ## 4. 安装与部署(IT/工程师运维)
+
+ ### 4.1 部署方式
+
+ - **推荐**:使用发布后的程序目录(含可执行文件、依赖 DLL、`App.config`、`NLog.config` 等)。
+ - **注意**:首次部署时确保程序目录对当前登录用户具有读写权限(日志目录会写入到程序目录下)。
+
+ ### 4.2 单实例程序限制
+
+ **==程序启动时会检测是否已运行;若已运行会提示“程序已运行,不能再次打开!”。==**
+
+ ## 5. 配置说明(必须掌握)
+
+ ### 5.1 数据库连接(App.config)
+
+ 文件:`FATrace.App/App.config`
+
+ 系统实际使用的连接键为:`connecting`。
+
+ - **connecting**:FATrace 主库(SQL Server)连接字符串
+
+ **修改建议**
+
+ - **[服务器地址]**:`Data Source=...`
+ - **[数据库名]**:`Initial Catalog=...`
+ - **[账号密码]**:`User ID=...;Password=...`
+ - 如启用证书校验,需按现场策略配置 `TrustServerCertificate` 等参数。
+
+ ### 5.2 PLC 配置(App.config)
+
+ `PLCIP / PLCPort / PLCScan` 为保留配置项;若现场版本启用 PLC 通信,请由 IT 运维按实际设备信息配置。
+
+ ### 5.3 打印机与称重仪表配置(当前版本说明)
+
+ 当前版本中:
+
+ - 打印机 IP/端口:在主窗体中默认值为 `_printerIp=192.0.1.21`、`_printerPort=9100`
+ - 称重仪表 IP/端口:在主窗体加载时使用示例值 `192.168.0.80:10251`
+
+ 如果现场设备地址不同:
+
+ - **建议由开发/IT 运维修改为配置化**(后续可以将 IP/Port 放入 `App.config` 并提供配置界面)。
+ - 在未配置化前,需更新代码并重新发布程序。
+
+ ### 5.4 日志配置(NLog.config)
+
+ 文件:`FATrace.App/NLog.config`
+
+ - **错误日志**:`${basedir}/Log/${shortdate}/ErrorMessage.txt`
+ - **操作日志**:`${basedir}/OperationLogs/${shortdate}/OperationLog.txt`
+ - **保留天数**:默认 `maxArchiveDays=60`
+
+ ## 6. 界面结构与功能入口
+
+ 主界面由“左侧导航栏 + 右侧 Tab 页面”组成。
+
+
+
+ ### 6.1 左侧导航栏
+
+ - **主界面**:进入生产主页面(需要已登录)
+ - **历史数据**:进入历史查询页面
+ - **用户登录**:进入登录页面
+ - **退出**:关闭程序
+
+ ### 6.2 右侧页面
+
+ - **生产主页面**:原料选择、入库信息录入、实时称重显示、打印与剩余重量显示
+
+ 
+
+ - **用户登录页面**:确认者/操作者登录
+
+ 
+
+ - **历史数据页面**:按条件查询并显示 `RawProUse` 历史记录
+
+ 
+
+ ### 6.3 状态栏(底部)
+
+
+
+ - **用户**:显示当前确认者用户
+ - **称重状态**:称重仪表连接/通信状态
+ - **服务器通信状态**:数据库心跳状态
+ - **打印机状态**:打印机在线/离线状态
+
+ ## 7. 角色与权限(业务规则)
+
+ ### 7.1 登录角色
+
+ - **确认者用户**:用于确认生产记录的责任人(系统登录校验账号)
+ - **操作者用户**:用于记录实际操作人(当前版本为文本输入,不做账号校验)
+
+ ### 7.2 用户来源
+
+ 系统登录会在数据库表 `TbUser` 中按 `UserName` 查询并校验密码。
+
+ - **账号不存在**:提示“当前用户不存在!”
+ - **密码错误**:提示“密码错误!”
+ - **登录成功**:提示“登录成功!”并切换到主界面
+
+ ## 8. 日常操作 SOP(产线操作员)
+
+ ### 8.1 开机前检查(每日/每班)
+
+> **==图片示例:称重连接失败,打印机未正常连接==**
+>
+> 
+
+ - **[数据库连接]**
+ 状态栏“服务器通信状态”应显示“服务器正常”。
+
+ - **[称重仪表]**
+ 状态栏“称重状态”应显示“称重已连接/称重通讯正常”。
+
+ - **[打印机]**
+ 状态栏“打印机状态”应显示“打印机在线”。
+
+ - **[标签耗材]**
+ 确认打印机纸张/碳带充足,标签尺寸与模板匹配。
+
+
+
+ ***==确保4个设备连接正常(绿色展示)==***
+
+
+
+ ### 8.2 登录(必须)
+
+ 1. 点击左侧 **用户登录**。
+
+ 2. 输入:
+ - 确认者用户名
+ - 操作者用户名
+ - 密码(校验确认者账号)
+
+ 3. 点击 **登录**。
+
+ 4. 登录成功后进入主界面;状态栏显示“确认者用户:xxx”。
+
+ 
+
+ ### 8.3 选择原料(开始生产前)
+
+ 1. 在主页面左上区域点击原料按钮。
+
+ 
+
+ ***注意:***
+
+ **==在拆包分小包之前要确认当前是哪种原料,您可以点击对应的添加剂进行选中操作==**
+
+ **==在你选中后一个原料后,系统会根据选中原料查询之前是否有大包未使用完毕的添加剂,如果有的话,直接在之前未使用完毕的原料上继续分拆操作,可以开始称重操作==**
+
+ **==否则您需要新填写一个原料信息,如下:==**
+
+ 
+
+ **==上面信息填写完毕后,请点击【确认】按钮,代表一个新的原料开始要拆分使用了==**
+
+ **==如果当前原料还剩余很多但是因为一些其他的原因不能再使用了,需要使用另一包开始拆分使用,那么您可以点击【清零当前原料】清空当前原料信息,然后进行新原料的输入操作==**
+
+
+
+ 2. 系统会检查该原料是否存在“分拆中”的记录:
+ - **存在**:自动加载该原料的入库重量、批号、保质期、剩余重量。
+ - **不存在**:视为新原料,需进行入库信息录入。
+
+ **重要规则**
+
+ - **同一原料在未分拆完成前,系统优先使用最新一条“分拆中”记录。**
+
+ ### 8.4 原料入库建档(新原料/新批次)
+
+ 适用场景:该原料首次使用,或上一批次已分拆完成,需要建立新的入库记录。
+
+ 1. 在“入库信息”区域录入:
+ - **入库重量(Kg)**:必须为正数
+ - **批号**:不能为空、长度不超过 20
+ - **保质期(月)**:必须为正整数
+ 2. 点击 **确认**。
+ 3. 保存成功后:
+ - 系统写入保存(状态为 Splitting/分拆中)
+ - 剩余重量初始化为入库重量
+
+ ### 8.5 实时称重检查
+
+ - “实时重量”文本框会显示称重仪表的稳定重量值。
+ - 若实时重量长时间不更新:优先检查称重状态、网线、仪表 IP/端口配置是否正确。
+
+ ### 8.6 称重打印(核心流程)
+
+ 适用场景:每次从大包装分装到小包装/内袋时。
+
+ 1. 确认已选择原料且已录入批号/保质期/原料编码。
+ 2. 确认称重仪表显示稳定重量(g)。
+ 3. 点击 **打印**。
+ 4. 系统弹窗确认:选择 OK 执行,Cancel 取消。
+ 5. 打印成功后系统将自动:
+ - 生成内袋二维码(包含原料编码、批号、重量、保质期、产地编码、日产量序号等信息)
+ - 写入称重使用记录到服务器
+ - 更新该原料的剩余重量信息
+
+ **重量与单位说明**
+
+ - 实时称重 ``:g
+ - 剩余重量 ``:Kg
+ - 系统计算:`新剩余(Kg) = 旧剩余(Kg) - 称重(g)/1000`
+
+ ### 8.7 剩余重量不足/分拆完成处理
+
+ ==**打印完成后系统会做剩余重量检查:**==
+
+ - ==**当剩余重量非常接近 0 或小于本次称重折算值时:**==
+ - ==**系统会提示并将该原料记录置为“分拆完成”**==
+ - ==**清空当前选中原料与输入框**==
+ - ==**操作员应按现场实际将大包装清空并更换下一批/下一包**==
+
+
+
+ ### 8.8 手动清零(异常处理/强制结束)
+
+ 如果现场需要强制结束当前原料(例如称重异常、原料报废/更换、需要重新建档):
+
+ 1. 点击 **清零当前的原料**。
+
+ 2. 系统将把当前原料使用状态更新为“分拆完成”。
+
+ 3. 返回后可重新选择原料并重新建档。
+
+ 
+
+
+
+### 8.9 用户的管理(在工作站上操作)
+
+因为当前是工控机是触控电脑,没有鼠标,不方便输入一些姓名和文字等信息,所以把当前的确认者和操作者的一些增加、修改、删除等操作放到工作站上进行管理,具体操作说明请见服务器侧【UserManual.pdf】
+
+
+
+ ## 9. 历史数据查询 SOP(班组长/质检/追溯)
+
+ ### 9.1 进入历史数据页面
+
+ 点击左侧 **历史数据**。
+
+ ### 9.2 查询条件说明
+
+ - **开始时间/结束时间**:按日期选择,系统按整日(00:00:00 至 23:59:59)进行查询
+ - **原料名称**:下拉可选,也可手工输入;支持按原料名称或原料编码关键字模糊匹配
+ - **批号**:精确匹配(输入完整批号)
+
+ ### 9.3 执行查询
+
+
+
+ 点击 **查询**,结果显示在表格中。
+
+ 列头已转换为中文(如:原料编号、称重重量(g)、称重时间、操作员、确认者、出库时间等)。
+
+ ## 10. 数据口径与数据库表说明(IT/工程师追溯必读)
+
+ - **RawProInput(原料入库/分拆主表)**
+ - 记录一包/一批原料的入库重量与剩余重量
+ - 状态:Splitting(分拆中)/SplitComplete(分拆完成)
+ - **RawProUse(称重使用明细)**
+ - 每次打印产生一条记录
+ - 字段包括:原料信息、内袋二维码、称重重量、剩余重量、称重时间、操作员/确认者等
+ - **DayCount(日计数)**
+ - 用于生成二维码中的日产量序号
+ - **TbUser(用户表)**
+ - 登录认证来源(用户名/密码/权限等级)
+
+ ## 11. 日志与问题排查(IT 运维)
+
+ ### 11.1 日志位置
+
+ - **错误日志**:`程序目录/Log/日期/ErrorMessage.txt`
+ - **操作日志**:`程序目录/OperationLogs/日期/OperationLog.txt`
+
+ ### 11.2 常见异常与处理
+
+ - **[服务器异常]**
+ - 现象:状态栏“服务器异常”,或查询/保存提示失败
+ - 排查:检查 `App.config` 连接字符串、SQL Server 服务状态、网络连通性、防火墙
+ - **[称重未连接/不更新]**
+ - 现象:实时重量不刷新,状态栏显示未连接
+ - 排查:检查称重仪表 IP/端口、仪表是否开启 TCP Server、网络是否通、现场交换机端口是否隔离
+ - **[打印机离线/打印失败]**
+ - 现象:状态栏提示离线,或弹窗“打印失败”
+ - 排查:检查打印机 IP、网络、耗材、9100端口是否被策略阻断;必要时用 Ping 验证可达
+ - **[重复启动失败]**
+ - 现象:提示“程序已运行,不能再次打开!”
+ - 处理:在任务管理器中结束旧进程或联系 IT 排查是否有后台残留
+
+ ## 12. 维护与数据安全建议
+
+ - **[数据库备份]**
+ 建议 SQL Server 设置每日自动备份,并保留至少 30 天。
+ - **[日志清理]**
+ NLog 默认保留 60 天,若磁盘压力大可调整 `maxArchiveDays`。
+ - **[时间同步]**
+ 生产追溯依赖时间准确性,建议电脑加入域或配置 NTP 同步。
+ - **[网络稳定]**
+ 称重与打印均依赖局域网;建议独立 VLAN/交换机端口,避免办公网高峰抖动。
+
+ ## 13. 常见问题(FAQ)
+
+ - **Q:为什么重量显示有时不是两位小数?**
+ - A:称重来源可能为浮点值;显示建议使用固定格式(如 `00.00`)。存库建议按业务要求统一四舍五入后再保存。
+ - **Q:打印出来二维码内容是什么?**
+ - A:二维码包含原料编码、批号、重量(固定格式去掉小数点)、保质期、产地编码与日产量序号,用于后续追溯。
+ - **Q:电脑如何开机**
+ - A:**==工控机通电后自动开机==,关掉后可以断电,请不要在运行时直接断电,可能会导致工控机损坏**
+
+ ## 14. 版本记录
+
+ - **V1.0**:初版 SOP(安装/配置/登录/入库/称重打印/历史查询/日志排查)
diff --git a/FATrace.App/HelpManual/称重操作 SOP手册.pdf b/FATrace.App/HelpManual/称重操作 SOP手册.pdf
new file mode 100644
index 0000000..8422b1c
Binary files /dev/null and b/FATrace.App/HelpManual/称重操作 SOP手册.pdf differ
diff --git a/FATrace.App/frmMain.Designer.cs b/FATrace.App/frmMain.Designer.cs
index e8f78df..f715ea5 100644
--- a/FATrace.App/frmMain.Designer.cs
+++ b/FATrace.App/frmMain.Designer.cs
@@ -83,6 +83,8 @@ namespace FATrace.App
label2 = new Label();
label1 = new Label();
tabPage3 = new TabPage();
+ txtOpName = new TextBox();
+ label28 = new Label();
btnLogin = new Button();
txtPassword = new TextBox();
txtCheckUserName = new TextBox();
@@ -103,8 +105,6 @@ namespace FATrace.App
dtpSearchStartTime = new DateTimePicker();
dataGridView1 = new DataGridView();
label22 = new Label();
- txtOpName = new TextBox();
- label28 = new Label();
statusStrip1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit();
splitContainer1.Panel1.SuspendLayout();
@@ -245,6 +245,7 @@ namespace FATrace.App
label20.Size = new Size(74, 22);
label20.TabIndex = 6;
label20.Text = "系统配置";
+ label20.Visible = false;
//
// label19
//
@@ -266,6 +267,7 @@ namespace FATrace.App
btnSysConfig.Size = new Size(64, 62);
btnSysConfig.TabIndex = 4;
btnSysConfig.UseVisualStyleBackColor = true;
+ btnSysConfig.Visible = false;
btnSysConfig.Click += btnSysConfig_Click;
//
// bntFrmLogin
@@ -390,7 +392,6 @@ namespace FATrace.App
txtCode.ReadOnly = true;
txtCode.Size = new Size(410, 33);
txtCode.TabIndex = 16;
- txtCode.Text = "YG05030013,20250923,1802,3,01,0001";
//
// label13
//
@@ -707,7 +708,7 @@ namespace FATrace.App
btnRawName1.Name = "btnRawName1";
btnRawName1.Size = new Size(266, 48);
btnRawName1.TabIndex = 1;
- btnRawName1.Text = "DYG05030013 瑞士乳杆菌GCL1815";
+ btnRawName1.Text = "YG05030013 瑞士乳杆菌GCL1815";
btnRawName1.UseVisualStyleBackColor = true;
btnRawName1.Click += btnRawName1_Click;
//
@@ -752,6 +753,24 @@ namespace FATrace.App
tabPage3.TabIndex = 2;
tabPage3.UseVisualStyleBackColor = true;
//
+ // txtOpName
+ //
+ txtOpName.Font = new Font("微软雅黑", 16F);
+ txtOpName.Location = new Point(527, 218);
+ txtOpName.Name = "txtOpName";
+ txtOpName.Size = new Size(97, 36);
+ txtOpName.TabIndex = 9;
+ //
+ // label28
+ //
+ label28.AutoSize = true;
+ label28.Font = new Font("微软雅黑", 16F);
+ label28.Location = new Point(439, 219);
+ label28.Name = "label28";
+ label28.Size = new Size(84, 30);
+ label28.TabIndex = 8;
+ label28.Text = "操作者:";
+ //
// btnLogin
//
btnLogin.BackColor = Color.SandyBrown;
@@ -961,24 +980,6 @@ namespace FATrace.App
label22.Text = "历史数据";
label22.TextAlign = ContentAlignment.MiddleCenter;
//
- // txtOpName
- //
- txtOpName.Font = new Font("微软雅黑", 16F);
- txtOpName.Location = new Point(527, 218);
- txtOpName.Name = "txtOpName";
- txtOpName.Size = new Size(97, 36);
- txtOpName.TabIndex = 9;
- //
- // label28
- //
- label28.AutoSize = true;
- label28.Font = new Font("微软雅黑", 16F);
- label28.Location = new Point(439, 219);
- label28.Name = "label28";
- label28.Size = new Size(84, 30);
- label28.TabIndex = 8;
- label28.Text = "操作者:";
- //
// frmMain
//
AutoScaleDimensions = new SizeF(7F, 17F);
diff --git a/FATrace.App/frmMain.cs b/FATrace.App/frmMain.cs
index eec39f9..e40f68b 100644
--- a/FATrace.App/frmMain.cs
+++ b/FATrace.App/frmMain.cs
@@ -227,9 +227,9 @@ namespace FATrace.App
{
new RawCtrInfo(){
RawName="瑞士乳杆菌GCL1815",
- RawCode="DYG05030013",
+ RawCode="YG05030013",
BtnControlName="btnRawName1",
- RawSource=RawSource.China
+ RawSource=RawSource.Japan
},
new RawCtrInfo(){
RawName="抗性糊精",
@@ -348,7 +348,7 @@ namespace FATrace.App
///
/// 当前重量数据
///
- private double CurWeight { get; set; } = 80.8;
+ private double CurWeight { get; set; } = 0;
/////
///// 当前剩余数据
@@ -662,9 +662,27 @@ namespace FATrace.App
return;
}
+ //确认数据
+ // 显示消息框,并等待用户响应
+ DialogResult result = MessageBox.Show("确定要【打印】操作吗?", "确认操作", MessageBoxButtons.OKCancel);
+ if (result==DialogResult.Cancel)
+ {
+ return;
+ }
+ if (CurWeight<2.0)
+ {
+ //确认数据
+ // 显示消息框,并等待用户响应
+ DialogResult resultWeightCheck = MessageBox.Show("检测到当前的重量小于2g,确定要【打印】操作吗?", "确认操作", MessageBoxButtons.OKCancel);
+ if (resultWeightCheck == DialogResult.Cancel)
+ {
+ return;
+ }
+ }
+
//新的剩余重量 Kg
- var NewRemainWeight = CurSelectedRawProInput.RemainWeight - CurWeight / 1000;
+ var NewRemainWeight = CurSelectedRawProInput.RemainWeight - CurWeight / 1000.0;
//当前产品的剩余重量
txtRemainWeight.Text = NewRemainWeight.ToString();
@@ -767,6 +785,16 @@ namespace FATrace.App
}
}
+ ///
+ /// 格式化重量显示,整数部分至少2位(不足补0),小数部分固定2位
+ ///
+ /// 原始重量值
+ /// 格式化后的重量字符串(如:09.50, 81.10, 100.00)
+ private string FormatWeight(double weight)
+ {
+ return weight.ToString("00.00");
+ }
+
///
/// 获取当前的日产量信息
///
@@ -849,7 +877,7 @@ namespace FATrace.App
Code.Append(',');
Code.Append(Batch);
Code.Append(',');
- Code.Append(Weight.ToString().Replace(".", ""));
+ Code.Append(FormatWeight(Weight).ToString().Replace(".", ""));
Code.Append(',');
Code.Append(ShelfLife.ToString());
Code.Append(',');
@@ -950,17 +978,20 @@ namespace FATrace.App
return;
}
- var ListUser = FSqlContext.FDb.Select().Where(a => a.UserName == txtCheckUserName.Text.Trim()).ToList();
+ var ListUser = FSqlContext.FDb.Select()
+ .Where(a => a.CheckName == txtCheckUserName.Text.Trim() && a.OpName == txtOpName.Text.Trim())
+ .ToList();
if (ListUser != null && ListUser.Count() > 0)
{
if (ListUser.FirstOrDefault().Password == txtPassword.Text.Trim())
{
MessageBox.Show("登录成功!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
- Main_PopUserNameEvent(txtCheckUserName.Text.Trim(), txtOpName.Text.Trim(), ListUser.FirstOrDefault().AccessLevel);
+ Main_PopUserNameEvent(txtCheckUserName.Text.Trim(), txtOpName.Text.Trim(), "称重用户");
this.TabControlMain.SelectedIndex = 0;
txtCheckUserName.Text = "";
+ txtOpName.Text = "";
txtPassword.Text = "";
//PopUserNameEvent(txtUserName.Text.Trim());
//this.Close();
diff --git a/FATrace.Model/FileModel/FactoryInventoryTransaction.cs b/FATrace.Model/FileModel/FactoryInventoryTransaction.cs
index d539bb4..bd7fe8b 100644
--- a/FATrace.Model/FileModel/FactoryInventoryTransaction.cs
+++ b/FATrace.Model/FileModel/FactoryInventoryTransaction.cs
@@ -20,7 +20,7 @@ namespace FATrace.Model
public string? InTime { get; set; }
///
- /// 出库时间
+ /// 领料时间
///
public string? OutTime { get; set; }
@@ -45,7 +45,7 @@ namespace FATrace.Model
public string? TotalInWeightKg { get; set; }
///
- /// 出库总重量 KG
+ /// 领料总重量 KG
///
public string? TotalOutWeightKg { get; set; }
diff --git a/FATrace.Model/FileModel/OEMInventoryTransaction.cs b/FATrace.Model/FileModel/OEMInventoryTransaction.cs
index ab1481e..be3b9b0 100644
--- a/FATrace.Model/FileModel/OEMInventoryTransaction.cs
+++ b/FATrace.Model/FileModel/OEMInventoryTransaction.cs
@@ -29,6 +29,11 @@ namespace FATrace.Model
///
public string? Origin { get; set; }
+ ///
+ /// 批号
+ ///
+ public string? Batch { get; set; }
+
///
/// 原料代码
///
diff --git a/FATrace.Model/TbUser.cs b/FATrace.Model/TbUser.cs
index bbe8ca2..bb530d5 100644
--- a/FATrace.Model/TbUser.cs
+++ b/FATrace.Model/TbUser.cs
@@ -22,7 +22,7 @@ namespace FATrace.Model
/////
///// 机器名称
/////
- //[Column(Name = "MachineName", IsNullable = false, StringLength = 20)]
+ //[Column(Name = "MachineName", IsNullable = true, StringLength = 20)]
//public string MachineName { get; set; }
///
diff --git a/FATrace.Model/TbWeightUser.cs b/FATrace.Model/TbWeightUser.cs
new file mode 100644
index 0000000..b9e06a8
--- /dev/null
+++ b/FATrace.Model/TbWeightUser.cs
@@ -0,0 +1,46 @@
+using FreeSql.DataAnnotations;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace FATrace.Model
+{
+ ///
+ /// 称重用户登录
+ ///
+ [Table(Name = "dbo.TbWeightUser")]
+ public class TbWeightUser
+ {
+ ///
+ /// 主键
+ ///
+ [Column(IsPrimary = true, IsIdentity = true)]
+ public long Id { get; set; }
+
+ ///
+ /// 确认者
+ ///
+ [Column(Name = "CheckName", IsNullable = false, StringLength = 20)]
+ public string CheckName { get; set; }
+
+ ///
+ /// 操作者
+ ///
+ [Column(Name = "OpName", IsNullable = false, StringLength = 20)]
+ public string OpName { get; set; }
+
+ ///
+ /// 密码
+ ///
+ [Column(Name = "Password", IsNullable = false, StringLength = 20)]
+ public string Password { get; set; }
+
+ ///
+ /// 创建时间
+ ///
+ [Column(ServerTime = DateTimeKind.Local, CanUpdate = false)]
+ public DateTime CreateTime { get; set; }
+ }
+}
diff --git a/FATrace.OEMApp/HelpManual/SOPManual.md b/FATrace.OEMApp/HelpManual/SOPManual.md
new file mode 100644
index 0000000..bad6603
--- /dev/null
+++ b/FATrace.OEMApp/HelpManual/SOPManual.md
@@ -0,0 +1,355 @@
+# FATrace.OEMApp 软件使用手册(SOP)
+
+## 1. 文档说明
+
+### 1.1 适用范围
+
+- 本手册适用于 FATrace.OEMApp(WinForms 客户端)。
+- 该客户端用于:接收“内袋二维码/条码”触发事件,调用海康 NVR 按时间段下载录像文件,保存到本地/NAS,并将视频文件与条码信息写入数据库,提供历史查询与播放。
+
+### 1.2 目标读者
+
+- 产线操作人员:负责扫码触发、查看任务状态、核对视频保存结果。
+- 追溯/质量人员:负责历史查询与视频回放。
+- IT 运维:负责安装部署、网络与设备连通性、配置项维护、日志排查与数据清理策略。
+
+### 1.3 术语与口径
+
+- NVR:海康网络录像机(通过海康 SDK 登录并下载录像)。
+- 内袋二维码/条码:来自产线/外部系统的条码字符串;系统会解析出原料编号、批号、重量、保质期、产地、日产量等字段(解析规则见第 8 章)。
+- 下载任务:系统将一次“回溯录像下载请求”持久化为 `DownloadTask`,并由后台队列顺序执行。
+- 历史记录:数据库表 `OEMRawUse`,用于追溯条码对应的视频文件路径、名称与创建时间等。
+- NAS:本系统将视频保存路径配置为网络盘/共享盘(例如 `Y:`),用于长期留存。
+
+## 2. 系统概述
+
+FATrace.OEMApp 的核心流程为:
+
+1. 接收条码(PLC 扫码或 TCP 客户端发送);
+2. 解析条码得到业务字段;
+3. 生成下载任务 `DownloadTask` 并入队;
+4. 后台 `DownloadTaskWorker` 按顺序执行:
+ - 根据配置的 NVR 账号登录状态,从 NVR 按时间范围下载录像
+ - 将视频保存到 `NVRVideoSavePath` 指定目录(支持盘符/网络盘)
+ - 下载成功后写入 `OEMRawUse`(视频文件路径、视频名、条码信息等)
+ - 可选:生成 CSV(`RawUseCsvPath`)
+5. 历史页可查询 `OEMRawUse` 并双击播放视频。
+
+## 3. 运行环境与依赖
+
+### 3.1 操作系统
+
+- Windows 10/11。
+
+### 3.2 网络与外设
+
+- SQL Server 数据库(用于存储任务与追溯索引)。
+- 海康 NVR(IP/端口/账号/密码在配置中)。
+- 视频保存目录(本地磁盘或 NAS 网络盘,如 `Y:`)。
+-(可选)PLC/基恩士:用于从寄存器读取扫码结果。
+-(可选)TCP 客户端(如 PDA/中间件):向 OEMApp 内置 TCP Server 发送扫码文本。
+
+### 3.3 关键组件(实现层面)
+
+- 数据库:FreeSql + SQL Server Provider(自动同步表结构已启用)。
+- 海康 SDK:CHCNetSDK(程序启动后输出 SDK 日志到 `C:\SdkLog\`)。
+- 视频播放:LibVLCSharp(VideoView)。
+- TCP Server:TouchSocket(多客户端)。
+- PLC 通信:HslCommunication(KeyenceMcNet)。
+- 日志:NLog(错误日志 + 操作日志)。
+- CSV:CsvHelper(导出单条 CSV)。
+-(可选)Jellyfin:RestSharp 客户端与监控服务(当前主流程标记为停用)。
+
+## 4. 安装与部署(IT 运维)
+
+### 4.1 部署方式
+
+- 使用发布后的程序目录(含 exe、dll、`App.config`、`NLog.config`、VideoLAN.LibVLC 等依赖)。
+- 建议将程序目录放置在非系统盘且具备写权限的位置(日志目录在程序目录下生成)。
+
+### 4.2 单实例限制
+
+- 程序启动会检测单实例:若已运行会提示“程序已运行,不能再次打开!”。
+
+### 4.3 授权提示
+
+- 程序启动会进行授权校验;失败时提示“授权失败!当前程序只能使用8小时!”。
+
+### 4.4 首次启动检查清单
+
+- 数据库连通性:确认 `App.config` 中 `connecting` 正确,且 SQL Server 可访问。
+- NVR 可达:确认 `NVRIP` / `NVRPort` 可 ping 通,端口可达。
+- 视频保存盘符:确认 `NVRVideoSavePath` 盘符存在且可写(如为网络盘需先映射)。
+- SDK 日志目录:`C:\SdkLog\` 可创建且可写(建议提前创建目录并赋权)。
+
+## 5. 配置说明(App.config)
+
+文件位置:`FATrace.OEMApp/App.config`
+
+### 5.1 数据库连接
+
+- `connecting`:SQL Server 主库连接字符串(OEMApp 实际使用该键)。
+- `connecting1` / `RemoteConnecting`:保留项,是否使用取决于现场版本。
+
+### 5.2 PLC 配置
+
+- `PLCIP`:PLC IP
+- `PLCPort`:PLC 端口
+- `PLCScan`:扫描周期(毫秒),默认 600
+- `PDAScanCode`:PLC 内保存扫码字符串的寄存器地址(例如 `D1000`)
+
+### 5.3 TCP Server(外部扫码接入)
+
+- `TcpServerIp`:OEMApp 内置 TCP Server 监听 IP
+- `TcpServerPort`:OEMApp 内置 TCP Server 监听端口
+
+### 5.4 下载任务与回溯时长
+
+- `VideoTime`:下载时长(秒),默认 30。系统会下载 `[NvrStartTime, NvrEndTime]` 时间窗。
+- `DownloadTaskMaxRetry`:失败重试次数上限(程序重启后会将未超过次数的 Failed 重置为 Pending)。
+- `DownloadTaskTimeoutSeconds`:单任务下载超时时间(秒)。若未配置,系统会按“时间窗*3”并限制到 `[120,1800]` 计算。
+
+### 5.5 NVR 配置
+
+- `NVRIP`:NVR IP
+- `NVRPort`:NVR 端口(通常 8000)
+- `NVRUserName`:NVR 登录用户名
+- `NVRPw`:NVR 登录密码
+- `NVRVideoSavePath`:视频保存根路径(支持本地盘符或网络盘符,如 `Y:`)
+
+### 5.6 数据清理策略
+
+- `VideoFileSaveDay`:视频文件保留天数(用于清理 DownloadTask 关联的视频文件)
+- `DbSaveDay`:数据库记录保留天数(用于清理 DownloadTask/OEMRawUse/VideoAction 等旧记录)
+- `DataCleanupEnabled`:是否启用定时清理(默认 true)
+- `DataCleanupTimeOfDay`:每日执行时间(默认 02:00:00)
+
+### 5.7 CSV 导出
+
+- `RawUseCsvPath`:CSV 保存目录(如 `D:\TestData`)
+
+### 5.8 Jellyfin(可选/当前停用)
+
+- `JellyfinBaseUrl` / `JellyfinApiKey` / `JellyfinParentId` / `JellyfinPollIntervalMs` / `JellyfinPollMaxWaitMs`
+
+## 6. 日志与目录说明
+
+### 6.1 NLog 日志
+
+文件:`FATrace.OEMApp/NLog.config`
+
+- 错误日志:`程序目录\Log\yyyy-MM-dd\ErrorMessage.txt`
+- 操作日志:`程序目录\OperationLogs\yyyy-MM-dd\OperationLog.txt`
+- 默认保留 60 天(`maxArchiveDays=60`)
+
+### 6.2 海康 SDK 日志
+
+- 程序启动时启用 SDK 日志输出到:`C:\SdkLog\`
+- 建议 IT 运维定期清理该目录,避免长期占用磁盘。
+
+## 7. 界面结构与功能入口
+
+### 7.1 左侧导航栏
+
+- 主界面:显示扫码信息、任务队列状态、日志列表、CSV 保存状态等。
+- 历史记录:查询 `OEMRawUse` 并播放对应视频。
+- 设置:预留页(当前可用于放置 NVR 登录、下载进度等相关操作)。
+
+### 7.2 顶部/底部状态栏
+
+- Plc连接状态:来自 `PLCDataService`(若未启用 PLC,此状态可能不准确或保持默认)。
+- 服务连接状态:数据库连通性检测(`SELECT 1`)。
+- NAS连接状态:检测 `NVRVideoSavePath` 目录是否存在。
+- NVR连接状态:检测 NVR 登录态与在线状态。
+
+## 8. 条码格式与解析规则(重要)
+
+系统条码解析由 `FATrace.Com.NVRCom.ParseCodeFull(code)` 实现。
+
+### 8.1 支持的分隔符
+
+- 支持英文逗号 `,`
+- 支持中文逗号 `,`
+- 支持分号 `;` / `;`
+- 支持空格分隔
+
+### 8.2 推荐条码字段结构(按顺序)
+
+1. RawCode:原料编号
+2. Batch:批号(通常为 8 位日期 `yyyyMMdd`;系统会提取前 8 位数字)
+3. Weight:重量(3/4 位数字,最后 1 位为小数位,例如 `802 => 80.2`)
+4. ShelfLifeMonths:保质期(月)
+5. RegionCode:产地编码(`01=国内`,`02=日本`,其他=未知)
+6. Count:日产量序号
+
+### 8.3 视频文件命名规则
+
+- 视频文件路径:`NVRVideoSavePath\{Code}.mp4`(会清洗非法文件名字符)
+- 视频名称:`{Code}.mp4`
+
+## 9. 日常操作 SOP(产线/追溯人员)
+
+### 9.1 开机前检查(每日/每班)
+
+1. 启动 OEMApp。
+2. 查看底部状态栏:
+ - 服务连接状态:应为“正常”
+ - NAS连接状态:应为“正常”(表示视频保存目录存在)
+ - NVR连接状态:应为“正常”(表示 NVR 已登录且在线)
+3. 若异常,按第 12 章排查。
+
+### 9.2 NVR 登录
+
+- 主界面启动时会自动调用登录(NVRLogin),也可使用“设置页/登录按钮”手动登录(btnNVRLogin)。
+- 登录成功会弹窗“登录成功”。
+- 登录失败会弹窗“登录失败:{错误信息}”。
+
+### 9.3 扫码触发(两种方式)
+
+#### 方式 A:TCP Server 接入(推荐用于外部设备/中间件)
+
+1. OEMApp 启动后会读取配置 `TcpServerIp` / `TcpServerPort` 并启动 `TouchSocketServer`。
+2. 外部客户端连接后发送条码字符串(UTF-8 文本)。
+3. OEMApp 接收后会:
+ - 解析条码并填充界面(内袋二维码、原料编号等)
+ - 调用 `DownloadTaskWorker.Enqueue` 创建下载任务并入队
+ - 向客户端回复“OK”
+
+#### 方式 B:PLC 扫码(若现场启用 PLCDataService)
+
+1. `PLCDataService` 会按 `PLCScan` 周期从 `PDAScanCode` 地址读取字符串。
+2. 读取到新值后触发 `ScanCodeEventHandler`。
+3. OEMApp 会解析条码并入队下载任务。
+
+### 9.4 下载任务执行与监控
+
+- 下载队列服务:`DownloadTaskWorker.Instance.Start(HkCameraClient)`
+- 执行模型:单线程顺序下载(同一时刻只有一个任务 Running)。
+- 主界面显示:
+ - `DownloadFileName`:显示当前下载文件名
+ - `DownloadProgressBarMain`:显示下载进度(来自海康 SDK 回调)
+ - `gridRULog`:展示任务列表(时间、类型、条码、任务Id、状态、进度、备注)
+
+### 9.5 暂停下载
+
+- 点击“停止下载/暂停”(btnStopLoadVideo 或 btnRawStopLoadVideo)会调用 `Sdk_NET_DVR_StopGetFile`。
+- 注意:暂停会导致当前任务失败或中断,后续可依赖重试策略重新下载。
+
+### 9.6 任务完成后的结果
+
+下载成功后系统会:
+
+- 在视频保存目录生成视频文件(例如 `Y:\{Code}.mp4`)。
+- 在数据库表 `OEMRawUse` 写入一条记录:
+ - InBagCode、RawName、RawCode、User
+ - VideoStartTime、VideoEndTime
+ - VideoFilePath、VideoName
+- 在主界面/日志中提示下载完成。
+
+### 9.7 CSV 文件生成(可选)
+
+- 系统在 `SaveCsvForTaskAsync` 中会为任务生成 `RawUseCsvDto`,并调用 `CsvService.ExportSingle` 导出 CSV。
+- CSV 输出目录:`RawUseCsvPath`
+- 文件名:`{InBagCode}.csv`(若条码含非法字符会被替换)
+- 主界面“CSV文件生成状态”会提示成功/失败路径。
+
+### 9.8 历史查询与视频播放
+
+1. 点击左侧“历史记录”。
+2. 输入查询条件:
+ - 关键字:支持 InBagCode/RawCode/RawName 模糊匹配
+ - 日期范围:按 CreateTime 过滤(起始日 00:00:00 到结束日次日 00:00:00)
+3. 点击查询(btnHistoryVideoSearch)。
+4. 双击表格行播放对应视频(从 `OEMRawUse.VideoFilePath` 读取)。
+5. 点击“停止播放”(btnStopHistoryPlay)停止当前播放。
+
+## 10. 数据表口径(追溯/IT)
+
+- `DownloadTask`:下载任务表(队列持久化)
+ - Status:Pending/Running/Completed/Failed
+ - TryCount:尝试次数
+ - VideoFilePath:目标保存路径
+ - Error:失败原因
+- `OEMRawUse`:追溯索引表(历史记录页数据源)
+- `VideoAction` / `JellyfinMonitorTask`:历史遗留/扩展表(定时清理会涉及)
+
+## 11. 定时清理策略(IT 运维)
+
+`TimeClearDataService` 每日按配置时间执行:
+
+- 先按 `VideoFileSaveDay` 删除 `DownloadTask` 关联的视频文件;
+- 再按 `DbSaveDay` 删除 `DownloadTask` / `JellyfinMonitorTask` / `OEMRawUse` / `VideoAction` 等旧记录;
+- 删除数据库记录前会再次尽力删除关联文件,避免“库删了文件还在”。
+
+运维建议:
+
+- 若视频文件非常大,建议将 `VideoFileSaveDay` 与 `DbSaveDay` 分开设置。
+- 若需要永久留存视频,请将 `VideoFileSaveDay` 设置为较大值,并确保 NAS 容量与备份策略。
+
+## 12. 常见问题与排查(FAQ/故障处理)
+
+### 12.1 底部“服务连接状态:异常”
+
+- 原因:SQL Server 不可达、连接字符串错误、账号权限不足。
+- 处理:
+ 1. 检查 `App.config` 的 `connecting`。
+ 2. 检查数据库服务是否启动、网络是否可达。
+ 3. 查看错误日志 `ErrorMessage.txt`。
+
+### 12.2 “NVR连接状态:异常”或登录失败
+
+- 原因:NVRIP/NVRPort 不通、账号密码错误、NVR 离线、海康 SDK 环境/依赖缺失。
+- 处理:
+ 1. Ping `NVRIP` 并确认端口可达(8000)。
+ 2. 核对 `NVRUserName` / `NVRPw`。
+ 3. 查看 `C:\SdkLog\` 与 NLog 错误日志。
+
+### 12.3 “NAS连接状态:异常”
+
+- 原因:`NVRVideoSavePath` 不存在、网络盘未映射、权限不足。
+- 处理:
+ 1. 在资源管理器确认该盘符/目录存在并可创建文件。
+ 2. 若为网络共享,确认映射在“运行 OEMApp 的同一账号”下可用。
+ 3. 建议使用固定盘符映射并设置开机自动重连。
+
+### 12.4 扫码后无任务入队/无反应
+
+- TCP 方式:
+ 1. 确认 `TcpServerIp` / `TcpServerPort` 配置正确。
+ 2. 确认防火墙放行端口。
+ 3. 查看操作日志是否有“客户端连接/接收数据”。
+- PLC 方式:
+ 1. 检查 `PLCIP` / `PLCPort` / `PDAScanCode`。
+ 2. 检查 PLC 是否正确写入字符串。
+
+### 12.5 下载任务一直 Pending
+
+- 可能原因:任务的 `NvrEndTime` 还未到(系统会等待 `NvrEndTime < Now+5 秒` 才取出执行)。
+- 处理:等待数秒后观察;或检查系统时间是否正确。
+
+### 12.6 下载失败/超时
+
+- 原因:网络抖动、NVR 负载高、录像文件不可用、保存路径不可写。
+- 处理:
+ 1. 检查视频保存路径是否可写。
+ 2. 检查 NVR 登录与在线。
+ 3. 适当增大 `DownloadTaskTimeoutSeconds`。
+ 4. 查看 `DownloadTask.Error` 与 `ErrorMessage.txt`。
+
+### 12.7 历史记录能查到但播放失败
+
+- 原因:视频文件被清理、路径变更、盘符未映射。
+- 处理:
+ 1. 按 `VideoFilePath` 检查文件是否存在。
+ 2. 检查 NAS 映射与权限。
+ 3. 如文件已被清理,需调整 `VideoFileSaveDay` 或备份策略。
+
+## 13. 安全与合规建议
+
+- 不建议在现场明文传播数据库 sa 密码;建议使用最小权限账号。
+- 建议对 NAS 目录做访问控制,防止误删/篡改。
+- 建议定期备份数据库与关键视频目录。
+
+## 14. 版本记录
+
+- V1.0:OEMApp 初版 SOP(安装/配置/扫码触发下载/历史播放/CSV/定时清理/故障排查)
diff --git a/FATrace.OEMApp/HelpManual/SOPManual.txt b/FATrace.OEMApp/HelpManual/SOPManual.txt
new file mode 100644
index 0000000..2b07551
--- /dev/null
+++ b/FATrace.OEMApp/HelpManual/SOPManual.txt
@@ -0,0 +1,307 @@
+ FATrace.OEMApp 软件使用手册(SOP)
+
+ 1. 文档说明
+
+ 1.1 适用范围
+ - 本手册适用于 FATrace.OEMApp(WinForms 客户端)。
+ - 该客户端用于:接收“内袋二维码/条码”触发事件,调用海康 NVR 按时间段下载录像文件,保存到本地/NAS,并将视频文件与条码信息写入数据库,提供历史查询与播放。
+
+ 1.2 目标读者
+ - 产线操作人员:负责扫码触发、查看任务状态、核对视频保存结果。
+ - 追溯/质量人员:负责历史查询与视频回放。
+ - IT 运维:负责安装部署、网络与设备连通性、配置项维护、日志排查与数据清理策略。
+
+ 1.3 术语与口径
+ - NVR:海康网络录像机(通过海康 SDK 登录并下载录像)。
+ - 内袋二维码/条码:来自产线/外部系统的条码字符串;系统会解析出原料编号、批号、重量、保质期、产地、日产量等字段(解析规则见第 8 章)。
+ - 下载任务:系统将一次“回溯录像下载请求”持久化为 DownloadTask,并由后台队列顺序执行。
+ - 历史记录:数据库表 OEMRawUse,用于追溯条码对应的视频文件路径、名称与创建时间等。
+ - NAS:本系统将视频保存路径配置为网络盘/共享盘(例如 Y:),用于长期留存。
+
+ 2. 系统概述
+
+ FATrace.OEMApp 的核心流程为:
+ 1) 接收条码(PLC 扫码或 TCP 客户端发送);
+ 2) 解析条码得到业务字段;
+ 3) 生成下载任务 DownloadTask 并入队;
+ 4) 后台 DownloadTaskWorker 按顺序执行:
+ - 根据配置的 NVR 账号登录状态,从 NVR 按时间范围下载录像
+ - 将视频保存到 NVRVideoSavePath 指定目录(支持盘符/网络盘)
+ - 下载成功后写入 OEMRawUse(视频文件路径、视频名、条码信息等)
+ - 可选:生成 CSV(RawUseCsvPath)
+ 5) 历史页可查询 OEMRawUse 并双击播放视频。
+
+ 3. 运行环境与依赖
+
+ 3.1 操作系统
+ - Windows 10/11。
+
+ 3.2 网络与外设
+ - SQL Server 数据库(用于存储任务与追溯索引)。
+ - 海康 NVR(IP/端口/账号/密码在配置中)。
+ - 视频保存目录(本地磁盘或 NAS 网络盘,如 Y:)。
+ -(可选)PLC/基恩士:用于从寄存器读取扫码结果。
+ -(可选)TCP 客户端(如 PDA/中间件):向 OEMApp 内置 TCP Server 发送扫码文本。
+
+ 3.3 关键组件(实现层面)
+ - 数据库:FreeSql + SQL Server Provider(自动同步表结构已启用)。
+ - 海康 SDK:CHCNetSDK(程序启动后输出 SDK 日志到 C:\SdkLog\)。
+ - 视频播放:LibVLCSharp(VideoView)。
+ - TCP Server:TouchSocket(多客户端)。
+ - PLC 通信:HslCommunication(KeyenceMcNet)。
+ - 日志:NLog(错误日志 + 操作日志)。
+ - CSV:CsvHelper(导出单条 CSV)。
+ -(可选)Jellyfin:RestSharp 客户端与监控服务(当前主流程标记为停用)。
+
+ 4. 安装与部署(IT 运维)
+
+ 4.1 部署方式
+ - 使用发布后的程序目录(含 exe、dll、App.config、NLog.config、VideoLAN.LibVLC 等依赖)。
+ - 建议将程序目录放置在非系统盘且具备写权限的位置(日志目录在程序目录下生成)。
+
+ 4.2 单实例限制
+ - 程序启动会检测单实例:若已运行会提示“程序已运行,不能再次打开!”。
+
+ 4.3 授权提示
+ - 程序启动会进行授权校验;失败时提示“授权失败!当前程序只能使用8小时!”。
+
+ 4.4 首次启动检查清单
+ - 数据库连通性:确认 App.config 中 connecting 正确,且 SQL Server 可访问。
+ - NVR 可达:确认 NVRIP/NVRPort 可 ping 通,端口可达。
+ - 视频保存盘符:确认 NVRVideoSavePath 盘符存在且可写(如为网络盘需先映射)。
+ - SDK 日志目录:C:\SdkLog\ 可创建且可写(建议提前创建目录并赋权)。
+
+ 5. 配置说明(App.config)
+
+ 文件位置:FATrace.OEMApp/App.config
+
+ 5.1 数据库连接
+ - connecting:SQL Server 主库连接字符串(OEMApp 实际使用该键)。
+ - connecting1 / RemoteConnecting:保留项,是否使用取决于现场版本。
+
+ 5.2 PLC 配置
+ - PLCIP:PLC IP
+ - PLCPort:PLC 端口
+ - PLCScan:扫描周期(毫秒),默认 600
+ - PDAScanCode:PLC 内保存扫码字符串的寄存器地址(例如 D1000)
+
+ 5.3 TCP Server(外部扫码接入)
+ - TcpServerIp:OEMApp 内置 TCP Server 监听 IP
+ - TcpServerPort:OEMApp 内置 TCP Server 监听端口
+
+ 5.4 下载任务与回溯时长
+ - VideoTime:下载时长(秒),默认 30。系统会下载 [NvrStartTime, NvrEndTime] 时间窗。
+ - DownloadTaskMaxRetry:失败重试次数上限(程序重启后会将未超过次数的 Failed 重置为 Pending)。
+ - DownloadTaskTimeoutSeconds:单任务下载超时时间(秒)。若未配置,系统会按“时间窗*3”并限制到 [120,1800] 计算。
+
+ 5.5 NVR 配置
+ - NVRIP:NVR IP
+ - NVRPort:NVR 端口(通常 8000)
+ - NVRUserName:NVR 登录用户名
+ - NVRPw:NVR 登录密码
+ - NVRVideoSavePath:视频保存根路径(支持本地盘符或网络盘符,如 Y:)
+
+ 5.6 数据清理策略
+ - VideoFileSaveDay:视频文件保留天数(用于清理 DownloadTask 关联的视频文件)
+ - DbSaveDay:数据库记录保留天数(用于清理 DownloadTask/OEMRawUse/VideoAction 等旧记录)
+ - DataCleanupEnabled:是否启用定时清理(默认 true)
+ - DataCleanupTimeOfDay:每日执行时间(默认 02:00:00)
+
+ 5.7 CSV 导出
+ - RawUseCsvPath:CSV 保存目录(如 D:\TestData)
+
+ 5.8 Jellyfin(可选/当前停用)
+ - JellyfinBaseUrl / JellyfinApiKey / JellyfinParentId / JellyfinPollIntervalMs / JellyfinPollMaxWaitMs
+
+ 6. 日志与目录说明
+
+ 6.1 NLog 日志
+ 文件:FATrace.OEMApp/NLog.config
+ - 错误日志:程序目录\Log\yyyy-MM-dd\ErrorMessage.txt
+ - 操作日志:程序目录\OperationLogs\yyyy-MM-dd\OperationLog.txt
+ - 默认保留 60 天(maxArchiveDays=60)
+
+ 6.2 海康 SDK 日志
+ - 程序启动时启用 SDK 日志输出到:C:\SdkLog\
+ - 建议 IT 运维定期清理该目录,避免长期占用磁盘。
+
+ 7. 界面结构与功能入口
+
+ 7.1 左侧导航栏
+ - 主界面:显示扫码信息、任务队列状态、日志列表、CSV 保存状态等。
+ - 历史记录:查询 OEMRawUse 并播放对应视频。
+ - 设置:预留页(当前可用于放置 NVR 登录、下载进度等相关操作)。
+
+ 7.2 顶部/底部状态栏
+ - Plc连接状态:来自 PLCDataService(若未启用 PLC,此状态可能不准确或保持默认)。
+ - 服务连接状态:数据库连通性检测(SELECT 1)。
+ - NAS连接状态:检测 NVRVideoSavePath 目录是否存在。
+ - NVR连接状态:检测 NVR 登录态与在线状态。
+
+ 8. 条码格式与解析规则(重要)
+
+ 系统条码解析由 FATrace.Com.NVRCom.ParseCodeFull(code) 实现。
+
+ 8.1 支持的分隔符
+ - 支持英文逗号 ,
+ - 支持中文逗号 ,
+ - 支持分号 ; / ;
+ - 支持空格分隔
+
+ 8.2 推荐条码字段结构(按顺序)
+ 1) RawCode:原料编号
+ 2) Batch:批号(通常为 8 位日期 yyyyMMdd;系统会提取前 8 位数字)
+ 3) Weight:重量(3/4 位数字,最后 1 位为小数位,例如 802 => 80.2)
+ 4) ShelfLifeMonths:保质期(月)
+ 5) RegionCode:产地编码(01=国内,02=日本,其他=未知)
+ 6) Count:日产量序号
+
+ 8.3 视频文件命名规则
+ - 视频文件路径:NVRVideoSavePath\{Code}.mp4(会清洗非法文件名字符)
+ - 视频名称:{Code}.mp4
+
+ 9. 日常操作 SOP(产线/追溯人员)
+
+ 9.1 开机前检查(每日/每班)
+ 1) 启动 OEMApp。
+ 2) 查看底部状态栏:
+ - 服务连接状态:应为“正常”
+ - NAS连接状态:应为“正常”(表示视频保存目录存在)
+ - NVR连接状态:应为“正常”(表示 NVR 已登录且在线)
+ 3) 若异常,按第 12 章排查。
+
+ 9.2 NVR 登录
+ - 主界面启动时会自动调用登录(NVRLogin),也可使用“设置页/登录按钮”手动登录(btnNVRLogin)。
+ - 登录成功会弹窗“登录成功”。
+ - 登录失败会弹窗“登录失败:{错误信息}”。
+
+ 9.3 扫码触发(两种方式)
+
+ 方式 A:TCP Server 接入(推荐用于外部设备/中间件)
+ 1) OEMApp 启动后会读取配置 TcpServerIp/TcpServerPort 并启动 TouchSocketServer。
+ 2) 外部客户端连接后发送条码字符串(UTF-8 文本)。
+ 3) OEMApp 接收后会:
+ - 解析条码并填充界面(内袋二维码、原料编号等)
+ - 调用 DownloadTaskWorker.Enqueue 创建下载任务并入队
+ - 向客户端回复“OK”
+
+ 方式 B:PLC 扫码(若现场启用 PLCDataService)
+ 1) PLCDataService 会按 PLCScan 周期从 PDAScanCode 地址读取字符串。
+ 2) 读取到新值后触发 ScanCodeEventHandler。
+ 3) OEMApp 会解析条码并入队下载任务。
+
+ 9.4 下载任务执行与监控
+ - 下载队列服务:DownloadTaskWorker.Instance.Start(HkCameraClient)
+ - 执行模型:单线程顺序下载(同一时刻只有一个任务 Running)。
+ - 主界面显示:
+ - DownloadFileName:显示当前下载文件名
+ - DownloadProgressBarMain:显示下载进度(来自海康 SDK 回调)
+ - gridRULog:展示任务列表(时间、类型、条码、任务Id、状态、进度、备注)
+
+ 9.5 暂停下载
+ - 点击“停止下载/暂停”(btnStopLoadVideo 或 btnRawStopLoadVideo)会调用 Sdk_NET_DVR_StopGetFile。
+ - 注意:暂停会导致当前任务失败或中断,后续可依赖重试策略重新下载。
+
+ 9.6 任务完成后的结果
+ 下载成功后系统会:
+ - 在视频保存目录生成视频文件(例如 Y:\{Code}.mp4)。
+ - 在数据库表 OEMRawUse 写入一条记录:
+ - InBagCode、RawName、RawCode、User
+ - VideoStartTime、VideoEndTime
+ - VideoFilePath、VideoName
+ - 在主界面/日志中提示下载完成。
+
+ 9.7 CSV 文件生成(可选)
+ - 系统在 SaveCsvForTaskAsync 中会为任务生成 RawUseCsvDto,并调用 CsvService.ExportSingle 导出 CSV。
+ - CSV 输出目录:RawUseCsvPath
+ - 文件名:{InBagCode}.csv(若条码含非法字符会被替换)
+ - 主界面“CSV文件生成状态”会提示成功/失败路径。
+
+ 9.8 历史查询与视频播放
+ 1) 点击左侧“历史记录”。
+ 2) 输入查询条件:
+ - 关键字:支持 InBagCode/RawCode/RawName 模糊匹配
+ - 日期范围:按 CreateTime 过滤(起始日 00:00:00 到结束日次日 00:00:00)
+ 3) 点击查询(btnHistoryVideoSearch)。
+ 4) 双击表格行播放对应视频(从 OEMRawUse.VideoFilePath 读取)。
+ 5) 点击“停止播放”(btnStopHistoryPlay)停止当前播放。
+
+ 10. 数据表口径(追溯/IT)
+
+ - DownloadTask:下载任务表(队列持久化)
+ - Status:Pending/Running/Completed/Failed
+ - TryCount:尝试次数
+ - VideoFilePath:目标保存路径
+ - Error:失败原因
+ - OEMRawUse:追溯索引表(历史记录页数据源)
+ - VideoAction / JellyfinMonitorTask:历史遗留/扩展表(定时清理会涉及)
+
+ 11. 定时清理策略(IT 运维)
+
+ TimeClearDataService 每日按配置时间执行:
+ - 先按 VideoFileSaveDay 删除 DownloadTask 关联的视频文件;
+ - 再按 DbSaveDay 删除 DownloadTask/JellyfinMonitorTask/OEMRawUse/VideoAction 等旧记录;
+ - 删除数据库记录前会再次尽力删除关联文件,避免“库删了文件还在”。
+
+ 运维建议:
+ - 若视频文件非常大,建议将 VideoFileSaveDay 与 DbSaveDay 分开设置。
+ - 若需要永久留存视频,请将 VideoFileSaveDay 设置为较大值,并确保 NAS 容量与备份策略。
+
+ 12. 常见问题与排查(FAQ/故障处理)
+
+ 12.1 底部“服务连接状态:异常”
+ - 原因:SQL Server 不可达、连接字符串错误、账号权限不足。
+ - 处理:
+ 1) 检查 App.config 的 connecting。
+ 2) 检查数据库服务是否启动、网络是否可达。
+ 3) 查看错误日志 ErrorMessage.txt。
+
+ 12.2 “NVR连接状态:异常”或登录失败
+ - 原因:NVRIP/NVRPort 不通、账号密码错误、NVR 离线、海康 SDK 环境/依赖缺失。
+ - 处理:
+ 1) Ping NVRIP 并确认端口可达(8000)。
+ 2) 核对 NVRUserName/NVRPw。
+ 3) 查看 C:\SdkLog\ 与 NLog 错误日志。
+
+ 12.3 “NAS连接状态:异常”
+ - 原因:NVRVideoSavePath 不存在、网络盘未映射、权限不足。
+ - 处理:
+ 1) 在资源管理器确认该盘符/目录存在并可创建文件。
+ 2) 若为网络共享,确认映射在“运行 OEMApp 的同一账号”下可用。
+ 3) 建议使用固定盘符映射并设置开机自动重连。
+
+ 12.4 扫码后无任务入队/无反应
+ - TCP 方式:
+ 1) 确认 TcpServerIp/TcpServerPort 配置正确。
+ 2) 确认防火墙放行端口。
+ 3) 查看操作日志是否有“客户端连接/接收数据”。
+ - PLC 方式:
+ 1) 检查 PLCIP/PLCPort/PDAScanCode。
+ 2) 检查 PLC 是否正确写入字符串。
+
+ 12.5 下载任务一直 Pending
+ - 可能原因:任务的 NvrEndTime 还未到(系统会等待 NvrEndTime < Now+5 秒才取出执行)。
+ - 处理:等待数秒后观察;或检查系统时间是否正确。
+
+ 12.6 下载失败/超时
+ - 原因:网络抖动、NVR 负载高、录像文件不可用、保存路径不可写。
+ - 处理:
+ 1) 检查视频保存路径是否可写。
+ 2) 检查 NVR 登录与在线。
+ 3) 适当增大 DownloadTaskTimeoutSeconds。
+ 4) 查看 DownloadTask.Error 与 ErrorMessage.txt。
+
+ 12.7 历史记录能查到但播放失败
+ - 原因:视频文件被清理、路径变更、盘符未映射。
+ - 处理:
+ 1) 按 VideoFilePath 检查文件是否存在。
+ 2) 检查 NAS 映射与权限。
+ 3) 如文件已被清理,需调整 VideoFileSaveDay 或备份策略。
+
+ 13. 安全与合规建议
+ - 不建议在现场明文传播数据库 sa 密码;建议使用最小权限账号。
+ - 建议对 NAS 目录做访问控制,防止误删/篡改。
+ - 建议定期备份数据库与关键视频目录。
+
+ 14. 版本记录
+ - V1.0:OEMApp 初版 SOP(安装/配置/扫码触发下载/历史播放/CSV/定时清理/故障排查)
diff --git a/FATrace.WPLApp/App.xaml.cs b/FATrace.WPLApp/App.xaml.cs
index c3a325d..04e6df9 100644
--- a/FATrace.WPLApp/App.xaml.cs
+++ b/FATrace.WPLApp/App.xaml.cs
@@ -269,6 +269,8 @@ namespace FATrace.WPLApp
// 用户管理
containerRegistry.RegisterForNavigation();
+ // 称重用户管理
+ containerRegistry.RegisterForNavigation();
// 工厂/OEM Excel 导入数据查询页
containerRegistry.RegisterForNavigation();
@@ -301,6 +303,7 @@ namespace FATrace.WPLApp
//注册Dialog视图时绑定VM
containerRegistry.RegisterDialog("DialogUserEditView");
+ containerRegistry.RegisterDialog("DialogWeightUserEditView");
//containerRegistry.RegisterDialog();
//containerRegistry.RegisterDialog();
//containerRegistry.RegisterDialog();
diff --git a/FATrace.WPLApp/FATrace.WPLApp.csproj b/FATrace.WPLApp/FATrace.WPLApp.csproj
index 9002cf5..5712747 100644
--- a/FATrace.WPLApp/FATrace.WPLApp.csproj
+++ b/FATrace.WPLApp/FATrace.WPLApp.csproj
@@ -1,7 +1,7 @@
- Exe
+ WinExe
net8.0-windows
enable
enable
diff --git a/FATrace.WPLApp/Report/UserManual.md b/FATrace.WPLApp/Report/UserManual.md
index 4dbf3ba..dc9b6d2 100644
--- a/FATrace.WPLApp/Report/UserManual.md
+++ b/FATrace.WPLApp/Report/UserManual.md
@@ -1,12 +1,12 @@
-# FATrace 用户说明书(WPLApp)
+# 称重产线系统用户说明书
-版本:v1.0 适用模块:FATrace.WPLApp(桌面应用)
+版本:v1.0 适用模块:(桌面应用)

## 1. 简介
-FATrace 是一套用于食品添加剂生产过程追溯与运行监控的桌面应用。WPLApp 负责生产端的日常使用,包括:
+称重产线系统是一套用于食品添加剂生产过程追溯与运行监控的桌面应用。客户端负责生产端的日常使用,包括:
- 实时运行信息查看与统计总览(Dashboard)
- 历史报警查询(History Alarm)
@@ -34,6 +34,8 @@ FATrace 是一套用于食品添加剂生产过程追溯与运行监控的桌面
如启动失败、页面空白或频繁报错,请联系系统管理员检查运行环境与数据库连接配置。
+电脑的登录密码默认是:123456
+
## 3. 登录与账号
- 打开左侧菜单“用户登录”进入登录页。
@@ -44,7 +46,7 @@ FATrace 是一套用于食品添加剂生产过程追溯与运行监控的桌面
- 登录成功后自动导航到 Dashboard;底部状态栏将显示“当前用户:已登录”。
-注意:请妥善保管账号信息。若忘记密码,请联系管理员重置。
+注意:请妥善保管账号信息。若忘记密码,请联系管理员重置。初始默认用户和密码都是:123
## 4. 主界面与导航
@@ -166,7 +168,13 @@ Dashboard 提供生产运行的概览信息,包括:

-本功能用于读取QR系统FTP每天生成的 Excel 文件(多 Sheet),并将各 Sheet 数据分别存入数据库表,同时将文件移动到归档目录,便于对账与追溯。
+==**本功能用于云服务管理系统FTP每天生成的 Excel 文件(多 Sheet),并将各 Sheet 数据分别存入数据库表,同时将文件移动到归档目录,便于对账与追溯。**==
+
+==**系统每个小时进行FTP下载的Excel文件检测,检测到文件后进行数据保存到数据库供查询使用,然后把数据归档到【D:\Archive】中保存**==
+
+
+
+既是:整个系统的数据在除了云端保存外,本地数据库和文件也会备份保存供使用
### 10.1 文件放置与命名规则
@@ -178,6 +186,10 @@ Dashboard 提供生产运行的概览信息,包括:
### 10.2 配置项说明(App.config)
+> [!IMPORTANT]
+>
+> ==建议工程师进行更改配置==
+
配置文件位置:`FATrace.WPLApp/App.config`(部署后为程序同目录的配置文件)。
- `ExcelImportSourceDir`
@@ -187,9 +199,9 @@ Dashboard 提供生产运行的概览信息,包括:
- Excel 文件归档目录。
- 导入完成后,程序会将源文件移动到该目录。
-### 10.3 Sheet 与数据表映射
+### 10.3 Sheet 与数据表映射(约定规则,使用者无需关注)
-Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下列规则导入:
+FTP下载的Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下列规则导入:
- 工厂-入库 → `FactoryInbound`
- 工厂-领料 → `FactoryMaterialWithdrawal`
@@ -204,8 +216,17 @@ Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下
注意:
- 导入时从每个 Sheet 的第 2 行开始读取(第 1 行为表头)。
+
- 如果整行为空会被跳过。
+
+
+ **Excel中Sheet**
+
+
+
+
+
### 10.4 重复导入与归档规则
- **防重复**:当 `FileImportLog` 表中已存在相同文件名且状态为 `Success` 的记录时,该文件会被跳过,不会重复导入。
@@ -213,12 +234,18 @@ Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下
## 11. 文件导入日志
+
+
+
+
入口:左侧菜单 → 数据管理 → 文件导入日志。
用途:用于查看每次 Excel 文件导入的执行情况,便于定位导入失败原因与核对数据。
日志字段说明:
+每次检测到文件时都会产生一个【文件导入日志】供查询使用
+
- **文件名**:导入的 Excel 文件名。
- **开始时间/结束时间**:导入流程的起止时间。
- **状态**:`Success` / `Failed` / `Running`。
@@ -234,44 +261,118 @@ Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下
## 12. 工厂/OEM 数据查询
+
+
入口:左侧菜单 → 工厂/OEM 数据查询。
-说明:这些页面展示的是 Excel 导入后的数据(字符串字段为主),主要用于“展示、对账、追溯”。
+说明:这些页面展示的是 Excel 导入后的数据,主要用于“展示、对账、追溯”。
### 12.1 工厂数据
- 工厂-入库
- - 条件:产地、原料代码、原料名称、登录日期范围(基于 `LoginDateTime`)。
+ - 条件:产地、原料代码、原料名称、登录日期范围。
+
+ 
+
+ 内容区域的上侧为查询条件,你可以根据需要输入查询的条件,然后点击【查询】,然后下方的表格就会展示信息。
+
+ 较多数据展示时会分页展示,您可以切换到指定的页码进行查看分析
+
+
+
- 工厂-领料
- - 条件:产地、原料代码、原料名称、登录日期范围(基于 `LoginDateTime`)。
+ - 条件:产地、原料代码、原料名称、登录日期范围。
+
+ 
+
- 工厂-出入库
- - 条件:产地、原料代码、原料名称、入库时间日期范围(基于 `InTime`)。
+ - 条件:产地、原料代码、原料名称、入库时间日期范围。
+
+ 
+
- 工厂-原料生产信息
- - 条件:原料编号、原料名称、批号、称重时间日期范围(基于 `WeightTime`)。
+ - 条件:原料编号、原料名称、批号、称重时间日期范围。
+
+ 
+
- 工厂-成品出库
- - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。
+ - 条件:产地、原料代码、原料名称、批号、登录日期范围。
+
+ 
### 12.2 OEM 数据
- OEM-入库
- - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。
+ - 条件:产地、原料代码、原料名称、批号、登录日期范围。
+
+ 
+
- OEM-出库
- - 条件:产地、原料代码、原料名称、批号、登录日期范围(基于 `LoginDateTime`)。
+ - 条件:产地、原料代码、原料名称、批号、登录日期范围。
+
+ 
+
- OEM-出入库
- - 条件:产地、原料代码、原料名称、入库时间日期范围(基于 `InTime`)。
+ - 条件:产地、原料代码、原料名称、入库时间日期范围。
+
+ 
+
- OEM-原料使用信息
- - 条件:内袋二维码、原料代码、原料名称、产地、原料使用时间日期范围(基于 `RawUseTime`)。
+ - 条件:内袋二维码、原料代码、原料名称、产地、原料使用时间日期范围。
-注意:以上页面的日期条件会对字符串时间字段做数据库端转换比较(例如 `TRY_CONVERT(datetime, LoginDateTime)`)。若 Excel 中日期格式不规范,可能导致该条数据无法被日期条件命中。
+注意:以上页面的日期条件会对字符串时间字段做数据库端转换比较。若 Excel 中日期格式不规范,可能导致该条数据无法被日期条件命中。
-## 13. 常见操作流程
+## 13. 用户管理
+
+
+
+入口:左侧菜单 → 系统 → 用户管理。
+
+功能说明:
+
+- 对登录 WPLApp 桌面应用的用户进行管理(增删改查)。
+- 支持设置用户权限等级(管理员、操作员、访客)。
+- **注意**:仅管理员权限账号可进行编辑操作。
+
+操作步骤:
+
+1. **查询**:输入用户名或选择等级,点击“查询”。
+2. **新增**:点击“新增”,填写用户名、密码与等级,保存。
+3. **编辑**:选中列表中的用户,点击“编辑”修改信息。
+4. **删除**:选中列表中的用户,点击“删除”(无法删除当前登录用户)。
+
+## 14. 称重用户管理
+
+
+
+入口:左侧菜单 → 系统 → 称重用户。
+
+功能说明:
+
+- 对生产端 FATrace.App 称重程序的用户进行管理。
+- 称重端采用双人复核登录模式,因此包含“确认者”与“操作者”两个角色字段。
+- **注意**:仅管理员权限账号可进行编辑操作。
+
+操作步骤:
+
+1. **查询**:输入确认者或操作者名称,点击“查询”。
+2. **新增**:点击“新增”,填写确认者、操作者姓名与密码,保存。
+3. **编辑**:选中列表中的用户,点击“编辑”修改信息。
+4. **删除**:选中列表中的用户,点击“删除”。
+
+## 15. 常见操作流程
- 开机与登录:启动应用 → 进入“用户登录” → 登录成功 → 自动跳转至 Dashboard。
+
+ 
+
- 查询生产数据:进入“原料使用查询/原料入库查询” → 设置条件 → 查询 → 需要时导出 Excel。
+
- 导入外部 Excel:将 `.xlsx` 文件放入 `ExcelImportSourceDir` 目录 → 等待系统后台导入 → 到“文件导入日志”确认状态为 `Success` → 如需对账,使用“工厂/OEM 数据查询”查看导入数据。
+
- 报警定位与复盘:在“历史报警”中按时间段和关键字查询,结合现场记录进行处理与复盘。
-## 14. 常见问题与排查
+## 16. 常见问题与排查
- 无法登录:
- 确认用户名、密码输入正确;
@@ -308,24 +409,24 @@ Excel 中各 Sheet 必须使用固定名称(中文 Sheet 名),系统按下
- 检查数据库是否可写;
- 关注应用日志,必要时联系管理员。
-## 15. 使用技巧
+## 17. 使用技巧
- 登录页支持“回车键”快捷登录。
- 关注底部状态栏的用户与 PLC 状态,能快速判断系统是否处于可用状态。
- 发生导入问题时优先查看“文件导入日志”,比直接看数据库更直观。
-## 16. 安全与权限
+## 18. 安全与权限
- 账号仅限本人使用,请勿外借与分享。
- 退出离岗前可在“用户登录”页切换用户(如需要可联系管理员提供退出功能)。
- 如需更细粒度权限(基于岗位/等级),请与管理员沟通后续版本规划。
-## 17. 版本与更新
+## 19. 版本与更新
- 本说明书适配当前应用版本的已上线功能。新增功能或 UI 调整将另行更新说明。
- 如需升级或安装补丁,请联系系统管理员统一安排。
-## 18. 反馈与支持
+## 20. 反馈与支持
- 使用中如遇到界面异常、统计不准、报警缺失、导入失败等问题:
- 记录问题发生时间、操作步骤、界面提示;
diff --git a/FATrace.WPLApp/Report/UserManual.pdf b/FATrace.WPLApp/Report/UserManual.pdf
index bb86fb3..2a82874 100644
Binary files a/FATrace.WPLApp/Report/UserManual.pdf and b/FATrace.WPLApp/Report/UserManual.pdf differ
diff --git a/FATrace.WPLApp/Report/称重产线系统说明书.pdf b/FATrace.WPLApp/Report/称重产线系统说明书.pdf
new file mode 100644
index 0000000..bc56da6
Binary files /dev/null and b/FATrace.WPLApp/Report/称重产线系统说明书.pdf differ
diff --git a/FATrace.WPLApp/Services/DataServices.cs b/FATrace.WPLApp/Services/DataServices.cs
index c269f05..8017f2c 100644
--- a/FATrace.WPLApp/Services/DataServices.cs
+++ b/FATrace.WPLApp/Services/DataServices.cs
@@ -36,8 +36,8 @@ namespace FATrace.WPLApp.Services
StartPlcScan();
//var DD= RevData("DYG05030013,20250923,802,3,01,0001,");
- //var dd1= RevData("DYG05030013,251111,10193,6,01,3");
- //var dd2 = RevData("DYG05030013,251111,2116,6,01,3");
+ //var dd1= RevData("DYG05030013,251111,10193,6,01,3");
+ //var dd2 = RevData("DYG05030013,251111,2116,6,01,3");
}
@@ -64,6 +64,7 @@ namespace FATrace.WPLApp.Services
var BoxSprayCodeSource = CodeItem!.Code + ",A";
var BoxSprayCodeRev = RevData(BoxSprayCodeSource);
+ BoxSprayCode = BoxSprayCodeSource;
KeyencePlcMcNet.Write("D1150", BoxSprayCodeRev);
Console.WriteLine($"外箱喷码:{BoxSprayCodeSource}-发送OK");
@@ -90,7 +91,7 @@ namespace FATrace.WPLApp.Services
}
///
- /// 箱子扫描码请求
+ /// 外箱扫描码请求
///
///
///
@@ -125,12 +126,14 @@ namespace FATrace.WPLApp.Services
{
Console.WriteLine($"外箱扫描码:{BoxScanCode}-删除临时队列数据成功");
LogService.Info($"外箱扫描码:{BoxScanCode}-删除临时队列数据成功");
+ try { EventAggregator?.GetEvent()?.Publish(true); } catch { }
}
else
{
Console.WriteLine($"外箱扫描码:{BoxScanCode}-删除临时队列数据失败");
LogService.Info($"外箱扫描码:{BoxScanCode}-删除临时队列数据失败");
AddAlarm("外箱扫描", $"{BoxScanCode}-删除临时队列数据失败");
+ try { EventAggregator?.GetEvent()?.Publish(true); } catch { }
}
var UpdatedResult = FreeSql.Update()
@@ -184,13 +187,10 @@ namespace FATrace.WPLApp.Services
KeyencePlcMcNet.Write("D1250", new Int16[30]);
});
- //try { EventAggregator?.GetEvent()?.Publish(true); } catch { }
-
-
}
///
- /// 称重扫描码请求数据
+ /// 称重 内包扫描码请求数据
///
///
///
@@ -217,6 +217,14 @@ namespace FATrace.WPLApp.Services
{
Code = WeightScanCode
}).ExecuteAffrows();
+ try
+ {
+ EventAggregator?.GetEvent()?.Publish(true);
+ }
+ catch
+ {
+
+ }
var Result = FreeSql.Update()
.Set(p => p.WeightScanTime, DateTime.Now)
@@ -304,6 +312,17 @@ namespace FATrace.WPLApp.Services
}
+ private string _boxSprayCode;
+ ///
+ /// 最近一次下发给 PLC 的外箱喷码数据(源字符串,不做 RevData 对调处理)
+ ///
+ public string BoxSprayCode
+ {
+ get { return _boxSprayCode; }
+ set { _boxSprayCode = value; RaisePropertyChanged(); }
+ }
+
+
// 扫描控制
private CancellationTokenSource? _plcScanCts;
diff --git a/FATrace.WPLApp/Services/NavigationServices.cs b/FATrace.WPLApp/Services/NavigationServices.cs
index 27c6c9b..84b75ff 100644
--- a/FATrace.WPLApp/Services/NavigationServices.cs
+++ b/FATrace.WPLApp/Services/NavigationServices.cs
@@ -101,6 +101,13 @@ namespace FATrace.WPLApp.Services
IsParent = false,
},
new NavItemDto()
+ {
+ Name = "称重用户",
+ CmdPar = "称重用户",
+ Icon = "\uec46",
+ IsParent = false,
+ },
+ new NavItemDto()
{
Name = "使用手册",
CmdPar = "使用手册",
diff --git a/FATrace.WPLApp/Services/ReadFileServices.cs b/FATrace.WPLApp/Services/ReadFileServices.cs
index f98e4c4..86ed997 100644
--- a/FATrace.WPLApp/Services/ReadFileServices.cs
+++ b/FATrace.WPLApp/Services/ReadFileServices.cs
@@ -590,14 +590,15 @@ namespace FATrace.WPLApp.Services
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),
- TotalInCase = GetCellString(row, 5, formatter),
- TotalOutCase = GetCellString(row, 6, formatter),
- RemainCase = GetCellString(row, 7, formatter)
+ Batch = GetCellString(row, 3, formatter),
+ RawCode = GetCellString(row, 4, formatter),
+ RawName = GetCellString(row, 5, formatter),
+ TotalInCase = GetCellString(row, 6, formatter),
+ TotalOutCase = GetCellString(row, 7, formatter),
+ RemainCase = GetCellString(row, 8, formatter)
};
- if (IsAllEmpty(entity.InTime, entity.OutTime, entity.Origin, entity.RawCode, entity.RawName,
+ if (IsAllEmpty(entity.InTime, entity.OutTime, entity.Origin, entity.Batch, entity.RawCode, entity.RawName,
entity.TotalInCase, entity.TotalOutCase, entity.RemainCase))
continue;
diff --git a/FATrace.WPLApp/ViewModels/DashboardViewModel.cs b/FATrace.WPLApp/ViewModels/DashboardViewModel.cs
index 618cb21..e6ede0d 100644
--- a/FATrace.WPLApp/ViewModels/DashboardViewModel.cs
+++ b/FATrace.WPLApp/ViewModels/DashboardViewModel.cs
@@ -13,6 +13,7 @@ using System.Windows;
using System.Text;
using Prism.Events;
using FATrace.WPLApp.Events;
+using System.Threading;
namespace FATrace.WPLApp.ViewModels
{
@@ -22,6 +23,9 @@ namespace FATrace.WPLApp.ViewModels
private readonly ILogService _log;
private readonly DataServices _data;
+ private int _dashboardRefreshRequested;
+ private int _dashboardRefreshLoopRunning;
+
private DispatcherTimer _logTimer;
private bool _initialized;
private TextWriter _originalConsoleOut;
@@ -37,6 +41,7 @@ namespace FATrace.WPLApp.ViewModels
_ea = ea;
LiveMessages = new ObservableCollection();
+ LineTempCodes = new ObservableCollection();
RefreshCommand = new DelegateCommand(async () => await RefreshStatsAsync());
ClearLogsCommand = new DelegateCommand(() => LiveMessages.Clear());
@@ -66,7 +71,13 @@ namespace FATrace.WPLApp.ViewModels
};
_data.LineSglModel.BoxSprayCodeReqHandle += (s, e) =>
{
- AppendLiveMessage("外箱喷码请求: 已向PLC下发喷码数据");
+ var code = _data.BoxSprayCode;
+ if (Application.Current?.Dispatcher?.CheckAccess() == true)
+ LatestBoxSprayCode = code;
+ else
+ Application.Current?.Dispatcher?.BeginInvoke(new Action(() => LatestBoxSprayCode = code));
+ //AppendLiveMessage("外箱喷码请求: 已向PLC下发喷码数据");
+ AppendLiveMessage($"外箱喷码请求: 已向PLC下发喷码数据;{code}");
};
_data.LineSglModel.BoxScanCodeReqHandle += (s, e) =>
{
@@ -81,12 +92,89 @@ namespace FATrace.WPLApp.ViewModels
// 订阅外部刷新事件(来自 DataServices 的 BoxScanCode 完成后)
try
{
- _dashEventToken = _ea.GetEvent().Subscribe(_ =>
- {
- Application.Current?.Dispatcher?.BeginInvoke(new Action(async () => await RefreshStatsAsync()));
- });
+ EnsureDashboardRefreshSubscription();
+ }
+ catch (Exception ex)
+ {
+ _log?.Warn($"DashboardRefreshEvent 订阅失败: {ex.Message}");
+ }
+ }
+
+ private void EnsureDashboardRefreshSubscription()
+ {
+ if (_dashEventToken != null) return;
+
+ // 注意:Prism 默认 keepSubscriberReferenceAlive=false(弱引用)。
+ // 若使用 lambda,目标可能是 closure 对象,存在被 GC 导致订阅失效的风险。
+ // 这里使用具名方法 + keepSubscriberReferenceAlive=true,确保订阅稳定。
+ _dashEventToken = _ea.GetEvent()
+ .Subscribe(OnDashboardRefreshEvent, ThreadOption.UIThread, true);
+ }
+
+ private void OnDashboardRefreshEvent(bool _)
+ {
+ RequestDashboardRefresh();
+ }
+
+ ///
+ /// 触发一次 Dashboard 刷新(合并 + 互斥):短时间多次触发只会串行执行,并在执行结束后最多再补跑一次。
+ ///
+ private void RequestDashboardRefresh()
+ {
+ // 标记有刷新请求
+ Interlocked.Exchange(ref _dashboardRefreshRequested, 1);
+
+ // 若刷新循环已在跑,则仅标记请求即可
+ if (Interlocked.Exchange(ref _dashboardRefreshLoopRunning, 1) == 1)
+ {
+ return;
+ }
+
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null)
+ {
+ // 极少数场景(无 UI Dispatcher),直接后台跑
+ _ = DashboardRefreshLoopAsync();
+ return;
+ }
+
+ dispatcher.BeginInvoke(new Action(async () =>
+ {
+ try
+ {
+ await DashboardRefreshLoopAsync();
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _dashboardRefreshLoopRunning, 0);
+
+ // 如果在执行过程中又来了刷新请求,则再次启动一次循环(确保不漏刷新)
+ if (Interlocked.Exchange(ref _dashboardRefreshRequested, 0) == 1)
+ {
+ RequestDashboardRefresh();
+ }
+ }
+ }));
+ }
+
+ ///
+ /// 在 UI 线程串行执行刷新,且合并多次触发。
+ ///
+ private async Task DashboardRefreshLoopAsync()
+ {
+ // 只要存在刷新请求,就执行一次刷新;过程中再来请求,会在下一轮继续跑
+ while (Interlocked.Exchange(ref _dashboardRefreshRequested, 0) == 1)
+ {
+ try
+ {
+ await RefreshStatsAsync();
+ await RefreshLineTempCodesAsync();
+ }
+ catch (Exception ex)
+ {
+ _log.Error($"DashboardRefreshEvent 刷新失败: {ex}");
+ }
}
- catch { }
}
#region Properties
@@ -108,17 +196,28 @@ namespace FATrace.WPLApp.ViewModels
private string _latestBoxScanCode;
public string LatestBoxScanCode { get => _latestBoxScanCode; set { _latestBoxScanCode = value; RaisePropertyChanged(); } }
+ private string _latestBoxSprayCode;
+ ///
+ /// 最近一次外箱喷码数据(即下发给 PLC 的源字符串)
+ ///
+ public string LatestBoxSprayCode { get => _latestBoxSprayCode; set { _latestBoxSprayCode = value; RaisePropertyChanged(); } }
+
private bool _plcConnected;
public bool PlcConnected { get => _plcConnected; set { _plcConnected = value; RaisePropertyChanged(); } }
public ObservableCollection LiveMessages { get; }
+
+ ///
+ /// 产线临时条码队列(内包扫码入队,外箱扫码确认后出队)
+ ///
+ public ObservableCollection LineTempCodes { get; }
#endregion
#region Commands
public DelegateCommand RefreshCommand { get; }
public DelegateCommand ClearLogsCommand { get; }
#endregion
-
+
private void AppendLiveMessage(string message)
{
if (string.IsNullOrWhiteSpace(message)) return;
@@ -186,8 +285,63 @@ namespace FATrace.WPLApp.ViewModels
}
}
+ ///
+ /// 从数据库刷新产线临时队列(LineTempCode 表)并同步到 UI。
+ ///
+ private async Task RefreshLineTempCodesAsync()
+ {
+ try
+ {
+ var list = await Task.Run(() =>
+ {
+ return _fsql.Select()
+ .OrderBy(a => a.Id)
+ .Limit(200)
+ .ToList();
+ });
+
+ void apply()
+ {
+ LineTempCodes.Clear();
+ foreach (var item in list)
+ {
+ LineTempCodes.Add(item);
+ }
+ }
+
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null)
+ {
+ apply();
+ return;
+ }
+
+ if (dispatcher.CheckAccess())
+ {
+ apply();
+ }
+ else
+ {
+ await dispatcher.InvokeAsync(apply).Task.ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error($"刷新 LineTempCode 队列失败: {ex}");
+ }
+ }
+
public override async void OnNavigatedTo(Prism.Regions.NavigationContext navigationContext)
{
+ try
+ {
+ EnsureDashboardRefreshSubscription();
+ }
+ catch (Exception ex)
+ {
+ _log?.Warn($"DashboardRefreshEvent OnNavigatedTo 订阅失败: {ex.Message}");
+ }
+
if (!_initialized)
{
_initialized = true;
@@ -196,6 +350,8 @@ namespace FATrace.WPLApp.ViewModels
// 初始化展示最近一次扫描值
LatestWeightScanCode = _data.WeightScanCode;
LatestBoxScanCode = _data.BoxScanCode;
+ LatestBoxSprayCode = _data.BoxSprayCode;
+ await RefreshLineTempCodesAsync();
TryHookConsole();
}
}
@@ -208,7 +364,18 @@ namespace FATrace.WPLApp.ViewModels
_logTimer = null;
}
UnhookConsole();
- try { if (_dashEventToken != null) _ea.GetEvent().Unsubscribe(_dashEventToken); } catch { }
+ try
+ {
+ if (_dashEventToken != null)
+ {
+ _ea.GetEvent().Unsubscribe(_dashEventToken);
+ _dashEventToken = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ _log?.Warn($"DashboardRefreshEvent 退订失败: {ex.Message}");
+ }
}
private void TryHookConsole()
diff --git a/FATrace.WPLApp/ViewModels/DialogWeightUserEditViewModel.cs b/FATrace.WPLApp/ViewModels/DialogWeightUserEditViewModel.cs
new file mode 100644
index 0000000..1c95e47
--- /dev/null
+++ b/FATrace.WPLApp/ViewModels/DialogWeightUserEditViewModel.cs
@@ -0,0 +1,207 @@
+using FATrace.Model;
+using FATrace.WPLApp.Core;
+using FATrace.WPLApp.Services;
+using FreeSql;
+using Prism.Commands;
+using Prism.Services.Dialogs;
+using System;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace FATrace.WPLApp.ViewModels
+{
+ ///
+ /// 称重用户新增/编辑 弹窗 VM
+ ///
+ public class DialogWeightUserEditViewModel : DialogViewModel
+ {
+ private readonly IFreeSql _fsql;
+ private readonly ILogService _log;
+
+ private string _mode = WeightUserManageViewModel.DialogModes.Add;
+ private long _editUserId;
+
+ public DialogWeightUserEditViewModel(IFreeSql fsql, ILogService log)
+ {
+ _fsql = fsql;
+ _log = log;
+
+ SaveCommand = new DelegateCommand(async () => await SaveAsync(), () => !IsBusy)
+ .ObservesProperty(() => IsBusy);
+ CancelCommand = new DelegateCommand(() => OnDialogClosed(ButtonResult.Cancel));
+ }
+
+ private string? _checkName;
+ ///
+ /// 确认者
+ ///
+ public string? CheckName
+ {
+ get => _checkName;
+ set { _checkName = value; RaisePropertyChanged(); }
+ }
+
+ private string? _opName;
+ ///
+ /// 操作者
+ ///
+ public string? OpName
+ {
+ get => _opName;
+ set { _opName = value; RaisePropertyChanged(); }
+ }
+
+ private string? _password;
+ ///
+ /// 密码(当前按现有模型使用明文;如后续需哈希,可在保存处替换)
+ ///
+ public string? Password
+ {
+ get => _password;
+ set { _password = value; RaisePropertyChanged(); }
+ }
+
+ ///
+ /// 保存
+ ///
+ public DelegateCommand SaveCommand { get; }
+
+ ///
+ /// 取消
+ ///
+ public DelegateCommand CancelCommand { get; }
+
+ ///
+ /// 打开弹窗时初始化
+ ///
+ /// 参数
+ public override void OnDialogOpened(IDialogParameters parameters)
+ {
+ _mode = parameters.GetValue(WeightUserManageViewModel.DialogKeys.Mode) ?? WeightUserManageViewModel.DialogModes.Add;
+ Title = _mode == WeightUserManageViewModel.DialogModes.Edit ? "编辑称重用户" : "新增称重用户";
+
+ if (_mode == WeightUserManageViewModel.DialogModes.Edit)
+ {
+ _editUserId = parameters.GetValue(WeightUserManageViewModel.DialogKeys.UserId);
+ LoadUser(_editUserId);
+ }
+ else
+ {
+ _editUserId = 0;
+ CheckName = string.Empty;
+ OpName = string.Empty;
+ Password = string.Empty;
+ }
+ }
+
+ 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;
+ }
+
+ CheckName = user.CheckName;
+ OpName = user.OpName;
+ Password = user.Password;
+ }
+ 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(CheckName))
+ {
+ MessageBox.Show("请输入确认者", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(OpName))
+ {
+ MessageBox.Show("请输入操作者", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(Password))
+ {
+ MessageBox.Show("请输入密码", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ IsBusy = true;
+
+ if (_mode == WeightUserManageViewModel.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 entity = new TbWeightUser
+ {
+ CheckName = CheckName!,
+ OpName = OpName!,
+ Password = Password!,
+ CreateTime = DateTime.Now
+ };
+
+ _fsql.Insert(entity).ExecuteAffrows();
+ });
+
+ _log.Info($"新增称重用户成功: {CheckName}/{OpName}");
+ }
+
+ private async Task UpdateAsync()
+ {
+ var id = _editUserId;
+ if (id <= 0) throw new InvalidOperationException("编辑称重用户Id无效");
+
+ await Task.Run(() =>
+ {
+ var aff = _fsql.Update()
+ .Set(a => a.CheckName, CheckName!)
+ .Set(a => a.OpName, OpName!)
+ .Set(a => a.Password, Password!)
+ .Where(a => a.Id == id)
+ .ExecuteAffrows();
+
+ if (aff <= 0)
+ {
+ throw new InvalidOperationException("更新失败:称重用户不存在或未发生变化");
+ }
+ });
+
+ _log.Info($"更新称重用户成功: Id={id}, CheckName={CheckName}, OpName={OpName}");
+ }
+ }
+}
diff --git a/FATrace.WPLApp/ViewModels/MainViewModel.cs b/FATrace.WPLApp/ViewModels/MainViewModel.cs
index ab8d451..9631190 100644
--- a/FATrace.WPLApp/ViewModels/MainViewModel.cs
+++ b/FATrace.WPLApp/ViewModels/MainViewModel.cs
@@ -235,6 +235,9 @@ namespace FATrace.WPLApp.ViewModels
case "用户管理":
this.regionManager.Regions["ContentRegion"].RequestNavigate("UserManageView");
break;
+ case "称重用户":
+ this.regionManager.Regions["ContentRegion"].RequestNavigate("WeightUserManageView");
+ break;
case "使用手册":
this.regionManager.Regions["ContentRegion"].RequestNavigate("HelpManualView");
break;
diff --git a/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs b/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs
index e0398af..c57caf9 100644
--- a/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs
+++ b/FATrace.WPLApp/ViewModels/OEMInventoryTransactionViewModel.cs
@@ -50,6 +50,9 @@ namespace FATrace.WPLApp.ViewModels
private string? _origin;
public string? Origin { get => _origin; set { _origin = value; RaisePropertyChanged(); } }
+ private string? _batch;
+ public string? Batch { get => _batch; set { _batch = value; RaisePropertyChanged(); } }
+
private string? _rawCode;
public string? RawCode { get => _rawCode; set { _rawCode = value; RaisePropertyChanged(); } }
@@ -107,7 +110,7 @@ namespace FATrace.WPLApp.ViewModels
private void ClearFilters()
{
- Origin = RawCode = RawName = string.Empty;
+ Origin = Batch = RawCode = RawName = string.Empty;
StartDate = null;
EndDate = null;
}
@@ -126,6 +129,8 @@ namespace FATrace.WPLApp.ViewModels
if (!string.IsNullOrWhiteSpace(Origin))
q = q.Where(a => a.Origin != null && a.Origin.Contains(Origin));
+ if (!string.IsNullOrWhiteSpace(Batch))
+ q = q.Where(a => a.Batch != null && a.Batch.Contains(Batch));
if (!string.IsNullOrWhiteSpace(RawCode))
q = q.Where(a => a.RawCode != null && a.RawCode.Contains(RawCode));
if (!string.IsNullOrWhiteSpace(RawName))
diff --git a/FATrace.WPLApp/ViewModels/WeightUserManageViewModel.cs b/FATrace.WPLApp/ViewModels/WeightUserManageViewModel.cs
new file mode 100644
index 0000000..ba08e2c
--- /dev/null
+++ b/FATrace.WPLApp/ViewModels/WeightUserManageViewModel.cs
@@ -0,0 +1,367 @@
+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(TbWeightUser 增删改查)
+ ///
+ public class WeightUserManageViewModel : NavigationViewModel
+ {
+ private readonly IFreeSql _fsql;
+ private readonly ILogService _log;
+ private readonly IDialogService _dialog;
+ private readonly SysRunService _sys;
+
+ public WeightUserManageViewModel(IFreeSql fsql, ILogService log, IDialogService dialogService, SysRunService sys)
+ {
+ _fsql = fsql;
+ _log = log;
+ _dialog = dialogService;
+ _sys = sys;
+
+ 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, UserManageViewModel.AccessLevels.Admin, StringComparison.OrdinalIgnoreCase);
+
+ #region 查询条件
+ private string? _checkName;
+ ///
+ /// 确认者(模糊匹配)
+ ///
+ public string? CheckName
+ {
+ get => _checkName;
+ set { _checkName = value; RaisePropertyChanged(); }
+ }
+
+ private string? _opName;
+ ///
+ /// 操作者(模糊匹配)
+ ///
+ public string? OpName
+ {
+ get => _opName;
+ set { _opName = value; RaisePropertyChanged(); }
+ }
+ #endregion
+
+ #region 列表与分页
+ public ObservableCollection Items { get; }
+
+ private TbWeightUser? _selectedItem;
+ ///
+ /// 当前选中行
+ ///
+ public TbWeightUser? 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()
+ {
+ CheckName = string.Empty;
+ OpName = string.Empty;
+ }
+
+ private async Task SearchAsync()
+ {
+ if (IsBusy) return;
+ try
+ {
+ IsBusy = true;
+ await SearchCoreAsync();
+ }
+ catch (Exception ex)
+ {
+ _log.Error($"TbWeightUser 查询失败: {ex}");
+ MessageBox.Show($"查询失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task SearchCoreAsync()
+ {
+ _log.Info("TbWeightUser 查询开始");
+
+ var data = await Task.Run(() =>
+ {
+ var q = _fsql.Select();
+
+ if (!string.IsNullOrWhiteSpace(CheckName))
+ q = q.Where(a => a.CheckName != null && a.CheckName.Contains(CheckName));
+
+ if (!string.IsNullOrWhiteSpace(OpName))
+ q = q.Where(a => a.OpName != null && a.OpName.Contains(OpName));
+
+ 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($"TbWeightUser 查询完成,记录数: {TotalCount}");
+ }
+
+ private void AddUser()
+ {
+ var p = new DialogParameters
+ {
+ { DialogKeys.Mode, DialogModes.Add }
+ };
+
+ _dialog.ShowDialog("DialogWeightUserEditView", 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("DialogWeightUserEditView", p, async r =>
+ {
+ if (r.Result == ButtonResult.OK)
+ {
+ await SearchAsync();
+ }
+ });
+ }
+
+ private async Task DeleteUserAsync()
+ {
+ if (SelectedItem == null)
+ {
+ MessageBox.Show("请选择要删除的用户", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var ok = MessageBox.Show($"确认删除称重用户:{SelectedItem.CheckName}/{SelectedItem.OpName} ?", "确认", 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();
+ }
+
+ ///
+ /// 弹窗参数 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 28fd451..ffa48ba 100644
--- a/FATrace.WPLApp/Views/DashboardView.xaml
+++ b/FATrace.WPLApp/Views/DashboardView.xaml
@@ -237,7 +237,6 @@
BorderThickness="1"
CornerRadius="6">