Compare commits

...

20 Commits

Author SHA1 Message Date
Scottxjw
5189c665c3 docs(requirement): 需求文档添加 2026-08-12 15:50:58 +08:00
ssss
66402d7e0b fix(chart): 修正Ploss图表字段索引映射 2026-07-06 15:23:52 +08:00
ssss
7addc9f551 docs(ploss): 完善字段注释与表格列头命名 2026-07-06 14:27:06 +08:00
ssss
b422358c3a feat(chart): 实现Ploss和Qfod图表真实数据加载 2026-07-06 13:37:48 +08:00
ssss
cf4f24fde3 feat(wpf): 添加Ploss和Qfod数据表格视图 2026-07-06 13:37:34 +08:00
ssss
cee3536507 feat(service): 添加按场景ID查询Ploss和Qfod记录方法 2026-07-06 13:37:03 +08:00
ssss
eaac759a3f feat(qfod): 添加FieldF字段支持第6个数字解析 2026-07-06 13:36:52 +08:00
ssss
52435ab8cd feat(wpf): 实现场景列表加载、筛选与消息导航功能
- ScenarioService新增筛选查询方法和动态筛选选项
- 使用WeakReferenceMessenger实现导入完成通知和导航消息
- ViewModel改为Singleton生命周期确保消息订阅正确工作
- 场景列表支持数据库加载和按维度筛选
- ScenarioId类型从int改为Guid

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:23:41 +08:00
ssss
440088648b refactor(PlossParser): 优化正则匹配逻辑避免重复匹配 2026-07-06 09:09:33 +08:00
ssss
d4ea6b454f bugfix: 修复已知bug 2026-07-03 18:06:01 +08:00
ssss
4bff6e1e87 chore(wpf): 为ImportViewModel添加操作日志记录 2026-07-03 14:29:18 +08:00
ssss
f858c38981 fix(log): 修正CleanService日志级别为Information 2026-07-03 14:28:55 +08:00
ssss
f771abb6bf perf(stats): 使用数据库端聚合优化统计查询 2026-07-03 14:28:35 +08:00
ssss
d3e4b2b8de fix(security): 添加路径验证和CSV转义防止安全漏洞 2026-07-03 14:28:22 +08:00
ssss
fa0b93446f fix(concurrency): 修复维度和场景创建的竞态条件 2026-07-03 14:27:59 +08:00
ssss
c9be8f4ed2 fix(wpf): 添加配置文件缺失时的默认配置支持 2026-07-03 14:27:42 +08:00
ssss
266317d948 fix(config): 修复CLI配置源传递断裂问题 2026-07-03 14:27:26 +08:00
ssss
0f7c0d9060 fix(parser): 修正文件名分隔符解析逻辑 2026-07-03 13:17:38 +08:00
ssss
b2442b9c6e fix(db): 添加SQLite数据库自动创建逻辑 2026-07-03 13:17:31 +08:00
ssss
76b29e39a2 refactor: 将解决方案文件从slnx格式迁移到sln格式 2026-07-03 13:17:15 +08:00
45 changed files with 2741 additions and 425 deletions

37
WCTDataMiner.sln Normal file
View File

@@ -0,0 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WCTDataMiner.Core", "src\WCTDataMiner.Core\WCTDataMiner.Core.csproj", "{A1B2C3D4-1234-5678-9ABC-DEF012345601}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WCTDataMiner.Wpf", "src\WCTDataMiner.Wpf\WCTDataMiner.Wpf.csproj", "{A1B2C3D4-1234-5678-9ABC-DEF012345602}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WCTDataMiner", "src\WCTDataMiner\WCTDataMiner.csproj", "{A1B2C3D4-1234-5678-9ABC-DEF012345603}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345602}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345602}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345602}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345602}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A1B2C3D4-1234-5678-9ABC-DEF012345600}
EndGlobalSection
EndGlobal

View File

@@ -1,7 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/WCTDataMiner.Core/WCTDataMiner.Core.csproj" />
<Project Path="src/WCTDataMiner.Wpf/WCTDataMiner.Wpf.csproj" />
<Project Path="src/WCTDataMiner/WCTDataMiner.csproj" />
</Folder>
</Solution>

View File

@@ -5,65 +5,95 @@
---
## 1. 日志格式
**格式:** `FOD-> (1) X X X (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)`
**示例:** `FOD-> 4 2 1 127 10690 12013 35377 9904 1213 -2882 750 0 0 1 500 1`
> 括号数字表示有效字段编号X 表示忽略该位置数据
```
pow_loss = 3053, delta_p = 1950
FOD-> (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13) (14) (15) (16)
```
---
## 2. 字段定义
### 2.1 第一行字段
| 编号 | 字段名称 | 数据类型 | 说明 |
|------|----------|----------|------|
| (1) | RxType | byte | 接收端类型标识 |
| (2) | RxPower | short | 接收端功率mW |
| (3) | TxPower | short | 发射端功率mW |
| (4) | Vcoil | short | 发射线圈电压 |
| (5) | Vin | short | 输入电压 |
| (6) | Isns | short | 输入电流采样值 |
| (7) | Ploss | int | **核心值**:计算损耗(负数表示安全) |
| (8) | Threshold | int | **安全红线**:当前功率段阈值 |
| (9) | TriggerCount | short | 触发计数器 |
| (10) | FodResult | short | FOD判定结果状态 |
| (11) | ProtocolType | byte | 充电协议类型 |
| (12) | PwmDuty | short | PWM控制占空比 |
| (13) | CoilIndex | byte | 充电线圈索引 |
| (A) | PowLoss | int | 功率损耗值 |
| (B) | DeltaP | int | 功率差值 |
### 2.2 第二行字段 (FOD->)
| 编号 | 字段名称 | 数据类型 | 说明 |
|------|----------|----------|------|
| (1) | Field1 | int | 待定义 |
| (2) | Field2 | int | 待定义 |
| (3) | Field3 | int | 待定义 |
| (4) | Field4 | int | 待定义 |
| (5) | Field5 | int | 待定义 |
| (6) | Field6 | int | 待定义 |
| (7) | Field7 | int | 待定义 |
| (8) | Field8 | int | 待定义 |
| (9) | Field9 | int | 待定义 |
| (10) | Field10 | int | 待定义 |
| (11) | Field11 | int | 待定义 |
| (12) | Field12 | int | 待定义 |
| (13) | Field13 | int | 待定义 |
| (14) | Field14 | int | 待定义 |
| (15) | Field15 | int | 待定义 |
| (16) | Field16 | int | 待定义 |
---
## 3. 正则表达式
> **修正说明**原设计稿正则只有2个忽略字段实际格式有3个X忽略字段已修正。
日志由两行组成,需要分别匹配:
```csharp
// Parsers/PlossParser.cs
// 格式: FOD-> (1) X X X (2) (3) ... (13)其中3个X需要3个 \d+ 忽略
private static readonly Regex PlossPattern = new(
@"^FOD->\s+(\d+)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
// 第一行格式: pow_loss = 3053, delta_p = 1950
private static readonly Regex HeaderPattern = new(
@"^pow_loss\s*=\s*(\d+),\s*delta_p\s*=\s*(\d+)$",
RegexOptions.Compiled
);
// 第二行格式: FOD-> 后跟16个数字均支持负数
private static readonly Regex FodPattern = new(
@"^FOD->\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$",
RegexOptions.Compiled
);
```
> **说明**所有16个字段均支持负数使用 `-?\d+` 匹配。
**捕获组映射:**
**第一行 (HeaderPattern)**
| 捕获组 | 字段 | 类型转换 |
|--------|------|----------|
| Group 1 | RxType | byte.Parse() |
| Group 2 | RxPower | short.Parse() |
| Group 3 | TxPower | short.Parse() |
| Group 4 | Vcoil | short.Parse() |
| Group 5 | Vin | short.Parse() |
| Group 6 | Isns | short.Parse() |
| Group 7 | Ploss | int.Parse() |
| Group 8 | Threshold | int.Parse() |
| Group 9 | TriggerCount | short.Parse() |
| Group 10 | FodResult | short.Parse() |
| Group 11 | ProtocolType | byte.Parse() |
| Group 12 | PwmDuty | short.Parse() |
| Group 13 | CoilIndex | byte.Parse() |
| Group 1 | PowLoss | int.Parse() |
| Group 2 | DeltaP | int.Parse() |
**第二行 (FodPattern)**
| 捕获组 | 字段 | 类型转换 |
|--------|------|----------|
| Group 1 | Field1 | int.Parse() |
| Group 2 | Field2 | int.Parse() |
| Group 3 | Field3 | int.Parse() |
| Group 4 | Field4 | int.Parse() |
| Group 5 | Field5 | int.Parse() |
| Group 6 | Field6 | int.Parse() |
| Group 7 | Field7 | int.Parse() |
| Group 8 | Field8 | int.Parse() |
| Group 9 | Field9 | int.Parse() |
| Group 10 | Field10 | int.Parse() |
| Group 11 | Field11 | int.Parse() |
| Group 12 | Field12 | int.Parse() |
| Group 13 | Field13 | int.Parse() |
| Group 14 | Field14 | int.Parse() |
| Group 15 | Field15 | int.Parse() |
| Group 16 | Field16 | int.Parse() |
---
@@ -78,57 +108,102 @@ namespace WCTDataMiner.Parsers;
public class PlossParser : IParser<PlossRecord>
{
private static readonly Regex PlossPattern = new(
@"^FOD->\s+(\d+)\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
private static readonly Regex HeaderPattern = new(
@"^pow_loss\s*=\s*(\d+),\s*delta_p\s*=\s*(\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => PlossPattern.IsMatch(line);
private static readonly Regex FodPattern = new(
@"^FOD->\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => HeaderPattern.IsMatch(line) || FodPattern.IsMatch(line);
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
// 尝试匹配第一行
var headerMatch = HeaderPattern.Match(line);
if (headerMatch.Success)
{
return ParseResult<PlossRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Ploss pattern"
);
return ParseHeaderLine(headerMatch, scenarioId);
}
// 尝试匹配第二行
var fodMatch = FodPattern.Match(line);
if (fodMatch.Success)
{
return ParseFodLine(fodMatch, scenarioId);
}
return ParseResult<PlossRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Ploss pattern"
);
}
private ParseResult<PlossRecord> ParseHeaderLine(Match match, Guid scenarioId)
{
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
RxType = byte.Parse(match.Groups[1].Value),
RxPower = short.Parse(match.Groups[2].Value),
TxPower = short.Parse(match.Groups[3].Value),
Vcoil = short.Parse(match.Groups[4].Value),
Vin = short.Parse(match.Groups[5].Value),
Isns = short.Parse(match.Groups[6].Value),
Ploss = int.Parse(match.Groups[7].Value),
Threshold = int.Parse(match.Groups[8].Value),
TriggerCount = short.Parse(match.Groups[9].Value),
FodResult = short.Parse(match.Groups[10].Value),
ProtocolType = byte.Parse(match.Groups[11].Value),
PwmDuty = short.Parse(match.Groups[12].Value),
CoilIndex = byte.Parse(match.Groups[13].Value)
PowLoss = int.Parse(match.Groups[1].Value),
DeltaP = int.Parse(match.Groups[2].Value),
IsHeaderLine = true
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
$"Failed to convert header field: {ex.Message}"
);
}
}
private ParseResult<PlossRecord> ParseFodLine(Match match, Guid scenarioId)
{
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
Field1 = int.Parse(match.Groups[1].Value),
Field2 = int.Parse(match.Groups[2].Value),
Field3 = int.Parse(match.Groups[3].Value),
Field4 = int.Parse(match.Groups[4].Value),
Field5 = int.Parse(match.Groups[5].Value),
Field6 = int.Parse(match.Groups[6].Value),
Field7 = int.Parse(match.Groups[7].Value),
Field8 = int.Parse(match.Groups[8].Value),
Field9 = int.Parse(match.Groups[9].Value),
Field10 = int.Parse(match.Groups[10].Value),
Field11 = int.Parse(match.Groups[11].Value),
Field12 = int.Parse(match.Groups[12].Value),
Field13 = int.Parse(match.Groups[13].Value),
Field14 = int.Parse(match.Groups[14].Value),
Field15 = int.Parse(match.Groups[15].Value),
Field16 = int.Parse(match.Groups[16].Value),
IsHeaderLine = false
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert FOD field: {ex.Message}"
);
}
}
}
```
> **注意**:实际实现中,两行日志需要合并为一条完整记录。可以使用 `PlossRecordMerger` 或在业务逻辑层处理合并。
---
## 5. 核心判定逻辑

View File

@@ -8,7 +8,7 @@
**表名**`qfod_record`
**描述**:存储 Qfod 格式解析数据,对应日志格式 `X#:Q A->B C D E`
**描述**:存储 Qfod 格式解析数据,对应日志格式 `X#:Q A->B C D E F`
---
@@ -24,6 +24,7 @@
| current_q | FLOAT | NOT NULL | 当前实时 Q值 |
| raw_q | FLOAT | NOT NULL | 原始 Q值 |
| fod_type | TINYINT | NOT NULL | 异物类型编码 |
| field_f | TINYINT | NOT NULL, DEFAULT 0 | 第6个数字预留字段当前取值0业务含义待确认 |
| is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | 软删除标记 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
@@ -77,6 +78,7 @@ public class QfodRecord
public float CurrentQ { get; set; }
public float RawQ { get; set; }
public byte FodType { get; set; }
public byte FieldF { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -132,6 +134,11 @@ public class QfodRecordConfiguration : IEntityTypeConfiguration<QfodRecord>
.IsRequired()
.HasColumnName("fod_type");
builder.Property(e => e.FieldF)
.IsRequired()
.HasDefaultValue((byte)0)
.HasColumnName("field_f");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)

View File

