WPF 上位机开发笔记:IO 监控与操作日志模块
目录
- 项目结构总览
- [IO 监控模块](#IO 监控模块)
- 2.1 界面布局(IoMonitorPage.xaml)
- 2.2 后端属性(MainViewModel DI/DO 属性)
- 2.3 轮询读取逻辑(PollAllAxes)
- 2.4 跳转逻辑与页面生命周期
- 2.5 Modbus 地址映射(PlcAddressMap)
- 2.6 Modbus 通信基类(ModbusServiceBase)
- 2.7 Modbus 字节序转换(ModbusHelper)
- 操作日志模块
- 3.1 数据模型(LogEntry)
- 3.2 查询参数模型(LogQuery)
- 3.3 SQLite 数据层(SqliteeData)
- 3.3.1 建表(InitDataBase)
- 3.3.2 插入日志(InsertLog)
- 3.3.3 分页查询(QueryLogs)
- 3.3.4 软删除与还原(SoftDelete / Restore)
- 3.3.5 永久删除(PermanentDelete)
- 3.3.6 清空回收站(ClearRecycleBin)
- 3.3.7 清理过期记录(ClearExpired)
- 3.4 操作日志界面(OperationLogPage)
- 3.5 SQLite 常见 Bug 避坑
- 架构设计要点
- 4.1 MVVM + Navigation Delegate
- 4.2 IO 页面不停止轮询的原因
- 4.3 大端字节序转换
- 4.4 软删除设计模式
- 踩坑记录
1. 项目结构总览
UpperMachine/
├── Models/ # 数据模型
│ ├── AxisData.cs # 轴数据(状态 + 运动参数)
│ ├── AxisParam.cs # 轴参数(含上下限属性)
│ ├── LogEntry.cs # 操作日志实体
│ └── PlcAddressMap.cs # Modbus 地址常量
├── ViewModels/ # ViewModel 层(MVVM)
│ ├── MainViewModel.cs # 主 ViewModel(连接控制、4 轴数据、IO 监控、导航)
│ └── RelayCommand.cs # ICommand 实现(按钮绑定用)
├── View/ # 页面层
│ ├── HomePage.xaml # 首页(4 轴状态 + 导航)
│ ├── IoMonitorPage.xaml # IO 监控页面(DI/DO 指示灯)
│ ├── OperationLogPage.xaml# 操作日志页面(搜索/筛选/分页/回收站)
│ ├── AxisParamSettingsPage.xaml # 绝对参数设置
│ ├── ManualAdjustPage.xaml # 手动参数调整
│ └── LimitAxesPage.xaml # 限位参数设置
├── Services/
│ ├── ModbusServiceBase.cs # Modbus 通信基类
│ ├── ModbusTcpService.cs # TCP 子类
│ └── ModbusRtuService.cs # RTU 子类
├── Helpers/
│ └── ModbusHelper.cs # 浮点数 ↔ PLC 字节转换
├── Data/
│ ├── SqliteeData.cs # SQLite 数据访问层
│ └── LogQuery.cs # 日志查询参数
├── MainWindow.xaml # 主窗口(导航容器)
└── App.xaml # 应用入口 + 全局异常处理
2. IO 监控模块
2.1 界面布局(IoMonitorPage.xaml)
功能:实时显示 16 路 DI(数字量输入)和 16 路 DO(数字量输出)的通断状态。
布局结构:
- 顶部:返回按钮 + "I/O Monitor" 标题
- 中间分隔线
- 内容区:左右两列卡片
- 左列:Digital Inputs(DI01-DI16,2 行 × 8 列)
- 右列:Digital Outputs(DO01-DO16,2 行 × 8 列)
- 每个指示灯:
Ellipse控件,默认灰色(StatusStandByBrush),true 时变绿色(GreenBrush)
核心 XAML 讲解:
xml
<!-- IoMonitorPage.xaml ------ 整体布局 -->
<Page Background="{StaticResource BackgroundBrush}">
<!-- 三行网格:标题行 + 分隔线 + 内容区 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60"/> <!-- 标题行 -->
<RowDefinition Height="1"/> <!-- 分隔线 -->
<RowDefinition Height="*"/> <!-- 内容区 -->
</Grid.RowDefinitions>
<!-- ===== 返回按钮(箭头图标) ===== -->
<Button Grid.Row="0"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="3,10,0,0"
Background="{StaticResource BackgroundBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" Height="45" Width="45"
Click="BackButton_Click">
<Path Data="M18,2 L4,20 L18,38"
Stroke="{StaticResource AccentBrush}"
StrokeThickness="4"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"/>
</Button>
<!-- ===== 标题 ===== -->
<TextBlock Grid.Row="0" Text="I/O Monitor"
FontSize="30" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
<!-- ===== 内容区:两列(DI 左、DO 右) ===== -->
<Grid Grid.Row="2" Margin="10,15,10,15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <!-- DI 列 -->
<ColumnDefinition Width="*"/> <!-- DO 列 -->
</Grid.ColumnDefinitions>
<!-- ***** DI 卡片 ***** -->
<Border Grid.Column="0" Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="10" Margin="5">
<StackPanel Margin="10,10">
<TextBlock Text="Digital Inputs" FontSize="20" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}" HorizontalAlignment="Center"/>
<!-- 每个指示灯 = Ellipse + DataTrigger 颜色切换
关键设计:用 Style 包裹 Setter + Triggers,
绑定的 DI1-DI16 是 MainViewModel 中的 bool 属性
true → GreenBrush(绿色发光)
false → StatusStandByBrush(灰色待机) -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,10">
<!-- DI01 示例 -->
<StackPanel Margin="12,0" HorizontalAlignment="Center">
<Ellipse Width="30" Height="30">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource StatusStandByBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding DI1}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="DI01" FontSize="12"
Foreground="{StaticResource TextBrush}"
HorizontalAlignment="Center" Margin="0,3,0,0"/>
</StackPanel>
<!-- DI02-DI08 同上,略 -->
</StackPanel>
<!-- 第 2 行:DI09-DI16(同上结构,略) -->
</StackPanel>
</Border>
<!-- ***** DO 卡片(结构和 DI 完全一致,绑 DO1-DO16) ***** -->
</Grid>
</Grid>
</Page>
核心知识点:
DataTrigger:WPF 触发器,当绑定的属性等于指定值时触发样式改变。这里DI1=True时指示灯变绿。{StaticResource XXXBrush}:引用 App.xaml 中定义的全局画刷资源,暗色主题统一风格。Ellipse:WPF 椭圆形状控件,宽高相同就是正圆。
2.2 后端 IO 属性(MainViewModel.cs)
MainViewModel 中定义了 32 个 IO 属性(16 DI + 16 DO),每个都实现 INotifyPropertyChanged:
csharp
// MainViewModel.cs ------ 大约第 164-197 行
// ========== IO 监控属性 ==========
// 数字量输入属性(DI1-DI16)
public bool DI1 { get => _di1; set { _di1 = value; OnPropertyChanged(); } }
public bool DI2 { get => _di2; set { _di2 = value; OnPropertyChanged(); } }
// ... DI3-DI15 同上 ...
public bool DI16 { get => _di16; set { _di16 = value; OnPropertyChanged(); } }
// 数字量输出属性(DO1-DO16)
public bool DO1 { get => _do1; set { _do1 = value; OnPropertyChanged(); } }
public bool DO2 { get => _do2; set { _do2 = value; OnPropertyChanged(); } }
// ... DO3-DO15 同上 ...
public bool DO16 { get => _do16; set { _do16 = value; OnPropertyChanged(); } }
// 对应私有字段
private bool _di1, _di2, _di3, _di4, _di5, _di6, _di7, _di8;
private bool _di9, _di10, _di11, _di12, _di13, _di14, _di15, _di16;
private bool _do1, _do2, _do3, _do4, _do5, _do6, _do7, _do8;
private bool _do9, _do10, _do11, _do12, _do13, _do14, _do15, _do16;
// ========== INotifyPropertyChanged 标准实现 ==========
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
讲解:
- 每个属性都先更新私有字段,再调用
OnPropertyChanged()通知 WPF 引擎刷新 UI。 [CallerMemberName]特性:编译器自动将调用属性的名字作为参数传入,省去手写字符串。- XAML 中绑定的
{Binding DI1}会自动匹配到这里public bool DI1。
2.3 轮询读取 IO 逻辑(PollAllAxes)
PollAllAxes 方法由 DispatcherTimer 每 200ms 触发一次,同时读取 4 轴数据和 IO 状态:
csharp
// MainViewModel.cs ------ 约第 398-450 行
// 定时器轮询所有轴的信息 + IO 状态
private void PollAllAxes(object? sender, EventArgs e)
{
if (_service == null || !IsConnected) return; // 未连接则跳过
try
{
// ====== 读取 4 轴数据 ======
var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };
for (int i = 0; i < 4; i++)
{
int baseAddress = PlcAddressMap.PerAxisStartAddress[i]; // 各轴起始地址:0,22,44,66
var regs = _service.ReadHoldingRegisters(
(ushort)baseAddress, (ushort)PlcAddressMap.RegisterPerAxisCount); // 功能码 03
var data = axes[i].Data;
// 每 2 个寄存器拼一个 float(大端模式)
data.CurrentPos = ModbusHelper.ConvertPlcToFloat(regs[0], regs[1]);
data.CurrentVel = ModbusHelper.ConvertPlcToFloat(regs[2], regs[3]);
data.CurrentTorque = ModbusHelper.ConvertPlcToFloat(regs[4], regs[5]);
data.AbsoPos = ModbusHelper.ConvertPlcToFloat(regs[6], regs[7]);
data.AbsoVel = ModbusHelper.ConvertPlcToFloat(regs[8], regs[9]);
data.AbsoAccel = ModbusHelper.ConvertPlcToFloat(regs[10], regs[11]);
data.AbsoDecel = ModbusHelper.ConvertPlcToFloat(regs[12], regs[13]);
data.RelPos = ModbusHelper.ConvertPlcToFloat(regs[14], regs[15]);
data.RelVel = ModbusHelper.ConvertPlcToFloat(regs[16], regs[17]);
data.RelAccel = ModbusHelper.ConvertPlcToFloat(regs[18], regs[19]);
data.RelDecel = ModbusHelper.ConvertPlcToFloat(regs[20], regs[21]);
}
// ====== 读取 IO 状态(当前注释,逐功能码测试)======
// 功能码 02:ReadDiscreteInputs------读取 16 路 DI
// var di = _service.ReadDiscreteInputs(
// PlcAddressMap.DI_StartAddress, PlcAddressMap.DI_Count);
// if (di.Length >= 16)
// {
// DI1 = di[0]; DI2 = di[1]; DI3 = di[2]; DI4 = di[3];
// DI5 = di[4]; DI6 = di[5]; DI7 = di[6]; DI8 = di[7];
// DI9 = di[8]; DI10 = di[9]; DI11 = di[10]; DI12 = di[11];
// DI13 = di[12]; DI14 = di[13]; DI15 = di[14]; DI16 = di[15];
// }
// 功能码 01:ReadCoils------读取 16 路 DO
// var dout = _service.ReadCoils(
// PlcAddressMap.DO_StartAddress, PlcAddressMap.DO_Count);
// if (dout.Length >= 16)
// {
// DO1 = dout[0]; DO2 = dout[1]; DO3 = dout[2]; DO4 = dout[3];
// DO5 = dout[4]; DO6 = dout[5]; DO7 = dout[6]; DO8 = dout[7];
// DO9 = dout[8]; DO10 = dout[9]; DO11 = dout[10]; DO12 = dout[11];
// DO13 = dout[12]; DO14 = dout[13]; DO15 = dout[14]; DO16 = dout[15];
// }
}
catch (Exception ex)
{
// 轮询出错时只弹一次提示,之后停止轮询
if (!_hasShownPollingError)
{
_hasShownPollingError = true;
System.Windows.MessageBox.Show($"PLC响应失败,检测PLC是否已经连接{ex.Message}");
_pollingTimer?.Stop();
_pollingTimer = null;
_service?.Disconnect();
_service = null;
IsConnected = false;
}
}
}
讲解:
_service.ReadDiscreteInputs()对应 Modbus 功能码 02,读取离散量输入(DI)。_service.ReadCoils()对应 Modbus 功能码 01,读取线圈状态(DO)。- 当前注释了 IO 读取代码、只开寄存器读取,因为硬件一次只支持一个功能码,逐项测试。
- 轮询错误只提示一次,防止疯狂弹窗。
2.4 页面跳转与生命周期
csharp
// MainViewModel.cs ------ 约第 489-495 行
// 跳转 IO 监控页面
private void ExecuteIoMonitor()
{
var page = new IoMonitorPage();
page.DataContext = this; // 共享 MainViewModel 实例
NavigateToPage?.Invoke(page);
// 注意:这里没有 _pollingTimer?.Stop()
// IO 监控页面需要实时显示,所以不停止轮询
}
csharp
// IoMonitorPage.xaml.cs ------ 全部代码
namespace UpperMachine.View;
public partial class IoMonitorPage : Page
{
public IoMonitorPage()
{
InitializeComponent();
}
// 返回按钮 ------ 直接导航回首页
private void BackButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
var page = new HomePage();
page.DataContext = DataContext; // 传递同一个 MainViewModel
NavigationService.Navigate(page);
// 注意:这里没有 ResumePolling()
// 因为在 IoMonitorPage 轮询从未停止,所以不需要恢复
}
}
关键设计决策:
| 页面 | 进入时停止轮询? | 返回时恢复轮询? | 原因 |
|---|---|---|---|
| AxisParamSettingsPage | ✅ Stop | ✅ Resume | 写入参数时避免冲突 |
| ManualAdjustPage | ✅ Stop | ✅ Resume | 同上 |
| LimitAxesPage | ✅ Stop | ✅ Resume | 同上 |
| OperationLogPage | ✅ Stop | ✅ Resume | 不需要实时数据 |
| IoMonitorPage | ❌ 不停止 | ❌ 不恢复 | 需要实时刷新 DI/DO |
2.5 Modbus 地址映射(PlcAddressMap)
csharp
// PlcAddressMap.cs ------ IO 地址常量
namespace UpperMachine.Models;
public class PlcAddressMap
{
// 每个轴占用 22 个寄存器
public const int ParamPerAxis = 11; // 每个轴 11 个参数
public const int RegisterPerAxisCount = 22; // 每个轴占用 22 个寄存器(float×11)
// 四个轴的起始地址:轴 0 = 0, 轴 1 = 22, 轴 2 = 44, 轴 3 = 66
public static readonly int[] PerAxisStartAddress = new int[] { 0, 22, 44, 66 };
// =========== 线圈地址(控制命令) ===========
public static ushort Coil_Start = 0; // 启动
public static ushort Coil_Stop = 1; // 停止
public static ushort Coil_Pause = 2; // 暂停
public static ushort Coil_EmergencyStop = 3; // 急停
public static ushort Coil_ReturnHome = 4; // 回原
// =========== IO 监控地址 ===========
public static ushort DI_StartAddress = 0; // 离散量输入起始地址(功能码 02)
public static ushort DI_Count = 16; // DI 点数:16 路
public static ushort DO_StartAddress = 100; // 线圈输出起始地址(避开 0-4)
public static ushort DO_Count = 16; // DO 点数:16 路
}
讲解:
- DI 起始地址为 0,对应 PLC 的 DI 模块的第 0 位开始,连续读取 16 位。
- DO 起始地址为 100,因为 0-4 已被控制命令占用了,DO 从 100 开始避免冲突。
2.6 Modbus 通信基类(ModbusServiceBase)
csharp
// ModbusServiceBase.cs ------ 核心通信方法
public abstract class ModbusServiceBase : IDisposable
{
protected IModbusMaster? Master; // NModbus4 主站接口
public bool IsConnected { get; protected set; }
public CommConfig Config = new CommConfig();
public abstract bool Connect();
public abstract void Disconnect();
// ===== 5 个标准 Modbus 读写方法 =====
/// <summary>功能码 0x03:读取保持寄存器(可读可写,最常用)</summary>
public ushort[] ReadHoldingRegisters(ushort startAddress, ushort count)
{
if (!_connected()) throw new InvalidOperationException("未连接");
return Master!.ReadHoldingRegisters(Config.SlaveId, startAddress, count);
}
/// <summary>功能码 0x04:读取输入寄存器(只读传感器等)</summary>
public ushort[] ReadInputRegisters(ushort startAddress, ushort count) { ... }
/// <summary>功能码 0x02:读取离散量输入(读取 DI 开关量)</summary>
public bool[] ReadDiscreteInputs(ushort startAddress, ushort count)
{
if (!_connected()) throw new InvalidOperationException("未连接");
return Master!.ReadInputs(Config.SlaveId, startAddress, count);
}
/// <summary>功能码 0x01:读取线圈状态(读取 DO/开关量输出)</summary>
public bool[] ReadCoils(ushort startAddress, ushort count)
{
if (!_connected()) throw new InvalidOperationException("未连接");
return Master!.ReadCoils(Config.SlaveId, startAddress, count);
}
/// <summary>功能码 0x06:写入单个寄存器</summary>
public void WriteSingleRegister(ushort address, ushort value) { ... }
/// <summary>功能码 0x05:写入单个线圈</summary>
public void WriteSingleCoil(ushort address, bool value) { ... }
/// <summary>功能码 0x10:批量写入多个寄存器</summary>
public void WriteMultipleRegisters(ushort startAddress, ushort[] values) { ... }
}
讲解:
- 基类抽象了 TCP 和 RTU 的公共部分,每个方法都对应一个 Modbus 功能码。
IModbusMaster是 NModbus4 库的统一接口,TCP 用ModbusIpMaster,RTU 用ModbusSerialMaster。- 所有读写前都检查
_connected(),未连接则抛出异常,由 ViewModel 的 try-catch 捕获。
2.7 Modbus 字节序转换(ModbusHelper)
csharp
// ModbusHelper.cs ------ 大端字节序转换
namespace UpperMachine.Helpers;
public class ModbusHelper
{
/// <summary>
/// PLC 数据 → 上位机 float
/// PLC 中两个 16 位寄存器以大端方式存储一个 32 位 float
/// high 存高 16 位,low 存低 16 位
/// </summary>
public static float ConvertPlcToFloat(ushort high, ushort low)
{
// high << 16:将高 16 位移到高位
// | low:低 16 位填充低位
// 结果:32 位 uint,例如 0x40490FDB
uint raw = (uint)(high << 16 | low);
return BitConverter.Int32BitsToSingle((int)raw);
}
/// <summary>
/// 上位机 float → PLC 数据(大端)
/// 返回元组 (low, high):
/// - low:高 16 位(放在低地址)
/// - high:低 16 位(放在高地址)
/// 符合 PLC 大端规范
/// </summary>
public static (ushort low, ushort high) ConvertFloatToPlc(float? v)
{
if (v == null) throw new ArgumentNullException($"写入数据失败,参数可能为空");
int bits = BitConverter.SingleToInt32Bits(v.Value); // float → 32bit int
return (
(ushort)(bits >> 16), // 高 16 位 → low(低地址)
(ushort)(bits & 0xFFFF) // 低 16 位 → high(高地址)
);
}
}
字节序图解:
float 值: 3.14159
内存 (IEEE 754): 0x40490FDB
大端存储(PLC):
低地址寄存器 (regs[0]) = 0x4049 ← 高 16 位
高地址寄存器 (regs[1]) = 0x0FDB ← 低 16 位
ConvertPlcToFloat(0x4049, 0x0FDB):
raw = 0x4049 << 16 | 0x0FDB = 0x40490FDB
→ 3.14159
3. 操作日志模块
3.1 数据模型(LogEntry)
csharp
// LogEntry.cs ------ 操作日志实体
namespace UpperMachine.Models;
public class LogEntry
{
public int Id { get; set; } // 自增主键
public string Type { get; set; } = ""; // 操作类型:Control / Param Write / Limit Save / Connection / Validation
public string Operation { get; set; } = ""; // 具体操作:Start / Stop / AxisManualConfirm / SettingAbso 等
public string Description { get; set; } = ""; // 操作描述:对操作的可读说明
public string Level { get; set; } = "Info"; // 日志级别:Info / Warning / Error / Critical
public string? BeforeValue { get; set; } // 操作前的值
public string? AfterValue { get; set; } // 操作后的值
public DateTime CreatedAt { get; set; } = DateTime.Now; // 创建时间
public int DeleteStatus { get; set; } // 删除状态:0=正常, 1=回收站
public DateTime? DeletedAt { get; set; } // 软删除时间
}
设计要点:
BeforeValue/AfterValue用string?:因为有些操作(如启动/停止)没有前后值对比。CreatedAt默认DateTime.Now:创建时自动赋值。DeleteStatus+DeletedAt:实现软删除(标记删除而非真删)。
3.2 查询参数模型(LogQuery)
csharp
// LogQuery.cs ------ 日志查询筛选参数
namespace UpperMachine.Data;
internal class LogQuery
{
public int Page { get; set; } = 1; // 当前页码(默认第 1 页)
public int Size { get; set; } = 20; // 每页条数(默认 20 条/页)
public string? Search { get; set; } // 搜索关键词(模糊匹配 Description)
public string? Type { get; set; } // 按类型筛选(null 表示不限)
public string? Level { get; set; } // 按级别筛选(null 表示不限)
public bool IsRecycle { get; set; } // true=查询回收站, false=查询主列表
}
设计要点:
- 所有筛选字段为
nullable,null表示不启用该筛选。 - SQL 中使用
(@param IS NULL OR condition)模式。 IsRecycle控制查DeleteStatus=0(主列表)还是DeleteStatus=1(回收站)。
3.3 SQLite 数据层(SqliteeData)
3.3.1 建表(InitDataBase)
csharp
// SqliteeData.cs ------ 初始化数据库和表
public void InitDataBase()
{
// 确保 Data 目录存在
var dir = Path.GetDirectoryName(DbPath);
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir!);
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
create table if not exists OperationLogs(
Id integer primary key autoincrement, -- 自增主键
Type text not null, -- 操作类型
Operation text not null, -- 操作名
Description text, -- 描述(可空)
Level text default 'Info', -- 级别(默认 Info)
BeforeValue text, -- 操作前值(可空)
AfterValue text, -- 操作后值(可空)
CreatedAt text not null, -- 创建时间
DeleteStatus integer default 0, -- 删除标记:0=正常, 1=回收站
DeletedAt text -- 软删除时间(可空)
)";
cmd.ExecuteNonQuery();
}
注意:
- SQLite 没有专门的 DATETIME 类型,时间存为 TEXT(ISO 8601 格式
yyyy-MM-dd HH:mm:ss)。 if not exists防止重复建表报错。
3.3.2 插入日志(InsertLog)
csharp
// SqliteeData.cs ------ 插入日志记录
public void InsertLog(LogEntry log)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
insert into OperationLogs
(Type,Operation,Description,Level,BeforeValue,AfterValue,CreatedAt)
values (@Type,@Operation,@Description,@Level,@BeforeValue,@AfterValue,@CreatedAt)";
cmd.Parameters.AddWithValue("@Type", log.Type);
cmd.Parameters.AddWithValue("@Operation", log.Operation);
cmd.Parameters.AddWithValue("@Description", log.Description);
cmd.Parameters.AddWithValue("@Level", log.Level);
cmd.Parameters.AddWithValue("@BeforeValue", log.BeforeValue);
cmd.Parameters.AddWithValue("@AfterValue", log.AfterValue);
cmd.Parameters.AddWithValue("@CreatedAt", log.CreatedAt); // DateTime → 自动转 ISO 8601 文本
cmd.ExecuteNonQuery();
}
注意事项:
log.CreatedAt是DateTime类型,AddWithValue传给 SQLite 时会自动转为 ISO 8601 格式文本。- 注意不要写错列名:
CreatedAt不是CreateAt(常见拼写错误)。
3.3.3 分页查询(QueryLogs)
csharp
// SqliteeData.cs ------ 分页查询(同时返回总数)
public (List<LogEntry> Items, int Total) QueryLogs(LogQuery query)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
// ====== 第一步:查询匹配的总数量(用于分页计算)======
cmd.CommandText = @"
select count(*) from OperationLogs
where DeleteStatus = @status
and (@search is null or Description like '%' || @search || '%')
and (@type is null or Type = @type)
and (@level is null or Level = @level)";
// 绑定参数(注意:DBNull.Value 才是 SQL 的 NULL)
cmd.Parameters.AddWithValue("@status", query.IsRecycle ? 1 : 0);
cmd.Parameters.AddWithValue("@search", (object?)query.Search ?? DBNull.Value);
cmd.Parameters.AddWithValue("@type", (object?)query.Type ?? DBNull.Value);
cmd.Parameters.AddWithValue("@level", (object?)query.Level ?? DBNull.Value);
int total = Convert.ToInt32(cmd.ExecuteScalar() ?? 0);
// ↑↑↑ 坑:ExecuteScalar 返回 boxed long,不能用 (int) 直接强转
// 用 Convert.ToInt32 安全处理
// ====== 第二步:查询分页数据 ======
cmd.CommandText = @"
select * from OperationLogs where
DeleteStatus = @status
and (@search is null or Description like '%' || @search || '%')
and (@type is null or Type = @type)
and (@level is null or Level = @level)
order by CreatedAt desc
limit @size offset @offset"; -- 分页:LIMIT 限制条数,OFFSET 跳过前 N 条
cmd.Parameters.AddWithValue("@size", query.Size);
cmd.Parameters.AddWithValue("@offset", (query.Page - 1) * query.Size);
var items = new List<LogEntry>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
items.Add(new LogEntry
{
Id = reader.GetInt32(0), // Id
Type = reader.GetString(1), // Type(not null)
Operation = reader.GetString(2), // Operation(not null)
Description = reader.IsDBNull(3) // Description(可空)
? "" : reader.GetString(3), // 为空则返回空串
Level = reader.GetString(4), // Level
BeforeValue = reader.IsDBNull(5) // BeforeValue(可空)
? null : reader.GetString(5), // 不为空才读
AfterValue = reader.IsDBNull(6) // AfterValue(可空)
? null : reader.GetString(6),
CreatedAt = DateTime.Parse(reader.GetString(7)), // 文本 → DateTime
DeleteStatus = reader.GetInt32(8), // DeleteStatus
DeletedAt = reader.IsDBNull(9) // DeletedAt(可空)
? null : DateTime.Parse(reader.GetString(9)),
});
}
return (items, total);
}
关键点讲解:
| 问题 | 说明 |
|---|---|
?? DBNull.Value |
C# 的 null 传给 SQLite 参数会被理解为"不提供值"。必须显式转 DBNull.Value 才是 SQL 的 NULL |
Convert.ToInt32 |
ExecuteScalar() 返回的是 object?,COUNT(*) 的结果是 long 装箱。(int) 直接解箱会抛异常,要用 Convert.ToInt32 |
IsDBNull(n) |
数据库可空列(Description / BeforeValue / AfterValue / DeletedAt)读取前必须先判断是否为 NULL,否则 GetString 会抛 InvalidCastException |
Description 用 "" |
C# 的 LogEntry.Description 是 string 而非 string?,所以 null 时给空串 |
DateTime.Parse |
SQLite 存的是 ISO 8601 格式文本,DateTime.Parse 能正确解析 "2026-06-29 12:34:56" |
| LIMIT / OFFSET | OFFSET = (Page-1) * Size,第 1 页 OFFSET=0,第 2 页跳 20 条 |
3.3.4 软删除与还原(SoftDelete / Restore)
csharp
// 软删除------将日志移到回收站(标记删除)
public void SoftDelete(int id)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
update OperationLogs set
DeleteStatus = 1, -- 标记为已删除
DeletedAt = datetime('now') -- 记录删除时间
where Id = @id";
cmd.Parameters.AddWithValue("@id", id);
cmd.ExecuteNonQuery();
}
// 还原------将日志从回收站恢复到主列表
public void Restore(int id)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
update OperationLogs set
DeleteStatus = 0, -- 取消删除标记
DeletedAt = null -- 清空删除时间
where Id = @id";
cmd.Parameters.AddWithValue("@id", id);
cmd.ExecuteNonQuery();
}
注意:
datetime('now')是 SQLite 内置函数,返回当前 UTC 时间(格式YYYY-MM-DD HH:MM:SS)。Restore还原时记得把DeletedAt清为null,否则下次过期清理可能误删。
3.3.5 永久删除(PermanentDelete)
csharp
// 永久删除------直接从数据库删除(仅限回收站中的记录)
public void PermanentDelete(int id)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
Delete from OperationLogs
where Id = @id and DeleteStatus = 1"; -- 安全保护:只有回收站记录才能永久删除
cmd.Parameters.AddWithValue("@id", id);
cmd.ExecuteNonQuery();
}
设计要点:
- 加了
DeleteStatus = 1保护:如果误传了主列表的 ID,不会误删。 - 数据一旦 DELETE 无法恢复,所以只在"永久删除"按钮使用。
3.3.6 清空回收站(ClearRecycleBin)
csharp
// 清空回收站------永久删除所有已标记删除的记录
public void ClearRecycleBin()
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
Delete from OperationLogs
where DeleteStatus = 1";
cmd.ExecuteNonQuery();
}
3.3.7 清理过期记录(ClearExpired)
csharp
// 清理过期记录------删除回收站中超过指定天数的日志(默认 30 天)
public void ClearExpired(int days = 30)
{
using var conn = GetConnection();
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = @"
DELETE FROM OperationLogs
WHERE DeletedAt < @cutoff -- 删除时间早于截止日期
AND DeleteStatus = 1"; -- 安全保护:只删回收站的
// 计算 30 天前的日期字符串
// AddDays(-30) 表示"倒退 30 天"
var cutoff = DateTime.Now.AddDays(-days).ToString("yyyy-MM-dd HH:mm:ss");
cmd.Parameters.AddWithValue("@cutoff", cutoff);
cmd.ExecuteNonQuery();
}
AddDays(-days) 讲解:
今天是 6 月 29 日,days = 30
DateTime.Now = 2026-06-29 12:34:56
DateTime.Now.AddDays(-30) = 2026-05-30 12:34:56 ← 截止日期
SQL 执行:
DELETE FROM OperationLogs WHERE DeletedAt < '2026-05-30 12:34:56'
意思:删掉所有 5 月 30 日之前就被软删除的记录
→ 即删除了超过 30 天的回收站记录
3.4 操作日志界面(OperationLogPage)
功能概述:
- 两个 Tab:Active(主列表)和 Recycle Bin(回收站)
- 搜索框 + 类型/级别下拉筛选
- 7 列列表:Time / Level / Type / Operation / Description / Before→After / Actions
- 分页栏(20 条/页)
- Active Tab 每条记录右侧有删除按钮
- Recycle Tab 每条记录右侧有还原和永久删除按钮
- 左上角返回按钮,右上角 Export Excel 按钮
页面结构:
xml
<!-- OperationLogPage.xaml ------ 布局骨架 -->
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="50"/> <!-- Row 0: Header(标题 + Export 按钮) -->
<RowDefinition Height="Auto"/> <!-- Row 1: Search / Filter -->
<RowDefinition Height="40"/> <!-- Row 2: Tabs -->
<RowDefinition Height="*"/> <!-- Row 3: ListView(Active / Recycle 两个面板) -->
<RowDefinition Height="40"/> <!-- Row 4: Pagination -->
</Grid.RowDefinitions>
<!-- ===== Row 0: Header ===== -->
<Border Grid.Row="0" ...>
<Grid>
<Button Click="BackButton_Click">←</Button>
<TextBlock Text="Operation Log"/>
<Button Click="ExportButton_Click">Export Excel</Button>
</Grid>
</Border>
<!-- ===== Row 1: Search + Filter ===== -->
<Border Grid.Row="1" ...>
<TextBox x:Name="SearchBox" TextChanged="SearchBox_TextChanged"/>
<ComboBox x:Name="TypeFilter" SelectionChanged="Filter_Changed">
<ComboBoxItem Content="All Types"/>
<ComboBoxItem Content="Control"/>
<ComboBoxItem Content="Param Write"/>
<ComboBoxItem Content="Limit Save"/>
<ComboBoxItem Content="Connection"/>
<ComboBoxItem Content="Validation"/>
</ComboBox>
<ComboBox x:Name="LevelFilter" SelectionChanged="Filter_Changed">
<ComboBoxItem Content="All Levels"/>
<ComboBoxItem Content="Info"/>
<ComboBoxItem Content="Warning"/>
<ComboBoxItem Content="Error"/>
<ComboBoxItem Content="Critical"/>
</ComboBox>
<Button x:Name="ClearRecycleBtn" Click="ClearRecycleBin_Click"
Content="Empty Recycle Bin" Visibility="Collapsed"/>
<!-- 仅在回收站 Tab 显示 -->
</Border>
<!-- ===== Row 2: Tabs ===== -->
<Grid Grid.Row="2">
<Button x:Name="ActiveTabBtn" Click="ActiveTab_Click" Content="Active"/>
<Button x:Name="RecycleTabBtn" Click="RecycleTab_Click" Content="Recycle Bin"/>
</Grid>
<!-- ===== Row 3: Active Panel ===== -->
<Border x:Name="ActivePanel" Grid.Row="3">
<!-- 7 列列头 + ListView + 空状态提示 -->
<ListView x:Name="ActiveListView">
<ListView.ItemTemplate>
<DataTemplate DataType="models:LogEntry">
<!-- 7 列:Time / Level(带颜色圆点) / Type(带颜色标签) / Operation / Description / Before→After / Delete按钮 -->
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock Text="No operation logs yet."/>
</Border>
<!-- Recycle Panel(结构和 Active 类似,列数不同) -->
<Border x:Name="RecyclePanel" Grid.Row="3" Visibility="Collapsed">
<!-- 7 列:Time / Level / Type / Operation / Description / DeletedAt / Actions -->
<!-- Actions 列含 Restore 和 PermanentDelete 两个按钮 -->
</Border>
<!-- ===== Row 4: Pagination ===== -->
<Border Grid.Row="4">
<Button x:Name="PrevPageBtn" Click="PrevPage_Click" Content="< Prev"/>
<TextBlock x:Name="PageInfoText" Text="Page 1/1"/>
<Button x:Name="NextPageBtn" Click="NextPage_Click" Content="Next >"/>
</Border>
</Grid>
讲解:
- 使用两个
ListView(ActivePanel / RecyclePanel),通过Visibility切换显示哪个。 DataTrigger绑定Items.Count:列表为空时显示"空状态"提示文字。- 每个 Alerts 列使用
Ellipse颜色圆点 + 颜色标签Border,不同类型显示不同颜色。 - 分页按钮使用透明背景 +
AccentBrush文字,禁用时变LabelBrush。
当前页面代码状态:
OperationLogPage.xaml.cs中所有方法体目前为空(LoadSampleData/ApplyFilterAndRender/ 按钮事件等),需要下一步对接SqliteeData的 8 个方法。
csharp
// OperationLogPage.xaml.cs ------ 当前骨架(待填充)
public partial class OperationLogPage : Page
{
private bool _isRecycleView; // 当前是否为回收站视图
private int _currentPage = 1; // 当前页码
private const int PageSize = 20; // 每页条数
private int _totalPages = 1; // 总页数
private ObservableCollection<LogEntry> _activeItems = new();
private ObservableCollection<LogEntry> _recycleItems = new();
public OperationLogPage()
{
InitializeComponent();
LoadData(); // 初始化加载数据
}
private void LoadData()
{
// TODO: 用 SqliteeData.QueryLogs() 从 SQLite 查询
// 示例:
// var data = new SqliteeData();
// data.InitDataBase(); // 确保表存在
// var (items, total) = data.QueryLogs(new LogQuery
// {
// Page = _currentPage,
// Size = PageSize,
// IsRecycle = _isRecycleView
// });
// _activeItems = new ObservableCollection<LogEntry>(items);
// ActiveListView.ItemsSource = _activeItems;
// _totalPages = (int)Math.Ceiling((double)total / PageSize);
}
private void ActiveTab_Click(object sender, RoutedEventArgs e)
{
_isRecycleView = false;
_currentPage = 1;
ActivePanel.Visibility = Visibility.Visible;
RecyclePanel.Visibility = Visibility.Collapsed;
ClearRecycleBtn.Visibility = Visibility.Collapsed;
LoadData();
}
private void RecycleTab_Click(object sender, RoutedEventArgs e)
{
_isRecycleView = true;
_currentPage = 1;
ActivePanel.Visibility = Visibility.Collapsed;
RecyclePanel.Visibility = Visibility.Visible;
ClearRecycleBtn.Visibility = Visibility.Visible;
LoadData();
}
// Soft Delete 按钮事件
private void DeleteButton_Click(object sender, RoutedEventArgs e)
{
// 从 Button.Tag 取出 Id
// new SqliteeData().SoftDelete(id);
// 重新 LoadData()
}
// Restore 按钮事件
private void RestoreButton_Click(object sender, RoutedEventArgs e) { ... }
// PermanentDelete 按钮事件
private void PermanentDelete_Click(object sender, RoutedEventArgs e) { ... }
// ClearRecycleBin 按钮事件
private void ClearRecycleBin_Click(object sender, RoutedEventArgs e) { ... }
// Export 按钮事件
private void ExportButton_Click(object sender, RoutedEventArgs e)
{
// 用 ClosedXML 导出到 Excel
}
}
3.5 SQLite 常见 Bug 避坑
| 问题 | 错误写法 | 正确写法 | 后果 |
|---|---|---|---|
| 列名拼写 | CreateAt |
CreatedAt |
SQLite Error 1: table has no column named CreateAt |
| 参数名 | AddWithValue("id", ...) |
AddWithValue("@id", ...) |
参数不绑定,SQL 变量无值 |
| 可空列读取 | reader.GetString(3) |
reader.IsDBNull(3) ? "" : reader.GetString(3) |
InvalidCastException |
| ExecuteScalar 类型 | (int)(cmd.ExecuteScalar()) |
Convert.ToInt32(cmd.ExecuteScalar() ?? 0) |
InvalidCastException(long 不能直接解箱成 int) |
| 表名不存在 | from OperationLog |
from OperationLogs |
SQLite Error 1: no such table |
| 连接释放 | var conn = GetConnection() |
using var conn = GetConnection() |
连接泄漏,数据库文件被锁 |
| 隐式类型转换 | AddDays(30) 删除了错误方向的记录 |
AddDays(-30) |
逻辑完全相反 |
4. 架构设计要点
4.1 MVVM + Navigation Delegate
核心思想:ViewModel 不引用 WPF 的 UI 类型(Frame / Page),通过委托实现导航解耦。
csharp
// MainViewModel 中定义导航委托
public Action<Page>? NavigateToPage { get; set; }
// 跳转方法
public void ExecuteNavigateToAxesParam()
{
_pollingTimer?.Stop();
var page = new AxisParamSettingsPage();
page.DataContext = this; // 共享同一个 ViewModel
NavigateToPage?.Invoke(page); // 委托实际导航给 MainWindow
}
// MainWindow.xaml.cs 注入委托
var vm = new MainViewModel();
vm.NavigateToPage = page => MainFrame.Navigate(page);
好处:
- ViewModel 纯逻辑,可单元测试。
- 换 UI 框架不影响业务逻辑。
4.2 IO 页面不停止轮询的原因
| 页面类型 | 进入时是否停轮询 | 原因 |
|---|---|---|
| 参数设置页 | ✅ 停止 | 写入参数时轮询会覆盖文本框值 |
| 参数设置页 | ✅ 停止 | 写入参数时轮询会覆盖文本框值 |
| IO 监控页 | ❌ 不停 | 需要实时显示 DI/DO 通断状态变化 |
| 操作日志页 | ✅ 停止 | 不需要实时数据 |
4.3 大端字节序转换
PLC 用大端(Big-endian):高字节在低地址。
寄存器 0 (低地址) = 0x4049 → 高 16 位
寄存器 1 (高地址) = 0x0FDB → 低 16 位
合并: 0x4049_0FDB = 3.14159 (float IEEE 754)
4.4 软删除设计模式
┌─────────────┐ 删除按钮 ┌──────────────┐ 永久删除 ┌──────────┐
│ Active Tab │ ──────────────→ │ Recycle Bin │ ────────────→ │ 真删除 │
│ (DeleteStatus=0)│ SoftDelete │ (DeleteStatus=1)│ │ (DELETE) │
└─────────────┘ └──────────────┘ └──────────┘
↑ │
│ 还原按钮 │
└──────────────────────────────┘
Restore
5. 踩坑记录
1. WPF Binding 大小写敏感
xml
<!-- 错误 ❌ -->
<TextBox Text="{Binding accelUpLimit}"/>
<!-- 正确 ✅(必须和 C# 属性名完全一致) -->
<TextBox Text="{Binding AccelUpLimit}"/>
后果:绑定不报错也不生效,UI 一片空白。
2. (int)(ExecuteScalar()) 解箱异常
csharp
// 错误 ❌
int total = (int)(cmd.ExecuteScalar() ?? 0);
// ExecuteScalar() 返回 boxed long,不能直接解箱为 int → InvalidCastException
// 正确 ✅
int total = Convert.ToInt32(cmd.ExecuteScalar() ?? 0);
3. SQLite 列名拼写 CreateAt vs CreatedAt
SQLite 不会帮你纠错,CREATE TABLE 时写的是 CreatedAt,INSERT 时写成 CreateAt 就会报错。
4. 回收站操作保护
sql
-- 永久删除时一定要加 DeleteStatus = 1 保护
DELETE FROM OperationLogs WHERE Id = @id AND DeleteStatus = 1
-- 防止误传了主列表的 ID
本笔记由 AI 辅助整理,发布于 CSDN 博客用于复习参考。
项目代码仓库:
UpperMachine(WPF .NET 10 + MVVM + Modbus + SQLite)