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:
tian 2026-06-20 11:57:38 +08:00
parent f77fb88487
commit 8bcf4cf675
52 changed files with 2301 additions and 378 deletions

View File

@ -2,6 +2,42 @@
--- ---
## [0.12.0] - 2026-06-20
### Added — PDF 导出 + 报告模板架构Phase 8.2.1
- **PdfSharpCore 1.3.64**:纯托管 PDF 库netstandard2.0Unity 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`SimHei9.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 — 发射平台与探测设备分离

View File

@ -1 +1 @@
0.11.0 0.12.0

BIN
data/fonts/CJK-Font.ttf Normal file

Binary file not shown.

View File

@ -1367,9 +1367,10 @@ public class FrameDataStore
### 8.3 导出 ### 8.3 导出
- 格式PDF / Word默认 PDF - 格式PDF / Markdown仿真后自动生成 MDPDF 按需调用导出)
- 模板占位符填充后输出 - 架构ReportGenerator 构建结构化 ReportData → MarkdownRenderer / StandardPdfTemplate 分别渲染
- 技术选型QuestPDF 或其他 .NET Standard 2.1 兼容方案(待调研) - 技术选型PdfSharpCore 1.3.64netstandard2.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.64netstandard2.0纯托管Unity 兼容) |
| 9 | 预置典型目标库内容 | 具体包含哪些无人机型号及参数 | | 9 | 预置典型目标库内容 | 具体包含哪些无人机型号及参数 |
| 10 | 扩散模型的大气稳定度参数 | 不同天气条件下 σ 的增长速率如何量化 | | 10 | 扩散模型的大气稳定度参数 | 不同天气条件下 σ 的增长速率如何量化 |
| 11 | 毁伤曲线的具体数值 | 三种机制的 damageRate、threshold、burstDamage 等参数取值 | | 11 | 毁伤曲线的具体数值 | 三种机制的 damageRate、threshold、burstDamage 等参数取值 |

View File

@ -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),仿真后自动生成 MDPDF 按需调用 `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 导出 | ✅ PdfSharpCorenetstandard2.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

View File

@ -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

View File

@ -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>

View File

@ -7,5 +7,6 @@ namespace CounterDrone.Core
string GetMainDbPath(); string GetMainDbPath();
string GetFramesDir(); string GetFramesDir();
string GetModelsDir(); string GetModelsDir();
string GetFontPath();
} }
} }

View File

@ -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; }
} }
} }

View 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;
}
}

View File

@ -0,0 +1,8 @@
namespace CounterDrone.Core.Reporting
{
/// <summary>报告模板接口 — 从 ReportData 渲染为目标格式</summary>
public interface IReportTemplate
{
byte[] Render(ReportData data);
}
}

View 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);
}
}
}

View 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();
}
}
}

View 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; }
}
}

View 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);
}
}
}

View File

@ -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);
} }
} }

View File

@ -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(),
}; };
} }

View File

@ -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;
} }
} }

View File

@ -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");
}
} }
} }

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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()

View File

@ -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");
}
} }
} }

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 756fcb6926ada6744b1dfa95cc84cc1f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 699c5f98bb34f914db674c61c60abc93
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
} }

View 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);
}
}
}

View File

@ -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);
} }
} }

View File

@ -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 目录");
}
} }
} }

View 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");
}
}
}

View File

@ -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.