Compare commits

...

4 Commits

Author SHA1 Message Date
GukSang.Jin
1467313c80 更新 2026-03-26 19:06:51 +08:00
GukSang.Jin
baa53b690e 更新 2026-03-26 17:54:48 +08:00
GukSang.Jin
f5a97ab550 2026/0326更新 2026-03-26 16:15:04 +08:00
GukSang.Jin
42634e1747 更新3/26 2026-03-26 15:11:31 +08:00
14 changed files with 880 additions and 69 deletions

View File

@@ -2,6 +2,7 @@ using System.Windows;
using Cardiopulmonarybypasssystems.Services;
using Cardiopulmonarybypasssystems.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using OfficeOpenXml;
using QuestPDF.Infrastructure;
namespace Cardiopulmonarybypasssystems;
@@ -13,6 +14,7 @@ public partial class App : Application
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
QuestPDF.Settings.License = LicenseType.Community;
var services = new ServiceCollection();

View File

@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="EPPlus" Version="7.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="NModbus" Version="3.0.81" />
<PackageReference Include="QuestPDF" Version="2024.12.1" />

View File

@@ -93,10 +93,10 @@
<WrapPanel.Resources>
<Style TargetType="Button" BasedOn="{StaticResource HeroCompactButtonStyle}" />
</WrapPanel.Resources>
<Button MinWidth="92" MinHeight="36" Padding="12,7" Margin="0,0,8,8" Command="{Binding ToggleAcquisitionCommand}" Content="采集" Background="#FFFFFFFF" Foreground="{StaticResource HeaderBrush}" />
<Button MinWidth="92" MinHeight="36" Padding="12,7" Margin="0,0,8,8" Command="{Binding ToggleAcquisitionCommand}" Content="采集" Background="#FFFFFFFF" Foreground="{StaticResource HeaderBrush}" IsEnabled="{Binding CanModifySession}" />
<Button MinWidth="92" MinHeight="36" Padding="12,7" Margin="0,0,8,8" Command="{Binding AcknowledgeAlarmCommand}" Content="消警" Background="#33FFFFFF" />
<Button MinWidth="108" MinHeight="36" Padding="12,7" Margin="0,0,8,8" Command="{Binding CompleteDetectionCommand}" Content="完成检测" Background="#FFF0B145" />
<Button MinWidth="136" MinHeight="36" Padding="12,7" Margin="0,0,0,8" Command="{Binding ExportReportCommand}" Content="导出检查报告" Background="#FFEA7E3C" />
<Button MinWidth="108" MinHeight="36" Padding="12,7" Margin="0,0,8,8" Command="{Binding CompleteDetectionCommand}" Content="完成检测" Background="#FFF0B145" IsEnabled="{Binding CanModifySession}" />
<Button MinWidth="156" MinHeight="36" Padding="12,7" Margin="0,0,0,8" Command="{Binding ExportReportCommand}" Content="导出检查报告Excel" Background="#FFEA7E3C" IsEnabled="{Binding CanExportReport}" ToolTip="导出为 Excel.xlsx检查报告" />
</WrapPanel>
<TextBlock HorizontalAlignment="Right"
Foreground="#EFFAFC"
@@ -421,7 +421,7 @@
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="2" Margin="0,0,0,4">
<StackPanel Grid.Column="2" Margin="0,0,0,4" IsEnabled="{Binding CanModifySession}">
<TextBlock Style="{StaticResource CaptionStyle}" Text="填写说明" />
<TextBlock Margin="0,0,0,6" Foreground="{StaticResource MutedTextBrush}" FontSize="13" Text="{Binding RealtimeMeasurementHint}" TextWrapping="Wrap" />
<Border Margin="0,0,0,8" Padding="12" Background="#FFF8F4EA" CornerRadius="14">
@@ -595,7 +595,7 @@
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Style="{StaticResource CaptionStyle}" Text="4.3.4 共用试验记录要点" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="共用试验记录要点" />
<TextBlock FontSize="13" Text="{Binding HemolysisStandardSummary}" TextWrapping="Wrap" />
<TextBlock Margin="0,6,0,0" FontSize="13" Text="{Binding HemolysisTemplateGuidance}" TextWrapping="Wrap" />
<Border Margin="0,8,0,0" Padding="10" Background="#FFFFFAF6" CornerRadius="12" BorderBrush="#FFE8D9CC" BorderThickness="1">
@@ -874,7 +874,7 @@
</WrapPanel.Resources>
<Button Command="{Binding SelectPreviousItemCommand}" Content="上一项" Background="#FF6B8791" />
<Button Command="{Binding SelectNextItemCommand}" Content="下一项" Background="#FF6B8791" />
<Button Command="{Binding ApplyResultCommand}" Content="保存并更新状态" Background="#FF2B8F6A" />
<Button Command="{Binding ApplyResultCommand}" Content="保存并更新状态" Background="#FF2B8F6A" IsEnabled="{Binding CanModifySession}" />
</WrapPanel>
</Grid>
</StackPanel>
@@ -960,7 +960,8 @@
Command="{Binding DataContext.TogglePumpControlCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Content="{Binding ActionText}"
Background="#FF4D8C72" />
Background="#FF4D8C72"
IsEnabled="{Binding DataContext.CanModifySession, RelativeSource={RelativeSource AncestorType=Window}}" />
</StackPanel>
</Border>
</DataTemplate>
@@ -980,7 +981,8 @@
Command="{Binding DataContext.ToggleValveControlCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Content="{Binding ActionText}"
Background="#FF4D8C72" />
Background="#FF4D8C72"
IsEnabled="{Binding DataContext.CanModifySession, RelativeSource={RelativeSource AncestorType=Window}}" />
</StackPanel>
</Border>
</DataTemplate>
@@ -1164,7 +1166,8 @@
FontSize="13"
Command="{Binding ClearTrendDataCommand}"
Content="清空曲线"
Background="#FF6B8791" />
Background="#FF6B8791"
IsEnabled="{Binding CanModifySession}" />
<TextBlock Style="{StaticResource SectionTitleStyle}" FontSize="18" Margin="0,0,0,6" Text="趋势图" />
</DockPanel>

View File

