安全功能各种修改、细节调整

This commit is contained in:
zhaotielin 2026-07-03 14:16:15 +08:00
parent 61819a3139
commit a5a36d466f
41 changed files with 797 additions and 323 deletions

117
Docs/警灯代码.html Normal file
View File

@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>警示灯图标预览 v2</title>
<style>
body{font-family:Segoe UI,Microsoft YaHei,sans-serif;background:#f5f5f5;margin:28px;color:#222}
h2{margin:0 0 8px;font-size:20px} p{margin:0 0 22px;color:#555;font-size:14px}
.row{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
.card{background:white;border:1px solid #ddd;border-radius:12px;padding:18px;width:180px;text-align:center;box-shadow:0 2px 10px #0001}
.stage{height:100px;display:flex;align-items:center;justify-content:center}
.small .stage{height:58px}
.label{margin-top:10px;font-size:14px;color:#555}
svg.big{width:82px;height:82px} svg.smallIcon{width:34px;height:34px}
.inactive .glassMain{fill:url(#grayGlass)}
.inactive .glassCap{fill:url(#grayCap)}
.inactive .rib{stroke:#555}
.inactive .baseMain{fill:url(#grayBase)}
.inactive .redOnly{opacity:0}
.active .blinkLayer{animation:hardBlink .72s steps(2,end) infinite}
.active .sweep{animation:sweepMove .95s linear infinite}
.active .rim{animation:rimBlink .72s steps(2,end) infinite}
.active .baseFlash{animation:baseFlash .72s steps(2,end) infinite}
@keyframes hardBlink{0%,44%{opacity:1}45%,100%{opacity:.42}}
@keyframes rimBlink{0%,44%{opacity:1;stroke-width:2.3}45%,100%{opacity:.35;stroke-width:1.2}}
@keyframes baseFlash{0%,44%{opacity:1}45%,100%{opacity:.15}}
@keyframes sweepMove{from{transform:translateX(-24px)}to{transform:translateX(30px)}}
</style>
</head>
<body>
<h2>警示灯图标预览 v2</h2>
<p>去掉外部红色光晕,改为灯罩本体强闪 + 内部扫光。小尺寸下动态更明显。</p>
<div class="row">
<div class="card inactive">
<div class="stage">
<svg class="big" viewBox="0 0 64 64" role="img" aria-label="inactive warning light">
<defs>
<radialGradient id="redGlass" cx="34%" cy="16%" r="78%"><stop offset="0" stop-color="#ff8a8a"/><stop offset=".36" stop-color="#df0010"/><stop offset="1" stop-color="#720006"/></radialGradient>
<radialGradient id="redCap" cx="38%" cy="12%" r="85%"><stop offset="0" stop-color="#ffb0b0"/><stop offset=".5" stop-color="#f00012"/><stop offset="1" stop-color="#8a0008"/></radialGradient>
<radialGradient id="grayGlass" cx="34%" cy="16%" r="78%"><stop offset="0" stop-color="#dedede"/><stop offset=".36" stop-color="#969696"/><stop offset="1" stop-color="#4b4b4b"/></radialGradient>
<radialGradient id="grayCap" cx="38%" cy="12%" r="85%"><stop offset="0" stop-color="#f0f0f0"/><stop offset=".5" stop-color="#aaa"/><stop offset="1" stop-color="#5d5d5d"/></radialGradient>
<linearGradient id="base" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#3b3b3b"/><stop offset=".45" stop-color="#101010"/><stop offset="1" stop-color="#303030"/></linearGradient>
<linearGradient id="grayBase" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#666"/><stop offset=".45" stop-color="#303030"/><stop offset="1" stop-color="#565656"/></linearGradient>
<clipPath id="clipGlass"><path d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z"/></clipPath>
</defs>
<path class="glassMain" d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z" fill="url(#redGlass)"/>
<path class="glassCap" d="M15 26C16 11.8 23.8 5 32 5s16 6.8 17 21c-8-3.1-25.9-3.1-34 0z" fill="url(#redCap)" opacity=".95"/>
<g opacity=".38" clip-path="url(#clipGlass)"><path class="rib" d="M18 22v23M22 18v27M26 15v30M30 14v31M34 14v31M38 15v30M42 18v27M46 22v23" stroke="#880007" stroke-width="1"/></g>
<path d="M25 13l7-3 3 6-8 3zM38 11c4 1 6 3 8 7l-5 2c-1-3-3-4-6-5z" fill="#fff" opacity=".72"/>
<ellipse cx="32" cy="44" rx="22" ry="5" fill="#1a0000" opacity=".48"/>
<path class="redOnly baseFlash" d="M13 42h38v2H13z" fill="#ff0014"/>
<path d="M11 43h42v6H11z" fill="#240004" opacity=".88"/>
<path class="baseMain" d="M9 48h46v8c0 3-3 5-6 5H15c-3 0-6-2-6-5z" fill="url(#base)"/>
<path d="M13 50h38" stroke="#777" stroke-width="1" opacity=".45"/>
<path d="M42 56v4M47 55v4M52 54v3" stroke="#050505" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="label">未激活:灰色静态</div>
</div>
<div class="card active">
<div class="stage">
<svg class="big" viewBox="0 0 64 64" role="img" aria-label="active warning light">
<defs>
<radialGradient id="redGlass2" cx="34%" cy="16%" r="78%"><stop offset="0" stop-color="#ff8a8a"/><stop offset=".36" stop-color="#df0010"/><stop offset="1" stop-color="#720006"/></radialGradient>
<radialGradient id="redCap2" cx="38%" cy="12%" r="85%"><stop offset="0" stop-color="#ffb0b0"/><stop offset=".5" stop-color="#f00012"/><stop offset="1" stop-color="#8a0008"/></radialGradient>
<linearGradient id="base2" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#3b3b3b"/><stop offset=".45" stop-color="#101010"/><stop offset="1" stop-color="#303030"/></linearGradient>
<clipPath id="clipGlass2"><path d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z"/></clipPath>
</defs>
<g class="blinkLayer">
<path d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z" fill="url(#redGlass2)"/>
<path d="M15 26C16 11.8 23.8 5 32 5s16 6.8 17 21c-8-3.1-25.9-3.1-34 0z" fill="url(#redCap2)" opacity=".95"/>
</g>
<path class="rim" d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z" fill="none" stroke="#ff2635" stroke-width="2"/>
<g opacity=".38" clip-path="url(#clipGlass2)"><path d="M18 22v23M22 18v27M26 15v30M30 14v31M34 14v31M38 15v30M42 18v27M46 22v23" stroke="#650005" stroke-width="1"/></g>
<g clip-path="url(#clipGlass2)">
<path class="sweep" d="M17 6h7l-11 42h-7z" fill="#fff" opacity=".62"/>
</g>
<path d="M25 13l7-3 3 6-8 3zM38 11c4 1 6 3 8 7l-5 2c-1-3-3-4-6-5z" fill="#fff" opacity=".78"/>
<ellipse cx="32" cy="44" rx="22" ry="5" fill="#1a0000" opacity=".48"/>
<path class="baseFlash" d="M13 42h38v2H13z" fill="#ff0014"/>
<path d="M11 43h42v6H11z" fill="#240004" opacity=".88"/>
<path d="M9 48h46v8c0 3-3 5-6 5H15c-3 0-6-2-6-5z" fill="url(#base2)"/>
<path d="M13 50h38" stroke="#777" stroke-width="1" opacity=".45"/>
<path d="M42 56v4M47 55v4M52 54v3" stroke="#050505" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="label">激活:本体强闪 + 扫光</div>
</div>
<div class="card active small">
<div class="stage">
<svg class="smallIcon" viewBox="0 0 64 64" role="img" aria-label="small active warning light">
<defs>
<radialGradient id="redGlass3" cx="34%" cy="16%" r="78%"><stop offset="0" stop-color="#ff8a8a"/><stop offset=".36" stop-color="#df0010"/><stop offset="1" stop-color="#720006"/></radialGradient>
<linearGradient id="base3" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#3b3b3b"/><stop offset=".45" stop-color="#101010"/><stop offset="1" stop-color="#303030"/></linearGradient>
<clipPath id="clipGlass3"><path d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z"/></clipPath>
</defs>
<path class="blinkLayer" d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z" fill="url(#redGlass3)"/>
<path class="rim" d="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z" fill="none" stroke="#ff2635" stroke-width="2"/>
<g clip-path="url(#clipGlass3)"><path class="sweep" d="M17 6h8l-11 42h-8z" fill="#fff" opacity=".7"/></g>
<path d="M25 13l7-3 3 6-8 3zM38 11c4 1 6 3 8 7l-5 2c-1-3-3-4-6-5z" fill="#fff" opacity=".78"/>
<ellipse cx="32" cy="44" rx="22" ry="5" fill="#1a0000" opacity=".48"/>
<path class="baseFlash" d="M13 42h38v2H13z" fill="#ff0014"/>
<path d="M11 43h42v6H11z" fill="#240004" opacity=".88"/>
<path d="M9 48h46v8c0 3-3 5-6 5H15c-3 0-6-2-6-5z" fill="url(#base3)"/>
</svg>
</div>
<div class="label">34px 小图标预览</div>
</div>
</div>
</body>
</html>

View File

@ -93,9 +93,9 @@ public sealed class MockSspc : MockDeviceBase, ISspc
return Task.FromResult(new ChannelMeasurement
{
PhysicalSlot = physicalSlot,
Voltage = on ? 28.0 : 0.0,
Voltage = 28.0,
Current = on ? 1.0 : 0.0,
VoltageRaw = on ? (ushort)2800 : (ushort)0,
VoltageRaw = 2800,
CurrentRaw = on ? (short)167 : (short)0,
Timestamp = DateTimeOffset.Now
});

View File

@ -9,6 +9,9 @@ public partial class ChannelResult : ObservableObject
[ObservableProperty] private double _voltageOn;
[ObservableProperty] private double _currentOn;
[ObservableProperty] private double _voltageOff;
[ObservableProperty] private double _currentOff;
[ObservableProperty] private double _displayVoltage;
[ObservableProperty] private double _displayCurrent;
[ObservableProperty] private TestStatus _status = TestStatus.Untested;
}
@ -152,6 +155,8 @@ public static class ProtectTestPlan
row.Trigger = item.Trigger;
if (string.IsNullOrWhiteSpace(row.JudgeMode))
row.JudgeMode = ManualJudgeMode;
if (row.Status == TestStatus.Measured)
row.Status = TestStatus.Untested;
project.ProtectResults.Add(row);
}

View File

@ -25,7 +25,7 @@ public sealed class ReportSelection
public bool Curve { get; set; } = true;
public bool Power { get; set; } = true;
public bool Protect { get; set; } = true;
public string FileName { get; set; } = "report_{yyyyMMdd_HHmmss}.pdf";
public string FileName { get; set; } = "{ProjectName}_{yyyyMMdd_HHmmss}.pdf";
}
/// <summary>四个测试页“测试说明”手动记录内容。</summary>

View File

@ -349,12 +349,12 @@ public sealed class ReportGenerator
}
private static void BasicTable(IContainer container, Project p) =>
DataTable(container, new[] { 0.65f, 1f, 1f, 1.15f, 0.8f },
new[] { "通道", "导通电压\nV", "导通电流\nA", "关后电压\nV", "判定" },
DataTable(container, new[] { 0.65f, 1f, 1f, 1.15f, 1.15f, 0.8f },
new[] { "通道", "导通电压\nV", "导通电流\nA", "关后电压\nV", "关后电流\nA", "判定" },
p.BasicResults.Select(r => new ReportRow(r.Status, new[]
{
$"CH{r.Channel:00}", Format(r.VoltageOn, "F2"), Format(r.CurrentOn, "F3"),
Format(r.VoltageOff, "F2"), StatusText(r.Status)
Format(r.VoltageOff, "F2"), Format(r.CurrentOff, "F3"), StatusText(r.Status)
})));
private static void CurveTable(IContainer container, Project p) =>
@ -386,7 +386,7 @@ public sealed class ReportGenerator
ValueOrDash(r.Trigger),
ValueOrDash(r.MeasuredValue),
ValueOrDash(r.JudgeMode),
StatusText(r.Status)
ProtectStatusText(r.Status)
})));
private static void DataTable(IContainer container, IReadOnlyList<float> widths,
@ -501,6 +501,13 @@ public sealed class ReportGenerator
TestStatus.Running => "测试中", TestStatus.Measured => "已测",
_ => "未测试"
};
private static string ProtectStatusText(TestStatus status) => status switch
{
TestStatus.Pass => "合格",
TestStatus.Fail => "不合格",
TestStatus.Running => "测试中",
_ => "未判定"
};
private static string StatusColor(TestStatus status) => status switch
{
TestStatus.Pass => Success, TestStatus.Fail => Danger,

View File

@ -13,7 +13,7 @@ public enum ReportOutputFormat
public static class ReportFileHelper
{
public const string DefaultTemplate = "report_{yyyyMMdd_HHmmss}.pdf";
public const string DefaultTemplate = "{ProjectName}_{yyyyMMdd_HHmmss}.pdf";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,

View File

@ -189,15 +189,16 @@ internal sealed class LegacyReportGenerator
{
t.ColumnsDefinition(cd =>
{
cd.ConstantColumn(60); cd.RelativeColumn(); cd.RelativeColumn(); cd.RelativeColumn(); cd.ConstantColumn(70);
cd.ConstantColumn(60); cd.RelativeColumn(); cd.RelativeColumn(); cd.RelativeColumn(); cd.RelativeColumn(); cd.ConstantColumn(70);
});
HeaderRow(t, "通道", "电压(V)", "电流(A)", "关电压(V)", "结果");
HeaderRow(t, "通道", "电压(V)", "电流(A)", "关电压(V)", "关电流(A)", "结果");
foreach (var r in p.BasicResults)
{
t.Cell().Padding(3).Text($"CH{r.Channel}");
t.Cell().Padding(3).Text(r.VoltageOn.ToString("F2"));
t.Cell().Padding(3).Text(r.CurrentOn.ToString("F3"));
t.Cell().Padding(3).Text(r.VoltageOff.ToString("F2"));
t.Cell().Padding(3).Text(r.CurrentOff.ToString("F3"));
t.Cell().Padding(3).Text(StatusText(r.Status)).FontColor(StatusColor(r.Status));
}
});
@ -271,7 +272,7 @@ internal sealed class LegacyReportGenerator
t.Cell().Padding(3).Text(r.Trigger);
t.Cell().Padding(3).Text(r.MeasuredValue);
t.Cell().Padding(3).Text(r.JudgeMode);
t.Cell().Padding(3).Text(StatusText(r.Status)).FontColor(StatusColor(r.Status));
t.Cell().Padding(3).Text(ProtectStatusText(r.Status)).FontColor(StatusColor(r.Status));
}
});
}
@ -292,6 +293,13 @@ internal sealed class LegacyReportGenerator
TestStatus.Measured => "已测",
_ => "未测试"
};
private static string ProtectStatusText(TestStatus s) => s switch
{
TestStatus.Pass => "合格",
TestStatus.Fail => "不合格",
TestStatus.Running => "测试中",
_ => "未判定"
};
private static string StatusColor(TestStatus s) => s switch
{

View File

@ -118,11 +118,11 @@ public sealed class WordReportGenerator
Section(body, section++, "基础测试", "BASIC FUNCTION TEST");
if (p.BasicResults.Count > 0)
body.Append(ResultTable(
new[] { "通道", "导通电压 V", "导通电流 A", "关后电压 V", "判定" },
new[] { "通道", "导通电压 V", "导通电流 A", "关后电压 V", "关后电流 A", "判定" },
p.BasicResults.Select(r => new[]
{
$"CH{r.Channel:00}", Format(r.VoltageOn, "F2"), Format(r.CurrentOn, "F3"),
Format(r.VoltageOff, "F2"), StatusText(r.Status)
Format(r.VoltageOff, "F2"), Format(r.CurrentOff, "F3"), StatusText(r.Status)
})));
ManualNote(body, p.TestFunction?.Basic);
}
@ -174,7 +174,7 @@ public sealed class WordReportGenerator
ValueOrDash(r.Trigger),
ValueOrDash(r.MeasuredValue),
ValueOrDash(r.JudgeMode),
StatusText(r.Status)
ProtectStatusText(r.Status)
})));
ManualNote(body, p.TestFunction?.Protect);
}
@ -417,6 +417,13 @@ public sealed class WordReportGenerator
TestStatus.Measured => "已测",
_ => "未测试"
};
private static string ProtectStatusText(TestStatus status) => status switch
{
TestStatus.Pass => "合格",
TestStatus.Fail => "不合格",
TestStatus.Running => "测试中",
_ => "未判定"
};
private static string StatusColor(TestStatus status) => status switch
{

View File

@ -26,6 +26,9 @@ public sealed class BasicTest : ITestModule
var on = await ctx.Sspc.ReadMeasurementAsync(ch, ct).ConfigureAwait(false);
row.VoltageOn = on.Voltage;
row.CurrentOn = Math.Abs(on.Current);
row.DisplayVoltage = row.VoltageOn;
row.DisplayCurrent = row.CurrentOn;
bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn);
await ctx.Sspc.TurnOffAsync(ch, ct).ConfigureAwait(false);
await Task.Delay(BasicTestCriteria.SwitchSettleDelayMs, ct).ConfigureAwait(false);
@ -33,7 +36,10 @@ public sealed class BasicTest : ITestModule
.WaitForStableOffAsync(ctx.Sspc, ch, ct)
.ConfigureAwait(false);
row.VoltageOff = offResult.Measurement.Voltage;
row.Status = BasicTestCriteria.IsOn(row.VoltageOn) && offResult.IsStableOff
row.CurrentOff = Math.Abs(offResult.Measurement.Current);
row.DisplayVoltage = row.VoltageOff;
row.DisplayCurrent = row.CurrentOff;
row.Status = onOk && offResult.IsStableOff
? TestStatus.Pass : TestStatus.Fail;
}
catch (Exception ex)

