示波器加入源适应性界面

This commit is contained in:
“hsc”
2026-10-20 15:33:36 +08:00
parent 981bc1e558
commit 12b532382d
3 changed files with 781 additions and 55 deletions
@@ -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";
/// <summary>示波器设备名称(DeviceMap 键名)</summary>
public string ScopeName { get => _scopeName; set => SetProperty(ref _scopeName, value); }
private bool _isScopeConnected;
/// <summary>示波器连接状态</summary>
public bool IsScopeConnected { get => _isScopeConnected; set => SetProperty(ref _isScopeConnected, value); }
private string _scopeImageDir = @"D:\acp\源测试\波形截图";
/// <summary>波形图片存储位置(抓取的屏幕 PNG 保存目录),抓图/浏览选择时自动持久化</summary>
public string ScopeImageDir { get => _scopeImageDir; set => SetProperty(ref _scopeImageDir, value); }
private BitmapImage? _scopeImage;
/// <summary>最近一次抓取的示波器屏幕图片(供界面 Image 控件显示)</summary>
public BitmapImage? ScopeImage { get => _scopeImage; set => SetProperty(ref _scopeImage, value); }
private bool _hasScopeImage;
/// <summary>是否已有截图(控制图片区占位提示的显隐)</summary>
public bool HasScopeImage { get => _hasScopeImage; set => SetProperty(ref _hasScopeImage, value); }
private string _scopeImageHint = "";
/// <summary>最近抓取的图片文件提示</summary>
public string ScopeImageHint { get => _scopeImageHint; set => SetProperty(ref _scopeImageHint, value); }
#endregion
#region 示波器设置(采集 / 水平 / 通道 / 测量,对应 TektronixMSO 已有 SCPI 方法)
/// <summary>采集模式选项 (ACQuire:MODe,显示名与 TekAcquisitionMode 枚举一致)</summary>
public IReadOnlyList<string> ScopeAcquireModeOptions { get; } =
new[] { "SAMple", "PEAKdetect", "HIRes", "AVErage", "ENVelope" };
/// <summary>测量类型选项 (MEASUrement:TYPe,显示名与 TekMeasurementType 枚举一致)</summary>
public IReadOnlyList<string> ScopeMeasTypeOptions { get; } =
new[] { "AMPLitude", "FREQuency", "MEAN", "PK2PK", "MAXimum", "MINimum" };
/// <summary>通道选择列表 (对应通道号 1~4)</summary>
public IReadOnlyList<string> ScopeChannelOptions { get; } = new[] { "CH1", "CH2", "CH3", "CH4" };
private string _scopeAcquireMode = "SAMple";
/// <summary>采集模式 (ACQuire:MODe)</summary>
public string ScopeAcquireMode { get => _scopeAcquireMode; set => SetProperty(ref _scopeAcquireMode, value); }
private double _scopeTimeScale = 0.001;
/// <summary>水平刻度待设值 (HORizontal:SCAle, s/Div)</summary>
public double ScopeTimeScale { get => _scopeTimeScale; set => SetProperty(ref _scopeTimeScale, value); }
private long _scopeRecordLength = 6250000;
/// <summary>记录长度待设值 (HORizontal:RECOrdlength, 采样点数)</summary>
public long ScopeRecordLength { get => _scopeRecordLength; set => SetProperty(ref _scopeRecordLength, value); }
private string _sampleRateText = "";
/// <summary>采样率查询结果显示 (HORizontal:SAMPLERate?, Sa/s)</summary>
public string SampleRateText { get => _sampleRateText; set => SetProperty(ref _sampleRateText, value); }
private int _selectedScopeChannelIndex;
/// <summary>当前编辑通道索引 (0~3 → CH1~CH4)</summary>
public int SelectedScopeChannelIndex { get => _selectedScopeChannelIndex; set => SetProperty(ref _selectedScopeChannelIndex, value); }
private double _channelScale = 100;
/// <summary>通道垂直刻度待设值 (CH:SCAle, V/Div)</summary>
public double ChannelScale { get => _channelScale; set => SetProperty(ref _channelScale, value); }
private double _channelPosition;
/// <summary>通道垂直位置待设值 (CH:POSition, Div)</summary>
public double ChannelPosition { get => _channelPosition; set => SetProperty(ref _channelPosition, value); }
private int _measurementNumber = 1;
/// <summary>测量项编号 (MEASUrement:MEASn)</summary>
public int MeasurementNumber { get => _measurementNumber; set => SetProperty(ref _measurementNumber, value); }
private string _measurementType = "FREQuency";
/// <summary>测量类型 (MEASUrement:TYPe)</summary>
public string MeasurementType { get => _measurementType; set => SetProperty(ref _measurementType, value); }
private string _measurementSource = "CH1";
/// <summary>测量源 (MEASUrement:SOUrce1, 如 CH1/CH2)</summary>
public string MeasurementSource { get => _measurementSource; set => SetProperty(ref _measurementSource, value); }
private string _measResultText = "";
/// <summary>测量查询结果显示</summary>
public string MeasResultText { get => _measResultText; set => SetProperty(ref _measResultText, value); }
/// <summary>当前编辑通道号 (1~4)</summary>
private int ScopeChannelNo => SelectedScopeChannelIndex + 1;
#endregion
#region 输入参数(三相输出设置)
private double _voltage = 220;
/// <summary>待设置的交流电压 (V)</summary>
@@ -212,6 +301,9 @@ namespace PowerAdaptabilityModule.ViewModels
/// <summary>当前波形说明(随文件保存,载入后自动填充)</summary>
public string SynthPresetNote { get => _synthPresetNote; set => SetProperty(ref _synthPresetNote, value); }
/// <summary>当前已打开的波形文件完整路径(载入或另存后设置);null 表示尚未与磁盘文件绑定</summary>
private string? _currentSynthPath;
private int _selectedSynthPresetIndex = -1;
/// <summary>当前选中的合成波形预设索引(-1 表示未选择)</summary>
public int SelectedSynthPresetIndex
@@ -260,6 +352,9 @@ namespace PowerAdaptabilityModule.ViewModels
/// <summary>当前测试项说明(随文件保存,载入后自动填充)</summary>
public string PresetNote { get => _presetNote; set => SetProperty(ref _presetNote, value); }
/// <summary>当前已打开的测试项文件完整路径(载入或另存后设置);null 表示尚未与磁盘文件绑定</summary>
private string? _currentPresetPath;
private int _selectedPresetIndex = -1;
/// <summary>当前选中的测试项索引(-1 表示未选择)</summary>
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<TekAcquisitionMode>(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<TekMeasurementType>(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
}
}
/// <summary>从磁盘重新加载测试项列表,刷新下拉框并保持选中项有效。</summary>
/// <summary>从磁盘重新加载测试项列表,刷新下拉框并按名称恢复选中项。</summary>
private void RefreshPresets()
{
// 刷新前记住选中项名称:列表重建后索引会漂移,按名称恢复最可靠
string? selectedName = (_selectedPresetIndex >= 0 && _selectedPresetIndex < _presets.Count)
? _presets[_selectedPresetIndex].Name
: null;
var list = new List<ListPreset>();
try
{
@@ -608,7 +896,7 @@ namespace PowerAdaptabilityModule.ViewModels
{
var json = File.ReadAllText(file);
var item = JsonConvert.DeserializeObject<ListPreset>(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;
}
/// <summary>把一个测试项写入 D:\acp\源测试\{清洗后名称}.json(缩进格式化,便于人工查看)。</summary>
private void WritePresetFile(ListPreset preset)
/// <summary>
/// 以磁盘文件名校正测试项名称:文件被外部改名后内容中的 Name 仍是旧名,
/// 统一改为文件名(不含扩展名),保证“名称=文件名”不变式,载入/保存/删除都按同一名称寻址。
/// </summary>
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);
}
/// <summary>按名称拼出测试项文件完整路径(写入、另存、删除共用同一规则)。</summary>
private static string PresetPathOf(string name) => Path.Combine(PresetStorageDir, SanitizeFileName(name) + PresetFileExt);
/// <summary>把一个测试项写入 D:\acp\源测试\{清洗后名称}.json(缩进格式化,便于人工查看)。</summary>
private static void WritePresetFile(ListPreset preset) => WritePresetFile(preset, PresetPathOf(preset.Name));
/// <summary>把测试项写入指定文件(覆盖写入)。</summary>
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);
}
/// <summary>用当前界面参数 + 三相配置快照构建测试项对象(内容名称始终与文件名一致)。</summary>
private ListPreset SnapshotPreset(string name) => new(
name,
PresetNote ?? "",
ListCount,
new[] { _phaseConfigs[0].Clone(), _phaseConfigs[1].Clone(), _phaseConfigs[2].Clone() },
ListTrigger,
ListTimeBase);
/// <summary>
/// 保存当前界面的 LIST 配置为测试项文件(名称取自“测试项名称”输入框)。
/// <para>快照当前三相序列配置 + 执行次数/触发/时基 + 说明,写入后刷新列表并选中该项。</para>
/// 保存:覆盖写入“当前打开的文件”,不改变文件名(名称框内容不参与)。
/// <para>尚未打开任何文件时,退化为“另存为”,用名称框内容新建文件。</para>
/// </summary>
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
}
}
/// <summary>
/// 另存为:弹出文件对话框选择 .json 保存位置,成功后“当前打开文件”切换到新文件。
/// <para>存入预设目录(D:\acp\源测试)内会自动出现在下拉列表;存到目录外则仅本次会话可用“保存”覆盖,
/// 对话框自身的“是否替换”提示已覆盖同名覆盖确认。</para>
/// </summary>
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}");
}
}
/// <summary>
/// 删除当前选中的测试项文件(带二次确认),删除后刷新列表。
/// </summary>
@@ -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
}
}
/// <summary>从磁盘重新加载合成波形预设列表,刷新下拉框并保持选中项有效。</summary>
/// <summary>从磁盘重新加载合成波形预设列表,刷新下拉框并按名称恢复选中项。</summary>
private void RefreshSynthPresets()
{
// 刷新前记住选中项名称:列表重建后索引会漂移,按名称恢复最可靠
string? selectedName = (_selectedSynthPresetIndex >= 0 && _selectedSynthPresetIndex < _synthPresets.Count)
? _synthPresets[_selectedSynthPresetIndex].Name
: null;
var list = new List<SynthPreset>();
try
{
@@ -754,7 +1110,7 @@ namespace PowerAdaptabilityModule.ViewModels
{
var json = File.ReadAllText(file);
var item = JsonConvert.DeserializeObject<SynthPreset>(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;
}
/// <summary>把一个合成波形预设写入 {SynthStorageDir}\{清洗后名称}.json(缩进格式化)。</summary>
private void WriteSynthPresetFile(SynthPreset preset)
/// <summary>以磁盘文件名校正波形名称(同 <see cref="NormalizePresetName"/> 的“名称=文件名”不变式)。</summary>
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);
}
/// <summary>按名称拼出合成波形文件完整路径(写入、另存、删除共用同一规则)。</summary>
private static string SynthPathOf(string name) => Path.Combine(SynthStorageDir, SanitizeFileName(name) + PresetFileExt);
/// <summary>把一个合成波形预设写入 {SynthStorageDir}\{清洗后名称}.json(缩进格式化)。</summary>
private static void WriteSynthPresetFile(SynthPreset preset) => WriteSynthPresetFile(preset, SynthPathOf(preset.Name));
/// <summary>把合成波形预设写入指定文件(覆盖写入)。</summary>
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);
}
/// <summary>用当前界面参数快照构建波形预设对象(内容名称始终与文件名一致)。</summary>
private SynthPreset SnapshotSynthPreset(string name) => new(
name, SynthPresetNote ?? "", SynthCompose, SynthFrequency,
SynthFundamental, SynthDc, SynthStartPhase, SynthAmplitudeText, SynthPhaseText);
/// <summary>载入选中波形:把文件参数一次性回填到界面各 SYNTHESIS 输入框。</summary>
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)");
}
/// <summary>
/// 保存当前界面的 SYNTHESIS 参数快照为波形文件(名称取自“波形名称”输入框)。
/// 保存:覆盖写入“当前打开的文件”,不改变文件名(名称框内容不参与)。
/// <para>尚未打开任何文件时,退化为“另存为”,用名称框内容新建文件。</para>
/// </summary>
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
}
}
/// <summary>
/// 另存为:弹出文件对话框选择 .json 保存位置,成功后“当前打开文件”切换到新文件。
/// <para>存入预设目录(D:\acp\源测试\合成波形)内会自动出现在下拉列表;存到目录外则仅本次会话可用“保存”覆盖,
/// 对话框自身的“是否替换”提示已覆盖同名覆盖确认。</para>
/// </summary>
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}");
}
}
/// <summary>删除当前选中的波形文件(带二次确认),删除后刷新列表。</summary>
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)与存储位置持久化
/// <summary>
/// 示波器命令执行包装:与 Exec 同一防重入/异常日志机制,仅把设备校验对象换成示波器实例。
/// </summary>
private async Task ExecScope(Func<Task> 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; }
}
/// <summary>
/// 把抓取到的 PNG 加载为 BitmapImage 并刷新界面显示。
/// <para>BitmapCacheOption.OnLoad 读完即释放文件句柄,不占用图片文件;Freeze 后跨线程安全。</para>
/// </summary>
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}");
}
}
/// <summary>示波器截图目录配置文件路径(%AppData%\ACP\ScopeCaptureDir.json)。</summary>
private static string ScopeDirConfigPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ACP", "ScopeCaptureDir.json");
/// <summary>启动时恢复上次设置的波形图片存储位置(配置缺失/损坏时保持默认目录)。</summary>
private void LoadScopeImageDir()
{
try
{
if (File.Exists(ScopeDirConfigPath))
{
var dir = JsonConvert.DeserializeObject<string>(File.ReadAllText(ScopeDirConfigPath));
if (!string.IsNullOrWhiteSpace(dir)) ScopeImageDir = dir;
}
}
catch { /* 配置缺失或损坏时静默使用默认目录 */ }
}
/// <summary>把当前波形图片存储位置写入配置(抓图或浏览选择后调用)。</summary>
private void SaveScopeImageDir()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ScopeDirConfigPath)!);
File.WriteAllText(ScopeDirConfigPath, JsonConvert.SerializeObject(ScopeImageDir));
}
catch (Exception ex)
{
Log($"保存截图目录设置失败:{ex.Message}");
}
}
#endregion
#region 辅助
/// <summary>创建 10 秒超时的取消令牌(命令用)。</summary>
private CancellationToken Ct() => (_cmdCts = new CancellationTokenSource(TimeSpan.FromSeconds(10))).Token;