@@ -0,0 +1,36 @@
namespace Cardiopulmonarybypasssystems.Models;
public sealed class TelemetryChannelSnapshot
{
public required string Name { get; init; }
public required double Value { get; init; }
public required bool IsAvailable { get; init; }
}
public sealed class PumpControlSnapshot
{
public required string Key { get; init; }
public required bool IsRunning { get; init; }
public required double FlowValue { get; init; }
public required bool StateAvailable { get; init; }
public required bool FlowAvailable { get; init; }
}
public sealed class ValveControlSnapshot
{
public required string Key { get; init; }
public required bool IsOpen { get; init; }
public required bool StateAvailable { get; init; }
}
public sealed class TelemetryUpdateSnapshot
{
public required IReadOnlyList<TelemetryChannelSnapshot> Channels { get; init; }
public required IReadOnlyList<PumpControlSnapshot> PumpControls { get; init; }
public required IReadOnlyList<ValveControlSnapshot> ValveControls { get; init; }
public required IReadOnlyList<AlarmMessage> Alarms { get; init; }
public required bool IsLiveConnected { get; init; }
public required string EndpointDescription { get; init; }
public required DateTime? LastSuccessfulReadAt { get; init; }
public required string LastErrorMessage { get; init; }
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View File

@@ -0,0 +1,435 @@
using System.IO;
using System.Drawing;
using Cardiopulmonarybypasssystems.Models;
using OfficeOpenXml;
using OfficeOpenXml.Style;
namespace Cardiopulmonarybypasssystems.Services;
public sealed class ExcelReportDocument(
string pageTitle,
string batchNumber,
string currentStage,
string operatorName,
string reviewerName,
string approverName,
string complianceDisplay,
string deltaPressureDisplay,
string detectionSummary,
string configurationSummary,
DateTime exportTime,
IReadOnlyCollection<InspectionItem> inspectionItems,
IReadOnlyCollection<TraceEvent> traceEvents,
IReadOnlyCollection<KinkResistancePointEntry> kinkResistanceEntries,
string kinkResistanceFlowPointDisplay,
string kinkResistanceMandrelDiameterDisplay,
IReadOnlyCollection<PressureDropPointEntry> pressureDropEntries,
string pressureDropLimitDisplay,
string antiCollapseBaselineDisplay,
string antiCollapseComparisonDisplay,
string antiCollapseCurrentNegativePressure,
string antiCollapseCurrentFlowDisplay,
string antiCollapseAllowedIncreaseRateDisplay,
IReadOnlyCollection<RecirculationPointEntry> recirculationEntries,
string recirculationLimitDisplay)
{
private const int TotalColumns = 10;
private static readonly Color BorderColor = ColorTranslator.FromHtml("#A0AEC0");
private static readonly Color SectionFillColor = ColorTranslator.FromHtml("#DDEFEA");
private static readonly Color HeaderFillColor = ColorTranslator.FromHtml("#CFE8E3");
private static readonly Color SummaryFillColor = ColorTranslator.FromHtml("#D9F3EE");
private static readonly Color MetaLabelFillColor = ColorTranslator.FromHtml("#F7FAFC");
private static readonly Color MetaLabelFontColor = ColorTranslator.FromHtml("#4A5568");
public void GenerateExcel(string filePath)
{
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("检查报告");
package.Workbook.Properties.Title = pageTitle;
ConfigureWorksheet(worksheet);
var row = 1;
row = ComposeTitle(worksheet, row);
row = ComposeMeta(worksheet, row + 2);
row = ComposeSummary(worksheet, row + 2);
row = ComposeKinkResistanceSection(worksheet, row + 2);
row = ComposePressureDropSection(worksheet, row + 2);
row = ComposeAntiCollapseSection(worksheet, row + 2);
row = ComposeRecirculationSection(worksheet, row + 2);
row = ComposeInspectionTable(worksheet, row + 2);
row = ComposeTraceEvents(worksheet, row + 2);
ComposeSignatures(worksheet, row + 2);
package.SaveAs(new FileInfo(filePath));
}
private static void ConfigureWorksheet(ExcelWorksheet worksheet)
{
worksheet.View.ShowGridLines = false;
worksheet.PrinterSettings.Orientation = eOrientation.Landscape;
worksheet.PrinterSettings.PaperSize = ePaperSize.A4;
worksheet.PrinterSettings.FitToPage = true;
worksheet.PrinterSettings.FitToWidth = 1;
worksheet.PrinterSettings.FitToHeight = 0;
worksheet.PrinterSettings.LeftMargin = 0.35m;
worksheet.PrinterSettings.RightMargin = 0.35m;
worksheet.PrinterSettings.TopMargin = 0.4m;
worksheet.PrinterSettings.BottomMargin = 0.4m;
worksheet.Cells.Style.Font.Name = "Microsoft YaHei";
worksheet.Cells.Style.Font.Size = 9;
worksheet.Cells.Style.WrapText = true;
worksheet.Cells.Style.VerticalAlignment = ExcelVerticalAlignment.Top;
var widths = new[] { 12d, 14d, 22d, 24d, 20d, 16d, 10d, 12d, 16d, 18d };
for (var index = 0; index < widths.Length; index++)
{
worksheet.Column(index + 1).Width = widths[index];
}
}
private int ComposeTitle(ExcelWorksheet worksheet, int row)
{
var range = worksheet.Cells[row, 1, row, TotalColumns];
range.Merge = true;
range.Value = ValueOrFallback(pageTitle);
range.Style.Font.Size = 18;
range.Style.Font.Bold = true;
range.Style.Font.Color.SetColor(ColorTranslator.FromHtml("#0F766E"));
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
worksheet.Row(row).Height = 26;
return row;
}
private int ComposeMeta(ExcelWorksheet worksheet, int startRow)
{
var items = new (string Label, string Value, int ColStart, int ColEnd)[]
{
("批次", batchNumber, 1, 2),
("阶段", currentStage, 3, 4),
("测试员", operatorName, 5, 6),
("复核人", reviewerName, 7, 8),
("批准人", approverName, 1, 2),
("导出时间", exportTime.ToString("yyyy-MM-dd HH:mm:ss"), 3, 4),
("合格率", complianceDisplay, 5, 6),
("压差", deltaPressureDisplay, 7, 8)
};
for (var index = 0; index < items.Length; index++)
{
var rowOffset = index < 4 ? 0 : 2;
WriteMetaCell(
worksheet,
startRow + rowOffset,
startRow + rowOffset + 1,
items[index].ColStart,
items[index].ColEnd,
items[index].Label,
items[index].Value);
}
return startRow + 3;
}
private int ComposeSummary(ExcelWorksheet worksheet, int startRow)
{
var titleRange = worksheet.Cells[startRow, 1, startRow, TotalColumns];
titleRange.Merge = true;
titleRange.Value = "检测结论";
titleRange.Style.Font.Bold = true;
titleRange.Style.Font.Size = 11;
titleRange.Style.Fill.PatternType = ExcelFillStyle.Solid;
titleRange.Style.Fill.BackgroundColor.SetColor(SummaryFillColor);
ApplyBorder(titleRange);
var summaryRange = worksheet.Cells[startRow + 1, 1, startRow + 1, TotalColumns];
summaryRange.Merge = true;
summaryRange.Value = ValueOrFallback(detectionSummary);
ApplyBodyCell(summaryRange);
var configRange = worksheet.Cells[startRow + 2, 1, startRow + 2, TotalColumns];
configRange.Merge = true;
configRange.Value = $"产品配置:{ValueOrFallback(configurationSummary)}";
ApplyBodyCell(configRange);
return startRow + 2;
}
private int ComposeKinkResistanceSection(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "专项记录:抗扭结抗性");
row = WriteMergedBodyRow(worksheet, row + 1, kinkResistanceFlowPointDisplay);
row = WriteMergedBodyRow(worksheet, row + 1, kinkResistanceMandrelDiameterDisplay);
var headers = new[] { "流量点", "目标流量", "L0", "L1", "降幅", "L0时间", "L1时间" };
var rows = kinkResistanceEntries.Select(entry => new[]
{
entry.Label,
$"{entry.TargetFlow:F2} L/min",
entry.HasBaseline ? $"{entry.BaselineFlow:F2} L/min" : "-",
entry.HasKinked ? $"{entry.KinkedFlow:F2} L/min" : "-",
entry.FlowDropRateText,
entry.BaselineCapturedAtText,
entry.KinkedCapturedAtText
});
return WriteTable(worksheet, row + 1, headers, rows);
}
private int ComposePressureDropSection(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "专项记录:压力降");
row = WriteMergedBodyRow(worksheet, row + 1, $"声明限值:{pressureDropLimitDisplay}");
var headers = new[] { "流量点", "目标流量", "实际主泵", "近端压力", "远端压力", "ΔP" };
var rows = pressureDropEntries
.OrderBy(item => item.TargetFlow)
.Select(entry => new[]
{
entry.Label,
$"{entry.TargetFlow:F2} L/min",
entry.HasSample ? $"{entry.ActualPumpFlow:F2} L/min" : "-",
entry.HasSample ? $"{entry.ProximalPressure:F1} mmHg" : "-",
entry.HasSample ? $"{entry.DistalPressure:F1} mmHg" : "-",
entry.HasSample ? $"{entry.DeltaPressure:F1} mmHg" : "-"
});
return WriteTable(worksheet, row + 1, headers, rows);
}
private int ComposeAntiCollapseSection(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "专项记录:抗塌陷");
var entries = new (string Label, string Value)[]
{
("基线", antiCollapseBaselineDisplay),
("允许增幅", antiCollapseAllowedIncreaseRateDisplay),
("负压状态", antiCollapseCurrentNegativePressure),
("当前流量", antiCollapseCurrentFlowDisplay),
("比较结果", antiCollapseComparisonDisplay)
};
foreach (var entry in entries)
{
row++;
WriteKeyValueRow(worksheet, row, entry.Label, entry.Value);
}
return row;
}
private int ComposeRecirculationSection(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "专项记录:再循环");
row = WriteMergedBodyRow(worksheet, row + 1, recirculationLimitDisplay);
var headers = new[] { "流量点", "目标流量", "实际主泵", "C1(D)", "C2(C)", "R%", "在线参考", "时间" };
var rows = recirculationEntries
.OrderBy(item => item.TargetFlow)
.Select(entry => new[]
{
entry.Label,
$"{entry.TargetFlow:F2} L/min",
entry.HasSample ? $"{entry.ActualPumpFlow:F2} L/min" : "-",
entry.ConcentrationC1?.ToString("F0") ?? "-",
entry.ConcentrationC2?.ToString("F0") ?? "-",
entry.RecirculationResultText,
entry.HasSample ? $"{entry.OnlineEstimate:F1}%" : "-",
entry.SampledAtText
});
return WriteTable(worksheet, row + 1, headers, rows);
}
private int ComposeInspectionTable(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "项目记录");
var headers = new[] { "类别", "测试内容", "判定要求", "检测方法", "记录要点", "结果", "状态", "记录人", "记录时间", "备注" };
var rows = inspectionItems.Select(item => new[]
{
item.Category,
item.Item,
item.AcceptanceCriteria,
item.TestMethod,
item.RecordFocus,
item.Measured,
item.StatusText,
item.RecordedBy,
item.RecordedAtText,
item.Notes
});
return WriteTable(worksheet, row + 1, headers, rows);
}
private int ComposeTraceEvents(ExcelWorksheet worksheet, int startRow)
{
var row = WriteSectionTitle(worksheet, startRow, "追溯事件");
var orderedTraceEvents = traceEvents.OrderBy(item => item.Timestamp).ToList();
if (orderedTraceEvents.Count == 0)
{
return WriteMergedBodyRow(worksheet, row + 1, "-");
}
foreach (var trace in orderedTraceEvents)
{
row++;
var range = worksheet.Cells[row, 1, row, TotalColumns];
range.Merge = true;
range.Value = $"{trace.Timestamp:yyyy-MM-dd HH:mm:ss} {ValueOrFallback(trace.Stage)} {ValueOrFallback(trace.Detail)} ({ValueOrFallback(trace.Operator)})";
range.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
range.Style.Border.Bottom.Color.SetColor(ColorTranslator.FromHtml("#CBD5E0"));
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
range.Style.VerticalAlignment = ExcelVerticalAlignment.Top;
}
return row;
}
private void ComposeSignatures(ExcelWorksheet worksheet, int startRow)
{
WriteSignatureCell(worksheet, startRow, 1, 3, "测试员", operatorName);
WriteSignatureCell(worksheet, startRow, 4, 6, "复核人", reviewerName);
WriteSignatureCell(worksheet, startRow, 7, 10, "批准人", approverName);
}
private static void WriteMetaCell(
ExcelWorksheet worksheet,
int labelRow,
int valueRow,
int startColumn,
int endColumn,
string label,
string value)
{
var labelRange = worksheet.Cells[labelRow, startColumn, labelRow, endColumn];
labelRange.Merge = true;
labelRange.Value = label;
labelRange.Style.Font.Size = 8;
labelRange.Style.Font.Color.SetColor(MetaLabelFontColor);
labelRange.Style.Fill.PatternType = ExcelFillStyle.Solid;
labelRange.Style.Fill.BackgroundColor.SetColor(MetaLabelFillColor);
ApplyBorder(labelRange);
var valueRange = worksheet.Cells[valueRow, startColumn, valueRow, endColumn];
valueRange.Merge = true;
valueRange.Value = ValueOrFallback(value);
valueRange.Style.Font.Bold = true;
ApplyBodyCell(valueRange);
}
private static int WriteSectionTitle(ExcelWorksheet worksheet, int row, string title)
{
var range = worksheet.Cells[row, 1, row, TotalColumns];
range.Merge = true;
range.Value = title;
range.Style.Font.Bold = true;
range.Style.Font.Size = 11;
range.Style.Fill.PatternType = ExcelFillStyle.Solid;
range.Style.Fill.BackgroundColor.SetColor(SectionFillColor);
ApplyBorder(range);
return row;
}
private static int WriteMergedBodyRow(ExcelWorksheet worksheet, int row, string value)
{
var range = worksheet.Cells[row, 1, row, TotalColumns];
range.Merge = true;
range.Value = ValueOrFallback(value);
ApplyBodyCell(range);
return row;
}
private static void WriteKeyValueRow(ExcelWorksheet worksheet, int row, string label, string value)
{
var labelRange = worksheet.Cells[row, 1, row, 2];
labelRange.Merge = true;
labelRange.Value = label;
labelRange.Style.Font.Bold = true;
labelRange.Style.Fill.PatternType = ExcelFillStyle.Solid;
labelRange.Style.Fill.BackgroundColor.SetColor(HeaderFillColor);
ApplyBorder(labelRange);
var valueRange = worksheet.Cells[row, 3, row, TotalColumns];
valueRange.Merge = true;
valueRange.Value = ValueOrFallback(value);
ApplyBodyCell(valueRange);
}
private static int WriteTable(
ExcelWorksheet worksheet,
int startRow,
IReadOnlyList<string> headers,
IEnumerable<string[]> rows)
{
for (var index = 0; index < headers.Count; index++)
{
var cell = worksheet.Cells[startRow, index + 1];
cell.Value = headers[index];
cell.Style.Font.Bold = true;
cell.Style.Fill.PatternType = ExcelFillStyle.Solid;
cell.Style.Fill.BackgroundColor.SetColor(HeaderFillColor);
cell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
cell.Style.VerticalAlignment = ExcelVerticalAlignment.Center;
ApplyBorder(cell);
}
var currentRow = startRow;
foreach (var row in rows)
{
currentRow++;
for (var index = 0; index < headers.Count; index++)
{
var cell = worksheet.Cells[currentRow, index + 1];
cell.Value = ValueOrFallback(row[index]);
ApplyBodyCell(cell);
}
}
return currentRow;
}
private static void WriteSignatureCell(
ExcelWorksheet worksheet,
int startRow,
int startColumn,
int endColumn,
string label,
string value)
{
var lineRange = worksheet.Cells[startRow, startColumn, startRow, endColumn];
lineRange.Merge = true;
lineRange.Style.Border.Top.Style = ExcelBorderStyle.Thin;
lineRange.Style.Border.Top.Color.SetColor(Color.Black);
var textRange = worksheet.Cells[startRow + 1, startColumn, startRow + 1, endColumn];
textRange.Merge = true;
textRange.Value = $"{label}{ValueOrFallback(value)}";
textRange.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
}
private static void ApplyBodyCell(ExcelRange range)
{
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
range.Style.VerticalAlignment = ExcelVerticalAlignment.Top;
ApplyBorder(range);
}
private static void ApplyBorder(ExcelRange range)
{
range.Style.Border.Top.Style = ExcelBorderStyle.Thin;
range.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
range.Style.Border.Left.Style = ExcelBorderStyle.Thin;
range.Style.Border.Right.Style = ExcelBorderStyle.Thin;
range.Style.Border.Top.Color.SetColor(BorderColor);
range.Style.Border.Bottom.Color.SetColor(BorderColor);
range.Style.Border.Left.Color.SetColor(BorderColor);
range.Style.Border.Right.Color.SetColor(BorderColor);
}
private static string ValueOrFallback(string? value) =>
string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
}