View File

@ -8,6 +8,7 @@ public static class BasicTestCriteria
{
public const double OnVoltageThreshold = 1.0;
public const double OffVoltageThreshold = 0.5;
public const double CurrentPresentThreshold = 0.001;
public const int SwitchSettleDelayMs = 1_000;
public const int OffSettleTimeoutMs = 2_000;
public const int OffInitialDelayMs = 100;
@ -18,8 +19,18 @@ public static class BasicTestCriteria
public static bool IsOff(double voltage) => Math.Abs(voltage) < OffVoltageThreshold;
public static bool HasVoltageData(double voltage) => voltage > OnVoltageThreshold;
public static bool HasCurrentData(double current) => Math.Abs(current) > CurrentPresentThreshold;
public static bool IsOnState(double voltage, double current) =>
HasVoltageData(voltage) && HasCurrentData(current);
public static bool IsOffState(double voltage, double current) =>
HasVoltageData(voltage) && !HasCurrentData(current);
/// <summary>
/// 等待关断电压稳定。连续多次低于阈值才确认关断,避免旧帧和瞬时噪声造成误判。
/// 等待关状态稳定。连续多次满足“电压有数据、电流无数据”才确认关断,避免旧帧和瞬时噪声造成误判。
/// </summary>
public static async Task<OffStateResult> WaitForStableOffAsync(
ISspc sspc,
@ -35,7 +46,7 @@ public static class BasicTestCriteria
while (stopwatch.ElapsedMilliseconds < OffSettleTimeoutMs)
{
latest = await sspc.ReadMeasurementAsync(channel, ct).ConfigureAwait(false);
stableSamples = IsOff(latest.Voltage) ? stableSamples + 1 : 0;
stableSamples = IsOffState(latest.Voltage, latest.Current) ? stableSamples + 1 : 0;
if (stableSamples >= RequiredStableOffSamples)
return new OffStateResult(true, latest, stopwatch.Elapsed);

View File

@ -22,6 +22,9 @@ public sealed class PowerTest : ITestModule
{
if (!_power.IsConnected) await _power.ConnectAsync(ct).ConfigureAwait(false);
if (!ctx.Load.IsConnected) await ctx.Load.ConnectAsync(ct).ConfigureAwait(false);
await _power.OutputAsync(true, ct).ConfigureAwait(false);
await ctx.Load.InputAsync(true, ct).ConfigureAwait(false);
int n = ctx.Sspc.ChannelCount;
ctx.Project.PowerResults.Clear();

View File

@ -24,7 +24,7 @@ public sealed class ProtectTest : ITestModule
await Task.Delay(150, ct).ConfigureAwait(false);
if (!preserveManualJudgement && row.Status == TestStatus.Running)
row.Status = TestStatus.Measured;
row.Status = TestStatus.Untested;
progress.Report(new TestProgress { ModuleName = Name, Step = i + 1, TotalSteps = ctx.Project.ProtectResults.Count, StepStatus = row.Status });
}
}

View File

@ -22,4 +22,23 @@ public sealed class BasicTestCriteriaTests
{
Assert.Equal(expected, BasicTestCriteria.IsOff(voltage));
}
[Theory]
[InlineData(28.0, 1.0, true)]
[InlineData(28.0, 0.0, false)]
[InlineData(0.0, 1.0, false)]
public void IsOnState_RequiresVoltageAndCurrentData(double voltage, double current, bool expected)
{
Assert.Equal(expected, BasicTestCriteria.IsOnState(voltage, current));
}
[Theory]
[InlineData(28.0, 0.0, true)]
[InlineData(28.0, 0.0005, true)]
[InlineData(28.0, 0.01, false)]
[InlineData(0.0, 0.0, false)]
public void IsOffState_RequiresVoltageDataAndNoCurrentData(double voltage, double current, bool expected)
{
Assert.Equal(expected, BasicTestCriteria.IsOffState(voltage, current));
}
}

View File

@ -29,7 +29,8 @@ public sealed class InterfacePowerTests
}
}
});
var test = new PowerTest(new FixedPowerSupply()) { SamplesPerChannel = 1, IntervalMs = 0 };
var power = new FixedPowerSupply();
var test = new PowerTest(power) { SamplesPerChannel = 1, IntervalMs = 0 };
var context = new TestContext(project, sspc, forbiddenPower, load, daq);
await test.RunAsync(context, new Progress<TestProgress>(), CancellationToken.None);
@ -48,6 +49,8 @@ public sealed class InterfacePowerTests
});
Assert.Equal(0, sspc.TurnOffCount);
Assert.Equal(0, sspc.ReadMeasurementCount);
Assert.Equal(1, power.OutputOnCount);
Assert.Equal(1, load.InputOnCount);
Assert.Equal(24, load.ReadPowerCount);
Assert.Equal(0, daq.ReadInstantCount);
}
@ -127,6 +130,7 @@ public sealed class InterfacePowerTests
private sealed class MeasuringLoad : ILoad
{
public int ReadPowerCount { get; private set; }
public int InputOnCount { get; private set; }
public bool IsConnected => true;
public string DisplayName => "it8702p";
public event EventHandler<bool>? ConnectionChanged;
@ -134,7 +138,11 @@ public sealed class InterfacePowerTests
public Task DisconnectAsync() => Task.CompletedTask;
public Task SetModeAsync(LoadMode mode, CancellationToken ct = default) => throw new InvalidOperationException();
public Task SetValueAsync(double value, CancellationToken ct = default) => throw new InvalidOperationException();
public Task InputAsync(bool on, CancellationToken ct = default) => throw new InvalidOperationException();
public Task InputAsync(bool on, CancellationToken ct = default)
{
if (on) InputOnCount++;
return Task.CompletedTask;
}
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Task.FromResult((28.0, 1.0));
public Task<PowerReading> ReadPowerAsync(CancellationToken ct = default)
{
@ -146,6 +154,7 @@ public sealed class InterfacePowerTests
private sealed class FixedPowerSupply : IPowerSupply
{
public int OutputOnCount { get; private set; }
public bool IsConnected => true;
public string DisplayName => "fixed power";
public event EventHandler<bool>? ConnectionChanged;
@ -153,7 +162,11 @@ public sealed class InterfacePowerTests
public Task DisconnectAsync() => Task.CompletedTask;
public Task SetVoltageAsync(double volts, CancellationToken ct = default) => throw new InvalidOperationException();
public Task SetCurrentLimitAsync(double amps, CancellationToken ct = default) => throw new InvalidOperationException();
public Task OutputAsync(bool on, CancellationToken ct = default) => throw new InvalidOperationException();
public Task OutputAsync(bool on, CancellationToken ct = default)
{
if (on) OutputOnCount++;
return Task.CompletedTask;
}
public Task<(double volts, double amps)> ReadAsync(CancellationToken ct = default) => Task.FromResult((30.0, 1.0));
public Task<string> QueryIdentityAsync(CancellationToken ct = default) => Task.FromResult("fixed");
}

View File

@ -29,7 +29,7 @@ public sealed class ProtectTestTests
row => AssertProtection(row, 4, "过压", "≥32V", "28.193 V"),
row => AssertProtection(row, 5, "欠压", "≤22V", "28.193 V"),
row => AssertProtection(row, 6, "限流", "≥1.2A", "1.049 A"));
Assert.All(project.ProtectResults, row => Assert.Equal(TestStatus.Measured, row.Status));
Assert.All(project.ProtectResults, row => Assert.Equal(TestStatus.Untested, row.Status));
Assert.Contains(progress, x => x.ModuleName == ProtectTest.ModuleName);
}

