Browse Source

eat: 重构测试场景实体命名和结构

- 修复 ScenarioType 枚举类型找不到的问题
- 重命名 TestScenarioTestCase 为 ScenarioTestCase,避免重复前缀
- 简化 ScenarioTestCase 实体,保留核心字段
- 添加执行循环次数 (LoopCount) 字段
- 更新 TestScenario 导航属性命名
- 删除旧的 TestScenarioTestCase 文件

改进:
- 更符合领域驱动设计的命名规范
- 简化实体结构,避免过度设计
- 支持测试用例在场景中的循环执行
release/web-ui-v1.0.0
root 4 months ago
parent
commit
16319ed849
  1. 307
      src/ScenarioType_TestFlowType_对比分析.md
  2. 4
      src/X1.Application/DependencyInjection.cs
  3. 15
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommand.cs
  4. 78
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommandHandler.cs
  5. 348
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/TestCaseFlowBuilder.cs
  6. 70
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdQueryHandler.cs
  7. 15
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdResponse.cs
  8. 101
      src/X1.Domain/Entities/TestCase/ScenarioTestCase.cs
  9. 66
      src/X1.Domain/Entities/TestCase/ScenarioType.cs
  10. 1
      src/X1.Domain/Entities/TestCase/TestCaseFlow.cs
  11. 108
      src/X1.Domain/Entities/TestCase/TestScenario.cs
  12. 57
      src/X1.Domain/Models/DeviceRegistrationFormData.cs
  13. 8
      src/X1.Domain/Repositories/TestCase/IImsiRegistrationRecordRepository.cs
  14. 12
      src/X1.Infrastructure/Repositories/TestCase/ImsiRegistrationRecordRepository.cs
  15. 185
      src/X1.WebUI/docs/ReactFlow_Handle_Configuration.md
  16. 272
      src/X1.WebUI/src/components/testcases/DeviceRegistrationDrawer.tsx
  17. 404
      src/X1.WebUI/src/components/testcases/FormTypeDrawer.tsx
  18. 177
      src/X1.WebUI/src/components/testcases/NetworkConnectivityDrawer.tsx
  19. 197
      src/X1.WebUI/src/components/testcases/NetworkPerformanceDrawer.tsx
  20. 243
      src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx
  21. 181
      src/X1.WebUI/src/components/testcases/VoiceCallDrawer.tsx
  22. 2
      src/X1.WebUI/src/config/core/env.config.ts
  23. 455
      src/X1.WebUI/src/pages/testcases/ReactFlowDesigner.tsx
  24. 5
      src/X1.WebUI/src/pages/testcases/TestCasesView.tsx
  25. 87
      src/X1.WebUI/src/pages/teststeps/TestStepsTable.tsx
  26. 6
      src/X1.WebUI/src/services/testcaseService.ts
  27. 109
      src/X1.WebUI/src/styles/reactflow.css
  28. 77
      src/X1.WebUI/src/types/formTypes.ts
  29. 50
      src/X1.WebUI/src/utils/formDataUtils.ts
  30. 661
      src/modify.md
  31. 305
      src/场景与用例类型分析.md

307
src/ScenarioType_TestFlowType_对比分析.md

@ -0,0 +1,307 @@
# ScenarioType 与 TestFlowType 作用范围对比分析
## 概述
本文档详细分析了测试场景类型(ScenarioType)和测试用例流程类型(TestFlowType)的作用范围、业务价值以及在实际项目中的应用差异。
## 1. 概念层次对比
### 1.1 ScenarioType(场景类型)- 业务层面
**定义**:场景类型关注的是**业务目标和测试目的**,定义了为什么要进行测试以及测试要达到什么目标。
```csharp
public enum ScenarioType
{
Functional = 1, // 功能测试 - 验证业务功能
Performance = 2, // 性能测试 - 验证系统性能
Stress = 3, // 压力测试 - 验证系统极限
Regression = 4, // 回归测试 - 验证修改不影响现有功能
Integration = 5, // 集成测试 - 验证系统间集成
UAT = 6, // 用户验收测试 - 用户验收
Other = 99 // 其他类型
}
```
### 1.2 TestFlowType(用例流程类型)- 技术层面
**定义**:用例流程类型关注的是**技术实现和具体操作**,定义了如何进行测试以及具体的测试操作流程。
```csharp
public enum TestFlowType
{
Registration = 1, // 注册测试 - 网络注册流程
Voice = 2, // 语音测试 - 语音通话流程
Data = 3 // 数据测试 - 数据传输流程
}
```
## 2. 作用范围对比
| 维度 | ScenarioType | TestFlowType |
|------|-------------|--------------|
| **关注点** | 业务目标和测试目的 | 技术实现和具体操作 |
| **层次** | 宏观业务层面 | 微观技术层面 |
| **范围** | 整个测试场景的目标 | 单个用例流程的操作 |
| **决策** | 为什么测试 | 怎么测试 |
| **时间维度** | 长期测试策略 | 短期执行计划 |
| **资源需求** | 整体资源规划 | 具体资源分配 |
| **成功标准** | 业务目标达成 | 技术指标完成 |
## 3. 详细作用范围分析
### 3.1 ScenarioType 的作用范围
#### 3.1.1 测试策略制定
- **功能测试**:验证业务流程的完整性和正确性
- **性能测试**:验证系统在特定负载下的性能表现
- **压力测试**:验证系统在极限条件下的稳定性
- **回归测试**:验证代码修改对现有功能的影响
- **集成测试**:验证系统组件间的协作
- **UAT测试**:验证系统是否满足用户需求
#### 3.1.2 资源分配决策
- **设备选择**:根据测试类型选择合适数量的设备
- **环境配置**:配置适合的测试环境
- **人员安排**:分配相应的测试人员
- **时间规划**:制定合理的测试时间计划
#### 3.1.3 质量评估标准
- **功能测试**:功能正确性、完整性、易用性
- **性能测试**:响应时间、吞吐量、资源使用率
- **压力测试**:系统稳定性、故障恢复能力
- **回归测试**:现有功能稳定性、兼容性
### 3.2 TestFlowType 的作用范围
#### 3.2.1 用例流程分类
- **注册流程**:设备网络注册相关的测试操作
- **语音流程**:语音通话相关的测试操作
- **数据流程**:数据传输相关的测试操作
#### 3.2.2 执行环境配置
- **注册环境**:网络注册所需的网络环境
- **语音环境**:语音通话所需的通信环境
- **数据环境**:数据传输所需的网络环境
#### 3.2.3 表单类型映射
- **注册流程** → 设备注册表单
- **语音流程** → 语音通话表单
- **数据流程** → 网络性能表单
## 4. 业务价值体现
### 4.1 ScenarioType 的业务价值
#### 4.1.1 测试策略指导
```csharp
// 根据场景类型制定不同的测试策略
public ITestStrategy GetTestStrategy(ScenarioType scenarioType)
{
return scenarioType switch
{
ScenarioType.Functional => new FunctionalTestStrategy(),
ScenarioType.Performance => new PerformanceTestStrategy(),
ScenarioType.Stress => new StressTestStrategy(),
ScenarioType.Regression => new RegressionTestStrategy(),
ScenarioType.Integration => new IntegrationTestStrategy(),
ScenarioType.UAT => new UATTestStrategy(),
_ => new DefaultTestStrategy()
};
}
```
#### 4.1.2 资源分配决策
```csharp
// 根据场景类型分配不同的测试资源
public TestResource AllocateResource(TestScenario scenario)
{
return scenario.Type switch
{
ScenarioType.Performance => new PerformanceTestResource(), // 高性能设备
ScenarioType.Stress => new StressTestResource(), // 多设备并发
ScenarioType.Functional => new FunctionalTestResource(), // 标准设备
_ => new DefaultTestResource()
};
}
```
#### 4.1.3 质量评估标准
```csharp
// 根据场景类型设定不同的质量评估标准
public QualityCriteria GetQualityCriteria(TestScenario scenario)
{
return scenario.Type switch
{
ScenarioType.Functional => new FunctionalQualityCriteria(), // 功能正确性
ScenarioType.Performance => new PerformanceQualityCriteria(), // 性能指标
ScenarioType.Stress => new StressQualityCriteria(), // 稳定性指标
_ => new DefaultQualityCriteria()
};
}
```
### 4.2 TestFlowType 的业务价值
#### 4.2.1 用例流程分类管理
```csharp
// 根据流程类型进行用例管理
public List<TestCaseFlow> GetTestCaseFlowsByType(TestFlowType type)
{
return context.TestCaseFlows
.Where(f => f.Type == type && f.IsEnabled)
.OrderBy(f => f.Name)
.ToList();
}
```
#### 4.2.2 执行环境配置
```csharp
// 根据流程类型配置不同的执行环境
public TestEnvironment ConfigureEnvironment(TestFlowType flowType)
{
return flowType switch
{
TestFlowType.Registration => new RegistrationEnvironment(), // 注册环境
TestFlowType.Voice => new VoiceEnvironment(), // 语音环境
TestFlowType.Data => new DataEnvironment(), // 数据环境
_ => new DefaultEnvironment()
};
}
```
#### 4.2.3 表单类型映射
```csharp
// 根据流程类型确定需要的表单类型
public FormType GetRequiredFormType(TestFlowType flowType)
{
return flowType switch
{
TestFlowType.Registration => FormType.DeviceRegistrationForm,
TestFlowType.Voice => FormType.VoiceCallForm,
TestFlowType.Data => FormType.NetworkPerformanceForm,
_ => FormType.NoForm
};
}
```
## 5. 实际应用场景
### 5.1 功能测试场景示例
```csharp
// 功能测试场景:验证用户注册完整流程
var functionalScenario = TestScenario.Create(
"SC_FUNC_001",
"用户注册功能测试",
ScenarioType.Functional,
"admin",
"验证用户注册的完整业务流程"
);
// 包含注册、语音、数据三个流程
functionalScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_registration", // Registration 类型
ExecutionOrder = 1
});
functionalScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_voice_call", // Voice 类型
ExecutionOrder = 2
});
functionalScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_data_test", // Data 类型
ExecutionOrder = 3
});
```
### 5.2 性能测试场景示例
```csharp
// 性能测试场景:验证系统并发处理能力
var performanceScenario = TestScenario.Create(
"SC_PERF_001",
"系统性能测试",
ScenarioType.Performance,
"admin",
"验证系统在高并发下的性能表现"
);
// 主要包含数据流程的性能测试
performanceScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_data_performance", // Data 类型
ExecutionOrder = 1
});
```
### 5.3 回归测试场景示例
```csharp
// 回归测试场景:验证修改不影响现有功能
var regressionScenario = TestScenario.Create(
"SC_REGR_001",
"回归测试",
ScenarioType.Regression,
"admin",
"验证代码修改后现有功能正常"
);
// 包含所有类型的流程进行回归测试
regressionScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_regression_registration", // Registration 类型
ExecutionOrder = 1
});
regressionScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_regression_voice", // Voice 类型
ExecutionOrder = 2
});
regressionScenario.TestScenarioTestCases.Add(new TestScenarioTestCase
{
TestCaseFlowId = "flow_regression_data", // Data 类型
ExecutionOrder = 3
});
```
## 6. 设计优势
### 6.1 职责分离
- **ScenarioType**:专注于业务目标和测试策略
- **TestFlowType**:专注于技术实现和具体操作
### 6.2 灵活组合
- 同一个场景可以包含不同类型的流程
- 不同类型的场景可以使用相同的流程
### 6.3 可扩展性
- 可以轻松添加新的场景类型
- 可以轻松添加新的流程类型
### 6.4 维护性
- 清晰的分类便于维护和管理
- 降低代码复杂度和耦合度
## 7. 总结
### 7.1 核心差异
- **ScenarioType**:回答"为什么测试"的问题
- **TestFlowType**:回答"怎么测试"的问题
### 7.2 业务价值
- **ScenarioType**:指导测试策略、资源分配、质量评估
- **TestFlowType**:指导用例管理、环境配置、表单映射
### 7.3 应用建议
- 在创建测试场景时,首先确定场景类型
- 在创建用例流程时,根据具体操作确定流程类型
- 在组合场景和用例时,考虑业务目标和技术实现的匹配
这种分层设计很好地体现了"场景是用例集合,用例是具体操作"的核心思想,既保证了业务目标的清晰性,又保证了技术实现的灵活性。

4
src/X1.Application/DependencyInjection.cs

@ -3,6 +3,7 @@ using MediatR;
using FluentValidation;
using X1.Application.Behaviours;
using Microsoft.Extensions.Configuration;
using X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
namespace X1.Application;
@ -41,6 +42,9 @@ public static class DependencyInjection
// 注册验证器
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
// 注册测试用例流程构建器
services.AddScoped<TestCaseFlowBuilder>();
return services;
}
}

15
src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommand.cs

@ -72,6 +72,21 @@ public class NodeData
/// 是否正在拖拽
/// </summary>
public bool? Dragging { get; set; }
/// <summary>
/// 表单数据
/// </summary>
public object? FormData { get; set; }
/// <summary>
/// 表单类型
/// </summary>
public int? FormType { get; set; }
/// <summary>
/// 是否开启表单
/// </summary>
public bool? IsFormEnabled { get; set; }
}
/// <summary>

78
src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommandHandler.cs

