C# + WPF上位机实战:设备监控面板开发(自定义仪表盘+实时趋势图+数据导出Excel)

在工业监控场景中,一个直观、高效的设备监控面板能极大提升运维效率——操作工通过仪表盘快速判断设备状态,通过趋势图分析参数变化规律,通过数据导出追溯历史异常。WPF凭借强大的UI渲染能力和数据绑定机制,成为开发这类面板的理想选择。

本文从零开始,手把手开发一个工业级设备监控面板,包含3个核心功能:自定义仪表盘(显示温度/压力等关键参数)、实时趋势图(动态刷新数据曲线)、数据导出Excel(带时间戳的历史记录)。代码兼顾“视觉效果”和“工业实用性”,所有控件支持数据绑定,可直接适配真实设备数据。

最终效果预览

完成后的监控面板分为4个区域:

顶部状态栏:显示设备在线状态、当前时间、系统版本;参数监控区:3个自定义仪表盘(温度、压力、转速)+ 数值显示,超限时自动变红;趋势图区:实时绘制温度和压力的变化曲线,支持鼠标悬停查看具体值;操作区:包含“开始监控”“停止”“导出Excel”按钮,以及数据保存路径设置。

界面支持窗口缩放(自适应布局),仪表盘有平滑动画效果,趋势图每秒刷新一次,整体响应流畅无卡顿。

开发准备

技术栈与库

开发环境:Visual Studio 2022 + .NET 6(兼容.NET Framework 4.7.2+);UI框架:WPF(原生控件+自定义控件);图表库:OxyPlot(轻量、开源,适合实时数据可视化,NuGet搜索
OxyPlot.Wpf
);Excel导出:EPPlus(处理.xlsx格式,支持大数据量,NuGet搜索
EPPlus
)。

项目结构

按“功能模块化”组织代码,便于维护:


DeviceMonitor/
├─ Views/           # 界面相关
│  ├─ MainWindow.xaml      # 主窗口(监控面板)
│  └─ CustomControls/      # 自定义控件
│     └─ GaugeControl.xaml # 仪表盘控件
├─ ViewModels/      # 数据与逻辑(MVVM模式)
│  └─ MainViewModel.cs     # 主窗口数据上下文
├─ Models/          # 数据模型
│  ├─ DeviceData.cs        # 设备实时数据模型
│  └─ HistoricalData.cs    # 历史数据模型(带时间戳)
└─ Helpers/         # 工具类
   ├─ ExcelExporter.cs     # Excel导出工具
   └─ DataSimulator.cs     # 设备数据模拟工具(测试用)

步骤1:搭建主界面布局(XAML)

用WPF的Grid和DockPanel实现自适应布局,确保窗口缩放时控件比例合理:


<!-- MainWindow.xaml -->
<Window x:Class="DeviceMonitor.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:DeviceMonitor.Views"
        xmlns:oxy="http://oxyplot.org/wpf"
        Title="设备监控面板" Height="800" Width="1200"
        WindowStartupLocation="CenterScreen">
    
    <!-- 数据上下文(MVVM绑定) -->
    <Window.DataContext>
        <local:MainViewModel />
    </Window.DataContext>

    <Grid Margin="10">
        <!-- 主布局:3行(状态栏、监控区、操作区) -->
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>   <!-- 状态栏(高度自适应) -->
            <RowDefinition Height="*"/>      <!-- 监控区(占剩余空间) -->
            <RowDefinition Height="Auto"/>   <!-- 操作区(高度自适应) -->
        </Grid.RowDefinitions>

        <!-- 1. 顶部状态栏 -->
        <DockPanel Grid.Row="0" Background="#F0F0F0" Padding="10" Margin="0 0 0 10">
            <TextBlock Text="设备状态:" VerticalAlignment="Center"/>
            <TextBlock Text="{Binding DeviceStatus}" Foreground="{Binding StatusColor}" 
                       VerticalAlignment="Center" Margin="5 0 20 0"/>
            <TextBlock Text="当前时间:" VerticalAlignment="Center" Margin="20 0 0 0"/>
            <TextBlock Text="{Binding CurrentTime, StringFormat='yyyy-MM-dd HH:mm:ss'}" 
                       VerticalAlignment="Center" Margin="5 0 20 0"/>
            <TextBlock Text="系统版本:V1.0" VerticalAlignment="Center" HorizontalAlignment="Right"/>
        </DockPanel>

        <!-- 2. 中间监控区 -->
        <Grid Grid.Row="1" Margin="0 0 0 10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>  <!-- 左侧:仪表盘区域 -->
                <ColumnDefinition Width="*"/>  <!-- 右侧:趋势图区域 -->
            </Grid.ColumnDefinitions>

            <!-- 2.1 左侧:3个仪表盘(温度、压力、转速) -->
            <Grid Grid.Column="0">
                <Grid.RowDefinitions>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>

                <!-- 温度仪表盘 -->
                <local:GaugeControl Grid.Row="0" 
                                    Title="温度 (℃)" 
                                    Value="{Binding TempValue}" 
                                    MinValue="0" 
                                    MaxValue="100" 
                                    WarningValue="80"  <!-- 80℃警告 -->
                                    DangerValue="90"   <!-- 90℃超限 -->
                                    Margin="10"/>

                <!-- 压力仪表盘 -->
                <local:GaugeControl Grid.Row="1" 
                                    Title="压力 (MPa)" 
                                    Value="{Binding PressValue}" 
                                    MinValue="0" 
                                    MaxValue="2.5" 
                                    WarningValue="2.0" 
                                    DangerValue="2.3" 
                                    Margin="10"/>

                <!-- 转速仪表盘 -->
                <local:GaugeControl Grid.Row="2" 
                                    Title="转速 (rpm)" 
                                    Value="{Binding SpeedValue}" 
                                    MinValue="0" 
                                    MaxValue="3000" 
                                    WarningValue="2800" 
                                    DangerValue="2900" 
                                    Margin="10"/>
            </Grid>

            <!-- 2.2 右侧:实时趋势图 -->
            <Border Grid.Column="1" BorderBrush="Gray" BorderThickness="1" CornerRadius="5" Margin="10">
                <oxy:PlotView Model="{Binding PlotModel}" Margin="5"/>
            </Border>
        </Grid>

        <!-- 3. 底部操作区 -->
        <Grid Grid.Row="2" Background="#F0F0F0" Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="200"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>

            <Button Content="开始监控" Command="{Binding StartCommand}" Width="100" Margin="5"/>
            <Button Content="停止监控" Command="{Binding StopCommand}" Width="100" Margin="5"/>
            <Button Content="导出Excel" Command="{Binding ExportCommand}" Width="100" Margin="5"/>
            
            <TextBlock Text="数据保存路径:" Grid.Column="3" VerticalAlignment="Center" HorizontalAlignment="Right"/>
            <TextBox Text="{Binding ExportPath}" Grid.Column="4" Margin="5"/>
            <Button Content="浏览..." Command="{Binding BrowsePathCommand}" Grid.Column="5" Margin="5"/>
        </Grid>
    </Grid>
</Window>

步骤2:开发自定义仪表盘控件

工业监控中,传统数字显示不够直观,自定义仪表盘能通过颜色和指针位置快速传递状态信息。仪表盘需实现:刻度绘制、指针动画、数值绑定、状态色切换(正常/警告/超限)

2.1 仪表盘XAML(样式定义)


