曲线回读代码位置修改
This commit is contained in:
@@ -1,71 +1,381 @@
|
||||
using Logger;
|
||||
using MahApps.Metro.Controls;
|
||||
using Model.Entity;
|
||||
using Model.Models;
|
||||
using NLog.Targets;
|
||||
using OxyPlot;
|
||||
using Service.Interface;
|
||||
using OxyPlot.Axes;
|
||||
using OxyPlot.Legends;
|
||||
using OxyPlot.Series;
|
||||
using Prism.Commands;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
|
||||
using UIShare.GlobalVariable;
|
||||
using UIShare.UIViewModel;
|
||||
using UIShare.ViewModelBase;
|
||||
|
||||
namespace CurveModule.ViewModels
|
||||
|
||||
|
||||
{
|
||||
/// <summary>
|
||||
/// 曲线回读:加载 RecordView 导出的 CSV 文件,按 (MonitorName, Scope) 分组绘制曲线。
|
||||
/// </summary>
|
||||
public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable
|
||||
{
|
||||
#region 属性
|
||||
public bool KeepAlive => true;
|
||||
|
||||
public PlotModel Plot { get; }
|
||||
|
||||
/// <summary>已加载的曲线通道列表</summary>
|
||||
public ObservableCollection<CurveRecallChannelVM> Channels { get; } = new();
|
||||
|
||||
private CurveRecallChannelVM? _selectedChannel;
|
||||
public CurveRecallChannelVM? SelectedChannel
|
||||
{
|
||||
get => _selectedChannel;
|
||||
set => SetProperty(ref _selectedChannel, value);
|
||||
}
|
||||
|
||||
private string _statusMessage = "请加载 CSV 文件以回读曲线";
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 命令
|
||||
|
||||
public ICommand LoadCsvCommand { get; }
|
||||
public ICommand ResetViewCommand { get; }
|
||||
public ICommand ClearCommand { get; }
|
||||
#endregion
|
||||
|
||||
#region 私有字段
|
||||
|
||||
// 颜色调色盘(与 MonitorView 一致)
|
||||
private static readonly OxyColor[] _palette =
|
||||
{
|
||||
OxyColors.SteelBlue, OxyColors.IndianRed, OxyColors.SeaGreen,
|
||||
OxyColors.DarkOrange, OxyColors.MediumPurple, OxyColors.Goldenrod,
|
||||
OxyColors.Teal, OxyColors.Crimson, OxyColors.OliveDrab
|
||||
};
|
||||
private int _colorIndex;
|
||||
#endregion
|
||||
|
||||
public CurveRecallViewModel(IContainerProvider containerProvider) : base(containerProvider)
|
||||
{
|
||||
|
||||
Plot = BuildEmptyPlot();
|
||||
LoadCsvCommand = new DelegateCommand(OnLoadCsv);
|
||||
ResetViewCommand = new DelegateCommand(OnResetView);
|
||||
ClearCommand = new DelegateCommand(OnClear);
|
||||
}
|
||||
|
||||
#region 命令处理
|
||||
/// <summary>
|
||||
/// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。
|
||||
/// </summary>
|
||||
private void OnLoadCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Filter = "CSV 文件 (*.csv)|*.csv|所有文件|*.*",
|
||||
Title = "选择要加载的监测数据 CSV 文件"
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
var records = ParseCsv(dlg.FileName);
|
||||
if (records.Count == 0)
|
||||
{
|
||||
StatusMessage = "CSV 文件为空或格式不正确";
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 (MonitorName, Scope) 分组
|
||||
var groups = records.GroupBy(r => (r.MonitorName, r.Scope))
|
||||
.OrderBy(g => g.Key.MonitorName)
|
||||
.ThenBy(g => g.Key.Scope);
|
||||
|
||||
// 清空旧数据
|
||||
ClearPlotAndChannels();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var color = _palette[_colorIndex % _palette.Length];
|
||||
_colorIndex++;
|
||||
|
||||
string displayName = string.IsNullOrEmpty(group.Key.Scope)
|
||||
? group.Key.MonitorName
|
||||
: $"{group.Key.MonitorName} [{group.Key.Scope}]";
|
||||
|
||||
var channel = new CurveRecallChannelVM
|
||||
{
|
||||
MonitorName = group.Key.MonitorName,
|
||||
Scope = group.Key.Scope,
|
||||
DisplayName = displayName,
|
||||
Color = color,
|
||||
IsDisplayed = true
|
||||
};
|
||||
|
||||
// 创建 LineSeries 并填充数据点
|
||||
channel.Series = new LineSeries
|
||||
{
|
||||
Title = displayName,
|
||||
Color = color,
|
||||
StrokeThickness = 1.5
|
||||
};
|
||||
|
||||
// 按时间排序
|
||||
var sortedPoints = group.OrderBy(r => r.CreateTime);
|
||||
foreach (var point in sortedPoints)
|
||||
{
|
||||
channel.Series.Points.Add(new DataPoint(point.CreateTime.ToOADate(), point.MonitorValue));
|
||||
}
|
||||
|
||||
channel.PropertyChanged += OnChannelPropertyChanged;
|
||||
|
||||
Plot.Series.Add(channel.Series);
|
||||
Channels.Add(channel);
|
||||
}
|
||||
|
||||
Plot.InvalidatePlot(true);
|
||||
StatusMessage = $"已加载 {Path.GetFileName(dlg.FileName)},共 {Channels.Count} 条曲线,{records.Count} 个数据点";
|
||||
LoggerHelper.Info($"曲线回读:加载 {dlg.FileName},{Channels.Count} 条曲线,{records.Count} 个数据点");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.Error($"加载 CSV 失败:{ex.Message}");
|
||||
StatusMessage = $"加载失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResetView()
|
||||
{
|
||||
Plot.ResetAllAxes();
|
||||
Plot.InvalidatePlot(false);
|
||||
StatusMessage = "视图已复原";
|
||||
}
|
||||
|
||||
private void OnClear()
|
||||
{
|
||||
ClearPlotAndChannels();
|
||||
Plot.InvalidatePlot(true);
|
||||
StatusMessage = "已清空,请重新加载 CSV 文件";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CSV 解析
|
||||
/// <summary>
|
||||
/// 解析 RecordView 导出的 CSV 文件,返回监测记录列表。
|
||||
/// </summary>
|
||||
private static List<MonitorRecord> ParseCsv(string path)
|
||||
{
|
||||
var results = new List<MonitorRecord>();
|
||||
|
||||
using var sr = new StreamReader(path, Encoding.UTF8);
|
||||
|
||||
// 读取表头
|
||||
string? headerLine = sr.ReadLine();
|
||||
if (headerLine == null) return results;
|
||||
|
||||
var headers = ParseCsvLine(headerLine);
|
||||
int monitorNameIdx = headers.FindIndex(h => h.Trim() == "MonitorName");
|
||||
int monitorValueIdx = headers.FindIndex(h => h.Trim() == "MonitorValue");
|
||||
int scopeIdx = headers.FindIndex(h => h.Trim() == "Scope");
|
||||
int createTimeIdx = headers.FindIndex(h => h.Trim() == "CreateTime");
|
||||
|
||||
if (monitorNameIdx < 0 || monitorValueIdx < 0 || scopeIdx < 0 || createTimeIdx < 0)
|
||||
return results;
|
||||
|
||||
int maxIdx = Math.Max(monitorNameIdx, Math.Max(monitorValueIdx, Math.Max(scopeIdx, createTimeIdx)));
|
||||
|
||||
string? line;
|
||||
while ((line = sr.ReadLine()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var fields = ParseCsvLine(line);
|
||||
if (fields.Count <= maxIdx) continue;
|
||||
|
||||
if (!double.TryParse(fields[monitorValueIdx].Trim(), out double value)) continue;
|
||||
if (!DateTime.TryParse(fields[createTimeIdx].Trim(), out DateTime createTime)) continue;
|
||||
|
||||
results.Add(new MonitorRecord
|
||||
{
|
||||
MonitorName = fields[monitorNameIdx].Trim(),
|
||||
MonitorValue = value,
|
||||
Scope = fields[scopeIdx].Trim(),
|
||||
CreateTime = createTime
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析单行 CSV,正确处理引号转义。
|
||||
/// </summary>
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
bool inQuotes = false;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = true;
|
||||
}
|
||||
else if (c == ',')
|
||||
{
|
||||
fields.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region OxyPlot 构建
|
||||
/// <summary>
|
||||
/// 构建空的 OxyPlot 模型(与 MonitorView 完全一致的配置)。
|
||||
/// </summary>
|
||||
private static PlotModel BuildEmptyPlot()
|
||||
{
|
||||
var pm = new PlotModel
|
||||
{
|
||||
Title = "曲线回读",
|
||||
PlotAreaBorderColor = OxyColors.LightGray,
|
||||
Background = OxyColors.White
|
||||
};
|
||||
pm.Axes.Add(new DateTimeAxis
|
||||
{
|
||||
Position = AxisPosition.Bottom,
|
||||
Title = "时间",
|
||||
StringFormat = "HH:mm:ss",
|
||||
MajorGridlineStyle = LineStyle.Dot,
|
||||
MinorGridlineStyle = LineStyle.None
|
||||
});
|
||||
pm.Axes.Add(new LinearAxis
|
||||
{
|
||||
Position = AxisPosition.Left,
|
||||
Title = "值",
|
||||
MajorGridlineStyle = LineStyle.Dot,
|
||||
MinorGridlineStyle = LineStyle.None
|
||||
});
|
||||
pm.Legends.Add(new Legend
|
||||
{
|
||||
LegendPosition = LegendPosition.RightTop,
|
||||
LegendBackground = OxyColor.FromAColor(200, OxyColors.White),
|
||||
LegendBorder = OxyColors.LightGray
|
||||
});
|
||||
return pm;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
private void OnChannelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(CurveRecallChannelVM.IsDisplayed)) return;
|
||||
if (sender is not CurveRecallChannelVM channel) return;
|
||||
|
||||
if (channel.IsDisplayed)
|
||||
{
|
||||
if (channel.Series != null && !Plot.Series.Contains(channel.Series))
|
||||
{
|
||||
Plot.Series.Add(channel.Series);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (channel.Series != null)
|
||||
{
|
||||
Plot.Series.Remove(channel.Series);
|
||||
}
|
||||
}
|
||||
Plot.InvalidatePlot(true);
|
||||
}
|
||||
|
||||
private void ClearPlotAndChannels()
|
||||
{
|
||||
foreach (var ch in Channels)
|
||||
{
|
||||
ch.PropertyChanged -= OnChannelPropertyChanged;
|
||||
}
|
||||
Plot.Series.Clear();
|
||||
Channels.Clear();
|
||||
_colorIndex = 0;
|
||||
}
|
||||
#endregion
|
||||
#region 生命周期
|
||||
|
||||
#region 生命周期
|
||||
public override void OnNavigatedTo(NavigationContext navigationContext)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ClearPlotAndChannels();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV 监测记录(单行数据)。
|
||||
/// </summary>
|
||||
internal class MonitorRecord
|
||||
{
|
||||
public string MonitorName { get; set; } = string.Empty;
|
||||
public double MonitorValue { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 曲线回读通道:MonitorName + Scope 唯一标识一条曲线。
|
||||
/// </summary>
|
||||
public class CurveRecallChannelVM : Prism.Mvvm.BindableBase
|
||||
{
|
||||
public string MonitorName { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public OxyColor Color { get; set; } = OxyColors.SteelBlue;
|
||||
|
||||
private bool _isDisplayed = true;
|
||||
public bool IsDisplayed
|
||||
{
|
||||
get => _isDisplayed;
|
||||
set => SetProperty(ref _isDisplayed, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public LineSeries? Series { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,158 @@
|
||||
<UserControl x:Class="CurveModule.Views.CurveRecallView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
xmlns:prism="http://prismlibrary.com/"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
prism:ViewModelLocator.AutoWireViewModel="True"
|
||||
d:DesignHeight="1080" d:DesignWidth="1920">
|
||||
<Grid>
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:prism="http://prismlibrary.com/"
|
||||
xmlns:oxy="http://oxyplot.org/wpf"
|
||||
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
|
||||
prism:ViewModelLocator.AutoWireViewModel="True"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="700"
|
||||
d:DesignWidth="1200">
|
||||
<UserControl.Resources>
|
||||
<converters:LessThanConverter x:Key="LessThanConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
</Grid>
|
||||
<Border Background="#F5F7FA">
|
||||
<Grid x:Name="RootGrid" Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Row0:标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="曲线回读"
|
||||
FontSize="20" FontWeight="Bold"
|
||||
Margin="4,0,0,8"/>
|
||||
|
||||
<!-- Row1:工具栏 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4" Padding="8" Margin="0,0,0,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="📂 加载 CSV"
|
||||
Command="{Binding LoadCsvCommand}"
|
||||
Padding="12,4"/>
|
||||
<Button Content="↺ 复原视图"
|
||||
Command="{Binding ResetViewCommand}"
|
||||
Padding="12,4" Margin="6,0,0,0"
|
||||
ToolTip="按数据范围重置坐标轴缩放/平移"/>
|
||||
<Button Content="✕ 清空"
|
||||
Command="{Binding ClearCommand}"
|
||||
Padding="12,4" Margin="6,0,0,0"
|
||||
ToolTip="清空已加载的曲线数据"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row2:主体 -->
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition>
|
||||
<ColumnDefinition.Style>
|
||||
<Style TargetType="ColumnDefinition">
|
||||
<Setter Property="Width" Value="260"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ActualWidth, ElementName=RootGrid, Converter={StaticResource LessThanConverter}, ConverterParameter=600}" Value="True">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ColumnDefinition.Style>
|
||||
</ColumnDefinition>
|
||||
<ColumnDefinition>
|
||||
<ColumnDefinition.Style>
|
||||
<Style TargetType="ColumnDefinition">
|
||||
<Setter Property="Width" Value="6"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ActualWidth, ElementName=RootGrid, Converter={StaticResource LessThanConverter}, ConverterParameter=600}" Value="True">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ColumnDefinition.Style>
|
||||
</ColumnDefinition>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左侧:通道列表 -->
|
||||
<Border Grid.Column="0"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Background="#ECEFF4" Padding="8,4">
|
||||
<TextBlock Text="曲线通道(勾选显示/隐藏)" FontWeight="Bold"/>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1"
|
||||
ItemsSource="{Binding Channels}"
|
||||
SelectedItem="{Binding SelectedChannel}"
|
||||
BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="2">
|
||||
<DockPanel>
|
||||
<CheckBox IsChecked="{Binding IsDisplayed}"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip="勾选=在图表上显示,取消=隐藏"/>
|
||||
<Border Width="10" Height="10"
|
||||
CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Margin="4,0,6,0">
|
||||
<Border.Background>
|
||||
<SolidColorBrush Color="SteelBlue"/>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayName}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding StatusMessage}"
|
||||
FontSize="11" Foreground="#666"
|
||||
Margin="4,2" TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="Transparent"/>
|
||||
|
||||
<!-- 右侧:OxyPlot 图表 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<oxy:PlotView Model="{Binding Plot}"
|
||||
Background="Transparent"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Row3:状态栏 -->
|
||||
<Border Grid.Row="3"
|
||||
Background="#ECEFF4"
|
||||
Padding="8,4" Margin="0,6,0,0"
|
||||
CornerRadius="2">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Foreground="#444"
|
||||
FontSize="12"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
@@ -17,7 +17,6 @@ namespace MonitorModule
|
||||
{
|
||||
containerRegistry.RegisterForNavigation<MonitorView>("MonitorView");
|
||||
containerRegistry.RegisterForNavigation<RecordView>("RecordView");
|
||||
containerRegistry.RegisterForNavigation<CurveRecallView>("CurveRecallView");
|
||||
containerRegistry.RegisterDialog<ValueLimitView>("ValueLimitView");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
using Logger;
|
||||
using OxyPlot;
|
||||
using OxyPlot.Axes;
|
||||
using OxyPlot.Legends;
|
||||
using OxyPlot.Series;
|
||||
using Prism.Commands;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows.Input;
|
||||
using UIShare.ViewModelBase;
|
||||
|
||||
namespace MonitorModule.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// 曲线回读:加载 RecordView 导出的 CSV 文件,按 (MonitorName, Scope) 分组绘制曲线。
|
||||
/// </summary>
|
||||
public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable
|
||||
{
|
||||
#region 属性
|
||||
public bool KeepAlive => true;
|
||||
|
||||
public PlotModel Plot { get; }
|
||||
|
||||
/// <summary>已加载的曲线通道列表</summary>
|
||||
public ObservableCollection<CurveRecallChannelVM> Channels { get; } = new();
|
||||
|
||||
private CurveRecallChannelVM? _selectedChannel;
|
||||
public CurveRecallChannelVM? SelectedChannel
|
||||
{
|
||||
get => _selectedChannel;
|
||||
set => SetProperty(ref _selectedChannel, value);
|
||||
}
|
||||
|
||||
private string _statusMessage = "请加载 CSV 文件以回读曲线";
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 命令
|
||||
public ICommand LoadCsvCommand { get; }
|
||||
public ICommand ResetViewCommand { get; }
|
||||
public ICommand ClearCommand { get; }
|
||||
#endregion
|
||||
|
||||
#region 私有字段
|
||||
// 颜色调色盘(与 MonitorView 一致)
|
||||
private static readonly OxyColor[] _palette =
|
||||
{
|
||||
OxyColors.SteelBlue, OxyColors.IndianRed, OxyColors.SeaGreen,
|
||||
OxyColors.DarkOrange, OxyColors.MediumPurple, OxyColors.Goldenrod,
|
||||
OxyColors.Teal, OxyColors.Crimson, OxyColors.OliveDrab
|
||||
};
|
||||
private int _colorIndex;
|
||||
#endregion
|
||||
|
||||
public CurveRecallViewModel(IContainerProvider containerProvider) : base(containerProvider)
|
||||
{
|
||||
Plot = BuildEmptyPlot();
|
||||
LoadCsvCommand = new DelegateCommand(OnLoadCsv);
|
||||
ResetViewCommand = new DelegateCommand(OnResetView);
|
||||
ClearCommand = new DelegateCommand(OnClear);
|
||||
}
|
||||
|
||||
#region 命令处理
|
||||
/// <summary>
|
||||
/// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。
|
||||
/// </summary>
|
||||
private void OnLoadCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Filter = "CSV 文件 (*.csv)|*.csv|所有文件|*.*",
|
||||
Title = "选择要加载的监测数据 CSV 文件"
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
var records = ParseCsv(dlg.FileName);
|
||||
if (records.Count == 0)
|
||||
{
|
||||
StatusMessage = "CSV 文件为空或格式不正确";
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 (MonitorName, Scope) 分组
|
||||
var groups = records.GroupBy(r => (r.MonitorName, r.Scope))
|
||||
.OrderBy(g => g.Key.MonitorName)
|
||||
.ThenBy(g => g.Key.Scope);
|
||||
|
||||
// 清空旧数据
|
||||
ClearPlotAndChannels();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var color = _palette[_colorIndex % _palette.Length];
|
||||
_colorIndex++;
|
||||
|
||||
string displayName = string.IsNullOrEmpty(group.Key.Scope)
|
||||
? group.Key.MonitorName
|
||||
: $"{group.Key.MonitorName} [{group.Key.Scope}]";
|
||||
|
||||
var channel = new CurveRecallChannelVM
|
||||
{
|
||||
MonitorName = group.Key.MonitorName,
|
||||
Scope = group.Key.Scope,
|
||||
DisplayName = displayName,
|
||||
Color = color,
|
||||
IsDisplayed = true
|
||||
};
|
||||
|
||||
// 创建 LineSeries 并填充数据点
|
||||
channel.Series = new LineSeries
|
||||
{
|
||||
Title = displayName,
|
||||
Color = color,
|
||||
StrokeThickness = 1.5
|
||||
};
|
||||
|
||||
// 按时间排序
|
||||
var sortedPoints = group.OrderBy(r => r.CreateTime);
|
||||
foreach (var point in sortedPoints)
|
||||
{
|
||||
channel.Series.Points.Add(new DataPoint(point.CreateTime.ToOADate(), point.MonitorValue));
|
||||
}
|
||||
|
||||
channel.PropertyChanged += OnChannelPropertyChanged;
|
||||
|
||||
Plot.Series.Add(channel.Series);
|
||||
Channels.Add(channel);
|
||||
}
|
||||
|
||||
Plot.InvalidatePlot(true);
|
||||
StatusMessage = $"已加载 {Path.GetFileName(dlg.FileName)},共 {Channels.Count} 条曲线,{records.Count} 个数据点";
|
||||
LoggerHelper.Info($"曲线回读:加载 {dlg.FileName},{Channels.Count} 条曲线,{records.Count} 个数据点");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.Error($"加载 CSV 失败:{ex.Message}");
|
||||
StatusMessage = $"加载失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResetView()
|
||||
{
|
||||
Plot.ResetAllAxes();
|
||||
Plot.InvalidatePlot(false);
|
||||
StatusMessage = "视图已复原";
|
||||
}
|
||||
|
||||
private void OnClear()
|
||||
{
|
||||
ClearPlotAndChannels();
|
||||
Plot.InvalidatePlot(true);
|
||||
StatusMessage = "已清空,请重新加载 CSV 文件";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CSV 解析
|
||||
/// <summary>
|
||||
/// 解析 RecordView 导出的 CSV 文件,返回监测记录列表。
|
||||
/// </summary>
|
||||
private static List<MonitorRecord> ParseCsv(string path)
|
||||
{
|
||||
var results = new List<MonitorRecord>();
|
||||
|
||||
using var sr = new StreamReader(path, Encoding.UTF8);
|
||||
|
||||
// 读取表头
|
||||
string? headerLine = sr.ReadLine();
|
||||
if (headerLine == null) return results;
|
||||
|
||||
var headers = ParseCsvLine(headerLine);
|
||||
int monitorNameIdx = headers.FindIndex(h => h.Trim() == "MonitorName");
|
||||
int monitorValueIdx = headers.FindIndex(h => h.Trim() == "MonitorValue");
|
||||
int scopeIdx = headers.FindIndex(h => h.Trim() == "Scope");
|
||||
int createTimeIdx = headers.FindIndex(h => h.Trim() == "CreateTime");
|
||||
|
||||
if (monitorNameIdx < 0 || monitorValueIdx < 0 || scopeIdx < 0 || createTimeIdx < 0)
|
||||
return results;
|
||||
|
||||
int maxIdx = Math.Max(monitorNameIdx, Math.Max(monitorValueIdx, Math.Max(scopeIdx, createTimeIdx)));
|
||||
|
||||
string? line;
|
||||
while ((line = sr.ReadLine()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var fields = ParseCsvLine(line);
|
||||
if (fields.Count <= maxIdx) continue;
|
||||
|
||||
if (!double.TryParse(fields[monitorValueIdx].Trim(), out double value)) continue;
|
||||
if (!DateTime.TryParse(fields[createTimeIdx].Trim(), out DateTime createTime)) continue;
|
||||
|
||||
results.Add(new MonitorRecord
|
||||
{
|
||||
MonitorName = fields[monitorNameIdx].Trim(),
|
||||
MonitorValue = value,
|
||||
Scope = fields[scopeIdx].Trim(),
|
||||
CreateTime = createTime
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析单行 CSV,正确处理引号转义。
|
||||
/// </summary>
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
bool inQuotes = false;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = true;
|
||||
}
|
||||
else if (c == ',')
|
||||
{
|
||||
fields.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region OxyPlot 构建
|
||||
/// <summary>
|
||||
/// 构建空的 OxyPlot 模型(与 MonitorView 完全一致的配置)。
|
||||
/// </summary>
|
||||
private static PlotModel BuildEmptyPlot()
|
||||
{
|
||||
var pm = new PlotModel
|
||||
{
|
||||
Title = "曲线回读",
|
||||
PlotAreaBorderColor = OxyColors.LightGray,
|
||||
Background = OxyColors.White
|
||||
};
|
||||
pm.Axes.Add(new DateTimeAxis
|
||||
{
|
||||
Position = AxisPosition.Bottom,
|
||||
Title = "时间",
|
||||
StringFormat = "HH:mm:ss",
|
||||
MajorGridlineStyle = LineStyle.Dot,
|
||||
MinorGridlineStyle = LineStyle.None
|
||||
});
|
||||
pm.Axes.Add(new LinearAxis
|
||||
{
|
||||
Position = AxisPosition.Left,
|
||||
Title = "值",
|
||||
MajorGridlineStyle = LineStyle.Dot,
|
||||
MinorGridlineStyle = LineStyle.None
|
||||
});
|
||||
pm.Legends.Add(new Legend
|
||||
{
|
||||
LegendPosition = LegendPosition.RightTop,
|
||||
LegendBackground = OxyColor.FromAColor(200, OxyColors.White),
|
||||
LegendBorder = OxyColors.LightGray
|
||||
});
|
||||
return pm;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
private void OnChannelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(CurveRecallChannelVM.IsDisplayed)) return;
|
||||
if (sender is not CurveRecallChannelVM channel) return;
|
||||
|
||||
if (channel.IsDisplayed)
|
||||
{
|
||||
if (channel.Series != null && !Plot.Series.Contains(channel.Series))
|
||||
{
|
||||
Plot.Series.Add(channel.Series);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (channel.Series != null)
|
||||
{
|
||||
Plot.Series.Remove(channel.Series);
|
||||
}
|
||||
}
|
||||
Plot.InvalidatePlot(true);
|
||||
}
|
||||
|
||||
private void ClearPlotAndChannels()
|
||||
{
|
||||
foreach (var ch in Channels)
|
||||
{
|
||||
ch.PropertyChanged -= OnChannelPropertyChanged;
|
||||
}
|
||||
Plot.Series.Clear();
|
||||
Channels.Clear();
|
||||
_colorIndex = 0;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 生命周期
|
||||
public override void OnNavigatedTo(NavigationContext navigationContext)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ClearPlotAndChannels();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV 监测记录(单行数据)。
|
||||
/// </summary>
|
||||
internal class MonitorRecord
|
||||
{
|
||||
public string MonitorName { get; set; } = string.Empty;
|
||||
public double MonitorValue { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 曲线回读通道:MonitorName + Scope 唯一标识一条曲线。
|
||||
/// </summary>
|
||||
public class CurveRecallChannelVM : Prism.Mvvm.BindableBase
|
||||
{
|
||||
public string MonitorName { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public OxyColor Color { get; set; } = OxyColors.SteelBlue;
|
||||
|
||||
private bool _isDisplayed = true;
|
||||
public bool IsDisplayed
|
||||
{
|
||||
get => _isDisplayed;
|
||||
set => SetProperty(ref _isDisplayed, value);
|
||||
}
|
||||
|
||||
public LineSeries? Series { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
<UserControl x:Class="MonitorModule.Views.CurveRecallView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:prism="http://prismlibrary.com/"
|
||||
xmlns:oxy="http://oxyplot.org/wpf"
|
||||
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
|
||||
prism:ViewModelLocator.AutoWireViewModel="True"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="700"
|
||||
d:DesignWidth="1200">
|
||||
<UserControl.Resources>
|
||||
<converters:LessThanConverter x:Key="LessThanConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Background="#F5F7FA">
|
||||
<Grid x:Name="RootGrid" Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Row0:标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="曲线回读"
|
||||
FontSize="20" FontWeight="Bold"
|
||||
Margin="4,0,0,8"/>
|
||||
|
||||
<!-- Row1:工具栏 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4" Padding="8" Margin="0,0,0,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="📂 加载 CSV"
|
||||
Command="{Binding LoadCsvCommand}"
|
||||
Padding="12,4"/>
|
||||
<Button Content="↺ 复原视图"
|
||||
Command="{Binding ResetViewCommand}"
|
||||
Padding="12,4" Margin="6,0,0,0"
|
||||
ToolTip="按数据范围重置坐标轴缩放/平移"/>
|
||||
<Button Content="✕ 清空"
|
||||
Command="{Binding ClearCommand}"
|
||||
Padding="12,4" Margin="6,0,0,0"
|
||||
ToolTip="清空已加载的曲线数据"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row2:主体 -->
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition>
|
||||
<ColumnDefinition.Style>
|
||||
<Style TargetType="ColumnDefinition">
|
||||
<Setter Property="Width" Value="260"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ActualWidth, ElementName=RootGrid, Converter={StaticResource LessThanConverter}, ConverterParameter=600}" Value="True">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ColumnDefinition.Style>
|
||||
</ColumnDefinition>
|
||||
<ColumnDefinition>
|
||||
<ColumnDefinition.Style>
|
||||
<Style TargetType="ColumnDefinition">
|
||||
<Setter Property="Width" Value="6"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ActualWidth, ElementName=RootGrid, Converter={StaticResource LessThanConverter}, ConverterParameter=600}" Value="True">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ColumnDefinition.Style>
|
||||
</ColumnDefinition>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左侧:通道列表 -->
|
||||
<Border Grid.Column="0"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Background="#ECEFF4" Padding="8,4">
|
||||
<TextBlock Text="曲线通道(勾选显示/隐藏)" FontWeight="Bold"/>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1"
|
||||
ItemsSource="{Binding Channels}"
|
||||
SelectedItem="{Binding SelectedChannel}"
|
||||
BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="2">
|
||||
<DockPanel>
|
||||
<CheckBox IsChecked="{Binding IsDisplayed}"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip="勾选=在图表上显示,取消=隐藏"/>
|
||||
<Border Width="10" Height="10"
|
||||
CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Margin="4,0,6,0">
|
||||
<Border.Background>
|
||||
<SolidColorBrush Color="SteelBlue"/>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayName}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding StatusMessage}"
|
||||
FontSize="11" Foreground="#666"
|
||||
Margin="4,2" TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="Transparent"/>
|
||||
|
||||
<!-- 右侧:OxyPlot 图表 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="White"
|
||||
BorderBrush="#DDD" BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<oxy:PlotView Model="{Binding Plot}"
|
||||
Background="Transparent"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Row3:状态栏 -->
|
||||
<Border Grid.Row="3"
|
||||
Background="#ECEFF4"
|
||||
Padding="8,4" Margin="0,6,0,0"
|
||||
CornerRadius="2">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Foreground="#444"
|
||||
FontSize="12"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -1,28 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Navigation;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace MonitorModule.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// CurveRecallView.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public partial class CurveRecallView : UserControl
|
||||
{
|
||||
public CurveRecallView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,18 +358,6 @@ namespace ZLGUSBCANFD
|
||||
/// <param name="循环间隔毫秒">0 = 只发送一次;>0 = 按指定间隔循环发送(毫秒)</param>
|
||||
/// <param name="是否使用CANFD">1 = CANFD,0 = CAN</param>
|
||||
/// <returns>是否成功启动/发送</returns>
|
||||
public virtual bool 发送DBC定义报文(
|
||||
uint 通道号,
|
||||
uint 帧ID,
|
||||
Dictionary<string, double> 信号物理值字典,
|
||||
int 循环间隔毫秒 = 0,
|
||||
int 是否使用CANFD = 1)
|
||||
{
|
||||
if (通道号 >= _maxChannels || _channelHandles[通道号] == IntPtr.Zero) return false;
|
||||
if (!_isDbcLoadedArray[通道号]) throw new InvalidOperationException($"通道 {通道号} 的 DBC 协议未加载,无法执行打包发送指令。");
|
||||
if (循环间隔毫秒 < 0) throw new ArgumentOutOfRangeException(nameof(循环间隔毫秒), "循环间隔必须大于或等于 0。");
|
||||
if (信号物理值字典 == null || 信号物理值字典.Count == 0)
|
||||
throw new ArgumentException("信号物理值字典不能为空。", nameof(信号物理值字典));
|
||||
|
||||
if (循环间隔毫秒 == 0)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user