@ -3,8 +3,8 @@ using Microsoft.Extensions.Logging;
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
using X1.Domain.Repositories;
using X1.Domain.Repositories.TestCase;
using X1.Domain.Repositories.Base;
using X1.Domain.Repositories.TestCase;
using X1.Domain.Services;
@ -16,26 +16,22 @@ namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
public class CreateTestCaseFlowCommandHandler : IRequestHandler<CreateTestCaseFlowCommand, OperationResult<CreateTestCaseFlowResponse>>
{
private readonly ITestCaseFlowRepository _testCaseFlowRepository;
private readonly ITestCaseNodeRepository _testCaseNodeRepository;
private readonly ITestCaseEdgeRepository _testCaseEdgeRepository;
private readonly TestCaseFlowBuilder _testCaseFlowBuilder;
private readonly ILogger<CreateTestCaseFlowCommandHandler> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserService _currentUserService;
/// <summary>
/// 初始化命令处理器
/// </summary>
public CreateTestCaseFlowCommandHandler(
ITestCaseFlowRepository testCaseFlowRepository,
ITestCaseNodeRepository testCaseNodeRepository,
ITestCaseEdgeRepository testCaseEdgeRepository,
TestCaseFlowBuilder testCaseFlowBuilder,
ILogger<CreateTestCaseFlowCommandHandler> logger,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
{
_testCaseFlowRepository = testCaseFlowRepository;
_testCaseNodeRepository = testCaseNodeRepository;
_testCaseEdgeRepository = testCaseEdgeRepository;
_testCaseFlowBuilder = testCaseFlowBuilder;
_logger = logger;
_unitOfWork = unitOfWork;
_currentUserService = currentUserService;
@ -87,17 +83,8 @@ public class CreateTestCaseFlowCommandHandler : IRequestHandler<CreateTestCaseFl
// 保存测试用例流程到数据库
var createdFlow = await _testCaseFlowRepository.AddTestCaseFlowAsync(testCaseFlow, cancellationToken);
// 创建节点
if (request.Nodes != null && request.Nodes.Any())
{
await CreateNodesAsync(createdFlow.Id, request.Nodes, cancellationToken);
}
// 创建连线
if (request.Edges != null && request.Edges.Any())
{
await CreateEdgesAsync(createdFlow.Id, request.Edges, cancellationToken);
}
// 构建测试用例流程(创建节点和连线)
await _testCaseFlowBuilder.BuildAsync(createdFlow.Id, request.Nodes, request.Edges, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
@ -128,59 +115,6 @@ public class CreateTestCaseFlowCommandHandler : IRequestHandler<CreateTestCaseFl
}
}
/// <summary>
/// 创建测试用例节点
/// </summary>
private async Task CreateNodesAsync(string testCaseId, List<NodeData> nodes, CancellationToken cancellationToken)
{
var sequenceNumber = 1;
foreach (var nodeData in nodes)
{
var testCaseNode = TestCaseNode.Create(
testCaseId: testCaseId,
nodeId: nodeData.Id,
sequenceNumber: sequenceNumber++,
positionX: nodeData.PositionX,
positionY: nodeData.PositionY,
stepId: nodeData.StepId,
width: nodeData.Width ?? 100,
height: nodeData.Height ?? 50,
isSelected: nodeData.Selected ?? false,
positionAbsoluteX: nodeData.PositionAbsoluteX,
positionAbsoluteY: nodeData.PositionAbsoluteY,
isDragging: nodeData.Dragging ?? false);
await _testCaseNodeRepository.AddTestCaseNodeAsync(testCaseNode, cancellationToken);
}
_logger.LogInformation("成功创建 {Count} 个测试用例节点,测试用例ID: {TestCaseId}", nodes.Count, testCaseId);
}
/// <summary>
/// 创建测试用例连线
/// </summary>
private async Task CreateEdgesAsync(string testCaseId, List<EdgeData> edges, CancellationToken cancellationToken)
{
foreach (var edgeData in edges)
{
var testCaseEdge = TestCaseEdge.Create(
testCaseId: testCaseId,
edgeId: edgeData.Id,
sourceNodeId: edgeData.Source,
targetNodeId: edgeData.Target,
sourceHandle: edgeData.SourceHandle,
targetHandle: edgeData.TargetHandle,
edgeType: edgeData.Type,
condition: edgeData.Condition,
isAnimated: edgeData.Animated ?? false,
style: edgeData.Style);
await _testCaseEdgeRepository.AddTestCaseEdgeAsync(testCaseEdge, cancellationToken);
}
_logger.LogInformation("成功创建 {Count} 个测试用例连线,测试用例ID: {TestCaseId}", edges.Count, testCaseId);
}
/// <summary>

348
src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/TestCaseFlowBuilder.cs

@ -0,0 +1,348 @@
using Microsoft.Extensions.Logging;
using X1.Domain.Entities.TestCase;
using X1.Domain.Repositories.TestCase;
using X1.Domain.Repositories.Base;
using X1.Domain.Models;
using Newtonsoft.Json;
namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
/// <summary>
/// 测试用例流程构建器
/// </summary>
public class TestCaseFlowBuilder
{
private readonly ITestCaseNodeRepository _testCaseNodeRepository;
private readonly ITestCaseEdgeRepository _testCaseEdgeRepository;
private readonly IImsiRegistrationRecordRepository _imsiRegistrationRecordRepository;
private readonly ILogger<TestCaseFlowBuilder> _logger;
/// <summary>
/// 初始化测试用例流程构建器
/// </summary>
public TestCaseFlowBuilder(
ITestCaseNodeRepository testCaseNodeRepository,
ITestCaseEdgeRepository testCaseEdgeRepository,
IImsiRegistrationRecordRepository imsiRegistrationRecordRepository,
ILogger<TestCaseFlowBuilder> logger)
{
_testCaseNodeRepository = testCaseNodeRepository;
_testCaseEdgeRepository = testCaseEdgeRepository;
_imsiRegistrationRecordRepository = imsiRegistrationRecordRepository;
_logger = logger;
}
/// <summary>
/// 构建测试用例流程(创建节点和连线)
/// </summary>
/// <param name="testCaseId">测试用例ID</param>
/// <param name="nodes">节点数据</param>
/// <param name="edges">连线数据</param>
/// <param name="cancellationToken">取消令牌</param>
public async Task BuildAsync(string testCaseId, List<NodeData>? nodes, List<EdgeData>? edges, CancellationToken cancellationToken)
{
// 创建节点
if (nodes != null && nodes.Any())
{
await CreateNodesAsync(testCaseId, nodes, cancellationToken);
}
// 创建连线
if (edges != null && edges.Any())
{
await CreateEdgesAsync(testCaseId, edges, cancellationToken);
}
_logger.LogInformation("测试用例流程构建完成,ID: {TestCaseId}, 节点数量: {NodeCount}, 连线数量: {EdgeCount}",
testCaseId, nodes?.Count ?? 0, edges?.Count ?? 0);
}
/// <summary>
/// 创建测试用例节点
/// </summary>
private async Task CreateNodesAsync(string testCaseId, List<NodeData> nodes, CancellationToken cancellationToken)
{
// 预处理节点数据
var processedNodes = PreprocessNodes(nodes);
// 创建节点实体
await CreateNodeEntitiesAsync(testCaseId, processedNodes, cancellationToken);
}
/// <summary>
/// 预处理节点数据
/// </summary>
/// <param name="nodes">原始节点数据</param>
/// <returns>预处理后的节点数据</returns>
private List<ProcessedNodeData> PreprocessNodes(List<NodeData> nodes)
{
return nodes.Select(nodeData => new ProcessedNodeData
{
NodeData = nodeData,
FormDataJson = nodeData.FormData?.ToString(),
HasFormData = nodeData.IsFormEnabled == true && nodeData.FormData is not null
}).ToList();
}
/// <summary>
/// 创建节点实体
/// </summary>
/// <param name="testCaseId">测试用例ID</param>
/// <param name="processedNodes">预处理后的节点数据</param>
/// <param name="cancellationToken">取消令牌</param>
private async Task CreateNodeEntitiesAsync(string testCaseId, List<ProcessedNodeData> processedNodes, CancellationToken cancellationToken)
{
var sequenceNumber = 1;
var nodeIdMapping = new Dictionary<string, string>(); // 前端节点ID -> 数据库节点ID的映射
foreach (var processedNode in processedNodes)
{
var nodeData = processedNode.NodeData;
var testCaseNode = TestCaseNode.Create(
testCaseId: testCaseId,
nodeId: nodeData.Id,
sequenceNumber: sequenceNumber++,
positionX: nodeData.PositionX,
positionY: nodeData.PositionY,
stepId: nodeData.StepId,
width: nodeData.Width ?? 100,
height: nodeData.Height ?? 50,
isSelected: nodeData.Selected ?? false,
positionAbsoluteX: nodeData.PositionAbsoluteX,
positionAbsoluteY: nodeData.PositionAbsoluteY,
isDragging: nodeData.Dragging ?? false);
await _testCaseNodeRepository.AddTestCaseNodeAsync(testCaseNode, cancellationToken);
// 保存前端节点ID到数据库节点ID的映射
nodeIdMapping[nodeData.Id] = testCaseNode.Id;
}
// 将映射传递给表单数据处理方法
await ProcessFormDataBatchAsync(testCaseId, processedNodes, nodeIdMapping, cancellationToken);
}
/// <summary>
/// 批量处理表单数据
/// </summary>
private async Task ProcessFormDataBatchAsync(string testCaseId, List<ProcessedNodeData> processedNodes, Dictionary<string, string> nodeIdMapping, CancellationToken cancellationToken)
{
// 同步处理:组装所有需要保存的实体
var entities = BuildFormDataEntities(testCaseId, processedNodes, nodeIdMapping);
// 异步处理:批量保存到数据库
if (entities.HasAnyEntities())
{
await SaveFormDataEntitiesAsync(entities, cancellationToken);
_logger.LogInformation("成功处理表单数据,测试用例ID: {TestCaseId}", testCaseId);
}
}
/// <summary>
/// 组装表单数据实体(同步操作)
/// </summary>
private FormDataEntities BuildFormDataEntities(string testCaseId, List<ProcessedNodeData> processedNodes, Dictionary<string, string> nodeIdMapping)
{
var entities = new FormDataEntities();
foreach (var processedNode in processedNodes)
{
if (!processedNode.HasFormData || string.IsNullOrEmpty(processedNode.FormDataJson))
{
continue;
}
try
{
var nodeData = processedNode.NodeData;
// 获取数据库节点ID
if (!nodeIdMapping.TryGetValue(nodeData.Id, out var databaseNodeId))
{
_logger.LogWarning("未找到节点ID映射,前端节点ID: {NodeId}", nodeData.Id);
continue;
}
switch (nodeData.FormType)
{
case (int)FormType.DeviceRegistrationForm:
_logger.LogDebug("开始反序列化设备注册表单数据,JSON: {Json}", processedNode.FormDataJson);
// 使用 Newtonsoft.Json 处理转义的 JSON 字符串
var deviceRegistrationData = JsonConvert.DeserializeObject<DeviceRegistrationFormData>(
System.Text.Json.Nodes.JsonObject.Parse(processedNode.FormDataJson).ToString());
if (deviceRegistrationData != null)
{
_logger.LogDebug("设备注册表单数据反序列化成功,前端节点ID: {FrontendNodeId}, 数据库节点ID: {DatabaseNodeId}",
deviceRegistrationData.NodeId, databaseNodeId);
var imsiRecord = BuildImsiRegistrationRecord(testCaseId, databaseNodeId, deviceRegistrationData);
entities.ImsiRegistrationRecords.Add(imsiRecord);
}
else
{
_logger.LogWarning("设备注册表单数据反序列化返回null,前端节点ID: {NodeId}", nodeData.Id);
}
break;
// 可以在这里添加其他表单类型的处理
// case (int)FormType.NetworkConnectivityForm:
// var networkData = JsonConvert.DeserializeObject<NetworkConnectivityFormData>(
// System.Text.Json.Nodes.JsonObject.Parse(processedNode.FormDataJson).ToString());
// if (networkData != null)
// {
// var networkEntity = BuildNetworkConnectivityEntity(testCaseId, databaseNodeId, networkData);
// entities.NetworkConnectivityEntities.Add(networkEntity);
// }
// break;
default:
_logger.LogInformation("未处理的表单类型: {FormType}, 前端节点ID: {NodeId}", nodeData.FormType, nodeData.Id);
break;
}
}
catch (JsonException jsonEx)
{
_logger.LogError(jsonEx, "JSON反序列化失败,前端节点ID: {NodeId}, JSON: {Json}",
processedNode.NodeData.Id, processedNode.FormDataJson);
}
catch (Exception ex)
{
_logger.LogError(ex, "组装表单数据实体失败,前端节点ID: {NodeId}, 表单类型: {FormType}",
processedNode.NodeData.Id, processedNode.NodeData.FormType);
}
}
return entities;
}
/// <summary>
/// 批量保存表单数据实体(异步操作)
/// </summary>
private async Task SaveFormDataEntitiesAsync(FormDataEntities entities, CancellationToken cancellationToken)
{
// 保存 IMSI 注册记录
if (entities.ImsiRegistrationRecords.Any())
{
foreach (var imsiRecord in entities.ImsiRegistrationRecords)
{
await _imsiRegistrationRecordRepository.AddAsync(imsiRecord, cancellationToken);
}
}
// 可以在这里添加其他实体类型的保存
// if (entities.NetworkConnectivityEntities.Any())
// {
// foreach (var networkEntity in entities.NetworkConnectivityEntities)
// {
// saveTasks.Add(_networkConnectivityRepository.AddAsync(networkEntity, cancellationToken));
// }
// }
}
/// <summary>
/// 组装 IMSI 注册记录
/// </summary>
private static ImsiRegistrationRecord BuildImsiRegistrationRecord(string testCaseId, string nodeId, DeviceRegistrationFormData deviceRegistrationData)
{
return ImsiRegistrationRecord.Create(
testCaseId: testCaseId,
nodeId: nodeId,
isDualSim: deviceRegistrationData.IsDualSim,
sim1Plmn: deviceRegistrationData.Sim1Plmn,
sim1CellId: deviceRegistrationData.Sim1CellId,
sim1RegistrationWaitTime: deviceRegistrationData.Sim1RegistrationWaitTime,
sim2Plmn: deviceRegistrationData.Sim2Plmn,
sim2CellId: deviceRegistrationData.Sim2CellId,
sim2RegistrationWaitTime: deviceRegistrationData.Sim2RegistrationWaitTime
);
}
/// <summary>
/// 创建测试用例连线
/// </summary>
private async Task CreateEdgesAsync(string testCaseId, List<EdgeData> edges, CancellationToken cancellationToken)
{
foreach (var edgeData in edges)
{
var testCaseEdge = TestCaseEdge.Create(
testCaseId: testCaseId,
edgeId: edgeData.Id,
sourceNodeId: edgeData.Source,
targetNodeId: edgeData.Target,
sourceHandle: edgeData.SourceHandle,
targetHandle: edgeData.TargetHandle,
edgeType: edgeData.Type,
condition: edgeData.Condition,
isAnimated: edgeData.Animated ?? false,
style: edgeData.Style);
await _testCaseEdgeRepository.AddTestCaseEdgeAsync(testCaseEdge, cancellationToken);
}
_logger.LogInformation("成功创建 {Count} 个测试用例连线,测试用例ID: {TestCaseId}", edges.Count, testCaseId);
}
}
/// <summary>
/// 预处理后的节点数据
/// </summary>
public class ProcessedNodeData
{
/// <summary>
/// 原始节点数据
/// </summary>
public NodeData NodeData { get; set; } = null!;
/// <summary>
/// 序列化后的表单数据JSON字符串
/// </summary>
public string? FormDataJson { get; set; }
/// <summary>
/// 是否有表单数据需要处理
/// </summary>
public bool HasFormData { get; set; }
}
/// <summary>
/// 表单数据实体集合
/// </summary>
public class FormDataEntities
{
/// <summary>
/// IMSI 注册记录集合
/// </summary>
public List<ImsiRegistrationRecord> ImsiRegistrationRecords { get; set; } = new();
// 可以在这里添加其他实体类型的集合
// public List<NetworkConnectivityEntity> NetworkConnectivityEntities { get; set; } = new();
// public List<NetworkPerformanceEntity> NetworkPerformanceEntities { get; set; } = new();
// public List<VoiceCallEntity> VoiceCallEntities { get; set; } = new();
/// <summary>
/// 检查是否有任何实体需要保存
/// </summary>
/// <returns>是否有实体</returns>
public bool HasAnyEntities()
{
return ImsiRegistrationRecords.Any();
// || NetworkConnectivityEntities.Any()
// || NetworkPerformanceEntities.Any()
// || VoiceCallEntities.Any();
}
/// <summary>
/// 获取所有实体的总数量
/// </summary>
/// <returns>实体总数量</returns>
public int GetTotalEntityCount()
{
return ImsiRegistrationRecords.Count;
// + NetworkConnectivityEntities.Count
// + NetworkPerformanceEntities.Count
// + VoiceCallEntities.Count;
}
}

70
src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdQueryHandler.cs

@ -3,6 +3,7 @@ using X1.Domain.Repositories.TestCase;
using Microsoft.Extensions.Logging;
using MediatR;
using System.Text.Json;
using X1.Domain.Entities.TestCase;
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlowById;
@ -15,6 +16,7 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
private readonly ITestCaseNodeRepository _testCaseNodeRepository;
private readonly ITestCaseEdgeRepository _testCaseEdgeRepository;
private readonly ICaseStepConfigRepository _caseStepConfigRepository;
private readonly IImsiRegistrationRecordRepository _imsiRegistrationRecordRepository;
private readonly ILogger<GetTestCaseFlowByIdQueryHandler> _logger;
/// <summary>
@ -25,12 +27,14 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
ITestCaseNodeRepository testCaseNodeRepository,
ITestCaseEdgeRepository testCaseEdgeRepository,
ICaseStepConfigRepository caseStepConfigRepository,
IImsiRegistrationRecordRepository imsiRegistrationRecordRepository,
ILogger<GetTestCaseFlowByIdQueryHandler> logger)
{
_testCaseFlowRepository = testCaseFlowRepository;
_testCaseNodeRepository = testCaseNodeRepository;
_testCaseEdgeRepository = testCaseEdgeRepository;
_caseStepConfigRepository = caseStepConfigRepository;
_imsiRegistrationRecordRepository = imsiRegistrationRecordRepository;
_logger = logger;
}
@ -69,7 +73,7 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
UpdatedAt = testCaseFlow.UpdatedAt,
UpdatedBy = testCaseFlow.UpdatedBy,
Nodes = await MapNodesToReactFlowFormatAsync(testCaseFlow.Nodes, cancellationToken),
Edges = await MapEdgesToReactFlowFormatAsync(testCaseFlow.Edges, cancellationToken)
Edges = MapEdgesToReactFlowFormat(testCaseFlow.Edges, cancellationToken)
}
};
@ -102,6 +106,12 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
// 批量查询步骤配置
var stepConfigsDict = await _caseStepConfigRepository.GetStepConfigsByIdsAsync(stepIds, cancellationToken);
// 收集所有需要查询表单数据的节点ID
var nodeIds = nodes.Select(n => n.Id).ToList();
// 批量查询表单数据
var formDataDict = await GetFormDataForNodesAsync(nodeIds, cancellationToken);
var result = new List<TestCaseNodeDto>();
foreach (var node in nodes)
@ -113,6 +123,10 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
stepConfigsDict.TryGetValue(node.StepId, out stepConfig);
}
if (stepConfig is null) continue;
// 从字典中获取表单数据
formDataDict.TryGetValue(node.Id, out var formData);
var nodeDto = new TestCaseNodeDto
{
// ReactFlow 格式字段 - 前端需要的格式
@ -129,7 +143,10 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
StepName = stepConfig?.StepName ?? "Unknown",
StepType = (int)(stepConfig?.StepType ?? X1.Domain.Entities.TestCase.CaseStepType.Process),
Description = stepConfig?.Description ?? "",
Icon = stepConfig?.Icon ?? "settings"
Icon = stepConfig?.Icon ?? "settings",
FormType = (int)(stepConfig?.FormType ?? FormType.NoForm),
FormData = formData,
IsFormEnabled = stepConfig?.FormType != FormType.NoForm && !string.IsNullOrEmpty(formData)
},
Width = node.Width,
Height = node.Height,
@ -150,10 +167,57 @@ public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowBy
return result;
}
/// <summary>
/// 批量获取节点的表单数据
/// </summary>
private async Task<Dictionary<string, string>> GetFormDataForNodesAsync(List<string> nodeIds, CancellationToken cancellationToken)
{
var formDataDict = new Dictionary<string, string>();
try
{
// 查询 IMSI 注册记录(设备注册表单数据)
var imsiRecords = await _imsiRegistrationRecordRepository.GetByNodeIdsAsync(nodeIds, cancellationToken);
foreach (var imsiRecord in imsiRecords)
{
// 将 IMSI 注册记录转换为 JSON 格式
var formData = new
{
IsDualSim = imsiRecord.IsDualSim,
Sim1Plmn = imsiRecord.Sim1Plmn,
Sim1CellId = imsiRecord.Sim1CellId,
Sim1RegistrationWaitTime = imsiRecord.Sim1RegistrationWaitTime,
Sim2Plmn = imsiRecord.Sim2Plmn,
Sim2CellId = imsiRecord.Sim2CellId,
Sim2RegistrationWaitTime = imsiRecord.Sim2RegistrationWaitTime
};
formDataDict[imsiRecord.NodeId] = JsonSerializer.Serialize(formData);
}
// 可以在这里添加其他表单类型的查询
// 例如:网络连通性测试表单、网络性能测试表单、语音通话测试表单等
// var networkConnectivityRecords = await _networkConnectivityRepository.GetByNodeIdsAsync(nodeIds, cancellationToken);
// foreach (var record in networkConnectivityRecords)
// {
// formDataDict[record.NodeId] = JsonSerializer.Serialize(record);
// }
_logger.LogDebug("成功获取 {Count} 个节点的表单数据", formDataDict.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取节点表单数据时发生错误");
}
return formDataDict;
}
/// <summary>
/// 将连线映射为 ReactFlow 格式
/// </summary>
private async Task<List<TestCaseEdgeDto>> MapEdgesToReactFlowFormatAsync(IEnumerable<X1.Domain.Entities.TestCase.TestCaseEdge>? edges, CancellationToken cancellationToken)
private List<TestCaseEdgeDto> MapEdgesToReactFlowFormat(IEnumerable<X1.Domain.Entities.TestCase.TestCaseEdge>? edges, CancellationToken cancellationToken)
{
if (edges == null || !edges.Any())
return new List<TestCaseEdgeDto>();

15
src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdResponse.cs

@ -29,6 +29,21 @@ public class TestCaseNodeDataDto
/// 图标
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// 表单数据(JSON格式)
/// </summary>
public string? FormData { get; set; }
/// <summary>
/// 表单类型
/// </summary>
public int? FormType { get; set; }
/// <summary>
/// 是否开启表单
/// </summary>
public bool IsFormEnabled { get; set; }
}
/// <summary>

101
src/X1.Domain/Entities/TestCase/ScenarioTestCase.cs

@ -0,0 +1,101 @@
using System;
using System.ComponentModel.DataAnnotations;
using X1.Domain.Abstractions;
namespace X1.Domain.Entities.TestCase
{
/// <summary>
/// 场景测试用例实体 - 表示场景中包含的测试用例
/// </summary>
public class ScenarioTestCase : AuditableEntity
{
/// <summary>
/// 场景ID
/// </summary>
[Required]
public string ScenarioId { get; set; } = null!;
/// <summary>
/// 测试用例流程ID
/// </summary>
[Required]
public string TestCaseFlowId { get; set; } = null!;
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 执行顺序 - 在场景中的执行顺序
/// </summary>
public int ExecutionOrder { get; set; } = 0;
/// <summary>
/// 执行循环次数 - 测试用例在场景中的执行循环次数
/// </summary>
public int LoopCount { get; set; } = 1;
// 导航属性
public virtual TestScenario Scenario { get; set; } = null!;
public virtual TestCaseFlow TestCaseFlow { get; set; } = null!;
/// <summary>
/// 创建场景测试用例
/// </summary>
/// <param name="scenarioId">场景ID</param>
/// <param name="testCaseFlowId">测试用例流程ID</param>
/// <param name="createdBy">创建人ID</param>
/// <param name="executionOrder">执行顺序</param>
/// <param name="isEnabled">是否启用</param>
/// <param name="loopCount">执行循环次数</param>
public static ScenarioTestCase Create(
string scenarioId,
string testCaseFlowId,
string createdBy,
int executionOrder = 0,
bool isEnabled = true,
int loopCount = 1)
{
return new ScenarioTestCase
{
Id = Guid.NewGuid().ToString(),
ScenarioId = scenarioId,
TestCaseFlowId = testCaseFlowId,
ExecutionOrder = executionOrder,
IsEnabled = isEnabled,
LoopCount = loopCount,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
CreatedBy = createdBy,
UpdatedBy = createdBy
};
}
/// <summary>
/// 更新场景测试用例
/// </summary>
/// <param name="updatedBy">更新人ID</param>
/// <param name="executionOrder">执行顺序</param>
/// <param name="isEnabled">是否启用</param>
/// <param name="loopCount">执行循环次数</param>
public void Update(
string updatedBy,
int? executionOrder = null,
bool? isEnabled = null,
int? loopCount = null)
{
if (executionOrder.HasValue)
ExecutionOrder = executionOrder.Value;
if (isEnabled.HasValue)
IsEnabled = isEnabled.Value;
if (loopCount.HasValue)
LoopCount = loopCount.Value;
UpdatedAt = DateTime.UtcNow;
UpdatedBy = updatedBy;
}
}
}

66
src/X1.Domain/Entities/TestCase/ScenarioType.cs

@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel;
namespace X1.Domain.Entities.TestCase;
/// <summary>
/// 测试场景类型枚举
/// </summary>
public enum ScenarioType
{
/// <summary>
/// 功能测试
/// </summary>
[Display(Name = "功能测试")]
[Description("验证系统功能是否按预期工作的测试场景")]
Functional = 1,
/// <summary>
/// 性能测试
/// </summary>
[Display(Name = "性能测试")]
[Description("验证系统性能指标是否满足要求的测试场景")]
Performance = 2,
/// <summary>
/// 压力测试
/// </summary>
[Display(Name = "压力测试")]
[Description("验证系统在高负载条件下的稳定性的测试场景")]
Stress = 3,
/// <summary>
/// 兼容性测试
/// </summary>
[Display(Name = "兼容性测试")]
[Description("验证系统与不同设备和环境的兼容性的测试场景")]
Compatibility = 4,
/// <summary>
/// 回归测试
/// </summary>
[Display(Name = "回归测试")]
[Description("验证系统修改后原有功能是否正常的测试场景")]
Regression = 5,
/// <summary>
/// 集成测试
/// </summary>
[Display(Name = "集成测试")]
[Description("验证系统各模块间集成是否正常的测试场景")]
Integration = 6,
/// <summary>
/// 安全测试
/// </summary>
[Display(Name = "安全测试")]
[Description("验证系统安全性和防护能力的测试场景")]
Security = 7,
/// <summary>
/// 用户体验测试
/// </summary>
[Display(Name = "用户体验测试")]
[Description("验证系统用户体验和界面友好性的测试场景")]
UserExperience = 8
}

1
src/X1.Domain/Entities/TestCase/TestCaseFlow.cs

@ -54,6 +54,7 @@ namespace X1.Domain.Entities.TestCase
// 导航属性
public virtual ICollection<TestCaseNode> Nodes { get; set; } = new List<TestCaseNode>();
public virtual ICollection<TestCaseEdge> Edges { get; set; } = new List<TestCaseEdge>();
public virtual ICollection<ScenarioTestCase> TestScenarioTestCases { get; set; } = new List<ScenarioTestCase>();
/// <summary>
/// 创建测试用例流程

108
src/X1.Domain/Entities/TestCase/TestScenario.cs

@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using X1.Domain.Abstractions;
namespace X1.Domain.Entities.TestCase
{
/// <summary>
/// 测试场景实体 - 定义用例集合
/// </summary>
public class TestScenario : AuditableEntity
{
/// <summary>
/// 场景编码
/// </summary>
[Required]
[MaxLength(50)]
public string ScenarioCode { get; set; } = null!;
/// <summary>
/// 场景名称
/// </summary>
[Required]
[MaxLength(200)]
public string ScenarioName { get; set; } = null!;
/// <summary>
/// 场景类型
/// </summary>
[Required]
public ScenarioType Type { get; set; } = ScenarioType.Functional;
/// <summary>
/// 场景说明
/// </summary>
[MaxLength(1000)]
public string? Description { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; } = true;
// 导航属性 - 场景包含的测试用例
public virtual ICollection<ScenarioTestCase> ScenarioTestCases { get; set; } = new List<ScenarioTestCase>();
/// <summary>
/// 创建测试场景
/// </summary>
/// <param name="scenarioCode">场景编码</param>
/// <param name="scenarioName">场景名称</param>
/// <param name="type">场景类型</param>
/// <param name="createdBy">创建人ID</param>
/// <param name="description">场景说明</param>
/// <param name="isEnabled">是否启用</param>
public static TestScenario Create(
string scenarioCode,
string scenarioName,
ScenarioType type,
string createdBy,
string? description = null,
bool isEnabled = true)
{
return new TestScenario
{
Id = Guid.NewGuid().ToString(),
ScenarioCode = scenarioCode,
ScenarioName = scenarioName,
Type = type,
Description = description,
IsEnabled = isEnabled,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
CreatedBy = createdBy,
UpdatedBy = createdBy
};
}
/// <summary>
/// 更新测试场景
/// </summary>
/// <param name="scenarioCode">场景编码</param>
/// <param name="scenarioName">场景名称</param>
/// <param name="type">场景类型</param>
/// <param name="updatedBy">更新人ID</param>
/// <param name="description">场景说明</param>
/// <param name="isEnabled">是否启用</param>
public void Update(
string scenarioCode,
string scenarioName,
ScenarioType type,
string updatedBy,
string? description = null,
bool? isEnabled = null)
{
ScenarioCode = scenarioCode;
ScenarioName = scenarioName;
Type = type;
Description = description;
if (isEnabled.HasValue)
IsEnabled = isEnabled.Value;
UpdatedAt = DateTime.UtcNow;
UpdatedBy = updatedBy;
}
}
}

57
src/X1.Domain/Models/DeviceRegistrationFormData.cs

@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace X1.Domain.Models;
/// <summary>
/// 设备注册表单数据模型
/// </summary>
public class DeviceRegistrationFormData
{
/// <summary>
/// 节点ID
/// </summary>
[JsonPropertyName("nodeId")]
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// 是否双卡
/// </summary>
[JsonPropertyName("isDualSim")]
public bool IsDualSim { get; set; } = false;
/// <summary>
/// 卡1 PLMN
/// </summary>
[JsonPropertyName("sim1Plmn")]
public string? Sim1Plmn { get; set; }
/// <summary>
/// 卡1 CellId
/// </summary>
[JsonPropertyName("sim1CellId")]
public string? Sim1CellId { get; set; }
/// <summary>
/// 卡1 注册等待时间(毫秒)
/// </summary>
[JsonPropertyName("sim1RegistrationWaitTime")]
public int? Sim1RegistrationWaitTime { get; set; }
/// <summary>
/// 卡2 PLMN
/// </summary>
[JsonPropertyName("sim2Plmn")]
public string? Sim2Plmn { get; set; }
/// <summary>
/// 卡2 CellId
/// </summary>
[JsonPropertyName("sim2CellId")]
public string? Sim2CellId { get; set; }
/// <summary>
/// 卡2 注册等待时间(毫秒)
/// </summary>
[JsonPropertyName("sim2RegistrationWaitTime")]
public int? Sim2RegistrationWaitTime { get; set; }
}

8
src/X1.Domain/Repositories/TestCase/IImsiRegistrationRecordRepository.cs

@ -28,6 +28,14 @@ namespace X1.Domain.Repositories.TestCase
/// <returns>IMSI注册记录</returns>
Task<ImsiRegistrationRecord?> GetByNodeIdAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据节点ID列表批量获取IMSI注册记录
/// </summary>
/// <param name="nodeIds">节点ID列表</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>IMSI注册记录列表</returns>
Task<IEnumerable<ImsiRegistrationRecord>> GetByNodeIdsAsync(IEnumerable<string> nodeIds, CancellationToken cancellationToken = default);
/// <summary>
/// 根据测试用例ID和节点ID获取IMSI注册记录
/// </summary>

12
src/X1.Infrastructure/Repositories/TestCase/ImsiRegistrationRecordRepository.cs

@ -53,6 +53,18 @@ namespace X1.Infrastructure.Repositories.TestCase
return await QueryRepository.FirstOrDefaultAsync(x => x.NodeId == nodeId, cancellationToken: cancellationToken);
}
/// <summary>
/// 根据节点ID列表批量获取IMSI注册记录
/// </summary>
/// <param name="nodeIds">节点ID列表</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>IMSI注册记录列表</returns>
public async Task<IEnumerable<ImsiRegistrationRecord>> GetByNodeIdsAsync(IEnumerable<string> nodeIds, CancellationToken cancellationToken = default)
{
var records = await QueryRepository.FindAsync(x => nodeIds.Contains(x.NodeId), cancellationToken: cancellationToken);
return records.OrderBy(x => x.CreatedAt);
}
/// <summary>
/// 根据测试用例ID和节点ID获取IMSI注册记录
/// </summary>