<!-- CustomControls/GaugeControl.xaml -->
<UserControl x:Class="DeviceMonitor.Views.CustomControls.GaugeControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:DeviceMonitor.Views.CustomControls"
             d:DesignHeight="200" d:DesignWidth="300">
    <Grid>
        <!-- 1. 标题 -->
        <TextBlock Text="{Binding Title}" HorizontalAlignment="Center" 
                   VerticalAlignment="Top" FontSize="16" FontWeight="Bold"/>
        
        <!-- 2. 仪表盘背景(圆弧) -->
        <Canvas Width="260" Height="160" HorizontalAlignment="Center" VerticalAlignment="Center">
            <!-- 底部圆弧(灰色底色) -->
            <Path Stroke="#E0E0E0" StrokeThickness="15" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
                <Path.Data>
                    <ArcSegment Size="100,100" Point="200,100" IsLargeArc="True" 
                                SweepDirection="Clockwise" />
                </Path.Data>
                <Path.RenderTransform>
                    <TranslateTransform X="10" Y="100"/>
                </Path.RenderTransform>
            </Path>

            <!-- 正常区域(绿色:0 ~ WarningValue) -->
            <Path Stroke="Green" StrokeThickness="15" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
                <Path.Data>
                    <ArcSegment x:Name="NormalArc" Size="100,100" Point="200,100" 
                                IsLargeArc="False" SweepDirection="Clockwise" />
                </Path.Data>
                <Path.RenderTransform>
                    <TranslateTransform X="10" Y="100"/>
                </Path.RenderTransform>
            </Path>

            <!-- 警告区域(黄色:WarningValue ~ DangerValue) -->
            <Path Stroke="Orange" StrokeThickness="15" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
                <Path.Data>
                    <ArcSegment x:Name="WarningArc" Size="100,100" Point="200,100" 
                                IsLargeArc="False" SweepDirection="Clockwise" />
                </Path.Data>
                <Path.RenderTransform>
                    <TranslateTransform X="10" Y="100"/>
                </Path.RenderTransform>
            </Path>

            <!-- 超限区域(红色:DangerValue ~ MaxValue) -->
            <Path Stroke="Red" StrokeThickness="15" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
                <Path.Data>
                    <ArcSegment x:Name="DangerArc" Size="100,100" Point="200,100" 
                                IsLargeArc="False" SweepDirection="Clockwise" />
                </Path.Data>
                <Path.RenderTransform>
                    <TranslateTransform X="10" Y="100"/>
                </Path.RenderTransform>
            </Path>

            <!-- 刻度线(每10%一个主刻度,5%一个次刻度) -->
            <ItemsControl x:Name="TicksContainer">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Line X1="0" Y1="0" X2="0" Y2="{Binding Length}" 
                              Stroke="Gray" StrokeThickness="{Binding Thickness}">
                            <Line.RenderTransform>
                                <RotateTransform Angle="{Binding Angle}" CenterX="0" CenterY="100"/>
                            </Line.RenderTransform>
                        </Line>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

            <!-- 指针(三角形+轴点) -->
            <Polygon x:Name="Pointer" Points="0,-70 5,0 -5,0" Fill="Black">
                <Polygon.RenderTransform>
                    <RotateTransform x:Name="PointerRotate" Angle="0" CenterX="0" CenterY="0"/>
                </Polygon.RenderTransform>
            </Polygon>
            <Ellipse Width="10" Height="10" Fill="Black" Canvas.Left="100" Canvas.Top="0"/>
        </Canvas>

        <!-- 3. 当前值显示 -->
        <TextBlock Text="{Binding Value, StringFormat={}{0:F1}}" FontSize="24" FontWeight="Bold"
                   HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0 0 0 20"
                   Foreground="{Binding ValueColor}"/>
    </Grid>
</UserControl>

