报告XP.ReportEngine:新增 IReportService接口封装完整报告生成流程,支持外部类库直接调用;新增 ReportRequest、ReportServiceResult 请求/响应模型;新增引擎预热机制;PDF 生成改为 Task.Run 后台线程执行,解决进度窗口和主窗口卡死问题;完善文档。

This commit is contained in:
QI Mingxuan
2026-05-14 19:29:11 +08:00
parent 11c69d03fb
commit 29bc8576af
13 changed files with 1485 additions and 318 deletions
+46 -7
View File
@@ -17,18 +17,57 @@ XP.ReportEngine 使用 **Windows 系统自带字体** 生成 PDF,无需额外
## 后备机制 | Fallback Mechanism
ITextPdfRenderer 按以下顺序加载字体:
`ITextPdfRenderer` 按以下顺序加载字体:
1. 微软雅黑(`msyh.ttc`)→ 加载失败时尝试宋体(`simsun.ttc`
2. Arial`arial.ttf`)→ 加载失败时使用 iText 内置 Helvetica
3. 最终后备:Helvetica(不支持中文字符)
```
微软雅黑 (msyh.ttc)
↓ 加载失败
宋体 (simsun.ttc)
↓ 加载失败
_cjkFont = null
Arial (arial.ttf)
↓ 加载失败
_westernFont = null
最终后备: iText 内置 Helvetica(不支持中文字符)
```
语言选择逻辑:
- zh-CN / zh-TW → 优先使用微软雅黑
- en-US → 优先使用 Arial,后备微软雅黑(微软雅黑也支持西文)
## 字体子集化 | Font Subsetting
iText7 在 `document.Close()` 时执行字体子集化:
- 分析文档中实际使用的字符
- 从完整字体文件(微软雅黑约 15MB)中提取用到的字形子集
- 仅嵌入子集到 PDF 中
这使得最终 PDF 文件大小合理(通常 200KB-2MB),但 `Close()` 操作需要约 1-1.5 秒。
## 性能说明 | Performance Notes
| 阶段 | 首次耗时 | 后续耗时 | 说明 |
|------|---------|---------|------|
| 字体加载 | ~5-6s | 0ms | 首次需从磁盘读取 TTC 文件并解析 |
| 字体子集化 | ~1.2s | ~1.2s | 每次生成都需要,与字符数量相关 |
模块通过 `WarmUpAsync()` 预热机制在应用启动时后台完成首次字体加载,用户首次生成报告时不会感受到延迟。
## 系统要求 | System Requirements
- Windows 10 或更高版本(微软雅黑为系统预装字体)
- 如果在精简版 Windows 上运行,需确保系统已安装微软雅黑字体
## Fonts 目录 | Fonts Directory
## 自定义字体扩展 | Custom Font Extension
`XP.ReportEngine/Fonts/` 目录当前为空,保留用于未来可能的自定义字体扩展。
如需添加自定义字体,可在 `ITextPdfRenderer.InitializeFonts()` 中扩展加载逻辑。
如需添加自定义字体
1. 将字体文件(.ttf / .ttc / .otf)放入项目 `Fonts/` 目录
2.`.csproj` 中取消注释嵌入资源配置:
```xml
<EmbeddedResource Include="Fonts\*.otf" />
<EmbeddedResource Include="Fonts\*.ttf" />
```
3. 在 `ITextPdfRenderer.InitializeFonts()` 中扩展加载逻辑
+269
View File
@@ -0,0 +1,269 @@
# XP.ReportEngine 使用指南 | Usage Guidance
## 1. 概述
本文档说明如何在 XplorePlane 项目中使用 `XP.ReportEngine` 模块生成 PDF 检测报告。
模块提供两种调用方式:
- **推荐**:通过 `IReportService` 门面接口(一行调用,自动处理所有细节)
- **高级**:直接使用底层管线接口(`IReportGenerator``IReportDataAdapter` 等)
## 2. 前置条件
### 2.1 项目引用
在调用方项目的 `.csproj` 中添加项目引用:
```xml
<ProjectReference Include="..\XP.ReportEngine\XP.ReportEngine.csproj" />
```
### 2.2 模块注册
确保 `ReportEngineModule` 已在 `XP.App` 的模块目录中注册。模块初始化时会自动:
- 注册所有服务到 DI 容器
- 注册多语言资源到 Fallback Chain
- 后台执行引擎预热(字体加载 + JIT 编译)
### 2.3 配置文件
`App.config` 中添加报告引擎配置项,参见 `Documents/App.config.example`
## 3. 通过 IReportService 生成报告(推荐)
### 3.1 基本用法
```csharp
using XP.ReportEngine.Interfaces;
using XP.ReportEngine.Models;
public class InspectionService
{
private readonly IReportService _reportService;
public InspectionService(IReportService reportService)
{
_reportService = reportService;
}
public async Task<string> GenerateInspectionReport(
List<ProcessorOutput> processorOutputs,
string productName,
string operatorName)
{
var request = new ReportRequest
{
// 必填:处理器输出数据
ProcessorOutputs = processorOutputs,
// 必填:报告元数据
Metadata = new ReportMetadata
{
SampleName = productName,
OperatorName = operatorName,
InspectionDate = DateTime.Now,
Description = "BGA 焊球气泡率检测"
},
// 可选:文件名占位符参数(用于 FileNamePattern
FileNameParameters = new Dictionary<string, string>
{
["ProductName"] = productName,
["ProductCode"] = "PCBA-X100",
["WorkpieceSN"] = "SN20250001",
["DeviceId"] = "XP-CT-001",
["MachineId"] = "MC01",
["Result"] = "Pass"
}
};
var result = await _reportService.GenerateAsync(request);
if (result.IsSuccess)
{
return result.OutputFilePath; // PDF 文件路径
}
else
{
throw new Exception($"报告生成失败: {result.ErrorMessage}");
}
}
}
```
### 3.2 指定输出路径
```csharp
var request = new ReportRequest
{
ProcessorOutputs = outputs,
Metadata = metadata,
// 指定输出路径后,不再使用 ReportConfig 中的 OutputDirectory 和 FileNamePattern
OutputFilePath = @"D:\CustomPath\MyReport.pdf"
};
```
### 3.3 注入额外图像
```csharp
var request = new ReportRequest
{
ProcessorOutputs = outputs,
Metadata = metadata,
AdditionalImages = new Dictionary<string, ImageData>
{
// 工件整体图(首页显示)
["workpieceImage"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = @"D:\Images\workpiece.png"
},
// 自定义图像
["customImage"] = new ImageData
{
SourceType = ImageSourceType.Bytes,
Bytes = imageBytes
}
}
};
```
### 3.4 注入自定义属性
```csharp
var request = new ReportRequest
{
ProcessorOutputs = outputs,
Metadata = metadata,
// 自定义属性会合并到 ReportContext.Properties,可在模板中通过 ${key} 绑定
CustomProperties = new Dictionary<string, object>
{
["batchNumber"] = "BATCH-2025-001",
["inspectionStation"] = "Station-A"
}
};
```
## 4. ReportRequest 完整字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `ProcessorOutputs` | `List<ProcessorOutput>` | 是 | 处理器输出数据列表 |
| `Metadata` | `ReportMetadata` | 是 | 报告元数据 |
| `OutputFilePath` | `string` | 否 | 输出路径,为空时自动生成 |
| `FileNameParameters` | `Dictionary<string, string>` | 否 | 文件名占位符参数 |
| `AdditionalImages` | `Dictionary<string, ImageData>` | 否 | 额外图像数据 |
| `CustomProperties` | `Dictionary<string, object>` | 否 | 自定义属性 |
### ReportMetadata 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `ReportId` | `string` | 报告编号(为空时自动生成 RPT-yyyyMMdd-NNN |
| `InspectionDate` | `DateTime` | 检测日期 |
| `SampleName` | `string` | 样品/产品名称 |
| `OperatorName` | `string` | 操作员 |
| `Description` | `string` | 描述信息 |
### ProcessorOutput 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `ProcessorType` | `string` | 处理器类型标识 |
| `OutputData` | `Dictionary<string, object>` | 输出数据字典 |
| `AnnotatedImage` | `ImageData` | 关联的已标注图像 |
支持的 ProcessorType
- `LineMeasurementProcessor` — 线测量
- `BgaVoidRateProcessor` — BGA 气泡率
- `VoidMeasurementProcessor` — 空隙测量
- `FillRateProcessor` — 通孔填锡率
## 5. ReportServiceResult 结果说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `IsSuccess` | `bool` | 是否成功 |
| `OutputFilePath` | `string` | 输出文件路径(成功时有值) |
| `ReportId` | `string` | 报告编号 |
| `ErrorMessage` | `string` | 错误信息(失败时有值) |
| `Exception` | `Exception` | 异常对象(失败时有值) |
## 6. 在 ViewModel 中使用(带 UI 交互)
如果需要进度条、文件对话框等 UI 交互,参考 `ReportDemoViewModel.cs` 的实现模式:
```csharp
// 在后台线程执行,避免阻塞 UI
var genResult = await Task.Run(() => _reportService.GenerateAsync(request));
```
关键点:
- `IReportService.GenerateAsync` 内部是 CPU 密集型操作(PDF 渲染)
- 必须用 `Task.Run` 推到线程池,否则会阻塞 UI 线程
- 进度条、对话框等 UI 逻辑留在 ViewModel 中
## 7. 高级用法:直接使用底层管线
适用于需要自定义管线某个阶段的场景:
```csharp
// 1. 数据适配
var adapter = container.Resolve<IReportDataAdapter>();
var context = adapter.Adapt(processorOutputs, metadata);
// 2. 手动修改上下文
context.Properties["customKey"] = "customValue";
context.Images["myImage"] = new ImageData { ... };
// 3. 调用管线生成
var generator = container.Resolve<IReportGenerator>();
var options = new ReportGenerationOptions
{
TemplatePath = "Templates/StandardReportTemplate.json",
OutputFilePath = @"D:\output.pdf",
Format = ReportOutputFormat.Pdf
};
var result = await generator.GenerateAsync(context, options);
```
## 8. 预热机制
模块启动时自动在后台执行预热,无需手动调用。如果需要在特定时机手动预热:
```csharp
var reportService = container.Resolve<IReportService>();
await reportService.WarmUpAsync();
```
预热内容:
- iText7 程序集加载
- BouncyCastle 加密提供程序注册
- 微软雅黑字体文件读取和解析(约 5-6 秒)
- JSON 模板反序列化 JIT 编译
- 管线各阶段代码 JIT 编译
预热完成后,首次正式生成报告不会有额外延迟。
## 9. 线程安全
`IReportService` 内部使用 `SemaphoreSlim(1, 1)` 互斥锁,确保:
- 预热与正式生成不会并发执行
- 多个并发的 `GenerateAsync` 调用会串行执行
这是因为 `ITextPdfRenderer` 的字体字段在每次渲染时重置,并发渲染会导致 iText7 的 `"belongs to other PDF document"` 错误。
## 10. 常见问题
### Q: 首次生成报告很慢?
A: 正常现象。首次需要加载字体(~5s)+ 字体子集化(~1.2s)。预热机制会在应用启动时后台完成字体加载,用户首次操作时只需等待子集化时间。
### Q: PDF 中图像显示为"无图像"占位矩形?
A: 检查 `ProcessorOutput.AnnotatedImage``ReportRequest.AdditionalImages` 中的图像路径是否正确、文件是否存在。
### Q: 报告中某些字段为空?
A: 检查 `ProcessorOutput.OutputData` 中的键名是否与 `ProcessorDataAdapter` 中的映射一致(区分大小写)。
### Q: 如何自定义报告模板?
A: 修改 `Templates/StandardReportTemplate.json`,参考 `Documents/XP.ReportEngineModelDefine.md` 中的模板结构定义。
+118 -113
View File
@@ -2,26 +2,34 @@
## 概述
XP.ReportEngine 是 XplorePlane 平面 CT 检测系统的 PDF 报告生成模块。负责将 XP.ImageProcessing 的检测分析结果(距离测量、BGA 气泡率、空隙测量、通孔填锡率)转换为结构化的 PDF 检测报告。
XP.ReportEngine 是 XplorePlane 平面 CT 检测系统的 PDF 报告生成模块。负责将检测分析结果(距离测量、BGA 气泡率、空隙测量、通孔填锡率)转换为结构化的 PDF 检测报告。
模块采用管线式架构(Pipeline),将报告生成过程分解为五个独立阶段:
**模板加载 → 数据适配 → 数据绑定 → 排版计算 → PDF 渲染**
对外提供 `IReportService` 门面接口,外部模块只需构建 `ReportRequest` 即可一行调用完成报告生成。
## 技术栈
| 依赖 | 版本 | 用途 |
|------|------|------|
| .NET 8.0 | net8.0-windows7.0 | 运行时 |
| itext7 | 8.0.5 | PDF 文档生成核心库 |
| iText7 | 8.0.5 | PDF 文档生成核心库 |
| itext7.bouncy-castle-adapter | 8.0.5 | iText7 加密支持 |
| Newtonsoft.Json | 13.0.3 | JSON 模板反序列化 |
| Prism.Wpf | 9.0.537 | 模块化框架与 DI |
| Telerik UI for WPF | 2024.1.408 | UI 控件(演示窗口) |
| XP.Common | — | 日志、本地化、通用窗体、PDF 阅读器 |
## 项目结构
```
XP.ReportEngine/
├── Configs/ # 配置加载
│ ├── ConfigLoader.cs
│ └── ReportConfig.cs
├── Interfaces/ # 核心接口定义
│ ├── IReportService.cs ★ 门面接口(外部调用入口)
│ ├── IReportGenerator.cs
│ ├── IReportGeneratorFactory.cs
│ ├── ITemplateEngine.cs
@@ -30,15 +38,20 @@ XP.ReportEngine/
│ ├── IPdfRenderer.cs
│ └── IReportDataAdapter.cs
├── Models/ # 数据模型
│ ├── ReportRequest.cs ★ 报告生成请求
│ ├── ReportServiceResult.cs ★ 报告服务结果
│ ├── ReportContext.cs
│ ├── ReportMetadata.cs
│ ├── ImageData.cs
│ ├── ReportTemplate.cs
│ ├── TemplateElement.cs
│ ├── LayoutPage.cs
│ ├── ReportResult.cs
│ ├── ReportGenerationOptions.cs
── ProcessorOutput.cs
── ProcessorOutput.cs
│ └── TemplateValidationResult.cs
├── Services/ # 服务实现
│ ├── ReportService.cs ★ 门面服务实现
│ ├── PdfReportGenerator.cs # 管线协调器
│ ├── ReportGeneratorFactory.cs # 工厂
│ ├── JsonTemplateEngine.cs # JSON 模板加载与验证
@@ -46,8 +59,7 @@ XP.ReportEngine/
│ ├── PageLayoutEngine.cs # 分页与排版
│ ├── ITextPdfRenderer.cs # iText7 PDF 渲染
│ ├── ProcessorDataAdapter.cs # 处理器数据适配
│ └── ReportIdGenerator.cs # 报告编号生成
├── Fonts/ # 字体目录(当前使用系统字体,目录保留备用)
│ └── ReportIdGenerator.cs # 报告编号生成RPT-yyyyMMdd-NNN
├── Templates/ # JSON 报告模板
│ └── StandardReportTemplate.json
├── Resources/ # 多语言资源文件
@@ -55,112 +67,99 @@ XP.ReportEngine/
│ ├── Resources.zh-CN.resx
│ ├── Resources.zh-TW.resx
│ └── Resources.en-US.resx
├── ViewModels/ # 演示窗口 ViewModel
│ └── ReportDemoViewModel.cs
├── Views/ # 演示窗口 View
│ ├── ReportDemoWindow.xaml
│ └── ReportDemoWindow.xaml.cs
├── Documents/ # 项目文档
│ ├── README.md ← 本文件
│ ├── Guidance.md # 使用指南
│ ├── App.config.example # 配置示例
│ ├── FontFilesGuidance.md # 字体方案说明
│ ├── XP.ReportEngineDesign.md # 架构设计文档
│ └── XP.ReportEngineModelDefine.md # 模型定义文档
├── ReportEngineModule.cs # Prism 模块入口
└── XP.ReportEngine.csproj
```
## 快速开始
### 1. 字体说明
模块使用 Windows 系统自带字体(微软雅黑 + Arial),无需额外添加字体文件。详见 `Documents/FontFilesGuidance.md`
### 2. 通过 DI 容器调用
模块通过 `ReportEngineModule` 自动注册到 Prism DI 容器,其他模块可直接注入 `IReportGenerator` 使用:
```csharp
public class MyService
{
private readonly IReportGenerator _reportGenerator;
public MyService(IReportGenerator reportGenerator)
{
_reportGenerator = reportGenerator;
}
public async Task GenerateReport()
{
var context = new ReportContext
{
Metadata = new ReportMetadata
{
ReportId = "RPT-20250511-001",
InspectionDate = DateTime.Now,
SampleName = "PCB-001",
OperatorName = "张三"
},
Properties = new Dictionary<string, object>
{
["sampleName"] = "PCB-001"
}
};
var options = new ReportGenerationOptions
{
TemplatePath = "Templates/StandardReportTemplate.json",
OutputFilePath = @"D:\Reports\report.pdf",
Format = ReportOutputFormat.Pdf
};
var result = await _reportGenerator.GenerateAsync(context, options);
if (result.IsSuccess)
{
// PDF 已保存到 OutputFilePath
}
else
{
// result.ErrorMessage 包含错误信息
}
}
}
```
### 3. 使用数据适配器
将 XP.ImageProcessing 处理器输出转换为 ReportContext
```csharp
var adapter = container.Resolve<IReportDataAdapter>();
var processorOutputs = new List<ProcessorOutput>
{
new ProcessorOutput
{
ProcessorType = "BgaVoidRateProcessor",
OutputData = new Dictionary<string, object>
{
["BgaCount"] = 120,
["VoidRate"] = 0.035,
["Classification"] = "Pass"
}
}
};
var metadata = new ReportMetadata
{
ReportId = "RPT-20250511-001",
InspectionDate = DateTime.Now,
SampleName = "PCB-001",
OperatorName = "张三"
};
ReportContext context = adapter.Adapt(processorOutputs, metadata);
```
## 核心接口
| 接口 | 实现类 | 职责 |
|------|--------|------|
| **`IReportService`** | **`ReportService`** | **门面接口,外部模块调用入口** |
| `IReportGenerator` | `PdfReportGenerator` | 协调管线各阶段,生成 PDF |
| `IReportGeneratorFactory` | `ReportGeneratorFactory` | 根据格式创建对应生成器 |
| `ITemplateEngine` | `JsonTemplateEngine` | JSON 模板加载、反序列化、验证 |
| `IDataBinder` | `ExpressionDataBinder` | `${}` 表达式解析与数据绑定 |
| `ILayoutEngine` | `PageLayoutEngine` | 分页、元素定位、表格跨页 |
| `IPdfRenderer` | `ITextPdfRenderer` | 使用 iText7 渲染 PDF |
| `IReportDataAdapter` | `ProcessorDataAdapter` | OutputData → ReportContext 转换 |
| `IReportDataAdapter` | `ProcessorDataAdapter` | ProcessorOutput → ReportContext 转换 |
## 架构分层
```
┌─────────────────────────────────────────────────────┐
│ 外部调用方(XP.App、其他模块) │
│ 注入 IReportService,传入 ReportRequest │
└──────────────────────┬──────────────────────────────┘
┌──────────────────────▼──────────────────────────────┐
│ ReportService(门面层) │
│ 生成报告ID → 解析输出路径 → 数据适配 → 注入配置数据 │
└──────────────────────┬──────────────────────────────┘
┌──────────────────────▼──────────────────────────────┐
│ PdfReportGenerator(管线协调层) │
│ 模板加载 → 数据绑定 → 排版计算 → PDF 渲染 → 保存 │
└─────────────────────────────────────────────────────┘
```
## 快速开始
详细使用指南请参阅 `Documents/Guidance.md`
### 最简调用(通过 IReportService
```csharp
public class MyService
{
private readonly IReportService _reportService;
public MyService(IReportService reportService)
{
_reportService = reportService;
}
public async Task GenerateReport(List<ProcessorOutput> outputs)
{
var request = new ReportRequest
{
ProcessorOutputs = outputs,
Metadata = new ReportMetadata
{
SampleName = "PCB-001",
OperatorName = "张三",
InspectionDate = DateTime.Now
}
};
var result = await _reportService.GenerateAsync(request);
if (result.IsSuccess)
{
// result.OutputFilePath — 生成的 PDF 路径
// result.ReportId — 报告编号
}
}
}
```
## 预热机制
模块在 Prism 初始化时自动在后台执行一次预热(`WarmUpAsync`),触发 iText7 初始化、字体加载、JIT 编译等一次性开销,避免用户首次生成报告时卡顿。
预热与正式生成通过 `SemaphoreSlim` 互斥锁串行执行,不会出现并发冲突。
## 数据绑定表达式
@@ -174,35 +173,32 @@ ReportContext context = adapter.Adapt(processorOutputs, metadata);
| `${functionName(param)}` | 格式化函数 | `${formatDate(inspectionDate)}` |
| `${loc:ResourceKey}` | 本地化键解析 | `${loc:Report_Title}` |
内置格式化函数:
- `formatDate(value)` — 根据当前语言格式化日期
- `formatNumber(value, decimals)` — 根据当前语言格式化数字
- `formatPercent(value)` — 格式化百分比
## 多语言支持
支持三种语言:简体中文(zh-CN)、繁体中文(zh-TW)、英文(en-US)。
通过 `ILocalizationService` 自动解析 `${loc:Key}` 表达式为当前语言文本。模块在初始化时注册资源源到 Fallback Chain。
## 模板定义
## 模板页面类型
标准模板位于 `Templates/StandardReportTemplate.json`包含以下页面类型:
- `homepage` — 报告首页(元数据 + 摘要表格)
- `metricData` — 距离测量数据页
- `bgaInspection` — BGA 焊球检测页(含数据表格)
- `voidInspection` — 空隙检测页(含数据表格)
- `viaFillInspection` — 通孔填锡检测页
标准模板 `StandardReportTemplate.json` 包含以下页面类型:
模板 JSON 结构需包含三个必需顶层字段:`document``pages``styles`
| 页面类型 | 说明 |
|---------|------|
| `homepage` | 报告首页(公司信息 + 元数据 + 汇总表格) |
| `summary` | 检测结果汇总页 |
| `metricData` | 距离测量数据页 |
| `bgaInspection` | BGA 焊球检测页(含数据表格) |
| `voidInspection` | 空隙检测页(含数据表格) |
| `viaFillInspection` | 通孔填锡检测页 |
## 错误处理
模块采用结果对象模式(Result Pattern,所有公共方法返回 `ReportResult` 而非抛出异常
- `ReportResult.Success(stream)` — 成功,包含 PDF MemoryStream
- `ReportResult.Failure(message, ex)` — 失败,包含错误信息和可选异常
模块采用结果对象模式(Result Pattern):
- `ReportServiceResult.Success(path, reportId)` — 成功
- `ReportServiceResult.Failure(message, ex)` — 失败
非致命性问题(缺失属性、未定义样式、图像缺失)会记录警告日志并继续执行,不会中断报告生成。
非致命性问题(缺失属性、图像缺失)会记录警告日志并继续执行,不会中断报告生成。
## 构建
@@ -210,3 +206,12 @@ ReportContext context = adapter.Adapt(processorOutputs, metadata);
cd XplorePlane
dotnet build XP.ReportEngine/XP.ReportEngine.csproj
```
## 相关文档
- `Documents/Guidance.md` — 详细使用指南(IReportService 调用方式)
- `Documents/TemplateDevelopment.md` — 模板开发指南(新增/自定义模板)
- `Documents/App.config.example` — 配置文件示例
- `Documents/FontFilesGuidance.md` — 字体方案说明
- `Documents/XP.ReportEngineDesign.md` — 架构设计文档
- `Documents/XP.ReportEngineModelDefine.md` — 模型定义文档
@@ -0,0 +1,403 @@
# 报告模板开发指南 | Template Development Guide
## 1. 概述
XP.ReportEngine 使用 JSON 格式定义报告模板。模板描述了 PDF 报告的页面结构、元素布局、数据绑定和样式定义。
模板文件存放在 `XP.ReportEngine/Templates/` 目录下,通过 `App.config` 中的 `Report:TemplatePath` 配置项指定使用哪个模板。
## 2. 新增模板步骤
### 2.1 创建模板文件
1.`Templates/` 目录下创建新的 JSON 文件,如 `CustomReportTemplate.json`
2.`.csproj` 中确认模板文件会被复制到输出目录(已有通配规则):
```xml
<Content Include="Templates\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
```
3. 修改 `App.config` 中的 `Report:TemplatePath` 指向新模板:
```xml
<add key="Report:TemplatePath" value="Templates\CustomReportTemplate.json" />
```
### 2.2 模板验证规则
模板加载后会自动验证,必须满足以下条件:
- 包含 `document` 顶层字段(页面设置)
- 包含 `pages` 顶层字段(至少一个页面定义)
- 包含 `styles` 顶层字段(样式字典)
验证失败时报告生成会返回错误,不会生成 PDF。
## 3. 模板 JSON 结构
```json
{
"document": { ... }, // 必需:文档级设置(页面尺寸、边距、页眉页脚)
"pages": [ ... ], // 必需:页面定义数组
"styles": { ... } // 必需:样式定义字典
}
```
## 4. document 配置
```json
{
"document": {
"pageSize": "A4",
"orientation": "Portrait",
"margins": { "top": 40, "bottom": 20, "left": 20, "right": 20 },
"header": {
"enabled": true,
"left": ["标题文本", "第二行文本"],
"right": ["右侧文本"],
"rightImageKey": "companyLogo",
"leftImageKey": "softwareLogo",
"fontSize": 7,
"color": "#666666",
"showLine": true
},
"footer": {
"enabled": true,
"left": ["公司名称"],
"right": ["{currentPage} / {totalPages}"],
"fontSize": 8,
"color": "#666666",
"showLine": true
}
}
}
```
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `pageSize` | string | `"A4"` | 页面尺寸 |
| `orientation` | string | `"Portrait"` | 方向:Portrait / Landscape |
| `margins` | object | `{top:20, bottom:20, left:20, right:20}` | 边距(mm |
| `header` | object | null | 页眉配置(首页不显示) |
| `footer` | object | null | 页脚配置(首页不显示) |
### 页眉/页脚字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `enabled` | bool | 是否启用 |
| `left` | string[] | 左侧文本行(支持 `${}` 绑定) |
| `right` | string[] | 右侧文本行(支持 `${}` 绑定) |
| `leftImageKey` | string | 左侧图像的 dataKey |
| `rightImageKey` | string | 右侧图像的 dataKey |
| `fontSize` | float | 字体大小 |
| `color` | string | 字体颜色(十六进制) |
| `showLine` | bool | 是否显示分隔线 |
页脚特殊占位符:
- `{currentPage}` — 当前页码
- `{totalPages}` — 总页数
## 5. pages 页面定义
```json
{
"pages": [
{
"type": "homepage",
"elements": [ ... ]
},
{
"type": "customPage",
"elements": [ ... ]
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | string | 页面类型标识(`homepage` 类型不显示页眉页脚) |
| `elements` | array | 页面内的元素列表 |
### 内置页面类型
| 类型 | 说明 | 特殊行为 |
|------|------|---------|
| `homepage` | 首页 | 不显示页眉页脚 |
| `summary` | 汇总页 | 无 |
| `metricData` | 距离测量页 | 无 |
| `bgaInspection` | BGA 检测页 | 无 |
| `voidInspection` | 空隙检测页 | 无 |
| `viaFillInspection` | 通孔填锡页 | 无 |
你可以自定义任意 `type` 名称,只有 `homepage` 有特殊行为(不显示页眉页脚)。
## 6. 元素类型
### 6.1 text — 文本元素
```json
{
"type": "text",
"content": "${loc:Report_Title}",
"style": "heading",
"positioning": "flow",
"align": "center",
"colorRules": { "Pass": "#008000", "Fail": "#FF0000" }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `content` | string | 文本内容,支持 `${}` 绑定表达式 |
| `style` | string | 引用 `styles` 中定义的样式名 |
| `align` | string | 对齐:left / center / right |
| `colorRules` | object | 条件颜色规则(内容包含关键词时变色) |
### 6.2 image — 图像元素
```json
{
"type": "image",
"dataKey": "workpieceImage",
"size": [160, 110],
"border": true,
"align": "center",
"style": "imageDefault",
"positioning": "flow"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `dataKey` | string | 图像数据键(对应 `ReportContext.Images` 中的键) |
| `size` | float[2] | [宽, 高]mm),图像会等比缩放适应 |
| `border` | bool | 是否显示边框 |
| `align` | string | 对齐:left / center / right |
图像缺失时会渲染灰色占位矩形,不会中断报告生成。
### 6.3 table — 表格元素
```json
{
"type": "table",
"dataKey": "bgaBallsTable",
"positioning": "flow",
"size": [170, 0],
"style": "tableDefault",
"columns": [
{ "header": "序号", "field": "index", "width": 25, "align": "center" },
{ "header": "气泡率", "field": "voidRate", "width": 40, "align": "center" },
{ "header": "面积", "field": "area", "width": 40, "align": "center" },
{
"header": "分类",
"field": "classification",
"width": 35,
"align": "center",
"colorRules": { "Pass": "#008000", "Fail": "#FF0000" }
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `dataKey` | string | 表格数据键(对应 `ReportContext.Properties` 中的 `List<Dictionary<string, object>>` 数据) |
| `size` | float[2] | [宽, 高]mm),高度为 0 表示自动 |
| `columns` | array | 列定义数组 |
#### 列定义(ColumnDefinition
| 字段 | 类型 | 说明 |
|------|------|------|
| `header` | string | 表头文本(支持 `${}` 绑定) |
| `field` | string | 数据字段名(对应行数据字典中的键) |
| `width` | float | 列宽(mm |
| `align` | string | 对齐:left / center / right |
| `colorRules` | object | 条件颜色规则(单元格值匹配时变色) |
### 6.4 row — 水平布局容器
```json
{
"type": "row",
"size": [170, 100],
"widths": [6, 4],
"positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "myImage", "size": [110, 90] }
]
},
{
"type": "column", "align": "left",
"children": [
{ "type": "text", "content": "文本内容", "style": "body" }
]
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `size` | float[2] | [总宽, 总高]mm |
| `widths` | float[] | 列宽比例数组(如 `[6, 4]` 表示 60%:40% |
| `children` | array | 子列元素(`type: "column"` |
### 6.5 spacer — 间距元素
```json
{ "type": "spacer", "size": [170, 10], "positioning": "flow" }
```
用于在元素之间添加垂直间距。`size[1]` 为间距高度(mm)。
### 6.6 divider — 分隔线
```json
{ "type": "divider", "positioning": "flow" }
```
渲染一条水平分隔线。
### 6.7 pagebreak — 强制分页
```json
{ "type": "pagebreak" }
```
在当前位置强制插入分页符。
## 7. 样式定义
```json
{
"styles": {
"heading": {
"font": "auto",
"size": 16,
"bold": true,
"italic": false,
"color": "#333333",
"align": "left",
"marginTop": 0,
"marginBottom": 3,
"paddingLeft": 0,
"lineHeight": 0,
"backgroundColor": ""
}
}
}
```
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `font` | string | `"auto"` | 字体(`auto` 根据语言自动选择) |
| `size` | float | 12 | 字体大小(pt |
| `bold` | bool | false | 粗体 |
| `italic` | bool | false | 斜体 |
| `color` | string | `"#000000"` | 字体颜色(十六进制) |
| `align` | string | `"left"` | 对齐:left / center / right |
| `backgroundColor` | string | — | 背景色(十六进制) |
| `marginTop` | float | 0 | 上边距(mm |
| `marginBottom` | float | 0 | 下边距(mm |
| `paddingLeft` | float | 0 | 左缩进(mm |
| `lineHeight` | float | 0 | 行高倍数(0 = 默认) |
元素引用未定义的样式名时,会使用默认样式(12pt、黑色、左对齐),不会报错。
## 8. 数据绑定表达式
模板中的 `content`、`header` 等文本字段支持 `${}` 绑定表达式:
| 语法 | 说明 | 示例 |
|------|------|------|
| `${key}` | 从 Properties 中取值 | `${sampleName}` |
| `${metadata.field}` | 从 Metadata 中取值 | `${metadata.reportId}` |
| `${loc:Key}` | 多语言资源键 | `${loc:Report_Title}` |
| `${formatDate(value)}` | 格式化日期 | `${formatDate(metadata.inspectionDate)}` |
| `${formatNumber(value, decimals)}` | 格式化数字 | `${formatNumber(totalArea, 2)}` |
| `${formatPercent(value)}` | 格式化百分比 | `${formatPercent(voidRate)}` |
### 数据来源
绑定表达式从 `ReportContext` 中查找数据:
- `Properties` 字典 — 扁平化的键值对(由 `ProcessorDataAdapter` 从处理器输出转换而来)
- `Metadata` — 报告元数据对象
- `Images` 字典 — 图像数据(通过 `dataKey` 引用)
### ProcessorDataAdapter 输出的键名
| 处理器类型 | Properties 中的键 | Images 中的键 | 表格 dataKey |
|-----------|------------------|--------------|-------------|
| LineMeasurementProcessor | measurementType, point1, point2, pixelDistance, actualDistance, unit, angle | lineMeasurementImage | — |
| BgaVoidRateProcessor | bgaCount, voidRate, fillRate, totalBgaArea, totalVoidArea, voidLimit, classification | bgaInspectionImage | bgaBallsTable |
| VoidMeasurementProcessor | roiArea, totalVoidArea, voidRate, voidLimit, voidCount, maxVoidArea, classification | voidInspectionImage | voidsTable |
| FillRateProcessor | fillRate, voidRate, fullDistance, fillDistance, thtLimit, classification, e1-e4 | viaFillImage | — |
### ReportService 自动注入的键
| 键 | 来源 | 说明 |
|----|------|------|
| `CompanyName` | ReportConfig | 公司名称 |
| `SoftwareName` | ReportConfig | 软件名称 |
| `companyLogo` | ReportConfig.CompanyLogo | 公司 LogoImages |
| `softwareLogo` | ReportConfig.SoftwareLogo | 软件 LogoImages |
| `summaryTable` | 自动生成 | 首页汇总表数据 |
## 9. 定位方式
| 值 | 说明 |
|----|------|
| `"flow"` | 流式布局,元素按顺序从上到下排列(推荐) |
| `"absolute"` | 绝对定位,使用 `position` 坐标(不推荐,兼容性差) |
建议所有元素使用 `"positioning": "flow"`。
## 10. 完整模板示例(最小化)
```json
{
"document": {
"pageSize": "A4",
"orientation": "Portrait",
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 }
},
"pages": [
{
"type": "homepage",
"elements": [
{ "type": "text", "content": "检测报告", "style": "title", "positioning": "flow" },
{ "type": "text", "content": "报告编号:${metadata.reportId}", "style": "body", "positioning": "flow" },
{ "type": "text", "content": "检测日期:${formatDate(metadata.inspectionDate)}", "style": "body", "positioning": "flow" }
]
},
{
"type": "dataPage",
"elements": [
{ "type": "text", "content": "检测数据", "style": "heading", "positioning": "flow" },
{ "type": "image", "dataKey": "inspectionImage", "size": [150, 100], "positioning": "flow" }
]
}
],
"styles": {
"title": { "size": 24, "bold": true, "align": "center" },
"heading": { "size": 16, "bold": true },
"body": { "size": 12 }
}
}
```
## 11. 注意事项
1. **尺寸单位**:所有尺寸(size、margins、width)单位为 **mm**(毫米)
2. **颜色格式**:使用十六进制格式 `#RRGGBB`(如 `#FF0000` 为红色)
3. **表格自动跨页**:当表格数据行超出当前页面剩余空间时,排版引擎会自动分页
4. **图像缺失容错**:图像 dataKey 对应的数据不存在时,渲染占位矩形,不中断生成
5. **样式缺失容错**:引用未定义的样式名时使用默认样式,不中断生成
6. **绑定表达式缺失**`${}` 表达式对应的数据不存在时,替换为空字符串
7. **首页特殊处理**`type: "homepage"` 的页面不显示页眉页脚
8. **JSON 编码**:模板文件必须使用 **UTF-8** 编码保存
@@ -0,0 +1,45 @@
using System.Threading.Tasks;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 报告服务接口(门面)| Report service interface (Facade)
/// 提供完整的报告生成流程,外部模块通过此接口调用报告功能
/// Provides complete report generation workflow for external modules
/// </summary>
/// <remarks>
/// 使用示例 | Usage example:
/// <code>
/// var request = new ReportRequest
/// {
/// ProcessorOutputs = processorOutputs,
/// Metadata = new ReportMetadata
/// {
/// SampleName = "PCB-001",
/// OperatorName = "Operator",
/// InspectionDate = DateTime.Now
/// }
/// };
/// var result = await _reportService.GenerateAsync(request);
/// </code>
/// </remarks>
public interface IReportService
{
/// <summary>
/// 生成报告 | Generate report
/// 执行完整流程:生成报告ID → 数据适配 → 上下文组装 → 管线生成 → 文件保存
/// Executes full workflow: generate report ID → data adaptation → context assembly → pipeline generation → file saving
/// </summary>
/// <param name="request">报告生成请求 | Report generation request</param>
/// <returns>报告生成结果 | Report generation result</returns>
Task<ReportServiceResult> GenerateAsync(ReportRequest request);
/// <summary>
/// 预热报告引擎(建议在应用启动后台调用)| Warm up report engine (recommended to call in background on app startup)
/// 触发 iText7 初始化、字体加载、JIT 编译等一次性开销,避免首次生成报告时卡顿
/// Triggers iText7 initialization, font loading, JIT compilation to avoid first-run latency
/// </summary>
Task WarmUpAsync();
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告生成请求 | Report generation request
/// 封装外部调用方生成报告所需的全部输入参数
/// Encapsulates all input parameters needed by external callers to generate a report
/// </summary>
public class ReportRequest
{
/// <summary>
/// 处理器输出数据列表 | List of processor output data
/// </summary>
public List<ProcessorOutput> ProcessorOutputs { get; set; } = new();
/// <summary>
/// 报告元数据(产品名、操作员、描述等)| Report metadata (product name, operator, description, etc.)
/// </summary>
public ReportMetadata Metadata { get; set; }
/// <summary>
/// 额外图像数据(如工件整体图、自定义图像)| Additional image data (e.g., workpiece overview, custom images)
/// 键为 dataKey,值为图像数据
/// Key is dataKey, value is image data
/// </summary>
public Dictionary<string, ImageData> AdditionalImages { get; set; } = new();
/// <summary>
/// 输出文件路径(可选)| Output file path (optional)
/// 为空时根据 ReportConfig 和 FileNameParameters 自动生成
/// When empty, auto-generated based on ReportConfig and FileNameParameters
/// </summary>
public string OutputFilePath { get; set; }
/// <summary>
/// 文件名占位符参数(可选)| File name placeholder parameters (optional)
/// 用于 ReportConfig.FileNamePattern 中的占位符替换
/// Used for placeholder replacement in ReportConfig.FileNamePattern
/// </summary>
public Dictionary<string, string> FileNameParameters { get; set; } = new();
/// <summary>
/// 自定义属性(可选)| Custom properties (optional)
/// 额外的键值对数据,会合并到 ReportContext.Properties 中
/// Additional key-value data merged into ReportContext.Properties
/// </summary>
public Dictionary<string, object> CustomProperties { get; set; } = new();
}
}
@@ -0,0 +1,49 @@
using System;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告服务结果 | Report service result
/// 封装报告生成的最终结果信息
/// Encapsulates the final result of report generation
/// </summary>
public class ReportServiceResult
{
/// <summary>
/// 是否成功 | Whether successful
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 输出文件路径(成功时有值)| Output file path (has value when successful)
/// </summary>
public string OutputFilePath { get; set; }
/// <summary>
/// 报告编号 | Report ID
/// </summary>
public string ReportId { get; set; }
/// <summary>
/// 错误信息(失败时有值)| Error message (has value when failed)
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// 异常对象(失败时有值)| Exception object (has value when failed)
/// </summary>
public Exception Exception { get; set; }
/// <summary>
/// 创建成功结果 | Create success result
/// </summary>
public static ReportServiceResult Success(string outputFilePath, string reportId)
=> new() { IsSuccess = true, OutputFilePath = outputFilePath, ReportId = reportId };
/// <summary>
/// 创建失败结果 | Create failure result
/// </summary>
public static ReportServiceResult Failure(string message, Exception ex = null)
=> new() { IsSuccess = false, ErrorMessage = message, Exception = ex };
}
}
+8
View File
@@ -37,6 +37,11 @@ namespace XP.ReportEngine
// Initialize LocalizationHelper to use ILocalizationService for string lookup (supports Fallback Chain)
LocalizationHelper.Initialize(localizationService);
// 后台预热报告引擎(触发 iText7 初始化、字体加载、JIT 编译,避免首次生成卡顿)
// Background warm-up report engine (triggers iText7 init, font loading, JIT to avoid first-run latency)
var reportService = containerProvider.Resolve<IReportService>();
_ = System.Threading.Tasks.Task.Run(() => reportService.WarmUpAsync());
System.Console.WriteLine("[ReportEngineModule] 模块已初始化 | Module initialized");
}
@@ -62,6 +67,9 @@ namespace XP.ReportEngine
// 注册报告生成器工厂(单例)| Register report generator factory (singleton)
containerRegistry.RegisterSingleton<IReportGeneratorFactory, ReportGeneratorFactory>();
// 注册报告服务门面(单例)| Register report service facade (singleton)
containerRegistry.RegisterSingleton<IReportService, ReportService>();
// 注册模板引擎(瞬态)| Register template engine (transient)
containerRegistry.Register<ITemplateEngine, JsonTemplateEngine>();
+18 -2
View File
@@ -85,6 +85,12 @@ namespace XP.ReportEngine.Services
_logger.Info("开始 PDF 渲染,共 {PageCount} 页 | Starting PDF rendering, {PageCount} pages", pages?.Count ?? 0);
_currentTemplate = template;
// 每次渲染重置字体,避免跨 PdfDocument 复用导致 "belongs to other PDF document" 错误
// Reset fonts on each render to avoid cross-PdfDocument reuse error
_fontsInitialized = false;
_cjkFont = null;
_westernFont = null;
var memoryStream = new MemoryStream();
try
@@ -130,6 +136,8 @@ namespace XP.ReportEngine.Services
{
for (int i = 0; i < pages.Count; i++)
{
var pageStopwatch = System.Diagnostics.Stopwatch.StartNew();
if (i > 0)
{
// 添加新页面 | Add new page
@@ -148,6 +156,10 @@ namespace XP.ReportEngine.Services
}
RenderPage(document, pages[i]);
pageStopwatch.Stop();
_logger.Info("第 {PageIndex}/{TotalPages} 页渲染完成,类型: {PageType},元素数: {ElementCount},耗时: {ElapsedMs}ms | Page {PageIndex}/{TotalPages} rendered, type: {PageType}, elements: {ElementCount}, elapsed: {ElapsedMs}ms",
i + 1, pages.Count, pages[i].PageType ?? "unknown", pages[i].Elements?.Count ?? 0, pageStopwatch.ElapsedMilliseconds);
}
}
@@ -157,9 +169,13 @@ namespace XP.ReportEngine.Services
headerFooterHandler.WriteTotal(pdfDocument);
}
// 关闭 Document(它会级联关闭 PdfDocument 和 PdfWriter
// Only close Document (it cascades to PdfDocument and PdfWriter)
// 关闭文档(触发字体子集化嵌入 + PDF 交叉引用表写入 + 流压缩
// Close document (triggers font subsetting + PDF cross-reference table writing + stream compression)
_logger.Info("开始关闭文档(字体嵌入 + 压缩)| Starting document close (font embedding + compression)");
var closeStopwatch = System.Diagnostics.Stopwatch.StartNew();
document.Close();
closeStopwatch.Stop();
_logger.Info("文档关闭完成,耗时: {ElapsedMs}ms | Document close completed, elapsed: {ElapsedMs}ms", closeStopwatch.ElapsedMilliseconds);
// 重置流位置以便后续读取 | Reset stream position for subsequent reading
memoryStream.Position = 0;
@@ -187,7 +187,7 @@ namespace XP.ReportEngine.Services
/// <summary>
/// 将 BgaBalls 列表转换为表格行 | Convert BgaBalls list to table rows
/// 每个焊球一行,包含 index、voidRate、classification
/// 每个焊球一行,包含 index、voidRate、area、classification
/// </summary>
private List<Dictionary<string, object>> ConvertBgaBallsToTableRows(Dictionary<string, object> data)
{
@@ -206,6 +206,7 @@ namespace XP.ReportEngine.Services
{
["index"] = i + 1,
["voidRate"] = GetNestedValue<double>(ball, "VoidRate", 0.0),
["area"] = GetNestedValue<double>(ball, "Area", 0.0),
["classification"] = GetNestedValue<string>(ball, "Classification", string.Empty)
};
tableRows.Add(row);
+390
View File
@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XP.ReportEngine.Configs;
using XP.ReportEngine.Interfaces;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Services
{
/// <summary>
/// 报告服务实现(门面)| Report service implementation (Facade)
/// 协调报告生成的完整流程,将 UI 无关的业务逻辑封装为可复用服务
/// Orchestrates the complete report generation workflow, encapsulating UI-independent business logic as a reusable service
/// </summary>
public class ReportService : IReportService
{
private readonly IReportGenerator _reportGenerator;
private readonly IReportDataAdapter _dataAdapter;
private readonly ILoggerService _logger;
private readonly ReportIdGenerator _reportIdGenerator;
private readonly ReportConfig _reportConfig;
/// <summary>
/// 生成互斥锁,防止并发渲染导致 iText7 字体对象跨文档引用错误
/// Generation mutex to prevent concurrent rendering causing iText7 cross-document font reference errors
/// </summary>
private readonly SemaphoreSlim _generateLock = new(1, 1);
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="reportGenerator">报告生成器 | Report generator</param>
/// <param name="dataAdapter">数据适配器 | Data adapter</param>
/// <param name="logger">日志服务 | Logger service</param>
/// <param name="reportIdGenerator">报告编号生成器 | Report ID generator</param>
/// <param name="reportConfig">报告配置 | Report config</param>
public ReportService(
IReportGenerator reportGenerator,
IReportDataAdapter dataAdapter,
ILoggerService logger,
ReportIdGenerator reportIdGenerator,
ReportConfig reportConfig)
{
_reportGenerator = reportGenerator ?? throw new ArgumentNullException(nameof(reportGenerator));
_dataAdapter = dataAdapter ?? throw new ArgumentNullException(nameof(dataAdapter));
_logger = logger?.ForModule<ReportService>() ?? throw new ArgumentNullException(nameof(logger));
_reportIdGenerator = reportIdGenerator ?? throw new ArgumentNullException(nameof(reportIdGenerator));
_reportConfig = reportConfig ?? throw new ArgumentNullException(nameof(reportConfig));
}
/// <summary>
/// 生成报告 | Generate report
/// </summary>
public async Task<ReportServiceResult> GenerateAsync(ReportRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
// 获取互斥锁,防止并发渲染 | Acquire mutex to prevent concurrent rendering
await _generateLock.WaitAsync();
try
{
_logger.Info("报告服务开始生成 | Report service starting generation");
// 步骤 1:生成报告编号 | Step 1: Generate report ID
var reportId = GenerateReportId(request);
_logger.Info("报告编号: {ReportId} | Report ID: {ReportId}", reportId);
// 步骤 2:确定输出路径 | Step 2: Determine output path
var outputPath = ResolveOutputPath(request, reportId);
_logger.Info("输出路径: {Path} | Output path: {Path}", outputPath);
// 步骤 3:数据适配 | Step 3: Data adaptation
var metadata = request.Metadata ?? new ReportMetadata();
if (string.IsNullOrEmpty(metadata.ReportId))
{
metadata.ReportId = reportId;
}
if (metadata.InspectionDate == default)
{
metadata.InspectionDate = DateTime.Now;
}
var context = _dataAdapter.Adapt(request.ProcessorOutputs ?? new List<ProcessorOutput>(), metadata);
_logger.Info("数据适配完成 | Data adaptation completed");
// 步骤 4:注入额外图像 | Step 4: Inject additional images
InjectAdditionalImages(context, request);
// 步骤 5:注入配置中的 Logo 和公司信息 | Step 5: Inject logo and company info from config
InjectConfigData(context);
// 步骤 6:注入自定义属性 | Step 6: Inject custom properties
InjectCustomProperties(context, request);
// 步骤 7:生成首页汇总表 | Step 7: Generate homepage summary table
context.Properties["summaryTable"] = CreateSummaryTableData(context);
// 步骤 8:调用管线生成 PDF | Step 8: Call pipeline to generate PDF
var templatePath = _reportConfig.GetResolvedTemplatePath();
var options = new ReportGenerationOptions
{
TemplatePath = templatePath,
OutputFilePath = outputPath,
Format = ReportOutputFormat.Pdf
};
var genResult = await _reportGenerator.GenerateAsync(context, options);
// 步骤 9:处理结果 | Step 9: Handle result
if (genResult.IsSuccess)
{
_logger.Info("报告生成成功: {Path} | Report generated successfully: {Path}", outputPath);
return ReportServiceResult.Success(outputPath, reportId);
}
else
{
_logger.Error(null, "报告生成失败: {Message} | Report generation failed: {Message}", genResult.ErrorMessage);
return ReportServiceResult.Failure(genResult.ErrorMessage, genResult.Exception);
}
}
catch (Exception ex)
{
_logger.Error(ex, "报告服务异常: {Message} | Report service exception: {Message}", ex.Message);
return ReportServiceResult.Failure($"报告生成过程中发生异常: {ex.Message}", ex);
}
finally
{
_generateLock.Release();
}
}
/// <summary>
/// 预热报告引擎 | Warm up report engine
/// 通过生成一个最小化的空白 PDF 来触发所有一次性初始化:
/// iText7 程序集加载、BouncyCastle 注册、字体子系统初始化、JIT 编译
/// </summary>
public async Task WarmUpAsync()
{
// 获取互斥锁,确保预热与正式生成不并发 | Acquire mutex to ensure warm-up doesn't overlap with generation
await _generateLock.WaitAsync();
try
{
_logger.Info("报告引擎预热开始 | Report engine warm-up started");
await Task.Run(() =>
{
// 加载模板(触发 JSON 反序列化 JIT| Load template (triggers JSON deserialization JIT)
var templatePath = _reportConfig.GetResolvedTemplatePath();
if (!File.Exists(templatePath))
{
_logger.Warn("预热跳过:模板文件不存在 {Path} | Warm-up skipped: template not found {Path}", templatePath);
return;
}
// 构建最小上下文 | Build minimal context
var context = new ReportContext
{
Metadata = new ReportMetadata
{
ReportId = "WARMUP",
InspectionDate = DateTime.Now,
SampleName = "WarmUp",
OperatorName = "System"
},
ResultGroups = new List<InspectionResultGroup>(),
Images = new Dictionary<string, ImageData>(),
Properties = new Dictionary<string, object>
{
["summaryTable"] = new List<Dictionary<string, object>>()
}
};
var options = new ReportGenerationOptions
{
TemplatePath = templatePath,
OutputFilePath = null, // 不保存文件 | Don't save file
Format = ReportOutputFormat.Pdf
};
// 执行完整管线(触发 iText7 初始化 + 字体加载 + JIT)
// Execute full pipeline (triggers iText7 init + font loading + JIT)
var result = _reportGenerator.GenerateAsync(context, options).GetAwaiter().GetResult();
// 释放预热产生的流 | Dispose warm-up stream
result.PdfStream?.Dispose();
});
_logger.Info("报告引擎预热完成 | Report engine warm-up completed");
}
catch (Exception ex)
{
// 预热失败不影响正常功能 | Warm-up failure doesn't affect normal functionality
_logger.Warn("报告引擎预热失败(不影响正常使用)| Report engine warm-up failed (doesn't affect normal usage): {Message}", ex.Message);
}
finally
{
_generateLock.Release();
}
}
#region | Private Methods
/// <summary>
/// 生成报告编号 | Generate report ID
/// 优先使用请求中已有的 ReportId,否则自动生成
/// Prefer existing ReportId from request, otherwise auto-generate
/// </summary>
private string GenerateReportId(ReportRequest request)
{
// 如果 Metadata 中已有 ReportId,直接使用 | If Metadata already has ReportId, use it directly
if (request.Metadata != null && !string.IsNullOrEmpty(request.Metadata.ReportId))
{
return request.Metadata.ReportId;
}
// 如果 FileNameParameters 中已有 ReportId,直接使用 | If FileNameParameters already has ReportId, use it
if (request.FileNameParameters != null &&
request.FileNameParameters.TryGetValue("ReportId", out var existingId) &&
!string.IsNullOrEmpty(existingId))
{
return existingId;
}
// 自动生成 | Auto-generate
return _reportIdGenerator.GenerateNext();
}
/// <summary>
/// 解析输出文件路径 | Resolve output file path
/// </summary>
private string ResolveOutputPath(ReportRequest request, string reportId)
{
// 如果请求中指定了输出路径,直接使用 | If output path specified in request, use it directly
if (!string.IsNullOrEmpty(request.OutputFilePath))
{
// 确保目录存在 | Ensure directory exists
var dir = Path.GetDirectoryName(request.OutputFilePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
return request.OutputFilePath;
}
// 使用配置和参数自动生成路径 | Auto-generate path using config and parameters
var fileNameParams = request.FileNameParameters ?? new Dictionary<string, string>();
// 确保 ReportId 在参数中 | Ensure ReportId is in parameters
if (!fileNameParams.ContainsKey("ReportId"))
{
fileNameParams["ReportId"] = reportId;
}
return _reportConfig.ResolveOutputFilePath(fileNameParams);
}
/// <summary>
/// 注入额外图像到上下文 | Inject additional images into context
/// </summary>
private void InjectAdditionalImages(ReportContext context, ReportRequest request)
{
if (request.AdditionalImages == null || request.AdditionalImages.Count == 0)
return;
foreach (var kvp in request.AdditionalImages)
{
if (kvp.Value != null)
{
context.Images[kvp.Key] = kvp.Value;
_logger.Debug("注入额外图像: {Key} | Injected additional image: {Key}", kvp.Key);
}
}
}
/// <summary>
/// 注入配置中的 Logo 和公司信息 | Inject logo and company info from config
/// </summary>
private void InjectConfigData(ReportContext context)
{
// 注入公司名称 | Inject company name
if (!string.IsNullOrEmpty(_reportConfig.CompanyName))
{
context.Properties["CompanyName"] = _reportConfig.CompanyName;
}
// 注入软件名称 | Inject software name
if (!string.IsNullOrEmpty(_reportConfig.SoftwareName))
{
context.Properties["SoftwareName"] = _reportConfig.SoftwareName;
}
// 注入公司 Logo | Inject company logo
if (!string.IsNullOrEmpty(_reportConfig.CompanyLogo))
{
var logoPath = Path.IsPathRooted(_reportConfig.CompanyLogo)
? _reportConfig.CompanyLogo
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _reportConfig.CompanyLogo);
if (File.Exists(logoPath))
{
context.Images["companyLogo"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = logoPath
};
_logger.Info("公司 Logo 已加载: {Path} | Company logo loaded: {Path}", logoPath);
}
else
{
_logger.Warn("公司 Logo 文件不存在: {Path} | Company logo file not found: {Path}", logoPath);
}
}
// 注入软件 Logo | Inject software logo
if (!string.IsNullOrEmpty(_reportConfig.SoftwareLogo))
{
var softwareLogoPath = Path.IsPathRooted(_reportConfig.SoftwareLogo)
? _reportConfig.SoftwareLogo
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _reportConfig.SoftwareLogo);
if (File.Exists(softwareLogoPath))
{
context.Images["softwareLogo"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = softwareLogoPath
};
_logger.Info("软件 Logo 已加载: {Path} | Software logo loaded: {Path}", softwareLogoPath);
}
else
{
_logger.Warn("软件 Logo 文件不存在: {Path} | Software logo file not found: {Path}", softwareLogoPath);
}
}
}
/// <summary>
/// 注入自定义属性 | Inject custom properties
/// </summary>
private void InjectCustomProperties(ReportContext context, ReportRequest request)
{
if (request.CustomProperties == null || request.CustomProperties.Count == 0)
return;
foreach (var kvp in request.CustomProperties)
{
context.Properties[kvp.Key] = kvp.Value;
}
}
/// <summary>
/// 根据 ReportContext 中的结果分组生成首页汇总表数据
/// Generate homepage summary table data from ReportContext result groups
/// </summary>
private List<Dictionary<string, object>> CreateSummaryTableData(ReportContext context)
{
var rows = new List<Dictionary<string, object>>();
foreach (var group in context.ResultGroups)
{
var inspectionType = group.ProcessorType switch
{
"LineMeasurementProcessor" => "线测量 | Line Measurement",
"BgaVoidRateProcessor" => "BGA 气泡率检测 | BGA Void Rate",
"VoidMeasurementProcessor" => "空隙测量 | Void Measurement",
"FillRateProcessor" => "通孔填锡率 | Via Fill Rate",
_ => group.ProcessorType
};
var classification = string.IsNullOrEmpty(group.Classification) ? "N/A" : group.Classification;
var status = classification == "Pass" ? "[PASS] 合格" : classification == "Fail" ? "[FAIL] 不合格" : "—";
rows.Add(new Dictionary<string, object>
{
["inspectionType"] = inspectionType,
["classification"] = classification,
["status"] = status
});
}
return rows;
}
#endregion
}
}
+24 -133
View File
@@ -17,13 +17,12 @@ namespace XP.ReportEngine.ViewModels
{
/// <summary>
/// 报告生成演示窗口 ViewModel | Report generation demo window ViewModel
/// 演示如何使用 XP.ReportEngine 生成 PDF 报告
/// Demonstrates how to use XP.ReportEngine to generate PDF reports
/// 演示如何使用 IReportService 生成 PDF 报告
/// Demonstrates how to use IReportService to generate PDF reports
/// </summary>
public class ReportDemoViewModel : BindableBase
{
private readonly IReportGenerator _reportGenerator;
private readonly IReportDataAdapter _dataAdapter;
private readonly IReportService _reportService;
private readonly IPdfViewerService _pdfViewerService;
private readonly IPdfPrintService _pdfPrintService;
private readonly ILoggerService _logger;
@@ -167,16 +166,14 @@ namespace XP.ReportEngine.ViewModels
/// 构造函数 | Constructor
/// </summary>
public ReportDemoViewModel(
IReportGenerator reportGenerator,
IReportDataAdapter dataAdapter,
IReportService reportService,
IPdfViewerService pdfViewerService,
IPdfPrintService pdfPrintService,
ILoggerService logger,
ReportIdGenerator reportIdGenerator,
ReportConfig reportConfig)
{
_reportGenerator = reportGenerator ?? throw new ArgumentNullException(nameof(reportGenerator));
_dataAdapter = dataAdapter ?? throw new ArgumentNullException(nameof(dataAdapter));
_reportService = reportService ?? throw new ArgumentNullException(nameof(reportService));
_pdfViewerService = pdfViewerService ?? throw new ArgumentNullException(nameof(pdfViewerService));
_pdfPrintService = pdfPrintService ?? throw new ArgumentNullException(nameof(pdfPrintService));
_logger = logger?.ForModule<ReportDemoViewModel>() ?? throw new ArgumentNullException(nameof(logger));
@@ -274,123 +271,52 @@ namespace XP.ReportEngine.ViewModels
var processorOutputs = CreateMockProcessorOutputs();
// 步骤 2数据适配 | Step 2: Data adaptation
progressWindow.UpdateProgress("正在适配数据...", 30);
// 步骤 2构建报告请求 | Step 2: Build report request
progressWindow.UpdateProgress("正在组装报告数据...", 30);
await Task.Delay(200);
var metadata = new ReportMetadata
var request = new ReportRequest
{
ProcessorOutputs = processorOutputs,
Metadata = new ReportMetadata
{
ReportId = fileNameParams["ReportId"],
InspectionDate = DateTime.Now,
SampleName = ProductName,
OperatorName = OperatorName,
Description = Description
},
OutputFilePath = outputPath,
FileNameParameters = fileNameParams
};
var context = _dataAdapter.Adapt(processorOutputs, metadata);
// 注入公司 Logo 图像数据 | Inject company logo image data
if (!string.IsNullOrEmpty(_reportConfig.CompanyLogo))
{
var logoPath = System.IO.Path.IsPathRooted(_reportConfig.CompanyLogo)
? _reportConfig.CompanyLogo
: System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _reportConfig.CompanyLogo);
if (File.Exists(logoPath))
{
context.Images["companyLogo"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = logoPath
};
_logger.Info("公司 Logo 已加载:{Path} | Company logo loaded: {Path}", logoPath);
}
else
{
_logger.Warn("公司 Logo 文件不存在:{Path} | Company logo file not found: {Path}", logoPath);
}
}
// 注入公司名称到上下文属性 | Inject company name into context properties
if (!string.IsNullOrEmpty(_reportConfig.CompanyName))
{
context.Properties["CompanyName"] = _reportConfig.CompanyName;
}
// 注入软件名称到上下文属性 | Inject software name into context properties
if (!string.IsNullOrEmpty(_reportConfig.SoftwareName))
{
context.Properties["SoftwareName"] = _reportConfig.SoftwareName;
}
// 注入软件 Logo 图像数据 | Inject software logo image data
if (!string.IsNullOrEmpty(_reportConfig.SoftwareLogo))
{
var softwareLogoPath = System.IO.Path.IsPathRooted(_reportConfig.SoftwareLogo)
? _reportConfig.SoftwareLogo
: System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _reportConfig.SoftwareLogo);
if (File.Exists(softwareLogoPath))
{
context.Images["softwareLogo"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = softwareLogoPath
};
_logger.Info("软件 Logo 已加载:{Path} | Software logo loaded: {Path}", softwareLogoPath);
}
else
{
_logger.Warn("软件 Logo 文件不存在:{Path} | Software logo file not found: {Path}", softwareLogoPath);
}
}
// 注入工件整体图片 | Inject workpiece overview image
var workpieceImagePath = System.IO.Path.Combine(MockImageDirectory, "OverView.png");
if (File.Exists(workpieceImagePath))
{
context.Images["workpieceImage"] = new ImageData
request.AdditionalImages["workpieceImage"] = new ImageData
{
SourceType = ImageSourceType.FilePath,
FilePath = workpieceImagePath
};
_logger.Info("工件整体图片已加载:{Path} | Workpiece image loaded: {Path}", workpieceImagePath);
}
else
{
_logger.Warn("工件整体图片不存在:{Path} | Workpiece image not found: {Path}", workpieceImagePath);
}
// 注入首页汇总表数据 | Inject homepage summary table data
context.Properties["summaryTable"] = CreateSummaryTableData(context);
// 步骤 3:确定模板 | Step 3: Determine template
progressWindow.UpdateProgress("正在加载模板...", 50);
// 步骤 3:调用报告服务生成(在后台线程执行,避免阻塞 UI)
// Step 3: Call report service (on background thread to avoid blocking UI)
progressWindow.UpdateProgress("正在生成 PDF...", 60);
await Task.Delay(200);
var templatePath = _reportConfig.GetResolvedTemplatePath();
var genResult = await Task.Run(() => _reportService.GenerateAsync(request));
var options = new ReportGenerationOptions
{
TemplatePath = templatePath,
OutputFilePath = outputPath,
Format = ReportOutputFormat.Pdf
};
// 步骤 4:生成 PDF | Step 4: Generate PDF
progressWindow.UpdateProgress("正在生成 PDF...", 70);
var genResult = await _reportGenerator.GenerateAsync(context, options);
// 步骤 5:处理结果 | Step 5: Handle result
// 步骤 4:处理结果 | Step 4: Handle result
progressWindow.UpdateProgress("正在完成...", 95);
await Task.Delay(200);
if (genResult.IsSuccess)
{
_lastOutputPath = outputPath;
StatusMessage = $"报告生成成功:{outputPath}";
_logger.Info("报告生成成功:{Path} | Report generated successfully: {Path}", outputPath);
_lastOutputPath = genResult.OutputFilePath;
StatusMessage = $"报告生成成功:{genResult.OutputFilePath}";
_logger.Info("报告生成成功:{Path} | Report generated successfully: {Path}", genResult.OutputFilePath);
progressWindow.UpdateProgress("报告生成完成!", 100);
await Task.Delay(500);
@@ -400,7 +326,7 @@ namespace XP.ReportEngine.ViewModels
{
try
{
_pdfViewerService.OpenViewer(outputPath);
_pdfViewerService.OpenViewer(genResult.OutputFilePath);
}
catch (Exception viewerEx)
{
@@ -687,41 +613,6 @@ namespace XP.ReportEngine.ViewModels
};
}
/// <summary>
/// 根据 ReportContext 中的结果分组生成首页汇总表数据
/// Generate homepage summary table data from ReportContext result groups
/// </summary>
/// <param name="context">报告上下文 | Report context</param>
/// <returns>汇总表行数据 | Summary table row data</returns>
private List<Dictionary<string, object>> CreateSummaryTableData(ReportContext context)
{
var rows = new List<Dictionary<string, object>>();
foreach (var group in context.ResultGroups)
{
var inspectionType = group.ProcessorType switch
{
"LineMeasurementProcessor" => "线测量 | Line Measurement",
"BgaVoidRateProcessor" => "BGA 气泡率检测 | BGA Void Rate",
"VoidMeasurementProcessor" => "空隙测量 | Void Measurement",
"FillRateProcessor" => "通孔填锡率 | Via Fill Rate",
_ => group.ProcessorType
};
var classification = string.IsNullOrEmpty(group.Classification) ? "N/A" : group.Classification;
var status = classification == "Pass" ? "[PASS] 合格" : classification == "Fail" ? "[FAIL] 不合格" : "—";
rows.Add(new Dictionary<string, object>
{
["inspectionType"] = inspectionType,
["classification"] = classification,
["status"] = status
});
}
return rows;
}
#endregion
}
}
+55 -54
View File
@@ -1,8 +1,9 @@
<Window x:Class="XP.ReportEngine.Views.ReportDemoWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
Title="报告生成演示 | Report Generation Demo"
Width="560" Height="580"
Width="580" Height="380"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize">
<Grid Margin="20">
@@ -10,98 +11,98 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 标题 | Title -->
<TextBlock Grid.Row="0" Text="XP.ReportEngine 报告生成演示"
FontSize="18" FontWeight="Bold" Margin="0,0,0,12"/>
FontSize="18" FontWeight="SemiBold" Margin="0,0,0,16"
Foreground="#FF333333"/>
<!-- 输入表单 | Input form -->
<Grid Grid.Row="1" Margin="0,0,0,12">
<Grid Grid.Row="1" Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="24"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="28"/>
<RowDefinition Height="28"/>
<RowDefinition Height="28"/>
<RowDefinition Height="28"/>
<RowDefinition Height="28"/>
<RowDefinition Height="32"/>
<RowDefinition Height="32"/>
<RowDefinition Height="32"/>
<RowDefinition Height="32"/>
<RowDefinition Height="32"/>
</Grid.RowDefinitions>
<!-- 第一列 | Column 1 -->
<TextBlock Grid.Row="0" Grid.Column="0" Text="产品名称:" VerticalAlignment="Center"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding ProductName, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="0" Grid.Column="1"
Text="{Binding ProductName, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入产品名称"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="产品类型码:" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ProductCode, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="1" Grid.Column="1"
Text="{Binding ProductCode, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入产品类型码"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="工件 SN 码:" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding WorkpieceSN, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="2" Grid.Column="1"
Text="{Binding WorkpieceSN, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入工件序列号"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="CNC 程序:" VerticalAlignment="Center"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CncProgram, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="3" Grid.Column="1"
Text="{Binding CncProgram, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入 CNC 程序名"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="操作员:" VerticalAlignment="Center"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding OperatorName, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="4" Grid.Column="1"
Text="{Binding OperatorName, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入操作员"
telerik:StyleManager.Theme="Crystal"/>
<!-- 第二列 | Column 2 -->
<TextBlock Grid.Row="0" Grid.Column="3" Text="设备编号:" VerticalAlignment="Center"/>
<TextBox Grid.Row="0" Grid.Column="4" Text="{Binding DeviceId, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="0" Grid.Column="4"
Text="{Binding DeviceId, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入设备编号"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="1" Grid.Column="3" Text="生产机台号:" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="4" Text="{Binding MachineId, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="1" Grid.Column="4"
Text="{Binding MachineId, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入机台号"
telerik:StyleManager.Theme="Crystal"/>
<TextBlock Grid.Row="2" Grid.Column="3" Text="描述:" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="4" Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center"/>
<telerik:RadWatermarkTextBox Grid.Row="2" Grid.Column="4"
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}"
WatermarkContent="输入描述信息"
telerik:StyleManager.Theme="Crystal"/>
</Grid>
<!-- 按钮区域 | Button area -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,12">
<Button Content="生成报告" Command="{Binding GenerateReportCommand}"
Width="100" Height="32" Margin="0,0,10,0"/>
<Button Content="查看 PDF" Command="{Binding OpenViewerCommand}"
Width="100" Height="32" Margin="0,0,10,0"/>
<Button Content="打印报告" Command="{Binding PrintReportCommand}"
Width="100" Height="32"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,16">
<telerik:RadButton Content="生成报告" Command="{Binding GenerateReportCommand}"
Width="110" Height="30" Margin="0,0,12,0"
telerik:StyleManager.Theme="Crystal"/>
<telerik:RadButton Content="查看 PDF" Command="{Binding OpenViewerCommand}"
Width="110" Height="30" Margin="0,0,12,0"
telerik:StyleManager.Theme="Crystal"/>
<telerik:RadButton Content="打印报告" Command="{Binding PrintReportCommand}"
Width="110" Height="30"
telerik:StyleManager.Theme="Crystal"/>
</StackPanel>
<!-- 说明区域 | Instructions -->
<Border Grid.Row="3" BorderBrush="#DDDDDD" BorderThickness="1"
Background="#F9F9F9" CornerRadius="4" Padding="10">
<TextBlock TextWrapping="Wrap" FontSize="12" Foreground="#555555">
<Run FontWeight="Bold">使用说明:</Run>
<LineBreak/>
<Run>1. 填写产品信息、设备信息和操作员</Run>
<LineBreak/>
<Run>2. 点击"生成报告",系统将使用模拟检测数据生成 PDF</Run>
<LineBreak/>
<Run>3. 生成完成后,可点击"查看 PDF"在阅读器中预览</Run>
<LineBreak/>
<Run>4. 点击"打印报告"可将 PDF 发送到打印机</Run>
<LineBreak/>
<LineBreak/>
<Run FontWeight="Bold">文件名模式(App.config 配置):</Run>
<LineBreak/>
<Run>{ReportId} {ProductName} {ProductCode} {WorkpieceSN}</Run>
<LineBreak/>
<Run>{CncProgram} {DeviceId} {MachineId} {Date} {Time} {Result}</Run>
<LineBreak/>
<LineBreak/>
<Run FontWeight="Bold">重复文件处理:</Run>
<Run>AutoIncrementOnDuplicate=true 时自动累加 (1)(2)...</Run>
</TextBlock>
</Border>
<!-- 状态栏 | Status bar -->
<Border Grid.Row="4" Background="#F0F0F0" Margin="0,10,0,0" Padding="8,5">
<TextBlock Text="{Binding StatusMessage}" FontSize="11" Foreground="#333333"
<Border Grid.Row="3" Background="#F5F5F7" CornerRadius="4" Padding="10,8">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#FF333333"
TextTrimming="CharacterEllipsis"/>
</Border>
</Grid>