View File

@ -57,11 +57,11 @@ public sealed class ReportFileHelperTests
[Fact]
public void ResolveFileName_UsesFallbackForBlankTemplate()
{
var project = new Project { Report = { FileName = " " } };
var project = new Project { Name = "ProjectA", Report = { FileName = " " } };
string fileName = ReportFileHelper.ResolveFileName(project, new DateTime(2026, 6, 28, 1, 2, 3));
Assert.Equal("report_20260628_010203.pdf", fileName);
Assert.Equal("ProjectA_20260628_010203.pdf", fileName);
}
[Theory]

View File

@ -59,7 +59,8 @@ public sealed class ReportLayoutTests
Channel = channel,
VoltageOn = failed ? 0.12 : 28.01,
CurrentOn = failed ? 0 : 1.002,
VoltageOff = 0.01,
VoltageOff = 28.01,
CurrentOff = failed ? 0.05 : 0,
Status = failed ? TestStatus.Fail : TestStatus.Pass
});
project.CurveResults.Add(new CurveResultRow

View File

@ -31,6 +31,7 @@ public sealed class WordReportGeneratorTests
Assert.Contains("基础测试", text);
Assert.Contains("功率损耗", text);
Assert.Contains("PROTECTION FUNCTION", text);
Assert.Contains("未判定", text);
var fontNames = document.MainDocumentPart.Document.Descendants<RunFonts>()
.SelectMany(x => new[] { x.Ascii?.Value, x.HighAnsi?.Value, x.EastAsia?.Value })
@ -88,7 +89,8 @@ public sealed class WordReportGeneratorTests
Channel = 1,
VoltageOn = 28.0,
CurrentOn = 1.0,
VoltageOff = 0.01,
VoltageOff = 28.0,
CurrentOff = 0.0,
Status = TestStatus.Pass
});
project.CurveResults.Add(new CurveResultRow
@ -115,7 +117,7 @@ public sealed class WordReportGeneratorTests
ProtectType = "短路",
Trigger = "输出短路",
TripTimeMs = 2.1,
Status = TestStatus.Pass
Status = TestStatus.Untested
});
return project;
}

View File

@ -1,10 +1,16 @@
<UserControl x:Class="SSPCTester.UI.Controls.CurvePlotControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sp="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
MinHeight="120"
Background="{StaticResource WaveBgBrush}">
<Border BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="{StaticResource RadiusMd}">
<sp:WpfPlot x:Name="Plot" />
<Grid x:Name="RootContainer">
<Border x:Name="DesignPlaceholder" Visibility="Collapsed" Background="{StaticResource WaveBgBrush}">
<TextBlock Text="曲线图设计时占位"
Foreground="{StaticResource TextMutedBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Grid>
</Border>
</UserControl>

View File

@ -1,16 +1,27 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ScottPlot;
using ScottPlot.WPF;
using SSPCTester.Devices.Interfaces;
namespace SSPCTester.UI.Controls;
public partial class CurvePlotControl : UserControl
{
private readonly bool _isDesignMode;
private WpfPlot? _plot;
public CurvePlotControl()
{
InitializeComponent();
_isDesignMode = DesignerProperties.GetIsInDesignMode(this);
if (_isDesignMode)
{
DesignPlaceholder.Visibility = Visibility.Visible;
return;
}
Loaded += (_, _) => InitPlot();
}
@ -42,30 +53,39 @@ public partial class CurvePlotControl : UserControl
private void InitPlot()
{
Plot.Plot.Title("");
Plot.Plot.XLabel("t (ms)");
Plot.Plot.YLabel("V");
Plot.Plot.Axes.AntiAlias(true);
Plot.Plot.FigureBackground.Color = ScottPlot.Color.FromHex("#FBFCFD");
Plot.Plot.DataBackground.Color = ScottPlot.Color.FromHex("#FBFCFD");
Plot.Plot.Grid.MajorLineColor = ScottPlot.Color.FromHex("#DFE4EA");
_plot ??= CreateRuntimePlot();
_plot.Plot.Title("");
_plot.Plot.XLabel("t (ms)");
_plot.Plot.YLabel("V");
_plot.Plot.Axes.AntiAlias(true);
_plot.Plot.FigureBackground.Color = ScottPlot.Color.FromHex("#FBFCFD");
_plot.Plot.DataBackground.Color = ScottPlot.Color.FromHex("#FBFCFD");
_plot.Plot.Grid.MajorLineColor = ScottPlot.Color.FromHex("#DFE4EA");
Render();
}
private WpfPlot CreateRuntimePlot()
{
var plot = new WpfPlot();
RootContainer.Children.Clear();
RootContainer.Children.Add(plot);
return plot;
}
private void Render()
{
if (!IsLoaded || Plot == null) return;
Plot.Plot.Clear();
if (_isDesignMode || !IsLoaded || _plot == null) return;
_plot.Plot.Clear();
var c = Curve;
if (c == null || c.Voltage.Length == 0)
{
Plot.Refresh();
_plot.Refresh();
return;
}
var points = BuildViewPoints(c);
if (points.Count == 0)
{
Plot.Refresh();
_plot.Refresh();
return;
}
@ -78,31 +98,31 @@ public partial class CurvePlotControl : UserControl
ys[i] = points[i].Voltage;
}
var color = IsRising ? "#1A73C4" : "#D23B3B";
var sig = Plot.Plot.Add.ScatterLine(xs, ys, ScottPlot.Color.FromHex(color));
var sig = _plot.Plot.Add.ScatterLine(xs, ys, ScottPlot.Color.FromHex(color));
sig.LineWidth = 2;
sig.MarkerSize = 0;
// 阈值线
double threshold = IsRising ? VMax * 0.9 : VMax * 0.1;
var hLine = Plot.Plot.Add.HorizontalLine(threshold);
var hLine = _plot.Plot.Add.HorizontalLine(threshold);
hLine.Color = ScottPlot.Color.FromHex("#98A1AD");
hLine.LinePattern = ScottPlot.LinePattern.Dashed;
hLine.LineWidth = 1;
var label = Plot.Plot.Add.Text($"{(IsRising ? 90 : 10)}%", xs[0], threshold);
var label = _plot.Plot.Add.Text($"{(IsRising ? 90 : 10)}%", xs[0], threshold);
label.LabelFontColor = ScottPlot.Color.FromHex("#98A1AD");
label.LabelFontSize = 10;
// 触发线 t=0
if (!HasViewWindow(out double start, out double end) || (start <= 0 && end >= 0))
{
var vLine = Plot.Plot.Add.VerticalLine(0);
var vLine = _plot.Plot.Add.VerticalLine(0);
vLine.Color = ScottPlot.Color.FromHex("#98A1AD");
vLine.LinePattern = ScottPlot.LinePattern.Dotted;
vLine.LineWidth = 1;
}
Plot.Plot.Axes.AutoScale();
Plot.Refresh();
_plot.Plot.Axes.AutoScale();
_plot.Refresh();
}
private List<(double TimeMs, double Voltage)> BuildViewPoints(CurveData curve)
@ -131,6 +151,9 @@ public partial class CurvePlotControl : UserControl
/// <summary>导出为 PNG 字节。</summary>
public byte[] ExportPng(int width = 800, int height = 300)
{
return Plot.Plot.GetImageBytes(width, height, ImageFormat.Png);
if (_plot == null)
return Array.Empty<byte>();
return _plot.Plot.GetImageBytes(width, height, ImageFormat.Png);
}
}