@@ -0,0 +1,206 @@
# 1 数据获取
## 1.1 手动抓数据
### 1.1.1 Qfod
#### 1.1.1.1 日志 Format
`X#:Q A->B C D E`
#### 1.1.1.2 数据结构
| 字段标识 | 字段名称 | 数据类型 | 取值范围 | 说明 |
| ----- | ---------------- | ----------- | -------- | ---------------------------- |
| X | Charger Index | `uint8` | 0, 1 | 充电器索引,标识使用哪个充电器 |
| Q | Q Factor Flag | `uint8` | - | Q 值标记/标志位 |
| A | Coil Index | `uint8` | 0, 1, 2 | 线圈索引,标识检测的线圈编号 |
| B | ΔQ (Delta Q) | `int16` | - | Q 值变化量/差值,反映 Q 值的相对变化 |
| C | Current Q Factor | `float` | - | 当前实时 Q 值 |
| D | Raw Q Factor | `float` | - | 原始 Q 值(未经处理的测量值) |
| ==E== | ==FOD Type== | ==`uint8`== | ==见表 2== | ==异物类型编码,对应 Table 2 中的异物分类== |
#### 1.1.1.3 字段关系说明
- `X + A` 组合唯一标识一个检测通道(充电器 + 线圈)
- `B = C - D`(变化量 = 当前值 - 原始值)
### 1.1.2 Ploss fod
基于功率损耗的异物检测Power Loss FOD
#### 1.1.2.1 日志 Format
`FOD-> (1) X X X (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)`
示例:
`FOD-> 4 2 1 127 10690 12013 35377 9904 1213 -2882 750 0 0 1 500 1`
> **说明**括号数字表示有效字段编号X 表示忽略该位置数据
前置参数(库内计算):
- `pow_loss` = 2005发射端基础损耗值
- `delta_p` = 2200功率损耗补偿系数**调试关键变量**
#### 1.1.2.2 数据结构
| 编号 | 字段名称 | 数据类型 | 示例值 | 说明 |
| ---- | ---------------------- | -------- | ----- | ---------------------------------------- |
| (1) | Rx Type | `uint8` | 4 | 接收端类型标识 |
| (2) | Rx Power | `uint16` | 10690 | 接收端实际获得功率,能量去向 |
| (3) | Tx Power | `uint16` | 12013 | 发射端输出总功率,能量来源 |
| (4) | Vcoil | `uint16` | 35377 | 发射线圈电压,用于计算理论损耗 |
| (5) | Vin | `uint16` | 9904 | 输入电压,系统状态监控 |
| (6) | Isns | `uint16` | 1213 | 输入电流采样值,系统状态监控 |
| (7) | Ploss | `int32` | -2882 | **核心判定值**计算损耗Tx-(Rx+base_loss+delta_p) |
| (8) | Threshold | `int32` | 750 | **安全红线**,允许的最大损耗限值 |
| (9) | Trigger Count | `uint16` | 0 | 触发计数器,持续超阈值会累积 |
| (10) | FOD Result | `uint16` | 0 | 当前判定结果状态 |
| (11) | Charging Protocol Type | `uint8` | 1 | 充电协议类型 |
| (12) | PWM Duty | `uint8` | 500 | PWM 控制占空比 |
| (13) | Charging coil index | `uint8` | 1 | 充电线圈索引 |
#### 1.1.2.3 核心判定逻辑
**能量守恒定律:**
```
发射功率 = 接收功率 + 系统损耗 + 异物损耗
```
**公式一:发射功率计算**
```
(3) = (5) × (6)
```
即:`Tx Power = Vin × Isns`(通过芯片实际工作的电压和电流相乘得到动态值)
**公式二:额外损耗计算**
```
(7) = (3) - ((2) + pow_loss + delta_p)
```
即:`Ploss = Tx Power - (Rx Power + pow_loss + delta_p)`
> 含义:用"发射端总功率"减去"接收端功率"、"已知系统损耗"和"补偿系数",剩下的数值 (7) 就是无法解释的能量损耗。如果这个值很大,说明有金属异物在发热消耗能量。
#### 1.1.2.4 变量定义详解
| 变量 | 名称 | 说明 |
| --- | ---- | ---- |
| (7) | Calculated Loss | 实时计算出的功率差值,**需要重点监控的变量** |
| (8) | Threshold | Power loss 阈值Qi 协议规定(参考 Table 4**随功率段动态变化**(功率越大,允许的损耗越大) |
| (2) | Rx Power | 接收端(手机/手表)上报给发射端的功率值 |
| pow_loss | Tx 固有损耗 | 发射板的固有损耗,库内计算得出,无需修改 |
| delta_p | 功率补偿系数 | **工程师唯一需要调整的"旋钮"** |
**判定条件:**
- 当 (7) Ploss > (8) Threshold 时,计数器 (9) 增加
- 当计数超过设定阈值,触发 FOD 报警并停止充电
**调试目标:**
- 正常充电时,需满足:(7) Ploss < (8) Threshold
- 如正常充电时误报 FOD需增大 `delta_p` 补偿系数
- 修改代码数组 `m_con_arr_I16PlossCoeff` 中的 `delta_p` 值进行校准
# 2 数据分析方法
## 2.1 QFOD 数据分析
### 2.1.1 阈值计算
1. 从 Log 提取两种场景的 ΔQ
- 场景 A纯手机ΔQ = 25最大值
- 场景 B手机+异物ΔQ = 40 (最小值)
1. 计算阈值Threshold = (25 + 40) / 2 = 32
### 2.1.2 分析步骤
1. 提取日志中 `#Q` 开头的行
2. 绘制 ΔQ 时序曲线,叠加 Y=Threshold 水平线
3. 验证余量:纯手机最高点 vs 阈值、有异物最低点 vs 阈值
## 2.2 Ploss FOD 数据分析
### 2.2.1 日志解析要点
**日志行识别:**`FOD->` 开头的行
**关键字段提取(按位置索引):**
| 位置 | 字段 | 示例值 | 用途 |
| ---- | ---- | ------ | ---- |
| 第 6 列 | Calculated Loss (Ploss) | -634 | 核心监控值,负数表示安全 |
| 第 7 列 | Threshold | 1000 | 当前功率段的阈值 |
| 第 2 列 | Rx Power | 10690 | 用于查表确定阈值 |
| 第 3 列 | Tx Power | 12013 | 用于验证计算 |
### 2.2.2 动态阈值表 (Table 4)
阈值根据接收功率动态查表:
| 接收功率 (Rx Power) | 阈值 (Threshold) | 备注 |
| ----------------- | -------------- | ---- |
| 0 ~ 5W | 350 mW | 低功率下最敏感 |
| 5 ~ 10W | 500 mW | |
| 10 ~ 15W | 750 mW | |
| 15 ~ 30W | 1000 mW | 示例 Log 处于此区间 |
| 30 ~ 50W | 1250 mW | 高功率允许更多损耗 |
> **注意**:数据分析时必须先读取 Rx Power查表获得正确阈值再与计算值对比。
### 2.2.3 计算公式验证
**能量守恒公式:**
```
Ploss = Tx_Power - Rx_Power - System_Loss - delta_p
```
**等价表达:**
```
delta_p + Ploss = Tx_Power - Rx_Power - System_Loss
```
**验证示例:**
- Tx Power = 12013
- Rx Power = 10690
- System Loss (pow_loss) = 2005
- delta_p = 2200
- 计算12013 - 10690 - 2005 - 2200 = -2882 ✓
### 2.2.4 判定逻辑
**FOD 触发条件:**
```
if (Ploss > Threshold) → 计数器累积
if (Count > Limit) → 触发 FOD 报警,停止充电
```
**安全余量监控:**
```
Margin = Threshold - Ploss
```
示例:`1000 - (-634) = 1634 mW` 余量充足
### 2.2.5 分析流程
1. **解析 (Parse)**:读取 Log 文件,找到 `FOD->` 开头的行
2. **提取 (Extract)**
- 抓取第 6 列(计算损耗,如 -634
- 抓取第 7 列(当前阈值,如 1000
- 抓取 Rx Power 用于查表校验
3. **计算 (Calculate)**
- 验证公式:`Loss = Tx_Power - Rx_Power - System_Loss - delta_p`
- 计算余量:`Margin = Threshold - Loss`
4. **可视化 (Visualize)**
- 画横线代表阈值 (Threshold)
- 画波动线代表实时损耗 (Ploss)
- 若波动线突破阈值线,标记为 **FOD Event**
5. **调试 (Tune)**
- 若 Ploss 频繁跳动或变正数(如 800说明 delta_p 设置不准
- 调整 delta_p 使曲线回归安全区(负值区)
# 3 QA
## 3.1 导入之后会有脏数据污染吗? 人工删除?
## 3.2 FOD Type 具体的是什么,对数据分析有影响吗?和文件名定义的有什么关系
## 3.3 Ploss fod 日志格式和 实际日志中的对应不上
## 3.4 数据是孤立的

View File

@@ -17,59 +17,84 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
.IsRequired()
.HasColumnName("scenario_id");
builder.Property(e => e.RxType)
.IsRequired()
.HasColumnName("rx_type");
// ===== 第一行字段nullable =====
builder.Property(e => e.PowLoss)
.IsRequired(false)
.HasColumnName("pow_loss");
builder.Property(e => e.RxPower)
.IsRequired()
.HasColumnName("rx_power");
builder.Property(e => e.DeltaP)
.IsRequired(false)
.HasColumnName("delta_p");
builder.Property(e => e.TxPower)
// ===== 第二行字段(通用命名) =====
builder.Property(e => e.Field1)
.IsRequired()
.HasColumnName("tx_power");
.HasColumnName("field_1");
builder.Property(e => e.Vcoil)
builder.Property(e => e.Field2)
.IsRequired()
.HasColumnName("vcoil");
.HasColumnName("field_2");
builder.Property(e => e.Vin)
builder.Property(e => e.Field3)
.IsRequired()
.HasColumnName("vin");
.HasColumnName("field_3");
builder.Property(e => e.Isns)
builder.Property(e => e.Field4)
.IsRequired()
.HasColumnName("isns");
.HasColumnName("field_4");
builder.Property(e => e.Ploss)
builder.Property(e => e.Field5)
.IsRequired()
.HasColumnName("ploss");
.HasColumnName("field_5");
builder.Property(e => e.Threshold)
builder.Property(e => e.Field6)
.IsRequired()
.HasColumnName("threshold");
.HasColumnName("field_6");
builder.Property(e => e.TriggerCount)
builder.Property(e => e.Field7)
.IsRequired()
.HasColumnName("field_7");
builder.Property(e => e.Field8)
.IsRequired()
.HasColumnName("field_8");
builder.Property(e => e.Field9)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("trigger_count");
.HasColumnName("field_9");
builder.Property(e => e.FodResult)
builder.Property(e => e.Field10)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("fod_result");
.HasColumnName("field_10");
builder.Property(e => e.ProtocolType)
builder.Property(e => e.Field11)
.IsRequired()
.HasColumnName("protocol_type");
.HasColumnName("field_11");
builder.Property(e => e.PwmDuty)
builder.Property(e => e.Field12)
.IsRequired()
.HasColumnName("pwm_duty");
.HasColumnName("field_12");
builder.Property(e => e.CoilIndex)
builder.Property(e => e.Field13)
.IsRequired()
.HasColumnName("coil_index");
.HasColumnName("field_13");
builder.Property(e => e.Field14)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_14");
builder.Property(e => e.Field15)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_15");
builder.Property(e => e.Field16)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_16");
builder.Property(e => e.IsDeleted)
.IsRequired()
@@ -86,10 +111,12 @@ public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
builder.HasIndex(e => e.RxPower).HasDatabaseName("idx_ploss_rx_power");
builder.HasIndex(e => e.Ploss).HasDatabaseName("idx_ploss_ploss");
builder.HasIndex(e => e.Threshold).HasDatabaseName("idx_ploss_threshold");
builder.HasIndex(e => e.FodResult).HasDatabaseName("idx_ploss_fod_result");
builder.HasIndex(e => e.Field2).HasDatabaseName("idx_ploss_field_2"); // 原 rx_power
builder.HasIndex(e => e.Field7).HasDatabaseName("idx_ploss_field_7"); // 原 ploss
builder.HasIndex(e => e.Field8).HasDatabaseName("idx_ploss_field_8"); // 原 threshold
builder.HasIndex(e => e.Field10).HasDatabaseName("idx_ploss_field_10"); // 原 fod_result
builder.HasIndex(e => e.PowLoss).HasDatabaseName("idx_ploss_pow_loss"); // 新增
builder.HasIndex(e => e.DeltaP).HasDatabaseName("idx_ploss_delta_p"); // 新增
// 关系配置
builder.HasOne(e => e.Scenario)

View File

@@ -45,6 +45,11 @@ public class QfodRecordConfiguration : IEntityTypeConfiguration<QfodRecord>
.IsRequired()
.HasColumnName("fod_type");
builder.Property(e => e.FieldF)
.IsRequired()
.HasDefaultValue((byte)0)
.HasColumnName("field_f");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)

View File

@@ -41,7 +41,12 @@ public class DbContextFactory : IDbContextFactory
optionsBuilder.EnableDetailedErrors();
#endif
return new WctMinerDbContext(optionsBuilder.Options);
var context = new WctMinerDbContext(optionsBuilder.Options);
// 确保数据库和表结构已创建(适用于 SQLite
context.Database.EnsureCreated();
return context;
}
private string BuildPostgreSqlConnectionString()

View File

@@ -2,35 +2,66 @@ namespace WCTDataMiner.Core.Models;
/// <summary>
/// Ploss记录实体 - 存储Ploss FOD格式解析数据
/// 支持两行格式:
/// 第一行: pow_loss = X, delta_p = Y
/// 第二行: FOD-> (16个字段)
/// </summary>
public class PlossRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ScenarioId { get; set; }
public byte RxType { get; set; }
public int RxPower { get; set; } // uint16 范围 0-65535使用 int 存储
public int TxPower { get; set; } // uint16 范围 0-65535使用 int 存储
public int Vcoil { get; set; } // uint16 范围 0-65535使用 int 存储
public int Vin { get; set; } // uint16 范围 0-65535使用 int 存储
public int Isns { get; set; } // uint16 范围 0-65535使用 int 存储
/// <summary>核心判定值:负数表示安全</summary>
public int Ploss { get; set; }
// ===== 第一行字段nullable兼容旧数据 =====
/// <summary>功率损耗值(来自第一行)</summary>
public int? PowLoss { get; set; }
/// <summary>安全红线:当前功率段阈值</summary>
public int Threshold { get; set; }
/// <summary>功率差值(来自第一行)</summary>
public int? DeltaP { get; set; }
// ===== 第二行字段16个通用字段均支持负数 =====
/// <summary>Field1: RX_TYPE - 接收端类型</summary>
public int Field1 { get; set; }
public int Field2 { get; set; }
public int Field3 { get; set; }
/// <summary>Field4: RX power - 接收端功率</summary>
public int Field4 { get; set; }
/// <summary>Field5: TX power - 发射端功率</summary>
public int Field5 { get; set; }
public int Field6 { get; set; }
/// <summary>核心判定值Field7 (原Ploss) - 负数表示安全</summary>
public int Field7 { get; set; }
/// <summary>安全红线Field8 (原Threshold) - 当前功率段阈值</summary>
public int Field8 { get; set; }
/// <summary>Field9: ploss - 功率损耗计算值</summary>
public int Field9 { get; set; }
/// <summary>Field10: threshold - 阈值</summary>
public int Field10 { get; set; }
/// <summary>Field11: 触发次数 - 当 Field7 > Field8损耗大于阈值时计数超过5次后触发FOD保护</summary>
public int Field11 { get; set; }
/// <summary>Field12: PFOD result 标志 - 触发时置位</summary>
public int Field12 { get; set; }
public int Field13 { get; set; }
public int Field14 { get; set; }
public int Field15 { get; set; }
public int Field16 { get; set; }
public int TriggerCount { get; set; } = 0;
public int FodResult { get; set; } = 0;
public byte ProtocolType { get; set; }
public int PwmDuty { get; set; } // uint16 范围 0-65535使用 int 存储
public byte CoilIndex { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
public TestScenario Scenario { get; set; } = null!;
/// <summary>派生属性:安全余量(不落库)</summary>
public int Margin => Threshold - Ploss;
/// <summary>派生属性:安全余量(不落库) = Field8 - Field7</summary>
public int Margin => Field8 - Field7;
}

View File

@@ -13,6 +13,7 @@ public class QfodRecord
public float CurrentQ { get; set; }
public float RawQ { get; set; }
public byte FodType { get; set; }
public byte FieldF { get; set; }
public bool IsDeleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

View File

@@ -5,11 +5,12 @@ namespace WCTDataMiner.Core.Parsers;
/// <summary>
/// 文件名解析器 - 从文件名提取场景信息
/// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log
/// 分隔符: 使用 '-' 作为字段分隔符,字段内部可包含 '_' 或 '.' 等符号
/// 示例: singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log
/// </summary>
public class FileNameParser
{
private static readonly char[] Separators = { '-', '_' };
private static readonly char[] Separators = { '-' };
/// <summary>
/// 解析日志文件名,提取场景信息

View File

@@ -11,11 +11,21 @@ public interface IParser<TRecord>
bool CanParse(string line);
/// <summary>
/// 解析日志行
/// 解析日志行(单行模式)
/// </summary>
/// <param name="line">日志行内容</param>
/// <param name="scenarioId">关联的测试场景ID</param>
ParseResult<TRecord> Parse(string line, Guid scenarioId);
/// <summary>
/// 判断是否为多行记录的起始行
/// </summary>
bool IsMultiLineStart(string line) => false;
/// <summary>
/// 解析多行记录(子类可重写)
/// </summary>
ParseResult<TRecord>? ParseMultiLine(string[] lines, Guid scenarioId) => null;
}
/// <summary>

View File

@@ -4,49 +4,121 @@ using WCTDataMiner.Core.Models;
namespace WCTDataMiner.Core.Parsers;
/// <summary>
/// Ploss FOD日志解析器
/// 格式: FOD-> (1) X X X (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)
/// 示例: FOD-> 4 2 1 127 10690 12013 35377 9904 1213 -2882 750 0 0 1 500 1
/// Ploss FOD日志解析器 - 支持两行格式
/// 第一行: pow_loss = 3053, delta_p = 1950
/// 第二行: FOD-> (1) (2) ... (16)
/// 同时兼容旧的单行格式
/// </summary>
public class PlossParser : IParser<PlossRecord>
{
// 格式说明有3个X忽略字段正则需要3个 \d+ 来匹配
private static readonly Regex PlossPattern = new(
@"^FOD->\s+(\d+)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
// 第一行正则pow_loss = 3053, delta_p = 1950
private static readonly Regex HeaderPattern = new(
@"^pow_loss\s*=\s*(\d+),\s*delta_p\s*=\s*(\d+)$",
RegexOptions.Compiled
);
public bool CanParse(string line) => PlossPattern.IsMatch(line);
// 第二行正则FOD-> 后跟16个数字均支持负数
private static readonly Regex FodPattern = new(
@"^FOD->\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$",
RegexOptions.Compiled
);
// 旧格式正则兼容前3个字段被忽略所有捕获组支持负数
private static readonly Regex LegacyPattern = new(
@"^FOD->\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$",
RegexOptions.Compiled
);
/// <summary>
/// 匹配类型枚举
/// </summary>
public enum MatchType { None, Header, Fod, Legacy }
/// <summary>
/// 单次匹配结果(避免重复正则匹配)
/// </summary>
public (MatchType Type, Match Match) TryMatch(string line)
{
var headerMatch = HeaderPattern.Match(line);
if (headerMatch.Success)
return (MatchType.Header, headerMatch);
var fodMatch = FodPattern.Match(line);
if (fodMatch.Success)
return (MatchType.Fod, fodMatch);
var legacyMatch = LegacyPattern.Match(line);
if (legacyMatch.Success)
return (MatchType.Legacy, legacyMatch);
return (MatchType.None, Match.Empty);
}
/// <summary>
/// 判断是否可以解析该行(任意一行)
/// </summary>
public bool CanParse(string line) => TryMatch(line).Type != MatchType.None;
/// <summary>
/// 判断是否为多行记录的起始行header行
/// </summary>
public bool IsMultiLineStart(string line) => TryMatch(line).Type == MatchType.Header;
/// <summary>
/// 单行解析(兼容旧格式)
/// </summary>
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
var (type, match) = TryMatch(line);
return type switch
{
return ParseResult<PlossRecord>.Failure(
"FORMAT_MISMATCH",
"Line does not match Ploss pattern"
);
}
MatchType.Legacy => ParseLegacyFormat(match, scenarioId),
MatchType.Fod => ParseFodLine(match, scenarioId),
MatchType.Header => ParseHeaderLine(match, scenarioId),
_ => ParseResult<PlossRecord>.Failure("FORMAT_MISMATCH", "Line does not match Ploss pattern")
};
}
/// <summary>
/// 多行解析(新两行格式)
/// </summary>
public ParseResult<PlossRecord>? ParseMultiLine(string[] lines, Guid scenarioId)
{
if (lines.Length < 2 || lines[0] == null || lines[1] == null)
return null;
var headerMatch = HeaderPattern.Match(lines[0].Trim());
var fodMatch = FodPattern.Match(lines[1].Trim());
if (!headerMatch.Success || !fodMatch.Success)
return null;
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
RxType = byte.Parse(match.Groups[1].Value),
RxPower = int.Parse(match.Groups[2].Value),
TxPower = int.Parse(match.Groups[3].Value),
Vcoil = int.Parse(match.Groups[4].Value),
Vin = int.Parse(match.Groups[5].Value),
Isns = int.Parse(match.Groups[6].Value),
Ploss = int.Parse(match.Groups[7].Value),
Threshold = int.Parse(match.Groups[8].Value),
TriggerCount = int.Parse(match.Groups[9].Value),
FodResult = int.Parse(match.Groups[10].Value),
ProtocolType = byte.Parse(match.Groups[11].Value),
PwmDuty = int.Parse(match.Groups[12].Value),
CoilIndex = byte.Parse(match.Groups[13].Value)
// 第一行字段
PowLoss = int.Parse(headerMatch.Groups[1].Value),
DeltaP = int.Parse(headerMatch.Groups[2].Value),
// 第二行字段
Field1 = int.Parse(fodMatch.Groups[1].Value),
Field2 = int.Parse(fodMatch.Groups[2].Value),
Field3 = int.Parse(fodMatch.Groups[3].Value),
Field4 = int.Parse(fodMatch.Groups[4].Value),
Field5 = int.Parse(fodMatch.Groups[5].Value),
Field6 = int.Parse(fodMatch.Groups[6].Value),
Field7 = int.Parse(fodMatch.Groups[7].Value),
Field8 = int.Parse(fodMatch.Groups[8].Value),
Field9 = int.Parse(fodMatch.Groups[9].Value), // 支持负数
Field10 = int.Parse(fodMatch.Groups[10].Value),
Field11 = int.Parse(fodMatch.Groups[11].Value),
Field12 = int.Parse(fodMatch.Groups[12].Value),
Field13 = int.Parse(fodMatch.Groups[13].Value),
Field14 = int.Parse(fodMatch.Groups[14].Value),
Field15 = int.Parse(fodMatch.Groups[15].Value),
Field16 = int.Parse(fodMatch.Groups[16].Value)
};
return ParseResult<PlossRecord>.Success(record);
@@ -59,4 +131,106 @@ public class PlossParser : IParser<PlossRecord>
);
}
}
private ParseResult<PlossRecord> ParseLegacyFormat(Match match, Guid scenarioId)
{
// 保持原有逻辑不变,映射到新字段名
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
// 旧格式没有第一行字段
PowLoss = null,
DeltaP = null,
// 映射到新字段名
Field1 = int.Parse(match.Groups[1].Value), // RxType
Field2 = int.Parse(match.Groups[2].Value), // RxPower
Field3 = int.Parse(match.Groups[3].Value), // TxPower
Field4 = int.Parse(match.Groups[4].Value), // Vcoil
Field5 = int.Parse(match.Groups[5].Value), // Vin
Field6 = int.Parse(match.Groups[6].Value), // Isns
Field7 = int.Parse(match.Groups[7].Value), // Ploss
Field8 = int.Parse(match.Groups[8].Value), // Threshold
Field9 = int.Parse(match.Groups[9].Value), // TriggerCount
Field10 = int.Parse(match.Groups[10].Value), // FodResult
Field11 = int.Parse(match.Groups[11].Value), // ProtocolType
Field12 = int.Parse(match.Groups[12].Value), // PwmDuty
Field13 = int.Parse(match.Groups[13].Value), // CoilIndex
// 旧格式没有Field14-16使用默认值
Field14 = 0,
Field15 = 0,
Field16 = 0
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
private ParseResult<PlossRecord> ParseHeaderLine(Match match, Guid scenarioId)
{
// 单独解析header行返回部分记录
// 实际使用时应通过ParseMultiLine合并
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
PowLoss = int.Parse(match.Groups[1].Value),
DeltaP = int.Parse(match.Groups[2].Value)
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert header field: {ex.Message}"
);
}
}
private ParseResult<PlossRecord> ParseFodLine(Match match, Guid scenarioId)
{
// 单独解析FOD行新格式无header
try
{
var record = new PlossRecord
{
ScenarioId = scenarioId,
PowLoss = null,
DeltaP = null,
Field1 = byte.Parse(match.Groups[1].Value),
Field2 = int.Parse(match.Groups[2].Value),
Field3 = int.Parse(match.Groups[3].Value),
Field4 = int.Parse(match.Groups[4].Value),
Field5 = int.Parse(match.Groups[5].Value),
Field6 = int.Parse(match.Groups[6].Value),
Field7 = int.Parse(match.Groups[7].Value),
Field8 = int.Parse(match.Groups[8].Value),
Field9 = int.Parse(match.Groups[9].Value),
Field10 = int.Parse(match.Groups[10].Value),
Field11 = int.Parse(match.Groups[11].Value),
Field12 = int.Parse(match.Groups[12].Value),
Field13 = int.Parse(match.Groups[13].Value),
Field14 = int.Parse(match.Groups[14].Value),
Field15 = int.Parse(match.Groups[15].Value),
Field16 = int.Parse(match.Groups[16].Value)
};
return ParseResult<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert FOD field: {ex.Message}"
);
}
}
}

