diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdbde0..ec4c2e3 100644 --- a/CHANGELOG.md +++ b/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 ### Breaking — 发射平台与探测设备分离 diff --git a/VERSION b/VERSION index d9df1bb..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/data/fonts/CJK-Font.ttf b/data/fonts/CJK-Font.ttf new file mode 100644 index 0000000..1f1bdf8 Binary files /dev/null and b/data/fonts/CJK-Font.ttf differ diff --git a/docs/design/architecture/总体架构设计.md b/docs/design/architecture/总体架构设计.md index 9f48e56..e0ac432 100644 --- a/docs/design/architecture/总体架构设计.md +++ b/docs/design/architecture/总体架构设计.md @@ -1367,9 +1367,10 @@ public class FrameDataStore ### 8.3 导出 -- 格式:PDF / Word(默认 PDF) -- 模板占位符填充后输出 -- 技术选型:QuestPDF 或其他 .NET Standard 2.1 兼容方案(待调研) +- 格式:PDF / Markdown(仿真后自动生成 MD,PDF 按需调用导出) +- 架构:ReportGenerator 构建结构化 ReportData → MarkdownRenderer / StandardPdfTemplate 分别渲染 +- 技术选型: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 { - SimulationReport Generate(string scenarioId); + SimulationReport Generate(string scenarioId, ScenarioConfig config, + List events, string finalDroneStatus, float duration); // 仿真后调用,自动导出 MD SimulationReport GetReport(string id); void DeleteReport(string id); - byte[] ExportReport(string id, string format); // "pdf" | "word" - PagedResult SearchReports(string kw, DateTime? from, DateTime? to, int page, int size); + byte[] ExportReport(string id, string format); // "pdf" | "md",按需导出 + string ExportToFile(string reportId, string outputDir, string format); // 导出文件,返回路径 + PagedResult SearchReports(string kw, string? from, string? to, int page, int size); } ``` @@ -1933,7 +1936,7 @@ FOR EACH 飞行无人机(维护"本帧已发现该目标的最优探测设备" | 5 | 蜂群模式运动模型 | 随机扰动范围、个体行为差异 | | 6 | 报告模板具体格式 | 统计维度、图表需求 | | 7 | 场景类型对仿真的影响 | 山区是否影响探测/运动 | -| 8 | PDF/Word 导出库选型 | QuestPDF 或其他 .NET Standard 2.1 方案 | +| 8 | ~~PDF/Word 导出库选型~~ | ✅ 已选定 PdfSharpCore 1.3.64(netstandard2.0,纯托管,Unity 兼容) | | 9 | 预置典型目标库内容 | 具体包含哪些无人机型号及参数 | | 10 | 扩散模型的大气稳定度参数 | 不同天气条件下 σ 的增长速率如何量化 | | 11 | 毁伤曲线的具体数值 | 三种机制的 damageRate、threshold、burstDamage 等参数取值 | diff --git a/docs/implementation/tasks/实施计划与任务跟踪.md b/docs/implementation/tasks/实施计划与任务跟踪.md index b117384..c39ff9a 100644 --- a/docs/implementation/tasks/实施计划与任务跟踪.md +++ b/docs/implementation/tasks/实施计划与任务跟踪.md @@ -1,8 +1,8 @@ # 实施计划与任务跟踪 > **项目**:反无人机仿真系统后端 -> **文档版本**:V1.6 -> **更新日期**:2026-06-18 +> **文档版本**:V1.7 +> **更新日期**:2026-06-20 --- @@ -95,7 +95,7 @@ Phase 13 ✅ 模型重构:分层架构 + 代码审查 + 探测分离 |---|------|------| | 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%+** | | 分支覆盖率 | **80%+** | -| 执行时间 | ~11 秒 | +| 执行时间 | ~15 秒 | | Core 程序集 | `CounterDrone.Core.dll` (.NET Standard 2.1) | | 共享物理工具类 | `Kinematics` / `RouteGeometry` / `CloudExpansionModel` / `DamageAssessment` | | 全局配置 | `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.3 第三方对接 @@ -452,6 +452,7 @@ T1(数据层)→ T2(算法层 IsInCoverage) | M10 — 探测实时链路 | ✅ | | M11 — 运动学重构 + 实体暴露 + 基础数据 CRUD | ✅ | | M12 — 空基/地基统一规划 + 编队轴 + LaneDivider | ✅ | +| M13 — PDF 导出 + 报告模板架构 | ✅ | --- diff --git a/docs/对接文档_Unity前端.md b/docs/对接文档_Unity前端.md index cc9438b..73dbeec 100644 --- a/docs/对接文档_Unity前端.md +++ b/docs/对接文档_Unity前端.md @@ -1,33 +1,76 @@ # 后端对接文档(Unity 前端) -> **版本**:V1.8 -> **日期**:2026-06-18 +> **版本**:V2.0 +> **日期**:2026-06-20 > **Unity 版本**:2022.3.62f3c1 --- -## 一、交付物 +## 一、交付物与部署 -从仓库取以下内容,拖入 Unity 项目: +### 1.1 需要取的文件 ``` -src/Unity/Assets/Plugins/ ← DLL(Core + sqlite + JSON) -src/Unity/Assets/Scripts/Managers/ ← 桥接脚本 - -将以下文件放到 Unity 项目 Assets/StreamingAssets/ 目录下: - data/defaults.json ← 默认数据(首次运行自动复制到 persistentDataPath) - data/planner_config.json ← 规划器配置(同上) +src/Unity/Assets/Plugins/CounterDrone.Core/ ← 22 个 DLL(拖入 Unity 项目的 Plugins 目录) +src/Unity/Assets/Scripts/Managers/ ← 9 个桥接脚本 ``` +StreamingAssets 资源文件(放到 Unity 项目 `Assets/StreamingAssets/` 下): + +``` +StreamingAssets/ + defaults.json ← 默认数据(首次运行自动复制到 persistentDataPath) + planner_config.json ← 规划器配置 + fonts/CJK-Font.ttf ← PDF 导出用的中文字体(仅 PDF 导出需要) +``` + +> 以上资源文件可在仓库的 `data/` 目录找到。字体文件名硬编码为 `CJK-Font.ttf`,可替换为任意 CJK TrueType 字体但不可改名。 + +### 1.2 DLL 清单(22 个) + +全部为纯托管 DLL,无原生库依赖,Unity IL2CPP/Mono 全平台兼容。 + +| DLL | 用途 | +|-----|------| +| `CounterDrone.Core.dll` | 核心库 | +| `SQLite-net.dll` | SQLite ORM | +| `SQLitePCLRaw.core.dll` / `batteries_v2.dll` / `provider.e_sqlite3.dll` | SQLite 原生提供程序 | +| `System.Text.Json.dll` / `Encodings.Web.dll` / `Microsoft.Bcl.AsyncInterfaces.dll` | JSON 序列化 | +| `System.Memory.dll` / `Buffers.dll` / `Numerics.Vectors.dll` / `Runtime.CompilerServices.Unsafe.dll` / `IO.Pipelines.dll` / `Threading.Tasks.Extensions.dll` | 基础库传递依赖 | +| `PdfSharpCore.dll` | PDF 生成 | +| `ICSharpCode.SharpZipLib.dll` | PdfSharpCore 依赖(ZIP) | +| `SixLabors.Fonts.dll` / `SixLabors.ImageSharp.dll` | PdfSharpCore 依赖(字体/图像) | +| `System.Collections.Concurrent.dll` / `IO.UnmanagedMemoryStream.dll` / `Threading.Tasks.Parallel.dll` / `Threading.dll` / `IO.FileSystem.Primitives.dll` | SixLabors 传递依赖 | + +### 1.3 DLL 导入设置 + +DLL 拖入 `Assets/Plugins/CounterDrone.Core/` 后,在 Inspector 中确认所有 DLL 的平台勾选:**Editor + Standalone + Android + iOS 全部勾选**(所有 DLL 均全平台兼容)。 + +> **IL2CPP 注意**:若遇 `TypeLoadException`,检查 `Player Settings → Managed Stripping Level`,建议设为 **Low** 或 **Medium**。 + +### 1.4 更新后端 DLL + +```bash +# 方式 A:一键脚本(自动编译 + 复制 DLL + Unity 编译验证) +pwsh scripts/check_unity_build.ps1 + +# 方式 B:手动 +cd src/CounterDrone.Core +dotnet publish -c Release -o ../../unity_plugins +# 然后将 unity_plugins/*.dll 全部复制到 src/Unity/Assets/Plugins/CounterDrone.Core/ +``` + +> **重要**:更新 DLL 后首次运行 Unity 会重建数据库,**历史仿真报告会丢失**(基础数据和预设想定自动重新种子)。这是预期行为。 + --- ## 二、快速开始 -1. 将 `defaults.json` 和 `planner_config.json` 放到 `Assets/StreamingAssets/` +1. 按 1.1 部署 DLL、脚本和 StreamingAssets 资源 2. 创建空 GameObject,挂 `SimulationBootstrap` 3. 运行 — 自动种子数据库,选一个 `[Demo]` 想定,启动仿真 -> `SimulationBootstrap` 是前端开发的参考模板,展示预设模式(一行代码)和自定义模式两种路径。 +> `SimulationBootstrap` 是参考模板,展示预设模式(一行代码)和自定义模式两种路径。Inspector 勾选 `_usePreset` 切换。 --- @@ -40,51 +83,35 @@ src/Unity/Assets/Scripts/Managers/ ← 桥接脚本 └─────────────┘ └─────────────┘ └─────────────┘ └──────────┘ └──────────┘ ``` -### 3.1 步骤 1:管理基础数据 +### 3.1 管理基础数据 ```csharp -var mgr = GetComponent(); -var ds = mgr.DataService; +var ds = GetComponent().DataService; -// 列出所有 var drones = ds.GetAllDrones(); -var ammo = ds.GetAllAmmo(); -var units = ds.GetAllFireUnits(); - -// 新增 -ds.SaveDrone(new DroneSpec { Id = "custom", Name = "自定义", Model = "DJI-M3", Description = "小型四旋翼", TypicalSpeed = 100, ... }); -// 删除 +ds.SaveDrone(new DroneSpec { Id = "custom", Name = "自定义", TypicalSpeed = 100, ... }); ds.DeleteDrone("custom"); -// 7 类全覆盖:DroneSpec / FireUnitSpec / SensorSpec / EnvironmentSpec -// FormationTemplate / RouteTemplate / AmmunitionSpec +// 7 类基础数据均提供 GetAll/Get/Save/Delete 四个方法 ``` -### 3.2 步骤 2:创建想定 +### 3.2 创建想定 ```csharp var scenario = GetComponent(); -// 方式 A:预设想定(最快) +// 方式 A:预设想定 var demos = scenario.Search("Demo", null, null, 1, 100); var scenarioId = demos.Items[0].Id; -// 方式 B:自定义 +// 方式 B:自定义(5 步配置) var sc = scenario.CreateScenario("我的想定", ""); scenario.SaveScene(sc.Id, new CombatScene { WindSpeed = 5, WindDirection = (int)WindDirection.E }); - -// 无人机:引用 DroneSpec (基础数据) + 想定特有字段 -scenario.SaveScenarioDrone(sc.Id, new ScenarioDrone { - DroneSpecId = "shahed", WaveId = "w1", Quantity = 3 -}); - -// 部署:引用 FireUnitSpec (基础数据) + 想定特有字段 +scenario.SaveScenarioDrone(sc.Id, new ScenarioDrone { DroneSpecId = "shahed", WaveId = "w1", Quantity = 3 }); scenario.SaveDeployment(sc.Id, new List { new() { FireUnitSpecId = "ground-light", Quantity = 2, - PositionX = 5000, PositionY = 0, PositionZ = 50, - AerosolType = 0, MunitionCount = 8, Description = "地面轻型平台" } + PositionX = 5000, PositionY = 0, PositionZ = 50, AerosolType = 0, MunitionCount = 8 } }); - scenario.SaveRoute(sc.Id, "w1", new RoutePlan(), new List { new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }, new() { PosX = 10000, PosY = 500, PosZ = 0, Speed = 200 } @@ -93,39 +120,63 @@ scenario.SaveCloud(sc.Id, new CloudDispersal { AerosolType = 0, DisperseHeight = scenario.UpdateStep(sc.Id, 5); ``` -### 3.3 步骤 3:运行仿真 +### 3.3 运行仿真 ```csharp var runner = GetComponent(); runner.LoadAndStart(scenarioId); runner.Engine.TimeScale = 4f; -// 事件 +// 事件订阅 runner.Engine.OnMunitionLaunched += m => { }; runner.Engine.OnCloudGenerated += c => { }; runner.Engine.OnDroneDestroyed += d => { }; runner.Engine.OnSimulationEnded += () => { }; -// 逐帧 +// 逐帧驱动(在 Update 中调用) void Update() { var result = runner.Engine.Tick(Time.deltaTime); foreach (var snap in result.EntitySnapshots) { // snap.EntityType, PosX/Y/Z, VelX/Y/Z, Hp, CloudRadius... } } + +runner.Stop(); ``` -### 3.4 步骤 4:生成报告 +### 3.4 生成报告 ```csharp var rm = GetComponent(); var detail = scenario.GetDetail(scenarioId); var report = rm.Generate(scenarioId, detail, runner.Engine.Events.ToList(), runner.Engine.Drones[0].Status.ToString(), runner.Engine.SimulationTime); -string path = rm.Export(report.Id); + +// Generate() 自动导出 .md 文件到 {persistentDataPath}/reports/ +// PDF 按需导出: +byte[] pdf = rm.ExportReport(report.Id, "pdf"); // → byte[] +string path = rm.Export(report.Id, dir, "pdf"); // → 文件路径 ``` -### 3.5 步骤 5:回放 +**Generate 参数**: + +| 参数 | 类型 | 来源 | +|------|------|------| +| `scenarioId` | string | 创建想定时获得 | +| `config` | ScenarioConfig | `scenario.GetDetail(id)` | +| `events` | List\ | `runner.Engine.Events.ToList()`(必须 `.ToList()` 拷贝) | +| `finalDroneStatus` | string | `runner.Engine.Drones[0].Status.ToString()` | +| `duration` | float | `runner.Engine.SimulationTime`(秒) | + +**导出方法**: +- `ExportReport(id, "pdf")` → `byte[]`,实时渲染 PDF,需字体文件 +- `ExportReport(id, "md")` → `byte[]`,从数据库直接读取,快 +- `Export(id, dir, "pdf"/"md")` → 文件路径,自动创建目录 +- `Export(id)` → 默认导出 MD 到 `{persistentDataPath}/reports/` + +> PDF 导出依赖 `StreamingAssets/fonts/CJK-Font.ttf`,缺失抛 `FileNotFoundException`。PDF 渲染同步阻塞主线程(小报告 ~100ms,大报告可达 2s),UI 建议显示 loading。 + +### 3.5 回放 ```csharp var replay = GetComponent(); @@ -138,202 +189,655 @@ for (int i = 0; i < replay.TotalFrames; i++) { --- -## 四、Manager API 速查 +## 四、坐标系与 3D 可视化 -### ScenarioManager — 基础数据 + 想定 +### 4.1 坐标系约定 + +| 项目 | 约定 | +|------|------| +| **向上轴** | **Y 轴 = 高度**(与 Unity 默认一致) | +| **水平面** | X 轴 = 纵深(航路方向),Z 轴 = 横向 | +| **单位** | 米(m) | +| **原点** | 场景一角(非中心),航路通常从 X=0 沿 X 正方向延伸 | +| **典型场景尺寸** | 4000m × 10000m × 1000m(W × L × H,见 `CombatScene.SceneWidth/Length/Height`) | +| **典型无人机高度** | 300~500m(PosY = 500 即 500m 高空) | +| **速度单位** | 航路点 `Waypoint.Speed` = **km/h**;平台 `CruiseSpeed` = **m/s**(引擎内部自动换算) | + +> `SimulationRunner` 直接 `transform.position = new Vector3(snap.PosX, snap.PosY, snap.PosZ)`,无需轴变换。 + +### 4.2 Prefab 搭建 + +`SimulationRunner` 有 3 个 `[SerializeField]` prefab 字段,Inspector 中拖入: + +| 字段 | 用途 | null 时回退 | 建议缩放 | +|------|------|------------|---------| +| `_dronePrefab` | 无人机 | Cube | Runner 自动设 `localScale = Vector3.one * 50f` | +| `_cloudPrefab` | 云团 | Sphere | Runner 按云团半径动态设 `localScale = Vector3.one * CloudRadius` | +| `_platformPrefab` | 发射平台 | Capsule | Runner 自动设 `localScale = (30, 80, 30)` | +| —(无字段) | 弹药 | Cylinder | Runner 自动设 `localScale = (2, 10, 2)` | + +**Prefab 组件要求**: + +| 组件 | 必需 | 说明 | +|------|------|------| +| `Renderer` | ✅ | Runner 通过 `GetComponent().material` 设置颜色 | +| `Collider` | 可选 | Runner 不依赖 Collider | +| 脚本组件 | 不需要 | 位置由 Runner 每帧驱动,prefab 不需要自带移动脚本 | + +**云团可视化建议**:prefab 用 Sphere 即可(Runner 设红色半透明材质)。如需粒子效果,可自行扩展——Runner 每帧更新 `CloudRadius`(缩放)和 `CloudOpacity`(材质 alpha),粒子系统可读取这两个值驱动参数。 + +### 4.3 SimulationRunner 可视化机制 + +Runner 的 `Update()` 每帧执行: + +1. 调用 `engine.Tick(Time.deltaTime)` → 获取 `SimulationFrameResult` +2. 遍历 `result.EntitySnapshots`: + - 新实体(EntityId 未见过)→ Instantiate prefab + - 已有实体 → 更新 `transform.position` + - Cloud 类型 → 更新 `localScale`(按 `CloudRadius`)和材质 alpha(按 `CloudOpacity`) +3. 遍历 `engine.Munitions` 同步炮弹位置,清理已消失的弹药 GameObject + +**实体颜色约定**(Runner 硬编码,可自行修改): +- 无人机:prefab 原始材质 +- 云团:红色半透明 `(1, 0, 0, CloudOpacity)` +- 平台:蓝色半透明 `(0.2, 0.5, 1, 0.8)` +- 弹药:黄色 `(1, 0.92, 0.016, 1)` + +### 4.4 相机定位参考 + +`SimulationBootstrap` 中的相机设置(可供参考): ```csharp -var mgr = GetComponent(); +// 俯瞰视角 +cam.transform.position = new Vector3(midX, 2000, -3000); // midX = 场景中点 +cam.transform.LookAt(new Vector3(midX, 500, 0)); // 看向 500m 高度 +``` -// 基础数据 CRUD(7 类) -mgr.DataService.GetAllAmmo() / SaveAmmo() / GetAmmo() / DeleteAmmo() -mgr.DataService.GetAllFireUnits() / SaveFireUnit() / GetFireUnit() / DeleteFireUnit() -mgr.DataService.GetAllDrones() / SaveDrone() / GetDrone() / DeleteDrone() -mgr.DataService.GetAllSensors() / SaveSensor() / GetSensor() / DeleteSensor() -mgr.DataService.GetAllEnvironments() / SaveEnvironment() / GetEnvironment() / DeleteEnvironment() -mgr.DataService.GetAllFormations() / SaveFormation() / GetFormation() / DeleteFormation() -mgr.DataService.GetAllRoutes() / SaveRoute() / GetRoute() / DeleteRoute() +### 4.5 探测范围可视化 -// 想定 CRUD -mgr.CreateScenario(name, number) / mgr.UpdateScenario(s) / mgr.DeleteScenario(id) -mgr.Search(kw, from, to, page, size) / mgr.GetDetail(id) → ScenarioConfig +`DetectionEntity` 提供 `IsInRange(x, y, z, visibility, out effectiveRange)` 方法。如需绘制 3D 球冠范围,可从 `SensorSpec` 读取 `RadarRange`/`EORange`/`IRRange` 和 `MinElevation`/`MaxElevation`/`MinDetectAlt`/`MaxDetectAlt`,用 wireframe sphere 或 mesh 表示。 + +### 4.6 管控区域可视化 + +`ControlZone` 的 `VerticesJson` 存储多边形顶点 JSON:`[{"x":0,"y":0,"z":0},...]`。配合 `MinAltitude`/`MaxAltitude` 构成 3D 棱柱区域。前端可用 `LineRenderer` 画底面多边形 + 垂直线段表示高度范围。 + +--- + +## 五、Manager API 完整签名 + +所有 Manager 继承 `MonoBehaviour`,在 `Awake()` 中初始化内部服务。**必须在 `Awake` 之后(如 `Start`)才能调用**,`OnDisable` 后不可调用。 + +### ScenarioManager + +```csharp +public IScenarioService Service { get; } +public IDataService DataService { get; } // 基础数据 CRUD(7 类) + +// Scenario CRUD +public Scenario CreateScenario(string name, string number); +public Scenario UpdateScenario(Scenario s); +public void DeleteScenario(string id); +public ScenarioConfig GetDetail(string id); +public PagedResult Search(string kw, string from, string to, int page, int size); // CombatScene -mgr.SaveScene(id, s) / mgr.GetScene(id) +public void SaveScene(string scenarioId, CombatScene s); +public CombatScene GetScene(string scenarioId); // ControlZone -mgr.SaveControlZones(id, zones) / mgr.GetControlZones(id) / mgr.DeleteControlZone(id) +public void SaveControlZones(string scenarioId, List z); +public List GetControlZones(string scenarioId); +public void DeleteControlZone(string id); // ScenarioDrone -mgr.SaveScenarioDrone(id, d) / mgr.AddScenarioDrone(id, d) / mgr.UpdateScenarioDrone(d) -mgr.DeleteScenarioDrone(id) / mgr.GetScenarioDrones(id) +public ScenarioDrone SaveScenarioDrone(string scenarioId, ScenarioDrone d); +public ScenarioDrone AddScenarioDrone(string scenarioId, ScenarioDrone d); +public void UpdateScenarioDrone(ScenarioDrone d); +public void DeleteScenarioDrone(string id); +public List GetScenarioDrones(string scenarioId); -// ScenarioUnit -mgr.SaveDeployment(id, units) / mgr.AddDeploymentUnit(id, u) / mgr.UpdateDeploymentUnit(u) -mgr.DeleteDeploymentUnit(id) / mgr.GetDeploymentUnits(id) +// ScenarioUnit(部署) +public void SaveDeployment(string scenarioId, List e); +public ScenarioUnit AddDeploymentUnit(string scenarioId, ScenarioUnit u); +public void UpdateDeploymentUnit(ScenarioUnit u); +public void DeleteDeploymentUnit(string id); +public List GetDeploymentUnits(string scenarioId); -// Detection -mgr.AddDetection(id, d) / mgr.DeleteDetection(id) / mgr.GetDetections(id) +// Detection(探测设备,复用 ScenarioUnit) +public void AddDetection(string scenarioId, ScenarioUnit d); +public void DeleteDetection(string id); +public List GetDetections(string scenarioId); // CloudDispersal -mgr.SaveCloud(id, c) / mgr.GetCloud(id) / mgr.DeleteCloud(id) +public void SaveCloud(string scenarioId, CloudDispersal c); +public CloudDispersal GetCloud(string scenarioId); +public void DeleteCloud(string scenarioId); // Route & Waypoint -mgr.SaveRoute(id, waveId, r, wps) / mgr.GetRoutes(id) / mgr.DeleteRoute(id) -mgr.GetWaypoints(id) / mgr.GetWaypointsByWave(id, waveId) / mgr.DeleteWaypoint(id) +public void SaveRoute(string scenarioId, string waveId, RoutePlan r, List w); +public List GetRoutes(string scenarioId); +public void DeleteRoute(string id); +public List GetWaypoints(string scenarioId); +public List GetWaypointsByWave(string scenarioId, string waveId); +public void DeleteWaypoint(string id); // Misc -mgr.UpdateStep(id, step) -mgr.GetEnums() → EnumMetadata // 所有枚举中英文对照(下拉框数据源) -// EnumMetadata 每个字段是 List -// EnumItem: { Name(英文), ChineseName(中文), Value(int) } -// 覆盖: DroneType, PowerType, PlatformType, AerosolType, -// WeatherType, WindDirection, SceneType, FormationMode, -// TriggerMode, ReleaseMode, EquipmentRole +public void UpdateStep(string scenarioId, int step); +public EnumMetadata GetEnums(); // 枚举中英文对照(下拉框数据源) ``` -### SimulationRunner — 仿真执行 +### IDataService(通过 `ScenarioManager.DataService` 访问) + +7 类基础数据,每类 4 个方法(`GetAll*` / `Get*(id)` / `Save*(spec)` / `Delete*(id)`): + +| 基础数据类型 | 方法前缀 | 模型类 | +|---|---|---| +| 弹药 | `Ammo` | `AmmunitionSpec` | +| 火力单元 | `FireUnit` | `FireUnitSpec` | +| 无人机 | `Drone` | `DroneSpec` | +| 传感器 | `Sensor` | `SensorSpec` | +| 环境 | `Environment` | `EnvironmentSpec` | +| 编队 | `Formation` | `FormationTemplate` | +| 航线 | `Route` | `RouteTemplate` | ```csharp -var runner = GetComponent(); -runner.LoadAndStart(scenarioId); -runner.Engine.TimeScale = 4f; -runner.Engine.State / Drones / Clouds / Munitions / Platforms / DetectionEntities / Events -runner.Engine.Tick(dt) → SimulationFrameResult { EntitySnapshots, NewEvents } -runner.Engine.OnMunitionLaunched / OnCloudGenerated / OnDroneDestroyed / ... -runner.Stop(); +List GetAllDrones(); +DroneSpec GetDrone(string id); +void SaveDrone(DroneSpec spec); +void DeleteDrone(string id); +// 其余 6 类签名相同,替换前缀即可 ``` -### ReportManager — 报告 +### SimulationRunner ```csharp -var rm = GetComponent(); -rm.Generate(scenarioId, detail, events, status, duration) → SimulationReport -rm.Export(reportId) → 文件路径 -rm.Export(reportId, "D:\\MyReports") // 支持指定导出路径 +public FrameDataStore FrameStore { get; } +public SimulationEngine Engine { get; } // 核心引擎 +public IDataService DataService { get; set; } + +public void LoadAndStart(string scenarioId); +public void Stop(); ``` -### ReplayController — 回放 +通过 `runner.Engine` 访问的成员: ```csharp -var replay = GetComponent(); -replay.LoadReplay(scenarioId); -replay.TotalFrames / replay.GetFrame(index) +// ── 状态 ── +runner.Engine.State // SimulationState: Idle/Running/Paused/Stopped/Completed +runner.Engine.TimeScale // float,时间倍率(默认 1,设 4 = 4 倍速) +runner.Engine.SimulationTime // float,仿真累计时间(秒) +runner.Engine.FrameIndex // int,当前帧序号 +runner.Engine.RecordFrames // bool,是否录制帧数据(回放用),默认 true + +// ── 实体列表(IReadOnlyList,只读) ── +runner.Engine.Drones // IReadOnlyList +runner.Engine.Clouds // IReadOnlyList +runner.Engine.Munitions // IReadOnlyList +runner.Engine.Platforms // IReadOnlyList +runner.Engine.DetectionEntities // IReadOnlyList +runner.Engine.Events // IReadOnlyList(仿真期间持续追加) + +// ── 方法 ── +runner.Engine.Tick(float dt) // → SimulationFrameResult(推进一帧) +runner.Engine.Pause() // Running → Paused +runner.Engine.Resume() // Paused → Running +runner.Engine.Stop() // → Stopped + Flush 帧数据到数据库 + +// ── 事件(Action 委托) ── +runner.Engine.OnMunitionLaunched // Action +runner.Engine.OnCloudGenerated // Action +runner.Engine.OnDroneDestroyed // Action +runner.Engine.OnDroneReachedTarget // Action +runner.Engine.OnZoneIntruded // Action +runner.Engine.OnTargetDetected // Action +runner.Engine.OnSimulationEnded // Action +``` + +### ReportManager + +```csharp +public IReportService Service { get; } + +public SimulationReport Generate(string scenarioId, ScenarioConfig cfg, + List events, string status, float duration); +public SimulationReport Get(string id); +public void Delete(string id); +public PagedResult Search(string kw, string from, string to, int page, int size); + +public byte[] ExportReport(string reportId, string format); // "pdf" | "md" +public string Export(string reportId, string outputDir, string format); // "pdf" | "md" +public string Export(string reportId); // 默认 MD 到 reports 目录 +``` + +### ReplayController + +```csharp +public int TotalFrames { get; } +public int CurrentFrameIndex { get; } +public List CurrentFrames { get; } + +public void LoadReplay(string scenarioId, FrameDataStore frameStore = null); +public List GetFrame(int frameIndex); +``` + +> `LoadReplay` 优先用内存帧数据(仿真刚结束),无则从 SQLite 帧库加载(历史回放)。 + +### ModelManager + +```csharp +public IModelService Service { get; } + +public ModelInfo Import(string filePath, string name); +public ModelInfo Import(string filePath, string name, int entityType, string desc); +public ModelInfo Add(ModelInfo model); +public ModelInfo UpdateModel(ModelInfo model); +public void Delete(string id); +public List GetAll(); +public ModelInfo Get(string id); ``` --- -## 五、EntitySnapshot(每帧推送) +## 六、枚举值清单 + +### 6.1 有官方中文翻译的枚举(GetEnums() 返回) + +这 11 个枚举通过 `scenario.GetEnums()` 获取中英文对照,可直接用于下拉框: + +```csharp +var enums = scenario.GetEnums(); +// enums.DroneTypes → List { Name="Rotor", ChineseName="旋翼", Value=0 }, ... +// enums.PowerTypes, enums.PlatformTypes, enums.AerosolTypes, +// enums.WeatherTypes, enums.WindDirections, enums.SceneTypes, +// enums.FormationModes, enums.TriggerModes, enums.ReleaseModes, enums.EquipmentRoles +``` + +| 枚举 | 成员 = 值 | 中文 | +|------|----------|------| +| **DroneType** | Rotor=0, FixedWing=1, HighSpeed=2 | 旋翼/固定翼/高速目标 | +| **PowerType** | Electric=0, Piston=1, Jet=2 | 电推/活塞/喷气 | +| **PlatformType** | AirBased=0, GroundBased=1 | 空基/地基 | +| **AerosolType** | InertGas=0, ActiveMaterial=1, ActiveFuel=2 | 惰性气体(吸入式灭火)/活性材料(爆燃式)/活性燃料(吸入式爆炸) | +| **WeatherType** | Sunny=0, Overcast=1, Fog=2, Rain=3, Night=4 | 晴/阴/雾/雨/夜 | +| **WindDirection** | N=0, NE=1, E=2, SE=3, S=4, SW=5, W=6, NW=7 | 北/东北/东/东南/南/西南/西/西北 | +| **SceneType** | Urban=0, Plain=1, Mountain=2, Coast=3 | 城市/平原/山区/海岸 | +| **FormationMode** | Single=0, Formation=1, Swarm=2 | 单机/编队/蜂群 | +| **TriggerMode** | Time=0, Area=1, Manual=2 | 时间触发/区域触发/手动触发 | +| **ReleaseMode** | Single=0, Continuous=1, Pulse=2 | 单次齐射/连续释放/脉冲释放 | +| **EquipmentRole** | Detection=0, LaunchPlatform=1 | 探测设备/发射平台 | + +### 6.2 无官方翻译的枚举(前端需自行映射中文) + +| 枚举 | 命名空间 | 成员 = 值 | 含义 | +|------|---------|----------|------| +| **SimulationState** | `Simulation` | Idle=0, Running=1, Paused=2, Stopped=3, Completed=4 | 空闲/运行/暂停/停止/完成 | +| **DroneStatus** | `Simulation` | Flying=0, Destroyed=1, ReachedTarget=2, ZoneIntruded=3 | 飞行中/已摧毁/到达目标/侵入管控区 | +| **PlatformState** | `Simulation` | Idle=0, FlyingToTarget=1, ReadyToRelease=2 | 空闲/飞向目标/准备投放 | +| **EntityType** | `Models` | Drone=0, Platform=1, DetectionEquip=2, Cloud=3, Munition=4 | 无人机/平台/探测设备/云团/弹药 | +| **SimEventType** | `Models` | TargetDetected=0, MunitionLaunched=1, CloudGenerated=2, DroneEnteredCloud=3, DroneDestroyed=4, DroneReachedTarget=5, ZoneIntruded=6, WaypointReached=7, SimulationEnd=8, PlanningFailed=9 | 目标发现/弹药发射/云团生成/进入云团/摧毁/到达目标/侵入管控区/到达航点/仿真结束/规划失败 | +| **InterceptResult** | `Models` | Success=0, Partial=1, Failed=2 | 成功拦截/部分拦截/拦截失败 | +| **DamageStage** | `Models` | Normal=0, EngineAnomaly=1, AttitudeLoss=2, Destroyed=3 | 正常/引擎异常/姿态丧失/摧毁 | +| **ScenarioStatus** | `Models` | Draft=0, Configuring=1, Completed=2 | 草稿/配置中/已完成 | + +> **EntitySnapshot.EntityType** 用的是 `EntityType` 枚举(int),判断实体类型时用 `(EntityType)snap.EntityType` 或直接比较 int 值。 + +--- + +## 七、关键数据结构 + +### EntitySnapshot(每帧推送) | 字段 | 类型 | 说明 | |------|------|------| -| `EntityId` | string | 实体唯一 ID | -| `EntityType` | enum | Drone / Platform / Munition / Cloud / DetectionEquip | -| `PosX/Y/Z` | float | 三维位置 | +| `EntityId` | string | 实体唯一 ID(属性) | +| `EntityType` | EntityType | Drone=0/Platform=1/DetectionEquip=2/Cloud=3/Munition=4(属性) | +| `PosX/Y/Z` | float | 三维位置(Y=高度,米) | +| `RotX/Y/Z` | float | 旋转 | | `VelX/Y/Z` | float | 瞬时速度 (m/s) | | `Hp` | float | 无人机血量 (0~1) | -| `DamageStage` | int | 损伤阶段 | -| `PlatformStateStr` | string | Idle / FlyingToTarget / ReadyToRelease | -| `CloudRadius` | float | 云团有效半径 | +| `DamageStage` | int | 毁伤阶段(0正常/1引擎异常/2姿态丧失/3摧毁) | +| `PlatformStateStr` | string? | 平台状态:Idle/FlyingToTarget/ReadyToRelease | +| `CloudRadius` | float | 云团有效半径(米) | | `CloudOpacity` | float | 不透明度 (0~1) | | `CloudPhase` | int | 扩散阶段 (1/2/3) | -| `CloudElapsed` | float | 已存在时间 (s) | +| `CloudElapsed` | float | 云团已存在时间 (s) | | `ModelId` | string? | 3D 模型 ID(Unity 实例化用) | +> 除 `EntityId`/`EntityType` 是属性外,其余均为**公共字段**(非属性)。 + +### SimEvent(仿真事件) + +```csharp +public class SimEvent +{ + public SimEventType Type { get; set; } // 事件类型(见枚举表) + public float OccurredAt { get; set; } // 发生时刻(仿真秒) + public string SourceId { get; set; } // 触发实体 ID + public string TargetId { get; set; } // 目标实体 ID + public string DataJson { get; set; } // 扩展数据(JSON) + public string Description { get; set; } // 人类可读描述 +} +``` + +### SimulationFrameResult(Tick 返回值) + +```csharp +public class SimulationFrameResult +{ + public List EntitySnapshots { get; set; } // 本帧所有实体快照 + public List NewEvents { get; set; } // 本帧新事件 + public SimulationState State { get; set; } // 当前仿真状态 + public int FrameIndex { get; set; } // 帧序号 + public float SimulationTime { get; set; } // 仿真累计时间 +} +``` + +### PagedResult<T>(分页查询返回值) + +```csharp +public class PagedResult +{ + public List Items { get; set; } // 当前页数据 + public int TotalCount { get; set; } // 总记录数 + public int Page { get; set; } // 当前页码(从 1 开始) + public int PageSize { get; set; } // 每页条数 + public int TotalPages { get; } // 总页数(计算属性) +} +``` + +### EnumMetadata / EnumItem(GetEnums 返回值) + +```csharp +public class EnumItem +{ + public string Name { get; set; } // 英文名 + public string ChineseName { get; set; } // 中文名 + public int Value { get; set; } // 枚举值 +} + +public class EnumMetadata +{ + public List DroneType { get; set; } + public List PowerType { get; set; } + public List PlatformType { get; set; } + public List AerosolType { get; set; } + public List WeatherType { get; set; } + public List WindDirection { get; set; } + public List SceneType { get; set; } + public List FormationMode { get; set; } + public List TriggerMode { get; set; } + public List ReleaseMode { get; set; } + public List EquipmentRole { get; set; } +} +``` + +### SimulationReport + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 报告 ID(主键) | +| `ScenarioId` | string | 想定 ID | +| `ScenarioName` | string | 想定名称 | +| `ScenarioNumber` | string | 想定编号 | +| `CompletedAt` | string | 完成时间(UTC ISO 8601) | +| `DroneCount` | int | 无人机总数 | +| `UnitCount` | int | 装备总数 | +| `InterceptResult` | int | 拦截结果(0成功/1部分/2失败) | +| `Content` | string | Markdown 报告文本(快速展示用) | +| `ReportDataJson` | string? | 结构化数据 JSON(PDF 导出用) | + --- -## 六、实体属性速查 +## 八、运行时实体属性 + +仿真运行时通过 `runner.Engine.Drones` / `Clouds` / `Munitions` / `Platforms` / `DetectionEntities` 直接访问。 | 实体 | 关键属性 | -|------|------| -| **DroneEntity** | Id, PosX/Y/Z, Hp, Status, DroneType, Wingspan, CruiseSpeed, Route, TraveledArc, TotalArc, Progress | -| **CloudEntity** | Id, PosX/Y/Z, Radius, Density, Phase, Elapsed, IsDissipated | -| **MunitionEntity** | Id, PosX/Y/Z, Velocity, Start, LaunchTime, ElapsedTime, LaunchAngle, Azimuth, MuzzleVelocity, FlightDuration, TargetX/Y/Z, HasArrived | -| **PlatformEntity** | Id, PosX/Y/Z, CurrentVelocity, State, MunitionCount, Cooldown, MuzzleVelocity, CruiseSpeed, ReleaseAltitude, TargetX/Y/Z, FlightDistance | -| **DetectionEntity** | Id, PosX/Y/Z, Source, IsInRange(), IsDetected() | +|------|---------| +| **DroneEntity** | Id, PosX/Y/Z, Hp, Status(DroneStatus), DroneType, PowerType, Wingspan, CruiseSpeed, Route, TraveledArc, TotalArc, Progress, ModelId | +| **CloudEntity** | Id, PosX/Y/Z, Radius, Density, Phase(1/2/3), Elapsed, IsDissipated, AerosolType | +| **MunitionEntity** | Id, PosX/Y/Z, Velocity(Vx,Vy,Vz), StartX/Y/Z, LaunchTime, ElapsedTime, LaunchAngle, Azimuth, MuzzleVelocity, FlightDuration, TargetX/Y/Z, HasArrived | +| **PlatformEntity** | Id, PosX/Y/Z, CurrentVelocity, State(PlatformState), MunitionCount, CooldownRemaining, MuzzleVelocity, CruiseSpeed, ReleaseAltitude, TargetX/Y/Z, FlightDistance, Ready, CanRelease | +| **DetectionEntity** | Id, PosX/Y/Z, Source, IsInRange(x,y,z,visibility,out range), IsDetected(droneId), ModelId | +| **ControlZoneEntity** | Id, Name, Vertices(List\), MinAltitude, MaxAltitude, ContainsPoint(x,y,z) | --- -## 七、核心模型 +## 九、核心模型完整字段 -### 7.1 数据分层 +### 9.1 ScenarioConfig(想定配置聚合根 — GetDetail 返回值) ``` -基础数据(全局模板) 想定数据(归属 ScenarioId) -───────────────── ────────────────────────── -DroneSpec ScenarioDrone (DroneSpecId FK) -LaunchPlatformSpec ScenarioUnit (LaunchPlatformSpecId / SensorSpecId FK) -SensorSpec -AmmunitionSpec -EnvironmentSpec -FormationTemplate -RouteTemplate -ModelInfo +ScenarioConfig + Info: Scenario // 想定主表 + Scene: CombatScene // 步骤1:作战环境 + ControlZones: List // 步骤1:管控区域 + Drones: List // 步骤2:威胁目标 + Units: List // 步骤3:我方部署(含探测+发射平台) + Cloud: CloudDispersal // 步骤4:云团抛撒配置 + Routes: List // 步骤5:航路规划(多批次) + WaypointGroups: Dictionary> // 按 WaveId 分组的航路点 ``` -### 7.2 数据库表 +### 9.2 Scenario(想定主表) -| 模型 | SQLite 表 | 说明 | +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `Id` | string | GUID | 主键 | +| `Name` | string | "" | 想定名称 | +| `ScenarioNumber` | string | "" | 想定编号(唯一) | +| `Description` | string | "" | 描述 | +| `Status` | int | 0 | 0=草稿 1=配置中 2=已完成 | +| `CurrentStep` | int | 1 | 当前配置步骤(1~5) | +| `TickRate` | int | 20 | 仿真帧率 | +| `CreatedAt` | string | UTC ISO | 创建时间 | +| `UpdatedAt` | string | UTC ISO | 更新时间 | + +### 9.3 CombatScene(步骤 1:作战环境) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `ScenarioId` | string | "" | 主键 = 想定 ID | +| `SceneType` | int | 1(Plain) | 0=城市 1=平原 2=山区 3=海岸 | +| `TimeOfDay` | string | "12:00" | 时间段 | +| `WeatherType` | int | 0(Sunny) | 0=晴 1=阴 2=雾 3=雨 4=夜 | +| `WindSpeed` | double | 5.0 | 风速 m/s | +| `WindDirection` | int | 0(N) | 0~7 = N/NE/E/SE/S/SW/W/NW | +| `Temperature` | double | 20.0 | 温度 °C | +| `Humidity` | double | 60.0 | 湿度 %RH | +| `Pressure` | double | 1013.0 | 气压 hPa | +| `Visibility` | double | 5000.0 | 能见度 m | +| `SceneWidth` | double | 4000.0 | 场景宽 m | +| `SceneLength` | double | 10000.0 | 场景长 m | +| `SceneHeight` | double | 1000.0 | 场景高 m | + +### 9.4 ControlZone(管控区域) + +| 字段 | 类型 | 说明 | |------|------|------| -| `Scenario` | `Scenario` | 想定任务 | -| `CombatScene` | `CombatScene` | 作战环境 | -| `ScenarioDrone` | `ScenarioDrone` | 无人机批次(引用 DroneSpec) | -| `ScenarioUnit` | `ScenarioUnit` | 装备部署(引用 LaunchPlatformSpec/SensorSpec) | -| `CloudDispersal` | `CloudDispersal` | 云团抛撒配置 | -| `RoutePlan` | `RoutePlan` | 航路规划 | -| `Waypoint` | `Waypoint` | 航路点 | -| `ControlZone` | `ControlZone` | 管控区域 | -| `SimulationReport` | `SimulationReport` | 仿真报告 | -| `DroneSpec` | `DroneSpec` | 无人机基础模板 | -| `LaunchPlatformSpec` | `LaunchPlatformSpec` | 发射平台模板 | -| `FireUnitSpec` | `FireUnitSpec` | 火力单元(保留不用) | -| `SensorSpec` | `SensorSpec` | 传感器基础模板 | -| `AmmunitionSpec` | `AmmunitionSpec` | 弹药基础参数 | -| `EnvironmentSpec` | `EnvironmentSpec` | 环境基础模板 | -| `FormationTemplate` | `FormationTemplate` | 编队基础模板 | -| `RouteTemplate` | `RouteTemplate` | 航线基础模板 | -| `ModelInfo` | `ModelInfo` | 3D 模型元数据 | +| `Id` | string | 主键 | +| `ScenarioId` | string | 想定 ID | +| `Name` | string | 区域名称 | +| `VerticesJson` | string | 多边形顶点 JSON:`[{"x":0,"y":0,"z":0},...]` | +| `MinAltitude` | double | 高度下限 m | +| `MaxAltitude` | double | 高度上限 m | +| `OrderIndex` | int | 排序序号 | -### 7.3 关键属性 +### 9.5 ScenarioDrone(步骤 2:威胁目标) -| 模型 | 属性 | 类型 | 说明 | -|------|------|------|------| -| `ScenarioDrone` | `DroneSpecId` | string | FK → DroneSpec | -| `ScenarioDrone` | `WaveId` | string | 批次 ID | -| `ScenarioDrone` | `Quantity` | int | 数量 | -| `ScenarioUnit` | `LaunchPlatformSpecId` | string | FK → FireUnitSpec(发射平台) | -| `ScenarioUnit` | `SensorSpecId` | string | FK → SensorSpec(探测设备) | -| `ScenarioUnit` | `PositionX/Y/Z` | double | 部署位置 | -| `ScenarioUnit` | `AerosolType` | int? | 挂载弹药类型 | -| `ScenarioUnit` | `MunitionCount` | int? | 弹药数量 | -| `ScenarioUnit` | `Description` | string | 部署备注 | -| `DroneSpec` | `Model` | string | 型号名称(非 3D 模型,3D 模型见 ModelId) | -| `DroneSpec` | `Description` | string | 描述/用途 | -| `DroneSpec` | `ModelId` | string | 3D 模型 FK → ModelInfo | -| `DroneSpec` | `DroneType` | DroneType | Rotor / FixedWing / HighSpeed | -| `DroneSpec` | `PowerType` | PowerType | Electric / Piston / Jet | -| `DroneSpec` | `TypicalSpeed` | double | 典型速度 (km/h) | -| `DroneSpec` | `TypicalAltitude` | double | 典型高度 (m) | -| `FireUnitSpec` | `Description` | string | 描述/用途(保留不用) | -| `LaunchPlatformSpec` | `Description` | string | 描述/用途 | -| `LaunchPlatformSpec` | `ModelId` | string | 3D 模型 FK → ModelInfo | -| `LaunchPlatformSpec` | `MuzzleVelocity` | double | 初速 (m/s) | -| `LaunchPlatformSpec` | `CruiseSpeed` | double | 巡航速度 (m/s) | -| `ModelInfo` | `EntityType` | int | 实体类型 0=Drone 1=Platform 2=DetectionEquip | -| `ModelInfo` | `FilePath` | string | 模型文件路径 | -| `Scenario` | `ScenarioNumber` | string | 想定编号(唯一) | -| `Scenario` | `Description` | string | 想定描述 | -| `SimulationReport` | `DroneCount` | int | 无人机总数 | -| `SimulationReport` | `UnitCount` | int | 装备总数 | +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `ScenarioId` | string | 想定 ID | +| `DroneSpecId` | string | FK → DroneSpec | +| `WaveId` | string | 批次 ID(如 "w1") | +| `Quantity` | int | 数量 | + +### 9.6 ScenarioUnit(步骤 3:我方部署) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `Id` | string | GUID | 主键 | +| `ScenarioId` | string | "" | 想定 ID | +| `EquipmentRole` | int | 1 | 0=探测设备 1=发射平台 | +| `LaunchPlatformSpecId` | string | "" | FK → LaunchPlatformSpec(发射平台时填) | +| `SensorSpecId` | string | "" | FK → SensorSpec(探测设备时填) | +| `Quantity` | int | 1 | 数量 | +| `PositionX/Y/Z` | double | 0 | 部署位置(Y=高度,米) | +| `AerosolType` | int? | null | 挂载弹药类型(0/1/2) | +| `MunitionCount` | int? | null | 弹药数量 | +| `WaveId` | string | "" | 关联批次 | +| `Description` | string | "" | 备注 | +| `Source` | int | 0 | 0=手动 1=算法推荐 | + +### 9.7 CloudDispersal(步骤 4:云团抛撒配置) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `ScenarioId` | string | "" | 主键 = 想定 ID | +| `AerosolType` | int | 0 | 0=惰性气体 1=活性材料 2=活性燃料 | +| `DisperseHeight` | double | 300.0 | 抛撒高度 m | +| `TriggerMode` | int | 0 | 0=时间 1=区域 2=手动 | +| `SpreadAngle` | double | 0 | 扩散方向 ° | +| `ReleaseMode` | int | 0 | 0=单次 1=连续 2=脉冲 | +| `InitialScale` | double | 1000.0 | 初始规模 m³ | +| `Duration` | double | 60.0 | 持续时间 s | +| `ReleaseInterval` | double | 5.0 | 释放间隔 s | +| `TotalAmount` | double | 50.0 | 释放总量 kg | +| `PositionX/Y/Z` | double | 0 | 抛撒位置 | +| `PositionMode` | int | 1 | 0=点选 1=手动输入 2=算法推荐 | +| `Source` | string | "Manual" | "Manual" 或 "Algorithm" | +| `SalvoRounds` | int | 0 | 推荐弹药数(算法填充) | +| `SalvoSpacing` | double | 0 | 云团间距 m(算法填充) | +| `EstimatedProbability` | double? | null | 算法估计拦截概率 | +| `RecommendedTiming` | double? | null | 推荐抛撒时机 s | +| `RecommendedPosX/Y/Z` | double? | null | 推荐抛撒位置 | +| `CriticalPosX/Y/Z` | double? | null | 关键拦截位置 | +| `CriticalTiming` | double? | null | 关键拦截时机 s | + +### 9.8 RoutePlan(步骤 5:航路规划) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `Id` | string | GUID | 主键 | +| `ScenarioId` | string | "" | 想定 ID | +| `WaveId` | string | "" | 批次 ID | +| `FormationMode` | int | 0 | 0=单机 1=编队 2=蜂群 | +| `LateralSpacing` | double | 50.0 | 横向间距 m | +| `LateralCount` | int? | null | 横向架数(null=全部) | +| `LongitudinalCount` | int? | null | 纵深架数(null=1) | +| `LongitudinalSpacing` | double | 50.0 | 纵向间距 m | +| `LateralAxis` | int | 2 | 横向展开轴:0=X 1=Y(垂直) 2=Z(默认) | +| `LongitudinalAxis` | int | 0 | 纵向串列轴:0=X(默认) 1=Y 2=Z | +| `ETA` | string | "" | 预计到达时间 | + +### 9.9 Waypoint(航路点) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `ScenarioId` | string | 想定 ID | +| `WaveId` | string | 批次 ID | +| `OrderIndex` | int | 顺序索引(从 0 开始) | +| `PosX` | double | X 坐标 m | +| `PosY` | double | Y 坐标 = 高度 m | +| `PosZ` | double | Z 坐标 m | +| `Altitude` | double | 高度 m(与 PosY 冗余) | +| `Speed` | double | 该段速度 **km/h** | --- -## 八、默认数据 +## 十、基础数据模型完整字段 + +### 10.1 DroneSpec(无人机模板) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `Name` | string | 名称 | +| `DroneType` | int | 0=旋翼 1=固定翼 2=高速 | +| `Model` | string | 型号名称 | +| `Description` | string | 描述 | +| `PowerType` | int | 0=电推 1=活塞 2=喷气 | +| `Wingspan` | double | 翼展 m | +| `TypicalSpeed` | double | 典型速度 km/h | +| `TypicalAltitude` | double | 典型高度 m | +| `ModelId` | string | 3D 模型 ID(FK → ModelInfo) | + +### 10.2 LaunchPlatformSpec(发射平台模板) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `Name` | string | 名称 | +| `Description` | string | 描述 | +| `PlatformType` | int | 0=空基 1=地基 | +| `GunCount` | int | 火炮数量 | +| `ChannelsPerGun` | int | 每炮通道数 | +| `ChannelInterval` | double | 同通道发射间隔 s | +| `Cooldown` | double | 射击冷却 s | +| `AmmoChangeTime` | double | 换弹时间 s | +| `MuzzleVelocity` | double | 初速 m/s | +| `CruiseSpeed` | double | 巡航速度 m/s(空基) | +| `ReleaseAltitude` | double | 释放高度 m(空基) | +| `ModelId` | string | 3D 模型 ID | + +### 10.3 SensorSpec(传感器模板) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `Name` | string | 名称 | +| `RadarRange` | double | 雷达探测距离 m(0=无) | +| `EORange` | double | 光电探测距离 m | +| `IRRange` | double | 红外探测距离 m | +| `Accuracy` | double | 探测精度(位置误差 m) | +| `MinElevation` | double? | 最小仰角 | +| `MaxElevation` | double? | 最大仰角 | +| `MinDetectAlt` | double? | 最小可探测高度 m | +| `MaxDetectAlt` | double? | 最大可探测高度 m | +| `ModelId` | string | 3D 模型 ID | + +### 10.4 AmmunitionSpec(弹药模板) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `Id` | string | 主键 | +| `AerosolType` | int | 0=惰性气体 1=活性材料 2=活性燃料 | +| `Name` | string | 名称 | +| `InitialRadius` | double | 初始半径 m | +| `InitialVolume` | double | 初始体积 m³ | +| `CoreDensity` | double | 核心密度 | +| `EdgeDensity` | double | 边缘密度 | +| `InitialTemperature` | double | 初始温度 | +| `BuoyancyFactor` | double | 浮力系数 | +| `EffectiveConcentration` | double | 有效浓度 | +| `SourceStrength` | double | 源强 Q (kg) | +| `DispersionRateBase` | double | 基础扩散速率 m/s | +| `BurstChargeKg` | double | 爆发药 TNT 当量 kg | +| `TurbulentExpansionK` | double | 湍流扩散系数 | +| `Phase2Duration` | double | Phase 2 持续时间 s | +| `MaxRadius` | double | 最大半径 m | +| `MaxDuration` | double | 最大持续时间 s | +| `ParticlesJson` | string | 粒子渲染参数 JSON | + +### 10.5 EnvironmentSpec / FormationTemplate / RouteTemplate / ModelInfo + +| 模型 | 关键字段 | +|------|---------| +| **EnvironmentSpec** | Id, Name, WeatherType, WindSpeed, WindDirection, Temperature, Humidity, Pressure, Visibility | +| **FormationTemplate** | Id, Name, FormationMode, LateralCount, LongitudinalCount, LateralSpacing, LongitudinalSpacing, LateralAxis, LongitudinalAxis | +| **RouteTemplate** | Id, Name, WaypointsJson(`[{"x":0,"y":0,"z":0},...]`) | +| **ModelInfo** | Id, Name, EntityType(0=Drone/1=Platform/2=DetectionEquip), Description, FilePath, FileSize, PreviewPath, HasHighResTexture, CreatedAt | + +--- + +## 十一、默认数据 首次运行自动种子,含 6 个 `[Demo]` 预设想定: | 类别 | 示例 ID | -|------|------| +|------|---------| | 弹药 | `inert` / `active` / `fuel` | | 编队 | `single` / `line-3` / `swarm-10` | | 航线 | `5km-h500` / `10km-h500` / `20km-h500` | @@ -344,19 +848,10 @@ ModelInfo --- -## 九、更新后端 - -```bash -cd src/CounterDrone.Core -dotnet publish -c Release -o ../../unity_plugins -``` - ---- - -## 十、参考 +## 十二、参考 | 文档 | 位置 | |------|------| | 架构设计 | `docs/design/architecture/总体架构设计.md` | | 实体事件映射 | `docs/design/technical/仿真器实体与事件映射.md` | -| 测试状态 | **238 测试,10 秒(全部通过)** | +| 测试状态 | **262 测试,15 秒(全部通过)** | diff --git a/scripts/check_unity_build.ps1 b/scripts/check_unity_build.ps1 index 83f830d..3d947d5 100644 --- a/scripts/check_unity_build.ps1 +++ b/scripts/check_unity_build.ps1 @@ -14,9 +14,27 @@ Copy-Item "$projectRoot\unity_plugins\*.dll" "$unityProject\Assets\Plugins\Count Remove-Item "$unityProject\Library\ScriptAssemblies" -Recurse -Force -ErrorAction SilentlyContinue Write-Host "Compiling Unity scripts..." -# 3. 编译(CompileErrorDetector 捕获错误并 Exit(-1)) -& $unityEditor -batchmode -projectPath $unityProject -logFile - 2>&1 | Out-Null -if ($LASTEXITCODE -ne 0) { Write-Host "UNITY BUILD FAILED (exit code $LASTEXITCODE)"; exit 1 } +# 3. 编译(-quit 确保编译完自动退出;超时 300s 兜底) +Write-Host "Compiling Unity scripts (timeout 300s)..." +$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" exit 0 diff --git a/src/CounterDrone.Core/CounterDrone.Core.csproj b/src/CounterDrone.Core/CounterDrone.Core.csproj index adde67d..110140b 100644 --- a/src/CounterDrone.Core/CounterDrone.Core.csproj +++ b/src/CounterDrone.Core/CounterDrone.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/CounterDrone.Core/IPathProvider.cs b/src/CounterDrone.Core/IPathProvider.cs index 3186e4f..5184dbb 100644 --- a/src/CounterDrone.Core/IPathProvider.cs +++ b/src/CounterDrone.Core/IPathProvider.cs @@ -7,5 +7,6 @@ namespace CounterDrone.Core string GetMainDbPath(); string GetFramesDir(); string GetModelsDir(); + string GetFontPath(); } } diff --git a/src/CounterDrone.Core/Models/SimulationReport.cs b/src/CounterDrone.Core/Models/SimulationReport.cs index 656fcf0..c0f3cf6 100644 --- a/src/CounterDrone.Core/Models/SimulationReport.cs +++ b/src/CounterDrone.Core/Models/SimulationReport.cs @@ -25,7 +25,10 @@ namespace CounterDrone.Core.Models public int InterceptResult { get; set; } - /// 报告富文本 / 结构化 JSON + /// Markdown 文本(用于快速展示) public string Content { get; set; } = string.Empty; + + /// 结构化报告数据 JSON(用于 PDF 等格式重新渲染) + public string? ReportDataJson { get; set; } } } diff --git a/src/CounterDrone.Core/Reporting/FontResolver.cs b/src/CounterDrone.Core/Reporting/FontResolver.cs new file mode 100644 index 0000000..871f88e --- /dev/null +++ b/src/CounterDrone.Core/Reporting/FontResolver.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using PdfSharpCore.Fonts; + +namespace CounterDrone.Core.Reporting +{ + /// CJK 字体解析器 — 从文件路径加载 TrueType 字体供 PdfSharpCore 使用 + public sealed class CjkFontResolver : IFontResolver + { + private readonly byte[] _fontData; + private readonly string _familyName; + + /// 注册 CJK 字体解析器到全局设置(进程内只能注册一次) + /// CJK TrueType/OpenType 字体文件路径 + /// 字体族名(用于 XFont 构造) + 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; + } +} diff --git a/src/CounterDrone.Core/Reporting/IReportTemplate.cs b/src/CounterDrone.Core/Reporting/IReportTemplate.cs new file mode 100644 index 0000000..121dcfc --- /dev/null +++ b/src/CounterDrone.Core/Reporting/IReportTemplate.cs @@ -0,0 +1,8 @@ +namespace CounterDrone.Core.Reporting +{ + /// 报告模板接口 — 从 ReportData 渲染为目标格式 + public interface IReportTemplate + { + byte[] Render(ReportData data); + } +} diff --git a/src/CounterDrone.Core/Reporting/MarkdownRenderer.cs b/src/CounterDrone.Core/Reporting/MarkdownRenderer.cs new file mode 100644 index 0000000..0810721 --- /dev/null +++ b/src/CounterDrone.Core/Reporting/MarkdownRenderer.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Text; + +namespace CounterDrone.Core.Reporting +{ + /// 从 ReportData 渲染 Markdown 文本 + 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 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 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 RepeatEach(string[] source, System.Func fn) + { + foreach (var s in source) + yield return fn(s); + } + } +} diff --git a/src/CounterDrone.Core/Reporting/ReportBlockConverter.cs b/src/CounterDrone.Core/Reporting/ReportBlockConverter.cs new file mode 100644 index 0000000..602f923 --- /dev/null +++ b/src/CounterDrone.Core/Reporting/ReportBlockConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CounterDrone.Core.Reporting +{ + /// ReportBlock 多态序列化转换器 + public class ReportBlockConverter : JsonConverter + { + 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(rawText, options)!, + "TableBlock" => JsonSerializer.Deserialize(rawText, options)!, + "TextBlock" => JsonSerializer.Deserialize(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(); + } + } +} diff --git a/src/CounterDrone.Core/Reporting/ReportData.cs b/src/CounterDrone.Core/Reporting/ReportData.cs new file mode 100644 index 0000000..82a585d --- /dev/null +++ b/src/CounterDrone.Core/Reporting/ReportData.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace CounterDrone.Core.Reporting +{ + /// 结构化报告数据 — 模板渲染的统一数据源 + public class ReportData + { + public string Title { get; set; } = ""; + public List Header { get; set; } = new(); + public List 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 Blocks { get; set; } = new(); + } + + public abstract class ReportBlock { } + + /// 键值对表(两列:参数 / 值) + public class KeyValueBlock : ReportBlock + { + public string LabelHeader { get; set; } = "参数"; + public string ValueHeader { get; set; } = "值"; + public List Items { get; set; } = new(); + } + + /// 通用表格(可带子标题) + public class TableBlock : ReportBlock + { + public string Caption { get; set; } = ""; + public string[] Headers { get; set; } = Array.Empty(); + public List Rows { get; set; } = new(); + } + + /// 文本段落(支持加粗、多行列表) + public class TextBlock : ReportBlock + { + public string Text { get; set; } = ""; + public bool Bold { get; set; } + } +} diff --git a/src/CounterDrone.Core/Reporting/StandardPdfTemplate.cs b/src/CounterDrone.Core/Reporting/StandardPdfTemplate.cs new file mode 100644 index 0000000..e0593dc --- /dev/null +++ b/src/CounterDrone.Core/Reporting/StandardPdfTemplate.cs @@ -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 +{ + /// 标准 PDF 模板 — A4 排版,中文字体,表格边框,自动分页 + 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 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 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[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 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 WrapText(string text, double maxWidth, XFont font) + { + var result = new List(); + 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); + } + } +} diff --git a/src/CounterDrone.Core/Services/IReportService.cs b/src/CounterDrone.Core/Services/IReportService.cs index ca6da5d..fee3596 100644 --- a/src/CounterDrone.Core/Services/IReportService.cs +++ b/src/CounterDrone.Core/Services/IReportService.cs @@ -12,7 +12,11 @@ namespace CounterDrone.Core.Services SimulationReport GetReport(string id); void DeleteReport(string id); PagedResult SearchReports(string keyword, string? dateFrom, string? dateTo, int page, int pageSize); - /// 导出报告为 Markdown 文件,返回文件路径 - string ExportToFile(string reportId, string outputDir); + + /// 导出报告为字节数组。format: "pdf" | "md" + byte[] ExportReport(string id, string format); + + /// 导出报告为文件,返回文件路径。format: "pdf" | "md" + string ExportToFile(string reportId, string outputDir, string format); } } diff --git a/src/CounterDrone.Core/Services/ReportGenerator.cs b/src/CounterDrone.Core/Services/ReportGenerator.cs index 98daf2a..8e62c24 100644 --- a/src/CounterDrone.Core/Services/ReportGenerator.cs +++ b/src/CounterDrone.Core/Services/ReportGenerator.cs @@ -1,22 +1,21 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using CounterDrone.Core; using CounterDrone.Core.Models; +using CounterDrone.Core.Reporting; using CounterDrone.Core.Simulation; namespace CounterDrone.Core.Services { public class ReportGenerator { - public string Generate(ScenarioConfig config, List events, + public ReportData Generate(ScenarioConfig config, List events, Simulation.DroneStatus finalDroneStatus, float simulationDuration, Dictionary droneSpecs, Dictionary fireUnitSpecs, Dictionary sensorSpecs) { - var sb = new StringBuilder(); var platforms = config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform).ToList(); var target = config.Drones.FirstOrDefault(); var scene = config.Scene; @@ -26,87 +25,114 @@ namespace CounterDrone.Core.Services var totalDrones = config.Drones.Sum(t => t.Quantity); var result = DetermineInterceptResult(destroyed, reached + intruded, totalDrones); - sb.AppendLine("# 仿真评估报告"); - sb.AppendLine(); - sb.AppendLine($"| 项目 | 值 |"); - sb.AppendLine($"|------|-----|"); - sb.AppendLine($"| 任务名称 | {config.Info.Name} |"); - sb.AppendLine($"| 任务编号 | {config.Info.ScenarioNumber} |"); - sb.AppendLine($"| 仿真耗时 | {simulationDuration:F1} 秒 |"); - sb.AppendLine($"| 气溶胶类型 | {TranslateAerosol((AerosolType)config.Cloud.AerosolType)} |"); - sb.AppendLine(); + var data = new ReportData + { + Title = "仿真评估报告", + Header = new List + { + new() { Label = "任务名称", Value = config.Info.Name }, + new() { Label = "任务编号", Value = config.Info.ScenarioNumber }, + new() { Label = "仿真耗时", Value = $"{simulationDuration:F1} 秒" }, + new() { Label = "气溶胶类型", Value = TranslateAerosol((AerosolType)config.Cloud.AerosolType) }, + }, + }; // === 一、仿真结果 === - sb.AppendLine("## 一、仿真结果"); - sb.AppendLine(); - sb.AppendLine($"| 统计项 | 值 |"); - sb.AppendLine($"|--------|-----|"); - sb.AppendLine($"| 无人机总数 | {totalDrones} |"); - sb.AppendLine($"| 被摧毁 | {destroyed} |"); - sb.AppendLine($"| 到达目标 | {reached} |"); - sb.AppendLine($"| 侵入管控区 | {intruded} |"); - sb.AppendLine($"| 弹药发射 | {events.Count(e => e.Type == SimEventType.MunitionLaunched)} |"); - sb.AppendLine($"| 目标发现 | {events.Count(e => e.Type == SimEventType.TargetDetected)} |"); - sb.AppendLine($"| 云团生成 | {events.Count(e => e.Type == SimEventType.CloudGenerated)} |"); - sb.AppendLine(); - sb.AppendLine($"**对抗结果**:{TranslateResult(result)}"); - sb.AppendLine(); + data.Sections.Add(new ReportSection + { + Title = "一、仿真结果", + Blocks = new List + { + new KeyValueBlock + { + LabelHeader = "统计项", + Items = new List + { + new() { Label = "无人机总数", Value = totalDrones.ToString() }, + new() { Label = "被摧毁", Value = destroyed.ToString() }, + new() { Label = "到达目标", Value = reached.ToString() }, + 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("## 二、作战环境"); - sb.AppendLine(); - sb.AppendLine($"| 参数 | 值 |"); - sb.AppendLine($"|------|-----|"); - sb.AppendLine($"| 场景类型 | {TranslateScene((SceneType)scene.SceneType)} |"); - sb.AppendLine($"| 时间段 | {scene.TimeOfDay} |"); - sb.AppendLine($"| 天气 | {TranslateWeather((WeatherType)scene.WeatherType)} |"); - sb.AppendLine($"| 风速 | {scene.WindSpeed:F1} m/s |"); - sb.AppendLine($"| 风向 | {TranslateWindDirection((WindDirection)scene.WindDirection)} |"); - sb.AppendLine($"| 温度 | {scene.Temperature:F1}°C |"); - sb.AppendLine($"| 湿度 | {scene.Humidity:F0}%RH |"); - sb.AppendLine($"| 气压 | {scene.Pressure:F0} hPa |"); - sb.AppendLine($"| 能见度 | {scene.Visibility:F0} m |"); - sb.AppendLine($"| 场景范围 | {scene.SceneWidth:F0} × {scene.SceneLength:F0} × {scene.SceneHeight:F0} m (W×L×H) |"); - sb.AppendLine(); + data.Sections.Add(new ReportSection + { + Title = "二、作战环境", + Blocks = new List + { + new KeyValueBlock + { + Items = new List + { + new() { Label = "场景类型", Value = TranslateScene((SceneType)scene.SceneType) }, + new() { Label = "时间段", Value = scene.TimeOfDay }, + new() { Label = "天气", Value = TranslateWeather((WeatherType)scene.WeatherType) }, + new() { Label = "风速", Value = $"{scene.WindSpeed:F1} m/s" }, + new() { Label = "风向", Value = TranslateWindDirection((WindDirection)scene.WindDirection) }, + 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); - sb.AppendLine($"| 类型 | {TranslateTarget((DroneType)(droneSpec?.DroneType ?? 0))} |"); - sb.AppendLine($"| 动力 | {TranslatePower((PowerType)(droneSpec?.PowerType ?? 0))} |"); - sb.AppendLine($"| 数量 | {totalDrones} 架 |"); - sb.AppendLine($"| 翼展 | {droneSpec?.Wingspan ?? 0:F1} m |"); - sb.AppendLine($"| 速度 | {droneSpec?.TypicalSpeed ?? 0:F0} km/h |"); - sb.AppendLine($"| 高度 | {droneSpec?.TypicalAltitude ?? 0:F0} m |"); - sb.AppendLine(); + data.Sections.Add(new ReportSection + { + Title = "三、威胁目标", + Blocks = new List + { + new KeyValueBlock + { + Items = new List + { + 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("## 四、云团抛撒配置"); - sb.AppendLine(); - sb.AppendLine($"| 参数 | 值 |"); - sb.AppendLine($"|------|-----|"); - sb.AppendLine($"| 气溶胶类型 | {TranslateAerosol((AerosolType)config.Cloud.AerosolType)} |"); - sb.AppendLine($"| 抛撒高度 | {config.Cloud.DisperseHeight:F0} m |"); - sb.AppendLine($"| 触发模式 | {TranslateTrigger((TriggerMode)config.Cloud.TriggerMode)} |"); - sb.AppendLine($"| 释放方式 | {TranslateRelease((ReleaseMode)config.Cloud.ReleaseMode)} |"); - sb.AppendLine($"| 扩散方向 | {config.Cloud.SpreadAngle:F0}° |"); - sb.AppendLine($"| 初始规模 | {config.Cloud.InitialScale:F0} m³ |"); - sb.AppendLine($"| 持续时间 | {config.Cloud.Duration:F0} s |"); - sb.AppendLine($"| 释放总量 | {config.Cloud.TotalAmount:F1} kg |"); - sb.AppendLine($"| 配置来源 | {config.Cloud.Source} |"); + var cloudItems = new List + { + new() { Label = "气溶胶类型", Value = TranslateAerosol((AerosolType)config.Cloud.AerosolType) }, + new() { Label = "抛撒高度", Value = $"{config.Cloud.DisperseHeight:F0} m" }, + new() { Label = "触发模式", Value = TranslateTrigger((TriggerMode)config.Cloud.TriggerMode) }, + new() { Label = "释放方式", Value = TranslateRelease((ReleaseMode)config.Cloud.ReleaseMode) }, + new() { Label = "扩散方向", Value = $"{config.Cloud.SpreadAngle:F0}°" }, + new() { Label = "初始规模", Value = $"{config.Cloud.InitialScale:F0} m³" }, + new() { Label = "持续时间", Value = $"{config.Cloud.Duration:F0} s" }, + new() { Label = "释放总量", Value = $"{config.Cloud.TotalAmount:F1} kg" }, + new() { Label = "配置来源", Value = config.Cloud.Source }, + }; if (config.Cloud.Source == "Algorithm" && config.Cloud.EstimatedProbability.HasValue) { - sb.AppendLine($"| **算法推荐** | |"); - sb.AppendLine($"| 推荐弹药数 | {config.Cloud.SalvoRounds} 发 |"); - sb.AppendLine($"| 云团间距 | {config.Cloud.SalvoSpacing:F0} m |"); - sb.AppendLine($"| 拦截概率估计 | {config.Cloud.EstimatedProbability.Value:P0} |"); - sb.AppendLine($"| 抛撒时机 | {config.Cloud.RecommendedTiming:F0}s |"); + cloudItems.Add(new() { Label = "推荐弹药数", Value = $"{config.Cloud.SalvoRounds} 发" }); + cloudItems.Add(new() { Label = "云团间距", Value = $"{config.Cloud.SalvoSpacing:F0} m" }); + cloudItems.Add(new() { Label = "拦截概率估计", Value = $"{config.Cloud.EstimatedProbability.Value:P0}" }); + cloudItems.Add(new() { Label = "抛撒时机", Value = $"{config.Cloud.RecommendedTiming:F0}s" }); } - sb.AppendLine(); + data.Sections.Add(new ReportSection + { + Title = "四、云团抛撒配置", + Blocks = new List { new KeyValueBlock { Items = cloudItems } }, + }); // 构建 PlatformId → 火力单元 映射(去重,五和六共用) var platToEquip = new Dictionary(); @@ -132,12 +158,10 @@ namespace CounterDrone.Core.Services } // === 五、我方部署 === - sb.AppendLine("## 五、我方部署"); - sb.AppendLine(); - sb.AppendLine($"### 发射平台"); - sb.AppendLine(); - sb.AppendLine($"| # | 平台类型 | 位置 (X,Y,Z) | 弹药类型 | 挂载量 | 发射数 |"); - sb.AppendLine($"|---|---------|-------------|---------|:------:|:------:|"); + var deployBlocks = new List(); + + // 发射平台表 + var platRows = new List(); int unitNum2 = 0; 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); string ptype = ((PlatformType?)(fuSpec?.PlatformType ?? 0)) == PlatformType.AirBased ? "空基" : "地基"; 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(); if (detections.Count > 0) { - sb.AppendLine($"### 探测设备"); - sb.AppendLine(); - sb.AppendLine($"| # | 位置 (X,Y,Z) | 雷达 | 光电 | 红外 | 精度 | 数量 |"); - sb.AppendLine($"|---|-------------|:----:|:----:|:----:|:----:|:----:|"); + var detRows = new List(); int detIdx = 0; foreach (var d in detections) { sensorSpecs.TryGetValue(d.SensorSpecId, out var sensor); 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(); - } - // 管控区域 - if (config.ControlZones.Count > 0) - { - 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(); + deployBlocks.Add(new TableBlock + { + Caption = "探测设备", + Headers = new[] { "#", "位置 (X,Y,Z)", "雷达", "光电", "红外", "精度", "数量" }, + Rows = detRows, + }); } + // 管控区域表 + if (config.ControlZones.Count > 0) + { + var zoneRows = new List(); + 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("## 六、事件时序"); - sb.AppendLine(); - sb.AppendLine("| 时间(s) | 事件 | 详情 | 火力单元 |"); - sb.AppendLine("|---------|------|------|:------:|"); + var eventRows = new List(); int munitionIdx = 0, cloudIdx = 0; foreach (var e in events.OrderBy(e => e.OccurredAt)) { string unitCell = e.Type == SimEventType.MunitionLaunched && platToEquip.TryGetValue(e.SourceId, out var u) ? $"#{u}" : "-"; - // 编号前缀 + 引擎生成的 Description(含坐标等调试信息) string prefix = e.Type switch { SimEventType.MunitionLaunched => $"第 {++munitionIdx} 发", @@ -201,9 +234,20 @@ namespace CounterDrone.Core.Services _ => string.Empty, }; 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 + { + new TableBlock + { + Headers = new[] { "时间(s)", "事件", "详情", "火力单元" }, + Rows = eventRows, + }, + }, + }); // === 七、时间线 === 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 cloudFirst = clouds.DefaultIfEmpty(0).FirstOrDefault(); float destroyFirst = destroys.DefaultIfEmpty(0).FirstOrDefault(); - sb.AppendLine("## 七、时间线"); - sb.AppendLine(); - sb.AppendLine($"| 阶段 | 时间 | 耗时 |"); - sb.AppendLine($"|------|------|------|"); + + var timelineRows = new List(); if (launchFirst > 0) { - sb.AppendLine($"| 首发发射 | {launchFirst:F1}s | - |"); + timelineRows.Add(new[] { "首发发射", $"{launchFirst:F1}s", "-" }); if (launches.Count > 1) - sb.AppendLine($"| 末发发射 | {launchLast:F1}s | {launchLast - launchFirst:F1}s(弹群展开) |"); - sb.AppendLine($"| 发射 → 云团生成 | {launchFirst:F1}s → {cloudFirst:F1}s | {cloudFirst - launchFirst:F1}s(弹药飞行) |"); + timelineRows.Add(new[] { "末发发射", $"{launchLast:F1}s", $"{launchLast - launchFirst:F1}s(弹群展开)" }); + timelineRows.Add(new[] { "发射 → 云团生成", $"{launchFirst:F1}s → {cloudFirst:F1}s", $"{cloudFirst - launchFirst:F1}s(弹药飞行)" }); } if (destroyFirst > 0) - sb.AppendLine($"| 云团生成 → 首个击毁 | {cloudFirst:F1}s → {destroyFirst:F1}s | {destroyFirst - cloudFirst:F1}s(暴露毁伤) |"); - sb.AppendLine($"| 总耗时 | 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(); + timelineRows.Add(new[] { "云团生成 → 首个击毁", $"{cloudFirst:F1}s → {destroyFirst:F1}s", $"{destroyFirst - cloudFirst:F1}s(暴露毁伤)" }); + timelineRows.Add(new[] { "总耗时", $"0 → {simulationDuration:F1}s", $"{simulationDuration:F1}s" }); - return sb.ToString(); + var timelineBlocks = new List + { + new TableBlock + { + Headers = new[] { "阶段", "时间", "耗时" }, + Rows = timelineRows, + }, + new TextBlock { Text = "统计汇总:", Bold = true }, + }; + + var summaryLines = new List + { + $"- 弹药发射 {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) @@ -248,9 +303,9 @@ namespace CounterDrone.Core.Services private static string TranslateResult(InterceptResult r) => r switch { - InterceptResult.Success => "✅ 成功拦截", - InterceptResult.Partial => "⚠️ 部分拦截", - InterceptResult.Failed => "❌ 拦截失败", + InterceptResult.Success => "成功拦截", + InterceptResult.Partial => "部分拦截", + InterceptResult.Failed => "拦截失败", _ => "未知", }; @@ -311,13 +366,13 @@ namespace CounterDrone.Core.Services private static string TranslateEvent(SimEventType t) => t switch { - SimEventType.MunitionLaunched => "🚀 弹药发射", - SimEventType.CloudGenerated => "☁️ 云团生成", - SimEventType.DroneDestroyed => "💥 目标击毁", - SimEventType.DroneReachedTarget => "🏁 抵达目标", - SimEventType.ZoneIntruded => "🚨 侵入管控区", - SimEventType.SimulationEnd => "⏹️ 仿真结束", - SimEventType.TargetDetected => "👁️ 目标发现", + SimEventType.MunitionLaunched => "弹药发射", + SimEventType.CloudGenerated => "云团生成", + SimEventType.DroneDestroyed => "目标击毁", + SimEventType.DroneReachedTarget => "抵达目标", + SimEventType.ZoneIntruded => "侵入管控区", + SimEventType.SimulationEnd => "仿真结束", + SimEventType.TargetDetected => "目标发现", _ => t.ToString(), }; } diff --git a/src/CounterDrone.Core/Services/ReportService.cs b/src/CounterDrone.Core/Services/ReportService.cs index b6f3a52..cdb15f0 100644 --- a/src/CounterDrone.Core/Services/ReportService.cs +++ b/src/CounterDrone.Core/Services/ReportService.cs @@ -2,7 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using CounterDrone.Core.Models; +using CounterDrone.Core.Reporting; using SQLite; using CounterDrone.Core.Simulation; @@ -13,6 +16,14 @@ namespace CounterDrone.Core.Services private readonly SQLiteConnection _db; private readonly ReportGenerator _generator = new(); 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) { @@ -33,6 +44,9 @@ namespace CounterDrone.Core.Services var total = config.Drones.Sum(t => t.Quantity); 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 { ScenarioId = scenarioId, @@ -42,11 +56,16 @@ namespace CounterDrone.Core.Services DroneCount = total, UnitCount = config.Units.Count, InterceptResult = (int)result, - Content = _generator.Generate(config, events, Simulation.DroneStatus.Flying, duration, - droneSpecs, fireUnitSpecs, sensorSpecs), + Content = _mdRenderer.Render(reportData), + ReportDataJson = JsonSerializer.Serialize(reportData, JsonOpts), }; _db.Insert(report); + + // 仿真后自动导出 Markdown 到报告目录 + var reportsDir = Path.Combine(_paths.GetDataRoot(), "reports"); + ExportToFile(report.Id, reportsDir, "md"); + 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(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(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(reportId); if (report == null) throw new ArgumentException($"报告 {reportId} 不存在"); Directory.CreateDirectory(outputDir); 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); - File.WriteAllText(filePath, report.Content); + File.WriteAllBytes(filePath, ExportReport(reportId, format)); return filePath; } } diff --git a/src/CounterDrone.Core/TestPathProvider.cs b/src/CounterDrone.Core/TestPathProvider.cs index 0673729..0b4edb6 100644 --- a/src/CounterDrone.Core/TestPathProvider.cs +++ b/src/CounterDrone.Core/TestPathProvider.cs @@ -28,6 +28,19 @@ namespace CounterDrone.Core if (File.Exists(src)) 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() @@ -63,5 +76,12 @@ namespace CounterDrone.Core Directory.CreateDirectory(dir); return dir; } + + public string GetFontPath() + { + var dir = Path.Combine(_root, "fonts"); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "CJK-Font.ttf"); + } } } diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll index a2f74fc..46c96d2 100644 Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll new file mode 100644 index 0000000..8a74343 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll.meta new file mode 100644 index 0000000..27e8f47 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/ICSharpCode.SharpZipLib.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll new file mode 100644 index 0000000..03c3849 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll.meta new file mode 100644 index 0000000..6e76d73 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/PdfSharpCore.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll new file mode 100644 index 0000000..11eb258 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll.meta new file mode 100644 index 0000000..deb9294 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.Fonts.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll new file mode 100644 index 0000000..c5d5f12 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll.meta new file mode 100644 index 0000000..a5ff24d --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/SixLabors.ImageSharp.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll new file mode 100644 index 0000000..1a1c925 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll.meta new file mode 100644 index 0000000..8d544e3 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Collections.Concurrent.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll new file mode 100644 index 0000000..050c54d Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll.meta new file mode 100644 index 0000000..2fa6c45 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.FileSystem.Primitives.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll new file mode 100644 index 0000000..374701c Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll.meta new file mode 100644 index 0000000..1e4f314 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/System.IO.UnmanagedMemoryStream.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Numerics.Vectors.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Numerics.Vectors.dll index a808165..1020577 100644 Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Numerics.Vectors.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Numerics.Vectors.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll new file mode 100644 index 0000000..a6f8e1f Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll.meta new file mode 100644 index 0000000..715a3aa --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.Tasks.Parallel.dll.meta @@ -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: diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll new file mode 100644 index 0000000..7868cf0 Binary files /dev/null and b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll differ diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll.meta b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll.meta new file mode 100644 index 0000000..07d5725 --- /dev/null +++ b/src/Unity/Assets/Plugins/CounterDrone.Core/System.Threading.dll.meta @@ -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: diff --git a/src/Unity/Assets/Scripts/Managers/ReportManager.cs b/src/Unity/Assets/Scripts/Managers/ReportManager.cs index d543fd5..fde4a93 100644 --- a/src/Unity/Assets/Scripts/Managers/ReportManager.cs +++ b/src/Unity/Assets/Scripts/Managers/ReportManager.cs @@ -33,16 +33,17 @@ namespace CounterDrone.Unity public PagedResult Search(string kw, string from, string to, int page, int size) => _service.SearchReports(kw, from, to, page, size); - public string Export(string reportId, string outputDir) - { - var dir = outputDir ?? System.IO.Path.Combine(_paths.GetDataRoot(), "reports"); - return _service.ExportToFile(reportId, dir); - } + /// 导出报告为字节数组。format: "pdf" | "md" + public byte[] ExportReport(string reportId, string format) + => _service.ExportReport(reportId, format); + /// 导出报告到指定目录,返回文件路径。format: "pdf" | "md" + public string Export(string reportId, string outputDir, string format) + => _service.ExportToFile(reportId, outputDir, format); + + /// 导出 Markdown 报告到默认报告目录,返回文件路径 public string Export(string reportId) - { - return Export(reportId, System.IO.Path.Combine(_paths.GetDataRoot(), "reports")); - } + => Export(reportId, System.IO.Path.Combine(_paths.GetDataRoot(), "reports"), "md"); [ContextMenu("Verify")] void Verify() diff --git a/src/Unity/Assets/Scripts/Managers/UnityPathProvider.cs b/src/Unity/Assets/Scripts/Managers/UnityPathProvider.cs index c774f63..66d0d87 100644 --- a/src/Unity/Assets/Scripts/Managers/UnityPathProvider.cs +++ b/src/Unity/Assets/Scripts/Managers/UnityPathProvider.cs @@ -34,6 +34,19 @@ namespace CounterDrone.Unity var src = Path.Combine(sourceDir, file); 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"); @@ -51,5 +64,12 @@ namespace CounterDrone.Unity Directory.CreateDirectory(dir); return dir; } + + public string GetFontPath() + { + var dir = Path.Combine(GetDataRoot(), "fonts"); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "CJK-Font.ttf"); + } } } diff --git a/src/Unity/Assets/StreamingAssets/fonts.meta b/src/Unity/Assets/StreamingAssets/fonts.meta new file mode 100644 index 0000000..a2c4ced --- /dev/null +++ b/src/Unity/Assets/StreamingAssets/fonts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 756fcb6926ada6744b1dfa95cc84cc1f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf b/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf new file mode 100644 index 0000000..1f1bdf8 Binary files /dev/null and b/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf differ diff --git a/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf.meta b/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf.meta new file mode 100644 index 0000000..17fef39 --- /dev/null +++ b/src/Unity/Assets/StreamingAssets/fonts/CJK-Font.ttf.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 699c5f98bb34f914db674c61c60abc93 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index d14509b..d20f0b2 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -5,6 +5,7 @@ using System.Linq; using CounterDrone.Core; using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; +using CounterDrone.Core.Reporting; using CounterDrone.Core.Repository; using CounterDrone.Core.Services; using CounterDrone.Core.Simulation; @@ -67,13 +68,11 @@ namespace CounterDrone.Core.Tests private void VerifyAndExportReport(SimulationEngine eng, DroneEntity drone) { 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 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) @@ -153,7 +152,8 @@ namespace CounterDrone.Core.Tests e => Assert.Contains("空基", e.Description)); var drone = eng.Drones[0]; 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}"); VerifyAndExportReport(eng, drone); } diff --git a/test/unit/CounterDrone.Core.Tests/MarkdownRendererTests.cs b/test/unit/CounterDrone.Core.Tests/MarkdownRendererTests.cs new file mode 100644 index 0000000..5c66cdd --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/MarkdownRendererTests.cs @@ -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 + { + new() { Label = "任务名称", Value = "测试任务" }, + new() { Label = "仿真耗时", Value = "10.0 秒" }, + }, + Sections = new List + { + new() + { + Title = "一、仿真结果", + Blocks = new List + { + new KeyValueBlock + { + LabelHeader = "统计项", + Items = new List + { + 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 + { + new() + { + Title = "五、我方部署", + Blocks = new List + { + new TableBlock + { + Caption = "发射平台", + Headers = new[] { "#", "类型", "位置" }, + Rows = new List + { + 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 + { + new() + { + Title = "七、时间线", + Blocks = new List + { + new TextBlock { Text = "- 第一行\n- 第二行" }, + }, + }, + }, + }; + + var md = new MarkdownRenderer().Render(data); + + Assert.Contains("- 第一行", md); + Assert.Contains("- 第二行", md); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/ReportGeneratorTests.cs b/test/unit/CounterDrone.Core.Tests/ReportGeneratorTests.cs index e0ff4d1..340ba10 100644 --- a/test/unit/CounterDrone.Core.Tests/ReportGeneratorTests.cs +++ b/test/unit/CounterDrone.Core.Tests/ReportGeneratorTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CounterDrone.Core.Models; +using CounterDrone.Core.Reporting; using CounterDrone.Core.Services; using CounterDrone.Core.Simulation; using Xunit; @@ -51,11 +52,12 @@ namespace CounterDrone.Core.Tests 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); } } diff --git a/test/unit/CounterDrone.Core.Tests/ReportServiceTests.cs b/test/unit/CounterDrone.Core.Tests/ReportServiceTests.cs index a1fe13f..d69b425 100644 --- a/test/unit/CounterDrone.Core.Tests/ReportServiceTests.cs +++ b/test/unit/CounterDrone.Core.Tests/ReportServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; using CounterDrone.Core; using CounterDrone.Core.Models; using CounterDrone.Core.Services; @@ -31,23 +32,27 @@ namespace CounterDrone.Core.Tests if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true); } - [Fact] - public void Generate_And_Get_ReturnsReport() + private ScenarioConfig CreateTestConfig() { - var config = new ScenarioConfig + return new ScenarioConfig { Info = new Scenario { Name = "Test", ScenarioNumber = "SIM-001" }, Drones = new List { TestData.CreateScenarioDrone("w1", 1) }, Units = new List(), Cloud = new CloudDispersal(), }; + } - var report = _service.Generate("scenario1", config, new List(), "Destroyed", 45f); + [Fact] + public void Generate_And_Get_ReturnsReport() + { + var report = _service.Generate("scenario1", CreateTestConfig(), new List(), "Destroyed", 45f); Assert.NotNull(report); Assert.NotEmpty(report.Id); Assert.Equal("Test", report.ScenarioName); Assert.Contains("仿真评估报告", report.Content); + Assert.NotNull(report.ReportDataJson); var fetched = _service.GetReport(report.Id); Assert.NotNull(fetched); @@ -56,15 +61,7 @@ namespace CounterDrone.Core.Tests [Fact] public void Delete_RemovesReport() { - var config = new ScenarioConfig - { - Info = new Scenario { Name = "Del", ScenarioNumber = "SIM-DEL" }, - Drones = new List { TestData.CreateScenarioDrone("w1", 1) }, - Units = new List(), - Cloud = new CloudDispersal(), - }; - - var report = _service.Generate("scenario1", config, new List(), "Destroyed", 10f); + var report = _service.Generate("scenario1", CreateTestConfig(), new List(), "Destroyed", 10f); _service.DeleteReport(report.Id); Assert.Null(_service.GetReport(report.Id)); } @@ -72,14 +69,8 @@ namespace CounterDrone.Core.Tests [Fact] public void SearchReports_Keyword() { - var cfg = new ScenarioConfig - { - Info = new Scenario { Name = "城市防御", ScenarioNumber = "SIM-A" }, - Drones = new List { TestData.CreateScenarioDrone("w1", 1) }, - Units = new List(), - Cloud = new CloudDispersal(), - }; - + var cfg = CreateTestConfig(); + cfg.Info.Name = "城市防御"; _service.Generate("t1", cfg, new List(), "Destroyed", 10f); cfg.Info.Name = "平原拦截"; cfg.Info.ScenarioNumber = "SIM-B"; @@ -88,5 +79,79 @@ namespace CounterDrone.Core.Tests var result = _service.SearchReports("城市", null, null, 1, 10); Assert.Equal(1, result.TotalCount); } + + [Fact] + public void ExportReport_Md_ReturnsMarkdownBytes() + { + var report = _service.Generate("scenario1", CreateTestConfig(), new List(), "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(), "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(() => _service.ExportReport(report.Id, "pdf")); + } + + [Fact] + public void ExportReport_UnsupportedFormat_Throws() + { + var report = _service.Generate("scenario1", CreateTestConfig(), new List(), "Destroyed", 45f); + Assert.Throws(() => _service.ExportReport(report.Id, "docx")); + } + + [Fact] + public void ExportToFile_Pdf_CreatesPdfFile() + { + var report = _service.Generate("scenario1", CreateTestConfig(), new List(), "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(), "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(), "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 目录"); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/StandardPdfTemplateTests.cs b/test/unit/CounterDrone.Core.Tests/StandardPdfTemplateTests.cs new file mode 100644 index 0000000..6e40781 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/StandardPdfTemplateTests.cs @@ -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 + { + new() { Label = "任务名称", Value = "城市防御测试" }, + new() { Label = "任务编号", Value = "SIM-001" }, + new() { Label = "仿真耗时", Value = "45.0 秒" }, + }, + Sections = new List + { + new() + { + Title = "一、仿真结果", + Blocks = new List + { + new KeyValueBlock + { + LabelHeader = "统计项", + Items = new List + { + new() { Label = "无人机总数", Value = "3" }, + new() { Label = "被摧毁", Value = "3" }, + new() { Label = "弹药发射", Value = "6" }, + }, + }, + new TextBlock { Text = "对抗结果:✅ 成功拦截", Bold = true }, + }, + }, + new() + { + Title = "六、事件时序", + Blocks = new List + { + new TableBlock + { + Headers = new[] { "时间(s)", "事件", "详情", "火力单元" }, + Rows = new List + { + 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(); + 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 + { + 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"); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/TestData.cs b/test/unit/CounterDrone.Core.Tests/TestData.cs index dfff2e6..b054513 100644 --- a/test/unit/CounterDrone.Core.Tests/TestData.cs +++ b/test/unit/CounterDrone.Core.Tests/TestData.cs @@ -90,6 +90,7 @@ namespace CounterDrone.Core.Tests public string GetMainDbPath() => throw new NotImplementedException(); public string GetFramesDir() => throw new NotImplementedException(); public string GetModelsDir() => throw new NotImplementedException(); + public string GetFontPath() => throw new NotImplementedException(); } public static void Cleanup() diff --git a/unity_plugins/CounterDrone.Core.dll b/unity_plugins/CounterDrone.Core.dll index a2f74fc..46c96d2 100644 Binary files a/unity_plugins/CounterDrone.Core.dll and b/unity_plugins/CounterDrone.Core.dll differ