Compare commits
20 Commits
6e183f84e4
...
5189c665c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5189c665c3 | ||
|
|
66402d7e0b | ||
|
|
7addc9f551 | ||
|
|
b422358c3a | ||
|
|
cf4f24fde3 | ||
|
|
cee3536507 | ||
|
|
eaac759a3f | ||
|
|
52435ab8cd | ||
|
|
440088648b | ||
|
|
d4ea6b454f | ||
|
|
4bff6e1e87 | ||
|
|
f858c38981 | ||
|
|
f771abb6bf | ||
|
|
d3e4b2b8de | ||
|
|
fa0b93446f | ||
|
|
c9be8f4ed2 | ||
|
|
266317d948 | ||
|
|
0f7c0d9060 | ||
|
|
b2442b9c6e | ||
|
|
76b29e39a2 |
37
WCTDataMiner.sln
Normal file
37
WCTDataMiner.sln
Normal 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
|
||||
@@ -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>
|
||||
@@ -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. 核心判定逻辑
|
||||
|
||||
@@ -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)
|
||||
|
||||
206
docs/requirement/WCT数据需求分析.md
Normal file
206
docs/requirement/WCT数据需求分析.md
Normal 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 数据是孤立的
|
||||
BIN
docs/requirement/无线充测试log保存及管理方案-20260702.pdf
Normal file
BIN
docs/requirement/无线充测试log保存及管理方案-20260702.pdf
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
/// 解析日志文件名,提取场景信息
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
157
src/WCTDataMiner.Core/Security/PathValidator.cs
Normal file
157
src/WCTDataMiner.Core/Security/PathValidator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
54
src/WCTDataMiner.Core/Security/SecurityConstants.cs
Normal file
54
src/WCTDataMiner.Core/Security/SecurityConstants.cs
Normal 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"
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
37
src/WCTDataMiner.Wpf/Configuration/ConfigurationLoader.cs
Normal file
37
src/WCTDataMiner.Wpf/Configuration/ConfigurationLoader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/WCTDataMiner.Wpf/Configuration/DefaultAppSettings.cs
Normal file
30
src/WCTDataMiner.Wpf/Configuration/DefaultAppSettings.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
31
src/WCTDataMiner.Wpf/Messages/DataImportedMessage.cs
Normal file
31
src/WCTDataMiner.Wpf/Messages/DataImportedMessage.cs
Normal 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);
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/WCTDataMiner.Wpf/ViewModels/PlossTableViewModel.cs
Normal file
87
src/WCTDataMiner.Wpf/ViewModels/PlossTableViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/WCTDataMiner.Wpf/ViewModels/QfodTableViewModel.cs
Normal file
87
src/WCTDataMiner.Wpf/ViewModels/QfodTableViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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}"
|
||||
|
||||
79
src/WCTDataMiner.Wpf/Views/PlossTableView.xaml
Normal file
79
src/WCTDataMiner.Wpf/Views/PlossTableView.xaml
Normal 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>
|
||||
14
src/WCTDataMiner.Wpf/Views/PlossTableView.xaml.cs
Normal file
14
src/WCTDataMiner.Wpf/Views/PlossTableView.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
61
src/WCTDataMiner.Wpf/Views/QfodTableView.xaml
Normal file
61
src/WCTDataMiner.Wpf/Views/QfodTableView.xaml
Normal 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>
|
||||
14
src/WCTDataMiner.Wpf/Views/QfodTableView.xaml.cs
Normal file
14
src/WCTDataMiner.Wpf/Views/QfodTableView.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user