WPF上位机开发:操作日志完整模块(数据库→ViewModel→UI→Excel导出全链路)

WPF 上位机开发笔记:操作日志完整模块(数据库→ViewModel→UI→Excel导出全链路)

日期:2026-06-30

项目:四轴运动控制上位机(.NET 10 WPF MVVM + NModbus4 + SQLite + ClosedXML)

NuGet:Microsoft.Data.Sqlite ClosedXML NModbus4 System.IO.Ports


一、功能全景图

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    操作日志完整架构                           │
├───────────┬───────────┬───────────┬─────────────────────────┤
│  DB层     │ ViewModel │  UI页面   │  用户交互                │
│ SqliteData│ SafeLog() │ XAML布局  │  搜索/筛选/分页/导出     │
│ 8个方法   │ 9处调用   │ 5行布局   │  删除/恢复/永久删/清空   │
└───────────┴───────────┴───────────┴─────────────────────────┘

涉及的文件清单

文件 行数 作用
Models/LogEntry.cs 15 日志实体模型
Data/LogQuery.cs 21 分页+筛选查询参数
Data/SqliteData.cs 180 完整SQLite数据访问层(8个方法)
ViewModels/MainViewModel.cs 877 SafeLog() + 9处日志写入
View/OperationLogPage.xaml 583 完整UI界面(5行布局)
View/OperationLogPage.xaml.cs 247 所有事件处理逻辑
App.xaml.cs 35 全局异常处理->error.log
UpperMachine.csproj 30 NuGet依赖: Sqlite + ClosedXML

二、数据模型层

2.1 LogEntry.cs ------ 日志实体

csharp 复制代码
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 / Pause / Emergency / Return Home / Abso Apply / Axis1 Manual / Axis1 Limit
    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; }        // 软删除的时间戳
}

10个属性映射数据库10列。BeforeValue / AfterValue 预留用于未来记录操作前后的值变化。

2.2 LogQuery.cs ------ 查询参数模型

csharp 复制代码
namespace UpperMachine.Data
{
    internal class LogQuery
    {
        public int Page { get; set; } = 1;          // 当前页码
        public int Size { get; set; } = 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,在SQL中通过 @param is null 跳过条件。


三、数据库层(SqliteData.cs 完整详解)

3.1 数据库路径与连接

csharp 复制代码
private static readonly string DbPath = Path.Combine(
    AppDomain.CurrentDomain.BaseDirectory, "Data", "OperationLog.db");

private SqliteConnection GetConnection()
    => new SqliteConnection($"Data Source={DbPath}");

数据库文件位于 bin/Debug/net10.0-windows/Data/OperationLog.db,随程序部署。

3.2 InitDataBase() ------ 建表

结构:select count(*) from OperationLogs 10列

csharp 复制代码
public void InitDataBase()
{
    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
        "DeletedAt text)";                              // 删除时间(可空)
    cmd.ExecuteNonQuery();
}

注意点:

  • CreatedAtDeletedAt 类型为 text(SQLite没有DateTime类型),存储 "yyyy-MM-dd HH:mm:ss" 格式字符串
  • DeleteStatus 默认0,软删除时改为1

3.3 InsertLog() ------ 写入日志

csharp 复制代码
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",(Object?) log.Description ?? DBNull.Value);
    cmd.Parameters.AddWithValue("@Level", log.Level);
    cmd.Parameters.AddWithValue("@BeforeValue", (object?)log.BeforeValue ?? DBNull.Value);
    cmd.Parameters.AddWithValue("@AfterValue", (object?)log.AfterValue ?? DBNull.Value);
    cmd.Parameters.AddWithValue("@CreatedAt", log.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
    cmd.ExecuteNonQuery();
}

关键陷阱

  • AddWithValuenull 会抛 InvalidOperationException → 必须用 (object?)value ?? DBNull.Value
  • CreatedAt 不用 DateTime 类型参数,而是先转字符串 → 避免不同Sqlite版本对DateTime处理不一致
  • Description / BeforeValue / AfterValue 三个可空列都做了DBNull转换

3.4 QueryLogs() ------ 分页查询(核心方法)

csharp 复制代码
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)";
    
    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));
    
    // ===== 第二步:查询分页数据 =====
    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";
    
    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),
            Type = reader.GetString(1),
            Operation = reader.GetString(2),
            Description = reader.IsDBNull(3) ? "" : reader.GetString(3),
            Level = reader.GetString(4),
            BeforeValue = reader.IsDBNull(5) ? null : reader.GetString(5),
            AfterValue = reader.IsDBNull(6) ? null : reader.GetString(6),
            CreatedAt = DateTime.Parse(reader.GetString(7)),
            DeleteStatus = reader.GetInt32(8),
            DeletedAt = reader.IsDBNull(9) ? null : DateTime.Parse(reader.GetString(9)),
        });
    }
    return (items, total);
}

核心技巧