View File

@@ -5,13 +5,14 @@ namespace WCTDataMiner.Core.Parsers;
/// <summary>
/// Qfod日志解析器
/// 格式: X#:Q A->B C D E
/// 示例: 0#:Q 1->25 45.5 20.5 2
/// 格式: X#:Q A->B C D E F
/// 字段映射: X=ChargerIndex, A=CoilIndex, B=DeltaQ, C=CurrentQ, D=RawQ, E=FodType, F=FieldF
/// 示例: 0#:Q 1->25 45.5 20.5 2 0
/// </summary>
public class QfodParser : IParser<QfodRecord>
{
private static readonly Regex QfodPattern = new(
@"^(\d)#:Q\s+(\d+)->(-?\d+)\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)$",
@"^(\d)#:Q\s+(\d+)->(-?\d+)\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\s+(\d+)$",
RegexOptions.Compiled
);
@@ -38,7 +39,8 @@ public class QfodParser : IParser<QfodRecord>
DeltaQ = int.Parse(match.Groups[3].Value),
CurrentQ = float.Parse(match.Groups[4].Value),
RawQ = float.Parse(match.Groups[5].Value),
FodType = byte.Parse(match.Groups[6].Value)
FodType = byte.Parse(match.Groups[6].Value),
FieldF = byte.Parse(match.Groups[7].Value)
};
return ParseResult<QfodRecord>.Success(record);

View File