View File

@ -57,7 +57,7 @@ public sealed class StatusToTextConverter : IValueConverter
TestStatus.Fail => "不合格",
TestStatus.Running => "测试中",
TestStatus.Measured => "已测",
_ => "未测试"
_ => "未判定"
} : "—";
public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) => Binding.DoNothing;
}
@ -75,6 +75,22 @@ public sealed class BoolToVisConverter : IValueConverter
=> value is Visibility v && v == Visibility.Visible;
}
/// <summary>bool 取反;用于 IsEnabled 这类布尔属性。</summary>
public sealed class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object? parameter, CultureInfo culture)
{
bool b = value is bool x && x;
return !b;
}
public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture)
{
bool b = value is bool x && x;
return !b;
}
}
/// <summary>null → Collapsed非 null → Visible参数 "Invert" 取反)。</summary>
public sealed class NullToVisConverter : IValueConverter
{

View File

@ -7,6 +7,7 @@
<cnv:StatusToSoftBrushConverter x:Key="StatusToSoftBrush" />
<cnv:StatusToTextConverter x:Key="StatusToText" />
<cnv:BoolToVisConverter x:Key="BoolToVis" />
<cnv:InverseBoolConverter x:Key="InverseBoolConverter" />
<cnv:NullToVisConverter x:Key="NullToVis" />
<cnv:LogLevelToBrushConverter x:Key="LogLevelToBrush" />
<cnv:NumberOrDashConverter x:Key="NumberOrDash" />

View File

@ -16,6 +16,7 @@ public sealed class SafetyMonitorService : IDisposable
private readonly SemaphoreSlim _gate = new(1, 1);
private Timer? _timer;
private bool _handlingEmergency;
private bool _softwareEmergencyActive;
private bool _disposed;
public SafetyMonitorService(
@ -56,18 +57,66 @@ public sealed class SafetyMonitorService : IDisposable
SetStatus(false, false, "设备正常", "正在监控 PCI2312 急停输入");
}
public async Task ResetAsync(CancellationToken ct = default)
public async Task TriggerSoftwareEmergencyAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
_softwareEmergencyActive = true;
_handlingEmergency = true;
SetStatus(true, IsAlarmOutputOn, "软件急停已触发", "正在执行软件急停安全关断");
_log.Add(UiLogLevel.Error, "软件触发报警急停,开始执行报警灯、电源、负载关断。");
await EnsureSafetyDioConnectedAsync(ct).ConfigureAwait(false);
await SetAlarmOutputAsync(true, ct).ConfigureAwait(false);
await TryShutdownPowerAsync().ConfigureAwait(false);
await TryShutdownLoadAsync().ConfigureAwait(false);
SetStatus(true, true, "软件急停已触发", "报警灯已接通,电源输出和负载输入已发送关闭指令");
}
catch (Exception ex)
{
_softwareEmergencyActive = false;
_handlingEmergency = false;
_logger.LogError(ex, "Software emergency shutdown failed.");
_log.Add(UiLogLevel.Error, $"软件急停执行异常:{ex.Message}");
SetStatus(true, IsAlarmOutputOn, "设备故障", ex.Message);
throw;
}
finally
{
_gate.Release();
}
}
public async Task<SafetyResetResult> ResetAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
return await ResetCoreAsync(ct).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task<SafetyResetResult> ResetCoreAsync(CancellationToken ct)
{
var options = _options.Pci2312;
await EnsureSafetyDioConnectedAsync(ct).ConfigureAwait(false);
if (await IsEmergencyInputActiveAsync(options, ct).ConfigureAwait(false))
{
SetStatus(true, IsAlarmOutputOn, "急停已触发", "急停输入仍处于接通状态,无法复位报警灯");
return;
const string message = "硬件急停按钮仍处于触发状态,无法执行状态重置。请先旋转或释放设备上的急停按钮,确认急停输入复位后再重试。";
SetStatus(true, IsAlarmOutputOn, "急停已触发", message);
return new SafetyResetResult(false, message);
}
await SetAlarmOutputAsync(false, ct).ConfigureAwait(false);
SetStatus(false, false, "设备正常", "急停已复位,正在监控 PCI2312 输入");
_softwareEmergencyActive = false;
_handlingEmergency = false;
SetStatus(false, false, "设备正常", "急停状态已重置,报警灯已关闭,正在监控 PCI2312 输入");
return new SafetyResetResult(true, "急停状态已重置。");
}
private void OnTimer(object? state)
@ -92,11 +141,13 @@ public sealed class SafetyMonitorService : IDisposable
{
await HandleEmergencyAsync().ConfigureAwait(false);
}
else if (_softwareEmergencyActive)
{
SetStatus(true, IsAlarmOutputOn, "软件急停已触发", "等待状态重置,电源和负载不会自动恢复");
}
else if (IsEmergencyActive || IsAlarmOutputOn)
{
await SetAlarmOutputAsync(false, CancellationToken.None).ConfigureAwait(false);
_handlingEmergency = false;
SetStatus(false, false, "设备正常", $"DI{options.EmergencyInputChannel} 已复位,报警灯已关闭");
SetStatus(true, IsAlarmOutputOn, "急停已触发", $"DI{options.EmergencyInputChannel} 已复位,请点击状态重置关闭报警灯");
}
else
{
@ -185,6 +236,16 @@ public sealed class SafetyMonitorService : IDisposable
IsAlarmOutputOn = on;
}
private async Task EnsureSafetyDioConnectedAsync(CancellationToken ct)
{
var options = _options.Pci2312;
if (!options.Enabled)
throw new InvalidOperationException("PCI2312 安全输入输出未启用,无法控制报警灯。");
if (!_safetyDio.IsConnected)
await _safetyDio.ConnectAsync(ct).ConfigureAwait(false);
}
private void SetStatus(bool emergencyActive, bool alarmOutputOn, string statusText, string detailText)
{
IsEmergencyActive = emergencyActive;
@ -201,3 +262,5 @@ public sealed class SafetyMonitorService : IDisposable
_gate.Dispose();
}
}
public sealed record SafetyResetResult(bool Success, string Message);

View File

@ -87,8 +87,13 @@ public partial class BasicTestViewModel : ObservableObject
var measurement = await _sspc.ReadMeasurementAsync(row.Channel);
row.VoltageOn = measurement.Voltage;
row.CurrentOn = Math.Abs(measurement.Current);
row.DisplayVoltage = row.VoltageOn;
row.DisplayCurrent = row.CurrentOn;
bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn);
row.Status = onOk ? TestStatus.Running : TestStatus.Fail;
StartPolling(row.Channel);
_log.Add(UiLogLevel.Info, $"CH{row.Channel} 开 -> V={row.VoltageOn:F2}V I={row.CurrentOn:F3}A");
_log.Add(onOk ? UiLogLevel.Info : UiLogLevel.Error,
$"CH{row.Channel} 开 -> V={row.VoltageOn:F2}V I={row.CurrentOn:F3}A {(onOk ? "" : "")}");
}
catch (Exception ex)
{
@ -112,12 +117,15 @@ public partial class BasicTestViewModel : ObservableObject
await Task.Delay(SwitchSettleDelayMs);
var offResult = await BasicTestCriteria.WaitForStableOffAsync(_sspc, row.Channel);
row.VoltageOff = offResult.Measurement.Voltage;
bool onOk = BasicTestCriteria.IsOn(row.VoltageOn);
row.CurrentOff = Math.Abs(offResult.Measurement.Current);
row.DisplayVoltage = row.VoltageOff;
row.DisplayCurrent = row.CurrentOff;
bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn);
row.Status = (onOk && offResult.IsStableOff) ? TestStatus.Pass : TestStatus.Fail;
string verdict = row.Status == TestStatus.Pass ? "合格" : "不合格";
string offState = offResult.IsStableOff ? "已稳定关断" : "关断超时";
_log.Add(row.Status == TestStatus.Pass ? UiLogLevel.Success : UiLogLevel.Error,
$"CH{row.Channel} 关 -> V={row.VoltageOff:F2}V {offState} {verdict}");
$"CH{row.Channel} 关 -> V={row.VoltageOff:F2}V I={row.CurrentOff:F3}A {offState} {verdict}");
}
catch (Exception ex)
{
@ -191,24 +199,37 @@ public partial class BasicTestViewModel : ObservableObject
if (!_sspc.IsConnected) await _sspc.ConnectAsync(ct);
row.Status = TestStatus.Running;
_log.Add(UiLogLevel.Info, $"CH{row.Channel} 基础测试开始:执行开状态测试。");
await _sspc.TurnOnAsync(row.Channel, ct);
await Task.Delay(SwitchSettleDelayMs, ct);
var measurement = await _sspc.ReadMeasurementAsync(row.Channel, ct);
row.VoltageOn = measurement.Voltage;
row.CurrentOn = Math.Abs(measurement.Current);
row.DisplayVoltage = row.VoltageOn;
row.DisplayCurrent = row.CurrentOn;
bool onOk = BasicTestCriteria.IsOnState(row.VoltageOn, row.CurrentOn);
_log.Add(onOk ? UiLogLevel.Info : UiLogLevel.Error,
$"CH{row.Channel} 开 -> V={row.VoltageOn:F2}V I={row.CurrentOn:F3}A {(onOk ? "" : "")}");
StartPolling(row.Channel);
ct.ThrowIfCancellationRequested();
await StopPollingAsync(row.Channel);
_log.Add(UiLogLevel.Info, $"CH{row.Channel} 执行关状态测试。");
await _sspc.TurnOffAsync(row.Channel, ct);
await Task.Delay(SwitchSettleDelayMs, ct);
var offResult = await BasicTestCriteria.WaitForStableOffAsync(_sspc, row.Channel, ct);
row.VoltageOff = offResult.Measurement.Voltage;
bool onOk = BasicTestCriteria.IsOn(row.VoltageOn);
row.CurrentOff = Math.Abs(offResult.Measurement.Current);
row.DisplayVoltage = row.VoltageOff;
row.DisplayCurrent = row.CurrentOff;
row.Status = (onOk && offResult.IsStableOff) ? TestStatus.Pass : TestStatus.Fail;
string verdict = row.Status == TestStatus.Pass ? "合格" : "不合格";
string offState = offResult.IsStableOff ? "关状态合格" : "关状态不合格";
_log.Add(row.Status == TestStatus.Pass ? UiLogLevel.Success : UiLogLevel.Error,
$"CH{row.Channel} 关 -> V={row.VoltageOff:F2}V I={row.CurrentOff:F3}A {offState} {verdict}");
}
[RelayCommand]
@ -216,7 +237,8 @@ public partial class BasicTestViewModel : ObservableObject
{
foreach (var r in Rows)
{
r.VoltageOn = 0; r.CurrentOn = 0; r.VoltageOff = 0;
r.VoltageOn = 0; r.CurrentOn = 0; r.VoltageOff = 0; r.CurrentOff = 0;
r.DisplayVoltage = 0; r.DisplayCurrent = 0;
r.Status = TestStatus.Untested;
}
}
@ -302,6 +324,8 @@ public partial class BasicTestViewModel : ObservableObject
row.VoltageOn = measurement.Voltage;
row.CurrentOn = Math.Abs(measurement.Current);
row.DisplayVoltage = row.VoltageOn;
row.DisplayCurrent = row.CurrentOn;
}
}
}