技巧 说明
`@search is null or Description like '%'
@type is null or Type=@type 类型筛选为null时跳过
Convert.ToInt32(ExecuteScalar() ?? 0) COUNT(*) 返回 boxed long,不能用 (int) 强转
reader.IsDBNull(n) 先判断 可空列读取前必须先检查,否则 GetString() 抛异常
DateTime.Parse(reader.GetString(7)) 从字符串反序列化DateTime

3.5 SoftDelete() ------ 软删除

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','localtime') where Id=@id";
    cmd.Parameters.AddWithValue("@id", id);
    cmd.ExecuteNonQuery();
}

关键点:

  • DeleteStatus = 1 标记删除
  • DeletedAt = datetime('now','localtime') 用SQLite函数记录本地时间(东八区),而不是UTC
  • 这样UI上显示的删除时间直接就是正确的北京时间

3.6 Restore() ------ 从回收站恢复

csharp 复制代码
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();
}

恢复就是把 DeleteStatus 改回0,DeletedAt 清空。

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

安全保护:and DeleteStatus = 1 防止误删还在Active面板中的记录。

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

直接删除所有 DeleteStatus=1 的记录。

3.9 ClearExpired() ------ 自动清理过期数据

csharp 复制代码
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";
    var cutoff = DateTime.Now.AddDays(-days).ToString("yyyy-MM-dd HH:mm:ss");
    cmd.Parameters.AddWithValue("@cutoff", cutoff);
    cmd.ExecuteNonQuery();
}

LoadData() 开头自动调用,每次加载数据前清理超过30天的回收站记录。无需用户手动操作。


四、ViewModel 层(MainViewModel 日志集成)

4.1 数据库初始化

在 MainViewModel 构造函数中:

csharp 复制代码
public MainViewModel()
{
    _db.InitDataBase();  // 程序启动时建表(如果不存在)
    // ... 初始化所有 Command ...
}

4.2 SafeLog() ------ 安全写入辅助方法

csharp 复制代码
private void SafeLog(string type, string operation, string desc, string level)
{
    try
    {
        _db.InsertLog(new LogEntry
        {
            Type = type,
            Operation = operation,
            Description = desc,
            Level = level
        });
    }
    catch { }  // 日志失败绝不能影响主流程
}

设计理念 :空的 try-catch { } 保证:

  1. 日志写入失败不会抛异常
  2. 不会中断按钮操作
  3. 不会弹出错误对话框打扰用户

4.3 9处日志写入调用全景

按钮/操作 Type Operation 级别 Description
Connect Connection Disconnect Info "断开连接"
Connect成功 Connection Connect (TCP/RTU) Info "连接成功"
Connect失败 Connection Connect (TCP/RTU) Error "连接失败: {ex.Message}"
Start Control Start Info/Error "自动启动,成功" / "失败,原因:{ex.Message}"
Stop Control Stop Info/Error "缓停启动,成功" / "失败,原因:{ex.Message}"
Pause Control Pause Info/Error "暂停启动,成功" / "失败,原因:{ex.Message}"
Emergency Control Emergency Info/Error "急停启动,成功" / "失败,原因:{ex.Message}"
ReturnHome Control Return Home Info/Error "回原启动,成功" / "失败,原因:{ex.Message}"
Abso Apply Param Write Abso Apply Info/Error 含4轴×4参数字段值
Axis1-4 Manual Param Write Axis{1-4} Manual Info/Error 含RelPos/Vel/Accel/Decel值
Axis1-4 Limit Save Limit Save Axis{1-4} Limit Info/Error 含Vel/Accel/Decel/Torque上下限

4.4 控制按钮日志实现

以 Start 为例:

csharp 复制代码
private void ExecuteStart()
{
    if (Is_Running) return;
    if (_service == null || !IsConnected) return;
    try
    {
        _service.WriteSingleCoil(PlcAddressMap.Coil_Start, true);
        Is_Running = true;
        // 更新4轴状态为 Running
        SafeLog("Control", "Start", "自动启动,成功", "Info");
    }
    catch (Exception ex)
    {
        SafeLog("Control", "Start", $"自动启动,失败,原因:{ex.Message}", "Error");
        System.Windows.MessageBox.Show($"启动失败: {ex.Message}");
    }
}

Stop、Pause、Emergency、ReturnHome 完全相同的模式:try写入PLC → 成功SafeLog(Info) → 失败SafeLog(Error)。

4.5 绝对参数写入日志

Abso Apply 需要记录4个轴×4个参数的完整值:

csharp 复制代码
public void ExecuteSettingAbso()
{
    // ... 写入PLC ...
    var logDesc = string.Join("; ", Axes.Select((a, i) =>
        $"Axis{i + 1}: Pos={a.Data.AbsoPos}, Vel={a.Data.AbsoVel}, Accel={a.Data.AbsoAccel}, Decel={a.Data.AbsoDecel}"));
    SafeLog("Param Write", "Abso Apply", logDesc, "Info");
}

生成的 Description 示例:

复制代码
Axis1: Pos=100, Vel=500, Accel=1000, Decel=800; Axis2: Pos=200, Vel=600, ...

4.6 手动参数写入日志

每个轴的Manual Confirm单独写日志:

csharp 复制代码
var logDesc = $"Axis{index + 1}: RelPos={data.RelPos}, RelVel={data.RelVel}, RelAccel={data.RelAccel}, RelDecel={data.RelDecel}";
SafeLog("Param Write", $"Axis{index + 1} Manual", logDesc, "Info");

4.7 限值保存日志

csharp 复制代码
var logDesc = $"Axis{index + 1}: Vel=[{data.VelLowerLimit}~{data.VelUpLimit}], " +
    $"Accel=[{data.AccelLowerLimit}~{data.AccelUpLimit}], " +
    $"Decel=[{data.DecelLowerLimit}~{data.DecelUpLimit}], " +
    $"Torque=[{data.TorqueLowerLimit}~{data.TorqueUpLimit}]";
SafeLog("Limit Save", $"Axis{index + 1} Limit", logDesc, "Info");

4.8 导航到操作日志页面

csharp 复制代码
private void ExecuteNavigateToOperationLog()
{
    _pollingTimer?.Stop();          // 进入日志页面前暂停轮询
    var page = new OperationLogPage();
    page.DataContext = this;        // 传入 MainViewModel 作为 DataContext
    NavigateToPage?.Invoke(page);   // 触发 MainWindow 导航
}

五、UI 界面(OperationLogPage.xaml 完整详解)

5.1 整体布局结构

复制代码
┌─────────────────────────────────────────────────────────────┐
│ Row 0: Header (50px)                                        │
│  ┌─← Back─┐       Operation Log        ┌─Export Excel─┐   │
│  └─────────┘                            └──────────────┘   │
├─────────────────────────────────────────────────────────────┤
│ Row 1: Search / Filter (Auto)                               │
│  ┌─Search Box────┐ ┌─Type Filter─┐ ┌─Level Filter─┐ 清空   │
│  │ Search desc..  │ │ All Types ▼ │ │ All Levels ▼│ 回收站  │
│  └───────────────┘ └─────────────┘ └──────────────┘ 按钮   │
├─────────────────────────────────────────────────────────────┤
│ Row 2: Tabs (40px)                                          │
│  ┌── Active ──┐ ┌── Recycle Bin ──┐                        │
│  └────────────┘ └─────────────────┘                        │
├─────────────────────────────────────────────────────────────┤
│ Row 3: Content (*)                                          │
│  ActivePanel (默认显示) 或 RecyclePanel (显隐切换)           │
│  ListView + Column Headers                                 │
├─────────────────────────────────────────────────────────────┤
│ Row 4: Pagination (40px)                                    │
│  ┌─Prev─┐ Page 1/3 ┌─Next─┐          20 per page           │
│  └──────┘           └──────┘                               │
└─────────────────────────────────────────────────────────────┘

5.2 Row 0: 标题栏

xml 复制代码
<Border Grid.Row="0" Background="{StaticResource HeaderBrush}"
        BorderBrush="{StaticResource BorderBrush}"
        BorderThickness="0,0,0,1" CornerRadius="10,10,0,0">
    <Grid>
        <!-- 返回按钮:Path绘制 ← 箭头 -->
        <Button HorizontalAlignment="Left" ... Click="BackButton_Click">
            <Path Data="M16,4 L4,20 L16,36"
                  Stroke="{StaticResource AccentBrush}" StrokeThickness="4"/>
        </Button>
        <!-- 标题 -->
        <TextBlock Text="Operation Log" FontSize="26" FontWeight="Bold"
                   Foreground="{StaticResource AccentBrush}" .../>
        <!-- 导出按钮 -->
        <Button ... Click="ExportButton_Click">
            <StackPanel Orientation="Horizontal">
                <TextBlock Text="+" FontSize="18"/>
                <TextBlock Text="Export Excel"/>
            </StackPanel>
        </Button>
    </Grid>
</Border>

5.3 Row 1: 搜索筛选栏

搜索框 + Placeholder 水印效果

xml 复制代码
<Border Background="{StaticResource PanelBrush}" ...>
    <TextBox x:Name="SearchBox"
             Background="Transparent" BorderThickness="0"
             Foreground="{StaticResource TextBrush}"
             FontSize="14" Padding="8,4"
             TextChanged="SearchBox_TextChanged"/>
</Border>
<!-- 水印文字:仅在TextBox为空时显示 -->
<TextBlock Text="  Search description..."
           FontSize="14" Foreground="{StaticResource LabelBrush}"
           IsHitTestVisible="False">
    <TextBlock.Style>
        <Style TargetType="TextBlock">
            <Setter Property="Visibility" Value="Collapsed"/>
            <Style.Triggers>
                <DataTrigger Binding="{Binding Text.Length, ElementName=SearchBox}" Value="0">
                    <Setter Property="Visibility" Value="Visible"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </TextBlock.Style>
</TextBlock>

小技巧:用 DataTrigger 监听 TextBox.Text.Length==0 控制水印显隐,实现placeholder效果。

类型筛选 TypeFilter

xml 复制代码
<ComboBox x:Name="TypeFilter" ... SelectionChanged="Filter_Changed">
    <ComboBoxItem Content="All Types" IsSelected="True"/>
    <ComboBoxItem Content="Control"/>
    <ComboBoxItem Content="Param Write"/>
    <ComboBoxItem Content="Limit Save"/>
    <ComboBoxItem Content="Connection"/>
    <ComboBoxItem Content="Validation"/>
</ComboBox>

级别筛选 LevelFilter

xml 复制代码
<ComboBox x:Name="LevelFilter" ... SelectionChanged="Filter_Changed">
    <ComboBoxItem Content="All Levels" IsSelected="True"/>
    <ComboBoxItem Content="Info"/>
    <ComboBoxItem Content="Warning"/>
    <ComboBoxItem Content="Error"/>
    <ComboBoxItem Content="Critical"/>
</ComboBox>

每个 ComboBoxItem 的 IsSelected="True" 设为第一个(All),确保初始时不过滤。

5.4 Row 2: Tab 切换

xml 复制代码
<Button x:Name="ActiveTabBtn"
        Content="  Active"
        Background="{StaticResource AccentBrush}"   <!-- 默认选中态 -->
        Foreground="White"
        Click="ActiveTab_Click"/>

<Button x:Name="RecycleTabBtn"
        Content="  Recycle Bin"
        Background="{StaticResource PanelBrush}"     <!-- 默认非选中态 -->
        Foreground="{StaticResource TextBrush}"
        Click="RecycleTab_Click"/>

ActiveTab 初始高亮(AccentBrush),点击切换时通过代码后置交换按钮样式。

5.5 Row 3: Active 面板

5.5.1 列定义(7列)
列索引 宽度 内容 对齐
0 150px Time Center
1 75px Level(带颜色圆点) Center
2 95px Type(彩色标签) Center
3 95px Operation Center
4 * (MaxWidth=700) Description(可截断) Center
5 1px 分隔线 Center
6 100px Delete 按钮 Right

列头

xml 复制代码
<Border Grid.Row="0" Background="{StaticResource PanelBrush}">
    <Grid Height="32">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="160"/>
            <ColumnDefinition Width="75"/>
            <ColumnDefinition Width="95"/>
            <ColumnDefinition Width="95"/>
            <ColumnDefinition Width="695"/>
            <ColumnDefinition Width="1"/>
            <ColumnDefinition Width="100"/>
        </Grid.ColumnDefinitions>
        <TextBlock Grid.Column="0" Text="Time" .../>
        <TextBlock Grid.Column="1" Text="Level" .../>
        <TextBlock Grid.Column="2" Text="Type" .../>
        <TextBlock Grid.Column="3" Text="Operation" .../>
        <TextBlock Grid.Column="4" Text="Description" .../>
        <Rectangle Grid.Column="5" Width="1" Fill="{StaticResource BorderBrush}"/>
        <TextBlock Grid.Column="6" Text=""/>
    </Grid>
</Border>
5.5.2 ListView 数据行
xml 复制代码
<ListView x:Name="ActiveListView" ...>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
        </Style>
    </ListView.ItemContainerStyle>
    <ListView.ItemTemplate>
        <DataTemplate DataType="models:LogEntry">
            <Border ...>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="150"/>
                        <ColumnDefinition Width="75"/>
                        <ColumnDefinition Width="95"/>
                        <ColumnDefinition Width="95"/>
                        <ColumnDefinition Width="*"/>    <!-- 自动填满 -->
                        <ColumnDefinition Width="1"/>
                        <ColumnDefinition Width="100"/>
                    </Grid.ColumnDefinitions>

                    <!-- 时间 -->
                    <TextBlock Grid.Column="0"
                               Text="{Binding CreatedAt, StringFormat=yyyy-MM-dd-HH:mm:ss}" .../>

                    <!-- 级别 + 颜色圆点 -->
                    <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Ellipse Width="10" Height="10">
                            <Ellipse.Style>
                                <Style TargetType="Ellipse">
                                    <Setter Property="Fill" Value="{StaticResource LevelInfoBrush}"/>
                                    <Style.Triggers>
                                        <DataTrigger Binding="{Binding Level}" Value="Warning">
                                            <Setter Property="Fill" Value="{StaticResource LevelWarningBrush}"/>
                                        </DataTrigger>
                                        <DataTrigger Binding="{Binding Level}" Value="Error">
                                            <Setter Property="Fill" Value="{StaticResource LevelErrorBrush}"/>
                                        </DataTrigger>
                                        <DataTrigger Binding="{Binding Level}" Value="Critical">
                                            <Setter Property="Fill" Value="{StaticResource LevelCriticalBrush}"/>
                                        </DataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </Ellipse.Style>
                        </Ellipse>
                        <TextBlock Text="{Binding Level}" .../>
                    </StackPanel>

                    <!-- 类型标签(彩色) -->
                    <Border Grid.Column="2" Style="{StaticResource TypeTagStyle}">
                        <Border.Style>
                            <Style TargetType="Border" BasedOn="{StaticResource TypeTagStyle}">
                                <Setter Property="Background" Value="{StaticResource TagControlBrush}"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding Type}" Value="Param Write">
                                        <Setter Property="Background" Value="{StaticResource TagParamBrush}"/>
                                    </DataTrigger>
                                    <DataTrigger Binding="{Binding Type}" Value="Limit Save">
                                        <Setter Property="Background" Value="{StaticResource TagLimitBrush}"/>
                                    </DataTrigger>
                                    <DataTrigger Binding="{Binding Type}" Value="Connection">
                                        <Setter Property="Background" Value="{StaticResource TagConnectBrush}"/>
                                    </DataTrigger>
                                    <DataTrigger Binding="{Binding Type}" Value="Validation">
                                        <Setter Property="Background" Value="{StaticResource TagValidateBrush}"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Border.Style>
                        <TextBlock Text="{Binding Type}" .../>
                    </Border>

                    <!-- Operation -->
                    <TextBlock Grid.Column="3" Text="{Binding Operation}" .../>

                    <!-- Description(最大宽度700,超出截断) -->
                    <TextBlock Grid.Column="4" Text="{Binding Description}"
                               TextTrimming="CharacterEllipsis"
                               MaxWidth="700" Margin="15,0,0,0" .../>

                    <!-- 分隔线 -->
                    <Rectangle Grid.Column="5" Width="1" Fill="{StaticResource BorderBrush}" .../>

                    <!-- 删除按钮 -->
                    <Button Grid.Column="6" Style="{StaticResource ActionBtnStyle}"
                            Tag="{Binding Id}" Click="DeleteButton_Click">
                        <TextBlock Text="Delete" Foreground="{StaticResource RedBrush}" .../>
                    </Button>
                </Grid>
            </Border>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>
5.5.3 空状态提示
xml 复制代码
<TextBlock Text="No operation logs yet."
           FontSize="20" Foreground="{StaticResource LabelBrush}"
           IsHitTestVisible="False">
    <TextBlock.Style>
        <Style TargetType="TextBlock">
            <Setter Property="Visibility" Value="Collapsed"/>
            <Style.Triggers>
                <DataTrigger Binding="{Binding Items.Count, ElementName=ActiveListView}" Value="0">
                    <Setter Property="Visibility" Value="Visible"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </TextBlock.Style>
</TextBlock>

5.6 Row 3: Recycle 面板

5.6.1 列定义(8列)
列索引 宽度 内容 对齐
0 150px Time Center
1 75px Level Center
2 95px Type Center
3 95px Operation Center
4 * (MaxWidth=700) Description Center
5 200px Deleted At Center
6 1px 分隔线 Center
7 * Restore / Force Delete 按钮 Right

Active和Recycle面板通过 Visibility="Collapsed" 切换,同一时间只有一个可见。

5.7 Row 4: 分页栏

xml 复制代码
<Border Grid.Row="4" ... CornerRadius="0,0,10,10">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <Button x:Name="PrevPageBtn" ... IsEnabled="False" Click="PrevPage_Click">
            <Button.Style>
                <Style TargetType="Button">
                    <Setter Property="Background" Value="Transparent"/>
                    <Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
                    <Setter Property="Content" Value="< Prev"/>
                    <Style.Triggers>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter Property="Foreground" Value="{StaticResource LabelBrush}"/>
                        </Trigger>
                    </Style.Triggers>
                </Style>
            </Button.Style>
        </Button>

        <TextBlock x:Name="PageInfoText" Text="Page 1/1" .../>

        <Button x:Name="NextPageBtn" ... IsEnabled="False" Click="NextPage_Click">
            <Button.Style>
                <Style TargetType="Button">
                    <Setter Property="Content" Value="Next >"/>
                    <Style.Triggers>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter Property="Foreground" Value="{StaticResource LabelBrush}"/>
                        </Trigger>
                    </Style.Triggers>
                </Style>
            </Button.Style>
        </Button>

        <TextBlock Text="20 per page" .../>
    </Grid>
</Border>

分页按钮使用 Content="< Prev"Content="Next >" 的 HTML实体编码,显示为 < PrevNext >


六、代码后置(OperationLogPage.xaml.cs 完整逻辑)

6.1 构造函数与初始化

csharp 复制代码
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();
    private SqliteData _db = new SqliteData();

    public OperationLogPage()
    {
        InitializeComponent();
        LoadData();  // 页面加载时立即查询
    }

6.2 获取筛选值的辅助方法

csharp 复制代码
private string? GetFilterValue(ComboBox combo)
{
    var val = (combo.SelectedItem as ComboBoxItem)?.Content?.ToString();
    return val?.StartsWith("All ") == true ? null : val;
}

将 "All Types" / "All Levels" 转换为 null,传递给SqliteData时跳过筛选条件。

6.3 LoadData() ------ 数据加载核心

csharp 复制代码
private void LoadData()
{
    _db.ClearExpired(); // 每次加载前自动清理30天前的回收站数据
    
    _isRecycleView = (RecyclePanel.Visibility == Visibility.Visible);

    var (items, total) = _db.QueryLogs(new LogQuery
    {
        Page = _currentPage,
        Size = PageSize,
        Search = string.IsNullOrWhiteSpace(SearchBox.Text) ? null : SearchBox.Text,
        Type = GetFilterValue(TypeFilter),
        Level = GetFilterValue(LevelFilter),
        IsRecycle = _isRecycleView
    });

    var collection = new ObservableCollection<LogEntry>(items);
    if (_isRecycleView)
        RecycleListView.ItemsSource = collection;
    else
        ActiveListView.ItemsSource = collection;

    _totalPages = Math.Max(1, (int)Math.Ceiling((double)total / PageSize));
    PageInfoText.Text = $"Page {_currentPage}/{_totalPages}";
    PrevPageBtn.IsEnabled = _currentPage > 1;
    NextPageBtn.IsEnabled = _currentPage < _totalPages;
}

6.4 Tab切换事件

csharp 复制代码
private void ActiveTab_Click(object sender, RoutedEventArgs e)
{
    ActivePanel.Visibility = Visibility.Visible;
    RecyclePanel.Visibility = Visibility.Collapsed;
    _isRecycleView = false;
    _currentPage = 1;  // 切回第一页
    // 切换按钮样式:Active高亮,Recycle灰
    ActiveTabBtn.Background = (Brush)FindResource("AccentBrush");
    ActiveTabBtn.Foreground = Brushes.White;
    RecycleTabBtn.Background = (Brush)FindResource("PanelBrush");
    RecycleTabBtn.Foreground = (Brush)FindResource("TextBrush");
    ClearRecycleBtn.Visibility = Visibility.Collapsed; // 回收站模式才显示清空按钮
    LoadData();
}

private void RecycleTab_Click(object sender, RoutedEventArgs e)
{
    ActivePanel.Visibility = Visibility.Collapsed;
    RecyclePanel.Visibility = Visibility.Visible;
    _isRecycleView = true;
    _currentPage = 1;
    // Recycle高亮,Active灰
    RecycleTabBtn.Background = (Brush)FindResource("AccentBrush");
    RecycleTabBtn.Foreground = Brushes.White;
    ActiveTabBtn.Background = (Brush)FindResource("PanelBrush");
    ActiveTabBtn.Foreground = (Brush)FindResource("TextBrush");
    ClearRecycleBtn.Visibility = Visibility.Visible;  // 显示清空回收站按钮
    LoadData();
}

6.5 搜索/筛选事件

csharp 复制代码
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
    _currentPage = 1;   // 搜索时回到第一页
    LoadData();
}

private void Filter_Changed(object sender, SelectionChangedEventArgs e)
{
    if (!IsLoaded) return;  // 关键!防止 InitializeComponent() 时触发
    _currentPage = 1;
    LoadData();
}

if (!IsLoaded) return; 是必需的。因为在 XAML InitializeComponent 阶段,ComboBox 会默认选中第一个 Item,此时触发 SelectionChanged 事件,但 DataContext 可能还没设置完成,导致空引用异常。

6.6 按钮操作事件

所有操作按钮都通过 Button.Tag 传递日志ID:

csharp 复制代码
// 软删除
private void DeleteButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is Button btn && btn.Tag is int id)
    {
        _db.SoftDelete(id);
        LoadData();  // 操作后立即刷新
    }
}

// 恢复
private void RestoreButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is Button btn && btn.Tag is int id)
    {
        _db.Restore(id);
        LoadData();
    }
}

// 永久删除
private void PermanentDelete_Click(object sender, RoutedEventArgs e)
{
    if (sender is Button btn && btn.Tag is int id)
    {
        _db.PermanentDelete(id);
        LoadData();
    }
}

// 清空回收站
private void ClearRecycleBin_Click(object sender, RoutedEventArgs e)
{
    _db.ClearRecycleBin();
    _currentPage = 1;
    LoadData();
}

6.7 分页事件

csharp 复制代码
private void PrevPage_Click(object sender, RoutedEventArgs e)
{
    if (_currentPage > 1) _currentPage--;
    LoadData();
}

private void NextPage_Click(object sender, RoutedEventArgs e)
{
    if (_currentPage < _totalPages) _currentPage++;
    LoadData();
}

6.8 返回按钮

csharp 复制代码
private void BackButton_Click(object sender, RoutedEventArgs e)
{
    var vm = DataContext as MainViewModel;
    vm?.ResumePolling();   // 从日志页返回时恢复轮询
    var page = new HomePage();
    page.DataContext = vm;
    NavigationService.Navigate(page);
}

6.9 Excel 导出

csharp 复制代码
private void ExportButton_Click(object sender, RoutedEventArgs e)
{
    // 1. 弹出保存对话框
    var dialog = new SaveFileDialog
    {
        Filter = "Excel Files|*.xlsx",
        DefaultExt = "xlsx",
        FileName = $"OperationLog_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"
    };
    if (dialog.ShowDialog() != true) return;

    // 2. 查询全部数据(不分页,但保留搜索/筛选条件)
    var (allItems, _) = _db.QueryLogs(new LogQuery
    {
        Page = 1,
        Size = int.MaxValue,   // 关键:int.MaxValue = 不分页
        Search = string.IsNullOrWhiteSpace(SearchBox.Text) ? null : SearchBox.Text,
        Type = GetFilterValue(TypeFilter),
        Level = GetFilterValue(LevelFilter),
        IsRecycle = _isRecycleView
    });

    if (allItems.Count == 0)
    {
        MessageBox.Show("No data to export.", "Info");
        return;
    }

    // 3. 创建 Excel
    using var workbook = new XLWorkbook();
    var ws = workbook.Worksheets.Add("OperationLog");

    // 表头:6列
    ws.Cell(1, 1).Value = "Time";
    ws.Cell(1, 2).Value = "Level";
    ws.Cell(1, 3).Value = "Type";
    ws.Cell(1, 4).Value = "Operation";
    ws.Cell(1, 5).Value = "Description";
    ws.Cell(1, 8).Value = "Deleted At";  // 列8是在数据列之后留空

    var header = ws.Range(1, 1, 1, 8);
    header.Style.Font.Bold = true;

    // 数据行
    int row = 2;
    foreach (var item in allItems)
    {
        ws.Cell(row, 1).Value = item.CreatedAt;
        ws.Cell(row, 2).Value = item.Level;
        ws.Cell(row, 3).Value = item.Type;
        ws.Cell(row, 4).Value = item.Operation;
        ws.Cell(row, 5).Value = item.Description ?? "";
        ws.Cell(row, 6).Value = item.DeletedAt?.ToString() ?? "";
        row++;
    }

    // 居中 + 自动列宽
    ws.RangeUsed()?.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
    ws.Columns().AdjustToContents();
    workbook.SaveAs(dialog.FileName);
    MessageBox.Show($"Exported {allItems.Count} records.", "Success");
}

注意:Size = int.MaxValue 是导出全部的关键。因为 SQLite 的 LIMIT 子句接受大值。


七、类型标签颜色映射(XAML资源)

xml 复制代码
<!-- 级别颜色 -->
<SolidColorBrush x:Key="LevelInfoBrush" Color="#00D2FF"/>       <!-- 青色 -->
<SolidColorBrush x:Key="LevelWarningBrush" Color="#FF9100"/>     <!-- 橙色 -->
<SolidColorBrush x:Key="LevelErrorBrush" Color="#FF1744"/>       <!-- 红色 -->
<SolidColorBrush x:Key="LevelCriticalBrush" Color="#B71C1C"/>    <!-- 深红 -->

<!-- 类型标签颜色 -->
<SolidColorBrush x:Key="TagControlBrush" Color="#448AFF"/>       <!-- 蓝色 - Control -->
<SolidColorBrush x:Key="TagParamBrush" Color="#00E676"/>         <!-- 绿色 - Param Write -->
<SolidColorBrush x:Key="TagLimitBrush" Color="#FFD740"/>         <!-- 黄色 - Limit Save -->
<SolidColorBrush x:Key="TagConnectBrush" Color="#00D2FF"/>       <!-- 青色 - Connection -->
<SolidColorBrush x:Key="TagValidateBrush" Color="#FF9100"/>      <!-- 橙色 - Validation -->

效果:在列表中,每条日志的 Type 显示为彩色标签,一目了然区分操作类型。


八、按钮连接到数据库的完整数据流程

以"停止"按钮为例,展示完整链路:

复制代码
用户点击 Stop 按钮
    ↓
MainViewModel.ExecuteStop()
    ↓ 调用 Modbus 写入线圈
_service.WriteSingleCoil(Coil_Stop, true)
    ↓ 成功/失败
SafeLog("Control", "Stop", "缓停启动,成功/失败", "Info/Error")
    ↓
SqliteData.InsertLog(LogEntry)
    ↓
INSERT INTO OperationLogs (Type, Operation, Description, Level, CreatedAt)
    ↓
SQLite 文件写入成功 ✓

在操作日志页面:

复制代码
页面加载
    ↓
LoadData()
    ↓ 先清理过期数据
_db.ClearExpired()
    ↓ 分页查询
_db.QueryLogs(query)
    ↓ 绑定到 ListView
ActiveListView.ItemsSource = observableCollection
    ↓ WPF 渲染每一行
DataTemplate → 时间/级别圆点/类型标签/操作/描述/删除按钮

点击 Delete 按钮:

复制代码
DeleteButton_Click(sender, e)
    ↓ 获取 Tag 中的 Id
btn.Tag is int id
    ↓ 软删除
_db.SoftDelete(id)
    ↓
UPDATE ... SET DeleteStatus=1, DeletedAt=datetime('now','localtime')
    ↓ 重新加载
LoadData() → 页面刷新

九、导航架构

MainWindow.xaml.cs ------ 导航入口

csharp 复制代码
public MainWindow()
{
    InitializeComponent();
    var vm = new ViewModels.MainViewModel();
    DataContext = vm;
    vm.NavigateToPage = (page) => MainFrame.Navigate(page);
    var homePage = new HomePage();
    homePage.DataContext = vm;
    MainFrame.Navigate(homePage);
}

MainFrame 是 XAML 中的 Frame 控件,NavigateToPage 委托被注入,ViewModel 通过 NavigateToPage?.Invoke(page) 触发导航。

返回按钮逻辑

每个子页面的返回按钮:

csharp 复制代码
private void BackButton_Click(object sender, RoutedEventArgs e)
{
    var vm = DataContext as MainViewModel;
    vm?.ResumePolling();  // 回到首页时恢复轮询
    var page = new HomePage();
    page.DataContext = vm;
    NavigationService.Navigate(page);
}

十、踩坑记录与解决方案

10.1 AddWithValue 传 null

复制代码
❌ cmd.Parameters.AddWithValue("@Description", null);
    → InvalidOperationException: Value must be set.
✅ cmd.Parameters.AddWithValue("@Description", (Object?)log.Description ?? DBNull.Value);

10.2 ExecuteScalar 返回值类型

复制代码
❌ int total = (int)cmd.ExecuteScalar();
    → InvalidCastException
✅ int total = Convert.ToInt32(cmd.ExecuteScalar() ?? 0);

10.3 可空列读取

复制代码
❌ var desc = reader.GetString(3);
    → 当 Description 为 NULL 时抛异常
✅ Description = reader.IsDBNull(3) ? "" : reader.GetString(3),

10.4 ComboBox 初始化触发 SelectionChanged

复制代码
❌ Filter_Changed 每次 InitializeComponent 都被误触发
    → LoadData() 时 DataContext 可能为 null
✅ private void Filter_Changed(object sender, SelectionChangedEventArgs e)
   {
       if (!IsLoaded) return;  // 关键保护!
       _currentPage = 1;
       LoadData();
   }

10.5 SQLite 时区问题

复制代码
❌ datetime('now') 返回 UTC
    → 东八区用户看到的 DeletedAt 比实际少8小时
✅ datetime('now','localtime') 返回本地时间

10.6 ListView 内容不拉伸

复制代码
❌ * 列的 Grid 宽度不对,按钮挤在左边
✅ <ListView.ItemContainerStyle>
       <Style TargetType="ListViewItem">
           <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
       </Style>
   </ListView.ItemContainerStyle>

10.7 Description 超长撑破布局

复制代码
❌ 长文本撑开列宽,导致右侧按钮跑到看不见的地方
✅ MaxWidth="700" + TextTrimming="CharacterEllipsis"

十一、项目结构总览

复制代码
UpperMachine/UpperMachine/
├── App.xaml / App.xaml.cs                # 全局异常处理→error.log
├── MainWindow.xaml / .xaml.cs            # 导航入口 + Frame
├── UpperMachine.csproj                   # NuGet: ClosedXML + Sqlite + NModbus4
│
├── Models/
│   ├── LogEntry.cs                       # 10字段日志实体
│   ├── AxisParam.cs                      # 轴参数(含8个限值属性)
│   ├── AxisData.cs                       # 轴数据包装
│   └── PlcAddressMap.cs                  # Modbus地址映射
│
├── Data/
│   ├── SqliteData.cs                     # 8个SQLite操作方法
│   └── LogQuery.cs                       # 分页/筛选查询参数
│
├── ViewModels/
│   ├── MainViewModel.cs                  # SafeLog + 9处日志写入 + 导航
│   └── RelayCommand.cs                   # ICommand 桥接实现
│
├── Services/
│   ├── ModbusServiceBase.cs              # Modbus读写基类
│   ├── ModbusTcpService.cs               # TCP实现
│   └── ModbusRtuService.cs               # RTU实现
│
├── Helpers/
│   └── ModbusHelper.cs                   # 大端字节序转换
│
├── View/
│   ├── HomePage.xaml                     # 首页:4轴卡片 + 控制 + 导航
│   ├── OperationLogPage.xaml / .xaml.cs  # 操作日志页面
│   ├── IoMonitorPage.xaml / .xaml.cs     # IO监控页面
│   ├── AxisParamSettingsPage.xaml        # 绝对参数设置
│   ├── ManualAdjustPage.xaml             # 手动参数设置
│   └── LimitAxesPage.xaml                # 限值设置
│
└── bin/Debug/net10.0-windows/
    └── Data/
        └── OperationLog.db               # SQLite数据库文件(运行时生成)

十二、总结

操作日志模块通过 SqliteData(8方法)MainViewModel(SafeLog + 9处写入)OperationLogPage(5行布局 + 7/8列ListView + 搜索/筛选/分页/回收站)ClosedXML Excel导出 实现了完整的全链路功能。

核心设计原则:

  1. 安全日志SafeLog 空catch)------ 日志失败不影响主流程
  2. 软删除DeleteStatus标记 + DeletedAt时间戳)------ 可恢复
  3. 自动清理ClearExpired默认30天)------ 无需用户干预
  4. 分页+搜索+筛选组合查询------ 大数据量场景可用
  5. 标签颜色映射(5种类型×5种级别颜色)------ 信息可视化