2.2 仪表盘后台逻辑(C#)

实现数值到角度的转换、刻度生成、指针动画、状态颜色切换:


// CustomControls/GaugeControl.xaml.cs
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;

namespace DeviceMonitor.Views.CustomControls
{
    public partial class GaugeControl : UserControl
    {
        // 依赖属性(支持数据绑定)
        public static readonly DependencyProperty TitleProperty =
            DependencyProperty.Register("Title", typeof(string), typeof(GaugeControl));

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register("Value", typeof(double), typeof(GaugeControl), 
                new PropertyMetadata(0.0, OnValueChanged));

        public static readonly DependencyProperty MinValueProperty =
            DependencyProperty.Register("MinValue", typeof(double), typeof(GaugeControl),
                new PropertyMetadata(0.0, OnRangeChanged));

        public static readonly DependencyProperty MaxValueProperty =
            DependencyProperty.Register("MaxValue", typeof(double), typeof(GaugeControl),
                new PropertyMetadata(100.0, OnRangeChanged));

        public static readonly DependencyProperty WarningValueProperty =
            DependencyProperty.Register("WarningValue", typeof(double), typeof(GaugeControl),
                new PropertyMetadata(80.0, OnRangeChanged));

        public static readonly DependencyProperty DangerValueProperty =
            DependencyProperty.Register("DangerValue", typeof(double), typeof(GaugeControl),
                new PropertyMetadata(90.0, OnRangeChanged));

        // 属性封装
        public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
        public double Value { get => (double)GetValue(ValueProperty); set => SetValue(ValueProperty, value); }
        public double MinValue { get => (double)GetValue(MinValueProperty); set => SetValue(MinValueProperty, value); }
        public double MaxValue { get => (double)GetValue(MaxValueProperty); set => SetValue(MaxValueProperty, value); }
        public double WarningValue { get => (double)GetValue(WarningValueProperty); set => SetValue(WarningValueProperty, value); }
        public double DangerValue { get => (double)GetValue(DangerValueProperty); set => SetValue(DangerValueProperty, value); }

        // 值颜色(根据状态变化)
        public Brush ValueColor => Value >= DangerValue ? Brushes.Red : 
                                 Value >= WarningValue ? Brushes.Orange : Brushes.Green;

        public GaugeControl()
        {
            InitializeComponent();
            DataContext = this; // 绑定到自身(简化示例,实际可用MVVM)
            GenerateTicks();    // 生成刻度
            UpdateArcs();       // 更新颜色区域
        }

        // 数值变化时,更新指针角度(带动画)
        private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var gauge = (GaugeControl)d;
            double newValue = (double)e.NewValue;
            // 限制值在Min-Max范围内
            newValue = Math.Clamp(newValue, gauge.MinValue, gauge.MaxValue);
            
            // 计算角度(仪表盘显示180度,从左到右对应Min到Max)
            double angle = 180 * (newValue - gauge.MinValue) / (gauge.MaxValue - gauge.MinValue);
            
            // 指针动画(0.5秒平滑过渡)
            var animation = new DoubleAnimation(angle, TimeSpan.FromSeconds(0.5))
            {
                EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut }
            };
            gauge.PointerRotate.BeginAnimation(RotateTransform.AngleProperty, animation);
            
            // 通知值颜色变化
            gauge.OnPropertyChanged(nameof(ValueColor));
        }

        // 范围参数变化时,重新生成刻度和颜色区域
        private static void OnRangeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var gauge = (GaugeControl)d;
            gauge.GenerateTicks();
            gauge.UpdateArcs();
        }

        // 生成刻度线(主刻度长20,次刻度长10)
        private void GenerateTicks()
        {
            var ticks = new List<TickData>();
            double range = MaxValue - MinValue;
            // 每5%一个刻度
            for (int i = 0; i <= 20; i++)
            {
                double ratio = i / 20.0;
                double angle = 180 * ratio; // 角度从0到180度
                bool isMajorTick = i % 2 == 0; // 每10%一个主刻度
                ticks.Add(new TickData
                {
                    Angle = angle,
                    Length = isMajorTick ? 20 : 10,
                    Thickness = isMajorTick ? 2 : 1
                });
            }
            TicksContainer.ItemsSource = ticks;
        }

        // 更新颜色区域(正常/警告/超限)
        private void UpdateArcs()
        {
            double range = MaxValue - MinValue;
            // 计算各区域占比(0-180度)
            double warningRatio = (WarningValue - MinValue) / range;
            double dangerRatio = (DangerValue - MinValue) / range;

            // 正常区域(0 ~ WarningValue)
            NormalArc.Point = GetArcEndPoint(warningRatio * 180);
            // 警告区域(WarningValue ~ DangerValue)
            WarningArc.Point = GetArcEndPoint(dangerRatio * 180);
            WarningArc.StartPoint = NormalArc.Point;
            // 超限区域(DangerValue ~ MaxValue)
            DangerArc.Point = GetArcEndPoint(180);
            DangerArc.StartPoint = WarningArc.Point;
        }

        // 计算圆弧终点坐标(基于角度)
        private Point GetArcEndPoint(double angleDegrees)
        {
            // 角度转弧度(0度在右侧,180度在左侧)
            double angleRadians = (angleDegrees - 180) * Math.PI / 180.0;
            double x = 100 * Math.Cos(angleRadians) + 100; // 圆心在(100,100)
            double y = 100 * Math.Sin(angleRadians) + 100;
            return new Point(x, y);
        }

        // 刻度数据模型
        private class TickData
        {
            public double Angle { get; set; }
            public double Length { get; set; }
            public double Thickness { get; set; }
        }

        // 通知属性变化(用于ValueColor)
        private void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        }
        public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
    }
}