185
src/X1.WebUI/docs/ReactFlow_Handle_Configuration.md

@ -0,0 +1,185 @@
# ReactFlow 连接点类型配置说明
## 概述
本文档详细说明了 ReactFlowDesigner 中连接点(Handle)的类型配置规则,包括不同类型节点的连接点配置、连线规则和业务逻辑。
## 连接点类型
### 1. 输出连接点(Source Handle)
- **类型**:`type="source"`
- **颜色**:绿色(`bg-green-500`)
- **功能**:可以发起连线,作为连线的起点
- **样式**:`hover:bg-green-600`
### 2. 输入连接点(Target Handle)
- **类型**:`type="target"`
- **颜色**:蓝色(`bg-blue-500`)
- **功能**:只能接收连线,作为连线的终点
- **样式**:`hover:bg-blue-600`
## 节点类型与连接点配置
### 1. 开始节点(stepType = 1)
**业务逻辑**:流程的起始点,只能输出,不能接收连线
**连接点配置**:
- **右边连接点**:`type="source"` - 可以发起连线
- **底部连接点**:`type="source"` - 可以发起连线
**连线规则**:
- ✅ 可以连线到:处理节点、判断节点
- ❌ 不能连线到:开始节点、结束节点
### 2. 结束节点(stepType = 2)
**业务逻辑**:流程的终止点,只能接收连线,不能输出
**连接点配置**:
- **上边连接点**:`type="target"` - 只能接收连线
- **左边连接点**:`type="target"` - 只能接收连线
**连线规则**:
- ✅ 可以接收来自:处理节点、判断节点的连线
- ❌ 不能接收来自:开始节点、结束节点的连线
### 3. 处理节点(stepType = 3)
**业务逻辑**:流程的处理步骤,可以接收和输出连线
**连接点配置**:
- **上边连接点**:`type="target"` - 只能接收连线
- **右边连接点**:`type="source"` - 可以发起连线
- **底部连接点**:`type="source"` - 可以发起连线
- **左边连接点**:`type="source"` - 可以发起连线
**连线规则**:
- ✅ 可以接收来自:开始节点、处理节点、判断节点的连线
- ✅ 可以连线到:处理节点、判断节点、结束节点
- ❌ 不能连线到:开始节点
### 4. 判断节点(stepType = 4)
**业务逻辑**:流程的分支判断,可以接收和输出连线
**连接点配置**:
- **上边连接点**:`type="target"` - 只能接收连线
- **右边连接点**:`type="source"` - 可以发起连线
- **底部连接点**:`type="source"` - 可以发起连线
- **左边连接点**:`type="source"` - 可以发起连线
**连线规则**:
- ✅ 可以接收来自:开始节点、处理节点、判断节点的连线
- ✅ 可以连线到:处理节点、判断节点、结束节点
- ❌ 不能连线到:开始节点
## 连线验证规则
### 基本验证
1. **不能从结束节点连出**:结束节点只能接收连线
2. **不能连到开始节点**:开始节点只能输出连线
3. **不能自己连自己**:节点不能连线到自己
4. **不能重复连线**:相同的源节点和目标节点之间只能有一条连线
### 业务逻辑验证
1. **流程完整性**:确保有开始节点和结束节点
2. **连接点类型匹配**:源连接点必须是 `source` 类型,目标连接点必须是 `target` 类型
3. **节点类型限制**:根据节点类型限制连线的方向
## 样式配置
### 连接点样式
```typescript
// 输出连接点(绿色)
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ position: -6, zIndex: 10, pointerEvents: 'all' }}
// 输入连接点(蓝色)
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ position: -6, zIndex: 10, pointerEvents: 'all' }}
```
### 样式说明
- **尺寸**:`w-3 h-3` - 12px × 12px
- **边框**:`border-2 border-white` - 2px 白色边框
- **形状**:`rounded-full` - 圆形
- **层级**:`zIndex: 10` - 确保不被其他元素遮挡
- **事件**:`pointerEvents: 'all'` - 确保正常接收鼠标事件
## 连线类型配置
### 支持的连线类型
1. **default**:贝塞尔曲线(默认)- 创建自然、流畅的曲线连接
2. **straight**:直线 - 简单的直线连接
3. **smoothstep**:平滑曲线 - 带拐角的平滑曲线
4. **step**:折线 - 直角拐角的折线
### 箭头配置
所有连线都配置了箭头指向,清晰地显示流程方向:
#### 箭头类型
- **type**: `'arrowclosed'` - 闭合箭头,视觉效果更好
- **width**: `8` - 箭头宽度(像素)
- **height**: `8` - 箭头高度(像素)
- **color**: `'#3b82f6'` - 箭头颜色,与连线颜色一致
#### 箭头位置
- **markerEnd**: 箭头位于连线的终点(目标节点端)
- 箭头指向目标节点,清晰显示流程方向
#### 其他箭头类型(可选)
React Flow 还支持其他箭头类型:
- `'arrow'` - 开放箭头
- `'arrowclosed'` - 闭合箭头(当前使用)
- `'default'` - 默认箭头
- `'triangle'` - 三角形箭头
### 连线样式
```typescript
style: {
stroke: '#3b82f6', // 连线颜色
strokeWidth: 2 // 连线宽度
},
markerEnd: {
type: 'arrowclosed', // 箭头类型:闭合箭头
width: 8, // 箭头宽度
height: 8, // 箭头高度
color: '#3b82f6', // 箭头颜色
}
```
## 调试和日志
### 连线调试日志
在连线验证函数中添加了详细的调试信息:
```typescript
console.log('=== 连线尝试 ===');
console.log('连接参数:', params);
console.log('源节点ID:', params.source);
console.log('目标节点ID:', params.target);
console.log('源连接点:', params.sourceHandle);
console.log('目标连接点:', params.targetHandle);
```
### 常见问题排查
1. **连接点不可见**:检查 z-index 和 pointerEvents 配置
2. **无法连线**:检查连接点类型是否正确(source/target)
3. **连线验证失败**:查看控制台调试日志,检查连线规则
## 扩展和维护
### 添加新节点类型
1. 定义节点类型的业务逻辑
2. 配置相应的连接点类型和位置
3. 更新连线验证规则
4. 添加相应的样式配置
### 修改连线规则
1. 更新 `isValidConnection` 函数中的验证逻辑
2. 测试所有可能的连线组合
3. 更新文档说明
## 总结
连接点类型配置是 ReactFlow 流程图设计器的核心功能,正确的配置确保了:
- **业务逻辑正确性**:符合流程图的业务规则
- **用户体验友好**:直观的视觉反馈和交互
- **系统稳定性**:防止无效连线和数据错误
- **扩展性**:便于后续功能扩展和维护

272
src/X1.WebUI/src/components/testcases/DeviceRegistrationDrawer.tsx

