382 lines
13 KiB
C#
382 lines
13 KiB
C#
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; }
|
||
}
|
||
}
|