View File

@ -82,6 +82,13 @@ public partial class CurveTestViewModel : ObservableObject
_ = LoadSelectedChannelCurvesAsync();
}
partial void OnSelectedRowChanged(CurveResultRow? value)
{
if (_syncingViewWindow || value == null) return;
if (SelectedChannel != value.Channel)
SelectedChannel = value.Channel;
}
partial void OnRisingViewStartMsChanged(double? value)
{
if (_syncingViewWindow || SelectedRow == null) return;
@ -117,10 +124,10 @@ public partial class CurveTestViewModel : ObservableObject
{
SelectedRow = Rows.FirstOrDefault(x => x.Channel == SelectedChannel)
?? Project?.CurveResults.FirstOrDefault(x => x.Channel == SelectedChannel);
RisingViewStartMs = SelectedRow?.RisingViewStartMs ?? SelectedRow?.ViewStartMs;
RisingViewEndMs = SelectedRow?.RisingViewEndMs ?? SelectedRow?.ViewEndMs;
FallingViewStartMs = SelectedRow?.FallingViewStartMs ?? SelectedRow?.ViewStartMs;
FallingViewEndMs = SelectedRow?.FallingViewEndMs ?? SelectedRow?.ViewEndMs;
RisingViewStartMs = SelectedRow?.RisingViewStartMs;
RisingViewEndMs = SelectedRow?.RisingViewEndMs;
FallingViewStartMs = SelectedRow?.FallingViewStartMs;
FallingViewEndMs = SelectedRow?.FallingViewEndMs;
}
finally
{
@ -228,9 +235,9 @@ public partial class CurveTestViewModel : ObservableObject
}
[RelayCommand]
private void OpenPopup()
private void OpenPopup(string? curveKind)
{
var win = new Views.CurvePopupWindow(this) { Owner = System.Windows.Application.Current?.MainWindow };
var win = new Views.CurvePopupWindow(this, curveKind) { Owner = System.Windows.Application.Current?.MainWindow };
win.Show();
}

View File

@ -30,8 +30,6 @@ public partial class MainViewModel : ObservableObject
private readonly UiLogSink _logSink;
private readonly DeviceOptions _deviceOptions;
private readonly DispatcherTimer _deviceMonitorTimer;
private readonly DispatcherTimer _safetyReadingsTimer;
private bool _safetyReadingsBusy;
private object? _viewBeforeSettings;
public MainViewModel(
@ -54,8 +52,6 @@ public partial class MainViewModel : ObservableObject
_deviceMonitorTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_deviceMonitorTimer.Tick += (_, _) => RefreshDeviceStatus();
_deviceMonitorTimer.Start();
_safetyReadingsTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_safetyReadingsTimer.Tick += async (_, _) => await RefreshSafetyReadingsAsync().ConfigureAwait(true);
AllProjects = new ObservableCollection<Project>();
TreeRoots = new ObservableCollection<ProjectNode>();
@ -81,8 +77,8 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] private object? _currentView;
[ObservableProperty] private bool _devicesConnected;
[ObservableProperty] private bool _devicePanelOpen;
[ObservableProperty] private bool _safetyPanelOpen;
[ObservableProperty] private bool _deviceOperationBusy;
[ObservableProperty] private bool _emergencyOperationBusy;
[ObservableProperty] private string _deviceSummary = "设备状态检查中";
[ObservableProperty] private string _sspcConnectionStatus = "检查中";
[ObservableProperty] private string _powerConnectionStatus = "检查中";
@ -91,26 +87,13 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] private string _pci2312ConnectionStatus = "检查中";
[ObservableProperty] private string _safetyStatusText = "设备故障";
[ObservableProperty] private string _safetyDetailText = "设备安全监控未启动";
[ObservableProperty] private string _safetyPowerCurrentText = "--";
[ObservableProperty] private string _safetyLoadCurrentText = "--";
[ObservableProperty] private bool _alarmLampActive;
[ObservableProperty] private string _emergencyButtonText = "报警急停(状态重置)";
[ObservableProperty] private string _testingMessage = "";
[ObservableProperty] private TestSessionViewModel? _currentSession;
partial void OnStateChanged(AppState value) => RebuildTree();
partial void OnCurrentProjectChanged(Project? value) => RebuildTree();
partial void OnSafetyPanelOpenChanged(bool value)
{
if (value)
{
_safetyReadingsTimer.Start();
_ = RefreshSafetyReadingsAsync();
}
else
{
_safetyReadingsTimer.Stop();
}
}
private async Task ConnectConfiguredDevicesAsync()
{
@ -180,55 +163,11 @@ public partial class MainViewModel : ObservableObject
{
SafetyStatusText = _safetyMonitor.StatusText;
SafetyDetailText = _safetyMonitor.DetailText;
AlarmLampActive = _safetyMonitor.IsEmergencyActive || _safetyMonitor.IsAlarmOutputOn;
AlarmLampActive = _safetyMonitor.IsAlarmOutputOn;
EmergencyButtonText = AlarmLampActive ? "状态重置" : "报警急停";
});
}
private async Task RefreshSafetyReadingsAsync()
{
if (_safetyReadingsBusy) return;
_safetyReadingsBusy = true;
try
{
SafetyPowerCurrentText = await ReadPowerCurrentTextAsync().ConfigureAwait(true);
SafetyLoadCurrentText = await ReadLoadCurrentTextAsync().ConfigureAwait(true);
}
finally
{
_safetyReadingsBusy = false;
}
}
private async Task<string> ReadPowerCurrentTextAsync()
{
if (!_power.IsConnected) return "未连接";
try
{
var (_, amps) = await _power.ReadAsync().ConfigureAwait(true);
return $"{amps:F3} A";
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Read UDP5080 current for safety panel failed.");
return "读取失败";
}
}
private async Task<string> ReadLoadCurrentTextAsync()
{
if (!_load.IsConnected) return "未连接";
try
{
var (_, amps) = await _load.ReadAsync().ConfigureAwait(true);
return $"{amps:F3} A";
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Read IT8702P current for safety panel failed.");
return "读取失败";
}
}
private static string DeviceStatusText(IDeviceConnection device) =>
device.IsConnected ? "已连接" : "未连接";
@ -240,18 +179,51 @@ public partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void ToggleSafetyPanel()
private async Task ToggleEmergencyAlarm()
{
if (EmergencyOperationBusy) return;
EmergencyOperationBusy = true;
try
{
if (AlarmLampActive)
{
var result = await _safetyMonitor.ResetAsync().ConfigureAwait(false);
RefreshSafetyStatus();
SafetyPanelOpen = !SafetyPanelOpen;
if (!result.Success)
{
Application.Current?.Dispatcher.Invoke(() =>
MessageBox.Show(
Application.Current?.MainWindow,
result.Message,
"状态重置受阻",
MessageBoxButton.OK,
MessageBoxImage.Warning));
}
[RelayCommand]
private async Task ResetSafetyAlarm()
}
else
{
await _safetyMonitor.ResetAsync().ConfigureAwait(false);
await _safetyMonitor.TriggerSoftwareEmergencyAsync().ConfigureAwait(false);
RefreshSafetyStatus();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Toggle emergency alarm failed.");
_logSink.Add(UiLogLevel.Error, $"报警急停操作失败:{ex.Message}");
Application.Current?.Dispatcher.Invoke(() =>
MessageBox.Show(
Application.Current?.MainWindow,
$"报警急停操作失败:{ex.Message}",
"报警急停",
MessageBoxButton.OK,
MessageBoxImage.Error));
RefreshSafetyStatus();
}
finally
{
EmergencyOperationBusy = false;
}
}
[RelayCommand]
private async Task ConnectAllDevices()
@ -427,8 +399,7 @@ public partial class MainViewModel : ObservableObject
{
CurrentProject = p;
State = AppState.Testing;
TestingMessage = $"测试进行中:{p.Name}";
TestingMessage = $"测试页面:{p.Name}";
var session = new TestSessionViewModel(_sp, p, _sspc, _power, _load, _daq, _logSink);
CurrentSession = session;
@ -439,30 +410,31 @@ public partial class MainViewModel : ObservableObject
try
{
await session.RunAllAsync();
_logSink.Add(UiLogLevel.Success, "全部测试完成。");
}
catch (OperationCanceledException)
{
_logSink.Add(UiLogLevel.Warning, "测试已中止。");
await _store.SaveAsync(p);
_logSink.Add(UiLogLevel.Info, "已进入测试页面,请在各测试页手动执行当前项目。");
}
catch (Exception ex)
{
_logger.LogError(ex, "Test failed.");
_logSink.Add(UiLogLevel.Error, "测试出错:" + ex.Message);
}
finally
{
await _store.SaveAsync(p);
State = AppState.Browsing;
CurrentSession = null;
_logger.LogError(ex, "Failed to enter test view.");
_logSink.Add(UiLogLevel.Error, "进入测试页面出错:" + ex.Message);
}
}
[RelayCommand]
private void AbortTest()
private async Task AbortTest()
{
CurrentSession?.Cancel();
if (CurrentView is TestHostViewModel host && host.Basic.IsBatchRunning)
await host.Basic.CancelRunAllCommand.ExecuteAsync(null);
if (CurrentProject != null)
await _store.SaveAsync(CurrentProject);
CurrentSession = null;
TestingMessage = string.Empty;
State = AppState.Browsing;
if (CurrentProject != null)
OpenProjectInfo(CurrentProject);
}
[RelayCommand]

View File

@ -131,6 +131,9 @@ public partial class PowerTestViewModel : ObservableObject
throw new InvalidOperationException($"IT8702P connection failed: {ex.Message}", ex);
}
await _power.OutputAsync(true);
await _load.InputAsync(true);
double sVin = 0, sIin = 0, sVout = 0, sIout = 0, sPout = 0;
for (int s = 0; s < SamplesPerChannel; s++)
{
@ -165,7 +168,7 @@ public partial class PowerTestViewModel : ObservableObject
row.MeasuredPowerOut = sPout / SamplesPerChannel;
row.Status = TestStatus.Measured;
Current = row;
_log.Add(UiLogLevel.Success, $"CH{ch} 功率损耗 {row.PowerLossW:F3} W效率 {row.EfficiencyPct:F3}%");
_log.Add(UiLogLevel.Success, $"CH{ch} 功率损耗 {row.PowerLossW:F3} W效率 {row.EfficiencyPct:F1}%");
}
catch (Exception ex)
{

View File

@ -61,8 +61,8 @@ public partial class ProtectViewModel : ObservableObject
"过压" or "欠压" => $"{volts:F3} V",
_ => $"{amps:F3} A"
};
if (row.Status is TestStatus.Untested or TestStatus.Running)
row.Status = TestStatus.Measured;
if (row.Status == TestStatus.Running)
row.Status = TestStatus.Untested;
_log.Add(UiLogLevel.Success, $"保护功能 {row.ProtectType} 实测值:{row.MeasuredValue}");
}
catch (Exception ex)