@@ -0,0 +1,157 @@
using System.Text.RegularExpressions;
namespace WCTDataMiner.Core.Security;
/// <summary>
/// 路径验证结果
/// </summary>
public class PathValidationResult
{
public bool IsValid { get; }
public string? NormalizedPath { get; }
public string? ErrorMessage { get; }
private PathValidationResult(bool isValid, string? normalizedPath, string? errorMessage)
{
IsValid = isValid;
NormalizedPath = normalizedPath;
ErrorMessage = errorMessage;
}
public static PathValidationResult Success(string normalizedPath)
=> new(true, normalizedPath, null);
public static PathValidationResult Fail(string errorMessage)
=> new(false, null, errorMessage);
}
/// <summary>
/// 路径验证器 - 防止路径遍历攻击
/// </summary>
public class PathValidator
{
private readonly string _applicationBasePath;
private readonly HashSet<string> _allowedDirectories;
/// <summary>
/// 默认允许的输出目录
/// </summary>
private static readonly string[] DefaultAllowedDirectories = ["exports", "output", "data/exports"];
public PathValidator(string applicationBasePath, IEnumerable<string>? allowedDirectories = null)
{
_applicationBasePath = Path.GetFullPath(applicationBasePath);
_allowedDirectories = new HashSet<string>(
(allowedDirectories ?? DefaultAllowedDirectories).Select(d => d.Replace('/', Path.DirectorySeparatorChar)),
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 验证输出目录路径
/// </summary>
/// <param name="outputDir">用户输入的输出目录路径</param>
/// <returns>验证结果</returns>
public PathValidationResult ValidateOutputDirectory(string outputDir)
{
// 1. 基础验证
if (string.IsNullOrWhiteSpace(outputDir))
return PathValidationResult.Fail("输出目录不能为空");
// 2. 禁止的模式检查
var forbiddenPatterns = new[]
{
"..", // 路径遍历
"~", // 用户主目录
"\\\\", // UNC 路径
};
foreach (var pattern in forbiddenPatterns)
{
if (outputDir.Contains(pattern))
{
return PathValidationResult.Fail($"路径包含禁止的模式: {pattern}");
}
}
// 3. 检查是否为绝对路径Windows 和 Unix
if (Path.IsPathRooted(outputDir) ||
Regex.IsMatch(outputDir, @"^[A-Za-z]:") || // Windows 驱动器
outputDir.StartsWith("/")) // Unix 绝对路径
{
return PathValidationResult.Fail("仅允许相对路径");
}
// 4. 规范化并验证
string normalizedPath;
try
{
normalizedPath = Path.GetFullPath(Path.Combine(_applicationBasePath, outputDir));
}
catch (Exception ex)
{
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
}
// 5. 严格验证必须在应用目录内
if (!normalizedPath.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
{
return PathValidationResult.Fail("路径必须在应用程序目录内");
}
// 6. 白名单检查
var relative = normalizedPath
.Substring(_applicationBasePath.Length)
.TrimStart(Path.DirectorySeparatorChar);
bool inAllowedDir = _allowedDirectories
.Any(allowed => relative.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
string.Equals(relative, allowed, StringComparison.OrdinalIgnoreCase));
if (!inAllowedDir)
{
var allowedList = string.Join(", ", _allowedDirectories);
return PathValidationResult.Fail($"输出目录必须在允许列表中: {allowedList}");
}
return PathValidationResult.Success(normalizedPath);
}
/// <summary>
/// 简化版验证 - 仅检查路径安全性,不做白名单检查
/// </summary>
public PathValidationResult ValidatePathSafety(string path)
{
if (string.IsNullOrWhiteSpace(path))
return PathValidationResult.Fail("路径不能为空");
// 检查路径遍历
if (path.Contains(".."))
return PathValidationResult.Fail("路径包含非法的遍历字符");
// 检查绝对路径
if (Path.IsPathRooted(path) ||
Regex.IsMatch(path, @"^[A-Za-z]:") ||
path.StartsWith("/"))
{
return PathValidationResult.Fail("仅允许相对路径");
}
// 检查 UNC 路径
if (path.StartsWith("\\\\"))
return PathValidationResult.Fail("不允许 UNC 路径");
// 规范化验证
try
{
var normalized = Path.GetFullPath(Path.Combine(_applicationBasePath, path));
if (!normalized.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
return PathValidationResult.Fail("路径超出应用程序目录范围");
}
catch (Exception ex)
{
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
}
return PathValidationResult.Success(path);
}
}

View File

@@ -0,0 +1,54 @@
namespace WCTDataMiner.Core.Security;
/// <summary>
/// 安全相关常量定义
/// </summary>
public static class SecurityConstants
{
/// <summary>
/// 默认允许的输出目录
/// </summary>
public static readonly string[] DefaultAllowedExportDirectories =
[
"exports",
"output",
"data/exports",
"data/output"
];
/// <summary>
/// 路径最大长度限制
/// </summary>
public const int MaxPathLength = 260;
/// <summary>
/// 文件名最大长度限制
/// </summary>
public const int MaxFileNameLength = 255;
/// <summary>
/// 禁止的路径模式
/// </summary>
public static readonly string[] ForbiddenPathPatterns =
[
"..", // 路径遍历
"~", // 用户主目录
"\\\\", // UNC 路径开始
"|", // 管道字符Windows 禁止)
">", // 重定向字符
"<", // 重定向字符
"*", // 通配符(用于文件名时)
"?" // 通配符(用于文件名时)
];
/// <summary>
/// 安全的文件扩展名白名单
/// </summary>
public static readonly string[] AllowedExportExtensions =
[
".csv",
".json",
".txt",
".log"
];
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
@@ -10,10 +11,12 @@ namespace WCTDataMiner.Core.Services;
public class CleanService
{
private readonly WctMinerDbContext _context;
private readonly ILogger<CleanService> _logger;
public CleanService(WctMinerDbContext context)
public CleanService(WctMinerDbContext context, ILogger<CleanService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
@@ -21,8 +24,14 @@ public class CleanService
/// </summary>
public async Task<int> CleanByScenarioAsync(Guid scenarioId)
{
_logger.LogInformation("开始清理场景 {ScenarioId} 的数据", scenarioId);
var scenario = await _context.TestScenarios.FindAsync(scenarioId);
if (scenario == null) return 0;
if (scenario == null)
{
_logger.LogWarning("场景 {ScenarioId} 不存在", scenarioId);
return 0;
}
// 软删除关联的Qfod记录
var qfodRecords = await _context.QfodRecords
@@ -47,7 +56,10 @@ public class CleanService
await _context.SaveChangesAsync();
return qfodRecords.Count + plossRecords.Count + 1;
var count = qfodRecords.Count + plossRecords.Count + 1;
_logger.LogInformation("场景 {ScenarioId} 清理完成,共删除 {Count} 条记录", scenarioId, count);
return count;
}
/// <summary>
@@ -55,6 +67,8 @@ public class CleanService
/// </summary>
public async Task<int> CleanAllAsync()
{
_logger.LogWarning("开始清理所有数据");
int count = 0;
// 软删除所有Qfod记录
@@ -82,6 +96,8 @@ public class CleanService
}
await _context.SaveChangesAsync();
_logger.LogInformation("所有数据清理完成,共删除 {Count} 条记录", count);
return count;
}
}

View File

@@ -1,82 +1,138 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
namespace WCTDataMiner.Core.Services;
/// <summary>
/// 维度表管理服务 - 负责维度表的CRUD操作
/// 维度表管理服务 - 负责维度表的CRUD操作(并发安全版本)
/// </summary>
public class DimensionService
{
private readonly WctMinerDbContext _context;
private readonly ILogger<DimensionService> _logger;
public DimensionService(WctMinerDbContext context)
public DimensionService(WctMinerDbContext context, ILogger<DimensionService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// 获取或创建TX面板维度
/// 获取或创建TX面板维度(并发安全)
/// </summary>
public async Task<TxPanel> GetOrCreateTxPanelAsync(string name)
{
var existing = await _context.TxPanels
.FirstOrDefaultAsync(p => p.Name == name);
if (existing != null) return existing;
var newPanel = new TxPanel { Name = name };
_context.TxPanels.Add(newPanel);
await _context.SaveChangesAsync();
return newPanel;
return await GetOrCreateDimensionAsync(
() => _context.TxPanels.AsNoTracking().FirstOrDefaultAsync(p => p.Name == name),
() => new TxPanel { Name = name },
entity => _context.TxPanels.Add(entity),
name,
"TX Panel"
);
}
/// <summary>
/// 获取或创建TX硬件版本维度
/// 获取或创建TX硬件版本维度(并发安全)
/// </summary>
public async Task<TxHardware> GetOrCreateTxHardwareAsync(string version)
{
var existing = await _context.TxHardwares
.FirstOrDefaultAsync(h => h.Version == version);
if (existing != null) return existing;
var newHardware = new TxHardware { Version = version };
_context.TxHardwares.Add(newHardware);
await _context.SaveChangesAsync();
return newHardware;
return await GetOrCreateDimensionAsync(
() => _context.TxHardwares.AsNoTracking().FirstOrDefaultAsync(h => h.Version == version),
() => new TxHardware { Version = version },
entity => _context.TxHardwares.Add(entity),
version,
"TX Hardware"
);
}
/// <summary>
/// 获取或创建TX软件版本维度
/// 获取或创建TX软件版本维度(并发安全)
/// </summary>
public async Task<TxSoftware> GetOrCreateTxSoftwareAsync(string version)
{
var existing = await _context.TxSoftwares
.FirstOrDefaultAsync(s => s.Version == version);
if (existing != null) return existing;
var newSoftware = new TxSoftware { Version = version };
_context.TxSoftwares.Add(newSoftware);
await _context.SaveChangesAsync();
return newSoftware;
return await GetOrCreateDimensionAsync(
() => _context.TxSoftwares.AsNoTracking().FirstOrDefaultAsync(s => s.Version == version),
() => new TxSoftware { Version = version },
entity => _context.TxSoftwares.Add(entity),
version,
"TX Software"
);
}
/// <summary>
/// 获取或创建RX类型维度
/// 获取或创建RX类型维度(并发安全)
/// </summary>
public async Task<RxType> GetOrCreateRxTypeAsync(string name)
{
var existing = await _context.RxTypes
.FirstOrDefaultAsync(r => r.Name == name);
return await GetOrCreateDimensionAsync(
() => _context.RxTypes.AsNoTracking().FirstOrDefaultAsync(r => r.Name == name),
() => new RxType { Name = name },
entity => _context.RxTypes.Add(entity),
name,
"RX Type"
);
}
if (existing != null) return existing;
/// <summary>
/// 通用的并发安全 GetOrCreate 模式
/// </summary>
private async Task<T> GetOrCreateDimensionAsync<T>(
Func<Task<T?>> findExisting,
Func<T> createNew,
Action<T> addToContext,
string identifier,
string dimensionName) where T : class
{
const int maxRetries = 3;
var newRxType = new RxType { Name = name };
_context.RxTypes.Add(newRxType);
await _context.SaveChangesAsync();
return newRxType;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
// 使用 AsNoTracking 避免 ChangeTracker 缓存
var existing = await findExisting();
if (existing != null) return existing;
var newEntity = createNew();
addToContext(newEntity);
try
{
await _context.SaveChangesAsync();
_logger.LogDebug("创建新 {DimensionName} 维度: {Identifier}", dimensionName, identifier);
return newEntity;
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
// Detach 实体以避免 ChangeTracker 问题
_context.Entry(newEntity).State = EntityState.Detached;
_logger.LogDebug("{DimensionName} 并发创建冲突,重试: {Identifier}", dimensionName, identifier);
if (attempt < maxRetries - 1)
{
// 随机退避,减少并发冲突
await Task.Delay(Random.Shared.Next(5, 20));
}
}
}
// 最后一次尝试:必然存在(唯一约束已生效)
var finalEntity = await findExisting();
if (finalEntity == null)
{
// 极端情况:重试全部失败但数据不存在,抛出异常
throw new InvalidOperationException($"无法获取或创建 {dimensionName} 维度: {identifier}");
}
return finalEntity;
}
/// <summary>
/// 检测 SQLite 唯一约束违反错误
/// </summary>
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is Microsoft.Data.Sqlite.SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19; // SQLITE_CONSTRAINT
}
}

View File

@@ -2,21 +2,27 @@ using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
using WCTDataMiner.Core.Security;
namespace WCTDataMiner.Core.Services;
/// <summary>
/// 数据导出服务
/// 数据导出服务 - 支持安全的路径验证和 RFC 4180 标准 CSV 转义
/// </summary>
public class ExportService
{
private readonly WctMinerDbContext _context;
private readonly ILogger<ExportService> _logger;
private readonly PathValidator _pathValidator;
public ExportService(WctMinerDbContext context)
public ExportService(WctMinerDbContext context, ILogger<ExportService> logger)
{
_context = context;
_logger = logger;
_pathValidator = new PathValidator(AppDomain.CurrentDomain.BaseDirectory);
}
/// <summary>
@@ -24,30 +30,72 @@ public class ExportService
/// </summary>
public async Task<string> ExportQfodToCsvAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
_logger.LogInformation("开始导出 Qfod 数据为 CSV");
var records = await _context.QfodRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.ToListAsync();
var sb = new StringBuilder();
// CSV Header
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
foreach (var r in records)
// 路径验证
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
if (!validationResult.IsValid)
{
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.ChargerIndex},{r.CoilIndex},{r.DeltaQ},{r.CurrentQ},{r.RawQ},{r.FodType}");
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
await File.WriteAllTextAsync(filePath, sb.ToString());
var safeOutputDir = validationResult.NormalizedPath!;
Directory.CreateDirectory(safeOutputDir);
var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
// 使用流式导出避免内存问题
await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
// CSV Header
await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
// 使用 Select 投影避免 N+1 查询
var query = _context.QfodRecords
.Select(r => new
{
r.Id,
r.ScenarioId,
TxPanelName = r.Scenario!.TxPanel.Name,
TxHardwareVersion = r.Scenario.TxHardware.Version,
TxSoftwareVersion = r.Scenario.TxSoftware.Version,
RxTypeName = r.Scenario.RxType.Name,
r.Scenario.TestPurpose,
r.Scenario.TestDate,
r.Scenario.TestSequence,
r.ChargerIndex,
r.CoilIndex,
r.DeltaQ,
r.CurrentQ,
r.RawQ,
r.FodType
})
.OrderBy(r => r.Id);
int count = 0;
await foreach (var r in query.AsAsyncEnumerable())
{
var line = string.Join(',',
EscapeCsvField(r.Id),
EscapeCsvField(r.ScenarioId),
EscapeCsvField(r.TxPanelName),
EscapeCsvField(r.TxHardwareVersion),
EscapeCsvField(r.TxSoftwareVersion),
EscapeCsvField(r.RxTypeName),
EscapeCsvField(r.TestPurpose),
EscapeCsvField(r.TestDate),
EscapeCsvField(r.TestSequence),
EscapeCsvField(r.ChargerIndex),
EscapeCsvField(r.CoilIndex),
EscapeCsvField(r.DeltaQ),
EscapeCsvField(r.CurrentQ),
EscapeCsvField(r.RawQ),
EscapeCsvField(r.FodType)
);
await writer.WriteLineAsync(line);
count++;
}
_logger.LogInformation("Qfod CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
return filePath;
}
@@ -56,30 +104,98 @@ public class ExportService
/// </summary>
public async Task<string> ExportPlossToCsvAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
_logger.LogInformation("开始导出 Ploss 数据为 CSV");
var records = await _context.PlossRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.ToListAsync();
var sb = new StringBuilder();
// CSV Header
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
foreach (var r in records)
// 路径验证
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
if (!validationResult.IsValid)
{
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.RxType},{r.RxPower},{r.TxPower},{r.Vcoil},{r.Vin},{r.Isns},{r.Ploss},{r.Threshold},{r.TriggerCount},{r.FodResult},{r.ProtocolType},{r.PwmDuty},{r.CoilIndex},{r.Margin}");
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
await File.WriteAllTextAsync(filePath, sb.ToString());
var safeOutputDir = validationResult.NormalizedPath!;
Directory.CreateDirectory(safeOutputDir);
var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
// 使用流式导出避免内存问题
await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
// CSV Header包含新字段
await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,pow_loss,delta_p,field_1,field_2,field_3,field_4,field_5,field_6,field_7,field_8,field_9,field_10,field_11,field_12,field_13,field_14,field_15,field_16,margin");
// 使用 Select 投影避免 N+1 查询
var query = _context.PlossRecords
.Select(r => new
{
r.Id,
r.ScenarioId,
TxPanelName = r.Scenario!.TxPanel.Name,
TxHardwareVersion = r.Scenario.TxHardware.Version,
TxSoftwareVersion = r.Scenario.TxSoftware.Version,
RxTypeName = r.Scenario.RxType.Name,
r.Scenario.TestPurpose,
r.Scenario.TestDate,
r.Scenario.TestSequence,
r.PowLoss,
r.DeltaP,
r.Field1,
r.Field2,
r.Field3,
r.Field4,
r.Field5,
r.Field6,
r.Field7,
r.Field8,
r.Field9,
r.Field10,
r.Field11,
r.Field12,
r.Field13,
r.Field14,
r.Field15,
r.Field16,
Margin = r.Field8 - r.Field7
})
.OrderBy(r => r.Id);
int count = 0;
await foreach (var r in query.AsAsyncEnumerable())
{
var line = string.Join(',',
EscapeCsvField(r.Id),
EscapeCsvField(r.ScenarioId),
EscapeCsvField(r.TxPanelName),
EscapeCsvField(r.TxHardwareVersion),
EscapeCsvField(r.TxSoftwareVersion),
EscapeCsvField(r.RxTypeName),
EscapeCsvField(r.TestPurpose),
EscapeCsvField(r.TestDate),
EscapeCsvField(r.TestSequence),
EscapeCsvField(r.PowLoss),
EscapeCsvField(r.DeltaP),
EscapeCsvField(r.Field1),
EscapeCsvField(r.Field2),
EscapeCsvField(r.Field3),
EscapeCsvField(r.Field4),
EscapeCsvField(r.Field5),
EscapeCsvField(r.Field6),
EscapeCsvField(r.Field7),
EscapeCsvField(r.Field8),
EscapeCsvField(r.Field9),
EscapeCsvField(r.Field10),
EscapeCsvField(r.Field11),
EscapeCsvField(r.Field12),
EscapeCsvField(r.Field13),
EscapeCsvField(r.Field14),
EscapeCsvField(r.Field15),
EscapeCsvField(r.Field16),
EscapeCsvField(r.Margin)
);
await writer.WriteLineAsync(line);
count++;
}
_logger.LogInformation("Ploss CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
return filePath;
}
@@ -88,18 +204,20 @@ public class ExportService
/// </summary>
public async Task<string> ExportQfodToJsonAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
_logger.LogInformation("开始导出 Qfod 数据为 JSON");
// 路径验证
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
if (!validationResult.IsValid)
{
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
var safeOutputDir = validationResult.NormalizedPath!;
Directory.CreateDirectory(safeOutputDir);
var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.QfodRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
@@ -122,6 +240,8 @@ public class ExportService
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Qfod JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
return filePath;
}
@@ -130,18 +250,20 @@ public class ExportService
/// </summary>
public async Task<string> ExportPlossToJsonAsync(string outputDir)
{
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
_logger.LogInformation("开始导出 Ploss 数据为 JSON");
// 路径验证
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
if (!validationResult.IsValid)
{
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
var safeOutputDir = validationResult.NormalizedPath!;
Directory.CreateDirectory(safeOutputDir);
var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.PlossRecords
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxPanel)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxHardware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.TxSoftware)
.Include(r => r.Scenario)
.ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
@@ -153,25 +275,71 @@ public class ExportService
r.Scenario.TestPurpose,
TestDate = r.Scenario.TestDate.ToString("yyyy-MM-dd"),
r.Scenario.TestSequence,
RecordRxType = r.RxType,
r.RxPower,
r.TxPower,
r.Vcoil,
r.Vin,
r.Isns,
r.Ploss,
r.Threshold,
r.TriggerCount,
r.FodResult,
r.ProtocolType,
r.PwmDuty,
r.CoilIndex,
Margin = r.Threshold - r.Ploss
r.PowLoss,
r.DeltaP,
r.Field1,
r.Field2,
r.Field3,
r.Field4,
r.Field5,
r.Field6,
r.Field7,
r.Field8,
r.Field9,
r.Field10,
r.Field11,
r.Field12,
r.Field13,
r.Field14,
r.Field15,
r.Field16,
Margin = r.Field8 - r.Field7
})
.ToListAsync();
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Ploss JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
return filePath;
}
/// <summary>
/// RFC 4180 标准 CSV 字段转义
/// </summary>
private static string EscapeCsvField(object? value)
{
var field = value switch
{
null => string.Empty,
DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"),
DateOnly d => d.ToString("yyyy-MM-dd"),
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty
};
// 单次扫描检测所有特殊字符
bool needsEscape = false;
foreach (char c in field)
{
if (c == ',' || c == '"' || c == '\n' || c == '\r')
{
needsEscape = true;
break;
}
}
if (!needsEscape) return field;
// 使用 StringBuilder 避免多次字符串分配
var sb = new StringBuilder(field.Length + 10);
sb.Append('"');
foreach (char c in field)
{
if (c == '"') sb.Append("\"\"");
else sb.Append(c);
}
sb.Append('"');
return sb.ToString();
}
}

View File

@@ -71,6 +71,10 @@ public class ParseService
var plossRecords = new List<PlossRecord>();
int errorCount = 0;
// 用于暂存多行解析的header行
string? pendingHeaderLine = null;
int pendingHeaderLineNumber = 0;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
@@ -90,20 +94,56 @@ public class ParseService
errorCount++;
}
}
else if (_plossParser.IsMultiLineStart(line))
{
// 检测到两行格式的header行暂存
pendingHeaderLine = line;
pendingHeaderLineNumber = lineNumber;
}
else if (_plossParser.CanParse(line))
{
var result = _plossParser.Parse(line, scenario.Id);
if (result.IsSuccess)
plossRecords.Add(result.Record!);
if (pendingHeaderLine != null)
{
// 两行格式:合并解析
var result = _plossParser.ParseMultiLine(
new[] { pendingHeaderLine, line }, scenario.Id);
if (result != null && result.IsSuccess)
{
plossRecords.Add(result.Record!);
}
else
{
_logger.LogError("Ploss两行解析失败 [{Line}] {Error}: {Message}",
pendingHeaderLineNumber, result?.ErrorType, result?.ErrorMessage);
errorCount++;
}
pendingHeaderLine = null;
}
else
{
_logger.LogError("Ploss解析失败 [{Line}] {Error}: {Message}",
lineNumber, result.ErrorType, result.ErrorMessage);
errorCount++;
// 单行格式(旧格式兼容)
var result = _plossParser.Parse(line, scenario.Id);
if (result.IsSuccess)
plossRecords.Add(result.Record!);
else
{
_logger.LogError("Ploss解析失败 [{Line}] {Error}: {Message}",
lineNumber, result.ErrorType, result.ErrorMessage);
errorCount++;
}
}
}
}
// 处理末尾遗留的header行异常情况
if (pendingHeaderLine != null)
{
_logger.LogWarning("发现未匹配的Ploss header行 [{Line}]: {Content}",
pendingHeaderLineNumber, pendingHeaderLine);
errorCount++;
}
// 批量保存
await BatchInsertAsync(qfodRecords, plossRecords);

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
using WCTDataMiner.Core.Parsers;
@@ -6,32 +7,109 @@ using WCTDataMiner.Core.Parsers;
namespace WCTDataMiner.Core.Services;
/// <summary>
/// 测试场景服务 - 负责测试场景的创建和查询
/// 篮选条件常量
/// </summary>
public static class FilterConstants
{
/// <summary>
/// 表示"全部"选项的常量值
/// </summary>
public const string AllOption = "全部";
}
/// <summary>
/// 测试场景服务 - 负责测试场景的创建和查询(并发安全版本)
/// </summary>
public class ScenarioService
{
private readonly WctMinerDbContext _context;
private readonly DimensionService _dimensionService;
private readonly ILogger<ScenarioService> _logger;
public ScenarioService(WctMinerDbContext context, DimensionService dimensionService)
public ScenarioService(WctMinerDbContext context, DimensionService dimensionService, ILogger<ScenarioService> logger)
{
_context = context;
_dimensionService = dimensionService;
_logger = logger;
}
/// <summary>
/// 根据场景信息获取或创建测试场景
/// 根据场景信息获取或创建测试场景(并发安全)
/// </summary>
public async Task<TestScenario> GetOrCreateScenarioAsync(ScenarioInfo info)
{
_logger.LogDebug("查找/创建场景: TxPanel={TxPanel}, TxHardware={TxHardware}, TxSoftware={TxSoftware}, RxType={RxType}",
info.TxPanel, info.TxHardware, info.TxSoftware, info.RxType);
// 1. 获取或创建各维度
var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel);
var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware);
var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware);
var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType);
// 2. 检查场景是否已存在(利用唯一约束)
var existing = await _context.TestScenarios
// 2. 并发安全的场景创建
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
// 使用 AsNoTracking 避免 ChangeTracker 缓存
var existing = await _context.TestScenarios
.AsNoTracking()
.FirstOrDefaultAsync(s =>
s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id &&
s.TestPurpose == info.TestPurpose &&
s.TestDate == info.TestDate &&
s.TestSequence == info.TestSequence);
if (existing != null)
{
_logger.LogDebug("场景已存在: {ScenarioId}", existing.Id);
return existing;
}
// 3. 创建新场景
var scenario = new TestScenario
{
TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id,
TestPurpose = info.TestPurpose,
TestDate = info.TestDate,
TestSequence = info.TestSequence
};
_context.TestScenarios.Add(scenario);
try
{
await _context.SaveChangesAsync();
_logger.LogInformation("创建新测试场景: {ScenarioId}, TxPanel={TxPanel}, TxHardware={TxHardware}, RxType={RxType}, TestDate={TestDate}",
scenario.Id, info.TxPanel, info.TxHardware, info.RxType, info.TestDate);
return scenario;
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
// Detach 实体以避免 ChangeTracker 问题
_context.Entry(scenario).State = EntityState.Detached;
_logger.LogDebug("场景并发创建冲突,重试: TxPanel={TxPanel}", info.TxPanel);
if (attempt < maxRetries - 1)
{
// 随机退避,减少并发冲突
await Task.Delay(Random.Shared.Next(5, 20));
}
}
}
// 最后一次尝试:必然存在(唯一约束已生效)
var finalScenario = await _context.TestScenarios
.AsNoTracking()
.FirstOrDefaultAsync(s =>
s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id &&
@@ -41,24 +119,12 @@ public class ScenarioService
s.TestDate == info.TestDate &&
s.TestSequence == info.TestSequence);
if (existing != null) return existing;
// 3. 创建新场景
var scenario = new TestScenario
if (finalScenario == null)
{
TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id,
TestPurpose = info.TestPurpose,
TestDate = info.TestDate,
TestSequence = info.TestSequence
};
throw new InvalidOperationException($"无法获取或创建场景: {info.TxPanel}/{info.TxHardware}/{info.RxType}");
}
_context.TestScenarios.Add(scenario);
await _context.SaveChangesAsync();
return scenario;
return finalScenario;
}
/// <summary>
@@ -73,4 +139,175 @@ public class ScenarioService
.Include(s => s.RxType)
.FirstOrDefaultAsync(s => s.Id == scenarioId);
}
/// <summary>
/// 获取所有测试场景(含维度属性)
/// </summary>
/// <param name="pageSize">每页数量默认50传0表示不分页</param>
/// <param name="pageIndex">页码从0开始</param>
public async Task<List<TestScenario>> GetAllScenariosAsync(int pageSize = 50, int pageIndex = 0)
{
var query = _context.TestScenarios
.Include(s => s.TxPanel)
.Include(s => s.TxHardware)
.Include(s => s.TxSoftware)
.Include(s => s.RxType)
.OrderByDescending(s => s.TestDate)
.ThenByDescending(s => s.TestSequence)
.AsNoTracking();
if (pageSize > 0)
{
query = query.Skip(pageIndex * pageSize).Take(pageSize);
}
return await query.ToListAsync();
}
/// <summary>
/// 获取场景总数
/// </summary>
public async Task<int> GetScenariosCountAsync()
{
return await _context.TestScenarios.CountAsync();
}
/// <summary>
/// 获取可用的筛选选项
/// </summary>
public async Task<FilterOptions> GetFilterOptionsAsync()
{
var txPanels = await _context.TxPanels
.Select(p => p.Name)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
var txHardwares = await _context.TxHardwares
.Select(h => h.Version)
.Distinct()
.OrderBy(v => v)
.ToListAsync();
var txSoftwares = await _context.TxSoftwares
.Select(s => s.Version)
.Distinct()
.OrderBy(v => v)
.ToListAsync();
var rxTypes = await _context.RxTypes
.Select(r => r.Name)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
return new FilterOptions(
txPanels.Prepend(FilterConstants.AllOption).ToList(),
txHardwares.Prepend(FilterConstants.AllOption).ToList(),
txSoftwares.Prepend(FilterConstants.AllOption).ToList(),
rxTypes.Prepend(FilterConstants.AllOption).ToList());
}
/// <summary>
/// 根据筛选条件获取测试场景
/// </summary>
/// <param name="txPanel">TxPanel 名称null 或 "全部" 表示不过滤</param>
/// <param name="txHardware">TxHardware 版本null 或 "全部" 表示不过滤</param>
/// <param name="txSoftware">TxSoftware 版本null 或 "全部" 表示不过滤</param>
/// <param name="rxType">RxType 名称null 或 "全部" 表示不过滤</param>
/// <param name="pageSize">每页数量默认50传0表示不分页</param>
/// <param name="pageIndex">页码从0开始</param>
public async Task<List<TestScenario>> GetScenariosByFilterAsync(
string? txPanel = null,
string? txHardware = null,
string? txSoftware = null,
string? rxType = null,
int pageSize = 50,
int pageIndex = 0)
{
var query = _context.TestScenarios
.Include(s => s.TxPanel)
.Include(s => s.TxHardware)
.Include(s => s.TxSoftware)
.Include(s => s.RxType)
.AsNoTracking();
if (!string.IsNullOrEmpty(txPanel) && txPanel != FilterConstants.AllOption)
{
query = query.Where(s => s.TxPanel.Name == txPanel);
}
if (!string.IsNullOrEmpty(txHardware) && txHardware != FilterConstants.AllOption)
{
query = query.Where(s => s.TxHardware.Version == txHardware);
}
if (!string.IsNullOrEmpty(txSoftware) && txSoftware != FilterConstants.AllOption)
{
query = query.Where(s => s.TxSoftware.Version == txSoftware);
}
if (!string.IsNullOrEmpty(rxType) && rxType != FilterConstants.AllOption)
{
query = query.Where(s => s.RxType.Name == rxType);
}
query = query
.OrderByDescending(s => s.TestDate)
.ThenByDescending(s => s.TestSequence);
if (pageSize > 0)
{
query = query.Skip(pageIndex * pageSize).Take(pageSize);
}
return await query.ToListAsync();
}
/// <summary>
/// 获取指定场景的 Ploss 记录
/// </summary>
/// <param name="scenarioId">场景 ID</param>
/// <returns>Ploss 记录列表,按 TriggerCount (Field9) 排序</returns>
public async Task<List<PlossRecord>> GetPlossRecordsByScenarioIdAsync(Guid scenarioId)
{
return await _context.PlossRecords
.AsNoTracking()
.Where(r => r.ScenarioId == scenarioId && !r.IsDeleted)
.OrderBy(r => r.Field9)
.ToListAsync();
}
/// <summary>
/// 获取指定场景的 Qfod 记录
/// </summary>
/// <param name="scenarioId">场景 ID</param>
/// <returns>Qfod 记录列表,按 ChargerIndex 和 CoilIndex 排序</returns>
public async Task<List<QfodRecord>> GetQfodRecordsByScenarioIdAsync(Guid scenarioId)
{
return await _context.QfodRecords
.AsNoTracking()
.Where(r => r.ScenarioId == scenarioId && !r.IsDeleted)
.OrderBy(r => r.ChargerIndex)
.ThenBy(r => r.CoilIndex)
.ToListAsync();
}
/// <summary>
/// 检测 SQLite 唯一约束违反错误
/// </summary>
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is Microsoft.Data.Sqlite.SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19; // SQLITE_CONSTRAINT
}
}
/// <summary>
/// 筛选选项数据
/// </summary>
public record FilterOptions(
List<string> TxPanels,
List<string> TxHardwares,
List<string> TxSoftwares,
List<string> RxTypes);

