VERSION 0.12.0 — PDF 导出 + 报告模板架构 + 对接文档 V2.0
- PdfSharpCore 1.3.64 引入(纯托管,Unity IL2CPP 兼容,+9 DLL) - ReportData 结构化模型 + MarkdownRenderer + StandardPdfTemplate - IReportService.ExportReport(id, format) 按需导出 PDF/MD - 仿真后自动生成 MD 到 reports 目录,PDF 按需调用 - CJK 字体嵌入(SimHei),IPathProvider + GetFontPath - ReportGenerator 重构为构建 ReportData,去掉 emoji - check_unity_build.ps1 修复 -quit 参数缺失导致超时 - 对接文档 V2.0:坐标系/3D 可视化/完整枚举/Manager 签名/模型字段 - 262 测试全部通过
This commit is contained in:
parent
f77fb88487
commit
8bcf4cf675
36
CHANGELOG.md
36
CHANGELOG.md
@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-06-20
|
||||||
|
|
||||||
|
### Added — PDF 导出 + 报告模板架构(Phase 8.2.1)
|
||||||
|
|
||||||
|
- **PdfSharpCore 1.3.64**:纯托管 PDF 库(netstandard2.0,Unity IL2CPP 兼容),新增 9 个传递依赖 DLL
|
||||||
|
- **ReportData 结构化模型**:`ReportData → Sections → Blocks`(KeyValueBlock / TableBlock / TextBlock),替代原 StringBuilder 拼接
|
||||||
|
- **ReportBlockConverter**:ReportBlock 多态 JSON 序列化转换器(netstandard2.1 不支持 JsonPolymorphic 属性)
|
||||||
|
- **MarkdownRenderer**:从 ReportData 渲染 Markdown,与 PDF 共用数据源
|
||||||
|
- **StandardPdfTemplate**:内置标准 PDF 模板(A4、中文字体嵌入、表格边框、自动分页、页码)
|
||||||
|
- **CjkFontResolver**:PdfSharpCore IFontResolver 实现,从文件路径加载 CJK TrueType 字体
|
||||||
|
- **IPathProvider.GetFontPath()**:字体文件路径接口,`UnityPathProvider` / `TestPathProvider` 均已实现
|
||||||
|
- **SimulationReport +ReportDataJson**:结构化报告数据 JSON 字段,供 PDF 重新渲染
|
||||||
|
- **IReportService.ExportReport(id, format)**:返回 `byte[]`,支持 `"pdf"` / `"md"`
|
||||||
|
- **IReportService.ExportToFile(id, dir, format)**:导出文件,返回路径
|
||||||
|
- **ReportService.Generate 自动导出 MD**:仿真后自动生成 `.md` 文件到 `{DataRoot}/reports/`
|
||||||
|
- **Unity ReportManager**:新增 `ExportReport(reportId, format)` 和 `Export(reportId, dir, format)` 桥接
|
||||||
|
- **CJK 字体文件**:`data/fonts/CJK-Font.ttf`(SimHei,9.7MB)+ Unity StreamingAssets 同步
|
||||||
|
- **check_unity_build.ps1 修复**:加 `-quit` 参数 + 300s 超时,解决 Unity batchmode 不退出导致挂起
|
||||||
|
|
||||||
|
### Changed — ReportGenerator 重构
|
||||||
|
|
||||||
|
- **ReportGenerator.Generate 返回 ReportData**(原返回 string),翻译函数去掉 emoji 前缀(SimHei 不支持 emoji 字形)
|
||||||
|
- **ExportToFile 签名变更**:加 `format` 参数(`"pdf"` / `"md"`),不向后兼容旧的两参数签名
|
||||||
|
- **DLL 数量 14→22**:新增 PdfSharpCore + SharpZipLib + SixLabors.Fonts/ImageSharp + 5 个 System.* 传递依赖
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- 对接文档 V2.0:新增坐标系与 3D 可视化章节、完整枚举值清单、所有 Manager 完整方法签名、核心模型完整字段
|
||||||
|
- 架构设计:导出章节更新为 PdfSharpCore 选型,接口签名同步
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
- 测试 **262**(+12),全部通过 15s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.11.0] - 2026-06-18
|
## [0.11.0] - 2026-06-18
|
||||||
|
|
||||||
### Breaking — 发射平台与探测设备分离
|
### Breaking — 发射平台与探测设备分离
|
||||||
|
|||||||
BIN
data/fonts/CJK-Font.ttf
Normal file
BIN
data/fonts/CJK-Font.ttf
Normal file
Binary file not shown.
@ -1367,9 +1367,10 @@ public class FrameDataStore
|
|||||||
|
|
||||||
### 8.3 导出
|
### 8.3 导出
|
||||||
|
|
||||||
- 格式:PDF / Word(默认 PDF)
|
- 格式:PDF / Markdown(仿真后自动生成 MD,PDF 按需调用导出)
|
||||||
- 模板占位符填充后输出
|
- 架构:ReportGenerator 构建结构化 ReportData → MarkdownRenderer / StandardPdfTemplate 分别渲染
|
||||||
- 技术选型:QuestPDF 或其他 .NET Standard 2.1 兼容方案(待调研)
|
- 技术选型:PdfSharpCore 1.3.64(netstandard2.0,纯托管,Unity IL2CPP 兼容,CJK 字体嵌入)
|
||||||
|
- 字体:随程序附带 CJK TrueType 字体(`data/fonts/CJK-Font.ttf`),通过 `IPathProvider.GetFontPath()` 获取路径
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1435,11 +1436,13 @@ class FrameDataStore
|
|||||||
// === 仿真报告 ===
|
// === 仿真报告 ===
|
||||||
interface IReportService
|
interface IReportService
|
||||||
{
|
{
|
||||||
SimulationReport Generate(string scenarioId);
|
SimulationReport Generate(string scenarioId, ScenarioConfig config,
|
||||||
|
List<SimEvent> events, string finalDroneStatus, float duration); // 仿真后调用,自动导出 MD
|
||||||
SimulationReport GetReport(string id);
|
SimulationReport GetReport(string id);
|
||||||
void DeleteReport(string id);
|
void DeleteReport(string id);
|
||||||
byte[] ExportReport(string id, string format); // "pdf" | "word"
|
byte[] ExportReport(string id, string format); // "pdf" | "md",按需导出
|
||||||
PagedResult<SimulationReport> SearchReports(string kw, DateTime? from, DateTime? to, int page, int size);
|
string ExportToFile(string reportId, string outputDir, string format); // 导出文件,返回路径
|
||||||
|
PagedResult<SimulationReport> SearchReports(string kw, string? from, string? to, int page, int size);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1933,7 +1936,7 @@ FOR EACH 飞行无人机(维护"本帧已发现该目标的最优探测设备"
|
|||||||
| 5 | 蜂群模式运动模型 | 随机扰动范围、个体行为差异 |
|
| 5 | 蜂群模式运动模型 | 随机扰动范围、个体行为差异 |
|
||||||
| 6 | 报告模板具体格式 | 统计维度、图表需求 |
|
| 6 | 报告模板具体格式 | 统计维度、图表需求 |
|
||||||
| 7 | 场景类型对仿真的影响 | 山区是否影响探测/运动 |
|
| 7 | 场景类型对仿真的影响 | 山区是否影响探测/运动 |
|
||||||
| 8 | PDF/Word 导出库选型 | QuestPDF 或其他 .NET Standard 2.1 方案 |
|
| 8 | ~~PDF/Word 导出库选型~~ | ✅ 已选定 PdfSharpCore 1.3.64(netstandard2.0,纯托管,Unity 兼容) |
|
||||||
| 9 | 预置典型目标库内容 | 具体包含哪些无人机型号及参数 |
|
| 9 | 预置典型目标库内容 | 具体包含哪些无人机型号及参数 |
|
||||||
| 10 | 扩散模型的大气稳定度参数 | 不同天气条件下 σ 的增长速率如何量化 |
|
| 10 | 扩散模型的大气稳定度参数 | 不同天气条件下 σ 的增长速率如何量化 |
|
||||||
| 11 | 毁伤曲线的具体数值 | 三种机制的 damageRate、threshold、burstDamage 等参数取值 |
|
| 11 | 毁伤曲线的具体数值 | 三种机制的 damageRate、threshold、burstDamage 等参数取值 |
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# 实施计划与任务跟踪
|
# 实施计划与任务跟踪
|
||||||
|
|
||||||
> **项目**:反无人机仿真系统后端
|
> **项目**:反无人机仿真系统后端
|
||||||
> **文档版本**:V1.6
|
> **文档版本**:V1.7
|
||||||
> **更新日期**:2026-06-18
|
> **更新日期**:2026-06-20
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -95,7 +95,7 @@ Phase 13 ✅ 模型重构:分层架构 + 代码审查 + 探测分离
|
|||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 5.1-5.8 | ReportGenerator、ReportService、ExportToFile、搜索分页、单元测试 | ✅ |
|
| 5.1-5.8 | ReportGenerator、ReportService、ExportToFile、搜索分页、单元测试 | ✅ |
|
||||||
|
|
||||||
**注**:PDF/Word 导出暂以 Markdown 替代,后续可扩展。
|
**注**:PDF 导出已实现(见 8.2.1),仿真后自动生成 MD,PDF 按需调用 `ExportReport(id, "pdf")`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -144,10 +144,10 @@ Phase 13 ✅ 模型重构:分层架构 + 代码审查 + 探测分离
|
|||||||
|
|
||||||
| 指标 | 值 |
|
| 指标 | 值 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 测试总数 | **237**(全部通过,1 跳过-Jet高弹道) |
|
| 测试总数 | **262**(全部通过) |
|
||||||
| 行覆盖率 | **95%+** |
|
| 行覆盖率 | **95%+** |
|
||||||
| 分支覆盖率 | **80%+** |
|
| 分支覆盖率 | **80%+** |
|
||||||
| 执行时间 | ~11 秒 |
|
| 执行时间 | ~15 秒 |
|
||||||
| Core 程序集 | `CounterDrone.Core.dll` (.NET Standard 2.1) |
|
| Core 程序集 | `CounterDrone.Core.dll` (.NET Standard 2.1) |
|
||||||
| 共享物理工具类 | `Kinematics` / `RouteGeometry` / `CloudExpansionModel` / `DamageAssessment` |
|
| 共享物理工具类 | `Kinematics` / `RouteGeometry` / `CloudExpansionModel` / `DamageAssessment` |
|
||||||
| 全局配置 | `data/planner_config.json`(planner 策略参数,代码零默认值) |
|
| 全局配置 | `data/planner_config.json`(planner 策略参数,代码零默认值) |
|
||||||
@ -190,7 +190,7 @@ Phase 13 ✅ 模型重构:分层架构 + 代码审查 + 探测分离
|
|||||||
|
|
||||||
| # | 功能 | 说明 |
|
| # | 功能 | 说明 |
|
||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 8.2.1 | PDF 导出 | ⬜ QuestPDF 或其他 .NET Standard 2.1 方案调研,`IReportService.ExportReport()` 实现 |
|
| 8.2.1 | PDF 导出 | ✅ PdfSharpCore(netstandard2.0,纯托管 Unity 兼容)+ ReportData 结构化模型 + StandardPdfTemplate 内置标准模板 + CJK 字体嵌入。`IReportService.ExportReport(id, "pdf")` 按需导出,仿真后自动生成 MD 到报告目录 |
|
||||||
| 8.2.2 | Word 导出 | ⬜ 调研可行方案 |
|
| 8.2.2 | Word 导出 | ⬜ 调研可行方案 |
|
||||||
|
|
||||||
### 8.3 第三方对接
|
### 8.3 第三方对接
|
||||||
@ -452,6 +452,7 @@ T1(数据层)→ T2(算法层 IsInCoverage)
|
|||||||
| M10 — 探测实时链路 | ✅ |
|
| M10 — 探测实时链路 | ✅ |
|
||||||
| M11 — 运动学重构 + 实体暴露 + 基础数据 CRUD | ✅ |
|
| M11 — 运动学重构 + 实体暴露 + 基础数据 CRUD | ✅ |
|
||||||
| M12 — 空基/地基统一规划 + 编队轴 + LaneDivider | ✅ |
|
| M12 — 空基/地基统一规划 + 编队轴 + LaneDivider | ✅ |
|
||||||
|
| M13 — PDF 导出 + 报告模板架构 | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -14,9 +14,27 @@ Copy-Item "$projectRoot\unity_plugins\*.dll" "$unityProject\Assets\Plugins\Count
|
|||||||
Remove-Item "$unityProject\Library\ScriptAssemblies" -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item "$unityProject\Library\ScriptAssemblies" -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Write-Host "Compiling Unity scripts..."
|
Write-Host "Compiling Unity scripts..."
|
||||||
|
|
||||||
# 3. 编译(CompileErrorDetector 捕获错误并 Exit(-1))
|
# 3. 编译(-quit 确保编译完自动退出;超时 300s 兜底)
|
||||||
& $unityEditor -batchmode -projectPath $unityProject -logFile - 2>&1 | Out-Null
|
Write-Host "Compiling Unity scripts (timeout 300s)..."
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "UNITY BUILD FAILED (exit code $LASTEXITCODE)"; exit 1 }
|
$logFile = "$projectRoot\unity_build.log"
|
||||||
|
$proc = Start-Process -FilePath $unityEditor `
|
||||||
|
-ArgumentList "-batchmode","-quit","-projectPath","`"$unityProject`"","-logFile","`"$logFile`"" `
|
||||||
|
-PassThru -NoNewWindow
|
||||||
|
$proc | Wait-Process -Timeout 300 -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if (-not $proc.HasExited) {
|
||||||
|
Write-Host "Unity build TIMEOUT (300s), killing process..."
|
||||||
|
$proc | Stop-Process -Force
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($proc.ExitCode -ne 0) {
|
||||||
|
Write-Host "UNITY BUILD FAILED (exit code $($proc.ExitCode))"
|
||||||
|
if (Test-Path $logFile) {
|
||||||
|
Write-Host "--- Last 30 lines of log ---"
|
||||||
|
Get-Content $logFile -Tail 30
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "ALL CHECKS PASSED"
|
Write-Host "ALL CHECKS PASSED"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="PdfSharpCore" Version="1.3.64" />
|
||||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@ -7,5 +7,6 @@ namespace CounterDrone.Core
|
|||||||
string GetMainDbPath();
|
string GetMainDbPath();
|
||||||
string GetFramesDir();
|
string GetFramesDir();
|
||||||
string GetModelsDir();
|
string GetModelsDir();
|
||||||
|
string GetFontPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,10 @@ namespace CounterDrone.Core.Models
|
|||||||
|
|
||||||
public int InterceptResult { get; set; }
|
public int InterceptResult { get; set; }
|
||||||
|
|
||||||
/// <summary>报告富文本 / 结构化 JSON</summary>
|
/// <summary>Markdown 文本(用于快速展示)</summary>
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>结构化报告数据 JSON(用于 PDF 等格式重新渲染)</summary>
|
||||||
|
public string? ReportDataJson { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/CounterDrone.Core/Reporting/FontResolver.cs
Normal file
40
src/CounterDrone.Core/Reporting/FontResolver.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using PdfSharpCore.Fonts;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>CJK 字体解析器 — 从文件路径加载 TrueType 字体供 PdfSharpCore 使用</summary>
|
||||||
|
public sealed class CjkFontResolver : IFontResolver
|
||||||
|
{
|
||||||
|
private readonly byte[] _fontData;
|
||||||
|
private readonly string _familyName;
|
||||||
|
|
||||||
|
/// <summary>注册 CJK 字体解析器到全局设置(进程内只能注册一次)</summary>
|
||||||
|
/// <param name="fontPath">CJK TrueType/OpenType 字体文件路径</param>
|
||||||
|
/// <param name="familyName">字体族名(用于 XFont 构造)</param>
|
||||||
|
public static void Register(string fontPath, string familyName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fontPath))
|
||||||
|
throw new ArgumentException("字体路径不能为空", nameof(fontPath));
|
||||||
|
if (!File.Exists(fontPath))
|
||||||
|
throw new FileNotFoundException($"字体文件不存在: {fontPath}", fontPath);
|
||||||
|
|
||||||
|
if (GlobalFontSettings.FontResolver == null)
|
||||||
|
GlobalFontSettings.FontResolver = new CjkFontResolver(fontPath, familyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CjkFontResolver(string fontPath, string familyName)
|
||||||
|
{
|
||||||
|
_fontData = File.ReadAllBytes(fontPath);
|
||||||
|
_familyName = familyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic)
|
||||||
|
=> new FontResolverInfo(_familyName);
|
||||||
|
|
||||||
|
public byte[] GetFont(string faceName) => _fontData;
|
||||||
|
|
||||||
|
public string DefaultFontName => _familyName;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/CounterDrone.Core/Reporting/IReportTemplate.cs
Normal file
8
src/CounterDrone.Core/Reporting/IReportTemplate.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>报告模板接口 — 从 ReportData 渲染为目标格式</summary>
|
||||||
|
public interface IReportTemplate
|
||||||
|
{
|
||||||
|
byte[] Render(ReportData data);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/CounterDrone.Core/Reporting/MarkdownRenderer.cs
Normal file
79
src/CounterDrone.Core/Reporting/MarkdownRenderer.cs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>从 ReportData 渲染 Markdown 文本</summary>
|
||||||
|
public class MarkdownRenderer
|
||||||
|
{
|
||||||
|
public string Render(ReportData data)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine($"# {data.Title}");
|
||||||
|
sb.AppendLine();
|
||||||
|
AppendKeyValueTable(sb, "项目", "值", data.Header);
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
foreach (var section in data.Sections)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"## {section.Title}");
|
||||||
|
sb.AppendLine();
|
||||||
|
foreach (var block in section.Blocks)
|
||||||
|
RenderBlock(sb, block);
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderBlock(StringBuilder sb, ReportBlock block)
|
||||||
|
{
|
||||||
|
switch (block)
|
||||||
|
{
|
||||||
|
case KeyValueBlock kv:
|
||||||
|
AppendKeyValueTable(sb, kv.LabelHeader, kv.ValueHeader, kv.Items);
|
||||||
|
sb.AppendLine();
|
||||||
|
break;
|
||||||
|
case TableBlock tb:
|
||||||
|
if (!string.IsNullOrEmpty(tb.Caption))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"### {tb.Caption}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
AppendTable(sb, tb.Headers, tb.Rows);
|
||||||
|
sb.AppendLine();
|
||||||
|
break;
|
||||||
|
case TextBlock text:
|
||||||
|
if (text.Bold)
|
||||||
|
sb.AppendLine($"**{text.Text}**");
|
||||||
|
else
|
||||||
|
sb.AppendLine(text.Text);
|
||||||
|
sb.AppendLine();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendKeyValueTable(StringBuilder sb, string labelHeader, string valueHeader, List<MetaItem> items)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"| {labelHeader} | {valueHeader} |");
|
||||||
|
sb.AppendLine($"|{new string('-', labelHeader.Length + 2)}|{new string('-', valueHeader.Length + 2)}|");
|
||||||
|
foreach (var item in items)
|
||||||
|
sb.AppendLine($"| {item.Label} | {item.Value} |");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendTable(StringBuilder sb, string[] headers, List<string[]> rows)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"| {string.Join(" | ", headers)} |");
|
||||||
|
sb.AppendLine($"|{string.Join("|", RepeatEach(headers, h => new string('-', h.Length + 2)))}|");
|
||||||
|
foreach (var row in rows)
|
||||||
|
sb.AppendLine($"| {string.Join(" | ", row)} |");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> RepeatEach(string[] source, System.Func<string, string> fn)
|
||||||
|
{
|
||||||
|
foreach (var s in source)
|
||||||
|
yield return fn(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/CounterDrone.Core/Reporting/ReportBlockConverter.cs
Normal file
55
src/CounterDrone.Core/Reporting/ReportBlockConverter.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>ReportBlock 多态序列化转换器</summary>
|
||||||
|
public class ReportBlockConverter : JsonConverter<ReportBlock>
|
||||||
|
{
|
||||||
|
public override ReportBlock Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.ParseValue(ref reader);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("$blockType", out var typeProp))
|
||||||
|
throw new JsonException("缺少 $blockType 字段");
|
||||||
|
|
||||||
|
var typeName = typeProp.GetString();
|
||||||
|
var rawText = root.GetRawText();
|
||||||
|
return typeName switch
|
||||||
|
{
|
||||||
|
"KeyValueBlock" => JsonSerializer.Deserialize<KeyValueBlock>(rawText, options)!,
|
||||||
|
"TableBlock" => JsonSerializer.Deserialize<TableBlock>(rawText, options)!,
|
||||||
|
"TextBlock" => JsonSerializer.Deserialize<TextBlock>(rawText, options)!,
|
||||||
|
_ => throw new JsonException($"未知的 block 类型: {typeName}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, ReportBlock value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("$blockType", value.GetType().Name);
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case KeyValueBlock kv:
|
||||||
|
writer.WriteString(nameof(kv.LabelHeader), kv.LabelHeader);
|
||||||
|
writer.WriteString(nameof(kv.ValueHeader), kv.ValueHeader);
|
||||||
|
writer.WritePropertyName(nameof(kv.Items));
|
||||||
|
JsonSerializer.Serialize(writer, kv.Items, options);
|
||||||
|
break;
|
||||||
|
case TableBlock tb:
|
||||||
|
writer.WriteString(nameof(tb.Caption), tb.Caption);
|
||||||
|
writer.WritePropertyName(nameof(tb.Headers));
|
||||||
|
JsonSerializer.Serialize(writer, tb.Headers, options);
|
||||||
|
writer.WritePropertyName(nameof(tb.Rows));
|
||||||
|
JsonSerializer.Serialize(writer, tb.Rows, options);
|
||||||
|
break;
|
||||||
|
case TextBlock text:
|
||||||
|
writer.WriteString(nameof(text.Text), text.Text);
|
||||||
|
writer.WriteBoolean(nameof(text.Bold), text.Bold);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/CounterDrone.Core/Reporting/ReportData.cs
Normal file
50
src/CounterDrone.Core/Reporting/ReportData.cs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>结构化报告数据 — 模板渲染的统一数据源</summary>
|
||||||
|
public class ReportData
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public List<MetaItem> Header { get; set; } = new();
|
||||||
|
public List<ReportSection> Sections { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MetaItem
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReportSection
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public List<ReportBlock> Blocks { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class ReportBlock { }
|
||||||
|
|
||||||
|
/// <summary>键值对表(两列:参数 / 值)</summary>
|
||||||
|
public class KeyValueBlock : ReportBlock
|
||||||
|
{
|
||||||
|
public string LabelHeader { get; set; } = "参数";
|
||||||
|
public string ValueHeader { get; set; } = "值";
|
||||||
|
public List<MetaItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>通用表格(可带子标题)</summary>
|
||||||
|
public class TableBlock : ReportBlock
|
||||||
|
{
|
||||||
|
public string Caption { get; set; } = "";
|
||||||
|
public string[] Headers { get; set; } = Array.Empty<string>();
|
||||||
|
public List<string[]> Rows { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文本段落(支持加粗、多行列表)</summary>
|
||||||
|
public class TextBlock : ReportBlock
|
||||||
|
{
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
public bool Bold { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
377
src/CounterDrone.Core/Reporting/StandardPdfTemplate.cs
Normal file
377
src/CounterDrone.Core/Reporting/StandardPdfTemplate.cs
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using PdfSharpCore;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Reporting
|
||||||
|
{
|
||||||
|
/// <summary>标准 PDF 模板 — A4 排版,中文字体,表格边框,自动分页</summary>
|
||||||
|
public class StandardPdfTemplate : IReportTemplate
|
||||||
|
{
|
||||||
|
private readonly string _fontFamily;
|
||||||
|
private XFont _titleFont, _sectionFont, _tableHeaderFont, _tableCellFont, _textFont, _footerFont;
|
||||||
|
|
||||||
|
private const double MarginMm = 20;
|
||||||
|
private const double FooterMm = 15;
|
||||||
|
private static double MmToPt(double mm) => mm * 72.0 / 25.4;
|
||||||
|
private static readonly double MarginPt = MmToPt(MarginMm);
|
||||||
|
private static readonly XColor HeaderBgColor = XColor.FromArgb(240, 240, 240);
|
||||||
|
private static readonly XColor BorderColor = XColor.FromArgb(180, 180, 180);
|
||||||
|
private static readonly XColor TitleColor = XColor.FromArgb(30, 30, 30);
|
||||||
|
private static readonly XColor SectionColor = XColor.FromArgb(50, 50, 50);
|
||||||
|
|
||||||
|
public StandardPdfTemplate(string fontPath, string fontFamily)
|
||||||
|
{
|
||||||
|
CjkFontResolver.Register(fontPath, fontFamily);
|
||||||
|
_fontFamily = fontFamily;
|
||||||
|
_titleFont = new XFont(_fontFamily, 22, XFontStyle.Bold);
|
||||||
|
_sectionFont = new XFont(_fontFamily, 15, XFontStyle.Bold);
|
||||||
|
_tableHeaderFont = new XFont(_fontFamily, 11, XFontStyle.Bold);
|
||||||
|
_tableCellFont = new XFont(_fontFamily, 11, XFontStyle.Regular);
|
||||||
|
_textFont = new XFont(_fontFamily, 12, XFontStyle.Regular);
|
||||||
|
_footerFont = new XFont(_fontFamily, 9, XFontStyle.Regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Render(ReportData data)
|
||||||
|
{
|
||||||
|
var doc = new PdfDocument();
|
||||||
|
doc.Info.Title = data.Title;
|
||||||
|
|
||||||
|
NewPage(doc);
|
||||||
|
|
||||||
|
DrawTitle(data.Title);
|
||||||
|
|
||||||
|
if (data.Header.Count > 0)
|
||||||
|
{
|
||||||
|
EnsureSpace(30);
|
||||||
|
DrawKeyValueTable(data.Header, "项目", "值");
|
||||||
|
_y += 16; // Header 后空行,与后续段落保持间距
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var section in data.Sections)
|
||||||
|
{
|
||||||
|
EnsureSpace(40);
|
||||||
|
DrawSectionTitle(section.Title);
|
||||||
|
|
||||||
|
foreach (var block in section.Blocks)
|
||||||
|
RenderBlock(doc, block);
|
||||||
|
|
||||||
|
_y += 16; // 段落后空行
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawFooter();
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
doc.Save(ms, false);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════ 页面管理 ═════════════════
|
||||||
|
|
||||||
|
private PdfPage _page = null!;
|
||||||
|
private XGraphics _gfx = null!;
|
||||||
|
private double _y;
|
||||||
|
private double _contentWidth;
|
||||||
|
private double _bottomMargin;
|
||||||
|
private int _pageNum;
|
||||||
|
|
||||||
|
private void NewPage(PdfDocument doc)
|
||||||
|
{
|
||||||
|
_page = doc.AddPage();
|
||||||
|
_page.Size = PageSize.A4;
|
||||||
|
_gfx = XGraphics.FromPdfPage(_page);
|
||||||
|
_y = MarginPt;
|
||||||
|
_contentWidth = _page.Width.Point - 2 * MarginPt;
|
||||||
|
_bottomMargin = _page.Height.Point - MmToPt(FooterMm);
|
||||||
|
_pageNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSpace(double neededPt)
|
||||||
|
{
|
||||||
|
if (_y + neededPt > _bottomMargin)
|
||||||
|
{
|
||||||
|
DrawFooter();
|
||||||
|
NewPage(_page.Owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawFooter()
|
||||||
|
{
|
||||||
|
var footerY = _page.Height.Point - MmToPt(FooterMm / 2);
|
||||||
|
var fmt = new XStringFormat { Alignment = XStringAlignment.Center };
|
||||||
|
_gfx.DrawString($"第 {_pageNum} 页", _footerFont, XBrushes.Gray,
|
||||||
|
new XRect(0, footerY, _page.Width.Point, 20), fmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════ 内容渲染 ═════════════════
|
||||||
|
|
||||||
|
private void DrawTitle(string title)
|
||||||
|
{
|
||||||
|
var fmt = new XStringFormat { Alignment = XStringAlignment.Center };
|
||||||
|
var h = _titleFont.GetHeight() + 10;
|
||||||
|
_gfx.DrawString(title, _titleFont, new XSolidBrush(TitleColor),
|
||||||
|
new XRect(MarginPt, _y, _contentWidth, h), fmt);
|
||||||
|
_y += h + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSectionTitle(string title)
|
||||||
|
{
|
||||||
|
var h = _sectionFont.GetHeight() + 6;
|
||||||
|
_gfx.DrawString(title, _sectionFont, new XSolidBrush(SectionColor),
|
||||||
|
new XRect(MarginPt, _y, _contentWidth, h),
|
||||||
|
new XStringFormat { Alignment = XStringAlignment.Near, LineAlignment = XLineAlignment.Near });
|
||||||
|
_y += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderBlock(PdfDocument doc, ReportBlock block)
|
||||||
|
{
|
||||||
|
switch (block)
|
||||||
|
{
|
||||||
|
case KeyValueBlock kv:
|
||||||
|
DrawKeyValueTable(kv.Items, kv.LabelHeader, kv.ValueHeader);
|
||||||
|
_y += 4;
|
||||||
|
break;
|
||||||
|
case TableBlock tb:
|
||||||
|
if (!string.IsNullOrEmpty(tb.Caption))
|
||||||
|
{
|
||||||
|
EnsureSpace(20);
|
||||||
|
_gfx.DrawString(tb.Caption, _textFont, XBrushes.Black,
|
||||||
|
new XRect(MarginPt, _y, _contentWidth, 16),
|
||||||
|
new XStringFormat { Alignment = XStringAlignment.Near, LineAlignment = XLineAlignment.Near });
|
||||||
|
_y += 18;
|
||||||
|
}
|
||||||
|
DrawTable(tb.Headers, tb.Rows);
|
||||||
|
_y += 4;
|
||||||
|
break;
|
||||||
|
case TextBlock text:
|
||||||
|
RenderTextBlock(text);
|
||||||
|
_y += 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawKeyValueTable(List<MetaItem> items, string labelHeader, string valueHeader)
|
||||||
|
{
|
||||||
|
double labelW = _contentWidth * 0.35;
|
||||||
|
double valueW = _contentWidth - labelW;
|
||||||
|
double rowH = 22;
|
||||||
|
double x = MarginPt;
|
||||||
|
|
||||||
|
// header row
|
||||||
|
EnsureSpace(rowH);
|
||||||
|
_gfx.DrawRectangle(new XSolidBrush(HeaderBgColor), x, _y, _contentWidth, rowH);
|
||||||
|
_gfx.DrawCellText(labelHeader, _tableHeaderFont, x + 6, _y, labelW, rowH);
|
||||||
|
_gfx.DrawCellText(valueHeader, _tableHeaderFont, x + labelW + 6, _y, valueW, rowH);
|
||||||
|
_gfx.DrawRectangle(new XPen(BorderColor), x, _y, _contentWidth, rowH);
|
||||||
|
_gfx.DrawLine(new XPen(BorderColor), x + labelW, _y, x + labelW, _y + rowH);
|
||||||
|
_y += rowH;
|
||||||
|
|
||||||
|
// data rows
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var wrapped = WrapText(item.Value, valueW - 12, _tableCellFont);
|
||||||
|
double lineHeight = _tableCellFont.GetHeight();
|
||||||
|
rowH = Math.Max(22, wrapped.Count * lineHeight + 8);
|
||||||
|
|
||||||
|
EnsureSpace(rowH);
|
||||||
|
_gfx.DrawCellText(item.Label, _tableCellFont, x + 6, _y, labelW, rowH, true);
|
||||||
|
int lines = wrapped.Count;
|
||||||
|
if (lines == 1)
|
||||||
|
{
|
||||||
|
_gfx.DrawCellText(wrapped[0], _tableCellFont, x + labelW + 6, _y, valueW, rowH, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double yOffset = (rowH - lines * lineHeight) / 2;
|
||||||
|
for (int i = 0; i < lines; i++)
|
||||||
|
_gfx.DrawCellText(wrapped[i], _tableCellFont, x + labelW + 6,
|
||||||
|
_y + yOffset + i * lineHeight, valueW, lineHeight, false);
|
||||||
|
}
|
||||||
|
_gfx.DrawRectangle(new XPen(BorderColor), x, _y, _contentWidth, rowH);
|
||||||
|
_gfx.DrawLine(new XPen(BorderColor), x + labelW, _y, x + labelW, _y + rowH);
|
||||||
|
_y += rowH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawTable(string[] headers, List<string[]> rows)
|
||||||
|
{
|
||||||
|
double x = MarginPt;
|
||||||
|
var colWidths = CalculateColumnWidths(headers, rows, _contentWidth);
|
||||||
|
double headerH = 22;
|
||||||
|
|
||||||
|
EnsureSpace(headerH);
|
||||||
|
_gfx.DrawRectangle(new XSolidBrush(HeaderBgColor), x, _y, _contentWidth, headerH);
|
||||||
|
double cx = x;
|
||||||
|
for (int i = 0; i < headers.Length; i++)
|
||||||
|
{
|
||||||
|
_gfx.DrawCellText(headers[i], _tableHeaderFont, cx + 4, _y, colWidths[i], headerH);
|
||||||
|
cx += colWidths[i];
|
||||||
|
}
|
||||||
|
_gfx.DrawRectangle(new XPen(BorderColor), x, _y, _contentWidth, headerH);
|
||||||
|
cx = x;
|
||||||
|
for (int i = 0; i < headers.Length - 1; i++)
|
||||||
|
{
|
||||||
|
cx += colWidths[i];
|
||||||
|
_gfx.DrawLine(new XPen(BorderColor), cx, _y, cx, _y + headerH);
|
||||||
|
}
|
||||||
|
_y += headerH;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var wrappedCells = new List<string>[row.Length];
|
||||||
|
int maxLines = 1;
|
||||||
|
for (int i = 0; i < row.Length; i++)
|
||||||
|
{
|
||||||
|
wrappedCells[i] = WrapText(row[i], colWidths[i] - 8, _tableCellFont);
|
||||||
|
maxLines = Math.Max(maxLines, wrappedCells[i].Count);
|
||||||
|
}
|
||||||
|
double lineHeight = _tableCellFont.GetHeight();
|
||||||
|
double rowH = Math.Max(22, maxLines * lineHeight + 8);
|
||||||
|
|
||||||
|
EnsureSpace(rowH);
|
||||||
|
cx = x;
|
||||||
|
for (int i = 0; i < row.Length; i++)
|
||||||
|
{
|
||||||
|
int lines = wrappedCells[i].Count;
|
||||||
|
if (lines == 1)
|
||||||
|
{
|
||||||
|
_gfx.DrawCellText(wrappedCells[i][0], _tableCellFont, cx + 4, _y, colWidths[i], rowH, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double yOffset = (rowH - lines * lineHeight) / 2;
|
||||||
|
for (int l = 0; l < lines; l++)
|
||||||
|
_gfx.DrawCellText(wrappedCells[i][l], _tableCellFont, cx + 4,
|
||||||
|
_y + yOffset + l * lineHeight, colWidths[i], lineHeight, false);
|
||||||
|
}
|
||||||
|
cx += colWidths[i];
|
||||||
|
}
|
||||||
|
_gfx.DrawRectangle(new XPen(BorderColor), x, _y, _contentWidth, rowH);
|
||||||
|
cx = x;
|
||||||
|
for (int i = 0; i < headers.Length - 1; i++)
|
||||||
|
{
|
||||||
|
cx += colWidths[i];
|
||||||
|
_gfx.DrawLine(new XPen(BorderColor), cx, _y, cx, _y + rowH);
|
||||||
|
}
|
||||||
|
_y += rowH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderTextBlock(TextBlock block)
|
||||||
|
{
|
||||||
|
var font = block.Bold
|
||||||
|
? new XFont(_fontFamily, 12, XFontStyle.Bold)
|
||||||
|
: _textFont;
|
||||||
|
|
||||||
|
var lines = block.Text.Split('\n');
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var wrapped = WrapText(line, _contentWidth, font);
|
||||||
|
foreach (var wl in wrapped)
|
||||||
|
{
|
||||||
|
EnsureSpace(16);
|
||||||
|
_gfx.DrawString(wl, font, XBrushes.Black,
|
||||||
|
MarginPt, _y + 12);
|
||||||
|
_y += 14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_y += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════ 辅助方法 ═════════════════
|
||||||
|
|
||||||
|
private double[] CalculateColumnWidths(string[] headers, List<string[]> rows, double totalWidth)
|
||||||
|
{
|
||||||
|
int n = headers.Length;
|
||||||
|
var maxW = new double[n];
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
maxW[i] = _gfx.MeasureString(headers[i], _tableHeaderFont).Width;
|
||||||
|
foreach (var row in rows)
|
||||||
|
for (int i = 0; i < n && i < row.Length; i++)
|
||||||
|
maxW[i] = Math.Max(maxW[i], _gfx.MeasureString(row[i], _tableCellFont).Width);
|
||||||
|
|
||||||
|
const double padding = 8;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
maxW[i] += padding * 2;
|
||||||
|
|
||||||
|
double sum = maxW.Sum();
|
||||||
|
if (sum > totalWidth)
|
||||||
|
{
|
||||||
|
double scale = totalWidth / sum;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
maxW[i] *= scale;
|
||||||
|
}
|
||||||
|
return maxW;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> WrapText(string text, double maxWidth, XFont font)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
result.Add("");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = text.Split('\n');
|
||||||
|
foreach (var para in paragraphs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(para))
|
||||||
|
{
|
||||||
|
result.Add("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = para.Split(' ');
|
||||||
|
var current = "";
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
var test = current == "" ? word : current + " " + word;
|
||||||
|
if (_gfx.MeasureString(test, font).Width <= maxWidth)
|
||||||
|
current = test;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (current != "") result.Add(current);
|
||||||
|
if (_gfx.MeasureString(word, font).Width > maxWidth)
|
||||||
|
{
|
||||||
|
current = "";
|
||||||
|
foreach (var ch in word)
|
||||||
|
{
|
||||||
|
var test2 = current + ch;
|
||||||
|
if (_gfx.MeasureString(test2, font).Width <= maxWidth)
|
||||||
|
current = test2;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Add(current);
|
||||||
|
current = ch.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
current = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current != "") result.Add(current);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class GfxExtensions
|
||||||
|
{
|
||||||
|
public static void DrawCellText(this XGraphics gfx, string text, XFont font,
|
||||||
|
double x, double y, double width, double height, bool centerVertical = true)
|
||||||
|
{
|
||||||
|
var rect = new XRect(x, y, width, height);
|
||||||
|
var fmt = new XStringFormat
|
||||||
|
{
|
||||||
|
Alignment = XStringAlignment.Near,
|
||||||
|
LineAlignment = centerVertical ? XLineAlignment.Center : XLineAlignment.Near,
|
||||||
|
};
|
||||||
|
gfx.DrawString(text, font, XBrushes.Black, rect, fmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,11 @@ namespace CounterDrone.Core.Services
|
|||||||
SimulationReport GetReport(string id);
|
SimulationReport GetReport(string id);
|
||||||
void DeleteReport(string id);
|
void DeleteReport(string id);
|
||||||
PagedResult<SimulationReport> SearchReports(string keyword, string? dateFrom, string? dateTo, int page, int pageSize);
|
PagedResult<SimulationReport> SearchReports(string keyword, string? dateFrom, string? dateTo, int page, int pageSize);
|
||||||
/// <summary>导出报告为 Markdown 文件,返回文件路径</summary>
|
|
||||||
string ExportToFile(string reportId, string outputDir);
|
/// <summary>导出报告为字节数组。format: "pdf" | "md"</summary>
|
||||||
|
byte[] ExportReport(string id, string format);
|
||||||
|
|
||||||
|
/// <summary>导出报告为文件,返回文件路径。format: "pdf" | "md"</summary>
|
||||||
|
string ExportToFile(string reportId, string outputDir, string format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using CounterDrone.Core;
|
using CounterDrone.Core;
|
||||||
using CounterDrone.Core.Models;
|
using CounterDrone.Core.Models;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
using CounterDrone.Core.Simulation;
|
using CounterDrone.Core.Simulation;
|
||||||
|
|
||||||
namespace CounterDrone.Core.Services
|
namespace CounterDrone.Core.Services
|
||||||
{
|
{
|
||||||
public class ReportGenerator
|
public class ReportGenerator
|
||||||
{
|
{
|
||||||
public string Generate(ScenarioConfig config, List<SimEvent> events,
|
public ReportData Generate(ScenarioConfig config, List<SimEvent> events,
|
||||||
Simulation.DroneStatus finalDroneStatus, float simulationDuration,
|
Simulation.DroneStatus finalDroneStatus, float simulationDuration,
|
||||||
Dictionary<string, DroneSpec> droneSpecs,
|
Dictionary<string, DroneSpec> droneSpecs,
|
||||||
Dictionary<string, LaunchPlatformSpec> fireUnitSpecs,
|
Dictionary<string, LaunchPlatformSpec> fireUnitSpecs,
|
||||||
Dictionary<string, SensorSpec> sensorSpecs)
|
Dictionary<string, SensorSpec> sensorSpecs)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
|
||||||
var platforms = config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform).ToList();
|
var platforms = config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform).ToList();
|
||||||
var target = config.Drones.FirstOrDefault();
|
var target = config.Drones.FirstOrDefault();
|
||||||
var scene = config.Scene;
|
var scene = config.Scene;
|
||||||
@ -26,87 +25,114 @@ namespace CounterDrone.Core.Services
|
|||||||
var totalDrones = config.Drones.Sum(t => t.Quantity);
|
var totalDrones = config.Drones.Sum(t => t.Quantity);
|
||||||
var result = DetermineInterceptResult(destroyed, reached + intruded, totalDrones);
|
var result = DetermineInterceptResult(destroyed, reached + intruded, totalDrones);
|
||||||
|
|
||||||
sb.AppendLine("# 仿真评估报告");
|
var data = new ReportData
|
||||||
sb.AppendLine();
|
{
|
||||||
sb.AppendLine($"| 项目 | 值 |");
|
Title = "仿真评估报告",
|
||||||
sb.AppendLine($"|------|-----|");
|
Header = new List<MetaItem>
|
||||||
sb.AppendLine($"| 任务名称 | {config.Info.Name} |");
|
{
|
||||||
sb.AppendLine($"| 任务编号 | {config.Info.ScenarioNumber} |");
|
new() { Label = "任务名称", Value = config.Info.Name },
|
||||||
sb.AppendLine($"| 仿真耗时 | {simulationDuration:F1} 秒 |");
|
new() { Label = "任务编号", Value = config.Info.ScenarioNumber },
|
||||||
sb.AppendLine($"| 气溶胶类型 | {TranslateAerosol((AerosolType)config.Cloud.AerosolType)} |");
|
new() { Label = "仿真耗时", Value = $"{simulationDuration:F1} 秒" },
|
||||||
sb.AppendLine();
|
new() { Label = "气溶胶类型", Value = TranslateAerosol((AerosolType)config.Cloud.AerosolType) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// === 一、仿真结果 ===
|
// === 一、仿真结果 ===
|
||||||
sb.AppendLine("## 一、仿真结果");
|
data.Sections.Add(new ReportSection
|
||||||
sb.AppendLine();
|
{
|
||||||
sb.AppendLine($"| 统计项 | 值 |");
|
Title = "一、仿真结果",
|
||||||
sb.AppendLine($"|--------|-----|");
|
Blocks = new List<ReportBlock>
|
||||||
sb.AppendLine($"| 无人机总数 | {totalDrones} |");
|
{
|
||||||
sb.AppendLine($"| 被摧毁 | {destroyed} |");
|
new KeyValueBlock
|
||||||
sb.AppendLine($"| 到达目标 | {reached} |");
|
{
|
||||||
sb.AppendLine($"| 侵入管控区 | {intruded} |");
|
LabelHeader = "统计项",
|
||||||
sb.AppendLine($"| 弹药发射 | {events.Count(e => e.Type == SimEventType.MunitionLaunched)} |");
|
Items = new List<MetaItem>
|
||||||
sb.AppendLine($"| 目标发现 | {events.Count(e => e.Type == SimEventType.TargetDetected)} |");
|
{
|
||||||
sb.AppendLine($"| 云团生成 | {events.Count(e => e.Type == SimEventType.CloudGenerated)} |");
|
new() { Label = "无人机总数", Value = totalDrones.ToString() },
|
||||||
sb.AppendLine();
|
new() { Label = "被摧毁", Value = destroyed.ToString() },
|
||||||
sb.AppendLine($"**对抗结果**:{TranslateResult(result)}");
|
new() { Label = "到达目标", Value = reached.ToString() },
|
||||||
sb.AppendLine();
|
new() { Label = "侵入管控区", Value = intruded.ToString() },
|
||||||
|
new() { Label = "弹药发射", Value = events.Count(e => e.Type == SimEventType.MunitionLaunched).ToString() },
|
||||||
|
new() { Label = "目标发现", Value = events.Count(e => e.Type == SimEventType.TargetDetected).ToString() },
|
||||||
|
new() { Label = "云团生成", Value = events.Count(e => e.Type == SimEventType.CloudGenerated).ToString() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new TextBlock { Text = $"对抗结果:{TranslateResult(result)}", Bold = true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// === 二、作战环境 ===
|
// === 二、作战环境 ===
|
||||||
sb.AppendLine("## 二、作战环境");
|
data.Sections.Add(new ReportSection
|
||||||
sb.AppendLine();
|
{
|
||||||
sb.AppendLine($"| 参数 | 值 |");
|
Title = "二、作战环境",
|
||||||
sb.AppendLine($"|------|-----|");
|
Blocks = new List<ReportBlock>
|
||||||
sb.AppendLine($"| 场景类型 | {TranslateScene((SceneType)scene.SceneType)} |");
|
{
|
||||||
sb.AppendLine($"| 时间段 | {scene.TimeOfDay} |");
|
new KeyValueBlock
|
||||||
sb.AppendLine($"| 天气 | {TranslateWeather((WeatherType)scene.WeatherType)} |");
|
{
|
||||||
sb.AppendLine($"| 风速 | {scene.WindSpeed:F1} m/s |");
|
Items = new List<MetaItem>
|
||||||
sb.AppendLine($"| 风向 | {TranslateWindDirection((WindDirection)scene.WindDirection)} |");
|
{
|
||||||
sb.AppendLine($"| 温度 | {scene.Temperature:F1}°C |");
|
new() { Label = "场景类型", Value = TranslateScene((SceneType)scene.SceneType) },
|
||||||
sb.AppendLine($"| 湿度 | {scene.Humidity:F0}%RH |");
|
new() { Label = "时间段", Value = scene.TimeOfDay },
|
||||||
sb.AppendLine($"| 气压 | {scene.Pressure:F0} hPa |");
|
new() { Label = "天气", Value = TranslateWeather((WeatherType)scene.WeatherType) },
|
||||||
sb.AppendLine($"| 能见度 | {scene.Visibility:F0} m |");
|
new() { Label = "风速", Value = $"{scene.WindSpeed:F1} m/s" },
|
||||||
sb.AppendLine($"| 场景范围 | {scene.SceneWidth:F0} × {scene.SceneLength:F0} × {scene.SceneHeight:F0} m (W×L×H) |");
|
new() { Label = "风向", Value = TranslateWindDirection((WindDirection)scene.WindDirection) },
|
||||||
sb.AppendLine();
|
new() { Label = "温度", Value = $"{scene.Temperature:F1}°C" },
|
||||||
|
new() { Label = "湿度", Value = $"{scene.Humidity:F0}%RH" },
|
||||||
|
new() { Label = "气压", Value = $"{scene.Pressure:F0} hPa" },
|
||||||
|
new() { Label = "能见度", Value = $"{scene.Visibility:F0} m" },
|
||||||
|
new() { Label = "场景范围", Value = $"{scene.SceneWidth:F0} × {scene.SceneLength:F0} × {scene.SceneHeight:F0} m (W×L×H)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// === 三、威胁目标 ===
|
// === 三、威胁目标 ===
|
||||||
sb.AppendLine("## 三、威胁目标");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"| 参数 | 值 |");
|
|
||||||
sb.AppendLine($"|------|-----|");
|
|
||||||
// Resolve specs
|
|
||||||
droneSpecs.TryGetValue(target?.DroneSpecId ?? "", out var droneSpec);
|
droneSpecs.TryGetValue(target?.DroneSpecId ?? "", out var droneSpec);
|
||||||
sb.AppendLine($"| 类型 | {TranslateTarget((DroneType)(droneSpec?.DroneType ?? 0))} |");
|
data.Sections.Add(new ReportSection
|
||||||
sb.AppendLine($"| 动力 | {TranslatePower((PowerType)(droneSpec?.PowerType ?? 0))} |");
|
{
|
||||||
sb.AppendLine($"| 数量 | {totalDrones} 架 |");
|
Title = "三、威胁目标",
|
||||||
sb.AppendLine($"| 翼展 | {droneSpec?.Wingspan ?? 0:F1} m |");
|
Blocks = new List<ReportBlock>
|
||||||
sb.AppendLine($"| 速度 | {droneSpec?.TypicalSpeed ?? 0:F0} km/h |");
|
{
|
||||||
sb.AppendLine($"| 高度 | {droneSpec?.TypicalAltitude ?? 0:F0} m |");
|
new KeyValueBlock
|
||||||
sb.AppendLine();
|
{
|
||||||
|
Items = new List<MetaItem>
|
||||||
|
{
|
||||||
|
new() { Label = "类型", Value = TranslateTarget((DroneType)(droneSpec?.DroneType ?? 0)) },
|
||||||
|
new() { Label = "动力", Value = TranslatePower((PowerType)(droneSpec?.PowerType ?? 0)) },
|
||||||
|
new() { Label = "数量", Value = $"{totalDrones} 架" },
|
||||||
|
new() { Label = "翼展", Value = $"{droneSpec?.Wingspan ?? 0:F1} m" },
|
||||||
|
new() { Label = "速度", Value = $"{droneSpec?.TypicalSpeed ?? 0:F0} km/h" },
|
||||||
|
new() { Label = "高度", Value = $"{droneSpec?.TypicalAltitude ?? 0:F0} m" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// === 四、云团抛撒配置 ===
|
// === 四、云团抛撒配置 ===
|
||||||
sb.AppendLine("## 四、云团抛撒配置");
|
var cloudItems = new List<MetaItem>
|
||||||
sb.AppendLine();
|
{
|
||||||
sb.AppendLine($"| 参数 | 值 |");
|
new() { Label = "气溶胶类型", Value = TranslateAerosol((AerosolType)config.Cloud.AerosolType) },
|
||||||
sb.AppendLine($"|------|-----|");
|
new() { Label = "抛撒高度", Value = $"{config.Cloud.DisperseHeight:F0} m" },
|
||||||
sb.AppendLine($"| 气溶胶类型 | {TranslateAerosol((AerosolType)config.Cloud.AerosolType)} |");
|
new() { Label = "触发模式", Value = TranslateTrigger((TriggerMode)config.Cloud.TriggerMode) },
|
||||||
sb.AppendLine($"| 抛撒高度 | {config.Cloud.DisperseHeight:F0} m |");
|
new() { Label = "释放方式", Value = TranslateRelease((ReleaseMode)config.Cloud.ReleaseMode) },
|
||||||
sb.AppendLine($"| 触发模式 | {TranslateTrigger((TriggerMode)config.Cloud.TriggerMode)} |");
|
new() { Label = "扩散方向", Value = $"{config.Cloud.SpreadAngle:F0}°" },
|
||||||
sb.AppendLine($"| 释放方式 | {TranslateRelease((ReleaseMode)config.Cloud.ReleaseMode)} |");
|
new() { Label = "初始规模", Value = $"{config.Cloud.InitialScale:F0} m³" },
|
||||||
sb.AppendLine($"| 扩散方向 | {config.Cloud.SpreadAngle:F0}° |");
|
new() { Label = "持续时间", Value = $"{config.Cloud.Duration:F0} s" },
|
||||||
sb.AppendLine($"| 初始规模 | {config.Cloud.InitialScale:F0} m³ |");
|
new() { Label = "释放总量", Value = $"{config.Cloud.TotalAmount:F1} kg" },
|
||||||
sb.AppendLine($"| 持续时间 | {config.Cloud.Duration:F0} s |");
|
new() { Label = "配置来源", Value = config.Cloud.Source },
|
||||||
sb.AppendLine($"| 释放总量 | {config.Cloud.TotalAmount:F1} kg |");
|
};
|
||||||
sb.AppendLine($"| 配置来源 | {config.Cloud.Source} |");
|
|
||||||
if (config.Cloud.Source == "Algorithm" && config.Cloud.EstimatedProbability.HasValue)
|
if (config.Cloud.Source == "Algorithm" && config.Cloud.EstimatedProbability.HasValue)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"| **算法推荐** | |");
|
cloudItems.Add(new() { Label = "推荐弹药数", Value = $"{config.Cloud.SalvoRounds} 发" });
|
||||||
sb.AppendLine($"| 推荐弹药数 | {config.Cloud.SalvoRounds} 发 |");
|
cloudItems.Add(new() { Label = "云团间距", Value = $"{config.Cloud.SalvoSpacing:F0} m" });
|
||||||
sb.AppendLine($"| 云团间距 | {config.Cloud.SalvoSpacing:F0} m |");
|
cloudItems.Add(new() { Label = "拦截概率估计", Value = $"{config.Cloud.EstimatedProbability.Value:P0}" });
|
||||||
sb.AppendLine($"| 拦截概率估计 | {config.Cloud.EstimatedProbability.Value:P0} |");
|
cloudItems.Add(new() { Label = "抛撒时机", Value = $"{config.Cloud.RecommendedTiming:F0}s" });
|
||||||
sb.AppendLine($"| 抛撒时机 | {config.Cloud.RecommendedTiming:F0}s |");
|
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
data.Sections.Add(new ReportSection
|
||||||
|
{
|
||||||
|
Title = "四、云团抛撒配置",
|
||||||
|
Blocks = new List<ReportBlock> { new KeyValueBlock { Items = cloudItems } },
|
||||||
|
});
|
||||||
|
|
||||||
// 构建 PlatformId → 火力单元 映射(去重,五和六共用)
|
// 构建 PlatformId → 火力单元 映射(去重,五和六共用)
|
||||||
var platToEquip = new Dictionary<string, int>();
|
var platToEquip = new Dictionary<string, int>();
|
||||||
@ -132,12 +158,10 @@ namespace CounterDrone.Core.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === 五、我方部署 ===
|
// === 五、我方部署 ===
|
||||||
sb.AppendLine("## 五、我方部署");
|
var deployBlocks = new List<ReportBlock>();
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"### 发射平台");
|
// 发射平台表
|
||||||
sb.AppendLine();
|
var platRows = new List<string[]>();
|
||||||
sb.AppendLine($"| # | 平台类型 | 位置 (X,Y,Z) | 弹药类型 | 挂载量 | 发射数 |");
|
|
||||||
sb.AppendLine($"|---|---------|-------------|---------|:------:|:------:|");
|
|
||||||
int unitNum2 = 0;
|
int unitNum2 = 0;
|
||||||
foreach (var eq in config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
|
foreach (var eq in config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
|
||||||
{
|
{
|
||||||
@ -149,51 +173,60 @@ namespace CounterDrone.Core.Services
|
|||||||
int fired = unitLaunchCounts.GetValueOrDefault(unitNum2, 0);
|
int fired = unitLaunchCounts.GetValueOrDefault(unitNum2, 0);
|
||||||
string ptype = ((PlatformType?)(fuSpec?.PlatformType ?? 0)) == PlatformType.AirBased ? "空基" : "地基";
|
string ptype = ((PlatformType?)(fuSpec?.PlatformType ?? 0)) == PlatformType.AirBased ? "空基" : "地基";
|
||||||
string ammo = eq.AerosolType.HasValue ? TranslateAerosol((AerosolType)eq.AerosolType) : "-";
|
string ammo = eq.AerosolType.HasValue ? TranslateAerosol((AerosolType)eq.AerosolType) : "-";
|
||||||
sb.AppendLine($"| #{unitNum2} | {ptype} | ({eq.PositionX + j * 50:F0}, {eq.PositionY:F0}, {eq.PositionZ:F0}) | {ammo} | {eq.MunitionCount ?? channels} | {fired} |");
|
platRows.Add(new[] { $"#{unitNum2}", ptype, $"({eq.PositionX + j * 50:F0}, {eq.PositionY:F0}, {eq.PositionZ:F0})", ammo, $"{eq.MunitionCount ?? channels}", $"{fired}" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
deployBlocks.Add(new TableBlock
|
||||||
// 探测设备
|
{
|
||||||
|
Caption = "发射平台",
|
||||||
|
Headers = new[] { "#", "平台类型", "位置 (X,Y,Z)", "弹药类型", "挂载量", "发射数" },
|
||||||
|
Rows = platRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 探测设备表
|
||||||
var detections = config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.Detection).ToList();
|
var detections = config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.Detection).ToList();
|
||||||
if (detections.Count > 0)
|
if (detections.Count > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"### 探测设备");
|
var detRows = new List<string[]>();
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"| # | 位置 (X,Y,Z) | 雷达 | 光电 | 红外 | 精度 | 数量 |");
|
|
||||||
sb.AppendLine($"|---|-------------|:----:|:----:|:----:|:----:|:----:|");
|
|
||||||
int detIdx = 0;
|
int detIdx = 0;
|
||||||
foreach (var d in detections)
|
foreach (var d in detections)
|
||||||
{
|
{
|
||||||
sensorSpecs.TryGetValue(d.SensorSpecId, out var sensor);
|
sensorSpecs.TryGetValue(d.SensorSpecId, out var sensor);
|
||||||
for (int j = 0; j < d.Quantity; j++)
|
for (int j = 0; j < d.Quantity; j++)
|
||||||
sb.AppendLine($"| D{++detIdx} | ({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0}) | {(sensor?.RadarRange ?? 0):F0} | {(sensor?.EORange ?? 0):F0} | {(sensor?.IRRange ?? 0):F0} | {(sensor?.Accuracy ?? 0):F0} m | 1 |");
|
detRows.Add(new[] { $"D{++detIdx}", $"({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0})", $"{(sensor?.RadarRange ?? 0):F0}", $"{(sensor?.EORange ?? 0):F0}", $"{(sensor?.IRRange ?? 0):F0}", $"{(sensor?.Accuracy ?? 0):F0} m", "1" });
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
deployBlocks.Add(new TableBlock
|
||||||
}
|
{
|
||||||
// 管控区域
|
Caption = "探测设备",
|
||||||
if (config.ControlZones.Count > 0)
|
Headers = new[] { "#", "位置 (X,Y,Z)", "雷达", "光电", "红外", "精度", "数量" },
|
||||||
{
|
Rows = detRows,
|
||||||
sb.AppendLine($"### 管控区域");
|
});
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"| # | 名称 | 高度范围 |");
|
|
||||||
sb.AppendLine($"|---|------|---------|");
|
|
||||||
int zIdx = 0;
|
|
||||||
foreach (var z in config.ControlZones)
|
|
||||||
sb.AppendLine($"| Z{++zIdx} | {z.Name} | {(int)z.MinAltitude}-{(int)z.MaxAltitude} m |");
|
|
||||||
sb.AppendLine();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管控区域表
|
||||||
|
if (config.ControlZones.Count > 0)
|
||||||
|
{
|
||||||
|
var zoneRows = new List<string[]>();
|
||||||
|
int zIdx = 0;
|
||||||
|
foreach (var z in config.ControlZones)
|
||||||
|
zoneRows.Add(new[] { $"Z{++zIdx}", z.Name, $"{(int)z.MinAltitude}-{(int)z.MaxAltitude} m" });
|
||||||
|
deployBlocks.Add(new TableBlock
|
||||||
|
{
|
||||||
|
Caption = "管控区域",
|
||||||
|
Headers = new[] { "#", "名称", "高度范围" },
|
||||||
|
Rows = zoneRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Sections.Add(new ReportSection { Title = "五、我方部署", Blocks = deployBlocks });
|
||||||
|
|
||||||
// === 六、事件时序 ===
|
// === 六、事件时序 ===
|
||||||
sb.AppendLine("## 六、事件时序");
|
var eventRows = new List<string[]>();
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("| 时间(s) | 事件 | 详情 | 火力单元 |");
|
|
||||||
sb.AppendLine("|---------|------|------|:------:|");
|
|
||||||
int munitionIdx = 0, cloudIdx = 0;
|
int munitionIdx = 0, cloudIdx = 0;
|
||||||
foreach (var e in events.OrderBy(e => e.OccurredAt))
|
foreach (var e in events.OrderBy(e => e.OccurredAt))
|
||||||
{
|
{
|
||||||
string unitCell = e.Type == SimEventType.MunitionLaunched && platToEquip.TryGetValue(e.SourceId, out var u)
|
string unitCell = e.Type == SimEventType.MunitionLaunched && platToEquip.TryGetValue(e.SourceId, out var u)
|
||||||
? $"#{u}" : "-";
|
? $"#{u}" : "-";
|
||||||
// 编号前缀 + 引擎生成的 Description(含坐标等调试信息)
|
|
||||||
string prefix = e.Type switch
|
string prefix = e.Type switch
|
||||||
{
|
{
|
||||||
SimEventType.MunitionLaunched => $"第 {++munitionIdx} 发",
|
SimEventType.MunitionLaunched => $"第 {++munitionIdx} 发",
|
||||||
@ -201,9 +234,20 @@ namespace CounterDrone.Core.Services
|
|||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
string desc = string.IsNullOrEmpty(prefix) ? e.Description : $"{prefix} · {e.Description}";
|
string desc = string.IsNullOrEmpty(prefix) ? e.Description : $"{prefix} · {e.Description}";
|
||||||
sb.AppendLine($"| {e.OccurredAt:F2} | {TranslateEvent(e.Type)} | {desc} | {unitCell} |");
|
eventRows.Add(new[] { $"{e.OccurredAt:F2}", TranslateEvent(e.Type), desc, unitCell });
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
data.Sections.Add(new ReportSection
|
||||||
|
{
|
||||||
|
Title = "六、事件时序",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TableBlock
|
||||||
|
{
|
||||||
|
Headers = new[] { "时间(s)", "事件", "详情", "火力单元" },
|
||||||
|
Rows = eventRows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// === 七、时间线 ===
|
// === 七、时间线 ===
|
||||||
var launches = events.Where(e => e.Type == SimEventType.MunitionLaunched).Select(e => e.OccurredAt).OrderBy(t => t).ToList();
|
var launches = events.Where(e => e.Type == SimEventType.MunitionLaunched).Select(e => e.OccurredAt).OrderBy(t => t).ToList();
|
||||||
@ -213,29 +257,40 @@ namespace CounterDrone.Core.Services
|
|||||||
float launchLast = launches.DefaultIfEmpty(0).LastOrDefault();
|
float launchLast = launches.DefaultIfEmpty(0).LastOrDefault();
|
||||||
float cloudFirst = clouds.DefaultIfEmpty(0).FirstOrDefault();
|
float cloudFirst = clouds.DefaultIfEmpty(0).FirstOrDefault();
|
||||||
float destroyFirst = destroys.DefaultIfEmpty(0).FirstOrDefault();
|
float destroyFirst = destroys.DefaultIfEmpty(0).FirstOrDefault();
|
||||||
sb.AppendLine("## 七、时间线");
|
|
||||||
sb.AppendLine();
|
var timelineRows = new List<string[]>();
|
||||||
sb.AppendLine($"| 阶段 | 时间 | 耗时 |");
|
|
||||||
sb.AppendLine($"|------|------|------|");
|
|
||||||
if (launchFirst > 0)
|
if (launchFirst > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"| 首发发射 | {launchFirst:F1}s | - |");
|
timelineRows.Add(new[] { "首发发射", $"{launchFirst:F1}s", "-" });
|
||||||
if (launches.Count > 1)
|
if (launches.Count > 1)
|
||||||
sb.AppendLine($"| 末发发射 | {launchLast:F1}s | {launchLast - launchFirst:F1}s(弹群展开) |");
|
timelineRows.Add(new[] { "末发发射", $"{launchLast:F1}s", $"{launchLast - launchFirst:F1}s(弹群展开)" });
|
||||||
sb.AppendLine($"| 发射 → 云团生成 | {launchFirst:F1}s → {cloudFirst:F1}s | {cloudFirst - launchFirst:F1}s(弹药飞行) |");
|
timelineRows.Add(new[] { "发射 → 云团生成", $"{launchFirst:F1}s → {cloudFirst:F1}s", $"{cloudFirst - launchFirst:F1}s(弹药飞行)" });
|
||||||
}
|
}
|
||||||
if (destroyFirst > 0)
|
if (destroyFirst > 0)
|
||||||
sb.AppendLine($"| 云团生成 → 首个击毁 | {cloudFirst:F1}s → {destroyFirst:F1}s | {destroyFirst - cloudFirst:F1}s(暴露毁伤) |");
|
timelineRows.Add(new[] { "云团生成 → 首个击毁", $"{cloudFirst:F1}s → {destroyFirst:F1}s", $"{destroyFirst - cloudFirst:F1}s(暴露毁伤)" });
|
||||||
sb.AppendLine($"| 总耗时 | 0 → {simulationDuration:F1}s | {simulationDuration:F1}s |");
|
timelineRows.Add(new[] { "总耗时", $"0 → {simulationDuration:F1}s", $"{simulationDuration:F1}s" });
|
||||||
sb.AppendLine();
|
|
||||||
// 汇总统计(调试用)
|
|
||||||
sb.AppendLine("**统计汇总**:");
|
|
||||||
sb.AppendLine($"- 弹药发射 {launches.Count} 发,云团生成 {clouds.Count} 朵,击毁 {destroys.Count} 架");
|
|
||||||
if (launchFirst > 0 && launchLast > 0)
|
|
||||||
sb.AppendLine($"- 发射窗口 {launchLast - launchFirst:F1}s,弹药飞行 {(cloudFirst - launchFirst):F1}s");
|
|
||||||
sb.AppendLine();
|
|
||||||
|
|
||||||
return sb.ToString();
|
var timelineBlocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TableBlock
|
||||||
|
{
|
||||||
|
Headers = new[] { "阶段", "时间", "耗时" },
|
||||||
|
Rows = timelineRows,
|
||||||
|
},
|
||||||
|
new TextBlock { Text = "统计汇总:", Bold = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
var summaryLines = new List<string>
|
||||||
|
{
|
||||||
|
$"- 弹药发射 {launches.Count} 发,云团生成 {clouds.Count} 朵,击毁 {destroys.Count} 架",
|
||||||
|
};
|
||||||
|
if (launchFirst > 0 && launchLast > 0)
|
||||||
|
summaryLines.Add($"- 发射窗口 {launchLast - launchFirst:F1}s,弹药飞行 {(cloudFirst - launchFirst):F1}s");
|
||||||
|
timelineBlocks.Add(new TextBlock { Text = string.Join("\n", summaryLines) });
|
||||||
|
|
||||||
|
data.Sections.Add(new ReportSection { Title = "七、时间线", Blocks = timelineBlocks });
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InterceptResult DetermineInterceptResult(int destroyed, int reachedOrIntruded, int total)
|
public InterceptResult DetermineInterceptResult(int destroyed, int reachedOrIntruded, int total)
|
||||||
@ -248,9 +303,9 @@ namespace CounterDrone.Core.Services
|
|||||||
|
|
||||||
private static string TranslateResult(InterceptResult r) => r switch
|
private static string TranslateResult(InterceptResult r) => r switch
|
||||||
{
|
{
|
||||||
InterceptResult.Success => "✅ 成功拦截",
|
InterceptResult.Success => "成功拦截",
|
||||||
InterceptResult.Partial => "⚠️ 部分拦截",
|
InterceptResult.Partial => "部分拦截",
|
||||||
InterceptResult.Failed => "❌ 拦截失败",
|
InterceptResult.Failed => "拦截失败",
|
||||||
_ => "未知",
|
_ => "未知",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -311,13 +366,13 @@ namespace CounterDrone.Core.Services
|
|||||||
|
|
||||||
private static string TranslateEvent(SimEventType t) => t switch
|
private static string TranslateEvent(SimEventType t) => t switch
|
||||||
{
|
{
|
||||||
SimEventType.MunitionLaunched => "🚀 弹药发射",
|
SimEventType.MunitionLaunched => "弹药发射",
|
||||||
SimEventType.CloudGenerated => "☁️ 云团生成",
|
SimEventType.CloudGenerated => "云团生成",
|
||||||
SimEventType.DroneDestroyed => "💥 目标击毁",
|
SimEventType.DroneDestroyed => "目标击毁",
|
||||||
SimEventType.DroneReachedTarget => "🏁 抵达目标",
|
SimEventType.DroneReachedTarget => "抵达目标",
|
||||||
SimEventType.ZoneIntruded => "🚨 侵入管控区",
|
SimEventType.ZoneIntruded => "侵入管控区",
|
||||||
SimEventType.SimulationEnd => "⏹️ 仿真结束",
|
SimEventType.SimulationEnd => "仿真结束",
|
||||||
SimEventType.TargetDetected => "👁️ 目标发现",
|
SimEventType.TargetDetected => "目标发现",
|
||||||
_ => t.ToString(),
|
_ => t.ToString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using CounterDrone.Core.Models;
|
using CounterDrone.Core.Models;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
using SQLite;
|
using SQLite;
|
||||||
using CounterDrone.Core.Simulation;
|
using CounterDrone.Core.Simulation;
|
||||||
|
|
||||||
@ -13,6 +16,14 @@ namespace CounterDrone.Core.Services
|
|||||||
private readonly SQLiteConnection _db;
|
private readonly SQLiteConnection _db;
|
||||||
private readonly ReportGenerator _generator = new();
|
private readonly ReportGenerator _generator = new();
|
||||||
private readonly IPathProvider _paths;
|
private readonly IPathProvider _paths;
|
||||||
|
private readonly MarkdownRenderer _mdRenderer = new();
|
||||||
|
private StandardPdfTemplate? _pdfTemplate;
|
||||||
|
|
||||||
|
private const string FontFamily = "SimHei";
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
Converters = { new ReportBlockConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
public ReportService(SQLiteConnection db, IPathProvider paths)
|
public ReportService(SQLiteConnection db, IPathProvider paths)
|
||||||
{
|
{
|
||||||
@ -33,6 +44,9 @@ namespace CounterDrone.Core.Services
|
|||||||
var total = config.Drones.Sum(t => t.Quantity);
|
var total = config.Drones.Sum(t => t.Quantity);
|
||||||
var result = _generator.DetermineInterceptResult(destroyed, reached + intruded, total);
|
var result = _generator.DetermineInterceptResult(destroyed, reached + intruded, total);
|
||||||
|
|
||||||
|
var reportData = _generator.Generate(config, events, Simulation.DroneStatus.Flying, duration,
|
||||||
|
droneSpecs, fireUnitSpecs, sensorSpecs);
|
||||||
|
|
||||||
var report = new SimulationReport
|
var report = new SimulationReport
|
||||||
{
|
{
|
||||||
ScenarioId = scenarioId,
|
ScenarioId = scenarioId,
|
||||||
@ -42,11 +56,16 @@ namespace CounterDrone.Core.Services
|
|||||||
DroneCount = total,
|
DroneCount = total,
|
||||||
UnitCount = config.Units.Count,
|
UnitCount = config.Units.Count,
|
||||||
InterceptResult = (int)result,
|
InterceptResult = (int)result,
|
||||||
Content = _generator.Generate(config, events, Simulation.DroneStatus.Flying, duration,
|
Content = _mdRenderer.Render(reportData),
|
||||||
droneSpecs, fireUnitSpecs, sensorSpecs),
|
ReportDataJson = JsonSerializer.Serialize(reportData, JsonOpts),
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Insert(report);
|
_db.Insert(report);
|
||||||
|
|
||||||
|
// 仿真后自动导出 Markdown 到报告目录
|
||||||
|
var reportsDir = Path.Combine(_paths.GetDataRoot(), "reports");
|
||||||
|
ExportToFile(report.Id, reportsDir, "md");
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,16 +105,39 @@ namespace CounterDrone.Core.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ExportToFile(string reportId, string outputDir)
|
public byte[] ExportReport(string id, string format)
|
||||||
|
{
|
||||||
|
var report = _db.Find<SimulationReport>(id);
|
||||||
|
if (report == null) throw new ArgumentException($"报告 {id} 不存在");
|
||||||
|
|
||||||
|
if (format == "md")
|
||||||
|
return Encoding.UTF8.GetBytes(report.Content);
|
||||||
|
|
||||||
|
if (format == "pdf")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(report.ReportDataJson))
|
||||||
|
throw new InvalidOperationException($"报告 {id} 缺少结构化数据,无法导出 PDF");
|
||||||
|
|
||||||
|
var data = JsonSerializer.Deserialize<ReportData>(report.ReportDataJson, JsonOpts)
|
||||||
|
?? throw new InvalidOperationException("报告数据反序列化失败");
|
||||||
|
_pdfTemplate ??= new StandardPdfTemplate(_paths.GetFontPath(), FontFamily);
|
||||||
|
return _pdfTemplate.Render(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"不支持的导出格式: {format}(仅支持 pdf / md)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ExportToFile(string reportId, string outputDir, string format)
|
||||||
{
|
{
|
||||||
var report = _db.Find<SimulationReport>(reportId);
|
var report = _db.Find<SimulationReport>(reportId);
|
||||||
if (report == null) throw new ArgumentException($"报告 {reportId} 不存在");
|
if (report == null) throw new ArgumentException($"报告 {reportId} 不存在");
|
||||||
|
|
||||||
Directory.CreateDirectory(outputDir);
|
Directory.CreateDirectory(outputDir);
|
||||||
var safeTime = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
var safeTime = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||||
var fileName = $"{report.ScenarioName}_{safeTime}.md";
|
var ext = format == "pdf" ? "pdf" : "md";
|
||||||
|
var fileName = $"{report.ScenarioName}_{safeTime}.{ext}";
|
||||||
var filePath = Path.Combine(outputDir, fileName);
|
var filePath = Path.Combine(outputDir, fileName);
|
||||||
File.WriteAllText(filePath, report.Content);
|
File.WriteAllBytes(filePath, ExportReport(reportId, format));
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,19 @@ namespace CounterDrone.Core
|
|||||||
if (File.Exists(src))
|
if (File.Exists(src))
|
||||||
File.Copy(src, dst, true); // 始终覆盖,确保测试用最新配置
|
File.Copy(src, dst, true); // 始终覆盖,确保测试用最新配置
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 复制 CJK 字体文件
|
||||||
|
var fontDir = Path.Combine(sourceDir, "fonts");
|
||||||
|
if (Directory.Exists(fontDir))
|
||||||
|
{
|
||||||
|
var fontsDst = Path.Combine(_root, "fonts");
|
||||||
|
Directory.CreateDirectory(fontsDst);
|
||||||
|
foreach (var f in Directory.GetFiles(fontDir))
|
||||||
|
{
|
||||||
|
var dst = Path.Combine(fontsDst, Path.GetFileName(f));
|
||||||
|
if (File.Exists(f)) File.Copy(f, dst, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? FindProjectDataDirectory()
|
public static string? FindProjectDataDirectory()
|
||||||
@ -63,5 +76,12 @@ namespace CounterDrone.Core
|
|||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetFontPath()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_root, "fonts");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, "CJK-Font.ttf");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: df8f5b1767b80a84189c79f00a842b58
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll
Normal file
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll
Normal file
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7a6706c727ee9a943bb70ea9d234a134
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll
Normal file
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll
Normal file
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ef43ecd2ea68363409925a6ef41e48a8
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2c80ec4476cccbb46a30ce10576827a5
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 863e67a283c1e0b4eafceccb24b07b07
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 746ba1dda9d44e740be569f1791c9fe5
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1e2313593e88d6449bc61563ce851f19
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 37486e2069a0f814da01d363e5449f8a
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll
Normal file
BIN
src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll
Normal file
Binary file not shown.
@ -0,0 +1,33 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 441075d058b92c641ba0a9bef5a3b9bf
|
||||||
|
PluginImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
iconMap: {}
|
||||||
|
executionOrder: {}
|
||||||
|
defineConstraints: []
|
||||||
|
isPreloaded: 0
|
||||||
|
isOverridable: 0
|
||||||
|
isExplicitlyReferenced: 0
|
||||||
|
validateReferences: 1
|
||||||
|
platformData:
|
||||||
|
- first:
|
||||||
|
Any:
|
||||||
|
second:
|
||||||
|
enabled: 1
|
||||||
|
settings: {}
|
||||||
|
- first:
|
||||||
|
Editor: Editor
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
DefaultValueInitialized: true
|
||||||
|
- first:
|
||||||
|
Windows Store Apps: WindowsStoreApps
|
||||||
|
second:
|
||||||
|
enabled: 0
|
||||||
|
settings:
|
||||||
|
CPU: AnyCPU
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -33,16 +33,17 @@ namespace CounterDrone.Unity
|
|||||||
public PagedResult<SimulationReport> Search(string kw, string from, string to, int page, int size)
|
public PagedResult<SimulationReport> Search(string kw, string from, string to, int page, int size)
|
||||||
=> _service.SearchReports(kw, from, to, page, size);
|
=> _service.SearchReports(kw, from, to, page, size);
|
||||||
|
|
||||||
public string Export(string reportId, string outputDir)
|
/// <summary>导出报告为字节数组。format: "pdf" | "md"</summary>
|
||||||
{
|
public byte[] ExportReport(string reportId, string format)
|
||||||
var dir = outputDir ?? System.IO.Path.Combine(_paths.GetDataRoot(), "reports");
|
=> _service.ExportReport(reportId, format);
|
||||||
return _service.ExportToFile(reportId, dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// <summary>导出报告到指定目录,返回文件路径。format: "pdf" | "md"</summary>
|
||||||
|
public string Export(string reportId, string outputDir, string format)
|
||||||
|
=> _service.ExportToFile(reportId, outputDir, format);
|
||||||
|
|
||||||
|
/// <summary>导出 Markdown 报告到默认报告目录,返回文件路径</summary>
|
||||||
public string Export(string reportId)
|
public string Export(string reportId)
|
||||||
{
|
=> Export(reportId, System.IO.Path.Combine(_paths.GetDataRoot(), "reports"), "md");
|
||||||
return Export(reportId, System.IO.Path.Combine(_paths.GetDataRoot(), "reports"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[ContextMenu("Verify")]
|
[ContextMenu("Verify")]
|
||||||
void Verify()
|
void Verify()
|
||||||
|
|||||||
@ -34,6 +34,19 @@ namespace CounterDrone.Unity
|
|||||||
var src = Path.Combine(sourceDir, file);
|
var src = Path.Combine(sourceDir, file);
|
||||||
if (File.Exists(src)) File.Copy(src, dst);
|
if (File.Exists(src)) File.Copy(src, dst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 复制 CJK 字体文件
|
||||||
|
var fontSrcDir = Path.Combine(sourceDir, "fonts");
|
||||||
|
if (Directory.Exists(fontSrcDir))
|
||||||
|
{
|
||||||
|
var fontDstDir = Path.Combine(_root, "fonts");
|
||||||
|
Directory.CreateDirectory(fontDstDir);
|
||||||
|
foreach (var f in Directory.GetFiles(fontSrcDir))
|
||||||
|
{
|
||||||
|
var dst = Path.Combine(fontDstDir, Path.GetFileName(f));
|
||||||
|
if (!File.Exists(dst)) File.Copy(f, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetMainDbPath() => Path.Combine(GetDataRoot(), "main.db");
|
public string GetMainDbPath() => Path.Combine(GetDataRoot(), "main.db");
|
||||||
@ -51,5 +64,12 @@ namespace CounterDrone.Unity
|
|||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetFontPath()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(GetDataRoot(), "fonts");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, "CJK-Font.ttf");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/Unity/Assets/StreamingAssets/fonts.meta
Normal file
8
src/Unity/Assets/StreamingAssets/fonts.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 756fcb6926ada6744b1dfa95cc84cc1f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf
Normal file
BIN
src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf
Normal file
Binary file not shown.
7
src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf.meta
Normal file
7
src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf.meta
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 699c5f98bb34f914db674c61c60abc93
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -5,6 +5,7 @@ using System.Linq;
|
|||||||
using CounterDrone.Core;
|
using CounterDrone.Core;
|
||||||
using CounterDrone.Core.Algorithms;
|
using CounterDrone.Core.Algorithms;
|
||||||
using CounterDrone.Core.Models;
|
using CounterDrone.Core.Models;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
using CounterDrone.Core.Repository;
|
using CounterDrone.Core.Repository;
|
||||||
using CounterDrone.Core.Services;
|
using CounterDrone.Core.Services;
|
||||||
using CounterDrone.Core.Simulation;
|
using CounterDrone.Core.Simulation;
|
||||||
@ -67,13 +68,11 @@ namespace CounterDrone.Core.Tests
|
|||||||
private void VerifyAndExportReport(SimulationEngine eng, DroneEntity drone)
|
private void VerifyAndExportReport(SimulationEngine eng, DroneEntity drone)
|
||||||
{
|
{
|
||||||
var detail = _scenario.GetScenarioDetail(_scenarioId)!;
|
var detail = _scenario.GetScenarioDetail(_scenarioId)!;
|
||||||
var report = new ReportGenerator().Generate(detail, eng.Events.ToList(), drone.Status, eng.SimulationTime, TestData.EmptyDroneSpecs, TestData.EmptyFuSpecs, TestData.EmptySensorSpecs);
|
|
||||||
Assert.Contains("对抗结果", report);
|
|
||||||
Assert.Contains(detail.Info.Name, report);
|
|
||||||
|
|
||||||
var svc = new ReportService(_mainDb, _paths);
|
var svc = new ReportService(_mainDb, _paths);
|
||||||
var r = svc.Generate(_scenarioId, detail, eng.Events.ToList(), drone.Status.ToString(), eng.SimulationTime);
|
var r = svc.Generate(_scenarioId, detail, eng.Events.ToList(), drone.Status.ToString(), eng.SimulationTime);
|
||||||
svc.ExportToFile(r.Id, ReportOutputDir); // 持久化目录
|
Assert.Contains("对抗结果", r.Content);
|
||||||
|
Assert.Contains(detail.Info.Name, r.Content);
|
||||||
|
svc.ExportToFile(r.Id, ReportOutputDir, "md"); // 持久化目录
|
||||||
}
|
}
|
||||||
|
|
||||||
private ScenarioConfig LoadPreset(string name)
|
private ScenarioConfig LoadPreset(string name)
|
||||||
@ -153,7 +152,8 @@ namespace CounterDrone.Core.Tests
|
|||||||
e => Assert.Contains("空基", e.Description));
|
e => Assert.Contains("空基", e.Description));
|
||||||
var drone = eng.Drones[0];
|
var drone = eng.Drones[0];
|
||||||
var detail = _scenario.GetScenarioDetail(_scenarioId)!;
|
var detail = _scenario.GetScenarioDetail(_scenarioId)!;
|
||||||
var report = new ReportGenerator().Generate(detail, eng.Events.ToList(), drone.Status, eng.SimulationTime, TestData.EmptyDroneSpecs, TestData.EmptyFuSpecs, TestData.EmptySensorSpecs);
|
var reportData = new ReportGenerator().Generate(detail, eng.Events.ToList(), drone.Status, eng.SimulationTime, TestData.EmptyDroneSpecs, TestData.EmptyFuSpecs, TestData.EmptySensorSpecs);
|
||||||
|
var report = new MarkdownRenderer().Render(reportData);
|
||||||
Assert.True(drone.Hp < 0.1f, $"Hp={drone.Hp:F3} pos=({drone.PosX:F0},{drone.PosY:F0}) launched={eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched)}\n{report}");
|
Assert.True(drone.Hp < 0.1f, $"Hp={drone.Hp:F3} pos=({drone.PosX:F0},{drone.PosY:F0}) launched={eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched)}\n{report}");
|
||||||
VerifyAndExportReport(eng, drone);
|
VerifyAndExportReport(eng, drone);
|
||||||
}
|
}
|
||||||
|
|||||||
113
test/unit/CounterDrone.Core.Tests/MarkdownRendererTests.cs
Normal file
113
test/unit/CounterDrone.Core.Tests/MarkdownRendererTests.cs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Tests
|
||||||
|
{
|
||||||
|
public class MarkdownRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Render_BasicStructure()
|
||||||
|
{
|
||||||
|
var data = new ReportData
|
||||||
|
{
|
||||||
|
Title = "测试报告",
|
||||||
|
Header = new List<MetaItem>
|
||||||
|
{
|
||||||
|
new() { Label = "任务名称", Value = "测试任务" },
|
||||||
|
new() { Label = "仿真耗时", Value = "10.0 秒" },
|
||||||
|
},
|
||||||
|
Sections = new List<ReportSection>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = "一、仿真结果",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new KeyValueBlock
|
||||||
|
{
|
||||||
|
LabelHeader = "统计项",
|
||||||
|
Items = new List<MetaItem>
|
||||||
|
{
|
||||||
|
new() { Label = "无人机总数", Value = "3" },
|
||||||
|
new() { Label = "被摧毁", Value = "3" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new TextBlock { Text = "对抗结果:✅ 成功拦截", Bold = true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var md = new MarkdownRenderer().Render(data);
|
||||||
|
|
||||||
|
Assert.Contains("# 测试报告", md);
|
||||||
|
Assert.Contains("| 任务名称 | 测试任务 |", md);
|
||||||
|
Assert.Contains("## 一、仿真结果", md);
|
||||||
|
Assert.Contains("| 统计项 | 值 |", md);
|
||||||
|
Assert.Contains("| 无人机总数 | 3 |", md);
|
||||||
|
Assert.Contains("**对抗结果:✅ 成功拦截**", md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_TableBlock_WithCaption()
|
||||||
|
{
|
||||||
|
var data = new ReportData
|
||||||
|
{
|
||||||
|
Title = "测试",
|
||||||
|
Sections = new List<ReportSection>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = "五、我方部署",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TableBlock
|
||||||
|
{
|
||||||
|
Caption = "发射平台",
|
||||||
|
Headers = new[] { "#", "类型", "位置" },
|
||||||
|
Rows = new List<string[]>
|
||||||
|
{
|
||||||
|
new[] { "#1", "地基", "(100, 0, 50)" },
|
||||||
|
new[] { "#2", "空基", "(200, 500, 50)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var md = new MarkdownRenderer().Render(data);
|
||||||
|
|
||||||
|
Assert.Contains("### 发射平台", md);
|
||||||
|
Assert.Contains("| # | 类型 | 位置 |", md);
|
||||||
|
Assert.Contains("| #1 | 地基 | (100, 0, 50) |", md);
|
||||||
|
Assert.Contains("| #2 | 空基 | (200, 500, 50) |", md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_TextBlock_Multiline()
|
||||||
|
{
|
||||||
|
var data = new ReportData
|
||||||
|
{
|
||||||
|
Title = "测试",
|
||||||
|
Sections = new List<ReportSection>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = "七、时间线",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TextBlock { Text = "- 第一行\n- 第二行" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var md = new MarkdownRenderer().Render(data);
|
||||||
|
|
||||||
|
Assert.Contains("- 第一行", md);
|
||||||
|
Assert.Contains("- 第二行", md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using CounterDrone.Core.Models;
|
using CounterDrone.Core.Models;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
using CounterDrone.Core.Services;
|
using CounterDrone.Core.Services;
|
||||||
using CounterDrone.Core.Simulation;
|
using CounterDrone.Core.Simulation;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -51,11 +52,12 @@ namespace CounterDrone.Core.Tests
|
|||||||
new SimEvent { Type = SimEventType.DroneDestroyed, OccurredAt = 15f, Description = "摧毁" },
|
new SimEvent { Type = SimEventType.DroneDestroyed, OccurredAt = 15f, Description = "摧毁" },
|
||||||
};
|
};
|
||||||
|
|
||||||
var content = gen.Generate(config, events, DroneStatus.Destroyed, 30f, TestData.EmptyDroneSpecs, TestData.EmptyFuSpecs, TestData.EmptySensorSpecs);
|
var data = gen.Generate(config, events, DroneStatus.Destroyed, 30f, TestData.EmptyDroneSpecs, TestData.EmptyFuSpecs, TestData.EmptySensorSpecs);
|
||||||
|
var content = new MarkdownRenderer().Render(data);
|
||||||
|
|
||||||
Assert.Contains("仿真评估报告", content);
|
Assert.Contains("仿真评估报告", content);
|
||||||
Assert.Contains("测试任务", content);
|
Assert.Contains("测试任务", content);
|
||||||
Assert.Contains("✅ 成功拦截", content);
|
Assert.Contains("成功拦截", content);
|
||||||
Assert.Contains("发射", content);
|
Assert.Contains("发射", content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using CounterDrone.Core;
|
using CounterDrone.Core;
|
||||||
using CounterDrone.Core.Models;
|
using CounterDrone.Core.Models;
|
||||||
using CounterDrone.Core.Services;
|
using CounterDrone.Core.Services;
|
||||||
@ -31,23 +32,27 @@ namespace CounterDrone.Core.Tests
|
|||||||
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
|
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
private ScenarioConfig CreateTestConfig()
|
||||||
public void Generate_And_Get_ReturnsReport()
|
|
||||||
{
|
{
|
||||||
var config = new ScenarioConfig
|
return new ScenarioConfig
|
||||||
{
|
{
|
||||||
Info = new Scenario { Name = "Test", ScenarioNumber = "SIM-001" },
|
Info = new Scenario { Name = "Test", ScenarioNumber = "SIM-001" },
|
||||||
Drones = new List<ScenarioDrone> { TestData.CreateScenarioDrone("w1", 1) },
|
Drones = new List<ScenarioDrone> { TestData.CreateScenarioDrone("w1", 1) },
|
||||||
Units = new List<ScenarioUnit>(),
|
Units = new List<ScenarioUnit>(),
|
||||||
Cloud = new CloudDispersal(),
|
Cloud = new CloudDispersal(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var report = _service.Generate("scenario1", config, new List<SimEvent>(), "Destroyed", 45f);
|
[Fact]
|
||||||
|
public void Generate_And_Get_ReturnsReport()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
|
||||||
Assert.NotNull(report);
|
Assert.NotNull(report);
|
||||||
Assert.NotEmpty(report.Id);
|
Assert.NotEmpty(report.Id);
|
||||||
Assert.Equal("Test", report.ScenarioName);
|
Assert.Equal("Test", report.ScenarioName);
|
||||||
Assert.Contains("仿真评估报告", report.Content);
|
Assert.Contains("仿真评估报告", report.Content);
|
||||||
|
Assert.NotNull(report.ReportDataJson);
|
||||||
|
|
||||||
var fetched = _service.GetReport(report.Id);
|
var fetched = _service.GetReport(report.Id);
|
||||||
Assert.NotNull(fetched);
|
Assert.NotNull(fetched);
|
||||||
@ -56,15 +61,7 @@ namespace CounterDrone.Core.Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Delete_RemovesReport()
|
public void Delete_RemovesReport()
|
||||||
{
|
{
|
||||||
var config = new ScenarioConfig
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 10f);
|
||||||
{
|
|
||||||
Info = new Scenario { Name = "Del", ScenarioNumber = "SIM-DEL" },
|
|
||||||
Drones = new List<ScenarioDrone> { TestData.CreateScenarioDrone("w1", 1) },
|
|
||||||
Units = new List<ScenarioUnit>(),
|
|
||||||
Cloud = new CloudDispersal(),
|
|
||||||
};
|
|
||||||
|
|
||||||
var report = _service.Generate("scenario1", config, new List<SimEvent>(), "Destroyed", 10f);
|
|
||||||
_service.DeleteReport(report.Id);
|
_service.DeleteReport(report.Id);
|
||||||
Assert.Null(_service.GetReport(report.Id));
|
Assert.Null(_service.GetReport(report.Id));
|
||||||
}
|
}
|
||||||
@ -72,14 +69,8 @@ namespace CounterDrone.Core.Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SearchReports_Keyword()
|
public void SearchReports_Keyword()
|
||||||
{
|
{
|
||||||
var cfg = new ScenarioConfig
|
var cfg = CreateTestConfig();
|
||||||
{
|
cfg.Info.Name = "城市防御";
|
||||||
Info = new Scenario { Name = "城市防御", ScenarioNumber = "SIM-A" },
|
|
||||||
Drones = new List<ScenarioDrone> { TestData.CreateScenarioDrone("w1", 1) },
|
|
||||||
Units = new List<ScenarioUnit>(),
|
|
||||||
Cloud = new CloudDispersal(),
|
|
||||||
};
|
|
||||||
|
|
||||||
_service.Generate("t1", cfg, new List<SimEvent>(), "Destroyed", 10f);
|
_service.Generate("t1", cfg, new List<SimEvent>(), "Destroyed", 10f);
|
||||||
cfg.Info.Name = "平原拦截";
|
cfg.Info.Name = "平原拦截";
|
||||||
cfg.Info.ScenarioNumber = "SIM-B";
|
cfg.Info.ScenarioNumber = "SIM-B";
|
||||||
@ -88,5 +79,79 @@ namespace CounterDrone.Core.Tests
|
|||||||
var result = _service.SearchReports("城市", null, null, 1, 10);
|
var result = _service.SearchReports("城市", null, null, 1, 10);
|
||||||
Assert.Equal(1, result.TotalCount);
|
Assert.Equal(1, result.TotalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportReport_Md_ReturnsMarkdownBytes()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
var md = _service.ExportReport(report.Id, "md");
|
||||||
|
var text = Encoding.UTF8.GetString(md);
|
||||||
|
Assert.Contains("仿真评估报告", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportReport_Pdf_ReturnsValidPdf()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
var pdf = _service.ExportReport(report.Id, "pdf");
|
||||||
|
Assert.NotEmpty(pdf);
|
||||||
|
// PDF 文件以 "%PDF" 开头
|
||||||
|
Assert.Equal(0x25, pdf[0]); // %
|
||||||
|
Assert.Equal(0x50, pdf[1]); // P
|
||||||
|
Assert.Equal(0x44, pdf[2]); // D
|
||||||
|
Assert.Equal(0x46, pdf[3]); // F
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportReport_Pdf_MissingReportData_Throws()
|
||||||
|
{
|
||||||
|
var report = new SimulationReport
|
||||||
|
{
|
||||||
|
ScenarioId = "s1",
|
||||||
|
ScenarioName = "Old",
|
||||||
|
Content = "# Old Report",
|
||||||
|
ReportDataJson = null,
|
||||||
|
};
|
||||||
|
_db.Insert(report);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => _service.ExportReport(report.Id, "pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportReport_UnsupportedFormat_Throws()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
Assert.Throws<ArgumentException>(() => _service.ExportReport(report.Id, "docx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportToFile_Pdf_CreatesPdfFile()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
var path = _service.ExportToFile(report.Id, _testDir, "pdf");
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.EndsWith(".pdf", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportToFile_Md_CreatesMdFile()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
var path = _service.ExportToFile(report.Id, _testDir, "md");
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.EndsWith(".md", path);
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
Assert.Contains("仿真评估报告", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_AutoExportsMdToReportsDir()
|
||||||
|
{
|
||||||
|
var report = _service.Generate("scenario1", CreateTestConfig(), new List<SimEvent>(), "Destroyed", 45f);
|
||||||
|
var reportsDir = Path.Combine(_testDir, "reports");
|
||||||
|
Assert.True(Directory.Exists(reportsDir));
|
||||||
|
var mdFiles = Directory.GetFiles(reportsDir, "*.md");
|
||||||
|
Assert.True(mdFiles.Length > 0, "仿真后应自动生成 MD 文件到 reports 目录");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
test/unit/CounterDrone.Core.Tests/StandardPdfTemplateTests.cs
Normal file
121
test/unit/CounterDrone.Core.Tests/StandardPdfTemplateTests.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using CounterDrone.Core;
|
||||||
|
using CounterDrone.Core.Reporting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace CounterDrone.Core.Tests
|
||||||
|
{
|
||||||
|
public class StandardPdfTemplateTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _testDir;
|
||||||
|
|
||||||
|
public StandardPdfTemplateTests()
|
||||||
|
{
|
||||||
|
_testDir = Path.Combine(Path.GetTempPath(), $"cd_pdf_{Guid.NewGuid():N}");
|
||||||
|
var paths = new TestPathProvider(_testDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReportData CreateSampleReport()
|
||||||
|
{
|
||||||
|
return new ReportData
|
||||||
|
{
|
||||||
|
Title = "仿真评估报告",
|
||||||
|
Header = new List<MetaItem>
|
||||||
|
{
|
||||||
|
new() { Label = "任务名称", Value = "城市防御测试" },
|
||||||
|
new() { Label = "任务编号", Value = "SIM-001" },
|
||||||
|
new() { Label = "仿真耗时", Value = "45.0 秒" },
|
||||||
|
},
|
||||||
|
Sections = new List<ReportSection>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = "一、仿真结果",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new KeyValueBlock
|
||||||
|
{
|
||||||
|
LabelHeader = "统计项",
|
||||||
|
Items = new List<MetaItem>
|
||||||
|
{
|
||||||
|
new() { Label = "无人机总数", Value = "3" },
|
||||||
|
new() { Label = "被摧毁", Value = "3" },
|
||||||
|
new() { Label = "弹药发射", Value = "6" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new TextBlock { Text = "对抗结果:✅ 成功拦截", Bold = true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = "六、事件时序",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TableBlock
|
||||||
|
{
|
||||||
|
Headers = new[] { "时间(s)", "事件", "详情", "火力单元" },
|
||||||
|
Rows = new List<string[]>
|
||||||
|
{
|
||||||
|
new[] { "1.50", "🚀 弹药发射", "第 1 发 · 发射", "#1" },
|
||||||
|
new[] { "5.00", "☁️ 云团生成", "云团 #1 · 生成", "-" },
|
||||||
|
new[] { "8.30", "💥 目标击毁", "目标被摧毁", "-" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ReturnsValidPdf()
|
||||||
|
{
|
||||||
|
var paths = new TestPathProvider(_testDir);
|
||||||
|
var template = new StandardPdfTemplate(paths.GetFontPath(), "CJK");
|
||||||
|
var pdf = template.Render(CreateSampleReport());
|
||||||
|
|
||||||
|
Assert.NotEmpty(pdf);
|
||||||
|
// PDF 文件头:%PDF
|
||||||
|
Assert.Equal(0x25, pdf[0]); // %
|
||||||
|
Assert.Equal(0x50, pdf[1]); // P
|
||||||
|
Assert.Equal(0x44, pdf[2]); // D
|
||||||
|
Assert.Equal(0x46, pdf[3]); // F
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_MultiplePages_HandlesLongContent()
|
||||||
|
{
|
||||||
|
var data = CreateSampleReport();
|
||||||
|
// 添加大量事件行以触发分页
|
||||||
|
var eventRows = new List<string[]>();
|
||||||
|
for (int i = 0; i < 200; i++)
|
||||||
|
eventRows.Add(new[] { $"{i * 0.1:F1}", "🚀 弹药发射", $"第 {i + 1} 发 · 测试事件 {i}", $"#{(i % 3) + 1}" });
|
||||||
|
data.Sections.Add(new ReportSection
|
||||||
|
{
|
||||||
|
Title = "大量事件",
|
||||||
|
Blocks = new List<ReportBlock>
|
||||||
|
{
|
||||||
|
new TableBlock
|
||||||
|
{
|
||||||
|
Headers = new[] { "时间(s)", "事件", "详情", "单元" },
|
||||||
|
Rows = eventRows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var paths = new TestPathProvider(_testDir);
|
||||||
|
var template = new StandardPdfTemplate(paths.GetFontPath(), "CJK");
|
||||||
|
var pdf = template.Render(data);
|
||||||
|
|
||||||
|
Assert.NotEmpty(pdf);
|
||||||
|
Assert.True(pdf.Length > 10000, $"PDF 应有多个页面,大小 {pdf.Length} bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -90,6 +90,7 @@ namespace CounterDrone.Core.Tests
|
|||||||
public string GetMainDbPath() => throw new NotImplementedException();
|
public string GetMainDbPath() => throw new NotImplementedException();
|
||||||
public string GetFramesDir() => throw new NotImplementedException();
|
public string GetFramesDir() => throw new NotImplementedException();
|
||||||
public string GetModelsDir() => throw new NotImplementedException();
|
public string GetModelsDir() => throw new NotImplementedException();
|
||||||
|
public string GetFontPath() => throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Cleanup()
|
public static void Cleanup()
|
||||||
|
|||||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user