步骤3:实现实时趋势图(OxyPlot)

实时趋势图需要动态添加数据点、自动缩放坐标轴、支持多曲线显示(温度+压力)。用OxyPlot的
PlotModel

LineSeries
实现,配合定时器刷新数据。

3.1 趋势图数据绑定(ViewModel)


MainViewModel
中初始化图表模型,定义曲线系列,定时添加数据:


// ViewModels/MainViewModel.cs
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Input;
using OxyPlot;
using OxyPlot.Series;
using OxyPlot.Axes;
using Microsoft.Win32;
using System.Windows;

namespace DeviceMonitor.ViewModels
{
    public class MainViewModel : INotifyPropertyChanged
    {
        // 实时数据
        private double _tempValue;
        private double _pressValue;
        private double _speedValue;
        private string _deviceStatus;
        private Brush _statusColor;
        private DateTime _currentTime;
        private string _exportPath;

        // 图表模型
        private PlotModel _plotModel;
        private LineSeries _tempSeries; // 温度曲线
        private LineSeries _pressSeries; // 压力曲线
        private int _dataCount = 0; // 数据点计数(X轴)

        // 历史数据缓存(用于导出Excel)
        public ObservableCollection<HistoricalData> HistoryData { get; } = new ObservableCollection<HistoricalData>();

        // 命令
        public ICommand StartCommand { get; }
        public ICommand StopCommand { get; }
        public ICommand ExportCommand { get; }
        public ICommand BrowsePathCommand { get; }

        // 数据模拟与刷新定时器
        private readonly DataSimulator _simulator = new DataSimulator();
        private readonly System.Timers.Timer _refreshTimer = new System.Timers.Timer(1000); // 1秒刷新一次
        private bool _isMonitoring;

        // 属性(支持绑定)
        public double TempValue { get => _tempValue; set { _tempValue = value; OnPropertyChanged(); } }
        public double PressValue { get => _pressValue; set { _pressValue = value; OnPropertyChanged(); } }
        public double SpeedValue { get => _speedValue; set { _speedValue = value; OnPropertyChanged(); } }
        public string DeviceStatus { get => _deviceStatus; set { _deviceStatus = value; OnPropertyChanged(); } }
        public Brush StatusColor { get => _statusColor; set { _statusColor = value; OnPropertyChanged(); } }
        public DateTime CurrentTime { get => _currentTime; set { _currentTime = value; OnPropertyChanged(); } }
        public string ExportPath { get => _exportPath; set { _exportPath = value; OnPropertyChanged(); } }
        public PlotModel PlotModel { get => _plotModel; set { _plotModel = value; OnPropertyChanged(); } }