View File

@ -60,7 +60,8 @@ public partial class ReportViewModel : ObservableObject
{
Project = p;
if (string.IsNullOrWhiteSpace(p.Report.FileName) ||
string.Equals(p.Report.FileName, "report.pdf", StringComparison.OrdinalIgnoreCase))
string.Equals(p.Report.FileName, "report.pdf", StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.Report.FileName, "report_{yyyyMMdd_HHmmss}.pdf", StringComparison.OrdinalIgnoreCase))
{
p.Report.FileName = ReportFileHelper.DefaultTemplate;
}
@ -318,15 +319,15 @@ public partial class ReportViewModel : ObservableObject
: CurveImageRenderer.RenderRisingChannel(
row.Channel,
rising,
row.RisingViewStartMs ?? row.ViewStartMs,
row.RisingViewEndMs ?? row.ViewEndMs),
row.RisingViewStartMs,
row.RisingViewEndMs),
falling == null
? null
: CurveImageRenderer.RenderFallingChannel(
row.Channel,
falling,
row.FallingViewStartMs ?? row.ViewStartMs,
row.FallingViewEndMs ?? row.ViewEndMs));
row.FallingViewStartMs,
row.FallingViewEndMs));
}
catch (Exception ex)
{

View File

@ -87,7 +87,7 @@
</DataGridTextColumn>
<DataGridTextColumn Header="电压(V)" Width="*">
<DataGridTextColumn.Binding>
<Binding Path="VoltageOn" Converter="{StaticResource NumberOrDash}" ConverterParameter="F2" />
<Binding Path="DisplayVoltage" Converter="{StaticResource NumberOrDash}" ConverterParameter="F2" />
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource MonoText}" />
@ -95,7 +95,7 @@
</DataGridTextColumn>
<DataGridTextColumn Header="电流(A)" Width="*">
<DataGridTextColumn.Binding>
<Binding Path="CurrentOn" Converter="{StaticResource NumberOrDash}" ConverterParameter="F3" />
<Binding Path="DisplayCurrent" Converter="{StaticResource NumberOrDash}" ConverterParameter="F3" />
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource MonoText}" />

View File

@ -3,20 +3,20 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SSPCTester.UI.Controls"
xmlns:vm="clr-namespace:SSPCTester.UI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="曲线查看器"
Width="1100" Height="720"
MinWidth="600" MinHeight="400"
WindowStartupLocation="CenterOwner"
WindowState="Maximized"
Background="{StaticResource BgAppBrush}"
d:DataContext="{d:DesignInstance Type=vm:CurveTestViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Margin="0,0,0,12">
@ -31,64 +31,26 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" FontSize="{StaticResource FsH1}" FontWeight="Bold">
<Run Text="曲线查看器 " />
<Run x:Name="TitleRun" Text="曲线查看器 " />
<Run Text="CH" Foreground="{StaticResource TextSecondaryBrush}" />
<Run Text="{Binding SelectedChannel, Mode=OneWay}" Foreground="{StaticResource PrimaryBrush}" />
</TextBlock>
<ComboBox Grid.Column="1" Width="120" Margin="0,0,8,0"
ItemsSource="{Binding Channels}" SelectedItem="{Binding SelectedChannel}" />
<TextBlock Grid.Column="2" Text="起点" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Grid.Column="3" Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding RisingViewStartMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
<TextBox x:Name="StartTextBox" Grid.Column="3" Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
FontFamily="{StaticResource FontMono}" />
<TextBlock Grid.Column="4" Text="终点" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Grid.Column="5" Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding RisingViewEndMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
<TextBox x:Name="EndTextBox" Grid.Column="5" Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
FontFamily="{StaticResource FontMono}" />
<Button Grid.Column="6" Content="默认" Width="54" Height="26" Margin="0,0,8,0" Padding="0"
Command="{Binding ResetRisingCurveViewCommand}" />
<Button x:Name="ResetButton" Grid.Column="6" Content="默认" Width="54" Height="26" Margin="0,0,8,0" Padding="0" />
<Button Grid.Column="7" Content="关闭" Width="80" IsCancel="True" Click="Close_Click" />
</Grid>
<Border Grid.Row="1" Style="{StaticResource Card}" Margin="0,0,0,6">
<Border Grid.Row="1" Style="{StaticResource Card}">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Style="{StaticResource CardTitle}" Text="开启曲线电压上升90% 阈值)" />
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,0,6">
<TextBlock Text="璧风偣" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding RisingViewStartMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
FontFamily="{StaticResource FontMono}" />
<TextBlock Text="缁堢偣" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding RisingViewEndMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
FontFamily="{StaticResource FontMono}" />
<Button Content="榛樿" Width="54" Height="26" Margin="0,0,8,0" Padding="0"
Command="{Binding ResetRisingCurveViewCommand}" />
</StackPanel>
<ctl:CurvePlotControl Curve="{Binding RisingCurve}" IsRising="True" VMax="{Binding VMax}"
ViewStartMs="{Binding RisingViewStartMs}"
ViewEndMs="{Binding RisingViewEndMs}" />
</DockPanel>
</Border>
<Border Grid.Row="2" Style="{StaticResource Card}" Margin="0,6,0,0">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Style="{StaticResource CardTitle}" Text="关断曲线电压下降10% 阈值)" />
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,0,6">
<TextBlock Text="璧风偣" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding FallingViewStartMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
FontFamily="{StaticResource FontMono}" />
<TextBlock Text="缁堢偣" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox Width="64" Height="26" Margin="0,0,8,0" Padding="4,0"
Text="{Binding FallingViewEndMs, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}"
FontFamily="{StaticResource FontMono}" />
<Button Content="榛樿" Width="54" Height="26" Margin="0,0,8,0" Padding="0"
Command="{Binding ResetFallingCurveViewCommand}" />
</StackPanel>
<ctl:CurvePlotControl Curve="{Binding FallingCurve}" IsRising="False" VMax="{Binding VMax}"
ViewStartMs="{Binding FallingViewStartMs}"
ViewEndMs="{Binding FallingViewEndMs}" />
<TextBlock x:Name="CurveTitleText" DockPanel.Dock="Top" Style="{StaticResource CardTitle}" />
<ctl:CurvePlotControl x:Name="CurvePlot" VMax="{Binding VMax}" />
</DockPanel>
</Border>
</Grid>

View File

@ -1,14 +1,50 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using SSPCTester.UI.Controls;
using SSPCTester.UI.ViewModels;
namespace SSPCTester.UI.Views;
public partial class CurvePopupWindow : Window
{
public CurvePopupWindow(CurveTestViewModel vm)
public CurvePopupWindow()
{
InitializeComponent();
}
public CurvePopupWindow(CurveTestViewModel vm, string? curveKind)
: this()
{
DataContext = vm;
ConfigureCurve(curveKind);
}
private void ConfigureCurve(string? curveKind)
{
bool isFalling = string.Equals(curveKind, "Falling", StringComparison.OrdinalIgnoreCase);
string title = isFalling ? "关断曲线电压下降10% 阈值)" : "开启曲线电压上升90% 阈值)";
Title = title;
TitleRun.Text = $"{title} ";
CurveTitleText.Text = title;
CurvePlot.IsRising = !isFalling;
CurvePlot.SetBinding(CurvePlotControl.CurveProperty, new Binding(isFalling ? "FallingCurve" : "RisingCurve"));
CurvePlot.SetBinding(CurvePlotControl.ViewStartMsProperty, new Binding(isFalling ? "FallingViewStartMs" : "RisingViewStartMs"));
CurvePlot.SetBinding(CurvePlotControl.ViewEndMsProperty, new Binding(isFalling ? "FallingViewEndMs" : "RisingViewEndMs"));
StartTextBox.SetBinding(TextBox.TextProperty, new Binding(isFalling ? "FallingViewStartMs" : "RisingViewStartMs")
{
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
TargetNullValue = ""
});
EndTextBox.SetBinding(TextBox.TextProperty, new Binding(isFalling ? "FallingViewEndMs" : "RisingViewEndMs")
{
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
TargetNullValue = ""
});
ResetButton.SetBinding(Button.CommandProperty, new Binding(isFalling ? "ResetFallingCurveViewCommand" : "ResetRisingCurveViewCommand"));
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();

View File

@ -54,12 +54,10 @@
</Grid>
<Button Style="{StaticResource PrimaryButton}" Content="采集开启曲线" Margin="0,16,0,0"
Command="{Binding CaptureRisingCommand}"
IsEnabled="{Binding Busy, Converter={StaticResource BoolToVis}, ConverterParameter=Invert}" />
IsEnabled="{Binding Busy, Converter={StaticResource InverseBoolConverter}}" />
<Button Style="{StaticResource PrimaryButton}" Content="采集关断曲线" Margin="0,8,0,0"
Command="{Binding CaptureFallingCommand}"
IsEnabled="{Binding Busy, Converter={StaticResource BoolToVis}, ConverterParameter=Invert}" />
<Button Content="在新窗口查看曲线" Margin="0,8,0,0"
Command="{Binding OpenPopupCommand}" />
IsEnabled="{Binding Busy, Converter={StaticResource InverseBoolConverter}}" />
<TextBlock Margin="0,12,0,0" TextWrapping="Wrap"
Foreground="{StaticResource TextMutedBrush}"
Text="开启/关断时间以软件发送 Modbus 命令前的标记为基准,包含串口通信延迟,不等同于硬件同步触发精度。" />
@ -92,7 +90,8 @@
<Button Content="默认" Width="54" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ResetRisingCurveViewCommand}" />
<Button Content="放大" Width="64" Height="26" Padding="0"
Command="{Binding OpenPopupCommand}" />
Command="{Binding OpenPopupCommand}"
CommandParameter="Rising" />
</StackPanel>
</Grid>
<ctl:CurvePlotControl Curve="{Binding RisingCurve}" IsRising="True" VMax="{Binding VMax}"
@ -120,7 +119,8 @@
<Button Content="默认" Width="54" Height="26" Padding="0" Margin="0,0,8,0"
Command="{Binding ResetFallingCurveViewCommand}" />
<Button Content="放大" Width="64" Height="26" Padding="0"
Command="{Binding OpenPopupCommand}" />
Command="{Binding OpenPopupCommand}"
CommandParameter="Falling" />
</StackPanel>
</Grid>
<ctl:CurvePlotControl Curve="{Binding FallingCurve}" IsRising="False" VMax="{Binding VMax}"
@ -141,7 +141,8 @@
<Border Style="{StaticResource Card}" Margin="0,0,12,0">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Style="{StaticResource CardTitle}" Text="各通道时间参数" />
<DataGrid ItemsSource="{Binding Rows}">
<DataGrid ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow, Mode=TwoWay}">
<DataGrid.Columns>
<DataGridTextColumn Header="通道" Width="70">
<DataGridTextColumn.Binding>

