WPF 上位机开发笔记:IO 监控与操作日志模

WPF 上位机开发笔记:IO 监控与操作日志模块


目录

  1. 项目结构总览
  2. [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. 操作日志模块
    • 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. 架构设计要点
    • 4.1 MVVM + Navigation Delegate
    • 4.2 IO 页面不停止轮询的原因
    • 4.3 大端字节序转换
    • 4.4 软删除设计模式
  5. 踩坑记录

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 / AfterValuestring?:因为有些操作(如启动/停止)没有前后值对比。
  • 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=查询主列表
}

设计要点

  • 所有筛选字段为 nullablenull 表示不启用该筛选。
  • 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.CreatedAtDateTime 类型,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.Descriptionstring 而非 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. 架构设计要点

核心思想: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)