Compare commits
4 Commits
bd356a0082
...
1467313c80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1467313c80 | ||
|
|
baa53b690e | ||
|
|
f5a97ab550 | ||
|
|
42634e1747 |
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
7
Cardiopulmonarybypasssystems/NuGet.Config
Normal file
7
Cardiopulmonarybypasssystems/NuGet.Config
Normal 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>
|
||||
435
Cardiopulmonarybypasssystems/Services/ExcelReportDocument.cs
Normal file
435
Cardiopulmonarybypasssystems/Services/ExcelReportDocument.cs
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
6
Cardiopulmonarybypasssystems/global.json
Normal file
6
Cardiopulmonarybypasssystems/global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.312",
|
||||
"rollForward": "disable"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user