        public MainViewModel()
        {
            // 初始化命令
            StartCommand = new RelayCommand(StartMonitoring);
            StopCommand = new RelayCommand(StopMonitoring);
            ExportCommand = new RelayCommand(ExportToExcel);
            BrowsePathCommand = new RelayCommand(BrowseExportPath);

            // 初始化状态
            DeviceStatus = "未监控";
            StatusColor = Brushes.Gray;
            CurrentTime = DateTime.Now;
            ExportPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + "\设备数据.xlsx";

            // 初始化图表
            InitializePlotModel();

            // 定时器事件(刷新时间和数据)
            _refreshTimer.Elapsed += (s, e) =>
            {
                // UI线程更新
                Application.Current.Dispatcher.Invoke(() =>
                {
                    CurrentTime = DateTime.Now;
                    if (_isMonitoring)
                    {
                        // 模拟采集数据
                        var data = _simulator.GetNextData();
                        TempValue = data.Temperature;
                        PressValue = data.Pressure;
                        SpeedValue = data.Speed;

                        // 添加到趋势图
                        AddDataToPlot(data.Temperature, data.Pressure);

                        // 缓存历史数据
                        HistoryData.Add(new HistoricalData
                        {
                            Time = CurrentTime,
                            Temperature = data.Temperature,
                            Pressure = data.Pressure,
                            Speed = data.Speed
                        });

                        // 限制缓存数量(只保留最新1000条)
                        while (HistoryData.Count > 1000)
                            HistoryData.RemoveAt(0);
                    }
                });
            };
        }

        // 初始化图表
        private void InitializePlotModel()
        {
            PlotModel = new PlotModel { Title = "实时趋势图(温度/压力)" };

            // X轴(时间/数据点)
            PlotModel.Axes.Add(new LinearAxis
            {
                Position = AxisPosition.Bottom,
                Title = "数据点",
                MaximumPadding = 0.05,
                MinimumPadding = 0.05
            });

            // Y轴(左侧:温度)
            PlotModel.Axes.Add(new LinearAxis
            {
                Position = AxisPosition.Left,
                Title = "温度 (℃)",
                Minimum = 0,
                Maximum = 100,
                Key = "TempAxis" // 用于绑定温度曲线
            });

            // Y轴(右侧:压力)
            PlotModel.Axes.Add(new LinearAxis
            {
                Position = AxisPosition.Right,
                Title = "压力 (MPa)",
                Minimum = 0,
                Maximum = 2.5,
                Key = "PressAxis" // 用于绑定压力曲线
            });

            // 温度曲线
            _tempSeries = new LineSeries
            {
                Title = "温度",
                Color = OxyColors.Red,
                YAxisKey = "TempAxis"
            };
            // 压力曲线
            _pressSeries = new LineSeries
            {
                Title = "压力",
                Color = OxyColors.Blue,
                YAxisKey = "PressAxis"
            };

            PlotModel.Series.Add(_tempSeries);
            PlotModel.Series.Add(_pressSeries);
        }

        // 添加数据到趋势图
        private void AddDataToPlot(double temp, double press)
        {
            _dataCount++;
            _tempSeries.Points.Add(new DataPoint(_dataCount, temp));
            _pressSeries.Points.Add(new DataPoint(_dataCount, press));

            // 只显示最新50个点(避免曲线过密)
            if (_tempSeries.Points.Count > 50)
            {
                _tempSeries.Points.RemoveAt(0);
                _pressSeries.Points.RemoveAt(0);
            }

            PlotModel.InvalidatePlot(true); // 刷新图表
        }