View File

@ -37,58 +37,152 @@
FontSize="{StaticResource FsTitle}" FontWeight="Bold" />
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Column="2" VerticalAlignment="Center">
<Grid Margin="0,0,8,0">
<Button x:Name="SafetyStatusButton" Width="34" Height="30" Padding="0"
Command="{Binding ToggleSafetyPanelCommand}"
ToolTip="{Binding SafetyStatusText}"
Background="Transparent" BorderBrush="{StaticResource BorderBrush}">
<Grid>
<Ellipse Width="18" Height="18" Stroke="{StaticResource BorderBrush}" StrokeThickness="1">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource SuccessBrush}" />
<Viewbox Width="26" Height="26" Margin="0,0,6,0"
ToolTip="{Binding SafetyDetailText}">
<Canvas Width="64" Height="64">
<Canvas.Resources>
<RadialGradientBrush x:Key="AlarmRedGlassBrush" Center="0.34,0.16" GradientOrigin="0.34,0.16" RadiusX="0.78" RadiusY="0.78">
<GradientStop Color="#FFFF8A8A" Offset="0" />
<GradientStop Color="#FFDF0010" Offset="0.36" />
<GradientStop Color="#FF720006" Offset="1" />
</RadialGradientBrush>
<RadialGradientBrush x:Key="AlarmGrayGlassBrush" Center="0.34,0.16" GradientOrigin="0.34,0.16" RadiusX="0.78" RadiusY="0.78">
<GradientStop Color="#FFDEDEDE" Offset="0" />
<GradientStop Color="#FF969696" Offset="0.36" />
<GradientStop Color="#FF4B4B4B" Offset="1" />
</RadialGradientBrush>
<LinearGradientBrush x:Key="AlarmBaseBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FF3B3B3B" Offset="0" />
<GradientStop Color="#FF101010" Offset="0.45" />
<GradientStop Color="#FF303030" Offset="1" />
</LinearGradientBrush>
</Canvas.Resources>
<Path x:Name="AlarmGlass" Data="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Fill" Value="{StaticResource AlarmGrayGlassBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLampActive}" Value="True">
<Setter Property="Fill" Value="{StaticResource DangerBrush}" />
<Setter Property="Fill" Value="{StaticResource AlarmRedGlassBrush}" />
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="AlarmGlassBlink">
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="1" To=".42" Duration="0:0:0.36" AutoReverse="True" RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<StopStoryboard BeginStoryboardName="AlarmGlassBlink" />
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="!" Foreground="White" FontSize="12" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Path.Style>
</Path>
<Path x:Name="AlarmRim" Data="M14 44V23C14 10.5 22.1 4 32 4s18 6.5 18 19v21z"
Fill="Transparent">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Stroke" Value="#FF555555" />
<Setter Property="StrokeThickness" Value="1.4" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLampActive}" Value="True">
<Setter Property="Stroke" Value="#FFFF2635" />
<Setter Property="StrokeThickness" Value="2.2" />
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="AlarmRimBlink">
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="1" To=".35" Duration="0:0:0.36" AutoReverse="True" RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<StopStoryboard BeginStoryboardName="AlarmRimBlink" />
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
<Path x:Name="AlarmSweep" Data="M17 6h8l-11 42h-8z"
Fill="White">
<Path.RenderTransform>
<TranslateTransform />
</Path.RenderTransform>
<Path.Style>
<Style TargetType="Path">
<Setter Property="Opacity" Value="0" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLampActive}" Value="True">
<Setter Property="Opacity" Value=".65" />
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="AlarmSweepMove">
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
From="-24" To="30" Duration="0:0:0.95" RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<StopStoryboard BeginStoryboardName="AlarmSweepMove" />
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
<Path Data="M25 13l7-3 3 6-8 3zM38 11c4 1 6 3 8 7l-5 2c-1-3-3-4-6-5z"
Fill="White" Opacity=".72" />
<Ellipse Canvas.Left="10" Canvas.Top="39" Width="44" Height="10" Fill="#771A0000" />
<Rectangle x:Name="AlarmBaseFlash" Canvas.Left="13" Canvas.Top="42" Width="38" Height="2"
Fill="#FFFF0014">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Opacity" Value="0" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLampActive}" Value="True">
<Setter Property="Opacity" Value="1" />
<DataTrigger.EnterActions>
<BeginStoryboard x:Name="AlarmBaseBlink">
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="1" To=".15" Duration="0:0:0.36" AutoReverse="True" RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<StopStoryboard BeginStoryboardName="AlarmBaseBlink" />
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
<Path Data="M11 43h42v6H11z" Fill="#DD240004" />
<Path Data="M9 48h46v8c0 3-3 5-6 5H15c-3 0-6-2-6-5z"
Fill="{StaticResource AlarmBaseBrush}" />
<Path Data="M13 50h38" Stroke="#777777" StrokeThickness="1" Opacity=".45" />
<Path Data="M42 56v4M47 55v4M52 54v3" Stroke="#050505" StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" />
</Canvas>
</Viewbox>
<Button Margin="0,0,8,0" Padding="12,4"
Command="{Binding ToggleEmergencyAlarmCommand}"
IsEnabled="{Binding EmergencyOperationBusy, Converter={StaticResource InverseBoolConverter}}"
ToolTip="{Binding SafetyDetailText}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource DangerButton}">
<Setter Property="Content" Value="{Binding EmergencyButtonText}" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLampActive}" Value="False">
<Setter Property="Background" Value="{StaticResource WarningBrush}" />
<Setter Property="Foreground" Value="White" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Popup IsOpen="{Binding SafetyPanelOpen}" PlacementTarget="{Binding ElementName=SafetyStatusButton}"
Placement="Bottom" StaysOpen="False" AllowsTransparency="True">
<Border Width="300" Margin="0,8,0,0" Padding="16"
Background="{StaticResource BgSurfaceBrush}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="{StaticResource RadiusMd}">
<StackPanel>
<TextBlock Text="设备安全" FontWeight="Bold" FontSize="{StaticResource FsH2}" Margin="0,0,0,12" />
<Grid Margin="0,3">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="PCI2312" /><TextBlock Grid.Column="1" Text="{Binding Pci2312ConnectionStatus}" />
</Grid>
<Grid Margin="0,3">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="设备状态" /><TextBlock Grid.Column="1" Text="{Binding SafetyStatusText}" />
</Grid>
<Grid Margin="0,3">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="电源电流" /><TextBlock Grid.Column="1" Text="{Binding SafetyPowerCurrentText}" FontFamily="{StaticResource FontMono}" />
</Grid>
<Grid Margin="0,3">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="负载电流" /><TextBlock Grid.Column="1" Text="{Binding SafetyLoadCurrentText}" FontFamily="{StaticResource FontMono}" />
</Grid>
<TextBlock Text="{Binding SafetyDetailText}" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,12" />
<Button Content="复位报警灯" Command="{Binding ResetSafetyAlarmCommand}" HorizontalAlignment="Stretch" />
</StackPanel>
</Border>
</Popup>
</Grid>
<Grid>
<Button x:Name="DeviceStatusButton" Padding="10,4" BorderThickness="0"
Background="{Binding DevicesConnected, Converter={StaticResource StatusToSoftBrush}, ConverterParameter=Pass}"
@ -124,7 +218,7 @@
</Grid>
<Grid Margin="0,3,0,12">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBlock Text="PCI2312 安全输入输出" /><TextBlock Grid.Column="1" Text="{Binding Pci2312ConnectionStatus}" />
<TextBlock Text="报警/急停" /><TextBlock Grid.Column="1" Text="{Binding Pci2312ConnectionStatus}" />
</Grid>
<WrapPanel>
<Button Content="连接全部" Command="{Binding ConnectAllDevicesCommand}" Margin="0,0,8,8" />
@ -225,7 +319,7 @@
<TextBlock Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="{StaticResource TestingBarTextBrush}"
Text="{Binding TestingMessage}" />
<Button Grid.Column="2" Style="{StaticResource DangerButton}" Content="中止测试"
<Button Grid.Column="2" Style="{StaticResource DangerButton}" Content="退出测试"
Command="{Binding AbortTestCommand}" />
</Grid>
</Border>

View File

