WPF 上位机开发笔记:操作日志完整模块(数据库→ViewModel→UI→Excel导出全链路)
日期:2026-06-30
项目:四轴运动控制上位机(.NET 10 WPF MVVM + NModbus4 + SQLite + ClosedXML)
NuGet:
Microsoft.Data.SqliteClosedXMLNModbus4System.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();
}
注意点:
CreatedAt和DeletedAt类型为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();
}
关键陷阱:
AddWithValue传null会抛InvalidOperationException→ 必须用(object?)value ?? DBNull.ValueCreatedAt不用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 { } 保证:
- 日志写入失败不会抛异常
- 不会中断按钮操作
- 不会弹出错误对话框打扰用户
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实体编码,显示为 < Prev 和 Next >。
六、代码后置(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导出 实现了完整的全链路功能。
核心设计原则:
- 安全日志 (
SafeLog空catch)------ 日志失败不影响主流程 - 软删除 (
DeleteStatus标记 +DeletedAt时间戳)------ 可恢复 - 自动清理 (
ClearExpired默认30天)------ 无需用户干预 - 分页+搜索+筛选组合查询------ 大数据量场景可用
- 标签颜色映射(5种类型×5种级别颜色)------ 信息可视化