View File

@@ -1,106 +1,120 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
namespace WCTDataMiner.Core.Services;
/// <summary>
/// 统计查询服务
/// 统计查询服务 - 使用数据库端聚合优化内存效率
/// </summary>
public class StatsService
{
private readonly WctMinerDbContext _context;
private readonly ILogger<StatsService> _logger;
public StatsService(WctMinerDbContext context)
public StatsService(WctMinerDbContext context, ILogger<StatsService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// 获取整体统计摘要
/// 获取整体统计摘要(并行查询优化)
/// </summary>
public async Task<StatsSummary> GetSummaryAsync()
{
var scenarioCount = await _context.TestScenarios.CountAsync();
var qfodCount = await _context.QfodRecords.CountAsync();
var plossCount = await _context.PlossRecords.CountAsync();
var panelCount = await _context.TxPanels.CountAsync();
var hardwareCount = await _context.TxHardwares.CountAsync();
var softwareCount = await _context.TxSoftwares.CountAsync();
var rxTypeCount = await _context.RxTypes.CountAsync();
// 使用 Task.WhenAll 并行执行多个查询
var tasks = new[]
{
_context.TestScenarios.CountAsync(),
_context.QfodRecords.CountAsync(),
_context.PlossRecords.CountAsync(),
_context.TxPanels.CountAsync(),
_context.TxHardwares.CountAsync(),
_context.TxSoftwares.CountAsync(),
_context.RxTypes.CountAsync()
};
return new StatsSummary(
scenarioCount, qfodCount, plossCount,
panelCount, hardwareCount, softwareCount, rxTypeCount
var results = await Task.WhenAll(tasks);
var summary = new StatsSummary(
results[0], results[1], results[2],
results[3], results[4], results[5], results[6]
);
_logger.LogDebug("统计摘要: Scenarios={ScenarioCount}, Qfod={QfodCount}, Ploss={PlossCount}",
summary.ScenarioCount, summary.QfodCount, summary.PlossCount);
return summary;
}
/// <summary>
/// 按FOD类型统计Qfod记录
/// 按FOD类型统计Qfod记录(数据库端聚合)
/// </summary>
public async Task<List<FodTypeStats>> GetStatsByFodTypeAsync()
{
// 先加载到内存再分组EF Core 对 GroupBy + 自定义类型有限制)
var data = await _context.QfodRecords
.Select(r => r.FodType)
.ToListAsync();
return data
.GroupBy(f => f)
// 使用数据库端 GroupBy
var stats = await _context.QfodRecords
.GroupBy(r => r.FodType)
.Select(g => new FodTypeStats(g.Key, g.Count()))
.OrderBy(s => s.FodType)
.ToList();
.ToListAsync();
_logger.LogDebug("按 FOD 类型统计完成: {Count} 种类型", stats.Count);
return stats;
}
/// <summary>
/// 按面板类型统计
/// 按面板类型统计(数据库端聚合 - 使用维度表反向统计)
/// </summary>
public async Task<List<DimensionStats>> GetStatsByPanelAsync()
{
var data = await _context.TestScenarios
.Include(s => s.TxPanel)
.Select(s => s.TxPanel.Name)
// 方式: 使用维度表反向统计(更高效)
var stats = await _context.TxPanels
.Select(p => new DimensionStats(
p.Name,
p.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
return data
.GroupBy(n => n)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
_logger.LogDebug("按面板类型统计完成: {Count} 种面板", stats.Count);
return stats;
}
/// <summary>
/// 按硬件版本统计
/// 按硬件版本统计(数据库端聚合)
/// </summary>
public async Task<List<DimensionStats>> GetStatsByHardwareAsync()
{
var data = await _context.TestScenarios
.Include(s => s.TxHardware)
.Select(s => s.TxHardware.Version)
var stats = await _context.TxHardwares
.Select(h => new DimensionStats(
h.Version,
h.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
return data
.GroupBy(v => v)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
_logger.LogDebug("按硬件版本统计完成: {Count} 种版本", stats.Count);
return stats;
}
/// <summary>
/// 按RX类型统计
/// 按RX类型统计(数据库端聚合)
/// </summary>
public async Task<List<DimensionStats>> GetStatsByRxTypeAsync()
{
var data = await _context.TestScenarios
.Include(s => s.RxType)
.Select(s => s.RxType.Name)
var stats = await _context.RxTypes
.Select(r => new DimensionStats(
r.Name,
r.TestScenarios.Count()
))
.OrderByDescending(s => s.Count)
.ToListAsync();
return data
.GroupBy(n => n)
.Select(g => new DimensionStats(g.Key, g.Count()))
.OrderByDescending(s => s.Count)
.ToList();
_logger.LogDebug("按 RX 类型统计完成: {Count} 种类型", stats.Count);
return stats;
}
}

View File

@@ -1,8 +1,12 @@
using System.IO;
using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using WCTDataMiner.Core.Extensions;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Configuration;
using WCTDataMiner.Wpf.ViewModels;
using WCTDataMiner.Wpf.Views;
@@ -14,33 +18,96 @@ namespace WCTDataMiner.Wpf;
public partial class App : Application
{
private IHost? _host;
private ILogger? _logger;
protected override void OnStartup(StartupEventArgs e)
{
// 使用 ConfigurationLoader 加载配置,支持文件缺失
var configuration = ConfigurationLoader.LoadConfiguration(
AppDomain.CurrentDomain.BaseDirectory,
out var configFileExists);
// 配置 Serilog - 从配置文件读取
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.FromLogContext()
.CreateLogger();
_logger = Log.ForContext<App>();
if (!configFileExists)
{
_logger.Information("配置文件 appsettings.json 不存在,使用默认配置启动");
}
_logger.Information("WCTDataMiner WPF 应用启动");
_host = Host.CreateDefaultBuilder()
.ConfigureAppConfiguration((context, configBuilder) =>
{
configBuilder.Sources.Clear();
configBuilder.SetBasePath(AppDomain.CurrentDomain.BaseDirectory);
if (configFileExists)
{
configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
}
else
{
configBuilder.AddInMemoryCollection(DefaultAppSettings.GetDefaultConfiguration());
}
configBuilder.AddEnvironmentVariables("WCTDM_");
})
.ConfigureServices((context, services) =>
{
// 添加 Serilog
services.AddSerilog();
// 注册 Core 层服务
services.AddDatabaseServices(context.Configuration);
services.AddApplicationServices();
// 注册 ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<ScenarioListViewModel>();
// 注册 ViewModels(使用 Singleton 确保消息订阅正确工作)
services.AddSingleton<MainViewModel>();
services.AddSingleton<ScenarioListViewModel>();
// Chart ViewModels 使用 Transient每次导航创建新实例
services.AddTransient<PlossChartViewModel>();
services.AddTransient<QfodChartViewModel>();
services.AddTransient<ImportViewModel>();
// Table ViewModels 使用 Transient每次导航创建新实例
services.AddTransient<PlossTableViewModel>();
services.AddTransient<QfodTableViewModel>();
services.AddSingleton<ImportViewModel>();
// 注册 Views
services.AddTransient<MainWindow>();
services.AddTransient<ScenarioListView>();
services.AddTransient<PlossChartView>();
services.AddTransient<QfodChartView>();
services.AddTransient<PlossTableView>();
services.AddTransient<QfodTableView>();
services.AddTransient<ImportView>();
})
.Build();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
// 初始化加载场景数据
var mainViewModel = _host.Services.GetRequiredService<MainViewModel>();
mainWindow.Loaded += async (s, e) =>
{
try
{
await mainViewModel.InitializeAsync();
}
catch (Exception ex)
{
_logger?.Error(ex, "主窗口初始化失败");
MessageBox.Show($"应用初始化失败: {ex.Message}", "错误",
MessageBoxButton.OK, MessageBoxImage.Error);
}
};
mainWindow.Show();
base.OnStartup(e);
@@ -48,6 +115,8 @@ public partial class App : Application
protected override void OnExit(ExitEventArgs e)
{
_logger?.Information("WCTDataMiner WPF 应用退出");
Log.CloseAndFlush();
_host?.Dispose();
base.OnExit(e);
}

View File

@@ -0,0 +1,37 @@
using System.IO;
using Microsoft.Extensions.Configuration;
namespace WCTDataMiner.Wpf.Configuration;
/// <summary>
/// 配置加载器 - 支持配置文件缺失时的默认配置
/// </summary>
public static class ConfigurationLoader
{
/// <summary>
/// 加载配置,如果文件不存在则使用默认配置
/// </summary>
/// <param name="basePath">配置文件基础路径</param>
/// <param name="configFileExists">输出参数:指示配置文件是否存在</param>
/// <returns>配置对象</returns>
public static IConfiguration LoadConfiguration(string basePath, out bool configFileExists)
{
var settingsPath = Path.Combine(basePath, "appsettings.json");
configFileExists = File.Exists(settingsPath);
var builder = new ConfigurationBuilder().SetBasePath(basePath);
if (configFileExists)
{
builder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
}
else
{
// 使用默认配置
builder.AddInMemoryCollection(DefaultAppSettings.GetDefaultConfiguration());
}
builder.AddEnvironmentVariables("WCTDM_");
return builder.Build();
}
}

View File

@@ -0,0 +1,30 @@
namespace WCTDataMiner.Wpf.Configuration;
/// <summary>
/// 默认应用配置 - 当配置文件缺失时使用
/// </summary>
public static class DefaultAppSettings
{
/// <summary>
/// 获取默认配置字典
/// </summary>
public static Dictionary<string, string?> GetDefaultConfiguration()
{
return new Dictionary<string, string?>
{
// Serilog 配置
["Serilog:MinimumLevel:Default"] = "Information",
["Serilog:MinimumLevel:Override:WCTDataMiner"] = "Debug",
["Serilog:MinimumLevel:Override:Microsoft"] = "Warning",
["Serilog:WriteTo:0:Name"] = "Console",
["Serilog:WriteTo:1:Name"] = "File",
["Serilog:WriteTo:1:Args:path"] = "./data/logs/wctdataminer-.log",
["Serilog:WriteTo:1:Args:rollingInterval"] = "Day",
["Serilog:WriteTo:1:Args:outputTemplate"] = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}",
// 数据库配置
["Database:Type"] = "sqlite",
["Database:Path"] = "./data/database/wctminer.db"
};
}
}

View File

@@ -62,6 +62,12 @@
<DataTemplate DataType="{x:Type viewmodels:QfodChartViewModel}">
<views:QfodChartView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodels:PlossTableViewModel}">
<views:PlossTableView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodels:QfodTableViewModel}">
<views:QfodTableView/>
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</Grid>

View File

@@ -0,0 +1,31 @@
namespace WCTDataMiner.Wpf.Messages;
/// <summary>
/// 数据导入完成消息
/// </summary>
/// <param name="ImportedCount">导入的文件数量</param>
public record DataImportedMessage(int ImportedCount);
/// <summary>
/// 导航到 Ploss 图表消息
/// </summary>
/// <param name="ScenarioId">场景 ID</param>
public record NavigateToPlossChartMessage(Guid ScenarioId);
/// <summary>
/// 导航到 Qfod 图表消息
/// </summary>
/// <param name="ScenarioId">场景 ID</param>
public record NavigateToQfodChartMessage(Guid ScenarioId);
/// <summary>
/// 导航到 Ploss 表格消息
/// </summary>
/// <param name="ScenarioId">场景 ID</param>
public record NavigateToPlossTableMessage(Guid ScenarioId);
/// <summary>
/// 导航到 Qfod 表格消息
/// </summary>
/// <param name="ScenarioId">场景 ID</param>
public record NavigateToQfodTableMessage(Guid ScenarioId);

View File

@@ -1,8 +1,11 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using System.Collections.ObjectModel;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
@@ -12,6 +15,7 @@ namespace WCTDataMiner.Wpf.ViewModels;
public partial class ImportViewModel : ObservableObject
{
private readonly ParseService _parseService;
private readonly ILogger<ImportViewModel> _logger;
[ObservableProperty]
private string _selectedPath = "";
@@ -31,9 +35,10 @@ public partial class ImportViewModel : ObservableObject
[ObservableProperty]
private ObservableCollection<ImportResultItem> _importResults = new();
public ImportViewModel(ParseService parseService)
public ImportViewModel(ParseService parseService, ILogger<ImportViewModel> logger)
{
_parseService = parseService;
_logger = logger;
}
[RelayCommand]
@@ -50,6 +55,7 @@ public partial class ImportViewModel : ObservableObject
{
SelectedPath = dialog.FileName;
StatusMessage = $"已选择文件: {System.IO.Path.GetFileName(SelectedPath)}";
_logger.LogDebug("用户选择文件: {FilePath}", SelectedPath);
}
}
@@ -66,6 +72,7 @@ public partial class ImportViewModel : ObservableObject
{
SelectedPath = dialog.FolderName;
StatusMessage = $"已选择目录: {System.IO.Path.GetFileName(SelectedPath)}";
_logger.LogDebug("用户选择目录: {FolderPath}, 递归={IsRecursive}", SelectedPath, IsRecursive);
}
}
@@ -82,6 +89,8 @@ public partial class ImportViewModel : ObservableObject
ImportResults.Clear();
StatusMessage = "正在导入...";
_logger.LogInformation("开始导入: {Path}, 递归={IsRecursive}", SelectedPath, IsRecursive);
try
{
if (System.IO.File.Exists(SelectedPath))
@@ -90,6 +99,11 @@ public partial class ImportViewModel : ObservableObject
var report = await _parseService.ParseFileAsync(SelectedPath);
ImportResults.Add(new ImportResultItem(report));
StatusMessage = $"导入完成: Qfod={report.QfodCount}, Ploss={report.PlossCount}, 错误={report.ErrorCount}";
_logger.LogInformation("单文件导入完成: {FileName}, Qfod={Qfod}, Ploss={Ploss}, 错误={Errors}",
report.FileName, report.QfodCount, report.PlossCount, report.ErrorCount);
// 发送导入完成消息,通知其他 ViewModel 刷新数据
WeakReferenceMessenger.Default.Send(new DataImportedMessage(1));
}
else if (System.IO.Directory.Exists(SelectedPath))
{
@@ -107,15 +121,22 @@ public partial class ImportViewModel : ObservableObject
}
StatusMessage = $"导入完成: {reports.Count} 个文件, Qfod={totalQfod}, Ploss={totalPloss}, 错误={totalErrors}";
_logger.LogInformation("目录导入完成: {FileCount} 个文件, Qfod={Qfod}, Ploss={Ploss}, 错误={Errors}",
reports.Count, totalQfod, totalPloss, totalErrors);
// 发送导入完成消息,通知其他 ViewModel 刷新数据
WeakReferenceMessenger.Default.Send(new DataImportedMessage(reports.Count));
}
else
{
StatusMessage = "路径不存在";
_logger.LogWarning("导入路径不存在: {Path}", SelectedPath);
}
}
catch (Exception ex)
{
StatusMessage = $"导入失败: {ex.Message}";
_logger.LogError(ex, "导入失败: {Path}", SelectedPath);
}
finally
{

View File

@@ -1,15 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
/// <summary>
/// 主窗口 ViewModel - 导航控制
/// </summary>
public partial class MainViewModel : ObservableObject
public partial class MainViewModel : ObservableObject,
IRecipient<NavigateToPlossChartMessage>,
IRecipient<NavigateToQfodChartMessage>,
IRecipient<NavigateToPlossTableMessage>,
IRecipient<NavigateToQfodTableMessage>
{
private readonly ImportViewModel _importViewModel;
private readonly ScenarioListViewModel _scenarioListViewModel;
private readonly IServiceProvider _serviceProvider;
[ObservableProperty]
private ObservableObject _currentView = null!;
@@ -26,17 +36,50 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private string? _selectedRxType;
// 筛选选项列表
public List<string> TxPanels { get; } = new() { "全部", "singleMold", "dualMold" };
public List<string> TxHardwares { get; } = new() { "全部", "v1.0", "v2.0" };
public List<string> TxSoftwares { get; } = new() { "全部", "hex2_1", "hex2_2" };
public List<string> RxTypes { get; } = new() { "全部", "iPhone15", "iPhone16", "Android" };
// 筛选选项列表(动态加载)
[ObservableProperty]
private List<string> _txPanels = new() { FilterConstants.AllOption };
public MainViewModel(ImportViewModel importViewModel, ScenarioListViewModel scenarioListViewModel)
[ObservableProperty]
private List<string> _txHardwares = new() { FilterConstants.AllOption };
[ObservableProperty]
private List<string> _txSoftwares = new() { FilterConstants.AllOption };
[ObservableProperty]
private List<string> _rxTypes = new() { FilterConstants.AllOption };
public MainViewModel(
ImportViewModel importViewModel,
ScenarioListViewModel scenarioListViewModel,
IServiceProvider serviceProvider)
{
_importViewModel = importViewModel;
_scenarioListViewModel = scenarioListViewModel;
_serviceProvider = serviceProvider;
_currentView = _scenarioListViewModel;
// 注册导航消息订阅
WeakReferenceMessenger.Default.Register<NavigateToPlossChartMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToQfodChartMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPlossTableMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToQfodTableMessage>(this);
}
/// <summary>
/// 初始化加载场景数据和筛选选项
/// </summary>
public async Task InitializeAsync()
{
// 加载筛选选项
var filterOptions = await _scenarioListViewModel.GetFilterOptionsAsync();
TxPanels = filterOptions.TxPanels;
TxHardwares = filterOptions.TxHardwares;
TxSoftwares = filterOptions.TxSoftwares;
RxTypes = filterOptions.RxTypes;
// 加载场景列表
await _scenarioListViewModel.LoadScenariosAsync();
}
[RelayCommand]
@@ -46,20 +89,86 @@ public partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void NavigateToScenarios()
private async Task NavigateToScenarios()
{
CurrentView = _scenarioListViewModel;
await _scenarioListViewModel.LoadScenariosAsync();
}
[RelayCommand]
private void NavigateToPlossChart(int scenarioId)
private void NavigateToPlossChart(Guid scenarioId)
{
CurrentView = new PlossChartViewModel { ScenarioId = scenarioId };
var vm = _serviceProvider.GetRequiredService<PlossChartViewModel>();
vm.ScenarioId = scenarioId;
CurrentView = vm;
}
[RelayCommand]
private void NavigateToQfodChart(int scenarioId)
private void NavigateToQfodChart(Guid scenarioId)
{
CurrentView = new QfodChartViewModel { ScenarioId = scenarioId };
var vm = _serviceProvider.GetRequiredService<QfodChartViewModel>();
vm.ScenarioId = scenarioId;
CurrentView = vm;
}
/// <summary>
/// 应用筛选条件并刷新场景列表
/// </summary>
public async Task ApplyFilterAsync()
{
await _scenarioListViewModel.LoadScenariosByFilterAsync(
SelectedTxPanel, SelectedTxHardware, SelectedTxSoftware, SelectedRxType);
}
/// <summary>
/// 接收导航到 Ploss 图表消息
/// </summary>
public void Receive(NavigateToPlossChartMessage message)
{
Application.Current.Dispatcher.InvokeAsync(() =>
{
var vm = _serviceProvider.GetRequiredService<PlossChartViewModel>();
vm.ScenarioId = message.ScenarioId;
CurrentView = vm;
});
}
/// <summary>
/// 接收导航到 Qfod 图表消息
/// </summary>
public void Receive(NavigateToQfodChartMessage message)
{
Application.Current.Dispatcher.InvokeAsync(() =>
{
var vm = _serviceProvider.GetRequiredService<QfodChartViewModel>();
vm.ScenarioId = message.ScenarioId;
CurrentView = vm;
});
}
/// <summary>
/// 接收导航到 Ploss 表格消息
/// </summary>
public void Receive(NavigateToPlossTableMessage message)
{
Application.Current.Dispatcher.InvokeAsync(() =>
{
var vm = _serviceProvider.GetRequiredService<PlossTableViewModel>();
vm.ScenarioId = message.ScenarioId;
CurrentView = vm;
});
}
/// <summary>
/// 接收导航到 Qfod 表格消息
/// </summary>
public void Receive(NavigateToQfodTableMessage message)
{
Application.Current.Dispatcher.InvokeAsync(() =>
{
var vm = _serviceProvider.GetRequiredService<QfodTableViewModel>();
vm.ScenarioId = message.ScenarioId;
CurrentView = vm;
});
}
}

View File

@@ -1,6 +1,11 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
@@ -9,8 +14,11 @@ namespace WCTDataMiner.Wpf.ViewModels;
/// </summary>
public partial class PlossChartViewModel : ObservableObject
{
private readonly ScenarioService _scenarioService;
private readonly ILogger<PlossChartViewModel> _logger;
[ObservableProperty]
private int _scenarioId;
private Guid _scenarioId;
[ObservableProperty]
private ISeries[] _series;
@@ -21,29 +29,112 @@ public partial class PlossChartViewModel : ObservableObject
[ObservableProperty]
private Axis[] _yAxes;
public PlossChartViewModel()
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _loadingMessage = "";
public PlossChartViewModel(
ScenarioService scenarioService,
ILogger<PlossChartViewModel> logger)
{
_scenarioService = scenarioService;
_logger = logger;
// 初始化图表配置
Series = Array.Empty<ISeries>();
XAxes = new Axis[] { new Axis { Name = "Trigger Count" } };
YAxes = new Axis[] { new Axis { Name = "Power Loss (mW)" } };
}
/// <summary>
/// ScenarioId 属性变更时自动触发数据加载
/// </summary>
partial void OnScenarioIdChanged(Guid value)
{
if (value != Guid.Empty)
{
_ = LoadDataAsync().ContinueWith(t =>
{
if (t.Exception != null)
{
_logger.LogError(t.Exception, "LoadDataAsync 失败: ScenarioId={ScenarioId}", value);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
[RelayCommand]
private void SwitchToTable()
{
WeakReferenceMessenger.Default.Send(new NavigateToPlossTableMessage(ScenarioId));
}
public async Task LoadDataAsync()
{
// TODO: 从数据库加载 Ploss 数据并构建图表
Series = new ISeries[]
if (IsLoading) return; // 防并发
IsLoading = true;
LoadingMessage = "正在加载数据...";
try
{
new LineSeries<double>
var records = await _scenarioService.GetPlossRecordsByScenarioIdAsync(ScenarioId);
if (records.Count == 0)
{
Name = "Ploss",
Values = new double[] { -2882, -3500, -4200, -3800, -4500 }
},
new LineSeries<double>
{
Name = "Threshold",
Values = new double[] { -3000, -3000, -3000, -3000, -3000 }
Series = Array.Empty<ISeries>();
_logger.LogWarning("未找到 Ploss 数据: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = "无数据";
return;
}
};
// 构建 Ploss 曲线 (Field9) 和 Threshold 阈值线 (Field10)
var plossValues = records.Select(r => (double)r.Field9).ToArray();
var thresholdValues = records.Select(r => (double)r.Field10).ToArray();
var triggerLabels = records.Select(r => r.Field11.ToString()).ToArray();
Series = new ISeries[]
{
new LineSeries<double>
{
Name = "Ploss",
Values = plossValues,
Fill = null,
GeometrySize = 5
},
new LineSeries<double>
{
Name = "Threshold",
Values = thresholdValues,
Fill = null,
GeometrySize = 0,
LineSmoothness = 0 // 直线
}
};
// 更新 X 轴标签
XAxes = new Axis[]
{
new Axis
{
Name = "Trigger Count",
Labels = triggerLabels
}
};
_logger.LogInformation("加载 Ploss 图表数据成功: ScenarioId={ScenarioId}, 记录数={Count}", ScenarioId, records.Count);
LoadingMessage = "";
}
catch (Exception ex)
{
_logger.LogError(ex, "加载 Ploss 图表数据失败: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = $"加载失败: {ex.Message}";
}
finally
{
IsLoading = false;
}
}
}

View File

@@ -0,0 +1,87 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using WCTDataMiner.Core.Models;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
/// <summary>
/// Ploss 数据表格 ViewModel
/// </summary>
public partial class PlossTableViewModel : ObservableObject
{
private readonly ScenarioService _scenarioService;
private readonly ILogger<PlossTableViewModel> _logger;
[ObservableProperty]
private Guid _scenarioId;
[ObservableProperty]
private ObservableCollection<PlossRecord> _records = new();
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _loadingMessage = "";
public PlossTableViewModel(
ScenarioService scenarioService,
ILogger<PlossTableViewModel> logger)
{
_scenarioService = scenarioService;
_logger = logger;
}
/// <summary>
/// ScenarioId 属性变更时自动触发数据加载
/// </summary>
partial void OnScenarioIdChanged(Guid value)
{
if (value != Guid.Empty)
{
_ = LoadDataAsync().ContinueWith(t =>
{
if (t.Exception != null)
{
_logger.LogError(t.Exception, "LoadDataAsync 失败: ScenarioId={ScenarioId}", value);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
public async Task LoadDataAsync()
{
if (IsLoading) return; // 防并发
IsLoading = true;
LoadingMessage = "正在加载数据...";
try
{
var records = await _scenarioService.GetPlossRecordsByScenarioIdAsync(ScenarioId);
Records = new ObservableCollection<PlossRecord>(records);
_logger.LogInformation("加载 Ploss 表格数据成功: ScenarioId={ScenarioId}, 记录数={Count}", ScenarioId, records.Count);
LoadingMessage = "";
}
catch (Exception ex)
{
_logger.LogError(ex, "加载 Ploss 表格数据失败: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = $"加载失败: {ex.Message}";
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private void SwitchToChart()
{
WeakReferenceMessenger.Default.Send(new NavigateToPlossChartMessage(ScenarioId));
}
}

View File

@@ -1,6 +1,11 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
@@ -9,8 +14,11 @@ namespace WCTDataMiner.Wpf.ViewModels;
/// </summary>
public partial class QfodChartViewModel : ObservableObject
{
private readonly ScenarioService _scenarioService;
private readonly ILogger<QfodChartViewModel> _logger;
[ObservableProperty]
private int _scenarioId;
private Guid _scenarioId;
[ObservableProperty]
private ISeries[] _series;
@@ -21,29 +29,116 @@ public partial class QfodChartViewModel : ObservableObject
[ObservableProperty]
private Axis[] _yAxes;
public QfodChartViewModel()
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _loadingMessage = "";
public QfodChartViewModel(
ScenarioService scenarioService,
ILogger<QfodChartViewModel> logger)
{
_scenarioService = scenarioService;
_logger = logger;
// 初始化图表配置
Series = Array.Empty<ISeries>();
XAxes = new Axis[] { new Axis { Name = "Coil Index" } };
YAxes = new Axis[] { new Axis { Name = "Q Value" } };
}
/// <summary>
/// ScenarioId 属性变更时自动触发数据加载
/// </summary>
partial void OnScenarioIdChanged(Guid value)
{
if (value != Guid.Empty)
{
_ = LoadDataAsync().ContinueWith(t =>
{
if (t.Exception != null)
{
_logger.LogError(t.Exception, "LoadDataAsync 失败: ScenarioId={ScenarioId}", value);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
[RelayCommand]
private void SwitchToTable()
{
WeakReferenceMessenger.Default.Send(new NavigateToQfodTableMessage(ScenarioId));
}
public async Task LoadDataAsync()
{
// TODO: 从数据库加载 Qfod 数据并构建图表
Series = new ISeries[]
if (IsLoading) return; // 防并发
IsLoading = true;
LoadingMessage = "正在加载数据...";
try
{
new LineSeries<double>
var records = await _scenarioService.GetQfodRecordsByScenarioIdAsync(ScenarioId);
if (records.Count == 0)
{
Name = "DeltaQ",
Values = new double[] { 25, 30, 28 }
},
new LineSeries<double>
{
Name = "CurrentQ",
Values = new double[] { 45.5, 50.2, 48.8 }
Series = Array.Empty<ISeries>();
_logger.LogWarning("未找到 Qfod 数据: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = "无数据";
return;
}
};
// 构建 DeltaQ、CurrentQ、RawQ 三条曲线
var deltaQValues = records.Select(r => (double)r.DeltaQ).ToArray();
var currentQValues = records.Select(r => (double)r.CurrentQ).ToArray();
var rawQValues = records.Select(r => (double)r.RawQ).ToArray();
var coilLabels = records.Select(r => $"{r.ChargerIndex}-{r.CoilIndex}").ToArray();
Series = new ISeries[]
{
new LineSeries<double>
{
Name = "DeltaQ",
Values = deltaQValues,
GeometrySize = 5
},
new LineSeries<double>
{
Name = "CurrentQ",
Values = currentQValues,
GeometrySize = 5
},
new LineSeries<double>
{
Name = "RawQ",
Values = rawQValues,
GeometrySize = 5
}
};
// 更新 X 轴标签
XAxes = new Axis[]
{
new Axis
{
Name = "Charger-Coil Index",
Labels = coilLabels
}
};
_logger.LogInformation("加载 Qfod 图表数据成功: ScenarioId={ScenarioId}, 记录数={Count}", ScenarioId, records.Count);
LoadingMessage = "";
}
catch (Exception ex)
{
_logger.LogError(ex, "加载 Qfod 图表数据失败: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = $"加载失败: {ex.Message}";
}
finally
{
IsLoading = false;
}
}
}

View File

@@ -0,0 +1,87 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using WCTDataMiner.Core.Models;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
/// <summary>
/// Qfod 数据表格 ViewModel
/// </summary>
public partial class QfodTableViewModel : ObservableObject
{
private readonly ScenarioService _scenarioService;
private readonly ILogger<QfodTableViewModel> _logger;
[ObservableProperty]
private Guid _scenarioId;
[ObservableProperty]
private ObservableCollection<QfodRecord> _records = new();
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _loadingMessage = "";
public QfodTableViewModel(
ScenarioService scenarioService,
ILogger<QfodTableViewModel> logger)
{
_scenarioService = scenarioService;
_logger = logger;
}
/// <summary>
/// ScenarioId 属性变更时自动触发数据加载
/// </summary>
partial void OnScenarioIdChanged(Guid value)
{
if (value != Guid.Empty)
{
_ = LoadDataAsync().ContinueWith(t =>
{
if (t.Exception != null)
{
_logger.LogError(t.Exception, "LoadDataAsync 失败: ScenarioId={ScenarioId}", value);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
public async Task LoadDataAsync()
{
if (IsLoading) return; // 防并发
IsLoading = true;
LoadingMessage = "正在加载数据...";
try
{
var records = await _scenarioService.GetQfodRecordsByScenarioIdAsync(ScenarioId);
Records = new ObservableCollection<QfodRecord>(records);
_logger.LogInformation("加载 Qfod 表格数据成功: ScenarioId={ScenarioId}, 记录数={Count}", ScenarioId, records.Count);
LoadingMessage = "";
}
catch (Exception ex)
{
_logger.LogError(ex, "加载 Qfod 表格数据失败: ScenarioId={ScenarioId}", ScenarioId);
LoadingMessage = $"加载失败: {ex.Message}";
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private void SwitchToChart()
{
WeakReferenceMessenger.Default.Send(new NavigateToQfodChartMessage(ScenarioId));
}
}

View File

@@ -1,22 +1,163 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using WCTDataMiner.Core.Services;
using WCTDataMiner.Wpf.Messages;
namespace WCTDataMiner.Wpf.ViewModels;
/// <summary>
/// 场景列表 ViewModel
/// </summary>
public partial class ScenarioListViewModel : ObservableObject
public partial class ScenarioListViewModel : ObservableObject, IRecipient<DataImportedMessage>
{
private readonly ScenarioService _scenarioService;
private readonly ILogger<ScenarioListViewModel> _logger;
[ObservableProperty]
private List<ScenarioItem> _scenarios;
private ObservableCollection<ScenarioItem> _scenarios = new();
[ObservableProperty]
private ScenarioItem? _selectedScenario;
public ScenarioListViewModel()
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _loadingMessage = "";
public ScenarioListViewModel(
ScenarioService scenarioService,
ILogger<ScenarioListViewModel> logger)
{
// TODO: 从数据库加载场景列表
_scenarios = new List<ScenarioItem>();
_scenarioService = scenarioService;
_logger = logger;
// 注册消息订阅,接收导入完成通知
WeakReferenceMessenger.Default.Register<DataImportedMessage>(this);
}
/// <summary>
/// 从数据库加载场景列表
/// </summary>
public async Task LoadScenariosAsync()
{
// 避免并发加载
if (IsLoading)
{
_logger.LogWarning("跳过重复加载请求:当前正在加载");
return;
}
IsLoading = true;
LoadingMessage = "正在加载场景列表...";
try
{
var scenarios = await _scenarioService.GetAllScenariosAsync();
// 批量更新,避免逐项 Add 导致频繁 UI 刷新
var items = scenarios.Select(s => new ScenarioItem
{
Id = s.Id,
TxPanel = s.TxPanel?.Name ?? "",
TxHardware = s.TxHardware?.Version ?? "",
TxSoftware = s.TxSoftware?.Version ?? "",
RxType = s.RxType?.Name ?? "",
TestDate = s.TestDate.ToString("yyyy-MM-dd"),
QfodCount = s.QfodCount,
PlossCount = s.PlossCount
}).ToList();
Scenarios = new ObservableCollection<ScenarioItem>(items);
}
catch (Exception ex)
{
_logger.LogError(ex, "加载场景列表失败");
LoadingMessage = $"加载失败: {ex.Message}";
return;
}
finally
{
IsLoading = false;
LoadingMessage = "";
}
}
/// <summary>
/// 根据筛选条件加载场景
/// </summary>
public async Task LoadScenariosByFilterAsync(
string? txPanel = null,
string? txHardware = null,
string? txSoftware = null,
string? rxType = null)
{
// 避免并发加载
if (IsLoading)
{
_logger.LogWarning("跳过重复筛选请求:当前正在加载");
return;
}
IsLoading = true;
LoadingMessage = "正在筛选场景...";
try
{
var scenarios = await _scenarioService.GetScenariosByFilterAsync(
txPanel, txHardware, txSoftware, rxType);
// 批量更新,避免逐项 Add 导致频繁 UI 刷新
var items = scenarios.Select(s => new ScenarioItem
{
Id = s.Id,
TxPanel = s.TxPanel?.Name ?? "",
TxHardware = s.TxHardware?.Version ?? "",
TxSoftware = s.TxSoftware?.Version ?? "",
RxType = s.RxType?.Name ?? "",
TestDate = s.TestDate.ToString("yyyy-MM-dd"),
QfodCount = s.QfodCount,
PlossCount = s.PlossCount
}).ToList();
Scenarios = new ObservableCollection<ScenarioItem>(items);
}
catch (Exception ex)
{
_logger.LogError(ex, "筛选场景失败");
LoadingMessage = $"筛选失败: {ex.Message}";
return;
}
finally
{
IsLoading = false;
LoadingMessage = "";
}
}
/// <summary>
/// 接收数据导入完成消息,自动刷新列表
/// </summary>
public async void Receive(DataImportedMessage message)
{
try
{
await LoadScenariosAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "接收数据导入消息后刷新场景列表失败");
}
}
/// <summary>
/// 获取可用的筛选选项
/// </summary>
public async Task<FilterOptions> GetFilterOptionsAsync()
{
return await _scenarioService.GetFilterOptionsAsync();
}
}
@@ -25,7 +166,7 @@ public partial class ScenarioListViewModel : ObservableObject
/// </summary>
public class ScenarioItem
{
public int Id { get; set; }
public Guid Id { get; set; }
public string TxPanel { get; set; } = "";
public string TxHardware { get; set; } = "";
public string TxSoftware { get; set; } = "";

View File

@@ -9,16 +9,21 @@
d:DesignHeight="400" d:DesignWidth="600">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Ploss 功率损耗趋势图" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="18" Margin="5,0,0,0"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="14" Foreground="Gray" Margin="10,5,0,0"/>
</StackPanel>
<lvc:CartesianChart Grid.Row="1"
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,10">
<Button Content="切换到表格" Command="{Binding SwitchToTableCommand}" Padding="10,5"/>
</StackPanel>
<lvc:CartesianChart Grid.Row="2"
Series="{Binding Series}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"

View File

@@ -0,0 +1,79 @@
<UserControl x:Class="WCTDataMiner.Wpf.Views.PlossTableView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="400" d:DesignWidth="900">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 标题区域 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Ploss 数据表格" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="14" Foreground="Gray" Margin="10,5,0,0"/>
</StackPanel>
<!-- 操作按钮 -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,10">
<Button Content="切换到图表" Command="{Binding SwitchToChartCommand}" Padding="10,5" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Records.Count, StringFormat='共 {0} 条记录'}" VerticalAlignment="Center" Foreground="Gray"/>
<!-- 加载状态/错误消息显示 -->
<TextBlock Text="{Binding LoadingMessage}" VerticalAlignment="Center" Foreground="Orange" Margin="20,0,0,0"/>
</StackPanel>
<!-- 数据表格 -->
<DataGrid Grid.Row="2"
ItemsSource="{Binding Records}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
AlternatingRowBackground="#F9F9F9"
CanUserAddRows="False"
HeadersVisibility="Column"
HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
EnableRowVirtualization="True"
EnableColumnVirtualization="True">
<DataGrid.Columns>
<DataGridTextColumn Header="PowLoss" Binding="{Binding PowLoss}" Width="80"/>
<DataGridTextColumn Header="DeltaP" Binding="{Binding DeltaP}" Width="80"/>
<DataGridTextColumn Header="RX_TYPE" Binding="{Binding Field1}" Width="80"/>
<DataGridTextColumn Header="Field2" Binding="{Binding Field2}" Width="70"/>
<DataGridTextColumn Header="Field3" Binding="{Binding Field3}" Width="70"/>
<DataGridTextColumn Header="RX power" Binding="{Binding Field4}" Width="80"/>
<DataGridTextColumn Header="TX power" Binding="{Binding Field5}" Width="80"/>
<DataGridTextColumn Header="Field6" Binding="{Binding Field6}" Width="70"/>
<DataGridTextColumn Header="Field7" Binding="{Binding Field7}" Width="80">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Blue"/>
<Setter Property="FontWeight" Value="Bold"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Field8" Binding="{Binding Field8}" Width="80">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Red"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="ploss" Binding="{Binding Field9}" Width="70"/>
<DataGridTextColumn Header="threshold" Binding="{Binding Field10}" Width="80"/>
<DataGridTextColumn Header="Margin" Binding="{Binding Margin}" Width="80"/>
<DataGridTextColumn Header="触发次数" Binding="{Binding Field11}" Width="80"/>
<DataGridTextColumn Header="PFOD result" Binding="{Binding Field12}" Width="90"/>
<DataGridTextColumn Header="Field13" Binding="{Binding Field13}" Width="70"/>
<DataGridTextColumn Header="Field14" Binding="{Binding Field14}" Width="70"/>
<DataGridTextColumn Header="Field15" Binding="{Binding Field15}" Width="70"/>
<DataGridTextColumn Header="Field16" Binding="{Binding Field16}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,14 @@
using System.Windows.Controls;
namespace WCTDataMiner.Wpf.Views;
/// <summary>
/// PlossTableView.xaml 的交互逻辑
/// </summary>
public partial class PlossTableView : UserControl
{
public PlossTableView()
{
InitializeComponent();
}
}

View File

@@ -9,16 +9,21 @@
d:DesignHeight="400" d:DesignWidth="600">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Qfod Q值变化图" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="18" Margin="5,0,0,0"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="14" Foreground="Gray" Margin="10,5,0,0"/>
</StackPanel>
<lvc:CartesianChart Grid.Row="1"
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,10">
<Button Content="切换到表格" Command="{Binding SwitchToTableCommand}" Padding="10,5"/>
</StackPanel>
<lvc:CartesianChart Grid.Row="2"
Series="{Binding Series}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"

View File

@@ -0,0 +1,61 @@
<UserControl x:Class="WCTDataMiner.Wpf.Views.QfodTableView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="400" d:DesignWidth="600">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 标题区域 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Qfod 数据表格" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding ScenarioId, StringFormat=' - 场景 {0}'}" FontSize="14" Foreground="Gray" Margin="10,5,0,0"/>
</StackPanel>
<!-- 操作按钮 -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,10">
<Button Content="切换到图表" Command="{Binding SwitchToChartCommand}" Padding="10,5" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Records.Count, StringFormat='共 {0} 条记录'}" VerticalAlignment="Center" Foreground="Gray"/>
<!-- 加载状态/错误消息显示 -->
<TextBlock Text="{Binding LoadingMessage}" VerticalAlignment="Center" Foreground="Orange" Margin="20,0,0,0"/>
</StackPanel>
<!-- 数据表格 -->
<DataGrid Grid.Row="2"
ItemsSource="{Binding Records}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
AlternatingRowBackground="#F9F9F9"
CanUserAddRows="False"
HeadersVisibility="Column"
HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
EnableRowVirtualization="True"
EnableColumnVirtualization="True">
<DataGrid.Columns>
<DataGridTextColumn Header="ChargerIdx" Binding="{Binding ChargerIndex}" Width="100"/>
<DataGridTextColumn Header="CoilIdx" Binding="{Binding CoilIndex}" Width="80"/>
<DataGridTextColumn Header="DeltaQ" Binding="{Binding DeltaQ}" Width="80">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Blue"/>
<Setter Property="FontWeight" Value="Bold"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="CurrentQ" Binding="{Binding CurrentQ}" Width="80"/>
<DataGridTextColumn Header="RawQ" Binding="{Binding RawQ}" Width="80"/>
<DataGridTextColumn Header="FodType" Binding="{Binding FodType}" Width="80"/>
<DataGridTextColumn Header="FieldF" Binding="{Binding FieldF}" Width="80"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,14 @@
using System.Windows.Controls;
namespace WCTDataMiner.Wpf.Views;
/// <summary>
/// QfodTableView.xaml 的交互逻辑
/// </summary>
public partial class QfodTableView : UserControl
{
public QfodTableView()
{
InitializeComponent();
}
}

View File

@@ -1,5 +1,8 @@
using CommunityToolkit.Mvvm.Messaging;
using System.Windows;
using System.Windows.Controls;
using WCTDataMiner.Wpf.Messages;
using WCTDataMiner.Wpf.ViewModels;
namespace WCTDataMiner.Wpf.Views;
@@ -12,19 +15,17 @@ public partial class ScenarioListView : UserControl
private void OnViewPlossChart(object sender, RoutedEventArgs e)
{
// TODO: 导航到 Ploss 图表页面
if (DataContext is ViewModels.ScenarioListViewModel vm && vm.SelectedScenario != null)
if (DataContext is ScenarioListViewModel vm && vm.SelectedScenario != null)
{
// Navigation logic
WeakReferenceMessenger.Default.Send(new NavigateToPlossChartMessage(vm.SelectedScenario.Id));
}
}
private void OnViewQfodChart(object sender, RoutedEventArgs e)
{
// TODO: 导航到 Qfod 图表页面
if (DataContext is ViewModels.ScenarioListViewModel vm && vm.SelectedScenario != null)
if (DataContext is ScenarioListViewModel vm && vm.SelectedScenario != null)
{
// Navigation logic
WeakReferenceMessenger.Default.Send(new NavigateToQfodChartMessage(vm.SelectedScenario.Id));
}
}
}

View File

@@ -20,6 +20,13 @@
<!-- DI -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<!-- Logging -->
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -17,31 +17,37 @@ public class Program
{
public static async Task<int> Main(string[] args)
{
// 配置 Serilog
// 1. 构建引导配置(用于 Serilog 初始化)
var bootstrapConfig = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// 2. 创建引导日志器
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("WCTDataMiner", Serilog.Events.LogEventLevel.Debug)
.WriteTo.Console()
.WriteTo.File(
"./data/logs/wctminer-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
.ReadFrom.Configuration(bootstrapConfig)
.Enrich.FromLogContext()
.CreateBootstrapLogger();
try
{
var builder = Host.CreateApplicationBuilder(args);
// 配置
builder.Configuration.AddJsonFile("appsettings.json", optional: false);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
builder.Configuration.AddEnvironmentVariables();
builder.Configuration.AddCommandLine(args);
// 3. 使用 AddConfiguration 添加已构建的配置(关键修复)
builder.Configuration.AddConfiguration(bootstrapConfig);
// Serilog
builder.Services.AddSerilog();
// 4. 配置 Serilog
builder.Services.AddSerilog((_, loggerConfig) =>
{
loggerConfig
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext();
});
// 注册服务
// 5. 注册服务
builder.Services.AddDatabaseServices(builder.Configuration);
builder.Services.AddApplicationServices();

View File

@@ -18,9 +18,13 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<!-- EF Core Tools -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
<!-- Logging -->
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>