@ -1,24 +1,31 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.ComponentModel;
using SSPCTester.UI.ViewModels;
namespace SSPCTester.UI.Views;
public partial class MainWindow : Window
{
private readonly MainViewModel _vm;
private readonly MainViewModel? _vm;
public MainWindow(MainViewModel vm)
public MainWindow()
{
InitializeComponent();
}
public MainWindow(MainViewModel vm) : this()
{
_vm = vm;
DataContext = vm;
if (!DesignerProperties.GetIsInDesignMode(this))
Loaded += (_, _) => _vm.Initialize();
}
private void ProjectTree_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (_vm == null) return;
if (sender is TreeView tv && tv.SelectedItem is ProjectNode node)
{
_vm.HandleNodeActivated(node);

View File

@ -30,7 +30,7 @@
<TextBox Text="{Binding SamplesPerChannel}" Margin="0,0,0,12" FontFamily="{StaticResource FontMono}" />
<TextBlock Style="{StaticResource FieldLabel}" Text="采集间隔 (ms)" />
<TextBox Text="{Binding IntervalMs}" Margin="0,0,0,12" FontFamily="{StaticResource FontMono}" />
<UniformGrid Columns="2" Margin="0,16,0,0">
<UniformGrid Columns="2" Margin="0,0,0,0">
<Button Content="开启当前通道" Margin="0,0,6,0"
Command="{Binding TurnOnCurrentChannelCommand}"
IsEnabled="{Binding CanOperate}" />
@ -38,7 +38,7 @@
Command="{Binding TurnOffCurrentChannelCommand}"
IsEnabled="{Binding CanOperate}" />
</UniformGrid>
<Button Style="{StaticResource PrimaryButton}" Content="测量当前通道" Margin="0,16,0,0"
<Button Style="{StaticResource PrimaryButton}" Content="测量当前通道" Margin="0,10,0,0"
Command="{Binding MeasureCurrentCommand}"
IsEnabled="{Binding CanOperate}" />
</StackPanel>
@ -161,7 +161,7 @@
</Grid.ColumnDefinitions>
<TextBlock Text="效率" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBlock Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{StaticResource PrimaryBrush}" FontFamily="{StaticResource FontMono}" FontSize="20" FontWeight="Bold"
Text="{Binding Current.EfficiencyPct, Converter={StaticResource NumberOrDash}, ConverterParameter=F3}" />
Text="{Binding Current.EfficiencyPct, Converter={StaticResource NumberOrDash}, ConverterParameter=F1}" />
<TextBlock Grid.Column="2" Text="%" Margin="6,0,0,0" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" />
</Grid>
</StackPanel>
@ -236,7 +236,7 @@
</DataGridTextColumn>
<DataGridTextColumn Header="效率(%)" Width="*">
<DataGridTextColumn.Binding>
<Binding Path="EfficiencyPct" Converter="{StaticResource NumberOrDash}" ConverterParameter="F3" />
<Binding Path="EfficiencyPct" Converter="{StaticResource NumberOrDash}" ConverterParameter="F1" />
</DataGridTextColumn.Binding>
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource MonoText}" />

View File

@ -90,7 +90,6 @@
MinWidth="90">
<ComboBoxItem Content="合格" Tag="{x:Static model:TestStatus.Pass}" />
<ComboBoxItem Content="不合格" Tag="{x:Static model:TestStatus.Fail}" />
<ComboBoxItem Content="已测量" Tag="{x:Static model:TestStatus.Measured}" />
<ComboBoxItem Content="未判定" Tag="{x:Static model:TestStatus.Untested}" />
</ComboBox>
</DataTemplate>

View File

@ -9,8 +9,8 @@
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="34" />
<RowDefinition Height="218" />
<RowDefinition Height="354" />
<RowDefinition Height="252" />
<RowDefinition Height="320" />
<RowDefinition Height="90" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
@ -28,15 +28,15 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="36" />
<RowDefinition Height="38" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Style="{StaticResource CardTitle}" Margin="0,0,0,6" Text="设备连接配置" />
<Grid Grid.Row="1" Margin="0,0,0,3">
<Grid Grid.Row="1" Margin="0,0,0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="150" />
@ -57,7 +57,7 @@
<TextBlock Grid.Column="7" Margin="10,0,0,0" Text="{Binding SspcStatus}" VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="2" Margin="0,0,0,3">
<Grid Grid.Row="2" Margin="0,0,0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="220" />
@ -76,7 +76,7 @@
<TextBlock Grid.Column="6" Margin="10,0,0,0" Text="{Binding PowerStatus}" VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="3" Margin="0,0,0,3">
<Grid Grid.Row="3" Margin="0,0,0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="220" />
@ -95,7 +95,7 @@
<TextBlock Grid.Column="6" Margin="10,0,0,0" Text="{Binding LoadStatus}" VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="4">
<Grid Grid.Row="4" Margin="0,0,0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="260" />
@ -116,37 +116,53 @@
<TextBlock Grid.Column="7" Margin="10,0,0,0" Text="{Binding DaqStatus}" VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="5">
<Grid Grid.Row="5" Margin="0,0,74,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90" />
<ColumnDefinition Width="48" />
<ColumnDefinition Width="60" />
<ColumnDefinition Width="58" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="85" />
<ColumnDefinition Width="58" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="85" />
<ColumnDefinition Width="70" />
<ColumnDefinition Width="70" />
<ColumnDefinition Width="70" />
<ColumnDefinition Width="70" />
<ColumnDefinition Width="239" />
<ColumnDefinition Width="237" />
<ColumnDefinition Width="67" />
<ColumnDefinition Width="66" />
<ColumnDefinition Width="71" />
<ColumnDefinition Width="93" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock FontWeight="SemiBold" VerticalAlignment="Center" Height="18" ><Run Language="zh-cn" Text="报警、急停"/></TextBlock>
<TextBlock Grid.Column="1" Text="设备ID" Style="{StaticResource FieldLabel}" Margin="8,0,4,0" Height="16" />
<TextBox Grid.Column="2" Text="{Binding Devices.Pci2312.DeviceId, UpdateSourceTrigger=PropertyChanged}" FontFamily="{StaticResource FontMono}" TextChanged="DraftChanged" Margin="0,4,0,4" />
<Border Grid.Column="3" Margin="8,0,4,0" Padding="8,0"
Background="{StaticResource NeutralSoftBrush}" CornerRadius="{StaticResource RadiusSm}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="74" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="PCI2312" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="设备ID" Style="{StaticResource FieldLabel}" Margin="8,0,4,0" />
<TextBox Grid.Column="2" Text="{Binding Devices.Pci2312.DeviceId, UpdateSourceTrigger=PropertyChanged}" FontFamily="{StaticResource FontMono}" TextChanged="DraftChanged" />
<TextBlock Grid.Column="3" Text="急停DI" Style="{StaticResource FieldLabel}" Margin="8,0,4,0" />
<ComboBox Grid.Column="4" ItemsSource="{Binding Pci2312Channels}" SelectedItem="{Binding Devices.Pci2312.EmergencyInputChannel}" SelectionChanged="DraftChanged" />
<CheckBox Grid.Column="5" Margin="10,0,0,0" Content="DI高有效" IsChecked="{Binding Devices.Pci2312.EmergencyInputActiveHigh}" Click="DraftChanged" />
<TextBlock Grid.Column="6" Text="报警DO" Style="{StaticResource FieldLabel}" Margin="8,0,4,0" />
<ComboBox Grid.Column="7" ItemsSource="{Binding Pci2312Channels}" SelectedItem="{Binding Devices.Pci2312.AlarmOutputChannel}" SelectionChanged="DraftChanged" />
<CheckBox Grid.Column="8" Margin="10,0,65,0" Content="DO高有效" IsChecked="{Binding Devices.Pci2312.AlarmOutputActiveHigh}" Click="DraftChanged" Grid.ColumnSpan="2" />
<CheckBox Grid.Column="9" Margin="10,0,0,0" Content="启用" IsChecked="{Binding Devices.Pci2312.Enabled}" Click="DraftChanged" />
<CheckBox Grid.Column="10" Margin="10,0,0,0" Content="Mock" IsChecked="{Binding Devices.Pci2312.UseMock}" Click="DraftChanged" />
<CheckBox Grid.Column="11" Margin="10,0,0,0" Content="自连" IsChecked="{Binding Devices.Pci2312.AutoConnect}" Click="DraftChanged" />
<Button Grid.Column="12" Margin="8,0,0,0" Content="测试" Command="{Binding TestPci2312Command}" />
<TextBlock Grid.Column="13" Margin="10,0,0,0" Text="{Binding Pci2312Status}" VerticalAlignment="Center" />
<TextBlock Text="急停" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Pci2312Channels}" SelectedItem="{Binding Devices.Pci2312.EmergencyInputChannel}" SelectionChanged="DraftChanged" />
<CheckBox Grid.Column="2" Margin="8,0,0,0" Content="DI高有效" IsChecked="{Binding Devices.Pci2312.EmergencyInputActiveHigh}" Click="DraftChanged" />
</Grid>
</Border>
<Border Grid.Column="4" Margin="4,0,4,0" Padding="8,0"
Background="{StaticResource NeutralSoftBrush}" CornerRadius="{StaticResource RadiusSm}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="74" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="报警" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Pci2312Channels}" SelectedItem="{Binding Devices.Pci2312.AlarmOutputChannel}" SelectionChanged="DraftChanged" />
<CheckBox Grid.Column="2" Margin="8,0,0,0" Content="DO高有效" IsChecked="{Binding Devices.Pci2312.AlarmOutputActiveHigh}" Click="DraftChanged" />
</Grid>
</Border>
<CheckBox Grid.Column="5" Margin="10,0,0,0" Content="启用" IsChecked="{Binding Devices.Pci2312.Enabled}" Click="DraftChanged" />
<CheckBox Grid.Column="6" Margin="10,0,0,0" Content="Mock" IsChecked="{Binding Devices.Pci2312.UseMock}" Click="DraftChanged" />
<CheckBox Grid.Column="7" Margin="10,0,0,0" Content="自连" IsChecked="{Binding Devices.Pci2312.AutoConnect}" Click="DraftChanged" />
<Button Grid.Column="8" Margin="8,4,0,4" Content="测试" Command="{Binding TestPci2312Command}" />
<TextBlock Grid.Column="9" Margin="10,0,0,0" Text="{Binding Pci2312Status}" VerticalAlignment="Center" Height="18" />
</Grid>
</Grid>
</Border>

BIN
SSPCTester.zip Normal file

Binary file not shown.

View File

@ -34,6 +34,28 @@ param(
)
$ErrorActionPreference = 'Stop'
function Initialize-ConsoleUtf8 {
try {
if ($IsWindows) {
& chcp.com 65001 > $null
}
} catch {
Write-Warning "无法切换到 UTF-8 代码页,将继续使用当前代码页。"
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = $utf8NoBom
[Console]::OutputEncoding = $utf8NoBom
Set-Variable -Name OutputEncoding -Value $utf8NoBom -Scope Global
if ([string]::IsNullOrWhiteSpace($env:DOTNET_CLI_UI_LANGUAGE)) {
$env:DOTNET_CLI_UI_LANGUAGE = 'zh-Hans'
}
}
Initialize-ConsoleUtf8
$root = $PSScriptRoot
Set-Location $root

22
run.ps1
View File

@ -19,6 +19,28 @@ param(
)
$ErrorActionPreference = 'Stop'
function Initialize-ConsoleUtf8 {
try {
if ($IsWindows) {
& chcp.com 65001 > $null
}
} catch {
Write-Warning "无法切换到 UTF-8 代码页,将继续使用当前代码页。"
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = $utf8NoBom
[Console]::OutputEncoding = $utf8NoBom
Set-Variable -Name OutputEncoding -Value $utf8NoBom -Scope Global
if ([string]::IsNullOrWhiteSpace($env:DOTNET_CLI_UI_LANGUAGE)) {
$env:DOTNET_CLI_UI_LANGUAGE = 'zh-Hans'
}
}
Initialize-ConsoleUtf8
$root = $PSScriptRoot
Set-Location $root