@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Smartphone, Wifi, Settings } from 'lucide-react';
import { UserCheck } from 'lucide-react';
interface DeviceRegistrationDrawerProps {
open: boolean;
@ -46,7 +46,6 @@ export default function DeviceRegistrationDrawer({
sim2RegistrationWaitTime: 5000
});
// 当组件打开或初始数据变化时,更新表单数据
useEffect(() => {
if (open) {
if (initialData) {
@ -73,7 +72,6 @@ export default function DeviceRegistrationDrawer({
e.preventDefault();
if (loading) return;
// 验证必填字段
if (!formData.sim1Plmn?.trim()) {
alert('请输入卡1 PLMN');
return;
@ -91,194 +89,128 @@ export default function DeviceRegistrationDrawer({
onOpenChange(false);
};
const handleDualSimChange = (checked: boolean) => {
setFormData(prev => ({
...prev,
isDualSim: checked,
// 如果取消双卡,清空卡2数据
...(checked ? {} : {
sim2Plmn: '',
sim2CellId: '',
sim2RegistrationWaitTime: 5000
})
}));
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="border-b">
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-blue-600" />
<UserCheck className="h-4 w-4" />
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* 基本信息 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="flex items-center space-x-2">
<Checkbox
id="isDualSim"
checked={formData.isDualSim}
onCheckedChange={(checked) => {
setFormData(prev => ({
...prev,
isDualSim: checked as boolean,
...(checked ? {} : {
sim2Plmn: '',
sim2CellId: '',
sim2RegistrationWaitTime: 5000
})
}));
}}
/>
<Label htmlFor="isDualSim"></Label>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">SIM卡1配置</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label htmlFor="sim1Plmn">PLMN *</Label>
<Input
id="sim1Plmn"
value={formData.sim1Plmn}
onChange={(e) => setFormData(prev => ({ ...prev, sim1Plmn: e.target.value }))}
placeholder="例如: 46001"
/>
</div>
<div>
<Label htmlFor="sim1CellId">ID</Label>
<Input
id="sim1CellId"
value={formData.sim1CellId}
onChange={(e) => setFormData(prev => ({ ...prev, sim1CellId: e.target.value }))}
placeholder="例如: 12345"
/>
</div>
<div>
<Label htmlFor="sim1RegistrationWaitTime">(ms)</Label>
<Input
id="sim1RegistrationWaitTime"
type="number"
value={formData.sim1RegistrationWaitTime}
onChange={(e) => setFormData(prev => ({ ...prev, sim1RegistrationWaitTime: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</CardContent>
</Card>
{formData.isDualSim && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Settings className="h-5 w-5" />
</CardTitle>
<CardTitle className="text-sm">SIM卡2配置</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nodeId">ID</Label>
<CardContent className="space-y-3">
<div>
<Label htmlFor="sim2Plmn">PLMN *</Label>
<Input
id="nodeId"
value={formData.nodeId}
disabled
className="bg-gray-100 dark:bg-gray-800"
id="sim2Plmn"
value={formData.sim2Plmn}
onChange={(e) => setFormData(prev => ({ ...prev, sim2Plmn: e.target.value }))}
placeholder="例如: 46002"
/>
<p className="text-sm text-gray-500"></p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isDualSim"
checked={formData.isDualSim}
onCheckedChange={handleDualSimChange}
disabled={loading}
<div>
<Label htmlFor="sim2CellId">ID</Label>
<Input
id="sim2CellId"
value={formData.sim2CellId}
onChange={(e) => setFormData(prev => ({ ...prev, sim2CellId: e.target.value }))}
placeholder="例如: 12346"
/>
<Label htmlFor="isDualSim" className="text-sm font-medium">
</Label>
</div>
</CardContent>
</Card>
{/* 卡1配置 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Wifi className="h-5 w-5 text-blue-600" />
1
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sim1Plmn">PLMN *</Label>
<Input
id="sim1Plmn"
value={formData.sim1Plmn}
onChange={(e) => setFormData(prev => ({ ...prev, sim1Plmn: e.target.value }))}
placeholder="例如: 46001"
required
disabled={loading}
/>
<p className="text-sm text-gray-500"></p>
</div>
<div className="space-y-2">
<Label htmlFor="sim1CellId">CellId</Label>
<Input
id="sim1CellId"
value={formData.sim1CellId}
onChange={(e) => setFormData(prev => ({ ...prev, sim1CellId: e.target.value }))}
placeholder="例如: 12345"
disabled={loading}
/>
<p className="text-sm text-gray-500"></p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sim1RegistrationWaitTime"></Label>
<div>
<Label htmlFor="sim2RegistrationWaitTime">(ms)</Label>
<Input
id="sim1RegistrationWaitTime"
id="sim2RegistrationWaitTime"
type="number"
value={formData.sim1RegistrationWaitTime}
onChange={(e) => setFormData(prev => ({
...prev,
sim1RegistrationWaitTime: parseInt(e.target.value) || 5000
}))}
value={formData.sim2RegistrationWaitTime}
onChange={(e) => setFormData(prev => ({ ...prev, sim2RegistrationWaitTime: parseInt(e.target.value) }))}
min="1000"
max="30000"
disabled={loading}
/>
<p className="text-sm text-gray-500">5000ms</p>
</div>
</CardContent>
</Card>
{/* 卡2配置 - 仅在双卡模式下显示 */}
{formData.isDualSim && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Wifi className="h-5 w-5 text-green-600" />
2
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sim2Plmn">PLMN *</Label>
<Input
id="sim2Plmn"
value={formData.sim2Plmn}
onChange={(e) => setFormData(prev => ({ ...prev, sim2Plmn: e.target.value }))}
placeholder="例如: 46002"
required
disabled={loading}
/>
<p className="text-sm text-gray-500"></p>
</div>
<div className="space-y-2">
<Label htmlFor="sim2CellId">CellId</Label>
<Input
id="sim2CellId"
value={formData.sim2CellId}
onChange={(e) => setFormData(prev => ({ ...prev, sim2CellId: e.target.value }))}
placeholder="例如: 67890"
disabled={loading}
/>
<p className="text-sm text-gray-500"></p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sim2RegistrationWaitTime"></Label>
<Input
id="sim2RegistrationWaitTime"
type="number"
value={formData.sim2RegistrationWaitTime}
onChange={(e) => setFormData(prev => ({
...prev,
sim2RegistrationWaitTime: parseInt(e.target.value) || 5000
}))}
min="1000"
max="30000"
disabled={loading}
/>
<p className="text-sm text-gray-500">5000ms</p>
</div>
</CardContent>
</Card>
)}
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存配置'}
</Button>
</div>
</form>
</div>
)}
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DrawerContent>
</Drawer>
);

404
src/X1.WebUI/src/components/testcases/FormTypeDrawer.tsx

@ -0,0 +1,404 @@
import React, { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FileText, UserCheck, Network, Activity, PhoneIncoming } from 'lucide-react';
// 表单类型枚举
export enum FormType {
NoForm = 0,
DeviceRegistrationForm = 1,
NetworkConnectivityForm = 2,
NetworkPerformanceForm = 3,
VoiceCallForm = 4
}
interface FormTypeDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeId: string;
formType: number;
onSave: (data: any) => void;
loading?: boolean;
initialData?: any;
}
export default function FormTypeDrawer({
open,
onOpenChange,
nodeId,
formType,
onSave,
loading = false,
initialData
}: FormTypeDrawerProps) {
const [formData, setFormData] = useState<any>({});
useEffect(() => {
if (open) {
if (initialData) {
setFormData({ ...initialData, nodeId });
} else {
setFormData(getDefaultFormData(formType, nodeId));
}
}
}, [open, initialData, nodeId, formType]);
const getDefaultFormData = (formType: number, nodeId: string) => {
switch (formType) {
case FormType.DeviceRegistrationForm:
return {
nodeId,
isDualSim: false,
sim1Plmn: '',
sim1CellId: '',
sim1RegistrationWaitTime: 5000,
sim2Plmn: '',
sim2CellId: '',
sim2RegistrationWaitTime: 5000
};
case FormType.NetworkConnectivityForm:
return {
nodeId,
targetHost: '8.8.8.8',
pingCount: 4,
timeout: 5000,
packetSize: 64,
interval: 1000
};
case FormType.NetworkPerformanceForm:
return {
nodeId,
serverHost: 'localhost',
serverPort: 5201,
testDuration: 10,
bandwidth: 1000,
protocol: 'tcp',
parallel: 1
};
case FormType.VoiceCallForm:
return {
nodeId,
phoneNumber: '',
callDuration: 30,
callType: 'mo',
waitTime: 5000,
hangupDelay: 2000
};
default:
return { nodeId };
}
};
const getFormTypeIcon = (formType: number) => {
switch (formType) {
case FormType.DeviceRegistrationForm:
return <UserCheck className="h-4 w-4" />;
case FormType.NetworkConnectivityForm:
return <Network className="h-4 w-4" />;
case FormType.NetworkPerformanceForm:
return <Activity className="h-4 w-4" />;
case FormType.VoiceCallForm:
return <PhoneIncoming className="h-4 w-4" />;
default:
return <FileText className="h-4 w-4" />;
}
};
const getFormTypeTitle = (formType: number) => {
switch (formType) {
case FormType.DeviceRegistrationForm:
return '设备注册表单';
case FormType.NetworkConnectivityForm:
return '网络连通性测试表单';
case FormType.NetworkPerformanceForm:
return '网络性能测试表单';
case FormType.VoiceCallForm:
return '语音通话测试表单';
default:
return '无表单';
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
onSave(formData);
};
const handleCancel = () => {
onOpenChange(false);
};
const renderDeviceRegistrationForm = () => (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="isDualSim"
checked={formData.isDualSim || false}
onCheckedChange={(checked) => {
setFormData(prev => ({
...prev,
isDualSim: checked,
...(checked ? {} : {
sim2Plmn: '',
sim2CellId: '',
sim2RegistrationWaitTime: 5000
})
}));
}}
/>
<Label htmlFor="isDualSim"></Label>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">SIM卡1配置</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label htmlFor="sim1Plmn">PLMN *</Label>
<Input
id="sim1Plmn"
value={formData.sim1Plmn || ''}
onChange={(e) => setFormData(prev => ({ ...prev, sim1Plmn: e.target.value }))}
placeholder="例如: 46001"
/>
</div>
<div>
<Label htmlFor="sim1CellId">ID</Label>
<Input
id="sim1CellId"
value={formData.sim1CellId || ''}
onChange={(e) => setFormData(prev => ({ ...prev, sim1CellId: e.target.value }))}
placeholder="例如: 12345"
/>
</div>
<div>
<Label htmlFor="sim1RegistrationWaitTime">(ms)</Label>
<Input
id="sim1RegistrationWaitTime"
type="number"
value={formData.sim1RegistrationWaitTime || 5000}
onChange={(e) => setFormData(prev => ({ ...prev, sim1RegistrationWaitTime: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</CardContent>
</Card>
{formData.isDualSim && (
<Card>
<CardHeader>
<CardTitle className="text-sm">SIM卡2配置</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label htmlFor="sim2Plmn">PLMN *</Label>
<Input
id="sim2Plmn"
value={formData.sim2Plmn || ''}
onChange={(e) => setFormData(prev => ({ ...prev, sim2Plmn: e.target.value }))}
placeholder="例如: 46002"
/>
</div>
<div>
<Label htmlFor="sim2CellId">ID</Label>
<Input
id="sim2CellId"
value={formData.sim2CellId || ''}
onChange={(e) => setFormData(prev => ({ ...prev, sim2CellId: e.target.value }))}
placeholder="例如: 12346"
/>
</div>
<div>
<Label htmlFor="sim2RegistrationWaitTime">(ms)</Label>
<Input
id="sim2RegistrationWaitTime"
type="number"
value={formData.sim2RegistrationWaitTime || 5000}
onChange={(e) => setFormData(prev => ({ ...prev, sim2RegistrationWaitTime: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</CardContent>
</Card>
)}
</div>
);
const renderNetworkConnectivityForm = () => (
<div className="space-y-4">
<div>
<Label htmlFor="targetHost"> *</Label>
<Input
id="targetHost"
value={formData.targetHost || ''}
onChange={(e) => setFormData(prev => ({ ...prev, targetHost: e.target.value }))}
placeholder="例如: 8.8.8.8"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="pingCount">Ping次数</Label>
<Input
id="pingCount"
type="number"
value={formData.pingCount || 4}
onChange={(e) => setFormData(prev => ({ ...prev, pingCount: parseInt(e.target.value) }))}
min="1"
max="10"
/>
</div>
<div>
<Label htmlFor="timeout">(ms)</Label>
<Input
id="timeout"
type="number"
value={formData.timeout || 5000}
onChange={(e) => setFormData(prev => ({ ...prev, timeout: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</div>
</div>
);
const renderNetworkPerformanceForm = () => (
<div className="space-y-4">
<div>
<Label htmlFor="serverHost"> *</Label>
<Input
id="serverHost"
value={formData.serverHost || ''}
onChange={(e) => setFormData(prev => ({ ...prev, serverHost: e.target.value }))}
placeholder="例如: localhost"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="serverPort"></Label>
<Input
id="serverPort"
type="number"
value={formData.serverPort || 5201}
onChange={(e) => setFormData(prev => ({ ...prev, serverPort: parseInt(e.target.value) }))}
min="1"
max="65535"
/>
</div>
<div>
<Label htmlFor="testDuration">()</Label>
<Input
id="testDuration"
type="number"
value={formData.testDuration || 10}
onChange={(e) => setFormData(prev => ({ ...prev, testDuration: parseInt(e.target.value) }))}
min="1"
max="3600"
/>
</div>
</div>
</div>
);
const renderVoiceCallForm = () => (
<div className="space-y-4">
<div>
<Label htmlFor="phoneNumber"> *</Label>
<Input
id="phoneNumber"
value={formData.phoneNumber || ''}
onChange={(e) => setFormData(prev => ({ ...prev, phoneNumber: e.target.value }))}
placeholder="例如: 13800138000"
/>
</div>
<div>
<Label htmlFor="callType"></Label>
<Select
value={formData.callType || 'mo'}
onValueChange={(value) => setFormData(prev => ({ ...prev, callType: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mo">(MO)</SelectItem>
<SelectItem value="mt">(MT)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="callDuration">()</Label>
<Input
id="callDuration"
type="number"
value={formData.callDuration || 30}
onChange={(e) => setFormData(prev => ({ ...prev, callDuration: parseInt(e.target.value) }))}
min="5"
max="300"
/>
</div>
</div>
);
const renderFormContent = () => {
switch (formType) {
case FormType.DeviceRegistrationForm:
return renderDeviceRegistrationForm();
case FormType.NetworkConnectivityForm:
return renderNetworkConnectivityForm();
case FormType.NetworkPerformanceForm:
return renderNetworkPerformanceForm();
case FormType.VoiceCallForm:
return renderVoiceCallForm();
default:
return (
<div className="text-center py-8 text-gray-500">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p></p>
</div>
);
}
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
{getFormTypeIcon(formType)}
{getFormTypeTitle(formType)}
</DrawerTitle>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{renderFormContent()}
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DrawerContent>
</Drawer>
);
}

177
src/X1.WebUI/src/components/testcases/NetworkConnectivityDrawer.tsx

@ -0,0 +1,177 @@
import React, { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Network } from 'lucide-react';
interface NetworkConnectivityDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeId: string;
onSave: (data: NetworkConnectivityData) => void;
loading?: boolean;
initialData?: NetworkConnectivityData;
}
export interface NetworkConnectivityData {
nodeId: string;
targetHost: string;
pingCount: number;
timeout: number;
packetSize: number;
interval: number;
}
export default function NetworkConnectivityDrawer({
open,
onOpenChange,
nodeId,
onSave,
loading = false,
initialData
}: NetworkConnectivityDrawerProps) {
const [formData, setFormData] = useState<NetworkConnectivityData>({
nodeId,
targetHost: '8.8.8.8',
pingCount: 4,
timeout: 5000,
packetSize: 64,
interval: 1000
});
useEffect(() => {
if (open) {
if (initialData) {
setFormData({
...initialData,
nodeId
});
} else {
setFormData({
nodeId,
targetHost: '8.8.8.8',
pingCount: 4,
timeout: 5000,
packetSize: 64,
interval: 1000
});
}
}
}, [open, initialData, nodeId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
if (!formData.targetHost?.trim()) {
alert('请输入目标主机地址');
return;
}
onSave(formData);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Network className="h-4 w-4" />
</DrawerTitle>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm">Ping </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="targetHost"> *</Label>
<Input
id="targetHost"
value={formData.targetHost}
onChange={(e) => setFormData(prev => ({ ...prev, targetHost: e.target.value }))}
placeholder="例如: 8.8.8.8"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="pingCount">Ping次数</Label>
<Input
id="pingCount"
type="number"
value={formData.pingCount}
onChange={(e) => setFormData(prev => ({ ...prev, pingCount: parseInt(e.target.value) }))}
min="1"
max="10"
/>
</div>
<div>
<Label htmlFor="timeout">(ms)</Label>
<Input
id="timeout"
type="number"
value={formData.timeout}
onChange={(e) => setFormData(prev => ({ ...prev, timeout: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="packetSize">()</Label>
<Input
id="packetSize"
type="number"
value={formData.packetSize}
onChange={(e) => setFormData(prev => ({ ...prev, packetSize: parseInt(e.target.value) }))}
min="32"
max="65507"
/>
</div>
<div>
<Label htmlFor="interval">(ms)</Label>
<Input
id="interval"
type="number"
value={formData.interval}
onChange={(e) => setFormData(prev => ({ ...prev, interval: parseInt(e.target.value) }))}
min="100"
max="10000"
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DrawerContent>
</Drawer>
);
}

197
src/X1.WebUI/src/components/testcases/NetworkPerformanceDrawer.tsx

@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Activity } from 'lucide-react';
interface NetworkPerformanceDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeId: string;
onSave: (data: NetworkPerformanceData) => void;
loading?: boolean;
initialData?: NetworkPerformanceData;
}
export interface NetworkPerformanceData {
nodeId: string;
serverHost: string;
serverPort: number;
testDuration: number;
bandwidth: number;
protocol: 'tcp' | 'udp';
parallel: number;
}
export default function NetworkPerformanceDrawer({
open,
onOpenChange,
nodeId,
onSave,
loading = false,
initialData
}: NetworkPerformanceDrawerProps) {
const [formData, setFormData] = useState<NetworkPerformanceData>({
nodeId,
serverHost: 'localhost',
serverPort: 5201,
testDuration: 10,
bandwidth: 1000,
protocol: 'tcp',
parallel: 1
});
useEffect(() => {
if (open) {
if (initialData) {
setFormData({
...initialData,
nodeId
});
} else {
setFormData({
nodeId,
serverHost: 'localhost',
serverPort: 5201,
testDuration: 10,
bandwidth: 1000,
protocol: 'tcp',
parallel: 1
});
}
}
}, [open, initialData, nodeId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
if (!formData.serverHost?.trim()) {
alert('请输入服务器地址');
return;
}
onSave(formData);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
</DrawerTitle>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm">Iperf </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="serverHost"> *</Label>
<Input
id="serverHost"
value={formData.serverHost}
onChange={(e) => setFormData(prev => ({ ...prev, serverHost: e.target.value }))}
placeholder="例如: localhost"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="serverPort"></Label>
<Input
id="serverPort"
type="number"
value={formData.serverPort}
onChange={(e) => setFormData(prev => ({ ...prev, serverPort: parseInt(e.target.value) }))}
min="1"
max="65535"
/>
</div>
<div>
<Label htmlFor="testDuration">()</Label>
<Input
id="testDuration"
type="number"
value={formData.testDuration}
onChange={(e) => setFormData(prev => ({ ...prev, testDuration: parseInt(e.target.value) }))}
min="1"
max="3600"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="bandwidth">(Mbps)</Label>
<Input
id="bandwidth"
type="number"
value={formData.bandwidth}
onChange={(e) => setFormData(prev => ({ ...prev, bandwidth: parseInt(e.target.value) }))}
min="1"
max="10000"
/>
</div>
<div>
<Label htmlFor="parallel"></Label>
<Input
id="parallel"
type="number"
value={formData.parallel}
onChange={(e) => setFormData(prev => ({ ...prev, parallel: parseInt(e.target.value) }))}
min="1"
max="10"
/>
</div>
</div>
<div>
<Label htmlFor="protocol"></Label>
<Select
value={formData.protocol}
onValueChange={(value) => setFormData(prev => ({ ...prev, protocol: value as 'tcp' | 'udp' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DrawerContent>
</Drawer>
);
}

243
src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx

@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import ReactFlow, {
Node,
Edge,
@ -8,9 +10,11 @@ import ReactFlow, {
Handle,
Position,
ReactFlowProvider,
useReactFlow
useReactFlow,
EdgeMarker
} from 'reactflow';
import { testcaseService, TestCaseFlowDetail } from '@/services/testcaseService';
import { FormType, getFormTypeName } from '@/types/formTypes';
import 'reactflow/dist/style.css';
import styles from './TestCaseDetailDrawer.module.css';
import {
@ -28,7 +32,10 @@ import {
Activity,
Signal,
SignalHigh,
SignalLow
SignalLow,
FileText,
CheckCircle,
XCircle
} from 'lucide-react';
interface TestCaseDetailDrawerProps {
@ -38,7 +45,35 @@ interface TestCaseDetailDrawerProps {
}
// 简化的自定义节点组件
const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) => {
const TestStepNode = ({ data, selected, id }: { data: any; selected?: boolean; id: string }) => {
const [isFormEnabled, setIsFormEnabled] = useState(data.isFormEnabled);
// 从父组件获取全局状态
const { activeFormNodeId, setActiveFormNodeId } = data;
const showFormInfo = activeFormNodeId === id;
const handleNodeClick = () => {
if (setActiveFormNodeId) {
console.log('点击节点:', id, '当前激活节点:', activeFormNodeId, '有表单数据:', isFormEnabled && data.formData);
// 如果当前节点已经显示表单信息,则关闭
if (activeFormNodeId === id) {
console.log('关闭当前节点的表单信息');
setActiveFormNodeId(null);
} else {
// 否则显示当前节点的表单信息(只有有表单数据的节点才显示)
if (isFormEnabled && data.formData) {
console.log('显示节点的表单信息');
setActiveFormNodeId(id);
} else {
// 如果点击的是无表单节点,则关闭当前显示的表单信息
console.log('点击无表单节点,关闭当前表单信息');
setActiveFormNodeId(null);
}
}
}
};
const getIconComponent = (iconName: string) => {
// 根据图标名称返回对应的图标组件
switch (iconName) {
@ -133,95 +168,48 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
return (
<div className={`group relative transition-all duration-200`}>
{/* 开始和结束步骤使用圆形 */}
{(data.stepType === 1 || data.stepType === 2) && (
<div
className={`px-1.5 py-0.5 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '60px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-2.5 h-2.5 rounded-md ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div
className="font-medium text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '10px',
lineHeight: '1.1'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 处理步骤使用矩形 */}
{data.stepType === 3 && (
<div
className={`px-1.5 py-0.5 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '60px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-2.5 h-2.5 rounded-md ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div
className="font-medium text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '10px',
lineHeight: '1.1'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 节点主体 */}
<div
className={`px-1.5 py-0.5 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
} cursor-pointer`}
style={{
width: 'fit-content',
minWidth: '60px'
}}
onClick={handleNodeClick}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-2.5 h-2.5 rounded-md ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div
className="font-medium text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '10px',
lineHeight: '1.1'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
{/* 判断步骤使用菱形 */}
{data.stepType === 4 && (
<div
className={`px-1.5 py-0.5 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '60px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-2.5 h-2.5 rounded-md ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div
className="font-medium text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '10px',
lineHeight: '1.1'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 表单信息显示区域 - 只有启用表单且有数据时才显示 */}
{showFormInfo && isFormEnabled && data.formData && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg p-2 min-w-64 max-w-80">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
</div>
<FormInfoDisplay
formData={data.formData}
formType={data.formType}
isFormEnabled={data.isFormEnabled}
/>
</div>
)}
{/* 连接点 - 根据节点数据动态生成 */}
{data.handles && data.handles.map((handle: any, index: number) => (
@ -246,6 +234,49 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
);
};
// 表单信息显示组件
const FormInfoDisplay = ({ formData, formType, isFormEnabled }: {
formData?: string;
formType?: number;
isFormEnabled?: boolean;
}) => {
if (!isFormEnabled || !formType || formType === FormType.NoForm) {
return (
<div className="flex items-center space-x-2 text-gray-500">
<XCircle className="h-4 w-4" />
<span className="text-sm"></span>
</div>
);
}
let parsedFormData = null;
try {
if (formData) {
parsedFormData = JSON.parse(formData);
}
} catch (error) {
console.error('解析表单数据失败:', error);
}
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<Badge variant="secondary" className="text-xs">
{getFormTypeName(formType)}
</Badge>
</div>
{parsedFormData && (
<div className="text-xs text-gray-600 dark:text-gray-400">
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(parsedFormData, null, 2)}
</pre>
</div>
)}
</div>
);
};
const nodeTypes = {
testStep: TestStepNode,
startStep: TestStepNode,
@ -260,6 +291,7 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
const [error, setError] = useState<string | null>(null);
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [activeFormNodeId, setActiveFormNodeId] = useState<string | null>(null);
const { fitView } = useReactFlow();
useEffect(() => {
@ -270,9 +302,20 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
setError(null);
setNodes([]);
setEdges([]);
setActiveFormNodeId(null);
}
}, [open, testCaseId]);
// 当 activeFormNodeId 变化时,更新节点数据
useEffect(() => {
if (selectedTestCase) {
console.log('更新节点数据,当前激活节点:', activeFormNodeId);
const flowData = getReactFlowData(selectedTestCase);
setNodes(flowData.nodes);
setEdges(flowData.edges);
}
}, [activeFormNodeId, selectedTestCase]);
const loadTestCaseDetail = async (id: string) => {
try {
setFlowLoading(true);
@ -348,11 +391,16 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
stepName: node.data?.stepName || 'Unknown',
stepType: node.data?.stepType || 3,
icon: node.data?.icon || 'settings',
handles: nodeHandles[node.id] || []
handles: nodeHandles[node.id] || [],
formData: node.data?.formData,
formType: node.data?.formType,
isFormEnabled: node.data?.isFormEnabled,
activeFormNodeId,
setActiveFormNodeId
}
}));
const flowEdges: Edge[] = testCase.edges.map(edge => ({
const flowEdges = testCase.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
@ -360,8 +408,14 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
targetHandle: edge.targetHandle,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed' as const,
width: 8,
height: 8,
color: '#3b82f6',
},
data: edge.data
}));
})) as Edge[];
return { nodes: flowNodes, edges: flowEdges };
};
@ -422,6 +476,7 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
style={{ width: '100%', height: '100%' }}
snapToGrid={true}
snapGrid={[15, 15]}
onPaneClick={() => setActiveFormNodeId(null)}
>
<Controls className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm" />
<Background

181
src/X1.WebUI/src/components/testcases/VoiceCallDrawer.tsx

@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PhoneIncoming } from 'lucide-react';
interface VoiceCallDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeId: string;
onSave: (data: VoiceCallData) => void;
loading?: boolean;
initialData?: VoiceCallData;
}
export interface VoiceCallData {
nodeId: string;
phoneNumber: string;
callDuration: number;
callType: 'mo' | 'mt';
waitTime: number;
hangupDelay: number;
}
export default function VoiceCallDrawer({
open,
onOpenChange,
nodeId,
onSave,
loading = false,
initialData
}: VoiceCallDrawerProps) {
const [formData, setFormData] = useState<VoiceCallData>({
nodeId,
phoneNumber: '',
callDuration: 30,
callType: 'mo',
waitTime: 5000,
hangupDelay: 2000
});
useEffect(() => {
if (open) {
if (initialData) {
setFormData({
...initialData,
nodeId
});
} else {
setFormData({
nodeId,
phoneNumber: '',
callDuration: 30,
callType: 'mo',
waitTime: 5000,
hangupDelay: 2000
});
}
}
}, [open, initialData, nodeId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
if (!formData.phoneNumber?.trim()) {
alert('请输入电话号码');
return;
}
onSave(formData);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<PhoneIncoming className="h-4 w-4" />
</DrawerTitle>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="phoneNumber"> *</Label>
<Input
id="phoneNumber"
value={formData.phoneNumber}
onChange={(e) => setFormData(prev => ({ ...prev, phoneNumber: e.target.value }))}
placeholder="例如: 13800138000"
/>
</div>
<div>
<Label htmlFor="callType"></Label>
<Select
value={formData.callType}
onValueChange={(value) => setFormData(prev => ({ ...prev, callType: value as 'mo' | 'mt' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mo">(MO)</SelectItem>
<SelectItem value="mt">(MT)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="callDuration">()</Label>
<Input
id="callDuration"
type="number"
value={formData.callDuration}
onChange={(e) => setFormData(prev => ({ ...prev, callDuration: parseInt(e.target.value) }))}
min="5"
max="300"
/>
</div>
<div>
<Label htmlFor="waitTime">(ms)</Label>
<Input
id="waitTime"
type="number"
value={formData.waitTime}
onChange={(e) => setFormData(prev => ({ ...prev, waitTime: parseInt(e.target.value) }))}
min="1000"
max="30000"
/>
</div>
</div>
<div>
<Label htmlFor="hangupDelay">(ms)</Label>
<Input
id="hangupDelay"
type="number"
value={formData.hangupDelay}
onChange={(e) => setFormData(prev => ({ ...prev, hangupDelay: parseInt(e.target.value) }))}
min="0"
max="10000"
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DrawerContent>
</Drawer>
);
}

2
src/X1.WebUI/src/config/core/env.config.ts

@ -6,7 +6,7 @@ const DEFAULT_CONFIG = {
VITE_API_BASE_URL: 'https://localhost:7268/api',
VITE_API_TIMEOUT: '30000',
VITE_API_VERSION: 'v1',
VITE_API_MAX_RETRIES: '1',
VITE_API_MAX_RETRIES: '0',
VITE_API_RETRY_DELAY: '1000',
// 应用配置

455
src/X1.WebUI/src/pages/testcases/ReactFlowDesigner.tsx

@ -15,6 +15,7 @@ import ReactFlow, {
Position,
} from 'reactflow';
import 'reactflow/dist/style.css';
import '@/styles/reactflow.css';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@ -40,6 +41,21 @@ import {
SignalLow
} from 'lucide-react';
import { TestStep } from '@/services/teststepsService';
import DeviceRegistrationDrawer from '@/components/testcases/DeviceRegistrationDrawer';
import NetworkConnectivityDrawer from '@/components/testcases/NetworkConnectivityDrawer';
import NetworkPerformanceDrawer from '@/components/testcases/NetworkPerformanceDrawer';
import VoiceCallDrawer from '@/components/testcases/VoiceCallDrawer';
import {
FormData,
FormType,
SimpleFormData,
DeviceRegistrationData,
NetworkConnectivityData,
NetworkPerformanceData,
VoiceCallData,
getFormTypeName
} from '@/types/formTypes';
import { createFormData } from '@/utils/formDataUtils';
// 右键菜单组件
interface ContextMenuProps {
@ -47,20 +63,55 @@ interface ContextMenuProps {
y: number;
onClose: () => void;
onDelete: () => void;
onShowForm?: () => void;
type: 'node' | 'edge';
formType?: number;
isFormEnabled?: boolean;
}
const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose, onDelete, type }) => {
const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose, onDelete, onShowForm, type, formType, isFormEnabled }) => {
const handleDelete = () => {
onDelete();
onClose();
};
const handleShowForm = () => {
if (onShowForm) {
onShowForm();
}
onClose();
};
const getFormTypeName = (formType: number) => {
switch (formType) {
case 0: return '无表单';
case 1: return '设备注册表单';
case 2: return '网络连通性测试表单';
case 3: return '网络性能测试表单';
case 4: return '语音通话测试表单';
default: return '表单';
}
};
return (
<div
className="fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1"
style={{ left: x, top: y }}
>
{type === 'node' && formType && formType > 0 && (
<button
onClick={handleShowForm}
className="w-full px-4 py-2 text-left text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 flex items-center gap-2"
>
<Settings className="h-4 w-4" />
{isFormEnabled ? '编辑' : '配置'}{getFormTypeName(formType)}
{isFormEnabled && (
<span className="ml-1 text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 px-1 rounded">
</span>
)}
</button>
)}
<button
onClick={handleDelete}
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
@ -78,6 +129,9 @@ type FlowNode = ReactFlowNode<{
stepType: number;
description?: string;
icon?: string;
formType?: number; // 添加表单类型字段
formData?: FormData; // 使用类型化的表单数据
isFormEnabled?: boolean; // 添加是否开启表单字段
}>;
type FlowEdge = Edge<{
@ -294,14 +348,14 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
style={{ right: -6, pointerEvents: 'all' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
style={{ bottom: -6, pointerEvents: 'all' }}
/>
</>
)}
@ -314,14 +368,14 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
style={{ top: -6, pointerEvents: 'all' }}
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
style={{ left: -6, zIndex: 10, pointerEvents: 'all' }}
/>
</>
)}
@ -334,28 +388,28 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
style={{ top: -6, pointerEvents: 'all' }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
style={{ right: -6, pointerEvents: 'all' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
style={{ bottom: -6, pointerEvents: 'all' }}
/>
<Handle
type="target"
type="source"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ left: -6, zIndex: 10, pointerEvents: 'all' }}
/>
</>
)}
@ -368,28 +422,28 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
style={{ top: -6, pointerEvents: 'all' }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
style={{ right: -6, pointerEvents: 'all' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
style={{ bottom: -6, pointerEvents: 'all' }}
/>
<Handle
type="target"
type="source"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ left: -6, zIndex: 10, pointerEvents: 'all' }}
/>
</>
)}
@ -414,7 +468,7 @@ function ReactFlowDesignerInner({
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [showControls, setShowControls] = useState(true);
const [currentZoom, setCurrentZoom] = useState(1.5);
const [edgeType, setEdgeType] = useState<'step' | 'smoothstep' | 'straight'>('straight');
const [edgeType, setEdgeType] = useState<'step' | 'smoothstep' | 'straight' | 'default'>('default');
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
@ -424,6 +478,12 @@ function ReactFlowDesignerInner({
id: string;
} | null>(null);
// 表单状态管理
const [formDrawerOpen, setFormDrawerOpen] = useState(false);
const [currentFormType, setCurrentFormType] = useState<number>(0);
const [currentNodeId, setCurrentNodeId] = useState<string>('');
const [formData, setFormData] = useState<SimpleFormData | null>(null);
// 添加导入导出相关的状态
const [importedFlow, setImportedFlow] = useState<any>(null);
@ -560,7 +620,13 @@ function ReactFlowDesignerInner({
const onConnect = useCallback(
(params: Connection) => {
console.log('尝试连接:', params);
console.log('=== 连线尝试 ===');
console.log('连接参数:', params);
console.log('源节点ID:', params.source);
console.log('目标节点ID:', params.target);
console.log('源连接点:', params.sourceHandle);
console.log('目标连接点:', params.targetHandle);
if (isValidConnection(params) && params.source && params.target) {
const newEdge = {
...params,
@ -571,6 +637,12 @@ function ReactFlowDesignerInner({
stroke: '#3b82f6',
strokeWidth: 2,
},
markerEnd: {
type: 'arrowclosed',
width: 8,
height: 8,
color: '#3b82f6',
},
data: {
condition: 'default'
}
@ -606,11 +678,18 @@ function ReactFlowDesignerInner({
}
}, [setEdges]);
// 处理缩放事件
const onMove = useCallback((viewport: any) => {
const { zoom } = viewport;
setCurrentZoom(zoom);
}, []);
// 使用 useEffect 监听视口变化
useEffect(() => {
const interval = setInterval(() => {
const viewport = reactFlowInstance.getViewport();
const { zoom } = viewport;
if (typeof zoom === 'number' && !isNaN(zoom) && zoom !== currentZoom) {
setCurrentZoom(zoom);
}
}, 100); // 每100ms检查一次
return () => clearInterval(interval);
}, [reactFlowInstance, currentZoom]);
// 检查是否可以添加特定类型的节点
const canAddNodeType = useCallback((stepType: number) => {
@ -752,6 +831,139 @@ function ReactFlowDesignerInner({
setContextMenu(null);
}, []);
// 处理显示表单
const handleShowForm = useCallback(() => {
if (!contextMenu || contextMenu.type !== 'node') return;
const node = nodes.find(n => n.id === contextMenu.id);
if (!node || !node.data.formType || node.data.formType === 0) return;
console.log('=== 显示表单 ===');
console.log('节点ID:', node.id);
console.log('节点名称:', node.data.stepName);
console.log('表单类型:', node.data.formType);
console.log('是否已开启:', node.data.isFormEnabled);
console.log('现有表单数据:', node.data.formData);
setCurrentNodeId(node.id);
setCurrentFormType(node.data.formType);
// 如果节点已有表单数据,传递给表单组件
if (node.data.formData) {
setFormData(node.data.formData);
}
setFormDrawerOpen(true);
}, [contextMenu, nodes]);
// 处理表单保存 - 设备注册表单
const handleDeviceRegistrationSave = useCallback((data: DeviceRegistrationData) => {
console.log('=== 设备注册表单数据保存 ===');
console.log('节点ID:', currentNodeId);
console.log('表单数据:', data);
// 确保 nodeId 正确设置
const formDataWithNodeId = {
...data,
nodeId: currentNodeId
};
const typedFormData = createFormData.deviceRegistration(formDataWithNodeId);
updateNodeFormData(typedFormData);
}, [currentNodeId]);
// 处理表单保存 - 网络连通性表单
const handleNetworkConnectivitySave = useCallback((data: NetworkConnectivityData) => {
console.log('=== 网络连通性表单数据保存 ===');
console.log('节点ID:', currentNodeId);
console.log('表单数据:', data);
// 确保 nodeId 正确设置
const formDataWithNodeId = {
...data,
nodeId: currentNodeId
};
const typedFormData = createFormData.networkConnectivity(formDataWithNodeId);
updateNodeFormData(typedFormData);
}, [currentNodeId]);
// 处理表单保存 - 网络性能表单
const handleNetworkPerformanceSave = useCallback((data: NetworkPerformanceData) => {
console.log('=== 网络性能表单数据保存 ===');
console.log('节点ID:', currentNodeId);
console.log('表单数据:', data);
// 确保 nodeId 正确设置
const formDataWithNodeId = {
...data,
nodeId: currentNodeId
};
const typedFormData = createFormData.networkPerformance(formDataWithNodeId);
updateNodeFormData(typedFormData);
}, [currentNodeId]);
// 处理表单保存 - 语音通话表单
const handleVoiceCallSave = useCallback((data: VoiceCallData) => {
console.log('=== 语音通话表单数据保存 ===');
console.log('节点ID:', currentNodeId);
console.log('表单数据:', data);
// 确保 nodeId 正确设置
const formDataWithNodeId = {
...data,
nodeId: currentNodeId
};
const typedFormData = createFormData.voiceCall(formDataWithNodeId);
updateNodeFormData(typedFormData);
}, [currentNodeId]);
// 更新节点表单数据的通用函数
const updateNodeFormData = useCallback((typedFormData: SimpleFormData) => {
// 更新节点数据,保存表单数据和开启状态
setNodes((currentNodes) =>
currentNodes.map(node =>
node.id === currentNodeId
? {
...node,
data: {
...node.data,
formData: typedFormData,
isFormEnabled: true // 保存时设置为开启状态
}
}
: node
)
);
// 保存到全局状态
setFormData(typedFormData);
// 打印更新后的节点信息
const updatedNode = nodes.find(n => n.id === currentNodeId);
if (updatedNode) {
console.log('更新后的节点数据:', {
id: updatedNode.id,
stepName: updatedNode.data.stepName,
formType: updatedNode.data.formType,
formData: typedFormData,
isFormEnabled: true
});
}
// TODO: 这里可以调用后台服务保存表单数据
// 示例:saveFormDataToBackend(currentNodeId, typedFormData.type, typedFormData.data);
console.log('=== 表单数据保存完成 ===');
setFormDrawerOpen(false);
}, [currentNodeId, nodes, setNodes]);
const toggleControls = useCallback(() => {
setShowControls((prev) => !prev);
}, []);
@ -812,6 +1024,7 @@ function ReactFlowDesignerInner({
stepType: step.stepType,
description: step.description,
icon: step.icon,
formType: step.formType, // 添加表单类型字段
},
};
@ -831,124 +1044,7 @@ function ReactFlowDesignerInner({
[] // 移除依赖项,使用ref
);
// 添加样式
const styles = `
/* 防止节点字体被缩放影响 */
.react-flow__node {
transform-origin: center center !important;
}
.react-flow__node .font-medium {
font-size: 12px !important;
line-height: 1.2 !important;
transform: scale(1) !important;
transform-origin: left center !important;
}
.flow-toolbar {
display: flex;
gap: 8px;
margin-left: 16px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #595959;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.toolbar-btn svg {
width: 14px;
height: 14px;
}
.export-btn:hover {
border-color: #52c41a;
color: #52c41a;
}
.import-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.import-success-notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
}
.notification-content {
display: flex;
align-items: center;
gap: 8px;
}
.notification-content span {
color: #52c41a;
font-size: 14px;
}
.notification-content button {
background: none;
border: none;
color: #52c41a;
cursor: pointer;
font-size: 16px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.notification-content button:hover {
color: #389e0d;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
// 添加样式到页面
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.textContent = styles;
document.head.appendChild(styleElement);
return () => {
document.head.removeChild(styleElement);
};
}, []);
return (
<Card className="h-full flex flex-col shadow-lg dark:shadow-2xl border-0 bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800">
@ -962,11 +1058,12 @@ function ReactFlowDesignerInner({
{/* 连线类型选择器 */}
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600 dark:text-gray-300">线:</span>
<Select value={edgeType} onValueChange={(value: 'step' | 'smoothstep' | 'straight') => setEdgeType(value)}>
<Select value={edgeType} onValueChange={(value: 'step' | 'smoothstep' | 'straight' | 'default') => setEdgeType(value)}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">线</SelectItem>
<SelectItem value="straight">线</SelectItem>
<SelectItem value="smoothstep">线</SelectItem>
<SelectItem value="step">线</SelectItem>
@ -1055,7 +1152,7 @@ function ReactFlowDesignerInner({
onEdgeContextMenu={onEdgeContextMenu}
onDragOver={onDragOver}
onDrop={onDrop}
onMove={onMove}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
@ -1095,25 +1192,69 @@ function ReactFlowDesignerInner({
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
formType={contextMenu.type === 'node' ? nodes.find(n => n.id === contextMenu.id)?.data.formType : undefined}
isFormEnabled={contextMenu.type === 'node' ? nodes.find(n => n.id === contextMenu.id)?.data.isFormEnabled : undefined}
onClose={closeContextMenu}
onDelete={handleContextMenuDelete}
onShowForm={handleShowForm}
/>
)}
{/* 添加导入成功提示 */}
{importedFlow && (
<div className="import-success-notification">
<div className="notification-content">
<span> Flow数据导入成功</span>
<button onClick={() => setImportedFlow(null)}>×</button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}
{/* 添加导入成功提示 */}
{importedFlow && (
<div className="import-success-notification">
<div className="notification-content">
<span> Flow数据导入成功</span>
<button onClick={() => setImportedFlow(null)}>×</button>
</div>
</div>
)}
{/* 表单组件 */}
{formDrawerOpen && currentFormType === 1 && (
<DeviceRegistrationDrawer
open={formDrawerOpen}
onOpenChange={setFormDrawerOpen}
nodeId={currentNodeId}
onSave={handleDeviceRegistrationSave}
initialData={formData && currentFormType === 1 ? (formData as DeviceRegistrationData) : undefined}
/>
)}
{formDrawerOpen && currentFormType === 2 && (
<NetworkConnectivityDrawer
open={formDrawerOpen}
onOpenChange={setFormDrawerOpen}
nodeId={currentNodeId}
onSave={handleNetworkConnectivitySave}
initialData={formData && currentFormType === 2 ? (formData as NetworkConnectivityData) : undefined}
/>
)}
{formDrawerOpen && currentFormType === 3 && (
<NetworkPerformanceDrawer
open={formDrawerOpen}
onOpenChange={setFormDrawerOpen}
nodeId={currentNodeId}
onSave={handleNetworkPerformanceSave}
initialData={formData && currentFormType === 3 ? (formData as NetworkPerformanceData) : undefined}
/>
)}
{formDrawerOpen && currentFormType === 4 && (
<VoiceCallDrawer
open={formDrawerOpen}
onOpenChange={setFormDrawerOpen}
nodeId={currentNodeId}
onSave={handleVoiceCallSave}
initialData={formData && currentFormType === 4 ? (formData as VoiceCallData) : undefined}
/>
)}
</div>
</CardContent>
</Card>
);
}
export default function ReactFlowDesigner(props: ReactFlowDesignerProps) {
return (

5
src/X1.WebUI/src/pages/testcases/TestCasesView.tsx

@ -66,7 +66,10 @@ export default function TestCasesView() {
selected: node.selected,
positionAbsoluteX: node.positionAbsolute?.x,
positionAbsoluteY: node.positionAbsolute?.y,
dragging: node.dragging
dragging: node.dragging,
formData: node.data?.isFormEnabled && node.data?.formData ? JSON.stringify(node.data.formData) : undefined, // 根据是否开启表单来决定是否序列化表单数据
formType: node.data?.formType, // 添加表单类型字段
isFormEnabled: node.data?.isFormEnabled // 添加是否开启表单字段
}));
// 转换连线数据格式

87
src/X1.WebUI/src/pages/teststeps/TestStepsTable.tsx

@ -187,56 +187,47 @@ export default function TestStepsTable({
return (
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
{/* 固定的表头 */}
<div className="bg-background border-b shadow-sm">
<Table className="w-full">
<TableHeader>
<TableRow className="border-b-0">
{columns.filter(col => col.visible !== false).map((column) => (
<TableHead key={column.key} className="text-center whitespace-nowrap bg-background text-xs font-medium py-3">
{column.title || column.label}
</TableHead>
))}
<Table className="w-full">
<TableHeader>
<TableRow className="border-b-0">
{columns.filter(col => col.visible !== false).map((column) => (
<TableHead key={column.key} className="text-center whitespace-nowrap bg-background text-xs font-medium py-3">
{column.title || column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.filter(col => col.visible !== false).length} className="text-center py-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2">...</span>
</div>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
{/* 可滚动的表体 */}
<div className="h-[calc(100vh-400px)] min-h-[300px] max-h-[calc(100vh-200px)] overflow-auto">
<Table className="w-full">
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.filter(col => col.visible !== false).length} className="text-center py-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2">...</span>
</div>
</TableCell>
</TableRow>
) : testSteps.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.filter(col => col.visible !== false).length} className="text-center py-8">
<div className="text-muted-foreground">
</div>
</TableCell>
) : testSteps.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.filter(col => col.visible !== false).length} className="text-center py-8">
<div className="text-muted-foreground">
</div>
</TableCell>
</TableRow>
) : (
testSteps.map((step) => (
<TableRow key={step.id} className="hover:bg-muted/50">
{columns.filter(col => col.visible !== false).map((column) => (
<TableCell key={column.key} className={`${getTableCellClass()} text-center border-b`}>
{renderCell(step, column.key)}
</TableCell>
))}
</TableRow>
) : (
testSteps.map((step) => (
<TableRow key={step.id} className="hover:bg-muted/50">
{columns.filter(col => col.visible !== false).map((column) => (
<TableCell key={column.key} className={`${getTableCellClass()} text-center border-b`}>
{renderCell(step, column.key)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))
)}
</TableBody>
</Table>
</div>
</div>
);

6
src/X1.WebUI/src/services/testcaseService.ts

@ -28,6 +28,9 @@ export interface TestCaseNodeData {
stepType: number;
description?: string;
icon?: string;
formData?: string; // 表单数据(JSON格式)
formType?: number; // 表单类型
isFormEnabled?: boolean; // 是否开启表单
}
// 测试用例节点位置接口
@ -149,6 +152,9 @@ export interface CreateNodeData {
positionAbsoluteX?: number;
positionAbsoluteY?: number;
dragging?: boolean;
formData?: string; // 表单数据(JSON格式)
formType?: number; // 表单类型
isFormEnabled?: boolean; // 是否开启表单
}
// 创建测试用例流程连线数据接口

109
src/X1.WebUI/src/styles/reactflow.css

@ -0,0 +1,109 @@
/* ReactFlow 设计器样式 */
/* 防止节点字体被缩放影响 */
.react-flow__node {
transform-origin: center center !important;
}
.react-flow__node .font-medium {
font-size: 12px !important;
line-height: 1.2 !important;
transform: scale(1) !important;
transform-origin: left center !important;
}
/* 流程图工具栏样式 */
.flow-toolbar {
display: flex;
gap: 8px;
margin-left: 16px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #595959;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.toolbar-btn svg {
width: 14px;
height: 14px;
}
.export-btn:hover {
border-color: #52c41a;
color: #52c41a;
}
.import-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
/* 导入成功通知样式 */
.import-success-notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
}
.notification-content {
display: flex;
align-items: center;
gap: 8px;
}
.notification-content span {
color: #52c41a;
font-size: 14px;
}
.notification-content button {
background: none;
border: none;
color: #52c41a;
cursor: pointer;
font-size: 16px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.notification-content button:hover {
color: #389e0d;
}
/* 动画效果 */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

77
src/X1.WebUI/src/types/formTypes.ts

@ -0,0 +1,77 @@
// 表单数据类型定义 - 与表单组件中的类型保持一致
export interface DeviceRegistrationData {
nodeId: string;
isDualSim: boolean;
sim1Plmn?: string;
sim1CellId?: string;
sim1RegistrationWaitTime?: number;
sim2Plmn?: string;
sim2CellId?: string;
sim2RegistrationWaitTime?: number;
}
export interface NetworkConnectivityData {
nodeId: string;
targetHost: string;
pingCount: number;
timeout: number;
packetSize: number;
interval: number;
}
export interface NetworkPerformanceData {
nodeId: string;
serverHost: string;
serverPort: number;
testDuration: number;
bandwidth: number;
protocol: 'tcp' | 'udp';
parallel: number;
}
export interface VoiceCallData {
nodeId: string;
phoneNumber: string;
callDuration: number;
callType: 'mo' | 'mt';
waitTime: number;
hangupDelay: number;
}
// 联合类型,根据 FormType 确定具体的数据类型
export type FormData =
| { type: 0; data: null } // NoForm
| { type: 1; data: DeviceRegistrationData } // DeviceRegistrationForm
| { type: 2; data: NetworkConnectivityData } // NetworkConnectivityForm
| { type: 3; data: NetworkPerformanceData } // NetworkPerformanceForm
| { type: 4; data: VoiceCallData }; // VoiceCallForm
// 简化的表单数据类型,直接使用具体的数据类型,因为节点已经有formType
export type SimpleFormData =
| DeviceRegistrationData // DeviceRegistrationForm
| NetworkConnectivityData // NetworkConnectivityForm
| NetworkPerformanceData // NetworkPerformanceForm
| VoiceCallData; // VoiceCallForm
// FormType 枚举值
export enum FormType {
NoForm = 0,
DeviceRegistrationForm = 1,
NetworkConnectivityForm = 2,
NetworkPerformanceForm = 3,
VoiceCallForm = 4
}
// 表单类型名称映射
export const FORM_TYPE_NAMES: Record<FormType, string> = {
[FormType.NoForm]: '无表单',
[FormType.DeviceRegistrationForm]: '设备注册表单',
[FormType.NetworkConnectivityForm]: '网络连通性测试表单',
[FormType.NetworkPerformanceForm]: '网络性能测试表单',
[FormType.VoiceCallForm]: '语音通话测试表单'
};
// 获取表单类型名称的辅助函数
export const getFormTypeName = (formType: number): string => {
return FORM_TYPE_NAMES[formType as FormType] || '未知表单';
};

50
src/X1.WebUI/src/utils/formDataUtils.ts

@ -0,0 +1,50 @@
import {
DeviceRegistrationData,
NetworkConnectivityData,
NetworkPerformanceData,
VoiceCallData,
SimpleFormData
} from '@/types/formTypes';
/**
*
* type字段formType
*/
/**
*
*/
export const createDeviceRegistrationFormData = (data: DeviceRegistrationData): DeviceRegistrationData => {
return data;
};
/**
*
*/
export const createNetworkConnectivityFormData = (data: NetworkConnectivityData): NetworkConnectivityData => {
return data;
};
/**
*
*/
export const createNetworkPerformanceFormData = (data: NetworkPerformanceData): NetworkPerformanceData => {
return data;
};
/**
*
*/
export const createVoiceCallFormData = (data: VoiceCallData): VoiceCallData => {
return data;
};
/**
*
*/
export const createFormData = {
deviceRegistration: createDeviceRegistrationFormData,
networkConnectivity: createNetworkConnectivityFormData,
networkPerformance: createNetworkPerformanceFormData,
voiceCall: createVoiceCallFormData
};

661
src/modify.md

@ -1,5 +1,666 @@
# 修改记录
## 2024-12-19 - 修复 ScenarioType 类型找不到的问题
### 问题描述
`X1.Domain/Entities/TestCase/TestScenario.cs` 文件中,`ScenarioType` 类型无法找到,导致编译错误:
- 未能找到类型或命名空间名"ScenarioType"(是否缺少 using 指令或程序集引用?)
- 当前上下文中不存在名称"ScenarioType"
### 解决方案
1. **创建 ScenarioType 枚举文件**:在 `X1.Domain/Entities/TestCase/ScenarioType.cs` 中定义了 `ScenarioType` 枚举
2. **枚举定义包含以下类型**
- Functional (功能测试) = 1
- Performance (性能测试) = 2
- Stress (压力测试) = 3
- Compatibility (兼容性测试) = 4
- Regression (回归测试) = 5
- Integration (集成测试) = 6
- Security (安全测试) = 7
- UserExperience (用户体验测试) = 8
### 修改的文件
- `X1.Domain/Entities/TestCase/ScenarioType.cs` (新增)
- `modify.md` (更新,用于记录修改)
### 技术细节
- 枚举遵循项目现有的命名规范,使用 `Display``Description` 特性
- 与 `TestScenario.cs` 在同一命名空间 `X1.Domain.Entities.TestCase` 中,无需额外 using 语句
- 默认值设置为 `ScenarioType.Functional`,符合测试场景的常见用途
### 验证
- 编译错误已解决
- `TestScenario.cs` 中的 `ScenarioType` 引用现在可以正确识别
- 枚举值 `ScenarioType.Functional` 在默认值设置中正常工作
---
## 2024-12-19 - 添加测试场景相关实体
### 新增文件
#### 1. X1.Domain/Entities/TestCase/TestScenario.cs
- 创建测试场景实体
- 包含字段:
- 场景编码 (ScenarioCode)
- 场景名称 (ScenarioName)
- 场景类型 (Type) - 枚举类型
- 场景说明 (Description)
- 是否启用 (IsEnabled)
- 优先级 (Priority)
- 提供 Create() 和 Update() 方法
- 包含场景类型枚举 (ScenarioType)
#### 2. X1.Domain/Entities/TestCase/ScenarioTestCase.cs
- 创建场景测试用例实体(替代糟糕的 TestScenarioTestCase 命名)
- 表示场景中包含的测试用例
- 包含字段:
- 场景ID (ScenarioId)
- 测试用例流程ID (TestCaseFlowId)
- 是否启用 (IsEnabled)
- 执行顺序 (ExecutionOrder)
- 提供 Create() 和 Update() 方法
### 修改文件
#### 1. X1.Domain/Entities/TestCase/TestCaseFlow.cs
- 添加导航属性:`TestScenarioTestCases`
- 支持与场景的一对多关系
### 关系设计
- TestScenario (1) ←→ (N) ScenarioTestCase (N) ←→ (1) TestCaseFlow
- 一个场景可以包含多个测试用例
- 一个测试用例可以属于多个场景
- 通过中间表 ScenarioTestCase 实现多对多关系
---
## 2024-12-19 - 重构 TestScenarioTestCase 实体命名和结构
### 问题描述
- `TestScenarioTestCase` 命名不规范,重复了 "Test" 前缀
- 实体功能过于简单,缺乏测试用例执行所需的配置信息
- 命名不符合领域驱动设计的最佳实践
### 解决方案
1. **重命名为 `ScenarioTestCase`**
- 更简洁明了的命名
- 符合领域语言
- 避免重复的 "Test" 前缀
2. **简化实体功能**
- 保留核心字段:场景ID、测试用例流程ID、启用状态、执行顺序
- 移除复杂的执行配置,保持实体简洁
- 专注于场景与测试用例的关联关系
3. **更新导航属性**
- `TestScenario.TestScenarioTestCases``TestScenario.ScenarioTestCases`
- 更符合领域语言
### 修改的文件
- `X1.Domain/Entities/TestCase/ScenarioTestCase.cs` (新增,替代旧文件)
- `X1.Domain/Entities/TestCase/TestScenario.cs` (更新导航属性)
- `X1.Domain/Entities/TestCase/TestScenarioTestCase.cs` (删除)
### 技术特性
- **领域驱动设计**:使用更符合业务语言的命名
- **简洁设计**:专注于核心关联关系,避免过度设计
- **向后兼容**:保持原有的核心功能
- **易于维护**:简化的结构便于理解和维护
---
## 2025-01-22 - 为 TestCaseDetailDrawer 添加连线箭头功能
### 修改内容
1. **问题分析**
- `TestCaseDetailDrawer.tsx` 中的 ReactFlow 组件缺少连线箭头功能
- 需要与 `ReactFlowDesigner.tsx` 保持一致的视觉效果
- 连线缺少方向指示,影响用户体验
2. **解决方案**
- 在 `getReactFlowData` 方法中为 edges 添加 `markerEnd` 属性
- 使用与 `ReactFlowDesigner.tsx` 相同的箭头配置
- 添加类型断言解决 TypeScript 类型兼容性问题
3. **技术实现**
```typescript
const flowEdges = testCase.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed' as const,
width: 8,
height: 8,
color: '#3b82f6',
},
data: edge.data
})) as Edge[];
```
4. **优化内容**
- 添加 `EdgeMarker` 类型导入
- 使用类型断言确保类型安全
- 保持与设计器组件一致的箭头样式
### 修改时间
2025-01-22
### 修改原因
用户要求在 `FormTypeDrawer.tsx` 中保存之前 `ReactFlowDesigner.tsx` 的连线箭头功能,实际需要在 `TestCaseDetailDrawer.tsx` 中添加箭头功能。
### 验证方法
1. 打开测试用例详情抽屉,查看连线是否显示箭头
2. 确认箭头样式与设计器中的一致
3. 验证箭头方向正确指示流程方向
---
## 2025-01-22 - 修复 ReactFlow 缩放显示问题
### 修改内容
1. **问题分析**
- ReactFlow 缩放功能显示 NaN 或缩放值不更新
- `onMove` 回调接收的是 `WheelEvent` 而不是 `viewport` 对象
- 需要正确监听视口变化来更新缩放值
2. **解决方案**
- 使用 `useEffect``setInterval` 定期检查视口变化
- 通过 `reactFlowInstance.getViewport()` 获取当前视口状态
- 添加数值验证,确保只有有效的缩放值才会更新状态
- 优化显示逻辑,防止 NaN 显示
3. **技术实现**
```typescript
// 使用 useEffect 监听视口变化
useEffect(() => {
const interval = setInterval(() => {
const viewport = reactFlowInstance.getViewport();
const { zoom } = viewport;
if (typeof zoom === 'number' && !isNaN(zoom) && zoom !== currentZoom) {
setCurrentZoom(zoom);
}
}, 100); // 每100ms检查一次
return () => clearInterval(interval);
}, [reactFlowInstance, currentZoom]);
```
4. **优化内容**
- 移除调试日志,提高性能
- 添加缩放值变化检测,避免不必要的状态更新
- 确保缩放显示始终为有效数值
### 修改时间
2025-01-22
### 修改原因
用户报告缩放功能显示 NaN 或缩放值不更新,影响用户体验。
### 验证方法
1. 测试缩放功能,确保缩放值实时更新
2. 验证缩放显示不会出现 NaN
3. 确认缩放重置功能正常工作
---
## 2025-01-22 - 修复 ImsiRegistrationRecord 外键约束错误
### 修改内容
1. **问题分析**
- **TestCaseNode** 有两个ID字段:
- `Id`:数据库主键(通过 `Guid.NewGuid().ToString()` 生成)
- `NodeId`:前端ReactFlow的节点ID(前端传入)
- **ImsiRegistrationRecord.NodeId** 应该引用 `TestCaseNode.Id`(数据库主键),而不是 `TestCaseNode.NodeId`(前端节点ID)
- 在 `BuildImsiRegistrationRecord` 方法中,传递的是 `nodeData.Id`(前端节点ID),导致外键约束失败
2. **解决方案**
- 在 `CreateNodeEntitiesAsync` 方法中创建 `nodeIdMapping` 字典,保存前端节点ID到数据库节点ID的映射
- 修改 `ProcessFormDataBatchAsync` 方法签名,添加 `nodeIdMapping` 参数
- 修改 `BuildFormDataEntities` 方法,使用 `nodeIdMapping` 获取正确的数据库节点ID
- 在创建 `ImsiRegistrationRecord` 时使用数据库节点ID而不是前端节点ID
3. **技术实现**
- 使用 `Dictionary<string, string>` 存储前端节点ID到数据库节点ID的映射
- 在创建 `TestCaseNode` 后立即保存映射关系
- 在 `BuildFormDataEntities` 中使用 `TryGetValue` 安全获取数据库节点ID
- 添加详细的日志记录,区分前端节点ID和数据库节点ID
4. **修改的方法**
- `CreateNodeEntitiesAsync`:添加节点ID映射逻辑
- `ProcessFormDataBatchAsync`:添加 `nodeIdMapping` 参数
- `BuildFormDataEntities`:使用正确的数据库节点ID创建实体
### 修改时间
2025-01-22
### 修改原因
用户报告外键约束错误:`insert or update on table "tb_imsi_registration_records" violates foreign key constraint "FK_tb_imsi_registration_records_tb_testcasenode_NodeId"`。问题在于 `ImsiRegistrationRecord.NodeId` 引用了错误的前端节点ID而不是数据库节点ID。
### 验证方法
1. 创建测试用例流程时,确保 `ImsiRegistrationRecord``NodeId` 字段正确引用 `TestCaseNode.Id`
2. 检查数据库中的外键约束是否满足
3. 验证日志中显示的前端节点ID和数据库节点ID映射是否正确
---
## 2025-01-22 - 修复 CreateNodesAsync 方法中的设备注册表单处理逻辑
### 修改内容
1. **完善 CreateNodesAsync 方法中的表单处理逻辑**
- 修复了 `if (nodeData.IsFormEnabled==true && nodeData.FormData is not null)` 条件判断
- 使用 `Select` 预处理节点数据,分离表单处理逻辑,避免嵌套循环
- 提取了表单处理逻辑到独立的方法中,提高代码可维护性
- 创建了 `DeviceRegistrationFormData` 类来映射前端表单数据
2. **添加设备注册表单数据处理**
- 当 `FormType.DeviceRegistrationForm` 时,解析表单数据并创建 `ImsiRegistrationRecord` 实体
- 支持双卡配置:`isDualSim`、`sim1Plmn`、`sim1CellId`、`sim1RegistrationWaitTime` 等字段
- 自动保存 IMSI 注册记录到数据库
- 使用策略模式处理不同类型的表单数据,便于扩展
- 实现并行处理表单数据,提高性能
- 分离实体组装和数据库保存操作,实现更好的解耦
3. **依赖注入和仓储模式**
- 添加了 `IImsiRegistrationRecordRepository` 依赖注入
- 使用仓储模式保存 IMSI 注册记录
- 确保数据一致性和事务完整性
4. **数据模型定义**
- 在 `X1.Domain/Models/DeviceRegistrationFormData.cs` 中创建了 `DeviceRegistrationFormData` 类,包含以下字段:
- `NodeId`: 节点ID
- `IsDualSim`: 是否双卡
- `Sim1Plmn`: 卡1 PLMN
- `Sim1CellId`: 卡1 CellId
- `Sim1RegistrationWaitTime`: 卡1 注册等待时间
- `Sim2Plmn`: 卡2 PLMN
- `Sim2CellId`: 卡2 CellId
- `Sim2RegistrationWaitTime`: 卡2 注册等待时间
- 创建了 `ProcessedNodeData` 类用于预处理节点数据,包含:
- `NodeData`: 原始节点数据
- `FormDataJson`: 序列化后的表单数据JSON字符串
- `HasFormData`: 是否有表单数据需要处理
- 创建了 `FormDataEntities` 类用于强类型管理表单数据实体,包含:
- `ImsiRegistrationRecords`: IMSI 注册记录集合
- `HasAnyEntities()`: 检查是否有任何实体需要保存
- `GetTotalEntityCount()`: 获取所有实体的总数量
- 删除了冗余的 `ProcessDeviceRegistrationFormAsync` 方法,其功能已被新的批量处理方法替代
- 创建了 `TestCaseFlowBuilder` 类,将节点和连线创建逻辑从命令处理器中提取出来
- 在 `TestCaseFlowBuilder` 中进一步提取了 `PreprocessNodes``CreateNodeEntitiesAsync` 方法,使代码结构更清晰
- 在 `X1.Application/DependencyInjection.cs` 中注册了 `TestCaseFlowBuilder` 服务
- 修复了 `DeviceRegistrationFormData` 类的 JSON 反序列化问题,添加了 `JsonPropertyName` 属性映射
- 在 `TestCaseFlowBuilder` 中添加了详细的 JSON 反序列化错误处理和调试日志
- 修复了 JSON 双重序列化问题,添加了 `GetFormDataJson` 方法来正确处理表单数据
- 使用 `Newtonsoft.Json``System.Text.Json.Nodes.JsonObject.Parse` 来解决转义 JSON 字符串的反序列化问题
- 移除了 `System.Text.Json` 引用,统一使用 `Newtonsoft.Json` 进行 JSON 序列化和反序列化
- 移除了冗余的 `GetFormDataJson` 方法,简化了预处理逻辑
- 删除了冗余的 `BuildFormDataEntity` 方法,将表单处理逻辑直接整合到 `BuildFormDataEntities` 方法中
5. **技术特性**
- **类型安全**:使用强类型的数据模型
- **JSON 序列化**:正确解析前端传递的 JSON 数据
- **错误处理**:包含空值检查和异常处理
- **日志记录**:添加详细的日志信息
- **架构规范**:将数据模型放在 Domain 层,遵循 DDD 架构原则
- **代码重构**:提取表单处理逻辑到独立方法,提高代码可读性和可维护性
- **扩展性**:使用 switch 语句处理不同表单类型,便于后续添加新的表单类型
- **性能优化**:使用 `Select` 预处理数据,实现并行处理表单数据
- **分离关注点**:将节点创建和表单处理分离,提高代码清晰度
- **解耦设计**:分离实体组装(同步)和数据库保存(异步)操作
- **合理使用异步**:只在真正需要异步的地方使用异步操作,避免为异步而异步
- **类型安全**:使用强类型替代 `object`,避免运行时类型错误
- **代码清理**:删除冗余方法,保持代码简洁
- **职责分离**:将流程构建逻辑提取到专门的构建器类中
- **方法细化**:将复杂方法拆分为更小、更专注的方法
- **依赖注入**:正确注册和配置服务依赖
- **数据映射**:确保前后端数据格式的一致性
### 修改时间
2025-01-22
### 修改原因
用户要求修复 `CreateNodesAsync` 方法中的设备注册表单处理逻辑,确保能够正确解析前端传递的表单数据并创建对应的数据库记录。
---
## 2025-01-22 - 添加 FormType 字段到 ReactFlowDesigner 并创建对应的表单组件
### 修改内容
1. **更新 ReactFlowDesigner 中的节点数据结构**
- 在 `FlowNode` 类型中添加 `formType?: number` 字段
- 在创建新节点时包含 `formType: step.formType` 字段
2. **创建表单类型对应的 Drawer 组件**
- `DeviceRegistrationDrawer.tsx` - 设备注册表单 (FormType.DeviceRegistrationForm = 1)
- `NetworkConnectivityDrawer.tsx` - 网络连通性测试表单 (FormType.NetworkConnectivityForm = 2)
- `NetworkPerformanceDrawer.tsx` - 网络性能测试表单 (FormType.NetworkPerformanceForm = 3)
- `VoiceCallDrawer.tsx` - 语音通话测试表单 (FormType.VoiceCallForm = 4)
3. **表单组件特性**
- 每个组件都有完整的表单验证
- 支持初始数据加载和编辑
- 统一的 UI 设计和交互模式
- 类型安全的数据接口
4. **添加右键菜单表单显示功能**
- 在右键菜单中添加了"显示表单"选项
- 根据节点的 `formType` 显示对应的表单组件
- 添加了表单状态管理和处理函数
- 支持表单数据的保存和是否开启状态管理
5. **样式优化**
- 将 `ReactFlowDesigner.tsx` 中的内联样式提取到单独的 `reactflow.css` 文件中
- 便于后续维护和样式管理
6. **提取表单类型定义到单独文件**
- 创建 `X1.WebUI/src/types/formTypes.ts` 文件
- 包含所有表单数据类型的定义
- 添加 FormType 枚举和辅助函数
- 便于后期维护和类型管理
### 表单类型映射
- **FormType.NoForm (0)** - 无表单,无需特殊处理
- **FormType.DeviceRegistrationForm (1)** - 设备注册表单,支持双卡配置
- **FormType.NetworkConnectivityForm (2)** - 网络连通性测试表单,Ping 测试配置
- **FormType.NetworkPerformanceForm (3)** - 网络性能测试表单,Iperf 测试配置
- **FormType.VoiceCallForm (4)** - 语音通话测试表单,通话配置
### 修改时间
2025-01-22
### 修改原因
用户要求在 ReactFlowDesigner 中检查并添加 FormType 字段,根据不同的表单类型显示不同的表单组件。
---
## 2025-01-22 - 节点数据保存优化
### 修改内容
1. **TestCasesView.tsx 节点数据转换优化**
- 更新了 `handleSaveFormSubmit` 函数中的节点数据转换逻辑
- 在 `convertedNodes` 映射中添加了以下字段:
- `formData: node.data?.formData` - 表单数据字段
- `formType: node.data?.formType` - 表单类型字段
- `isFormEnabled: node.data?.isFormEnabled` - 是否开启表单字段
2. **数据完整性保障**
- 确保表单配置数据能够完整保存到后端
- 支持后续的流程加载和表单状态恢复
- 保持前端表单状态与后端数据的一致性
3. **技术特性**
- **类型安全**:使用可选链操作符确保安全访问
- **数据完整性**:保存所有必要的表单相关字段
- **向后兼容**:不影响现有的节点数据结构
- **用户体验**:确保表单配置在流程保存后能够正确恢复
### 修改时间
2025-01-22
### 修改原因
用户要求在保存测试用例流程时,确保 `formData`、`formType` 和 `isFormEnabled` 字段也被包含在节点数据中,以便后续加载流程时能够正确恢复表单状态。
---
## 2025-01-22 - 后端 NodeData 添加表单相关字段
### 修改内容
1. **CreateTestCaseFlowCommand.cs 中的 NodeData 类增强**
- 在 `NodeData` 类中添加了以下表单相关字段:
- `FormData: object?` - 表单数据字段,支持任意类型的表单数据
- `FormType: int?` - 表单类型字段,对应 FormType 枚举值
- `IsFormEnabled: bool?` - 是否开启表单字段,表示表单是否已配置
2. **前后端数据一致性**
- 确保后端 `NodeData` 类与前端传递的数据结构完全匹配
- 支持表单配置数据的完整保存和恢复
- 保持类型安全和数据完整性
3. **技术特性**
- **类型安全**:使用可空类型确保向后兼容
- **数据完整性**:支持完整的表单配置信息
- **扩展性**:为未来表单功能扩展预留空间
- **一致性**:前后端数据结构完全对应
### 修改时间
2025-01-22
### 修改原因
用户要求在后端的 `NodeData` 类中添加对应的表单相关字段,以匹配前端传递的 `formData`、`formType` 和 `isFormEnabled` 数据,确保前后端数据结构一致。
---
## 2025-01-22 - TestCaseNode 实体和处理器添加表单支持
### 修改内容
1. **TestCaseNode 实体增强**
- 在 `TestCaseNode` 实体中添加了以下表单相关字段:
- `FormData: string?` - 表单数据字段,以JSON格式存储
- `FormType: int?` - 表单类型字段,对应 FormType 枚举值
- `IsFormEnabled: bool` - 是否开启表单字段,默认值为 false
- 更新了 `Create` 方法,添加了表单相关参数支持
- 保持了向后兼容性,所有新参数都有默认值
2. **CreateTestCaseFlowCommandHandler 处理器更新**
- 在 `CreateNodesAsync` 方法中添加了表单数据处理逻辑
- 将前端传递的 `FormData` 对象序列化为JSON字符串存储
- 传递 `FormType``IsFormEnabled` 字段到 `TestCaseNode.Create` 方法
- 添加了完整的表单数据保存支持
3. **技术特性**
- **JSON序列化**:使用 `System.Text.Json.JsonSerializer` 将表单数据序列化为JSON字符串
- **类型安全**:使用可空类型确保向后兼容
- **数据完整性**:支持完整的表单配置信息保存
- **扩展性**:为未来表单功能扩展预留空间
### 修改时间
2025-01-22
### 修改原因
用户要求在 `CreateTestCaseFlowCommandHandler` 中也处理新增的表单相关字段,确保表单配置数据能够正确保存到数据库中。
---
## 2025-01-22 - 前端表单数据序列化优化
### 修改内容
1. **TestCasesView.tsx 数据序列化**
- 修改了 `convertedNodes` 中的 `formData` 字段处理逻辑
- 将前端传递的 `formData` 对象使用 `JSON.stringify()` 序列化为JSON字符串
- 确保与后台 `FormData` 字段的 JSON 格式存储方式保持一致
- 添加了空值检查,避免序列化 `null``undefined`
2. **数据格式统一**
- **前端**:`formData` 对象 → `JSON.stringify()` → JSON字符串
- **后台**:`FormData` 字段接收 JSON字符串格式的数据
- **数据库**:以 JSON字符串格式存储表单配置数据
3. **技术特性**
- **类型安全**:确保数据格式的一致性
- **向后兼容**:保持现有功能不受影响
- **数据完整性**:完整的表单配置信息保存
### 修改时间
2025-01-22
### 修改原因
用户指出后台 `FormData` 字段是 JSON 格式字符串,需要将前端传递的 `formData` 对象序列化为 JSON 字符串以匹配后台存储格式。
---
## 2025-01-22 - 表单数据条件序列化和服务接口更新
### 修改内容
1. **TestCasesView.tsx 条件序列化优化**
- 修改了 `formData` 字段的处理逻辑,现在根据 `isFormEnabled` 来判断是否序列化表单数据
- 只有当 `isFormEnabled``true``formData` 存在时才进行 JSON 序列化
- 修复了类型错误,将 `null` 改为 `undefined` 以匹配接口定义
2. **testcaseService.ts 接口更新**
- 在 `CreateNodeData` 接口中添加了表单相关字段:
- `formData?: string` - 表单数据(JSON格式)
- `formType?: number` - 表单类型
- `isFormEnabled?: boolean` - 是否开启表单
- 确保前端接口定义与后端 `NodeData` 类保持一致
3. **数据逻辑优化**
- **条件序列化**:只有在表单开启且有数据时才序列化,避免存储空数据
- **类型安全**:修复了 TypeScript 类型错误
- **接口一致性**:前后端接口定义完全匹配
### 修改时间
2025-01-22
### 修改原因
用户要求根据 `isFormEnabled` 字段来判断是否序列化 `formData`,并且需要检查 `testcaseService.ts` 是否已经更新以匹配新的 `CreateTestCaseFlowCommand` 结构。
---
## 2025-01-22 - 表单数据工具类提取和类型优化
### 修改内容
1. **创建表单数据工具类**
- 新建 `X1.WebUI/src/utils/formDataUtils.ts` 文件
- 提取了表单数据创建逻辑,不再需要 `type` 字段
- 提供了统一的表单数据创建接口:
- `createDeviceRegistrationFormData`
- `createNetworkConnectivityFormData`
- `createNetworkPerformanceFormData`
- `createVoiceCallFormData`
2. **类型系统优化**
- 在 `formTypes.ts` 中添加了 `SimpleFormData` 类型
- 移除了 `type` 字段,因为节点已经有 `formType`
- 简化了表单数据结构,避免冗余信息
3. **ReactFlowDesigner.tsx 重构**
- 更新了所有表单保存处理函数,使用新的工具类
- 修改了 `updateNodeFormData` 函数参数类型
- 更新了 `formData` 状态变量类型
- 修复了 Drawer 组件的 `initialData` 类型转换
4. **代码优化**
- **类型安全**:使用类型断言确保正确的数据类型传递
- **代码复用**:表单数据创建逻辑集中管理
- **维护性**:表单数据创建逻辑独立维护
- **一致性**:统一的数据创建模式
### 修改时间
2025-01-22
### 修改原因
用户要求将 `typedFormData` 提取到单独的文件中维护,不需要 `type` 字段,因为节点已经有了 `formType`
---
## 2025-01-22 - 修复右键菜单中 formType = 0 的处理
### 修改内容
1. **完善 getFormTypeName 函数**
- 在 `getFormTypeName` 函数中添加了 `case 0: return '无表单';` 的处理
- 确保所有表单类型都有对应的名称显示
2. **右键菜单逻辑验证**
- 确认右键菜单的条件判断 `formType > 0` 是正确的
- 确认 `handleShowForm` 函数中的检查 `node.data.formType === 0` 是正确的
- 当 `formType` 为 0 时,不会显示"显示表单"选项
3. **逻辑完整性**
- **右键菜单显示**:只有当 `formType > 0` 时才显示表单配置选项
- **表单打开检查**:在 `handleShowForm` 中再次检查 `formType === 0` 的情况
- **双重保护**:确保无表单的节点不会意外打开表单
### 修改时间
2025-01-22
### 修改原因
用户指出右键菜单在 `formType` 为 0 时显示不正确,需要完善表单类型名称的处理逻辑。
---
## 2025-01-22 - 修复表单数据结构,移除多余的 data 包装
### 修改内容
1. **简化表单数据结构**
- 移除了 `formData` 中外层的 `data` 包装
- 现在 `formData` 直接是具体的数据对象,而不是 `{"data": {...}}` 格式
- 例如:`{"nodeId":"node-1","isDualSim":false,...}` 而不是 `{"data":{"nodeId":"node-1","isDualSim":false,...}}`
2. **更新 formDataUtils.ts**
- 修改所有表单数据创建函数,直接返回数据对象
- 更新返回类型,不再使用 `SimpleFormData` 包装
- 简化了数据创建逻辑
3. **更新 formTypes.ts**
- 修改 `SimpleFormData` 类型定义,直接使用具体的数据类型
- 移除了 `{ data: ... }` 的包装结构
4. **更新 ReactFlowDesigner.tsx**
- 修改 Drawer 组件的 `initialData` 类型转换
- 直接使用具体的数据类型进行类型断言
- 简化了数据传递逻辑
5. **数据结构对比**
- **之前**:`{"data":{"nodeId":"node-1","isDualSim":false,...}}`
- **现在**:`{"nodeId":"node-1","isDualSim":false,...}`
### 修改时间
2025-01-22
### 修改原因
用户指出 `formData` 中不需要 `data` 包装,数据结构应该是直接的数据对象而不是 `{"data": {...}}` 格式。
---
## 2025-01-27 - TestStepsTable.tsx 表格对齐问题修复
### 修改内容
1. **问题描述**
- 表格的表头和表体单元格没有对齐,导致显示效果不佳
- 原代码将表头和表体分为两个独立的 `Table` 组件,导致列宽计算不一致
2. **解决方案**
- 将表头和表体合并到一个 `Table` 组件中
- 移除了分离的表头和表体容器
- 确保所有列使用相同的宽度计算逻辑
3. **具体修改**
- 删除了分离的表头容器 `<div className="bg-background border-b shadow-sm">`
- 删除了分离的表体容器 `<div className="h-[calc(100vh-400px)] min-h-[300px] max-h-[calc(100vh-200px)] overflow-auto">`
- 将 `TableHeader``TableBody` 合并到同一个 `Table` 组件中
- 保持了原有的样式和功能
4. **影响**
- 修复了表格对齐问题
- 提高了表格的视觉效果
- 保持了原有的响应式设计和交互功能
### 修改时间
2025-01-27
### 修改原因
用户反映 TestStepsTable.tsx 表格的单元格和表头没有对齐,需要修复表格布局问题。
---
## 2025-01-22 - TestStepForm 改为 Drawer 方式
### 修改内容

305
src/场景与用例类型分析.md

@ -0,0 +1,305 @@
# 场景类型与用例流程类型分析
## 概述
本文档详细分析了测试场景类型(ScenarioType)和测试用例流程类型(TestFlowType)的区别、关系以及在实际项目中的应用。
## 1. 概念层次对比
### 1.1 场景类型(ScenarioType)- 业务层面
场景类型关注的是**业务目标和测试目的**,定义了为什么要进行测试以及测试要达到什么目标。
```csharp
public enum ScenarioType
{
Functional = 1, // 功能测试 - 验证业务功能
Performance = 2, // 性能测试 - 验证系统性能
Stress = 3, // 压力测试 - 验证系统极限
Regression = 4, // 回归测试 - 验证修改不影响现有功能
Integration = 5, // 集成测试 - 验证系统间集成
UAT = 6, // 用户验收测试 - 用户验收
Other = 99 // 其他类型
}
```
### 1.2 用例流程类型(TestFlowType)- 技术层面
用例流程类型关注的是**技术实现和具体操作**,定义了如何进行测试以及具体的测试操作流程。
```csharp
public enum TestFlowType
{
Registration = 1, // 注册测试 - 网络注册流程
Voice = 2, // 语音测试 - 语音通话流程
Data = 3 // 数据测试 - 数据传输流程
}
```
## 2. 作用范围对比
### 2.1 场景类型 - 宏观业务目标
| 场景类型 | 业务目标 | 测试重点 | 适用场景 |
|----------|----------|----------|----------|
| **Functional** | 验证业务功能完整性 | 业务流程、用户操作、功能正确性 | 新功能开发、功能验证 |
| **Performance** | 验证系统性能指标 | 响应时间、吞吐量、资源使用率 | 性能优化、容量规划 |
| **Stress** | 验证系统极限能力 | 最大并发、极限负载、故障恢复 | 系统稳定性、极限测试 |
| **Regression** | 验证修改影响范围 | 现有功能、兼容性、稳定性 | 代码修改、版本发布 |
| **Integration** | 验证系统间协作 | 接口调用、数据流转、系统集成 | 系统集成、接口测试 |
| **UAT** | 用户验收确认 | 用户体验、业务符合性、验收标准 | 用户验收、上线前验证 |
### 2.2 用例流程类型 - 微观技术实现
| 流程类型 | 技术实现 | 具体操作 | 技术重点 |
|----------|----------|----------|----------|
| **Registration** | 网络注册相关 | IMSI注册、网络连接、认证授权 | 网络协议、认证机制 |
| **Voice** | 语音通话相关 | 通话建立、通话质量、通话结束 | 语音编解码、通话控制 |
| **Data** | 数据传输相关 | 数据上传、数据下载、网络性能 | 数据传输、网络性能 |
## 3. 组合关系示例
### 3.1 场景1:5G网络性能测试(Performance场景)
```csharp
// 场景定义 - 业务目标:验证5G网络性能
var performanceScenario = TestScenario.Create(
"5G_PERF_001",
"5G网络性能测试",
ScenarioType.Performance, // 性能测试场景
"admin",
"测试5G网络在不同负载下的性能表现"
);
// 该场景包含多个测试用例流程
var bindings = new List<TestScenarioTestCase>
{
// 1. 网络注册流程(Registration类型)
TestScenarioTestCase.Create(scenario.Id, networkRegistrationFlowId, "admin", 1),
// 2. 数据传输性能测试(Data类型)
TestScenarioTestCase.Create(scenario.Id, dataTransferFlowId, "admin", 2),
// 3. 语音通话质量测试(Voice类型)
TestScenarioTestCase.Create(scenario.Id, voiceCallFlowId, "admin", 3)
};
```
**业务逻辑**:
- 目标:验证5G网络性能
- 步骤1:先进行网络注册
- 步骤2:测试数据传输性能
- 步骤3:测试语音通话质量
### 3.2 场景2:用户注册功能测试(Functional场景)
```csharp
// 场景定义 - 业务目标:验证用户注册功能
var functionalScenario = TestScenario.Create(
"USER_REG_001",
"用户注册功能测试",
ScenarioType.Functional, // 功能测试场景
"admin",
"验证用户注册的完整业务流程"
);
// 该场景包含多个测试用例流程
var bindings = new List<TestScenarioTestCase>
{
// 1. 用户注册流程(Registration类型)
TestScenarioTestCase.Create(scenario.Id, userRegistrationFlowId, "admin", 1),
// 2. 邮箱验证流程(Registration类型)
TestScenarioTestCase.Create(scenario.Id, emailVerificationFlowId, "admin", 2),
// 3. 手机验证流程(Registration类型)
TestScenarioTestCase.Create(scenario.Id, phoneVerificationFlowId, "admin", 3)
};
```
**业务逻辑**:
- 目标:验证用户注册功能
- 步骤1:用户填写注册信息
- 步骤2:邮箱验证
- 步骤3:手机号验证
## 4. 设计优势分析
### 4.1 场景类型优势
1. **业务导向**
- 以业务目标为中心
- 关注用户价值和业务价值
- 便于业务人员理解和参与
2. **灵活组合**
- 可以组合不同类型的测试用例流程
- 支持复杂的业务场景
- 适应不同的测试需求
3. **目标明确**
- 每个场景都有明确的测试目标
- 便于制定测试计划和策略
- 便于评估测试结果
4. **可扩展性**
- 支持新的业务场景类型
- 适应业务发展变化
- 便于维护和扩展
### 4.2 用例流程类型优势
1. **技术聚焦**
- 专注于具体的技术实现
- 便于技术人员理解和维护
- 支持技术层面的优化
2. **可重用性**
- 同一个流程可以在不同场景中重用
- 减少重复开发工作
- 提高开发效率
3. **标准化**
- 标准化的技术操作流程
- 便于质量控制和保证
- 支持自动化测试
4. **维护性**
- 技术层面的维护和优化
- 便于问题定位和解决
- 支持持续改进
## 5. 实际应用建议
### 5.1 场景设计原则
```csharp
// 好的场景设计示例
var goodScenario = TestScenario.Create(
"E2E_USER_JOURNEY_001",
"用户端到端旅程测试",
ScenarioType.Functional, // 明确业务目标
"admin",
"验证用户从注册到完成首次购买的完整流程"
);
// 包含多种技术流程
var bindings = new List<TestScenarioTestCase>
{
// 注册流程
TestScenarioTestCase.Create(scenario.Id, registrationFlowId, "admin", 1),
// 登录流程
TestScenarioTestCase.Create(scenario.Id, loginFlowId, "admin", 2),
// 购物流程
TestScenarioTestCase.Create(scenario.Id, shoppingFlowId, "admin", 3),
// 支付流程
TestScenarioTestCase.Create(scenario.Id, paymentFlowId, "admin", 4)
};
```
**设计要点**:
- 场景名称要体现业务目标
- 场景描述要清晰明确
- 包含完整的业务流程
- 考虑用户的实际使用场景
### 5.2 流程设计原则
```csharp
// 好的流程设计示例
var goodFlow = TestCaseFlow.Create(
"USER_REGISTRATION_FLOW",
TestFlowType.Registration, // 明确技术类型
"admin",
viewportX, viewportY, viewportZoom,
"用户注册的标准流程,包含表单验证、网络注册、数据保存等步骤"
);
```
**设计要点**:
- 流程名称要体现技术特点
- 流程描述要包含技术细节
- 专注于单一的技术领域
- 便于在不同场景中重用
### 5.3 组合使用建议
1. **场景优先**
- 先确定业务场景和目标
- 再选择合适的测试用例流程
- 确保场景的完整性和有效性
2. **流程标准化**
- 建立标准的技术流程库
- 确保流程的可重用性
- 持续优化和改进流程
3. **灵活组合**
- 根据业务需求灵活组合流程
- 支持场景的定制化需求
- 保持架构的灵活性
## 6. 最佳实践
### 6.1 场景命名规范
```
格式:{业务领域}_{功能模块}_{测试类型}_{序号}
示例:
- USER_REGISTRATION_FUNCTIONAL_001
- NETWORK_PERFORMANCE_STRESS_001
- PAYMENT_INTEGRATION_REGRESSION_001
```
### 6.2 流程命名规范
```
格式:{技术领域}_{具体功能}_{FLOW}
示例:
- USER_REGISTRATION_FLOW
- NETWORK_CONNECTION_FLOW
- VOICE_CALL_QUALITY_FLOW
```
### 6.3 文档化要求
1. **场景文档**
- 业务背景和目标
- 测试范围和重点
- 预期结果和验收标准
- 依赖条件和环境要求
2. **流程文档**
- 技术实现细节
- 操作步骤和参数
- 错误处理和异常情况
- 性能要求和限制
## 7. 总结
### 7.1 核心区别
| 维度 | 场景类型(ScenarioType) | 用例流程类型(TestFlowType) |
|------|-------------------------|------------------------------|
| **关注点** | 业务目标和价值 | 技术实现和操作 |
| **层次** | 宏观业务层面 | 微观技术层面 |
| **范围** | 完整的业务场景 | 具体的技术流程 |
| **目标** | 验证业务功能 | 执行技术操作 |
| **复用性** | 业务场景复用 | 技术流程复用 |
### 7.2 设计价值
1. **分层清晰**:业务层和技术层分离,职责明确
2. **灵活组合**:支持复杂的业务场景和技术需求
3. **可维护性**:便于独立维护和优化
4. **可扩展性**:支持业务和技术的发展变化
5. **团队协作**:业务人员和技术人员可以并行工作
### 7.3 应用建议
1. **项目初期**:重点设计场景类型,明确业务目标
2. **开发阶段**:重点设计流程类型,实现技术细节
3. **测试阶段**:组合场景和流程,执行完整测试
4. **维护阶段**:持续优化场景和流程,提高质量
这种分层设计使得测试体系既能够满足业务需求,又能够提供技术实现的灵活性,是一个很好的架构设计模式。
Loading…
Cancel
Save