        // 开始监控
        private void StartMonitoring()
        {
            _isMonitoring = true;
            _refreshTimer.Start();
            DeviceStatus = "监控中";
            StatusColor = Brushes.Green;
        }

        // 停止监控
        private void StopMonitoring()
        {
            _isMonitoring = false;
            _refreshTimer.Stop();
            DeviceStatus = "已停止";
            StatusColor = Brushes.Orange;
        }

        // 浏览导出路径
        private void BrowseExportPath()
        {
            var dialog = new SaveFileDialog
            {
                Filter = "Excel文件 (*.xlsx)|*.xlsx",
                FileName = "设备数据.xlsx",
                InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
            };
            if (dialog.ShowDialog() == true)
            {
                ExportPath = dialog.FileName;
            }
        }

        // 导出到Excel
        private void ExportToExcel()
        {
            if (HistoryData.Count == 0)
            {
                MessageBox.Show("没有可导出的历史数据");
                return;
            }

            try
            {
                ExcelExporter.Export(HistoryData, ExportPath);
                MessageBox.Show($"导出成功:{ExportPath}");
            }
            catch (Exception ex)
            {
                MessageBox.Show($"导出失败:{ex.Message}");
            }
        }

        // INotifyPropertyChanged实现
        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

3.2 辅助类实现

数据模型:定义设备数据结构;数据模拟:生成随机数据模拟设备采集;命令实现:简化WPF命令绑定。


// Models/DeviceData.cs
namespace DeviceMonitor.Models
{
    // 实时设备数据
    public class DeviceData
    {
        public double Temperature { get; set; } // 温度
        public double Pressure { get; set; }   // 压力
        public double Speed { get; set; }      // 转速
    }

    // 带时间戳的历史数据
    public class HistoricalData
    {
        public DateTime Time { get; set; }
        public double Temperature { get; set; }
        public double Pressure { get; set; }
        public double Speed { get; set; }
    }
}

// Helpers/DataSimulator.cs(模拟设备数据)
using System;
using DeviceMonitor.Models;

namespace DeviceMonitor
{
    public class DataSimulator
    {
        private Random _random = new Random();
        private double _baseTemp = 50; // 基准温度
        private double _basePress = 1.0; // 基准压力
        private double _baseSpeed = 1500; // 基准转速

        public DeviceData GetNextData()
        {
            // 模拟小范围波动(±5%)
            var temp = _baseTemp + (_random.NextDouble() - 0.5) * 10;
            var press = _basePress + (_random.NextDouble() - 0.5) * 0.2;
            var speed = _baseSpeed + (_random.NextDouble() - 0.5) * 300;

            // 偶尔出现较大波动(模拟异常)
            if (_random.Next(100) < 5) // 5%概率
            {
                temp += (_random.NextDouble() - 0.5) * 20;
                press += (_random.NextDouble() - 0.5) * 0.5;
            }

            // 限制范围
            return new DeviceData
            {
                Temperature = Math.Clamp(temp, 0, 100),
                Pressure = Math.Clamp(press, 0, 2.5),
                Speed = Math.Clamp(speed, 0, 3000)
            };
        }
    }
}

// Helpers/RelayCommand.cs(命令实现)
using System;
using System.Windows.Input;

namespace DeviceMonitor
{
    public class RelayCommand : ICommand
    {
        private readonly Action _execute;
        private readonly Func<bool> _canExecute;

        public RelayCommand(Action execute, Func<bool> canExecute = null)
        {
            _execute = execute;
            _canExecute = canExecute;
        }