View File

@@ -11,7 +11,7 @@ public interface IModbusTelemetryService
IReadOnlyList<DeviceChannel> GetChannels();
IReadOnlyList<PumpControlChannel> GetPumpControls();
IReadOnlyList<ValveControlChannel> GetValveControls();
IReadOnlyList<AlarmMessage> UpdateChannels();
TelemetryUpdateSnapshot UpdateChannels();
void SetPumpRunning(string pumpKey, bool isRunning);
void SetValveOpen(string valveKey, bool isOpen);
}

View File

@@ -126,7 +126,7 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
return _valveControls;
}
public IReadOnlyList<AlarmMessage> UpdateChannels()
public TelemetryUpdateSnapshot UpdateChannels()
{
EnsureConnectionScheduled();
@@ -136,7 +136,7 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
TryReadPressureChannels(liveReadSucceeded);
SimulateAuxiliaryChannels(liveReadSucceeded);
SyncDerivedChannels();
return BuildAlarms();
return BuildSnapshot(BuildAlarms());
}
}
@@ -438,6 +438,42 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
return alarms;
}
private TelemetryUpdateSnapshot BuildSnapshot(IReadOnlyList<AlarmMessage> alarms) => new()
{
Channels = _channels.Select(channel => new TelemetryChannelSnapshot
{
Name = channel.Name,
Value = channel.Value,
IsAvailable = true
}).ToList(),
PumpControls = _pumpControls.Select(pump => new PumpControlSnapshot
{
Key = pump.Key,
IsRunning = pump.IsRunning,
FlowValue = pump.FlowValue,
StateAvailable = true,
FlowAvailable = pump.FlowAddress.HasValue
}).ToList(),
ValveControls = _valveControls.Select(valve => new ValveControlSnapshot
{
Key = valve.Key,
IsOpen = valve.IsOpen,
StateAvailable = true
}).ToList(),
Alarms = alarms
.Select(alarm => new AlarmMessage
{
Timestamp = alarm.Timestamp,
Level = alarm.Level,
Message = alarm.Message
})
.ToList(),
IsLiveConnected = _master is not null && _tcpClient?.Connected == true,
EndpointDescription = EndpointDescription,
LastSuccessfulReadAt = null,
LastErrorMessage = LastErrorMessage
};
private void SetSmoothedValue(string channelName, double nextValue)
{
var channel = Channel(channelName);

View File

@@ -155,7 +155,7 @@ public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposabl
return _valveControls;
}
public IReadOnlyList<AlarmMessage> UpdateChannels()
public TelemetryUpdateSnapshot UpdateChannels()
{
EnsureConnectionScheduled();
@@ -172,7 +172,7 @@ public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposabl
: "实时数据正常";
}
return BuildAlarms();
return BuildSnapshot(BuildAlarms());
}
}
@@ -456,6 +456,42 @@ public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposabl
return alarms;
}
private TelemetryUpdateSnapshot BuildSnapshot(IReadOnlyList<AlarmMessage> alarms) => new()
{
Channels = _channels.Select(channel => new TelemetryChannelSnapshot
{
Name = channel.Name,
Value = channel.Value,
IsAvailable = channel.IsAvailable
}).ToList(),
PumpControls = _pumpControls.Select(pump => new PumpControlSnapshot
{
Key = pump.Key,
IsRunning = pump.IsRunning,
FlowValue = pump.FlowValue,
StateAvailable = pump.StateAvailable,
FlowAvailable = pump.FlowAvailable
}).ToList(),
ValveControls = _valveControls.Select(valve => new ValveControlSnapshot
{
Key = valve.Key,
IsOpen = valve.IsOpen,
StateAvailable = valve.StateAvailable
}).ToList(),
Alarms = alarms
.Select(alarm => new AlarmMessage
{
Timestamp = alarm.Timestamp,
Level = alarm.Level,
Message = alarm.Message
})
.ToList(),
IsLiveConnected = _master is not null && _tcpClient?.Connected == true,
EndpointDescription = EndpointDescription,
LastSuccessfulReadAt = _lastSuccessfulReadAt,
LastErrorMessage = _lastErrorMessage
};
private void ApplyUnavailableDeviceState()
{
foreach (var pump in _pumpControls)

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows.Data;
using System.Windows.Threading;
using Cardiopulmonarybypasssystems.Models;
@@ -32,6 +33,12 @@ public partial class MainViewModel : ObservableObject, IDisposable
private string _lastAutoHemolysisResult = string.Empty;
private string _lastAutoHemolysisNote = string.Empty;
private bool _suppressLimitSettingsSave;
private bool _telemetryRefreshInProgress;
private bool _isTelemetryOnline;
private string _plcEndpointDisplay = string.Empty;
private DateTime? _telemetryLastUpdatedAt;
private string _telemetryStatusDetail = string.Empty;
private string _lastTelemetryRefreshFailureMessage = string.Empty;
[ObservableProperty]
private string pageTitle = "心肺转流系统一次性使用动静脉插管检测";
@@ -134,12 +141,16 @@ public partial class MainViewModel : ObservableObject, IDisposable
public MainViewModel(IStandardRepository repository, IModbusTelemetryService telemetryService)
{
_telemetryService = telemetryService;
_isTelemetryOnline = telemetryService.IsLiveConnected;
_plcEndpointDisplay = telemetryService.EndpointDescription;
_telemetryLastUpdatedAt = telemetryService.LastSuccessfulReadAt;
_telemetryStatusDetail = telemetryService.LastErrorMessage;
InspectionItems = new ObservableCollection<InspectionItem>(repository.GetInspectionItems());
FilteredItemsView = CollectionViewSource.GetDefaultView(InspectionItems);
FilteredItemsView.Filter = MatchesFilteredItem;
Channels = new ObservableCollection<DeviceChannel>(telemetryService.GetChannels());
PumpControls = new ObservableCollection<PumpControlChannel>(telemetryService.GetPumpControls());
ValveControls = new ObservableCollection<ValveControlChannel>(telemetryService.GetValveControls());
Channels = new ObservableCollection<DeviceChannel>(telemetryService.GetChannels().Select(CloneDeviceChannel));
PumpControls = new ObservableCollection<PumpControlChannel>(telemetryService.GetPumpControls().Select(ClonePumpControlChannel));
ValveControls = new ObservableCollection<ValveControlChannel>(telemetryService.GetValveControls().Select(CloneValveControlChannel));
TraceEvents = new ObservableCollection<TraceEvent>(repository.GetInitialTraceEvents());
AlarmMessages = new ObservableCollection<AlarmMessage>();
ResultStatusOptions = new ObservableCollection<string>(["待检", "合格", "预警", "不合格"]);
@@ -216,6 +227,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_timer.Tick += OnTelemetryTimerTick;
_timer.Start();
_ = RefreshTelemetryAsync();
}
public ObservableCollection<InspectionItem> InspectionItems { get; }
@@ -253,17 +265,19 @@ public partial class MainViewModel : ObservableObject, IDisposable
public bool HasSelectedItem => SelectedItem is not null;
public IEnumerable<DeviceChannel> FlowSensorChannels => Channels.Where(IsFlowSensorChannel);
public IEnumerable<DeviceChannel> OtherChannels => Channels.Where(channel => !IsFlowSensorChannel(channel));
public bool IsTelemetryOnline => _telemetryService.IsLiveConnected;
public string PlcEndpointDisplay => _telemetryService.EndpointDescription;
public string TelemetryLastUpdatedDisplay => _telemetryService.LastSuccessfulReadAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "未收到实时数据";
public string TelemetryStatusDetail => _telemetryService.LastErrorMessage;
public bool IsTelemetryOnline => _isTelemetryOnline;
public string PlcEndpointDisplay => _plcEndpointDisplay;
public string TelemetryLastUpdatedDisplay => _telemetryLastUpdatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "未收到实时数据";
public string TelemetryStatusDetail => _telemetryStatusDetail;
public string TelemetryAvailabilityDisplay => $"{Channels.Count(channel => channel.IsAvailable)}/{Channels.Count} 路已接入";
public string AlarmSummaryDisplay => AlarmMessages.Count == 0 ? "无实时告警" : $"{AlarmMessages.Count} 条实时告警";
public string TelemetryCoverageDisplay => "已映射:泵状态、阀状态、流量寄存器、近端/远端压力;未映射:负压、温度、游离 Hb、白细胞减少率。";
public string ComplianceDisplay => $"{ComplianceRate:F0}%";
public string DeltaPressureDisplay => HasChannelTelemetry("近端压力", "远端压力") ? $"{DeltaPressure:F1} mmHg" : "--";
public string ExportStateText => DetectionCompleted ? "检测已完成,可导出检查报告" : "检测进行中,完成后可导出检查报告";
public string ExportStateText => DetectionCompleted ? "检测已完成,可导出 Excel 检查报告" : "检测进行中,完成后可导出 Excel 检查报告";
public bool CanExportReport => DetectionCompleted;
public bool CanModifySession => !DetectionCompleted;
public string SelectedItemTitle => SelectedItem?.Item ?? "未选择项目";
public string SelectedItemStatusText => SelectedItem?.StatusText ?? "待检";
public string RealtimeRecirculationDisplay => ChannelDisplay("再循环率", "F1", "%");
@@ -442,7 +456,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
partial void OnComplianceRateChanged(double value) => OnPropertyChanged(nameof(ComplianceDisplay));
partial void OnDeltaPressureChanged(double value) => OnPropertyChanged(nameof(DeltaPressureDisplay));
partial void OnDetectionCompletedChanged(bool value) => OnPropertyChanged(nameof(ExportStateText));
partial void OnDetectionCompletedChanged(bool value)
{
UpdateDetectionSummary();
OnPropertyChanged(nameof(ExportStateText));
OnPropertyChanged(nameof(CanExportReport));
OnPropertyChanged(nameof(CanModifySession));
}
partial void OnProductModelChanged(string value) => UpdateAndPersistLimitSettings();
partial void OnApplicablePopulationChanged(string value) => UpdateAndPersistLimitSettings();
partial void OnRatedMaxFlowChanged(double value) => UpdateAndPersistLimitSettings();
@@ -565,6 +585,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void TogglePumpControl(PumpControlChannel? pump)
{
if (!EnsureSessionEditable("泵控"))
{
return;
}
if (pump is null)
{
return;
@@ -572,16 +597,21 @@ public partial class MainViewModel : ObservableObject, IDisposable
var nextState = !pump.IsRunning;
_telemetryService.SetPumpRunning(pump.Key, nextState);
LatestAction = _telemetryService.IsLiveConnected
LatestAction = IsTelemetryOnline
? $"{pump.Name} 已发送{(nextState ? "" : "")}指令。"
: $"{pump.Name} 指令未下发PLC 当前离线。";
TraceEvents.Insert(0, NewTrace("泵控", $"{pump.Name} => {(nextState ? "" : "")}"));
RefreshTelemetry();
_ = RefreshTelemetryAsync();
}
[RelayCommand]
private void ToggleValveControl(ValveControlChannel? valve)
{
if (!EnsureSessionEditable("阀控"))
{
return;
}
if (valve is null)
{
return;
@@ -589,16 +619,21 @@ public partial class MainViewModel : ObservableObject, IDisposable
var nextState = !valve.IsOpen;
_telemetryService.SetValveOpen(valve.Key, nextState);
LatestAction = _telemetryService.IsLiveConnected
LatestAction = IsTelemetryOnline
? $"{valve.Name} 已发送{(nextState ? "" : "")}指令。"
: $"{valve.Name} 指令未下发PLC 当前离线。";
TraceEvents.Insert(0, NewTrace("阀控", $"{valve.Name} => {(nextState ? "" : "")}"));
RefreshTelemetry();
_ = RefreshTelemetryAsync();
}
[RelayCommand]
private void ClearTrendData()
{
if (!EnsureSessionEditable("趋势清空"))
{
return;
}
ClearTrendSeries(
ProximalPressureTrendValues,
DistalPressureTrendValues,
@@ -641,6 +676,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void CaptureAntiCollapseBaseline()
{
if (!EnsureSessionEditable("抗塌陷基线采集"))
{
return;
}
if (!IsAntiCollapseSelected)
{
LatestAction = "当前选择的不是抗塌陷项目。";
@@ -674,6 +714,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void CaptureAntiCollapseComparison()
{
if (!EnsureSessionEditable("抗塌陷比较采集"))
{
return;
}
if (!IsAntiCollapseSelected)
{
LatestAction = "当前选择的不是抗塌陷项目。";
@@ -721,6 +766,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void ToggleAcquisition()
{
if (!EnsureSessionEditable("采集控制"))
{
return;
}
AcquisitionRunning = !AcquisitionRunning;
RefreshDeviceStatus();
LatestAction = AcquisitionRunning ? "继续采集实时数据,供检测参考。" : "已暂停实时采集。";
@@ -774,6 +824,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void ApplyResult()
{
if (!EnsureSessionEditable("结果填写"))
{
return;
}
if (SelectedItem is null)
{
LatestAction = "请先选择项目。";
@@ -829,12 +884,18 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void CompleteDetection()
{
if (DetectionCompleted)
{
LatestAction = "当前检测任务已完成。";
return;
}
DetectionCompleted = true;
CurrentStage = "检测完成";
AcquisitionRunning = false;
_timer.Stop();
DeviceStatus = "采集停止";
LatestAction = PendingCount == 0 ? "检测完成,全部项目已填写,可导出检查报告。" : $"检测完成,但仍有 {PendingCount} 项待处理,请确认后导出检查报告。";
LatestAction = PendingCount == 0 ? "检测完成,全部项目已填写,可导出 Excel 检查报告。" : $"检测完成,但仍有 {PendingCount} 项待处理,请确认后导出 Excel 检查报告。";
TraceEvents.Insert(0, NewTrace("检测完成", "检测员结束本次检测任务"));
}
@@ -856,6 +917,64 @@ public partial class MainViewModel : ObservableObject, IDisposable
[RelayCommand]
private void ExportReport()
{
ExportReportExcel();
}
private void ExportReportExcel()
{
if (!CanExportReport)
{
LatestAction = "检测尚未完成,不能导出正式的 Excel 检查报告。";
return;
}
var outputDirectory = ResolveReportOutputDirectory();
var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
var batchToken = string.IsNullOrWhiteSpace(BatchNumber) ? "未填写批号" : SanitizeFileNameSegment(BatchNumber.Trim());
var excelPath = Path.Combine(outputDirectory, $"检查报告-{batchToken}-{timestamp}.xlsx");
try
{
var document = new ExcelReportDocument(
pageTitle: PageTitle,
batchNumber: BatchNumber,
currentStage: CurrentStage,
operatorName: OperatorName,
reviewerName: ReviewerName,
approverName: ApproverName,
complianceDisplay: ComplianceDisplay,
deltaPressureDisplay: DeltaPressureDisplay,
detectionSummary: DetectionSummary,
configurationSummary: ConfigurationSummary,
exportTime: DateTime.Now,
inspectionItems: InspectionItems.ToList(),
traceEvents: TraceEvents.ToList(),
kinkResistanceEntries: KinkResistanceEntries.ToList(),
kinkResistanceFlowPointDisplay: KinkResistanceFlowPointDisplay,
kinkResistanceMandrelDiameterDisplay: KinkResistanceMandrelDiameterDisplay,
pressureDropEntries: PressureDropEntries.ToList(),
pressureDropLimitDisplay: PressureDropLimitDisplay,
antiCollapseBaselineDisplay: AntiCollapseBaselineDisplay,
antiCollapseComparisonDisplay: AntiCollapseComparisonDisplay,
antiCollapseCurrentNegativePressure: NegativeAssistPressureDisplay,
antiCollapseCurrentFlowDisplay: PumpFlowDisplay,
antiCollapseAllowedIncreaseRateDisplay: $"{AntiCollapseAllowedIncreaseRate:F1}%",
recirculationEntries: RecirculationEntries.ToList(),
recirculationLimitDisplay: RecirculationLimitDisplay);
document.GenerateExcel(excelPath);
LatestAction = $"已导出 Excel 检查报告: {excelPath}";
TraceEvents.Insert(0, NewTrace("Excel检查报告导出", Path.GetFileName(excelPath)));
}
catch (Exception ex)
{
LatestAction = $"Excel 检查报告导出失败: {ex.Message}";
TraceEvents.Insert(0, NewTrace("Excel检查报告导出失败", ex.Message));
}
}
private void ExportReportPdf()
{
var outputDirectory = ResolveReportOutputDirectory();
var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
@@ -902,20 +1021,39 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
}
private void RefreshTelemetry()
private async Task RefreshTelemetryAsync()
{
var alarms = _telemetryService.UpdateChannels();
AlarmMessages.Clear();
foreach (var alarm in alarms.OrderByDescending(a => a.Timestamp))
if (_telemetryRefreshInProgress)
{
AlarmMessages.Add(alarm);
return;
}
RefreshTelemetryPanel();
RefreshDeviceStatus();
RefreshComputedState();
RefreshFilteredItemsView();
_telemetryRefreshInProgress = true;
try
{
var snapshot = await Task.Run(_telemetryService.UpdateChannels);
ApplyTelemetrySnapshot(snapshot);
_lastTelemetryRefreshFailureMessage = string.Empty;
RefreshTelemetryPanel();
RefreshDeviceStatus();
RefreshComputedState();
RefreshFilteredItemsView();
}
catch (Exception ex)
{
if (!string.Equals(_lastTelemetryRefreshFailureMessage, ex.Message, StringComparison.Ordinal))
{
LatestAction = $"实时数据刷新失败: {ex.Message}";
TraceEvents.Insert(0, NewTrace("实时数据刷新失败", ex.Message));
_lastTelemetryRefreshFailureMessage = ex.Message;
}
RefreshDeviceStatus();
}
finally
{
_telemetryRefreshInProgress = false;
}
}
private void RefreshTelemetryPanel()
@@ -987,9 +1125,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
return;
}
DeviceStatus = _telemetryService.IsLiveConnected
DeviceStatus = IsTelemetryOnline
? "PLC在线"
: _telemetryService.LastSuccessfulReadAt.HasValue
: _telemetryLastUpdatedAt.HasValue
? "PLC断连"
: "等待连接";
}
@@ -1000,6 +1138,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
WarningCount = InspectionItems.Count(r => r.Status == InspectionItemStatus.Warning || r.Status == InspectionItemStatus.Critical);
PendingCount = InspectionItems.Count(r => r.Status == InspectionItemStatus.Pending);
ComplianceRate = InspectionItems.Count == 0 ? 0 : QualifiedCount * 100d / InspectionItems.Count;
UpdateDetectionSummary();
}
private void CaptureTrendSamples()
@@ -1198,11 +1337,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
var antiCollapseItem = InspectionItems.FirstOrDefault(item => item.Clause == "4.3.2");
if (antiCollapseItem is not null)
{
if (HasChannelTelemetry("主泵流量", "近端压力", "远端压力"))
{
UpdateAntiCollapseBaseline();
}
if (SelectedItem == antiCollapseItem)
{
var suggestedResult = BuildAntiCollapseMeasuredText();
@@ -1584,6 +1718,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
private void CaptureKinkResistanceBaseline(string label)
{
if (!EnsureSessionEditable("抗扭结基线采样"))
{
return;
}
if (!IsKinkResistanceSelected)
{
LatestAction = "当前选择的不是抗扭结抗性项目。";
@@ -1612,6 +1751,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
private void CaptureKinkResistanceKinked(string label)
{
if (!EnsureSessionEditable("抗扭结比较采样"))
{
return;
}
if (!IsKinkResistanceSelected)
{
LatestAction = "当前选择的不是抗扭结抗性项目。";
@@ -1705,11 +1849,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
private string BuildPressureDropRecordNote()
{
var nearestLabel = ResolveNearestFlowPointLabel();
var nearestLimit = PressureDropLimit(nearestLabel);
var pressureStatusText = DeltaPressure switch
{
<= 20 => "实时判定合格",
<= 24 => "实时判定预警,建议补充中间流量点复测",
_ => "实时判定超限,建议复核回路、样品及声明范围"
var value when value <= nearestLimit => $"按当前接近 {nearestLabel} 流量点(限值 {nearestLimit:F1} mmHg实时判定合格",
var value when value <= nearestLimit * 1.1 => $"按当前接近 {nearestLabel} 流量点(限值 {nearestLimit:F1} mmHg实时判定预警,建议复测",
_ => $"按当前接近 {nearestLabel} 流量点(限值 {nearestLimit:F1} mmHg实时判定超限,建议复核回路、样品及声明范围"
};
return
@@ -1750,6 +1896,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
private void CapturePressureDropSample(string label)
{
if (!EnsureSessionEditable("压力降采样"))
{
return;
}
if (!IsPressureDropSelected)
{
LatestAction = "当前选择的不是压力降项目。";
@@ -1775,26 +1926,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(SelectedItemLiveDisplay));
}
private void UpdateAntiCollapseBaseline()
{
if (!HasChannelTelemetry("主泵流量", "近端压力", "远端压力"))
{
return;
}
var negativePressure = ChannelValueOrDefault("负压辅助引流");
if (_antiCollapseBaselinePressureDrop is null || negativePressure >= -1.0)
{
_antiCollapseBaselinePressureDrop = DeltaPressure;
_antiCollapseBaselineFlow = PumpFlow;
_antiCollapseBaselineCapturedAt = DateTime.Now;
OnPropertyChanged(nameof(HasAntiCollapseBaseline));
OnPropertyChanged(nameof(AntiCollapseBaselineDisplay));
OnPropertyChanged(nameof(AntiCollapseComparisonDisplay));
}
}
private string BuildAntiCollapseLiveDisplay()
{
if (!HasChannelTelemetry("主泵流量", "近端压力", "远端压力"))
@@ -1937,6 +2068,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
private void UpdateAndPersistLimitSettings()
{
if (DetectionCompleted)
{
return;
}
RefreshSpecializedJudgements();
if (_suppressLimitSettingsSave)
@@ -1977,6 +2113,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
catch
{
LatestAction = "制造商限值加载失败,已继续使用当前默认参数。";
TraceEvents.Insert(0, NewTrace("限值配置加载失败", LimitSettingsPath));
}
finally
{
@@ -2014,6 +2152,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
catch
{
LatestAction = "制造商限值保存失败,请检查本地目录权限。";
TraceEvents.Insert(0, NewTrace("限值配置保存失败", LimitSettingsPath));
}
}
@@ -2098,6 +2238,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
private void CaptureRecirculationSample(string label)
{
if (!EnsureSessionEditable("再循环采样"))
{
return;
}
if (!IsRecirculationSelected)
{
LatestAction = "当前选择的不是再循环项目。";
@@ -2268,6 +2413,110 @@ public partial class MainViewModel : ObservableObject, IDisposable
private double PressureDropFlowPoint(double ratio) =>
Math.Max(RatedMaxFlow, 0) * ratio;
private void ApplyTelemetrySnapshot(TelemetryUpdateSnapshot snapshot)
{
_isTelemetryOnline = snapshot.IsLiveConnected;
_plcEndpointDisplay = snapshot.EndpointDescription;
_telemetryLastUpdatedAt = snapshot.LastSuccessfulReadAt;
_telemetryStatusDetail = snapshot.LastErrorMessage;
foreach (var channelSnapshot in snapshot.Channels)
{
var channel = Channels.FirstOrDefault(item => item.Name == channelSnapshot.Name);
if (channel is null)
{
continue;
}
channel.Value = channelSnapshot.Value;
channel.IsAvailable = channelSnapshot.IsAvailable;
}
foreach (var pumpSnapshot in snapshot.PumpControls)
{
var pump = PumpControls.FirstOrDefault(item => item.Key == pumpSnapshot.Key);
if (pump is null)
{
continue;
}
pump.IsRunning = pumpSnapshot.IsRunning;
pump.FlowValue = pumpSnapshot.FlowValue;
pump.StateAvailable = pumpSnapshot.StateAvailable;
pump.FlowAvailable = pumpSnapshot.FlowAvailable;
}
foreach (var valveSnapshot in snapshot.ValveControls)
{
var valve = ValveControls.FirstOrDefault(item => item.Key == valveSnapshot.Key);
if (valve is null)
{
continue;
}
valve.IsOpen = valveSnapshot.IsOpen;
valve.StateAvailable = valveSnapshot.StateAvailable;
}
AlarmMessages.Clear();
foreach (var alarm in snapshot.Alarms.OrderByDescending(item => item.Timestamp))
{
AlarmMessages.Add(alarm);
}
}
private void UpdateDetectionSummary()
{
var completedCount = Math.Max(InspectionItems.Count - PendingCount, 0);
var completionText = DetectionCompleted ? "本次检测已完成" : "本次检测进行中";
var riskText = WarningCount == 0 ? "未出现预警或不合格项" : $"共 {WarningCount} 项预警/不合格需复核";
var pendingText = PendingCount == 0 ? "所有项目均已录入" : $"仍有 {PendingCount} 项待处理";
DetectionSummary =
$"{completionText},共 {InspectionItems.Count} 项,已完成 {completedCount} 项,合格 {QualifiedCount} 项,{riskText}{pendingText}。";
}
private static DeviceChannel CloneDeviceChannel(DeviceChannel source) => new()
{
Name = source.Name,
Unit = source.Unit,
Min = source.Min,
Max = source.Max,
Value = source.Value,
IsAvailable = source.IsAvailable
};
private static PumpControlChannel ClonePumpControlChannel(PumpControlChannel source) => new()
{
Key = source.Key,
Name = source.Name,
StartAddress = source.StartAddress,
FlowAddress = source.FlowAddress,
IsRunning = source.IsRunning,
FlowValue = source.FlowValue,
StateAvailable = source.StateAvailable,
FlowAvailable = source.FlowAvailable
};
private static ValveControlChannel CloneValveControlChannel(ValveControlChannel source) => new()
{
Key = source.Key,
Name = source.Name,
StartAddress = source.StartAddress,
IsOpen = source.IsOpen,
StateAvailable = source.StateAvailable
};
private bool EnsureSessionEditable(string actionName)
{
if (CanModifySession)
{
return true;
}
LatestAction = $"检测已完成,不能继续执行{actionName}。如需修改,请开始新的检测任务。";
return false;
}
public void Dispose()
{
_timer.Stop();
@@ -2301,7 +2550,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
}
private void OnTelemetryTimerTick(object? sender, EventArgs e) => RefreshTelemetry();
private async void OnTelemetryTimerTick(object? sender, EventArgs e) => await RefreshTelemetryAsync();
private static string ResolveReportOutputDirectory()
{

View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "9.0.312",
"rollForward": "disable"
}
}