diff --git a/CurveModule/ViewModels/CurveRecallViewModel.cs b/CurveModule/ViewModels/CurveRecallViewModel.cs index a2fe064..888f4d6 100644 --- a/CurveModule/ViewModels/CurveRecallViewModel.cs +++ b/CurveModule/ViewModels/CurveRecallViewModel.cs @@ -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 - - { + /// + /// 曲线回读:加载 RecordView 导出的 CSV 文件,按 (MonitorName, Scope) 分组绘制曲线。 + /// public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable { #region 属性 public bool KeepAlive => true; + + public PlotModel Plot { get; } + + /// 已加载的曲线通道列表 + public ObservableCollection 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 命令处理 + /// + /// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。 + /// + 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 解析 + /// + /// 解析 RecordView 导出的 CSV 文件,返回监测记录列表。 + /// + private static List ParseCsv(string path) + { + var results = new List(); + + 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; + } + + /// + /// 解析单行 CSV,正确处理引号转义。 + /// + private static List ParseCsvLine(string line) + { + var fields = new List(); + 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 构建 + /// + /// 构建空的 OxyPlot 模型(与 MonitorView 完全一致的配置)。 + /// + 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 + } + + /// + /// CSV 监测记录(单行数据)。 + /// + 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; } + } + + /// + /// 曲线回读通道:MonitorName + Scope 唯一标识一条曲线。 + /// + 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; } } } diff --git a/CurveModule/Views/CurveRecallView.xaml b/CurveModule/Views/CurveRecallView.xaml index 8de793a..18ecbb9 100644 --- a/CurveModule/Views/CurveRecallView.xaml +++ b/CurveModule/Views/CurveRecallView.xaml @@ -1,14 +1,158 @@  - - - + 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"> + + + + + + + + + + + + + + + + + + + +