        public bool CanExecute(object parameter) => _canExecute?.Invoke() ?? true;
        public void Execute(object parameter) => _execute();
        public event EventHandler CanExecuteChanged
        {
            add => CommandManager.RequerySuggested += value;
            remove => CommandManager.RequerySuggested -= value;
        }
    }
}

步骤4:数据导出Excel(EPPlus)

使用EPPlus将历史数据导出为Excel,包含表头、时间戳、温度/压力/转速列,支持中文显示。


// Helpers/ExcelExporter.cs
using System.Collections.ObjectModel;
using OfficeOpenXml;
using DeviceMonitor.Models;
using System.IO;

namespace DeviceMonitor.Helpers
{
    public static class ExcelExporter
    {
        public static void Export(ObservableCollection<HistoricalData> data, string filePath)
        {
            // 必须设置许可证上下文(EPPlus 5+要求)
            ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

            using (var package = new ExcelPackage(new FileInfo(filePath)))
            {
                // 添加工作表
                var worksheet = package.Workbook.Worksheets.Add("设备历史数据");

                // 表头
                worksheet.Cells[1, 1].Value = "时间";
                worksheet.Cells[1, 2].Value = "温度 (℃)";
                worksheet.Cells[1, 3].Value = "压力 (MPa)";
                worksheet.Cells[1, 4].Value = "转速 (rpm)";

                // 设置表头样式(加粗)
                worksheet.Cells[1, 1, 1, 4].Style.Font.Bold = true;

                // 填充数据
                for (int i = 0; i < data.Count; i++)
                {
                    var item = data[i];
                    worksheet.Cells[i + 2, 1].Value = item.Time;
                    worksheet.Cells[i + 2, 1].Style.Numberformat.Format = "yyyy-MM-dd HH:mm:ss"; // 时间格式
                    
                    worksheet.Cells[i + 2, 2].Value = item.Temperature;
                    worksheet.Cells[i + 2, 2].Style.Numberformat.Format = "0.0"; // 保留1位小数
                    
                    worksheet.Cells[i + 2, 3].Value = item.Pressure;
                    worksheet.Cells[i + 2, 3].Style.Numberformat.Format = "0.00"; // 保留2位小数
                    
                    worksheet.Cells[i + 2, 4].Value = item.Speed;
                }

                // 自动调整列宽
                worksheet.Cells.AutoFitColumns();

                // 保存文件
                package.Save();
            }
        }
    }
}

工业级优化与避坑指南

1. 性能优化(避免卡顿)

趋势图数据限制:只保留最新50-100个点,避免曲线绘制卡顿;UI更新频率控制:实时数据1秒刷新一次足够(人眼无法分辨更高频率);仪表盘动画优化:指针动画时长控制在0.3-0.5秒,避免过度动画消耗资源。

2. 异常处理(提升稳定性)

Excel导出异常:捕获文件占用、权限不足等错误,提示用户重试;数据范围校验:确保仪表盘数值在
MinValue-MaxValue
范围内,避免指针角度异常;线程安全:后台数据采集线程更新UI时,必须用
Dispatcher.Invoke
切换到UI线程。

3. 易用性增强

导出路径记忆:保存用户上次选择的路径,下次打开自动填充;趋势图交互:OxyPlot支持鼠标悬停显示数据点值,添加图例区分曲线;超限报警:数值超限时,除颜色变化外,可添加声音提示(
System.Media.SoundPlayer
)。

结语:从Demo到工业产品的关键

本文实现的监控面板已具备工业场景的基础功能,但要成为产品级应用,还需根据实际需求扩展:

数据来源:替换
DataSimulator
,对接真实设备(Modbus/OPC UA等协议);用户权限:添加登录功能,区分操作工/管理员权限;报警记录:单独记录超限事件,支持查询和导出;多设备支持:扩展为多标签页,同时监控多台设备。

WPF的优势在于“UI与逻辑分离”和“自定义控件灵活性”,通过MVVM模式可轻松扩展功能,而无需大幅修改界面。掌握本文的仪表盘、趋势图、Excel导出三大核心模块,就能应对80%以上的工业监控场景。

你在开发监控面板时遇到过哪些UI或性能问题?欢迎在评论区分享解决方案~

© 版权声明

相关文章

暂无评论

none
暂无评论...