diff --git a/ACP/App.xaml.cs b/ACP/App.xaml.cs index ef1487e..c9b45b0 100644 --- a/ACP/App.xaml.cs +++ b/ACP/App.xaml.cs @@ -176,7 +176,7 @@ namespace ACP //注册弹窗 containerRegistry.RegisterDialog("MessageBox"); // 注册通知管理器 - INotificationManager NotificationManager = new NotificationManager(); + INotificationManager NotificationManager = new NotificationManager(); containerRegistry.RegisterInstance(NotificationManager); // 注册仓储 containerRegistry.RegisterScoped(typeof(SqlSugarRepository<>)); diff --git a/PowerAdaptabilityModule/ViewModels/PowerAdaptabilityViewModel.cs b/PowerAdaptabilityModule/ViewModels/PowerAdaptabilityViewModel.cs index c761809..b61ef00 100644 --- a/PowerAdaptabilityModule/ViewModels/PowerAdaptabilityViewModel.cs +++ b/PowerAdaptabilityModule/ViewModels/PowerAdaptabilityViewModel.cs @@ -1,4 +1,5 @@ using DeviceCommand.Devices; +using Microsoft.Win32; using Newtonsoft.Json; using Prism.Commands; using Prism.Ioc; @@ -11,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; +using System.Windows.Media.Imaging; using UIShare.GlobalVariable; using UIShare.ViewModelBase; @@ -26,6 +28,7 @@ namespace PowerAdaptabilityModule.ViewModels private readonly DeviceManager _dm; private readonly GlobalInfo _globalInfo; private Chroma61800? _dev; + private TektronixMSO? _scope; private CancellationTokenSource? _cmdCts; #endregion @@ -40,6 +43,92 @@ namespace PowerAdaptabilityModule.ViewModels public bool IsBusy { get => _isBusy; set => SetProperty(ref _isBusy, value); } #endregion + #region 示波器(TektronixMSO)屏幕抓图 + private string _scopeName = "TektronixMSO"; + /// 示波器设备名称(DeviceMap 键名) + public string ScopeName { get => _scopeName; set => SetProperty(ref _scopeName, value); } + + private bool _isScopeConnected; + /// 示波器连接状态 + public bool IsScopeConnected { get => _isScopeConnected; set => SetProperty(ref _isScopeConnected, value); } + + private string _scopeImageDir = @"D:\acp\源测试\波形截图"; + /// 波形图片存储位置(抓取的屏幕 PNG 保存目录),抓图/浏览选择时自动持久化 + public string ScopeImageDir { get => _scopeImageDir; set => SetProperty(ref _scopeImageDir, value); } + + private BitmapImage? _scopeImage; + /// 最近一次抓取的示波器屏幕图片(供界面 Image 控件显示) + public BitmapImage? ScopeImage { get => _scopeImage; set => SetProperty(ref _scopeImage, value); } + + private bool _hasScopeImage; + /// 是否已有截图(控制图片区占位提示的显隐) + public bool HasScopeImage { get => _hasScopeImage; set => SetProperty(ref _hasScopeImage, value); } + + private string _scopeImageHint = ""; + /// 最近抓取的图片文件提示 + public string ScopeImageHint { get => _scopeImageHint; set => SetProperty(ref _scopeImageHint, value); } + #endregion + + #region 示波器设置(采集 / 水平 / 通道 / 测量,对应 TektronixMSO 已有 SCPI 方法) + /// 采集模式选项 (ACQuire:MODe,显示名与 TekAcquisitionMode 枚举一致) + public IReadOnlyList ScopeAcquireModeOptions { get; } = + new[] { "SAMple", "PEAKdetect", "HIRes", "AVErage", "ENVelope" }; + + /// 测量类型选项 (MEASUrement:TYPe,显示名与 TekMeasurementType 枚举一致) + public IReadOnlyList ScopeMeasTypeOptions { get; } = + new[] { "AMPLitude", "FREQuency", "MEAN", "PK2PK", "MAXimum", "MINimum" }; + + /// 通道选择列表 (对应通道号 1~4) + public IReadOnlyList ScopeChannelOptions { get; } = new[] { "CH1", "CH2", "CH3", "CH4" }; + + private string _scopeAcquireMode = "SAMple"; + /// 采集模式 (ACQuire:MODe) + public string ScopeAcquireMode { get => _scopeAcquireMode; set => SetProperty(ref _scopeAcquireMode, value); } + + private double _scopeTimeScale = 0.001; + /// 水平刻度待设值 (HORizontal:SCAle, s/Div) + public double ScopeTimeScale { get => _scopeTimeScale; set => SetProperty(ref _scopeTimeScale, value); } + + private long _scopeRecordLength = 6250000; + /// 记录长度待设值 (HORizontal:RECOrdlength, 采样点数) + public long ScopeRecordLength { get => _scopeRecordLength; set => SetProperty(ref _scopeRecordLength, value); } + + private string _sampleRateText = ""; + /// 采样率查询结果显示 (HORizontal:SAMPLERate?, Sa/s) + public string SampleRateText { get => _sampleRateText; set => SetProperty(ref _sampleRateText, value); } + + private int _selectedScopeChannelIndex; + /// 当前编辑通道索引 (0~3 → CH1~CH4) + public int SelectedScopeChannelIndex { get => _selectedScopeChannelIndex; set => SetProperty(ref _selectedScopeChannelIndex, value); } + + private double _channelScale = 100; + /// 通道垂直刻度待设值 (CH:SCAle, V/Div) + public double ChannelScale { get => _channelScale; set => SetProperty(ref _channelScale, value); } + + private double _channelPosition; + /// 通道垂直位置待设值 (CH:POSition, Div) + public double ChannelPosition { get => _channelPosition; set => SetProperty(ref _channelPosition, value); } + + private int _measurementNumber = 1; + /// 测量项编号 (MEASUrement:MEASn) + public int MeasurementNumber { get => _measurementNumber; set => SetProperty(ref _measurementNumber, value); } + + private string _measurementType = "FREQuency"; + /// 测量类型 (MEASUrement:TYPe) + public string MeasurementType { get => _measurementType; set => SetProperty(ref _measurementType, value); } + + private string _measurementSource = "CH1"; + /// 测量源 (MEASUrement:SOUrce1, 如 CH1/CH2) + public string MeasurementSource { get => _measurementSource; set => SetProperty(ref _measurementSource, value); } + + private string _measResultText = ""; + /// 测量查询结果显示 + public string MeasResultText { get => _measResultText; set => SetProperty(ref _measResultText, value); } + + /// 当前编辑通道号 (1~4) + private int ScopeChannelNo => SelectedScopeChannelIndex + 1; + #endregion + #region 输入参数(三相输出设置) private double _voltage = 220; /// 待设置的交流电压 (V) @@ -212,6 +301,9 @@ namespace PowerAdaptabilityModule.ViewModels /// 当前波形说明(随文件保存,载入后自动填充) public string SynthPresetNote { get => _synthPresetNote; set => SetProperty(ref _synthPresetNote, value); } + /// 当前已打开的波形文件完整路径(载入或另存后设置);null 表示尚未与磁盘文件绑定 + private string? _currentSynthPath; + private int _selectedSynthPresetIndex = -1; /// 当前选中的合成波形预设索引(-1 表示未选择) public int SelectedSynthPresetIndex @@ -260,6 +352,9 @@ namespace PowerAdaptabilityModule.ViewModels /// 当前测试项说明(随文件保存,载入后自动填充) public string PresetNote { get => _presetNote; set => SetProperty(ref _presetNote, value); } + /// 当前已打开的测试项文件完整路径(载入或另存后设置);null 表示尚未与磁盘文件绑定 + private string? _currentPresetPath; + private int _selectedPresetIndex = -1; /// 当前选中的测试项索引(-1 表示未选择) public int SelectedPresetIndex @@ -305,6 +400,7 @@ namespace PowerAdaptabilityModule.ViewModels public ICommand EnterListModeCommand { get; } public ICommand LoadPresetCommand { get; } public ICommand SavePresetCommand { get; } + public ICommand SavePresetAsCommand { get; } public ICommand DeletePresetCommand { get; } public ICommand CopyPhaseToThreeCommand { get; } public ICommand DownloadThreePhaseListCommand { get; } @@ -314,7 +410,28 @@ namespace PowerAdaptabilityModule.ViewModels public ICommand ResetSynthHarmonicsCommand { get; } public ICommand LoadSynthPresetCommand { get; } public ICommand SaveSynthPresetCommand { get; } + public ICommand SaveSynthPresetAsCommand { get; } public ICommand DeleteSynthPresetCommand { get; } + public ICommand CaptureScopeImageCommand { get; } + public ICommand BrowseScopeImageDirCommand { get; } + public ICommand ScopeAcquireRunCommand { get; } + public ICommand ScopeAcquireStopCommand { get; } + public ICommand QueryAcquireStateCommand { get; } + public ICommand ApplyAcquireModeCommand { get; } + public ICommand SetScopeTimeScaleCommand { get; } + public ICommand SetRecordLengthCommand { get; } + public ICommand QuerySampleRateCommand { get; } + public ICommand ChannelDisplayOnCommand { get; } + public ICommand ChannelDisplayOffCommand { get; } + public ICommand SetChannelScaleCommand { get; } + public ICommand SetChannelPositionCommand { get; } + public ICommand AddMeasurementCommand { get; } + public ICommand QueryMeasCurrentCommand { get; } + public ICommand QueryMeasMeanCommand { get; } + public ICommand QueryMeasMaxCommand { get; } + public ICommand QueryScopeIdnCommand { get; } + public ICommand ClearScopeErrorsCommand { get; } + public ICommand ReadScopeErrorsCommand { get; } #endregion public PowerAdaptabilityViewModel(IContainerProvider containerProvider) : base(containerProvider) @@ -330,6 +447,9 @@ namespace PowerAdaptabilityModule.ViewModels EnsureSynthStorage(); RefreshSynthPresets(); + // 恢复上次设置的波形图片存储位置 + LoadScopeImageDir(); + #region 命令初始化 QueryIdnCommand = new DelegateCommand(async () => await Exec(async () => Log("IDN: " + await _dev!.查询设备信息Async(Ct())))); @@ -411,14 +531,18 @@ namespace PowerAdaptabilityModule.ViewModels ListEditPhaseIndex = 0; // 切到 A 相显示(若原本就是 0,setter 不触发刷新,故下方手动刷新) RaiseListSequenceChanged(); - PresetName = p.Name; // 填充名称/说明,便于修改后覆盖保存 + PresetName = p.Name; // 名称框回显当前文件名 PresetNote = p.Note; + _currentPresetPath = PresetPathOf(p.Name); // 记住当前打开的文件,此后“保存”只覆盖它 Log($"已载入测试项:{p.Name}(次数={(p.Count == 0 ? "无限" : p.Count.ToString())} 触发={p.Trigger} 时基={p.TimeBase})"); }); - // 保存当前界面的 LIST 配置为测试项文件(写入 D:\acp\源测试\{名称}.json) + // 保存:覆盖当前打开的测试项文件(未打开文件时按“另存为”新建) SavePresetCommand = new DelegateCommand(SaveCurrentPreset); + // 另存为:以“名称”框内容为新文件名新建文件,不修改当前打开的文件 + SavePresetAsCommand = new DelegateCommand(SaveCurrentPresetAs); + // 删除当前选中的测试项文件(带二次确认) DeletePresetCommand = new DelegateCommand(DeleteCurrentPreset); @@ -517,10 +641,145 @@ namespace PowerAdaptabilityModule.ViewModels Log("SYNTHESIS 谐波已重置:振幅/相位两框均填入 50 个 0;点「下发并启动 SYNTH」写入仪器"); }); - // 合成波形的载入 / 保存 / 删除(与 LIST 测试项同机制,文件存于 D:\acp\源测试\合成波形) + // 合成波形的载入 / 保存 / 另存为 / 删除(与 LIST 测试项同机制,文件存于 D:\acp\源测试\合成波形) LoadSynthPresetCommand = new DelegateCommand(LoadCurrentSynthPreset); SaveSynthPresetCommand = new DelegateCommand(SaveCurrentSynthPreset); + SaveSynthPresetAsCommand = new DelegateCommand(SaveCurrentSynthPresetAs); DeleteSynthPresetCommand = new DelegateCommand(DeleteCurrentSynthPreset); + + // 示波器抓图:抓取当前屏幕保存为 PNG 到存储位置,成功后自动加载显示 + CaptureScopeImageCommand = new DelegateCommand(async () => await ExecScope(async () => + { + if (string.IsNullOrWhiteSpace(ScopeImageDir)) + { + Log("请先设置波形图片存储位置"); + return; + } + + SaveScopeImageDir(); // 记住本次使用的存储位置 + Directory.CreateDirectory(ScopeImageDir); + string file = Path.Combine(ScopeImageDir, $"屏幕_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"); + + await _scope!.保存屏幕截图(file, Ct()); // SAVe:IMAGe → *OPC? → READFile → 写盘 + + LoadScopeImage(file); + Log($"示波器屏幕图片已抓取:{file}"); + })); + + // 浏览选择波形图片存储位置(选择后立即记住) + BrowseScopeImageDirCommand = new DelegateCommand(() => + { + var dlg = new OpenFolderDialog + { + Title = "选择波形图片存储位置", + InitialDirectory = Directory.Exists(ScopeImageDir) ? ScopeImageDir : PresetStorageDir, + }; + if (dlg.ShowDialog() != true) return; // 用户取消 + + ScopeImageDir = dlg.FolderName; + SaveScopeImageDir(); + Log($"波形图片存储位置已设置:{ScopeImageDir}"); + }); + + // —— 采集控制 —— + ScopeAcquireRunCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置采集状态(true, Ct()); Log("示波器采集已启动 (RUN)"); })); + + // 停止采集定格画面后抓图,是最稳定的截图方式(截图抓的是当前屏幕画面) + ScopeAcquireStopCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置采集状态(false, Ct()); Log("示波器采集已停止 (STOP),画面已定格;可点「抓取示波器屏幕图片」截取稳定波形"); })); + + QueryAcquireStateCommand = new DelegateCommand(async () => + await ExecScope(async () => Log("采集状态: " + await _scope!.查询采集状态(Ct())))); + + ApplyAcquireModeCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + if (!Enum.TryParse(ScopeAcquireMode, out var mode)) + { + Log($"未知采集模式:{ScopeAcquireMode}"); + return; + } + await _scope!.设置采集模式(mode, Ct()); + Log($"采集模式已设置:{mode}"); + })); + + // —— 水平系统 —— + SetScopeTimeScaleCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置水平刻度(ScopeTimeScale, Ct()); Log($"水平刻度={ScopeTimeScale} s/Div"); })); + + SetRecordLengthCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置记录长度(ScopeRecordLength, Ct()); Log($"记录长度={ScopeRecordLength} 点"); })); + + QuerySampleRateCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + double sr = await _scope!.查询采样率(Ct()); + SampleRateText = $"{sr:0.###} Sa/s"; + Log($"采样率={sr} Sa/s"); + })); + + // —— 通道设置 —— + ChannelDisplayOnCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置通道显示开关(ScopeChannelNo, true, Ct()); Log($"{ScopeChannelOptions[SelectedScopeChannelIndex]} 显示已开启"); })); + + ChannelDisplayOffCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置通道显示开关(ScopeChannelNo, false, Ct()); Log($"{ScopeChannelOptions[SelectedScopeChannelIndex]} 显示已关闭"); })); + + SetChannelScaleCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置通道垂直刻度(ScopeChannelNo, ChannelScale, Ct()); Log($"{ScopeChannelOptions[SelectedScopeChannelIndex]} 垂直刻度={ChannelScale} V/Div"); })); + + SetChannelPositionCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.设置通道垂直位置(ScopeChannelNo, ChannelPosition, Ct()); Log($"{ScopeChannelOptions[SelectedScopeChannelIndex]} 垂直位置={ChannelPosition} Div"); })); + + // —— 自动测量:开启测量项 + 类型/源一次完成(4/5/6 系必须先 ADDNew 动态创建) —— + AddMeasurementCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + if (!Enum.TryParse(MeasurementType, out var type)) + { + Log($"未知测量类型:{MeasurementType}"); + return; + } + await _scope!.开启测量项(MeasurementNumber, Ct()); + await _scope.设置测量类型(MeasurementNumber, type, Ct()); + await _scope.设置测量源(MeasurementNumber, MeasurementSource, Ct()); + Log($"测量项 MEAS{MeasurementNumber} 已开启:{type} @ {MeasurementSource}"); + })); + + QueryMeasCurrentCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + double v = await _scope!.查询测量项当前值(MeasurementNumber, Ct()); + MeasResultText = $"MEAS{MeasurementNumber} 当前值: {v}"; + Log(MeasResultText); + })); + + QueryMeasMeanCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + double v = await _scope!.查询测量项全局平均值(MeasurementNumber, Ct()); + MeasResultText = $"MEAS{MeasurementNumber} 全局均值: {v}"; + Log(MeasResultText); + })); + + QueryMeasMaxCommand = new DelegateCommand(async () => + await ExecScope(async () => + { + double v = await _scope!.查询测量项全局最大值(MeasurementNumber, Ct()); + MeasResultText = $"MEAS{MeasurementNumber} 全局最大: {v}"; + Log(MeasResultText); + })); + + // —— 设备辅助 —— + QueryScopeIdnCommand = new DelegateCommand(async () => + await ExecScope(async () => Log("示波器 IDN: " + await _scope!.查询设备标识(Ct())))); + + ClearScopeErrorsCommand = new DelegateCommand(async () => + await ExecScope(async () => { await _scope!.清除错误队列Async(Ct()); Log("示波器错误队列已清除 (*CLS)"); })); + + ReadScopeErrorsCommand = new DelegateCommand(async () => + await ExecScope(async () => Log("示波器错误队列: " + await _scope!.读取所有错误消息Async(Ct())))); #endregion Initialize(); @@ -555,6 +814,30 @@ namespace PowerAdaptabilityModule.ViewModels Log(found != null ? $"已关联设备 [{DeviceName}],连接:{(IsConnected ? "已连接" : "未连接")}" : "未找到 Chroma61800 设备,请先在设置界面添加并启用该设备"); + + // 示波器实例:查找方式与交流源一致(DeviceMap 优先,回退全局 HardwarePool) + TektronixMSO? scopeFound = null; + string? sfn = null; + + foreach (var kv in _dm.DeviceMap) + { + if (kv.Value is TektronixMSO s) { scopeFound = s; sfn = kv.Key; break; } + } + + if (scopeFound == null) + { + foreach (var kv in _globalInfo.HardwarePool) + { + if (kv.Value.IsValueCreated && kv.Value.Value is TektronixMSO s) { scopeFound = s; sfn = kv.Key; break; } + } + } + + _scope = scopeFound; + ScopeName = sfn ?? "TektronixMSO (未找到)"; + IsScopeConnected = _scope?.IsConnected ?? false; + + if (scopeFound != null) + Log($"已关联示波器 [{ScopeName}],连接:{(IsScopeConnected ? "已连接" : "未连接")}"); } public override void OnNavigatedTo(NavigationContext navigationContext) @@ -594,9 +877,14 @@ namespace PowerAdaptabilityModule.ViewModels } } - /// 从磁盘重新加载测试项列表,刷新下拉框并保持选中项有效。 + /// 从磁盘重新加载测试项列表,刷新下拉框并按名称恢复选中项。 private void RefreshPresets() { + // 刷新前记住选中项名称:列表重建后索引会漂移,按名称恢复最可靠 + string? selectedName = (_selectedPresetIndex >= 0 && _selectedPresetIndex < _presets.Count) + ? _presets[_selectedPresetIndex].Name + : null; + var list = new List(); try { @@ -608,7 +896,7 @@ namespace PowerAdaptabilityModule.ViewModels { var json = File.ReadAllText(file); var item = JsonConvert.DeserializeObject(json); - if (item != null) list.Add(item); + if (item != null) list.Add(NormalizePresetName(item, file)); } catch (Exception ex) { @@ -627,51 +915,72 @@ namespace PowerAdaptabilityModule.ViewModels RaisePropertyChanged(nameof(PresetOptions)); RaisePropertyChanged(nameof(PresetStorageHint)); - if (_selectedPresetIndex >= _presets.Count) - SelectedPresetIndex = -1; + SelectedPresetIndex = selectedName != null + ? _presets.FindIndex(p => p.Name == selectedName) + : -1; } - /// 把一个测试项写入 D:\acp\源测试\{清洗后名称}.json(缩进格式化,便于人工查看)。 - private void WritePresetFile(ListPreset preset) + /// + /// 以磁盘文件名校正测试项名称:文件被外部改名后内容中的 Name 仍是旧名, + /// 统一改为文件名(不含扩展名),保证“名称=文件名”不变式,载入/保存/删除都按同一名称寻址。 + /// + private static ListPreset NormalizePresetName(ListPreset item, string file) + { + string stem = Path.GetFileNameWithoutExtension(file); + return string.Equals(item.Name, stem, StringComparison.Ordinal) + ? item + : new ListPreset(stem, item.Note, item.Count, item.Phases, item.Trigger, item.TimeBase); + } + + /// 按名称拼出测试项文件完整路径(写入、另存、删除共用同一规则)。 + private static string PresetPathOf(string name) => Path.Combine(PresetStorageDir, SanitizeFileName(name) + PresetFileExt); + + /// 把一个测试项写入 D:\acp\源测试\{清洗后名称}.json(缩进格式化,便于人工查看)。 + private static void WritePresetFile(ListPreset preset) => WritePresetFile(preset, PresetPathOf(preset.Name)); + + /// 把测试项写入指定文件(覆盖写入)。 + private static void WritePresetFile(ListPreset preset, string path) { - string path = Path.Combine(PresetStorageDir, SanitizeFileName(preset.Name) + PresetFileExt); string json = JsonConvert.SerializeObject(preset, Formatting.Indented); File.WriteAllText(path, json); } + /// 用当前界面参数 + 三相配置快照构建测试项对象(内容名称始终与文件名一致)。 + private ListPreset SnapshotPreset(string name) => new( + name, + PresetNote ?? "", + ListCount, + new[] { _phaseConfigs[0].Clone(), _phaseConfigs[1].Clone(), _phaseConfigs[2].Clone() }, + ListTrigger, + ListTimeBase); + /// - /// 保存当前界面的 LIST 配置为测试项文件(名称取自“测试项名称”输入框)。 - /// 快照当前三相序列配置 + 执行次数/触发/时基 + 说明,写入后刷新列表并选中该项。 + /// 保存:覆盖写入“当前打开的文件”,不改变文件名(名称框内容不参与)。 + /// 尚未打开任何文件时,退化为“另存为”,用名称框内容新建文件。 /// private void SaveCurrentPreset() { - string name = (PresetName ?? "").Trim(); - if (string.IsNullOrEmpty(name)) + if (string.IsNullOrEmpty(_currentPresetPath)) { - Log("请先在“测试项名称”填写要保存的名称"); + SaveCurrentPresetAs(); return; } + // 内容名称以当前文件名为准,保证“名称=文件名”不变式 + string name = Path.GetFileNameWithoutExtension(_currentPresetPath); + string typed = (PresetName ?? "").Trim(); + if (!string.IsNullOrEmpty(typed) && !string.Equals(typed, name, StringComparison.Ordinal)) + Log($"名称框内容 [{typed}] 未用于保存:保存只覆盖当前文件,如需换名请用“另存为”"); + try { - if (!Directory.Exists(PresetStorageDir)) - Directory.CreateDirectory(PresetStorageDir); - - // 用当前界面参数 + 三相配置快照构建测试项 - var phases = new[] - { - _phaseConfigs[0].Clone(), - _phaseConfigs[1].Clone(), - _phaseConfigs[2].Clone(), - }; - var preset = new ListPreset(name, PresetNote ?? "", ListCount, phases, ListTrigger, ListTimeBase); - WritePresetFile(preset); + Directory.CreateDirectory(PresetStorageDir); + WritePresetFile(SnapshotPreset(name), _currentPresetPath); RefreshPresets(); - int idx = _presets.FindIndex(p => p.Name == name); - if (idx >= 0) SelectedPresetIndex = idx; - - Log($"测试项已保存:{Path.Combine(PresetStorageDir, SanitizeFileName(name) + PresetFileExt)}"); + SelectedPresetIndex = _presets.FindIndex(p => p.Name == name); + PresetName = name; // 名称框回显当前文件名 + Log($"测试项已保存(覆盖):{_currentPresetPath}"); } catch (Exception ex) { @@ -679,6 +988,45 @@ namespace PowerAdaptabilityModule.ViewModels } } + /// + /// 另存为:弹出文件对话框选择 .json 保存位置,成功后“当前打开文件”切换到新文件。 + /// 存入预设目录(D:\acp\源测试)内会自动出现在下拉列表;存到目录外则仅本次会话可用“保存”覆盖, + /// 对话框自身的“是否替换”提示已覆盖同名覆盖确认。 + /// + private void SaveCurrentPresetAs() + { + var dlg = new SaveFileDialog + { + Title = "测试项另存为", + Filter = "JSON 文件 (*.json)|*.json", + DefaultExt = ".json", + AddExtension = true, + InitialDirectory = PresetStorageDir, + FileName = SanitizeFileName((PresetName ?? "").Trim()), + }; + if (dlg.ShowDialog() != true) return; // 用户取消 + + try + { + string path = dlg.FileName; + string name = Path.GetFileNameWithoutExtension(path); // 内容名称始终与文件名一致 + WritePresetFile(SnapshotPreset(name), path); + + _currentPresetPath = path; // 另存为成功后,后续“保存”直接修改这个新文件 + RefreshPresets(); + SelectedPresetIndex = _presets.FindIndex(p => p.Name == name); + PresetName = name; + + Log(Path.GetDirectoryName(path) is string dir && string.Equals(dir, PresetStorageDir, StringComparison.OrdinalIgnoreCase) + ? $"测试项已另存为:{path}" + : $"测试项已另存为:{path}(在预设目录之外,不会出现在下拉列表,“保存”仍可覆盖此文件)"); + } + catch (Exception ex) + { + Log($"另存测试项失败:{ex.Message}"); + } + } + /// /// 删除当前选中的测试项文件(带二次确认),删除后刷新列表。 /// @@ -703,6 +1051,9 @@ namespace PowerAdaptabilityModule.ViewModels if (File.Exists(path)) File.Delete(path); + if (string.Equals(path, _currentPresetPath, StringComparison.OrdinalIgnoreCase)) + _currentPresetPath = null; // 当前打开的文件已被删除,解除绑定 + SelectedPresetIndex = -1; RefreshPresets(); Log($"已删除测试项:{path}"); @@ -740,9 +1091,14 @@ namespace PowerAdaptabilityModule.ViewModels } } - /// 从磁盘重新加载合成波形预设列表,刷新下拉框并保持选中项有效。 + /// 从磁盘重新加载合成波形预设列表,刷新下拉框并按名称恢复选中项。 private void RefreshSynthPresets() { + // 刷新前记住选中项名称:列表重建后索引会漂移,按名称恢复最可靠 + string? selectedName = (_selectedSynthPresetIndex >= 0 && _selectedSynthPresetIndex < _synthPresets.Count) + ? _synthPresets[_selectedSynthPresetIndex].Name + : null; + var list = new List(); try { @@ -754,7 +1110,7 @@ namespace PowerAdaptabilityModule.ViewModels { var json = File.ReadAllText(file); var item = JsonConvert.DeserializeObject(json); - if (item != null) list.Add(item); + if (item != null) list.Add(NormalizeSynthPresetName(item, file)); } catch (Exception ex) { @@ -773,18 +1129,39 @@ namespace PowerAdaptabilityModule.ViewModels RaisePropertyChanged(nameof(SynthPresetOptions)); RaisePropertyChanged(nameof(SynthStorageHint)); - if (_selectedSynthPresetIndex >= _synthPresets.Count) - SelectedSynthPresetIndex = -1; + SelectedSynthPresetIndex = selectedName != null + ? _synthPresets.FindIndex(p => p.Name == selectedName) + : -1; } - /// 把一个合成波形预设写入 {SynthStorageDir}\{清洗后名称}.json(缩进格式化)。 - private void WriteSynthPresetFile(SynthPreset preset) + /// 以磁盘文件名校正波形名称(同 的“名称=文件名”不变式)。 + private static SynthPreset NormalizeSynthPresetName(SynthPreset item, string file) + { + string stem = Path.GetFileNameWithoutExtension(file); + return string.Equals(item.Name, stem, StringComparison.Ordinal) + ? item + : new SynthPreset(stem, item.Note, item.Compose, item.Frequency, item.Fundamental, + item.Dc, item.StartPhase, item.AmplitudeText, item.PhaseText); + } + + /// 按名称拼出合成波形文件完整路径(写入、另存、删除共用同一规则)。 + private static string SynthPathOf(string name) => Path.Combine(SynthStorageDir, SanitizeFileName(name) + PresetFileExt); + + /// 把一个合成波形预设写入 {SynthStorageDir}\{清洗后名称}.json(缩进格式化)。 + private static void WriteSynthPresetFile(SynthPreset preset) => WriteSynthPresetFile(preset, SynthPathOf(preset.Name)); + + /// 把合成波形预设写入指定文件(覆盖写入)。 + private static void WriteSynthPresetFile(SynthPreset preset, string path) { - string path = Path.Combine(SynthStorageDir, SanitizeFileName(preset.Name) + PresetFileExt); string json = JsonConvert.SerializeObject(preset, Formatting.Indented); File.WriteAllText(path, json); } + /// 用当前界面参数快照构建波形预设对象(内容名称始终与文件名一致)。 + private SynthPreset SnapshotSynthPreset(string name) => new( + name, SynthPresetNote ?? "", SynthCompose, SynthFrequency, + SynthFundamental, SynthDc, SynthStartPhase, SynthAmplitudeText, SynthPhaseText); + /// 载入选中波形:把文件参数一次性回填到界面各 SYNTHESIS 输入框。 private void LoadCurrentSynthPreset() { @@ -803,35 +1180,37 @@ namespace PowerAdaptabilityModule.ViewModels SynthPhaseText = p.PhaseText; SynthPresetName = p.Name; SynthPresetNote = p.Note; + _currentSynthPath = SynthPathOf(p.Name); // 记住当前打开的文件,此后“保存”只覆盖它 Log($"已载入波形:{p.Name}(格式={p.Compose} 基频={p.Frequency}Hz 基波={p.Fundamental}V)"); } /// - /// 保存当前界面的 SYNTHESIS 参数快照为波形文件(名称取自“波形名称”输入框)。 + /// 保存:覆盖写入“当前打开的文件”,不改变文件名(名称框内容不参与)。 + /// 尚未打开任何文件时,退化为“另存为”,用名称框内容新建文件。 /// private void SaveCurrentSynthPreset() { - string name = (SynthPresetName ?? "").Trim(); - if (string.IsNullOrEmpty(name)) + if (string.IsNullOrEmpty(_currentSynthPath)) { - Log("请先在“波形名称”填写要保存的名称"); + SaveCurrentSynthPresetAs(); return; } + // 内容名称以当前文件名为准,保证“名称=文件名”不变式 + string name = Path.GetFileNameWithoutExtension(_currentSynthPath); + string typed = (SynthPresetName ?? "").Trim(); + if (!string.IsNullOrEmpty(typed) && !string.Equals(typed, name, StringComparison.Ordinal)) + Log($"名称框内容 [{typed}] 未用于保存:保存只覆盖当前文件,如需换名请用“另存为”"); + try { - if (!Directory.Exists(SynthStorageDir)) - Directory.CreateDirectory(SynthStorageDir); - - var preset = new SynthPreset(name, SynthPresetNote ?? "", SynthCompose, SynthFrequency, - SynthFundamental, SynthDc, SynthStartPhase, SynthAmplitudeText, SynthPhaseText); - WriteSynthPresetFile(preset); + Directory.CreateDirectory(SynthStorageDir); + WriteSynthPresetFile(SnapshotSynthPreset(name), _currentSynthPath); RefreshSynthPresets(); - int idx = _synthPresets.FindIndex(p => p.Name == name); - if (idx >= 0) SelectedSynthPresetIndex = idx; - - Log($"波形已保存:{Path.Combine(SynthStorageDir, SanitizeFileName(name) + PresetFileExt)}"); + SelectedSynthPresetIndex = _synthPresets.FindIndex(p => p.Name == name); + SynthPresetName = name; // 名称框回显当前文件名 + Log($"波形已保存(覆盖):{_currentSynthPath}"); } catch (Exception ex) { @@ -839,6 +1218,45 @@ namespace PowerAdaptabilityModule.ViewModels } } + /// + /// 另存为:弹出文件对话框选择 .json 保存位置,成功后“当前打开文件”切换到新文件。 + /// 存入预设目录(D:\acp\源测试\合成波形)内会自动出现在下拉列表;存到目录外则仅本次会话可用“保存”覆盖, + /// 对话框自身的“是否替换”提示已覆盖同名覆盖确认。 + /// + private void SaveCurrentSynthPresetAs() + { + var dlg = new SaveFileDialog + { + Title = "波形另存为", + Filter = "JSON 文件 (*.json)|*.json", + DefaultExt = ".json", + AddExtension = true, + InitialDirectory = SynthStorageDir, + FileName = SanitizeFileName((SynthPresetName ?? "").Trim()), + }; + if (dlg.ShowDialog() != true) return; // 用户取消 + + try + { + string path = dlg.FileName; + string name = Path.GetFileNameWithoutExtension(path); // 内容名称始终与文件名一致 + WriteSynthPresetFile(SnapshotSynthPreset(name), path); + + _currentSynthPath = path; // 另存为成功后,后续“保存”直接修改这个新文件 + RefreshSynthPresets(); + SelectedSynthPresetIndex = _synthPresets.FindIndex(p => p.Name == name); + SynthPresetName = name; + + Log(Path.GetDirectoryName(path) is string dir && string.Equals(dir, SynthStorageDir, StringComparison.OrdinalIgnoreCase) + ? $"波形已另存为:{path}" + : $"波形已另存为:{path}(在预设目录之外,不会出现在下拉列表,“保存”仍可覆盖此文件)"); + } + catch (Exception ex) + { + Log($"另存波形失败:{ex.Message}"); + } + } + /// 删除当前选中的波形文件(带二次确认),删除后刷新列表。 private void DeleteCurrentSynthPreset() { @@ -861,6 +1279,9 @@ namespace PowerAdaptabilityModule.ViewModels if (File.Exists(path)) File.Delete(path); + if (string.Equals(path, _currentSynthPath, StringComparison.OrdinalIgnoreCase)) + _currentSynthPath = null; // 当前打开的文件已被删除,解除绑定 + SelectedSynthPresetIndex = -1; RefreshSynthPresets(); Log($"已删除波形:{path}"); @@ -873,6 +1294,84 @@ namespace PowerAdaptabilityModule.ViewModels #endregion + #region 示波器抓图(TektronixMSO)与存储位置持久化 + /// + /// 示波器命令执行包装:与 Exec 同一防重入/异常日志机制,仅把设备校验对象换成示波器实例。 + /// + private async Task ExecScope(Func action) + { + if (_scope == null) { Log("错误:未找到 TektronixMSO 示波器实例,请先在设置界面添加并启用该设备"); return; } + if (IsBusy) return; + + IsBusy = true; + try + { + await action(); + IsScopeConnected = _scope.IsConnected; + } + catch (OperationCanceledException) { Log("示波器命令超时或已取消"); } + catch (Exception ex) { Log($"示波器错误:{ex.Message}"); } + finally { IsBusy = false; } + } + + /// + /// 把抓取到的 PNG 加载为 BitmapImage 并刷新界面显示。 + /// BitmapCacheOption.OnLoad 读完即释放文件句柄,不占用图片文件;Freeze 后跨线程安全。 + /// + private void LoadScopeImage(string file) + { + try + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.UriSource = new Uri(file, UriKind.Absolute); + bmp.EndInit(); + bmp.Freeze(); + + ScopeImage = bmp; + HasScopeImage = true; + ScopeImageHint = $"最近抓取:{Path.GetFileName(file)}"; + } + catch (Exception ex) + { + Log($"加载截图失败:{ex.Message}"); + } + } + + /// 示波器截图目录配置文件路径(%AppData%\ACP\ScopeCaptureDir.json)。 + private static string ScopeDirConfigPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ACP", "ScopeCaptureDir.json"); + + /// 启动时恢复上次设置的波形图片存储位置(配置缺失/损坏时保持默认目录)。 + private void LoadScopeImageDir() + { + try + { + if (File.Exists(ScopeDirConfigPath)) + { + var dir = JsonConvert.DeserializeObject(File.ReadAllText(ScopeDirConfigPath)); + if (!string.IsNullOrWhiteSpace(dir)) ScopeImageDir = dir; + } + } + catch { /* 配置缺失或损坏时静默使用默认目录 */ } + } + + /// 把当前波形图片存储位置写入配置(抓图或浏览选择后调用)。 + private void SaveScopeImageDir() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(ScopeDirConfigPath)!); + File.WriteAllText(ScopeDirConfigPath, JsonConvert.SerializeObject(ScopeImageDir)); + } + catch (Exception ex) + { + Log($"保存截图目录设置失败:{ex.Message}"); + } + } + #endregion + #region 辅助 /// 创建 10 秒超时的取消令牌(命令用)。 private CancellationToken Ct() => (_cmdCts = new CancellationTokenSource(TimeSpan.FromSeconds(10))).Token; diff --git a/PowerAdaptabilityModule/Views/PowerAdaptabilityView.xaml b/PowerAdaptabilityModule/Views/PowerAdaptabilityView.xaml index 7a7e361..9a6bf80 100644 --- a/PowerAdaptabilityModule/Views/PowerAdaptabilityView.xaml +++ b/PowerAdaptabilityModule/Views/PowerAdaptabilityView.xaml @@ -19,6 +19,7 @@ + @@ -184,11 +185,15 @@