Browse Source

caseflow 保持 用户分配 界面view 修复

release/web-ui-v1.0.0
root 4 months ago
parent
commit
7f3ab12df7
  1. 65
      src/X1.Application/Features/DeviceRuntimes/Commands/StartDeviceRuntime/StartDeviceRuntimeCommandHandler.cs
  2. 175
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommand.cs
  3. 198
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommandHandler.cs
  4. 84
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommandValidator.cs
  5. 57
      src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowResponse.cs
  6. 17
      src/X1.Application/Features/TestCaseFlow/Commands/DeleteTestCaseFlow/DeleteTestCaseFlowCommand.cs
  7. 74
      src/X1.Application/Features/TestCaseFlow/Commands/DeleteTestCaseFlow/DeleteTestCaseFlowCommandHandler.cs
  8. 15
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdQuery.cs
  9. 217
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdQueryHandler.cs
  10. 356
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlowById/GetTestCaseFlowByIdResponse.cs
  11. 35
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsQuery.cs
  12. 88
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsQueryHandler.cs
  13. 98
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsResponse.cs
  14. 4
      src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommand.cs
  15. 157
      src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommandHandler.cs
  16. 10
      src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommandValidator.cs
  17. 0
      src/X1.Domain/Entities/TestCase/TestCaseFlow.cs
  18. 7
      src/X1.Domain/Entities/TestCase/TestCaseNode.cs
  19. 8
      src/X1.Domain/Services/IUserRegistrationService.cs
  20. 5
      src/X1.Infrastructure/Configurations/TestCase/TestCaseNodeConfiguration.cs
  21. 1
      src/X1.Infrastructure/DependencyInjection.cs
  22. 1875
      src/X1.Infrastructure/Migrations/20250821080604_AddTestCaseFlowTables.Designer.cs
  23. 165
      src/X1.Infrastructure/Migrations/20250821080604_AddTestCaseFlowTables.cs
  24. 252
      src/X1.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
  25. 67
      src/X1.Infrastructure/Services/UserManagement/UserRegistrationService.cs
  26. 144
      src/X1.Presentation/Controllers/TestCaseFlowController.cs
  27. 487
      src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx
  28. 26
      src/X1.WebUI/src/components/ui/drawer.tsx
  29. 11
      src/X1.WebUI/src/constants/api.ts
  30. 349
      src/X1.WebUI/src/pages/testcases/ReactFlowDesigner.tsx
  31. 183
      src/X1.WebUI/src/pages/testcases/TestCasesListView.tsx
  32. 67
      src/X1.WebUI/src/pages/testcases/TestCasesView.tsx
  33. 344
      src/X1.WebUI/src/services/testcaseService.ts
  34. 14182
      src/modify.md

65
src/X1.Application/Features/DeviceRuntimes/Commands/StartDeviceRuntime/StartDeviceRuntimeCommandHandler.cs

@ -110,19 +110,35 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
return OperationResult<StartDeviceRuntimeResponse>.CreateFailure("没有有效的网络配置请求,无法启动设备运行时");
}
// 记录每个设备的网络配置构建结果
var processedDevices = networkRequests.Select(nr => nr.DeviceCode).ToHashSet();
var skippedDevices = request.DeviceRequests.Where(dr => !processedDevices.Contains(dr.DeviceCode)).ToList();
if (skippedDevices.Any())
{
_logger.LogWarning("以下设备在网络配置构建阶段被跳过: {SkippedDevices}",
string.Join(", ", skippedDevices.Select(d => $"{d.DeviceCode}({d.NetworkStackCode})")));
}
// 并行启动网络并收集结果
var (successfulDevices, failedDevices) = await StartNetworksInParallelAsync(networkRequests, cancellationToken);
if (failedDevices.Any())
{
_logger.LogWarning("部分设备网络启动失败,失败设备: {FailedDevices}",
string.Join(", ", failedDevices.Select(f => f.DeviceCode)));
string.Join(", ", failedDevices.Select(f => $"{f.DeviceCode}({f.ErrorMessage})")));
}
// 记录网络启动结果统计
_logger.LogInformation("网络启动结果统计 - 总请求数: {TotalRequests}, 成功数: {SuccessCount}, 失败数: {FailureCount}",
networkRequests.Count, successfulDevices.Count, failedDevices.Count);
// 只为成功启动网络的设备创建运行时详情和更新状态
var runtimeDetails = new List<CellularDeviceRuntimeDetail>();
var updatedRuntimes = new List<CellularDeviceRuntime>();
_logger.LogInformation("开始处理设备运行时状态更新,成功启动网络的设备数: {SuccessCount}", successfulDevices.Count);
foreach (var deviceRequest in request.DeviceRequests)
{
// 只处理网络启动成功的设备
@ -150,6 +166,9 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
continue;
}
_logger.LogDebug("找到设备运行时,设备代码: {DeviceCode}, 当前状态: {CurrentStatus}",
deviceRequest.DeviceCode, deviceRuntime.RuntimeStatus);
deviceRuntime.Start(deviceRequest.NetworkStackCode, runtimeCode);
_deviceRuntimeRepository.UpdateRuntime(deviceRuntime);
updatedRuntimes.Add(deviceRuntime);
@ -170,7 +189,8 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
_logger.LogInformation("批量启动设备运行时状态完成,运行时代码: {RuntimeCode}, 成功设备数: {SuccessCount}, 失败设备数: {FailureCount}",
runtimeCode, successfulDevices.Count, failedDevices.Count);
return OperationResult<StartDeviceRuntimeResponse>.CreateSuccess(new StartDeviceRuntimeResponse
// 构建响应数据
var response = new StartDeviceRuntimeResponse
{
Summary = new BatchOperationSummary
{
@ -178,7 +198,20 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
SuccessCount = successfulDevices.Count,
FailureCount = failedDevices.Count
}
});
};
// 根据业务逻辑判断是否成功:只有当所有设备都成功启动时才认为是成功的
if (successfulDevices.Count == request.DeviceRequests.Count)
{
_logger.LogInformation("所有设备启动成功,返回成功结果");
return OperationResult<StartDeviceRuntimeResponse>.CreateSuccess(response);
}
else
{
var errorMessage = $"部分设备启动失败,成功设备数: {successfulDevices.Count}, 失败设备数: {failedDevices.Count}";
_logger.LogWarning(errorMessage);
return OperationResult<StartDeviceRuntimeResponse>.CreateFailure(errorMessage);
}
}
catch (Exception ex)
{
@ -289,7 +322,7 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
// 使用HashSet进行去重,提高性能
var processedDeviceNetworkPairs = new HashSet<string>();
return request.DeviceRequests
var filteredRequests = request.DeviceRequests
.Where(deviceRequest =>
{
// 检查是否已处理过相同的设备-网络堆栈组合
@ -309,21 +342,39 @@ public class StartDeviceRuntimeCommandHandler : IRequestHandler<StartDeviceRunti
return false;
}
// 验证配置有效性
// 验证配置有效性 - 允许更灵活的配置组合
var hasValidRanConfig = deviceNetworkConfigs.Any(s => !string.IsNullOrEmpty(s.RanConfigContent));
var hasValidImsConfig = deviceNetworkConfigs.Any(s => !string.IsNullOrEmpty(s.IMSConfigContent));
var hasValidCoreNetworkConfig = deviceNetworkConfigs.Any(s => !string.IsNullOrEmpty(s.CoreNetworkConfigContent));
var hasValidNetworkConfigs = deviceNetworkConfigs.Any(s =>
!string.IsNullOrEmpty(s.IMSConfigContent) && !string.IsNullOrEmpty(s.CoreNetworkConfigContent));
if (!hasValidRanConfig && !hasValidNetworkConfigs)
// 至少需要RAN配置或者IMS配置(不要求同时有核心网配置)
if (!hasValidRanConfig && !hasValidImsConfig)
{
_logger.LogWarning("设备 {DeviceCode} 的网络配置既缺少RAN配置内容,又缺少有效的IMS和核心网配置内容,跳过处理",
_logger.LogWarning("设备 {DeviceCode} 的网络配置既缺少RAN配置内容,又缺少IMS配置内容,跳过处理",
deviceRequest.DeviceCode);
return false;
}
// 如果有IMS配置但没有核心网配置,记录警告但不阻止
if (hasValidImsConfig && !hasValidCoreNetworkConfig)
{
_logger.LogWarning("设备 {DeviceCode} 有IMS配置但缺少核心网配置,可能影响某些功能",
deviceRequest.DeviceCode);
}
processedDeviceNetworkPairs.Add(deviceNetworkKey);
_logger.LogDebug("设备 {DeviceCode} 的网络配置验证通过,RAN配置: {HasRanConfig}, IMS配置: {HasImsConfig}, 核心网配置: {HasCoreNetworkConfig}, 完整IMS+核心网配置: {HasNetworkConfigs}",
deviceRequest.DeviceCode, hasValidRanConfig, hasValidImsConfig, hasValidCoreNetworkConfig, hasValidNetworkConfigs);
return true;
})
.ToList();
_logger.LogInformation("网络配置请求过滤完成,原始请求数: {OriginalCount}, 有效请求数: {ValidCount}",
request.DeviceRequests.Count, filteredRequests.Count);
return filteredRequests
.Select(deviceRequest =>
{
var deviceNetworkConfigs = networkConfigsByCode[deviceRequest.NetworkStackCode];

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

@ -0,0 +1,175 @@
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
/// <summary>
/// 节点数据DTO
/// </summary>
public class NodeData
{
/// <summary>
/// 节点ID
/// </summary>
[Required]
public string Id { get; set; } = null!;
/// <summary>
/// 节点类型
/// </summary>
[Required]
public string Type { get; set; } = null!;
/// <summary>
/// 步骤ID
/// </summary>
[Required(ErrorMessage = "步骤ID不能为空")]
public string StepId { get; set; } = null!;
/// <summary>
/// 节点位置X坐标
/// </summary>
public double PositionX { get; set; }
/// <summary>
/// 节点位置Y坐标
/// </summary>
public double PositionY { get; set; }
/// <summary>
/// 节点数据
/// </summary>
public object? Data { get; set; }
/// <summary>
/// 节点宽度
/// </summary>
public double? Width { get; set; }
/// <summary>
/// 节点高度
/// </summary>
public double? Height { get; set; }
/// <summary>
/// 是否被选中
/// </summary>
public bool? Selected { get; set; }
/// <summary>
/// 绝对位置X坐标
/// </summary>
public double? PositionAbsoluteX { get; set; }
/// <summary>
/// 绝对位置Y坐标
/// </summary>
public double? PositionAbsoluteY { get; set; }
/// <summary>
/// 是否正在拖拽
/// </summary>
public bool? Dragging { get; set; }
}
/// <summary>
/// 连线数据DTO
/// </summary>
public class EdgeData
{
/// <summary>
/// 连线ID
/// </summary>
[Required]
public string Id { get; set; } = null!;
/// <summary>
/// 源节点ID
/// </summary>
[Required]
public string Source { get; set; } = null!;
/// <summary>
/// 目标节点ID
/// </summary>
[Required]
public string Target { get; set; } = null!;
/// <summary>
/// 连线类型
/// </summary>
[Required(ErrorMessage = "连线类型不能为空")]
public string Type { get; set; } = null!;
/// <summary>
/// 是否动画
/// </summary>
public bool? Animated { get; set; }
/// <summary>
/// 连线条件(JSON字符串格式)
/// </summary>
public string? Condition { get; set; }
/// <summary>
/// 连线样式(JSON字符串格式)
/// </summary>
public string? Style { get; set; }
}
/// <summary>
/// 创建测试用例流程命令
/// </summary>
public class CreateTestCaseFlowCommand : IRequest<OperationResult<CreateTestCaseFlowResponse>>
{
/// <summary>
/// 流程名称
/// </summary>
[Required(ErrorMessage = "流程名称不能为空")]
[MaxLength(200, ErrorMessage = "流程名称不能超过200个字符")]
public string Name { get; set; }
/// <summary>
/// 流程描述
/// </summary>
[MaxLength(1000, ErrorMessage = "流程描述不能超过1000个字符")]
public string? Description { get; set; }
/// <summary>
/// 测试流程类型
/// </summary>
[Required(ErrorMessage = "测试流程类型不能为空")]
public TestFlowType Type { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 视口X坐标
/// </summary>
public double ViewportX { get; set; } = 40.54057017483274;
/// <summary>
/// 视口Y坐标
/// </summary>
public double ViewportY { get; set; } = 21.183463943747256;
/// <summary>
/// 视口缩放级别
/// </summary>
public double ViewportZoom { get; set; } = 1.1367874248827994;
/// <summary>
/// 节点数据列表
/// </summary>
public List<NodeData>? Nodes { get; set; }
/// <summary>
/// 连线数据列表
/// </summary>
public List<EdgeData>? Edges { get; set; }
}

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

@ -0,0 +1,198 @@
using MediatR;
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.Services;
namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
/// <summary>
/// 创建测试用例流程命令处理器
/// </summary>
public class CreateTestCaseFlowCommandHandler : IRequestHandler<CreateTestCaseFlowCommand, OperationResult<CreateTestCaseFlowResponse>>
{
private readonly ITestCaseFlowRepository _testCaseFlowRepository;
private readonly ITestCaseNodeRepository _testCaseNodeRepository;
private readonly ITestCaseEdgeRepository _testCaseEdgeRepository;
private readonly ILogger<CreateTestCaseFlowCommandHandler> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserService _currentUserService;
/// <summary>
/// 初始化命令处理器
/// </summary>
public CreateTestCaseFlowCommandHandler(
ITestCaseFlowRepository testCaseFlowRepository,
ITestCaseNodeRepository testCaseNodeRepository,
ITestCaseEdgeRepository testCaseEdgeRepository,
ILogger<CreateTestCaseFlowCommandHandler> logger,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
{
_testCaseFlowRepository = testCaseFlowRepository;
_testCaseNodeRepository = testCaseNodeRepository;
_testCaseEdgeRepository = testCaseEdgeRepository;
_logger = logger;
_unitOfWork = unitOfWork;
_currentUserService = currentUserService;
}
/// <summary>
/// 处理创建测试用例流程命令
/// </summary>
public async Task<OperationResult<CreateTestCaseFlowResponse>> Handle(CreateTestCaseFlowCommand request, CancellationToken cancellationToken)
{
// 验证请求参数
var validationResult = CreateTestCaseFlowCommandValidator.Validate(request);
if (!validationResult.IsSuccess)
{
return OperationResult<CreateTestCaseFlowResponse>.CreateFailure(validationResult.ErrorMessages ?? new List<string>());
}
_logger.LogInformation("开始创建测试用例流程,流程名称: {Name}, 类型: {Type}",
request.Name, request.Type);
// 验证用户认证
var currentUserId = ValidateUserAuthentication();
if (!currentUserId.IsSuccess)
{
return OperationResult<CreateTestCaseFlowResponse>.CreateFailure(currentUserId.ErrorMessages ?? new List<string>());
}
try
{
// 检查流程名称是否已存在
var nameExists = await _testCaseFlowRepository.NameExistsAsync(request.Name, cancellationToken);
if (nameExists)
{
_logger.LogWarning("测试用例流程名称已存在: {Name}", request.Name);
return OperationResult<CreateTestCaseFlowResponse>.CreateFailure(new List<string> { $"流程名称 '{request.Name}' 已存在" });
}
// 创建测试用例流程
var testCaseFlow = X1.Domain.Entities.TestCase.TestCaseFlow.Create(
name: request.Name,
type: request.Type,
createdBy: currentUserId.Data!,
viewportX: request.ViewportX,
viewportY: request.ViewportY,
viewportZoom: request.ViewportZoom,
description: request.Description,
isEnabled: request.IsEnabled);
// 保存测试用例流程到数据库
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 _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("测试用例流程创建成功,ID: {Id}, 名称: {Name}, 节点数量: {NodeCount}, 连线数量: {EdgeCount}",
createdFlow.Id, createdFlow.Name, request.Nodes?.Count ?? 0, request.Edges?.Count ?? 0);
// 构建响应
var response = new CreateTestCaseFlowResponse
{
Id = createdFlow.Id,
Name = createdFlow.Name,
Description = createdFlow.Description,
Type = createdFlow.Type.ToString(),
IsEnabled = createdFlow.IsEnabled,
ViewportX = createdFlow.ViewportX,
ViewportY = createdFlow.ViewportY,
ViewportZoom = createdFlow.ViewportZoom,
CreatedAt = createdFlow.CreatedAt,
CreatedBy = createdFlow.CreatedBy
};
return OperationResult<CreateTestCaseFlowResponse>.CreateSuccess(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "创建测试用例流程时发生错误,流程名称: {Name}", request.Name);
return OperationResult<CreateTestCaseFlowResponse>.CreateFailure(new List<string> { "创建测试用例流程失败,请稍后重试" });
}
}
/// <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,
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>
private OperationResult<string> ValidateUserAuthentication()
{
var currentUserId = _currentUserService.GetCurrentUserId();
if (string.IsNullOrEmpty(currentUserId))
{
_logger.LogWarning("用户未认证,无法创建测试用例流程");
return OperationResult<string>.CreateFailure(new List<string> { "用户未认证,请先登录" });
}
return OperationResult<string>.CreateSuccess(currentUserId);
}
}

84
src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowCommandValidator.cs

@ -0,0 +1,84 @@
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
/// <summary>
/// 创建测试用例流程命令验证器
/// </summary>
public class CreateTestCaseFlowCommandValidator
{
/// <summary>
/// 验证创建测试用例流程命令
/// </summary>
/// <param name="request">创建测试用例流程命令</param>
/// <returns>验证结果</returns>
public static OperationResult<string> Validate(CreateTestCaseFlowCommand request)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.Name))
{
errors.Add("流程名称不能为空");
}
if (request.Name?.Length > 200)
{
errors.Add("流程名称不能超过200个字符");
}
if (request.Description?.Length > 1000)
{
errors.Add("流程描述不能超过1000个字符");
}
if (!Enum.IsDefined(typeof(TestFlowType), request.Type))
{
errors.Add("无效的测试流程类型");
}
// 验证节点数据
if (request.Nodes != null)
{
foreach (var node in request.Nodes)
{
if (string.IsNullOrWhiteSpace(node.Id))
{
errors.Add("节点ID不能为空");
}
if (string.IsNullOrWhiteSpace(node.Type))
{
errors.Add("节点类型不能为空");
}
}
}
// 验证连线数据
if (request.Edges != null)
{
foreach (var edge in request.Edges)
{
if (string.IsNullOrWhiteSpace(edge.Id))
{
errors.Add("连线ID不能为空");
}
if (string.IsNullOrWhiteSpace(edge.Source))
{
errors.Add("连线源节点ID不能为空");
}
if (string.IsNullOrWhiteSpace(edge.Target))
{
errors.Add("连线目标节点ID不能为空");
}
if (string.IsNullOrWhiteSpace(edge.Type))
{
errors.Add("连线类型不能为空");
}
}
}
return errors.Any()
? OperationResult<string>.CreateFailure(errors)
: OperationResult<string>.CreateSuccess(string.Empty);
}
}

57
src/X1.Application/Features/TestCaseFlow/Commands/CreateTestCaseFlow/CreateTestCaseFlowResponse.cs

@ -0,0 +1,57 @@
namespace X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
/// <summary>
/// 创建测试用例流程响应
/// </summary>
public class CreateTestCaseFlowResponse
{
/// <summary>
/// 流程ID
/// </summary>
public string Id { get; set; }
/// <summary>
/// 流程名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 流程描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 测试流程类型
/// </summary>
public string Type { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 视口X坐标
/// </summary>
public double ViewportX { get; set; }
/// <summary>
/// 视口Y坐标
/// </summary>
public double ViewportY { get; set; }
/// <summary>
/// 视口缩放级别
/// </summary>
public double ViewportZoom { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 创建人
/// </summary>
public string CreatedBy { get; set; }
}

17
src/X1.Application/Features/TestCaseFlow/Commands/DeleteTestCaseFlow/DeleteTestCaseFlowCommand.cs

@ -0,0 +1,17 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using X1.Domain.Common;
namespace X1.Application.Features.TestCaseFlow.Commands.DeleteTestCaseFlow;
/// <summary>
/// 删除测试用例流程命令
/// </summary>
public class DeleteTestCaseFlowCommand : IRequest<OperationResult<bool>>
{
/// <summary>
/// 测试用例流程ID
/// </summary>
[Required(ErrorMessage = "流程ID不能为空")]
public string Id { get; set; } = string.Empty;
}

74
src/X1.Application/Features/TestCaseFlow/Commands/DeleteTestCaseFlow/DeleteTestCaseFlowCommandHandler.cs

@ -0,0 +1,74 @@
using MediatR;
using Microsoft.Extensions.Logging;
using X1.Domain.Common;
using X1.Domain.Repositories.TestCase;
using X1.Domain.Repositories.Base;
using X1.Domain.Services;
namespace X1.Application.Features.TestCaseFlow.Commands.DeleteTestCaseFlow;
/// <summary>
/// 删除测试用例流程命令处理器
/// </summary>
public class DeleteTestCaseFlowCommandHandler : IRequestHandler<DeleteTestCaseFlowCommand, OperationResult<bool>>
{
private readonly ITestCaseFlowRepository _testCaseFlowRepository;
private readonly ILogger<DeleteTestCaseFlowCommandHandler> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserService _currentUserService;
/// <summary>
/// 初始化命令处理器
/// </summary>
public DeleteTestCaseFlowCommandHandler(
ITestCaseFlowRepository testCaseFlowRepository,
ILogger<DeleteTestCaseFlowCommandHandler> logger,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
{
_testCaseFlowRepository = testCaseFlowRepository;
_logger = logger;
_unitOfWork = unitOfWork;
_currentUserService = currentUserService;
}
/// <summary>
/// 处理删除测试用例流程命令
/// </summary>
public async Task<OperationResult<bool>> Handle(DeleteTestCaseFlowCommand request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("开始删除测试用例流程,流程ID: {Id}", request.Id);
// 验证用户认证
var currentUserId = _currentUserService.GetCurrentUserId();
if (string.IsNullOrEmpty(currentUserId))
{
_logger.LogError("无法获取当前用户ID,用户可能未认证");
return OperationResult<bool>.CreateFailure("用户未认证,无法删除测试用例流程");
}
// 检查测试用例流程是否存在
var existingFlow = await _testCaseFlowRepository.GetTestCaseFlowByIdAsync(request.Id, cancellationToken);
if (existingFlow == null)
{
_logger.LogWarning("测试用例流程不存在: {Id}", request.Id);
return OperationResult<bool>.CreateFailure($"测试用例流程 {request.Id} 不存在");
}
// 删除测试用例流程
await _testCaseFlowRepository.DeleteTestCaseFlowAsync(request.Id, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("测试用例流程删除成功,流程ID: {Id}, 流程名称: {Name}",
request.Id, existingFlow.Name);
return OperationResult<bool>.CreateSuccess(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "删除测试用例流程时发生错误,流程ID: {Id}", request.Id);
return OperationResult<bool>.CreateFailure($"删除测试用例流程时发生错误: {ex.Message}");
}
}
}

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

@ -0,0 +1,15 @@
using X1.Domain.Common;
using MediatR;
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlowById;
/// <summary>
/// 根据ID获取测试用例流程详情查询
/// </summary>
public class GetTestCaseFlowByIdQuery : IRequest<OperationResult<GetTestCaseFlowByIdResponse>>
{
/// <summary>
/// 测试用例流程ID
/// </summary>
public string Id { get; set; } = null!;
}

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

@ -0,0 +1,217 @@
using X1.Domain.Common;
using X1.Domain.Repositories.TestCase;
using Microsoft.Extensions.Logging;
using MediatR;
using System.Text.Json;
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlowById;
/// <summary>
/// 根据ID获取测试用例流程详情查询处理器
/// </summary>
public class GetTestCaseFlowByIdQueryHandler : IRequestHandler<GetTestCaseFlowByIdQuery, OperationResult<GetTestCaseFlowByIdResponse>>
{
private readonly ITestCaseFlowRepository _testCaseFlowRepository;
private readonly ITestCaseNodeRepository _testCaseNodeRepository;
private readonly ITestCaseEdgeRepository _testCaseEdgeRepository;
private readonly ICaseStepConfigRepository _caseStepConfigRepository;
private readonly ILogger<GetTestCaseFlowByIdQueryHandler> _logger;
/// <summary>
/// 初始化查询处理器
/// </summary>
public GetTestCaseFlowByIdQueryHandler(
ITestCaseFlowRepository testCaseFlowRepository,
ITestCaseNodeRepository testCaseNodeRepository,
ITestCaseEdgeRepository testCaseEdgeRepository,
ICaseStepConfigRepository caseStepConfigRepository,
ILogger<GetTestCaseFlowByIdQueryHandler> logger)
{
_testCaseFlowRepository = testCaseFlowRepository;
_testCaseNodeRepository = testCaseNodeRepository;
_testCaseEdgeRepository = testCaseEdgeRepository;
_caseStepConfigRepository = caseStepConfigRepository;
_logger = logger;
}
/// <summary>
/// 处理根据ID获取测试用例流程详情查询
/// </summary>
public async Task<OperationResult<GetTestCaseFlowByIdResponse>> Handle(GetTestCaseFlowByIdQuery request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("开始获取测试用例流程详情,ID: {Id}", request.Id);
// 获取测试用例流程详情(包含节点和连线)
var testCaseFlow = await _testCaseFlowRepository.GetTestCaseFlowWithDetailsAsync(request.Id, cancellationToken);
if (testCaseFlow == null)
{
_logger.LogWarning("测试用例流程不存在,ID: {Id}", request.Id);
return OperationResult<GetTestCaseFlowByIdResponse>.CreateFailure("测试用例流程不存在");
}
// 构建响应
var response = new GetTestCaseFlowByIdResponse
{
TestCaseFlow = new TestCaseFlowDetailDto
{
Id = testCaseFlow.Id,
Name = testCaseFlow.Name,
Description = testCaseFlow.Description,
Type = testCaseFlow.Type.ToString(),
IsEnabled = testCaseFlow.IsEnabled,
ViewportX = testCaseFlow.ViewportX,
ViewportY = testCaseFlow.ViewportY,
ViewportZoom = testCaseFlow.ViewportZoom,
CreatedAt = testCaseFlow.CreatedAt,
CreatedBy = testCaseFlow.CreatedBy,
UpdatedAt = testCaseFlow.UpdatedAt,
UpdatedBy = testCaseFlow.UpdatedBy,
Nodes = await MapNodesToReactFlowFormatAsync(testCaseFlow.Nodes, cancellationToken),
Edges = await MapEdgesToReactFlowFormatAsync(testCaseFlow.Edges, cancellationToken)
}
};
_logger.LogInformation("成功获取测试用例流程详情,ID: {Id}, 名称: {Name}, 节点数量: {NodeCount}, 连线数量: {EdgeCount}",
testCaseFlow.Id, testCaseFlow.Name, response.TestCaseFlow.Nodes.Count, response.TestCaseFlow.Edges.Count);
return OperationResult<GetTestCaseFlowByIdResponse>.CreateSuccess(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取测试用例流程详情时发生错误,ID: {Id}", request.Id);
return OperationResult<GetTestCaseFlowByIdResponse>.CreateFailure("获取测试用例流程详情失败,请稍后重试");
}
}
/// <summary>
/// 将节点映射为 ReactFlow 格式
/// </summary>
private async Task<List<TestCaseNodeDto>> MapNodesToReactFlowFormatAsync(IEnumerable<X1.Domain.Entities.TestCase.TestCaseNode>? nodes, CancellationToken cancellationToken)
{
if (nodes == null || !nodes.Any())
return new List<TestCaseNodeDto>();
var result = new List<TestCaseNodeDto>();
foreach (var node in nodes)
{
// 获取步骤配置信息
X1.Domain.Entities.TestCase.CaseStepConfig? stepConfig = null;
if (!string.IsNullOrEmpty(node.StepId))
{
stepConfig = await _caseStepConfigRepository.GetStepConfigByIdAsync(node.StepId, cancellationToken);
}
var nodeDto = new TestCaseNodeDto
{
// ReactFlow 格式字段 - 前端需要的格式
Id = node.NodeId,
Type = "testStep",
Position = new TestCaseNodePositionDto
{
X = node.PositionX,
Y = node.PositionY
},
Data = new TestCaseNodeDataDto
{
StepId = node.StepId ?? "",
StepName = stepConfig?.StepName ?? "Unknown",
StepType = (int)(stepConfig?.StepType ?? X1.Domain.Entities.TestCase.CaseStepType.Process),
StepTypeName = GetStepTypeName(stepConfig?.StepType ?? X1.Domain.Entities.TestCase.CaseStepType.Process),
Description = stepConfig?.Description ?? "",
Icon = stepConfig?.Icon ?? "settings"
},
Width = node.Width,
Height = node.Height,
Selected = node.IsSelected,
PositionAbsolute = node.PositionAbsoluteX.HasValue && node.PositionAbsoluteY.HasValue
? new TestCaseNodePositionDto
{
X = node.PositionAbsoluteX.Value,
Y = node.PositionAbsoluteY.Value
}
: null,
Dragging = node.IsDragging
};
result.Add(nodeDto);
}
return result;
}
/// <summary>
/// 将连线映射为 ReactFlow 格式
/// </summary>
private async Task<List<TestCaseEdgeDto>> MapEdgesToReactFlowFormatAsync(IEnumerable<X1.Domain.Entities.TestCase.TestCaseEdge>? edges, CancellationToken cancellationToken)
{
if (edges == null || !edges.Any())
return new List<TestCaseEdgeDto>();
var result = new List<TestCaseEdgeDto>();
foreach (var edge in edges)
{
// 解析样式 JSON
TestCaseEdgeStyleDto? styleDto = null;
if (!string.IsNullOrEmpty(edge.Style))
{
try
{
styleDto = JsonSerializer.Deserialize<TestCaseEdgeStyleDto>(edge.Style);
}
catch (JsonException)
{
// 如果解析失败,使用默认样式
styleDto = new TestCaseEdgeStyleDto
{
Stroke = "#3b82f6",
StrokeWidth = 2
};
}
}
var edgeDto = new TestCaseEdgeDto
{
// ReactFlow 格式字段 - 前端需要的格式
Id = edge.EdgeId,
Source = edge.SourceNodeId,
SourceHandle = "bottom",
Target = edge.TargetNodeId,
TargetHandle = "top",
Type = edge.EdgeType ?? "smoothstep",
Animated = edge.IsAnimated,
Style = styleDto ?? new TestCaseEdgeStyleDto
{
Stroke = "#3b82f6",
StrokeWidth = 2
},
Data = new TestCaseEdgeDataDto
{
Condition = edge.Condition ?? "default"
}
};
result.Add(edgeDto);
}
return result;
}
/// <summary>
/// 获取步骤类型名称
/// </summary>
private string GetStepTypeName(X1.Domain.Entities.TestCase.CaseStepType stepType)
{
return stepType switch
{
X1.Domain.Entities.TestCase.CaseStepType.Start => "Start",
X1.Domain.Entities.TestCase.CaseStepType.End => "End",
X1.Domain.Entities.TestCase.CaseStepType.Process => "Process",
X1.Domain.Entities.TestCase.CaseStepType.Decision => "Decision",
_ => "Unknown"
};
}
}

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

@ -0,0 +1,356 @@
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlowById;
/// <summary>
/// 测试用例节点数据DTO
/// </summary>
public class TestCaseNodeDataDto
{
/// <summary>
/// 步骤ID
/// </summary>
public string StepId { get; set; } = null!;
/// <summary>
/// 步骤名称
/// </summary>
public string StepName { get; set; } = null!;
/// <summary>
/// 步骤类型
/// </summary>
public int StepType { get; set; }
/// <summary>
/// 步骤类型名称
/// </summary>
public string StepTypeName { get; set; } = null!;
/// <summary>
/// 描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 图标
/// </summary>
public string? Icon { get; set; }
}
/// <summary>
/// 测试用例节点位置DTO
/// </summary>
public class TestCaseNodePositionDto
{
/// <summary>
/// X坐标
/// </summary>
public double X { get; set; }
/// <summary>
/// Y坐标
/// </summary>
public double Y { get; set; }
}
/// <summary>
/// 测试用例节点DTO
/// </summary>
public class TestCaseNodeDto
{
/// <summary>
/// 节点ID
/// </summary>
public string Id { get; set; } = null!;
/// <summary>
/// 节点类型
/// </summary>
public string Type { get; set; } = null!;
/// <summary>
/// 节点位置
/// </summary>
public TestCaseNodePositionDto Position { get; set; } = new();
/// <summary>
/// 节点数据
/// </summary>
public TestCaseNodeDataDto Data { get; set; } = new();
/// <summary>
/// 节点宽度
/// </summary>
public double Width { get; set; }
/// <summary>
/// 节点高度
/// </summary>
public double Height { get; set; }
/// <summary>
/// 是否被选中
/// </summary>
public bool Selected { get; set; }
/// <summary>
/// 绝对位置
/// </summary>
public TestCaseNodePositionDto? PositionAbsolute { get; set; }
/// <summary>
/// 是否正在拖拽
/// </summary>
public bool Dragging { get; set; }
// 兼容性字段 - 保留原有字段
/// <summary>
/// 测试用例ID
/// </summary>
public string TestCaseId { get; set; } = null!;
/// <summary>
/// 节点ID (ReactFlow中的节点ID)
/// </summary>
public string NodeId { get; set; } = null!;
/// <summary>
/// 执行序号
/// </summary>
public int SequenceNumber { get; set; }
/// <summary>
/// 位置X坐标
/// </summary>
public double PositionX { get; set; }
/// <summary>
/// 位置Y坐标
/// </summary>
public double PositionY { get; set; }
/// <summary>
/// 是否被选中
/// </summary>
public bool IsSelected { get; set; }
/// <summary>
/// 绝对位置X坐标
/// </summary>
public double? PositionAbsoluteX { get; set; }
/// <summary>
/// 绝对位置Y坐标
/// </summary>
public double? PositionAbsoluteY { get; set; }
/// <summary>
/// 是否正在拖拽
/// </summary>
public bool IsDragging { get; set; }
}
/// <summary>
/// 测试用例连线样式DTO
/// </summary>
public class TestCaseEdgeStyleDto
{
/// <summary>
/// 描边颜色
/// </summary>
public string? Stroke { get; set; }
/// <summary>
/// 描边宽度
/// </summary>
public int StrokeWidth { get; set; }
}
/// <summary>
/// 测试用例连线数据DTO
/// </summary>
public class TestCaseEdgeDataDto
{
/// <summary>
/// 条件
/// </summary>
public string? Condition { get; set; }
}
/// <summary>
/// 测试用例连线DTO
/// </summary>
public class TestCaseEdgeDto
{
/// <summary>
/// 源节点ID
/// </summary>
public string Source { get; set; } = null!;
/// <summary>
/// 源连接点
/// </summary>
public string? SourceHandle { get; set; }
/// <summary>
/// 目标节点ID
/// </summary>
public string Target { get; set; } = null!;
/// <summary>
/// 目标连接点
/// </summary>
public string? TargetHandle { get; set; }
/// <summary>
/// 连线ID
/// </summary>
public string Id { get; set; } = null!;
/// <summary>
/// 连线类型
/// </summary>
public string Type { get; set; } = null!;
/// <summary>
/// 是否动画
/// </summary>
public bool Animated { get; set; }
/// <summary>
/// 连线样式
/// </summary>
public TestCaseEdgeStyleDto Style { get; set; } = new();
/// <summary>
/// 连线数据
/// </summary>
public TestCaseEdgeDataDto Data { get; set; } = new();
// 兼容性字段 - 保留原有字段
/// <summary>
/// 测试用例ID
/// </summary>
public string TestCaseId { get; set; } = null!;
/// <summary>
/// 连线ID (ReactFlow中的连线ID)
/// </summary>
public string EdgeId { get; set; } = null!;
/// <summary>
/// 源节点ID
/// </summary>
public string SourceNodeId { get; set; } = null!;
/// <summary>
/// 目标节点ID
/// </summary>
public string TargetNodeId { get; set; } = null!;
/// <summary>
/// 连线类型
/// </summary>
public string? EdgeType { get; set; }
/// <summary>
/// 连线条件(JSON字符串格式)
/// </summary>
public string? Condition { get; set; }
/// <summary>
/// 是否动画
/// </summary>
public bool IsAnimated { get; set; }
/// <summary>
/// 连线样式(JSON字符串格式)
/// </summary>
public string? StyleJson { get; set; }
}
/// <summary>
/// 测试用例流程详情DTO
/// </summary>
public class TestCaseFlowDetailDto
{
/// <summary>
/// 流程ID
/// </summary>
public string Id { get; set; } = null!;
/// <summary>
/// 流程名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 流程描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 流程类型
/// </summary>
public string Type { get; set; } = null!;
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 视口X坐标
/// </summary>
public double ViewportX { get; set; }
/// <summary>
/// 视口Y坐标
/// </summary>
public double ViewportY { get; set; }
/// <summary>
/// 视口缩放级别
/// </summary>
public double ViewportZoom { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 创建人
/// </summary>
public string CreatedBy { get; set; } = null!;
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 更新人
/// </summary>
public string UpdatedBy { get; set; } = null!;
/// <summary>
/// 节点列表
/// </summary>
public List<TestCaseNodeDto> Nodes { get; set; } = new();
/// <summary>
/// 连线列表
/// </summary>
public List<TestCaseEdgeDto> Edges { get; set; } = new();
}
/// <summary>
/// 根据ID获取测试用例流程详情响应
/// </summary>
public class GetTestCaseFlowByIdResponse
{
/// <summary>
/// 测试用例流程详情
/// </summary>
public TestCaseFlowDetailDto TestCaseFlow { get; set; } = null!;
}

35
src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsQuery.cs

@ -0,0 +1,35 @@
using X1.Domain.Common;
using MediatR;
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlows;
/// <summary>
/// 获取测试用例流程列表查询
/// </summary>
public class GetTestCaseFlowsQuery : IRequest<OperationResult<GetTestCaseFlowsResponse>>
{
/// <summary>
/// 搜索关键词(流程名称或描述)
/// </summary>
public string? SearchTerm { get; set; }
/// <summary>
/// 流程类型过滤
/// </summary>
public string? Type { get; set; }
/// <summary>
/// 启用状态过滤
/// </summary>
public bool? IsEnabled { get; set; }
/// <summary>
/// 页码
/// </summary>
public int PageNumber { get; set; } = 1;
/// <summary>
/// 每页大小
/// </summary>
public int PageSize { get; set; } = 10;
}

88
src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsQueryHandler.cs

@ -0,0 +1,88 @@
using X1.Domain.Common;
using X1.Domain.Repositories.TestCase;
using Microsoft.Extensions.Logging;
using MediatR;
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlows;
/// <summary>
/// 获取测试用例流程列表查询处理器
/// </summary>
public class GetTestCaseFlowsQueryHandler : IRequestHandler<GetTestCaseFlowsQuery, OperationResult<GetTestCaseFlowsResponse>>
{
private readonly ITestCaseFlowRepository _testCaseFlowRepository;
private readonly ILogger<GetTestCaseFlowsQueryHandler> _logger;
/// <summary>
/// 初始化查询处理器
/// </summary>
public GetTestCaseFlowsQueryHandler(
ITestCaseFlowRepository testCaseFlowRepository,
ILogger<GetTestCaseFlowsQueryHandler> logger)
{
_testCaseFlowRepository = testCaseFlowRepository;
_logger = logger;
}
/// <summary>
/// 处理获取测试用例流程列表查询
/// </summary>
public async Task<OperationResult<GetTestCaseFlowsResponse>> Handle(GetTestCaseFlowsQuery request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("开始获取测试用例流程列表,搜索条件: {SearchTerm}, 类型: {Type}, 启用状态: {IsEnabled}, 页码: {PageNumber}, 每页大小: {PageSize}",
request.SearchTerm, request.Type, request.IsEnabled, request.PageNumber, request.PageSize);
// 解析流程类型
X1.Domain.Entities.TestCase.TestFlowType? flowType = null;
if (!string.IsNullOrEmpty(request.Type) && Enum.TryParse<X1.Domain.Entities.TestCase.TestFlowType>(request.Type, out var parsedType))
{
flowType = parsedType;
}
// 获取分页数据
var pagedResult = await _testCaseFlowRepository.GetPagedFlowsAsync(
name: request.SearchTerm,
type: flowType,
isEnabled: request.IsEnabled,
pageNumber: request.PageNumber,
pageSize: request.PageSize,
cancellationToken);
// 构建响应
var response = new GetTestCaseFlowsResponse
{
TestCaseFlows = pagedResult.Items.Select(flow => new TestCaseFlowDto
{
Id = flow.Id,
Name = flow.Name,
Description = flow.Description,
Type = flow.Type.ToString(),
IsEnabled = flow.IsEnabled,
ViewportX = flow.ViewportX,
ViewportY = flow.ViewportY,
ViewportZoom = flow.ViewportZoom,
CreatedAt = flow.CreatedAt,
CreatedBy = flow.CreatedBy,
UpdatedAt = flow.UpdatedAt,
UpdatedBy = flow.UpdatedBy
}).ToList(),
TotalCount = pagedResult.TotalCount,
PageNumber = request.PageNumber,
PageSize = request.PageSize,
TotalPages = (int)Math.Ceiling((double)pagedResult.TotalCount / request.PageSize)
};
_logger.LogInformation("成功获取测试用例流程列表,总数: {TotalCount}, 当前页: {PageNumber}, 每页大小: {PageSize}",
response.TotalCount, response.PageNumber, response.PageSize);
return OperationResult<GetTestCaseFlowsResponse>.CreateSuccess(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取测试用例流程列表时发生错误");
return OperationResult<GetTestCaseFlowsResponse>.CreateFailure("获取测试用例流程列表失败,请稍后重试");
}
}
}

98
src/X1.Application/Features/TestCaseFlow/Queries/GetTestCaseFlows/GetTestCaseFlowsResponse.cs

@ -0,0 +1,98 @@
namespace X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlows;
/// <summary>
/// 测试用例流程DTO
/// </summary>
public class TestCaseFlowDto
{
/// <summary>
/// 流程ID
/// </summary>
public string Id { get; set; } = null!;
/// <summary>
/// 流程名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 流程描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 流程类型
/// </summary>
public string Type { get; set; } = null!;
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 视口X坐标
/// </summary>
public double ViewportX { get; set; }
/// <summary>
/// 视口Y坐标
/// </summary>
public double ViewportY { get; set; }
/// <summary>
/// 视口缩放级别
/// </summary>
public double ViewportZoom { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 创建人
/// </summary>
public string CreatedBy { get; set; } = null!;
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 更新人
/// </summary>
public string UpdatedBy { get; set; } = null!;
}
/// <summary>
/// 获取测试用例流程列表响应
/// </summary>
public class GetTestCaseFlowsResponse
{
/// <summary>
/// 测试用例流程列表
/// </summary>
public List<TestCaseFlowDto> TestCaseFlows { get; set; } = new();
/// <summary>
/// 总记录数
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 当前页码
/// </summary>
public int PageNumber { get; set; }
/// <summary>
/// 每页大小
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总页数
/// </summary>
public int TotalPages { get; set; }
}

4
src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommand.cs

@ -11,8 +11,10 @@ namespace X1.Application.Features.Users.Commands.CreateUser;
/// <param name="Email">电子邮箱</param>
/// <param name="PhoneNumber">电话号码</param>
/// <param name="Password">密码</param>
/// <param name="Roles">角色名称数组(可选,默认为["User"])</param>
public sealed record CreateUserCommand(
string UserName,
string Email,
string PhoneNumber,
string Password) : IRequest<OperationResult<CreateUserResponse>>;
string Password,
string[]? Roles = null) : IRequest<OperationResult<CreateUserResponse>>;

157
src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommandHandler.cs

@ -1,14 +1,13 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using MediatR;
using X1.Domain.Entities;
using X1.Domain.Repositories;
using X1.Domain.Services;
using X1.Domain.Common;
using System.Threading.Tasks;
using System.Threading;
using System;
using System.Linq;
using X1.Domain.Repositories.Base;
using System.ComponentModel.DataAnnotations;
namespace X1.Application.Features.Users.Commands.CreateUser;
@ -18,24 +17,28 @@ namespace X1.Application.Features.Users.Commands.CreateUser;
/// </summary>
public sealed class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, OperationResult<CreateUserResponse>>
{
private readonly UserManager<AppUser> _userManager;
private readonly IUserRegistrationService _userRegistrationService;
private readonly ILogger<CreateUserCommandHandler> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserService _currentUserService;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="userManager">用户管理器,用于管理用户身份</param>
/// <param name="userRegistrationService">用户注册服务</param>
/// <param name="logger">日志记录器</param>
/// <param name="unitOfWork">工作单元,用于事务管理</param>
/// <param name="currentUserService">当前用户服务</param>
public CreateUserCommandHandler(
UserManager<AppUser> userManager,
IUserRegistrationService userRegistrationService,
ILogger<CreateUserCommandHandler> logger,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
{
_userManager = userManager;
_logger = logger;
_unitOfWork = unitOfWork;
_userRegistrationService = userRegistrationService ?? throw new ArgumentNullException(nameof(userRegistrationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
_currentUserService = currentUserService ?? throw new ArgumentNullException(nameof(currentUserService));
}
/// <summary>
@ -48,46 +51,140 @@ public sealed class CreateUserCommandHandler : IRequestHandler<CreateUserCommand
CreateUserCommand request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
// 检查用户是否已存在
var existingUser = await _userManager.FindByEmailAsync(request.Email);
if (existingUser != null)
_logger.LogInformation("开始创建用户,邮箱: {Email}, 用户名: {UserName}",
request.Email, request.UserName);
// 获取当前用户ID
var currentUserId = _currentUserService.GetCurrentUserId();
if (string.IsNullOrEmpty(currentUserId))
{
_logger.LogWarning("用户邮箱 {Email} 已存在", request.Email);
return OperationResult<CreateUserResponse>.CreateFailure("用户已存在");
_logger.LogWarning("用户未认证,无法创建新用户");
return OperationResult<CreateUserResponse>.CreateFailure("用户未认证");
}
// 创建新用户
var user = new AppUser
// 验证输入参数
var validationResult = ValidateRequest(request);
if (!validationResult.IsSuccess)
{
UserName = request.UserName,
Email = request.Email,
PhoneNumber = request.PhoneNumber
};
return validationResult;
}
// 创建新用户
var user = CreateUserFromRequest(request);
// 在事务中创建用户
await _unitOfWork.ExecuteTransactionAsync(async () =>
{
var result = await _userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
// 使用用户注册服务创建用户
var (success, errorMessage) = await _userRegistrationService.RegisterUserAsync(user, request.Password);
if (!success)
{
var errors = result.Errors.Select(e => e.Description).ToList();
_logger.LogWarning("创建用户失败: {Errors}", string.Join(", ", errors));
throw new InvalidOperationException(string.Join(", ", errors));
_logger.LogWarning("创建用户失败,邮箱: {Email}, 错误: {Error}",
request.Email, errorMessage);
throw new InvalidOperationException($"用户创建失败: {errorMessage}");
}
// 分配用户角色
var rolesToAssign = request.Roles ?? new[] { "User" };
(success, errorMessage) = await _userRegistrationService.AssignUserRolesAsync(user, rolesToAssign);
if (!success)
{
_logger.LogWarning("分配用户角色失败,邮箱: {Email}, 错误: {Error}",
request.Email, errorMessage);
throw new InvalidOperationException($"角色分配失败: {errorMessage}");
}
}, cancellationToken: cancellationToken);
_logger.LogInformation("用户 {UserId} 创建成功", user.Id);
_logger.LogInformation("用户创建成功,ID: {UserId}, 邮箱: {Email}",
user.Id, user.Email);
return OperationResult<CreateUserResponse>.CreateSuccess(
new CreateUserResponse(user.Id));
}
catch (OperationCanceledException)
{
_logger.LogWarning("用户创建操作被取消,邮箱: {Email}", request.Email);
return OperationResult<CreateUserResponse>.CreateFailure("操作被取消");
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "用户创建业务逻辑错误,邮箱: {Email}", request.Email);
return OperationResult<CreateUserResponse>.CreateFailure(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "创建用户时发生错误");
_logger.LogError(ex, "Error creating user");
return OperationResult<CreateUserResponse>.CreateFailure("Failed to create user");
_logger.LogError(ex, "创建用户时发生未预期的错误,邮箱: {Email}", request.Email);
return OperationResult<CreateUserResponse>.CreateFailure("创建用户失败,请稍后重试");
}
}
/// <summary>
/// 验证请求参数
/// </summary>
/// <param name="request">创建用户请求</param>
/// <returns>验证结果</returns>
private OperationResult<CreateUserResponse> ValidateRequest(CreateUserCommand request)
{
if (string.IsNullOrWhiteSpace(request.Email))
{
return OperationResult<CreateUserResponse>.CreateFailure("邮箱地址不能为空");
}
if (string.IsNullOrWhiteSpace(request.UserName))
{
return OperationResult<CreateUserResponse>.CreateFailure("用户名不能为空");
}
if (string.IsNullOrWhiteSpace(request.Password))
{
return OperationResult<CreateUserResponse>.CreateFailure("密码不能为空");
}
// 验证邮箱格式
var emailValidator = new EmailAddressAttribute();
if (!emailValidator.IsValid(request.Email))
{
return OperationResult<CreateUserResponse>.CreateFailure("邮箱格式不正确");
}
// 验证角色数组
if (request.Roles != null && request.Roles.Length > 0)
{
foreach (var role in request.Roles)
{
if (string.IsNullOrWhiteSpace(role))
{
return OperationResult<CreateUserResponse>.CreateFailure("角色名称不能为空");
}
}
}
return OperationResult<CreateUserResponse>.CreateSuccess(null);
}
/// <summary>
/// 从请求创建用户实体
/// </summary>
/// <param name="request">创建用户请求</param>
/// <returns>用户实体</returns>
private static AppUser CreateUserFromRequest(CreateUserCommand request)
{
return new AppUser
{
UserName = request.UserName?.Trim(),
Email = request.Email?.Trim().ToLowerInvariant(),
PhoneNumber = request.PhoneNumber?.Trim(),
RealName = request.UserName?.Trim(), // 使用UserName作为RealName
EmailConfirmed = false, // 默认需要邮箱确认
PhoneNumberConfirmed = false, // 默认需要手机号确认
TwoFactorEnabled = false, // 默认不启用双因子认证
LockoutEnabled = true, // 默认启用账户锁定
AccessFailedCount = 0
// CreatedTime 和 ModifiedTime 由 BaseIdentityUser 构造函数自动设置
};
}
}

10
src/X1.Application/Features/Users/Commands/CreateUser/CreateUserCommandValidator.cs

@ -30,10 +30,18 @@ public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCom
.Matches("[0-9]").WithMessage("密码必须包含至少一个数字")
.Matches("[^a-zA-Z0-9]").WithMessage("密码必须包含至少一个特殊字符");
// 验证电话号码
RuleFor(x => x.PhoneNumber)
.Matches(@"^1[3-9]\d{9}$").When(x => !string.IsNullOrEmpty(x.PhoneNumber))
.WithMessage("电话号码格式不正确");
// 验证角色数组
RuleFor(x => x.Roles)
.Must(roles => roles == null || roles.Length == 0 || roles.All(r => !string.IsNullOrWhiteSpace(r)))
.WithMessage("角色名称不能为空")
.Must(roles => roles == null || roles.Length == 0 || roles.All(r => r.Length <= 50))
.WithMessage("角色名称长度不能超过50个字符")
.Must(roles => roles == null || roles.Length == 0 || roles.All(r => r.All(c => char.IsLetterOrDigit(c) || c == '_')))
.WithMessage("角色名称只能包含字母、数字和下划线");
}
}

0
src/X1.Domain/Entities/TestCase/TestCaseTestFlow.cs → src/X1.Domain/Entities/TestCase/TestCaseFlow.cs

7
src/X1.Domain/Entities/TestCase/TestCaseNode.cs

@ -36,7 +36,8 @@ namespace X1.Domain.Entities.TestCase
/// <summary>
/// 步骤配置ID
/// </summary>
public string? StepId { get; set; }
[Required(ErrorMessage = "步骤配置ID不能为空")]
public string StepId { get; set; } = null!;
/// <summary>
/// 节点位置X坐标
@ -94,7 +95,7 @@ namespace X1.Domain.Entities.TestCase
/// <param name="sequenceNumber">执行序号</param>
/// <param name="positionX">节点位置X坐标</param>
/// <param name="positionY">节点位置Y坐标</param>
/// <param name="stepId">步骤配置ID</param>
/// <param name="stepId">步骤配置ID(必填)</param>
/// <param name="width">节点宽度</param>
/// <param name="height">节点高度</param>
/// <param name="isSelected">是否被选中</param>
@ -107,7 +108,7 @@ namespace X1.Domain.Entities.TestCase
int sequenceNumber,
double positionX,
double positionY,
string? stepId = null,
string stepId,
double width = 100,
double height = 50,
bool isSelected = false,

8
src/X1.Domain/Services/IUserRegistrationService.cs

@ -23,5 +23,11 @@ public interface IUserRegistrationService
/// <returns>分配结果</returns>
Task<(bool success, string? errorMessage)> AssignUserRoleAsync(AppUser user);
/// <summary>
/// 批量分配多个角色给用户
/// </summary>
/// <param name="user">用户</param>
/// <param name="roleNames">角色名称数组</param>
/// <returns>分配结果</returns>
Task<(bool success, string? errorMessage)> AssignUserRolesAsync(AppUser user, string[] roleNames);
}

5
src/X1.Infrastructure/Configurations/TestCase/TestCaseNodeConfiguration.cs

@ -43,7 +43,8 @@ public class TestCaseNodeConfiguration : IEntityTypeConfiguration<TestCaseNode>
builder.Property(x => x.StepId)
.HasColumnName("stepid")
.HasMaxLength(450);
.HasMaxLength(450)
.IsRequired();
builder.Property(x => x.PositionX)
.HasColumnName("positionx")
@ -94,6 +95,6 @@ public class TestCaseNodeConfiguration : IEntityTypeConfiguration<TestCaseNode>
builder.HasOne(x => x.StepConfig)
.WithMany()
.HasForeignKey(x => x.StepId)
.OnDelete(DeleteBehavior.SetNull);
.OnDelete(DeleteBehavior.Restrict);
}
}

1
src/X1.Infrastructure/DependencyInjection.cs

@ -198,6 +198,7 @@ public static class DependencyInjection
// 注册用例相关仓储
services.AddScoped<ICaseStepConfigRepository, CaseStepConfigRepository>();
services.AddScoped<ITestCaseFlowRepository, TestCaseFlowRepository>();
services.AddScoped<ITestCaseEdgeRepository, TestCaseEdgeRepository>();
services.AddScoped<ITestCaseNodeRepository, TestCaseNodeRepository>();

1875
src/X1.Infrastructure/Migrations/20250821080604_AddTestCaseFlowTables.Designer.cs

File diff suppressed because it is too large

165
src/X1.Infrastructure/Migrations/20250821080604_AddTestCaseFlowTables.cs

@ -0,0 +1,165 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace X1.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTestCaseFlowTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tb_testcaseflow",
columns: table => new
{
id = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
isenabled = table.Column<bool>(type: "boolean", nullable: false),
viewport_x = table.Column<double>(type: "double precision", nullable: false),
viewport_y = table.Column<double>(type: "double precision", nullable: false),
viewport_zoom = table.Column<double>(type: "double precision", nullable: false),
createdat = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updatedat = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
createdby = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
updatedby = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_tb_testcaseflow", x => x.id);
});
migrationBuilder.CreateTable(
name: "tb_testcaseedge",
columns: table => new
{
id = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
testcaseid = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
edgeid = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
sourcenodeid = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
targetnodeid = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
edgetype = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
condition = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
isanimated = table.Column<bool>(type: "boolean", nullable: false),
style = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_tb_testcaseedge", x => x.id);
table.ForeignKey(
name: "FK_tb_testcaseedge_tb_testcaseflow_testcaseid",
column: x => x.testcaseid,
principalTable: "tb_testcaseflow",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tb_testcasenode",
columns: table => new
{
id = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
testcaseid = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
nodeid = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
sequencenumber = table.Column<int>(type: "integer", nullable: false),
stepid = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
positionx = table.Column<double>(type: "double precision", nullable: false),
positiony = table.Column<double>(type: "double precision", nullable: false),
width = table.Column<double>(type: "double precision", nullable: false),
height = table.Column<double>(type: "double precision", nullable: false),
isselected = table.Column<bool>(type: "boolean", nullable: false),
positionabsolutex = table.Column<double>(type: "double precision", nullable: true),
positionabsolutey = table.Column<double>(type: "double precision", nullable: true),
isdragging = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tb_testcasenode", x => x.id);
table.ForeignKey(
name: "FK_tb_testcasenode_tb_casestepconfig_stepid",
column: x => x.stepid,
principalTable: "tb_casestepconfig",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_tb_testcasenode_tb_testcaseflow_testcaseid",
column: x => x.testcaseid,
principalTable: "tb_testcaseflow",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_testcaseedge_edgeid",
table: "tb_testcaseedge",
column: "edgeid");
migrationBuilder.CreateIndex(
name: "IX_testcaseedge_sourcenodeid",
table: "tb_testcaseedge",
column: "sourcenodeid");
migrationBuilder.CreateIndex(
name: "IX_testcaseedge_targetnodeid",
table: "tb_testcaseedge",
column: "targetnodeid");
migrationBuilder.CreateIndex(
name: "IX_testcaseedge_testcaseid",
table: "tb_testcaseedge",
column: "testcaseid");
migrationBuilder.CreateIndex(
name: "IX_testcaseflow_isenabled",
table: "tb_testcaseflow",
column: "isenabled");
migrationBuilder.CreateIndex(
name: "IX_testcaseflow_name",
table: "tb_testcaseflow",
column: "name");
migrationBuilder.CreateIndex(
name: "IX_testcaseflow_type",
table: "tb_testcaseflow",
column: "type");
migrationBuilder.CreateIndex(
name: "IX_tb_testcasenode_stepid",
table: "tb_testcasenode",
column: "stepid");
migrationBuilder.CreateIndex(
name: "IX_testcasenode_nodeid",
table: "tb_testcasenode",
column: "nodeid");
migrationBuilder.CreateIndex(
name: "IX_testcasenode_sequencenumber",
table: "tb_testcasenode",
column: "sequencenumber");
migrationBuilder.CreateIndex(
name: "IX_testcasenode_testcaseid",
table: "tb_testcasenode",
column: "testcaseid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tb_testcaseedge");
migrationBuilder.DropTable(
name: "tb_testcasenode");
migrationBuilder.DropTable(
name: "tb_testcaseflow");
}
}
}

252
src/X1.Infrastructure/Migrations/AppDbContextModelSnapshot.cs

@ -1,10 +1,10 @@
// <auto-generated />
using System;
using X1.Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using X1.Infrastructure.Context;
#nullable disable
@ -1478,6 +1478,220 @@ namespace X1.Infrastructure.Migrations
b.ToTable("tb_casestepconfig", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseEdge", b =>
{
b.Property<string>("Id")
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("id");
b.Property<string>("Condition")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("condition");
b.Property<string>("EdgeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("edgeid");
b.Property<string>("EdgeType")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("edgetype");
b.Property<bool>("IsAnimated")
.HasColumnType("boolean")
.HasColumnName("isanimated");
b.Property<string>("SourceNodeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("sourcenodeid");
b.Property<string>("Style")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("style");
b.Property<string>("TargetNodeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("targetnodeid");
b.Property<string>("TestCaseId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("testcaseid");
b.HasKey("Id");
b.HasIndex("EdgeId")
.HasDatabaseName("IX_testcaseedge_edgeid");
b.HasIndex("SourceNodeId")
.HasDatabaseName("IX_testcaseedge_sourcenodeid");
b.HasIndex("TargetNodeId")
.HasDatabaseName("IX_testcaseedge_targetnodeid");
b.HasIndex("TestCaseId")
.HasDatabaseName("IX_testcaseedge_testcaseid");
b.ToTable("tb_testcaseedge", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseFlow", b =>
{
b.Property<string>("Id")
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("createdat");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("createdby");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("isenabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updatedat");
b.Property<string>("UpdatedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("updatedby");
b.Property<double>("ViewportX")
.HasColumnType("double precision")
.HasColumnName("viewport_x");
b.Property<double>("ViewportY")
.HasColumnType("double precision")
.HasColumnName("viewport_y");
b.Property<double>("ViewportZoom")
.HasColumnType("double precision")
.HasColumnName("viewport_zoom");
b.HasKey("Id");
b.HasIndex("IsEnabled")
.HasDatabaseName("IX_testcaseflow_isenabled");
b.HasIndex("Name")
.HasDatabaseName("IX_testcaseflow_name");
b.HasIndex("Type")
.HasDatabaseName("IX_testcaseflow_type");
b.ToTable("tb_testcaseflow", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseNode", b =>
{
b.Property<string>("Id")
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("id");
b.Property<double>("Height")
.HasColumnType("double precision")
.HasColumnName("height");
b.Property<bool>("IsDragging")
.HasColumnType("boolean")
.HasColumnName("isdragging");
b.Property<bool>("IsSelected")
.HasColumnType("boolean")
.HasColumnName("isselected");
b.Property<string>("NodeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("nodeid");
b.Property<double?>("PositionAbsoluteX")
.HasColumnType("double precision")
.HasColumnName("positionabsolutex");
b.Property<double?>("PositionAbsoluteY")
.HasColumnType("double precision")
.HasColumnName("positionabsolutey");
b.Property<double>("PositionX")
.HasColumnType("double precision")
.HasColumnName("positionx");
b.Property<double>("PositionY")
.HasColumnType("double precision")
.HasColumnName("positiony");
b.Property<int>("SequenceNumber")
.HasColumnType("integer")
.HasColumnName("sequencenumber");
b.Property<string>("StepId")
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("stepid");
b.Property<string>("TestCaseId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)")
.HasColumnName("testcaseid");
b.Property<double>("Width")
.HasColumnType("double precision")
.HasColumnName("width");
b.HasKey("Id");
b.HasIndex("NodeId")
.HasDatabaseName("IX_testcasenode_nodeid");
b.HasIndex("SequenceNumber")
.HasDatabaseName("IX_testcasenode_sequencenumber");
b.HasIndex("StepId");
b.HasIndex("TestCaseId")
.HasDatabaseName("IX_testcasenode_testcaseid");
b.ToTable("tb_testcasenode", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.UserRole", b =>
{
b.Property<string>("UserId")
@ -1581,6 +1795,35 @@ namespace X1.Infrastructure.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseEdge", b =>
{
b.HasOne("X1.Domain.Entities.TestCase.TestCaseFlow", "TestCase")
.WithMany("Edges")
.HasForeignKey("TestCaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TestCase");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseNode", b =>
{
b.HasOne("X1.Domain.Entities.TestCase.CaseStepConfig", "StepConfig")
.WithMany()
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("X1.Domain.Entities.TestCase.TestCaseFlow", "TestCase")
.WithMany("Nodes")
.HasForeignKey("TestCaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StepConfig");
b.Navigation("TestCase");
});
modelBuilder.Entity("X1.Domain.Entities.UserRole", b =>
{
b.HasOne("X1.Domain.Entities.AppRole", "Role")
@ -1616,6 +1859,13 @@ namespace X1.Infrastructure.Migrations
{
b.Navigation("RolePermissions");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseFlow", b =>
{
b.Navigation("Edges");
b.Navigation("Nodes");
});
#pragma warning restore 612, 618
}
}

67
src/X1.Infrastructure/Services/UserManagement/UserRegistrationService.cs

@ -140,4 +140,71 @@ public class UserRegistrationService : IUserRegistrationService
return (true, null);
}
public async Task<(bool success, string? errorMessage)> AssignUserRolesAsync(AppUser user, string[] roleNames)
{
if (roleNames == null || roleNames.Length == 0)
{
throw new RoleAssignmentException("角色名称数组不能为空");
}
// 使用分布式锁确保角色分配的原子性
using var lockHandle = await _lockService.AcquireLockAsync($"role_assignment_{user.Id}", TimeSpan.FromSeconds(10));
if (!lockHandle.IsAcquired)
{
throw new RoleAssignmentException("系统繁忙,请稍后重试");
}
var userRoles = new List<UserRole>();
var assignedRoles = new List<string>();
foreach (var roleName in roleNames)
{
if (string.IsNullOrWhiteSpace(roleName))
{
throw new RoleAssignmentException("角色名称不能为空");
}
// 获取或创建指定角色
var role = await _roleManager.FindByNameAsync(roleName);
if (role == null)
{
role = new AppRole { Name = roleName, Description = roleName == "Admin" ? Description : string.Empty };
var roleResult = await _roleManager.CreateAsync(role);
if (!roleResult.Succeeded)
{
var errors = roleResult.Errors.Select(e => e.Description);
throw new RoleAssignmentException($"创建角色 {roleName} 失败: {string.Join(", ", errors)}");
}
}
// 检查用户是否已经有该角色
var hasRole = await _userRoleRepository.HasRoleAsync(user.Id, role.Id);
if (hasRole)
{
_logger.LogInformation("用户 {UserName} 已经拥有角色 {RoleName}", user.UserName, roleName);
continue;
}
// 创建用户角色关系
var userRole = new UserRole
{
UserId = user.Id,
RoleId = role.Id,
User = user
};
userRoles.Add(userRole);
assignedRoles.Add(roleName);
}
// 批量分配角色
if (userRoles.Count > 0)
{
await _userRoleRepository.AddUserRolesAsync(userRoles);
_logger.LogInformation("为用户 {UserName} 批量分配角色成功: {Roles}", user.UserName, string.Join(", ", assignedRoles));
}
return (true, null);
}
}

144
src/X1.Presentation/Controllers/TestCaseFlowController.cs

@ -0,0 +1,144 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlows;
using X1.Application.Features.TestCaseFlow.Queries.GetTestCaseFlowById;
using X1.Application.Features.TestCaseFlow.Commands.CreateTestCaseFlow;
using X1.Application.Features.TestCaseFlow.Commands.DeleteTestCaseFlow;
using X1.Presentation.Abstractions;
using X1.Domain.Common;
using MediatR;
using Microsoft.Extensions.Logging;
namespace X1.Presentation.Controllers;
/// <summary>
/// 测试用例流程控制器
/// </summary>
[ApiController]
[Route("api/testcaseflow")]
[Authorize]
public class TestCaseFlowController : ApiController
{
private readonly ILogger<TerminalServicesController> _logger;
/// <summary>
/// 初始化终端服务控制器
/// </summary>
public TestCaseFlowController(IMediator mediator, ILogger<TerminalServicesController> logger)
: base(mediator)
{
_logger = logger;
}
/// <summary>
/// 获取测试用例流程列表
/// </summary>
/// <param name="searchTerm">搜索关键词</param>
/// <param name="type">流程类型</param>
/// <param name="isEnabled">启用状态</param>
/// <param name="pageNumber">页码</param>
/// <param name="pageSize">每页大小</param>
/// <returns>测试用例流程列表</returns>
[HttpGet]
public async Task<OperationResult<GetTestCaseFlowsResponse>> GetTestCaseFlows(
[FromQuery] string? searchTerm = null,
[FromQuery] string? type = null,
[FromQuery] bool? isEnabled = null,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
_logger.LogInformation("获取测试用例流程列表,搜索条件: {SearchTerm}, 类型: {Type}, 启用状态: {IsEnabled}, 页码: {PageNumber}, 每页大小: {PageSize}",
searchTerm, type, isEnabled, pageNumber, pageSize);
var query = new GetTestCaseFlowsQuery
{
SearchTerm = searchTerm,
Type = type,
IsEnabled = isEnabled,
PageNumber = pageNumber,
PageSize = pageSize
};
var result = await mediator.Send(query);
if (!result.IsSuccess)
{
_logger.LogWarning("获取测试用例流程列表失败: {ErrorMessages}", string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功获取测试用例流程列表,总数: {TotalCount}", result.Data?.TotalCount);
return result;
}
/// <summary>
/// 根据ID获取测试用例流程详情
/// </summary>
/// <param name="id">测试用例流程ID</param>
/// <returns>测试用例流程详情(包含节点和连线)</returns>
[HttpGet("{id}")]
public async Task<OperationResult<GetTestCaseFlowByIdResponse>> GetTestCaseFlowById(string id)
{
_logger.LogInformation("获取测试用例流程详情,ID: {Id}", id);
var query = new GetTestCaseFlowByIdQuery { Id = id };
var result = await mediator.Send(query);
if (!result.IsSuccess)
{
_logger.LogWarning("获取测试用例流程详情失败,ID: {Id}, 错误: {ErrorMessages}",
id, string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功获取测试用例流程详情,ID: {Id}, 名称: {Name}, 节点数量: {NodeCount}, 连线数量: {EdgeCount}",
id, result.Data?.TestCaseFlow.Name, result.Data?.TestCaseFlow.Nodes.Count, result.Data?.TestCaseFlow.Edges.Count);
return result;
}
/// <summary>
/// 创建测试用例流程
/// </summary>
/// <param name="command">创建测试用例流程命令</param>
/// <returns>创建结果</returns>
[HttpPost]
public async Task<OperationResult<CreateTestCaseFlowResponse>> CreateTestCaseFlow([FromBody] CreateTestCaseFlowCommand command)
{
_logger.LogInformation("开始创建测试用例流程,流程名称: {Name}, 类型: {Type}",
command.Name, command.Type);
var result = await mediator.Send(command);
if (!result.IsSuccess)
{
_logger.LogWarning("创建测试用例流程失败,流程名称: {Name}, 错误: {ErrorMessages}",
command.Name, string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功创建测试用例流程,ID: {Id}, 名称: {Name}, 节点数量: {NodeCount}, 连线数量: {EdgeCount}",
result.Data?.Id, result.Data?.Name, command.Nodes?.Count ?? 0, command.Edges?.Count ?? 0);
return result;
}
/// <summary>
/// 删除测试用例流程
/// </summary>
/// <param name="id">测试用例流程ID</param>
/// <returns>删除结果</returns>
[HttpDelete("{id}")]
public async Task<OperationResult<bool>> DeleteTestCaseFlow(string id)
{
_logger.LogInformation("开始删除测试用例流程,流程ID: {Id}", id);
var command = new DeleteTestCaseFlowCommand { Id = id };
var result = await mediator.Send(command);
if (!result.IsSuccess)
{
_logger.LogWarning("删除测试用例流程失败,流程ID: {Id}, 错误: {ErrorMessages}",
id, string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功删除测试用例流程,流程ID: {Id}", id);
return result;
}
}

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

@ -0,0 +1,487 @@
import { useState, useEffect, useCallback } from 'react';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
Handle,
Position,
ReactFlowProvider,
useReactFlow
} from 'reactflow';
import { testcaseService, TestCaseFlowDetail } from '@/services/testcaseService';
import 'reactflow/dist/style.css';
import {
Play,
Square,
GitBranch,
Smartphone,
Wifi,
WifiOff,
Phone,
PhoneCall,
PhoneOff,
Network,
Activity,
Signal,
SignalHigh,
SignalLow,
Settings
} from 'lucide-react';
interface TestCaseDetailDrawerProps {
testCaseId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
// 自定义节点组件 - 与 ReactFlowDesigner 完全一致
const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) => {
console.log('TestStepNode 接收到的数据:', data);
console.log('TestStepNode selected:', selected);
const getIconComponent = (iconName: string) => {
// 根据图标名称返回对应的图标组件
switch (iconName) {
case 'play-circle': return <Play className="h-4 w-4" />;
case 'stop-circle': return <Square className="h-4 w-4" />;
case 'git-branch': return <GitBranch className="h-4 w-4" />;
case 'settings': return <Settings className="h-4 w-4" />;
case 'smartphone': return <Smartphone className="h-4 w-4" />;
case 'wifi': return <Wifi className="h-4 w-4" />;
case 'wifi-off': return <WifiOff className="h-4 w-4" />;
case 'phone': return <Phone className="h-4 w-4" />;
case 'phone-call': return <PhoneCall className="h-4 w-4" />;
case 'phone-off': return <PhoneOff className="h-4 w-4" />;
case 'network': return <Network className="h-4 w-4" />;
case 'activity': return <Activity className="h-4 w-4" />;
case 'signal': return <Signal className="h-4 w-4" />;
case 'signal-high': return <SignalHigh className="h-4 w-4" />;
case 'signal-low': return <SignalLow className="h-4 w-4" />;
default: return <Settings className="h-4 w-4" />;
}
};
const getNodeStyle = (stepType: number) => {
switch (stepType) {
case 1: // 开始步骤 - 圆形
return {
shape: 'rounded-full',
bgColor: 'bg-blue-50 dark:bg-blue-900/30',
textColor: 'text-blue-600 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-600/50',
hoverBorderColor: 'hover:border-blue-300 dark:hover:border-blue-500'
};
case 2: // 结束步骤 - 圆形
return {
shape: 'rounded-full',
bgColor: 'bg-red-50 dark:bg-red-900/30',
textColor: 'text-red-600 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-600/50',
hoverBorderColor: 'hover:border-red-300 dark:hover:border-red-500'
};
case 3: // 处理步骤 - 矩形
return {
shape: 'rounded-md',
bgColor: 'bg-green-50 dark:bg-green-900/30',
textColor: 'text-green-600 dark:text-green-400',
borderColor: 'border-green-200 dark:border-green-600/50',
hoverBorderColor: 'hover:border-green-300 dark:hover:border-green-500'
};
case 4: // 判断步骤 - 菱形
return {
shape: 'transform rotate-45',
bgColor: 'bg-purple-50 dark:bg-purple-900/30',
textColor: 'text-purple-600 dark:text-purple-400',
borderColor: 'border-purple-200 dark:border-purple-600/50',
hoverBorderColor: 'hover:border-purple-300 dark:hover:border-purple-500'
};
default:
return {
shape: 'rounded-md',
bgColor: 'bg-gray-50 dark:bg-gray-800',
textColor: 'text-gray-600 dark:text-gray-400',
borderColor: 'border-gray-200 dark:border-gray-700',
hoverBorderColor: 'hover:border-gray-300 dark:hover:border-gray-600'
};
}
};
const getIconBgColor = (iconName: string) => {
// 设备相关图标 - 蓝色
const deviceIcons = ['smartphone', 'phone', 'phone-call', 'phone-off', 'wifi', 'wifi-off', 'signal', 'signal-high', 'signal-low'];
// 网络相关图标 - 绿色
const networkIcons = ['network', 'activity'];
// 控制相关图标 - 橙色
const controlIcons = ['play-circle', 'stop-circle'];
// 配置相关图标 - 紫色
const configIcons = ['settings', 'git-branch'];
if (deviceIcons.includes(iconName)) {
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400';
} else if (networkIcons.includes(iconName)) {
return 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400';
} else if (controlIcons.includes(iconName)) {
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400';
} else if (configIcons.includes(iconName)) {
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400';
}
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
};
const nodeStyle = getNodeStyle(data.stepType);
return (
<div className={`group relative transition-all duration-200`}>
{/* 开始和结束步骤使用圆形 */}
{(data.stepType === 1 || data.stepType === 2) && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 处理步骤使用矩形 */}
{data.stepType === 3 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 判断步骤使用菱形 */}
{data.stepType === 4 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 连接点 - 根据节点类型显示不同的连接点 */}
{/* 开始节点 (type=1) - 只有输出连接点 */}
{data.stepType === 1 && (
<>
<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 }}
/>
<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 }}
/>
</>
)}
{/* 结束节点 (type=2) - 只有输入连接点 */}
{data.stepType === 2 && (
<>
<Handle
type="target"
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 }}
/>
<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 }}
/>
</>
)}
{/* 处理节点 (type=3) - 有输入和输出连接点 */}
{data.stepType === 3 && (
<>
<Handle
type="target"
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 }}
/>
<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 }}
/>
<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 }}
/>
<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 }}
/>
</>
)}
{/* 判断节点 (type=4) - 有输入和输出连接点 */}
{data.stepType === 4 && (
<>
<Handle
type="target"
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 }}
/>
<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 }}
/>
<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 }}
/>
<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 }}
/>
</>
)}
</div>
);
};
// 节点类型定义
const nodeTypes = {
testStep: TestStepNode,
};
function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseDetailDrawerProps) {
const [selectedTestCase, setSelectedTestCase] = useState<TestCaseFlowDetail | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const { fitView } = useReactFlow();
useEffect(() => {
if (open && testCaseId) {
loadTestCaseDetail(testCaseId);
} else {
// 关闭抽屉时清理状态
setSelectedTestCase(null);
setError(null);
setNodes([]);
setEdges([]);
}
}, [open, testCaseId]);
const loadTestCaseDetail = async (id: string) => {
try {
setFlowLoading(true);
setError(null);
const result = await testcaseService.getTestCaseFlowById(id);
if (result.isSuccess && result.data) {
setSelectedTestCase(result.data.testCaseFlow);
// 转换节点和连线为ReactFlow格式
const flowData = getReactFlowData(result.data.testCaseFlow);
setNodes(flowData.nodes);
setEdges(flowData.edges);
// 延迟执行 fitView,确保节点已渲染
setTimeout(() => {
fitView({ padding: 0.1 });
}, 100);
} else {
setError('加载测试用例详情失败');
console.error('加载测试用例详情失败:', result.errorMessages);
}
} catch (error) {
setError('加载测试用例详情出错');
console.error('加载测试用例详情出错:', error);
} finally {
setFlowLoading(false);
}
};
// 转换节点和连线为ReactFlow格式
const getReactFlowData = (testCase: TestCaseFlowDetail) => {
console.log('原始节点数据:', testCase.nodes);
console.log('原始连线数据:', testCase.edges);
const flowNodes: Node[] = testCase.nodes.map(node => {
console.log('处理节点:', node);
console.log('节点数据:', node.data);
console.log('节点位置:', node.position);
// 检查数据字段是否存在,如果不存在则使用默认值
const nodeData = {
stepId: node.data?.stepId || node.id,
stepName: node.data?.stepName || 'Unknown',
stepType: node.data?.stepType || 3,
stepTypeName: node.data?.stepTypeName || '处理步骤',
description: node.data?.description || '',
icon: node.data?.icon || 'settings'
};
console.log('处理后的节点数据:', nodeData);
return {
id: node.id,
type: 'testStep', // 使用自定义节点类型
position: node.position,
data: nodeData,
width: node.width || 150,
height: node.height || 50,
selected: node.selected || false,
positionAbsolute: node.positionAbsolute,
dragging: node.dragging || false
};
});
const flowEdges: Edge[] = testCase.edges.map(edge => {
console.log('处理连线:', edge);
return {
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || null,
target: edge.target,
targetHandle: edge.targetHandle || null,
type: edge.type || 'smoothstep',
animated: edge.animated || false,
style: edge.style || { stroke: '#333', strokeWidth: 2 },
data: edge.data || {}
};
});
return { nodes: flowNodes, edges: flowEdges };
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh]">
<DrawerHeader>
<DrawerTitle>{selectedTestCase?.name || '测试用例详情'}</DrawerTitle>
<DrawerDescription>
{selectedTestCase?.description || '无描述'}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4 overflow-hidden">
{flowLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600">...</p>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-red-500 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<p className="text-gray-600">{error}</p>
<button
onClick={() => testCaseId && loadTestCaseDetail(testCaseId)}
className="mt-2 text-blue-600 hover:text-blue-800 text-sm"
>
</button>
</div>
</div>
) : selectedTestCase ? (
<div className="h-full border rounded-lg">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
className="bg-gray-50"
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
minZoom={1}
maxZoom={2}
style={{ width: '100%', height: '100%' }}
snapToGrid={true}
snapGrid={[15, 15]}
>
<Controls />
<Background />
</ReactFlow>
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500"></p>
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}
export default function TestCaseDetailDrawer(props: TestCaseDetailDrawerProps) {
return (
<ReactFlowProvider>
<TestCaseDetailDrawerInner {...props} />
</ReactFlowProvider>
);
}

26
src/X1.WebUI/src/components/ui/drawer.tsx

@ -75,4 +75,30 @@ export function DrawerFooter({ children, className }: DrawerFooterProps) {
{children}
</div>
)
}
interface DrawerTitleProps {
children: React.ReactNode
className?: string
}
export function DrawerTitle({ children, className }: DrawerTitleProps) {
return (
<h2 className={cn("text-lg font-semibold", className)}>
{children}
</h2>
)
}
interface DrawerDescriptionProps {
children: React.ReactNode
className?: string
}
export function DrawerDescription({ children, className }: DrawerDescriptionProps) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
)
}

11
src/X1.WebUI/src/constants/api.ts

@ -28,9 +28,6 @@ export const API_PATHS = {
// 网络栈配置相关
NETWORK_STACK_CONFIGS: '/networkstackconfigs',
// 用例步骤配置相关
CASE_STEP_CONFIGS: '/casestepconfigs',
// 用户相关
USERS: '/users',
ROLES: '/roles',
@ -53,9 +50,11 @@ export const API_PATHS = {
// 场景相关
SCENARIOS: '/scenarios',
// 测试用例相关
TEST_CASES: '/test-cases',
TEST_STEPS: '/test-steps',
// 测试用例流程相关
TEST_CASE_FLOW: '/testcaseflow',
// 用例步骤配置相关
CASE_STEP_CONFIGS: '/casestepconfigs',
// 分析相关
ANALYSIS: {

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

@ -11,7 +11,6 @@ import ReactFlow, {
Panel,
ReactFlowProvider,
useReactFlow,
Handle,
Position,
} from 'reactflow';
@ -19,8 +18,8 @@ import 'reactflow/dist/style.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';
import {
Save,
import {
Save,
Settings,
Eye,
EyeOff,
@ -42,6 +41,37 @@ import {
} from 'lucide-react';
import { TestStep } from '@/services/teststepsService';
// 右键菜单组件
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
onDelete: () => void;
type: 'node' | 'edge';
}
const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose, onDelete, type }) => {
const handleDelete = () => {
onDelete();
onClose();
};
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 }}
>
<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"
>
<Trash2 className="h-4 w-4" />
{type === 'node' ? '节点' : '连线'}
</button>
</div>
);
};
type FlowNode = ReactFlowNode<{
stepId: string;
stepName: string;
@ -59,11 +89,12 @@ interface ReactFlowDesignerProps {
flowSteps: TestStep[];
onSaveFlow: (nodes: FlowNode[], edges: FlowEdge[]) => void;
onLoadFlow?: (nodes: FlowNode[], edges: FlowEdge[]) => void;
saving?: boolean;
}
// 自定义节点组件
const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) => {
const getIconComponent = (iconName: string) => {
// 根据图标名称返回对应的图标组件
switch (iconName) {
@ -150,74 +181,104 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
} else if (configIcons.includes(iconName)) {
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400';
}
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
};
const nodeStyle = getNodeStyle(data.stepType);
return (
<div className={`group relative transition-all duration-200`}>
{/* 开始和结束步骤使用圆形 */}
{(data.stepType === 1 || data.stepType === 2) && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 处理步骤使用矩形 */}
{data.stepType === 3 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 判断步骤使用菱形 */}
{data.stepType === 4 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 开始和结束步骤使用圆形 */}
{(data.stepType === 1 || data.stepType === 2) && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '80px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '12px',
lineHeight: '1.2'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 处理步骤使用矩形 */}
{data.stepType === 3 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '80px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '12px',
lineHeight: '1.2'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 判断步骤使用菱形 */}
{data.stepType === 4 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
style={{
width: 'fit-content',
minWidth: '80px'
}}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${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-sm text-gray-900 dark:text-gray-100 break-words"
style={{
fontSize: '12px',
lineHeight: '1.2'
}}
>
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 连接点 - 根据节点类型显示不同的连接点 */}
{/* 开始节点 (type=1) - 只有输出连接点 */}
{data.stepType === 1 && (
@ -238,7 +299,7 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
/>
</>
)}
{/* 结束节点 (type=2) - 只有输入连接点 */}
{data.stepType === 2 && (
<>
@ -258,7 +319,7 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
/>
</>
)}
{/* 处理节点 (type=3) - 有输入和输出连接点 */}
{data.stepType === 3 && (
<>
@ -292,7 +353,7 @@ const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) =>
/>
</>
)}
{/* 判断节点 (type=4) - 有输入和输出连接点 */}
{data.stepType === 4 && (
<>
@ -335,8 +396,9 @@ const nodeTypes = {
testStep: TestStepNode,
};
function ReactFlowDesignerInner({
onSaveFlow
function ReactFlowDesignerInner({
onSaveFlow,
saving
}: ReactFlowDesignerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
@ -344,6 +406,14 @@ function ReactFlowDesignerInner({
const [currentZoom, setCurrentZoom] = useState(1.5);
const [edgeType, setEdgeType] = useState<'step' | 'smoothstep' | 'straight'>('straight');
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
type: 'node' | 'edge';
id: string;
} | null>(null);
// 添加导入导出相关的状态
const [importedFlow, setImportedFlow] = useState<any>(null);
@ -367,7 +437,7 @@ function ReactFlowDesignerInner({
const dataStr = JSON.stringify(flowData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `testcase-flow-${new Date().toISOString().split('T')[0]}.json`;
@ -375,7 +445,7 @@ function ReactFlowDesignerInner({
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log('=== Flow数据已导出 ===');
};
@ -388,7 +458,7 @@ function ReactFlowDesignerInner({
reader.onload = (e) => {
try {
const flowData = JSON.parse(e.target?.result as string);
if (flowData.nodes && flowData.edges) {
setNodes(flowData.nodes);
setEdges(flowData.edges);
@ -406,7 +476,7 @@ function ReactFlowDesignerInner({
}
};
reader.readAsText(file);
// 清空input值,允许重复导入同一文件
event.target.value = '';
};
@ -423,7 +493,7 @@ function ReactFlowDesignerInner({
// 添加一个 ref 来直接访问 React Flow 容器
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
// 使用ref存储函数,避免useEffect依赖问题
const screenToFlowPositionRef = useRef(screenToFlowPosition);
const setNodesRef = useRef(setNodes);
@ -438,7 +508,7 @@ function ReactFlowDesignerInner({
const isValidConnection = useCallback((connection: Connection) => {
const sourceNode = nodes.find(node => node.id === connection.source);
const targetNode = nodes.find(node => node.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
@ -467,7 +537,7 @@ function ReactFlowDesignerInner({
}
// 检查是否已存在相同的连线
const existingEdge = edges.find(edge =>
const existingEdge = edges.find(edge =>
edge.source === connection.source && edge.target === connection.target
);
if (existingEdge) {
@ -518,8 +588,8 @@ function ReactFlowDesignerInner({
const onEdgeDoubleClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
const newCondition = prompt('请输入连线条件:', edge.data?.condition || '默认');
if (newCondition !== null) {
setEdges((currentEdges) => currentEdges.map(e =>
e.id === edge.id
setEdges((currentEdges) => currentEdges.map(e =>
e.id === edge.id
? { ...e, data: { ...e.data, condition: newCondition } }
: e
));
@ -532,11 +602,6 @@ function ReactFlowDesignerInner({
setCurrentZoom(zoom);
}, []);
// 点击画布取消选择
const onPaneClick = useCallback(() => {
// 可以在这里添加画布点击逻辑
}, []);
// 检查是否可以添加特定类型的节点
const canAddNodeType = useCallback((stepType: number) => {
if (stepType === 1) { // 开始步骤
@ -561,6 +626,18 @@ function ReactFlowDesignerInner({
canAddNodeTypeRef.current = canAddNodeType;
}, [canAddNodeType]);
// 添加全局点击事件监听器来关闭右键菜单
useEffect(() => {
const handleGlobalClick = () => {
setContextMenu(null);
};
document.addEventListener('click', handleGlobalClick);
return () => {
document.removeEventListener('click', handleGlobalClick);
};
}, []);
// 重置缩放级别
const resetZoom = useCallback(() => {
const reactFlowInstance = useReactFlow();
@ -573,7 +650,7 @@ function ReactFlowDesignerInner({
console.log('连线数据:', edges);
console.log('节点数量:', nodes.length);
console.log('连线数量:', edges.length);
// 打印每个节点的详细信息
nodes.forEach((node, index) => {
console.log(`节点 ${index + 1}:`, {
@ -583,7 +660,7 @@ function ReactFlowDesignerInner({
data: node.data
});
});
// 打印每条连线的详细信息
edges.forEach((edge, index) => {
console.log(`连线 ${index + 1}:`, {
@ -594,7 +671,7 @@ function ReactFlowDesignerInner({
data: edge.data
});
});
console.log('=== 保存数据结束 ===');
onSaveFlow(nodes as any[], edges as any[]);
}, [nodes, edges, onSaveFlow]);
@ -615,6 +692,54 @@ function ReactFlowDesignerInner({
});
}, [setEdges]);
// 右键菜单处理函数
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: ReactFlowNode) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'node',
id: node.id,
});
}, []);
const onEdgeContextMenu = useCallback((event: React.MouseEvent, edge: Edge) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'edge',
id: edge.id,
});
}, []);
const onPaneClick = useCallback(() => {
// 点击画布时关闭右键菜单
setContextMenu(null);
}, []);
const handleContextMenuDelete = useCallback(() => {
if (!contextMenu) return;
if (contextMenu.type === 'node') {
// 删除节点
setNodes((currentNodes) => currentNodes.filter(node => node.id !== contextMenu.id));
// 同时删除与该节点相关的所有连线
setEdges((currentEdges) => currentEdges.filter(edge =>
edge.source !== contextMenu.id && edge.target !== contextMenu.id
));
} else if (contextMenu.type === 'edge') {
// 删除连线
setEdges((currentEdges) => currentEdges.filter(edge => edge.id !== contextMenu.id));
}
setContextMenu(null);
}, [contextMenu, setNodes, setEdges]);
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
const toggleControls = useCallback(() => {
setShowControls((prev) => !prev);
}, []);
@ -642,7 +767,7 @@ function ReactFlowDesignerInner({
if (parsedData.type === 'test-step') {
const { step } = parsedData;
// 使用新的screenToFlowPosition函数,不需要手动计算边界
const position = screenToFlowPositionRef.current({
x: event.clientX,
@ -680,6 +805,18 @@ function ReactFlowDesignerInner({
// 添加样式
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;
@ -779,7 +916,7 @@ function ReactFlowDesignerInner({
const styleElement = document.createElement('style');
styleElement.textContent = styles;
document.head.appendChild(styleElement);
return () => {
document.head.removeChild(styleElement);
};
@ -808,7 +945,7 @@ function ReactFlowDesignerInner({
</SelectContent>
</Select>
</div>
<Button
size="sm"
variant="outline"
@ -836,13 +973,17 @@ function ReactFlowDesignerInner({
<Trash2 className="h-4 w-4" />
</Button>
<Button onClick={handleSave} className="flex items-center gap-1">
<Button
onClick={handleSave}
className="flex items-center gap-1"
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</Button>
{/* 在工具栏中添加导入导出按钮 */}
<div className="flow-toolbar">
<button
<button
onClick={exportFlow}
className="toolbar-btn export-btn"
title="导出Flow数据"
@ -854,8 +995,8 @@ function ReactFlowDesignerInner({
</svg>
</button>
<button
<button
onClick={handleFileSelect}
className="toolbar-btn import-btn"
title="导入Flow数据"
@ -881,6 +1022,8 @@ function ReactFlowDesignerInner({
onConnect={onConnect}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onDragOver={onDragOver}
onDrop={onDrop}
onMove={onMove}
@ -901,7 +1044,7 @@ function ReactFlowDesignerInner({
<Panel position="top-left" className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm p-2 rounded shadow">
<div className="text-xs text-gray-600 dark:text-gray-300 flex items-center justify-between">
<span>: {Math.round(currentZoom * 100)}%</span>
<button
<button
onClick={resetZoom}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
>
@ -916,6 +1059,18 @@ function ReactFlowDesignerInner({
</div>
</Panel>
</ReactFlow>
{/* 右键菜单 */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
onClose={closeContextMenu}
onDelete={handleContextMenuDelete}
/>
)}
{/* 添加导入成功提示 */}
{importedFlow && (
<div className="import-success-notification">

183
src/X1.WebUI/src/pages/testcases/TestCasesListView.tsx

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -6,84 +6,107 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Plus, Search, Edit, Trash2, Eye } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface TestCase {
id: string;
name: string;
description: string;
status: 'active' | 'inactive' | 'draft';
priority: 'high' | 'medium' | 'low';
category: string;
createdBy: string;
createdAt: string;
stepsCount: number;
}
const mockTestCases: TestCase[] = [
{
id: '1',
name: '用户登录测试',
description: '测试用户登录功能的正常流程和异常情况',
status: 'active',
priority: 'high',
category: '认证测试',
createdBy: '张三',
createdAt: '2024-01-15',
stepsCount: 5
},
{
id: '2',
name: '设备连接测试',
description: '测试设备连接和断开连接的稳定性',
status: 'active',
priority: 'medium',
category: '设备测试',
createdBy: '李四',
createdAt: '2024-01-10',
stepsCount: 8
}
];
import { testcaseService, TestCaseFlow } from '@/services/testcaseService';
import TestCaseDetailDrawer from '@/components/testcases/TestCaseDetailDrawer';
export default function TestCasesListView() {
const navigate = useNavigate();
const [testCases] = useState<TestCase[]>(mockTestCases);
const [testCases, setTestCases] = useState<TestCaseFlow[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedTestCaseId, setSelectedTestCaseId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const filteredTestCases = testCases.filter(testCase =>
testCase.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
testCase.description.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
loadTestCases();
}, []);
const loadTestCases = async () => {
try {
setLoading(true);
const result = await testcaseService.getTestCaseFlows({
searchTerm: searchTerm || undefined,
pageNumber: 1,
pageSize: 100
});
if (result.isSuccess && result.data) {
setTestCases(result.data.testCaseFlows);
} else {
console.error('加载测试用例失败:', result.errorMessages);
}
} catch (error) {
console.error('加载测试用例出错:', error);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
loadTestCases();
};
const handleViewTestCase = (id: string) => {
setSelectedTestCaseId(id);
setDrawerOpen(true);
};
const handleDelete = async (id: string) => {
if (window.confirm('确定要删除这个测试用例流程吗?')) {
try {
const result = await testcaseService.deleteTestCaseFlow(id);
if (result.isSuccess) {
loadTestCases(); // 重新加载列表
} else {
console.error('删除失败:', result.errorMessages);
}
} catch (error) {
console.error('删除出错:', error);
}
}
};
const getStatusBadge = (status: string) => {
const getStatusBadge = (isEnabled: boolean) => {
const statusConfig = {
active: { color: 'bg-green-100 text-green-800', text: '激活' },
inactive: { color: 'bg-gray-100 text-gray-800', text: '停用' },
draft: { color: 'bg-yellow-100 text-yellow-800', text: '草稿' }
true: { color: 'bg-green-100 text-green-800', text: '启用' },
false: { color: 'bg-gray-100 text-gray-800', text: '停用' }
};
const config = statusConfig[status as keyof typeof statusConfig];
const config = statusConfig[isEnabled.toString() as keyof typeof statusConfig];
return <Badge className={config.color}>{config.text}</Badge>;
};
const getPriorityBadge = (priority: string) => {
const priorityConfig = {
high: { color: 'bg-red-100 text-red-800', text: '高' },
medium: { color: 'bg-orange-100 text-orange-800', text: '中' },
low: { color: 'bg-blue-100 text-blue-800', text: '低' }
const getTypeBadge = (type: string) => {
const typeConfig = {
'Functional': { color: 'bg-blue-100 text-blue-800', text: '功能测试' },
'Performance': { color: 'bg-purple-100 text-purple-800', text: '性能测试' },
'Security': { color: 'bg-red-100 text-red-800', text: '安全测试' },
'UI': { color: 'bg-orange-100 text-orange-800', text: 'UI测试' },
'API': { color: 'bg-green-100 text-green-800', text: 'API测试' }
};
const config = priorityConfig[priority as keyof typeof priorityConfig];
const config = typeConfig[type as keyof typeof typeConfig] || { color: 'bg-gray-100 text-gray-800', text: type };
return <Badge className={config.color}>{config.text}</Badge>;
};
if (loading) {
return (
<div className="p-6">
<div className="text-center">...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"></h1>
<p className="text-gray-600 dark:text-gray-400 mt-1"></p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"></h1>
<p className="text-gray-600 dark:text-gray-400 mt-1"></p>
</div>
<Button onClick={() => navigate('/dashboard/testcases/create')} className="flex items-center gap-2">
<Plus className="w-4 h-4" />
</Button>
</div>
@ -92,12 +115,14 @@ export default function TestCasesListView() {
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜索测试用例名称或描述..."
placeholder="搜索测试用例流程名称或描述..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10"
/>
</div>
<Button onClick={handleSearch}></Button>
</div>
</Card>
@ -108,35 +133,40 @@ export default function TestCasesListView() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTestCases.map((testCase) => (
{testCases.map((testCase) => (
<TableRow key={testCase.id}>
<TableCell className="font-medium">{testCase.name}</TableCell>
<TableCell className="max-w-xs truncate">{testCase.description}</TableCell>
<TableCell>{getStatusBadge(testCase.status)}</TableCell>
<TableCell>{getPriorityBadge(testCase.priority)}</TableCell>
<TableCell>{testCase.category}</TableCell>
<TableCell>{testCase.stepsCount}</TableCell>
<TableCell className="max-w-xs truncate">{testCase.description || '-'}</TableCell>
<TableCell>{getTypeBadge(testCase.type)}</TableCell>
<TableCell>{getStatusBadge(testCase.isEnabled)}</TableCell>
<TableCell>{testCase.createdBy}</TableCell>
<TableCell>{testCase.createdAt}</TableCell>
<TableCell>{new Date(testCase.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="p-1">
<Button
variant="ghost"
size="sm"
className="p-1"
onClick={() => handleViewTestCase(testCase.id)}
title="查看详情"
>
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="p-1">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="p-1 text-red-600 hover:text-red-700">
<Button
variant="ghost"
size="sm"
className="p-1 text-red-600 hover:text-red-700"
onClick={() => handleDelete(testCase.id)}
title="删除测试用例"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
@ -147,6 +177,13 @@ export default function TestCasesListView() {
</Table>
</div>
</Card>
{/* 测试用例详情抽屉 */}
<TestCaseDetailDrawer
testCaseId={selectedTestCaseId}
open={drawerOpen}
onOpenChange={setDrawerOpen}
/>
</div>
);
}

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

@ -1,14 +1,14 @@
import { useState } from 'react';
import { TestStep } from '@/services/teststepsService';
import { testcaseService, CreateTestCaseFlowRequest, TestFlowType } from '@/services/testcaseService';
import TestStepsPanel from './TestStepsPanel';
import ReactFlowDesigner from './ReactFlowDesigner';
export default function TestCasesView() {
const [flowSteps] = useState<TestStep[]>([]);
const [saving, setSaving] = useState(false);
const handleSaveFlow = (nodes: any[], edges: any[]) => {
const handleSaveFlow = async (nodes: any[], edges: any[]) => {
console.log('=== TestCasesView 保存流程数据 ===');
console.log('完整流程数据:', { nodes, edges });
console.log('流程节点总数:', nodes.length);
@ -36,6 +36,66 @@ export default function TestCasesView() {
if (endNodes.length > 1) {
console.warn('⚠️ 警告: 流程有多个结束节点');
}
// 保存到后端
try {
setSaving(true);
// 转换节点数据格式
const convertedNodes = nodes.map(node => ({
id: node.id,
type: node.type,
stepId: node.data?.stepId || '', // 添加 stepId 字段
positionX: node.position.x,
positionY: node.position.y,
data: node.data,
width: node.width,
height: node.height,
selected: node.selected,
positionAbsoluteX: node.positionAbsolute?.x,
positionAbsoluteY: node.positionAbsolute?.y,
dragging: node.dragging
}));
// 转换连线数据格式
const convertedEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
animated: edge.animated,
condition: edge.data?.condition,
style: JSON.stringify(edge.style)
}));
// 创建测试用例流程请求
const createRequest: CreateTestCaseFlowRequest = {
name: `测试流程_${new Date().toLocaleString()}`,
description: `包含 ${nodes.length} 个节点和 ${edges.length} 个连线的测试流程`,
type: 1 as TestFlowType, // Registration = 1
isEnabled: true,
viewportX: 0,
viewportY: 0,
viewportZoom: 1,
nodes: convertedNodes,
edges: convertedEdges
};
const result = await testcaseService.createTestCaseFlow(createRequest);
if (result.isSuccess) {
console.log('✅ 测试用例流程保存成功:', result.data);
alert('测试用例流程保存成功!');
} else {
console.error('❌ 保存失败:', result.errorMessages);
alert('保存失败: ' + (result.errorMessages?.join(', ') || '未知错误'));
}
} catch (error) {
console.error('❌ 保存出错:', error);
alert('保存出错: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setSaving(false);
}
console.log('=== TestCasesView 保存流程数据结束 ===');
};
@ -52,6 +112,7 @@ export default function TestCasesView() {
<ReactFlowDesigner
flowSteps={flowSteps}
onSaveFlow={handleSaveFlow}
saving={saving}
/>
</div>
</div>

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

@ -1,203 +1,229 @@
import { httpClient } from '@/lib/http-client';
import { OperationResult } from '@/types/auth';
import { API_PATHS } from '@/constants/api';
// 用例状态类型
export type TestCaseStatus = 'active' | 'inactive' | 'draft' | 'completed';
// 测试流程类型 - 与后端 TestFlowType 枚举对应
export type TestFlowType = 1 | 2 | 3;
// 用例优先级类型
export type TestCasePriority = 'low' | 'medium' | 'high' | 'critical';
// 用例类型
export type TestCaseType = 'functional' | 'performance' | 'security' | 'ui' | 'api';
// 用例接口定义
export interface TestCase {
// 测试用例流程接口定义
export interface TestCaseFlow {
id: string;
name: string;
description: string;
status: TestCaseStatus;
priority: TestCasePriority;
type: TestCaseType;
tags: string[];
requirements: string;
expectedResult: string;
preconditions: string;
testSteps: TestStep[];
createdBy: string;
description?: string;
type: string;
isEnabled: boolean;
viewportX: number;
viewportY: number;
viewportZoom: number;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
// 测试用例节点数据接口
export interface TestCaseNodeData {
stepId: string;
stepName: string;
stepType: number;
stepTypeName: string;
description?: string;
icon?: string;
}
// 测试用例节点位置接口
export interface TestCaseNodePosition {
x: number;
y: number;
}
// 测试步骤接口定义
export interface TestStep {
// 测试用例节点接口
export interface TestCaseNode {
id: string;
type: string;
position: TestCaseNodePosition;
data: TestCaseNodeData;
width: number;
height: number;
selected: boolean;
positionAbsolute?: TestCaseNodePosition;
dragging: boolean;
// 兼容性字段
testCaseId: string;
stepNumber: number;
action: string;
expectedResult: string;
actualResult?: string;
status: 'pending' | 'passed' | 'failed' | 'blocked';
notes?: string;
createdBy: string;
createdAt: string;
updatedAt: string;
nodeId: string;
sequenceNumber: number;
positionX: number;
positionY: number;
isSelected: boolean;
positionAbsoluteX?: number;
positionAbsoluteY?: number;
isDragging: boolean;
}
// 创建用例请求接口
export interface CreateTestCaseRequest {
name: string;
description: string;
status: TestCaseStatus;
priority: TestCasePriority;
type: TestCaseType;
tags: string[];
requirements: string;
expectedResult: string;
preconditions: string;
}
// 更新用例请求接口
export interface UpdateTestCaseRequest {
name: string;
description: string;
status: TestCaseStatus;
priority: TestCasePriority;
type: TestCaseType;
tags: string[];
requirements: string;
expectedResult: string;
preconditions: string;
}
// 获取用例列表请求接口
export interface GetAllTestCasesRequest {
name?: string;
status?: TestCaseStatus;
priority?: TestCasePriority;
type?: TestCaseType;
page?: number;
pageSize?: number;
// 测试用例连线样式接口
export interface TestCaseEdgeStyle {
stroke?: string;
strokeWidth: number;
}
// 获取用例列表响应接口
export interface GetAllTestCasesResponse {
testCases: TestCase[];
totalCount: number;
// 测试用例连线数据接口
export interface TestCaseEdgeData {
condition?: string;
}
// 创建测试步骤请求接口
export interface CreateTestStepRequest {
// 测试用例连线接口
export interface TestCaseEdge {
source: string;
sourceHandle?: string;
target: string;
targetHandle?: string;
id: string;
type: string;
animated: boolean;
style: TestCaseEdgeStyle;
data: TestCaseEdgeData;
// 兼容性字段
testCaseId: string;
stepNumber: number;
action: string;
expectedResult: string;
notes?: string;
}
// 更新测试步骤请求接口
export interface UpdateTestStepRequest {
stepNumber: number;
action: string;
expectedResult: string;
actualResult?: string;
status: 'pending' | 'passed' | 'failed' | 'blocked';
notes?: string;
}
// 获取测试步骤列表请求接口
export interface GetAllTestStepsRequest {
testCaseId?: string;
status?: 'pending' | 'passed' | 'failed' | 'blocked';
page?: number;
pageSize?: number;
edgeId: string;
sourceNodeId: string;
targetNodeId: string;
edgeType?: string;
condition?: string;
isAnimated: boolean;
styleJson?: string;
}
// 获取测试步骤列表响应接口
export interface GetAllTestStepsResponse {
testSteps: TestStep[];
totalCount: number;
// 测试用例流程详情接口
export interface TestCaseFlowDetail {
id: string;
name: string;
description?: string;
type: string;
isEnabled: boolean;
viewportX: number;
viewportY: number;
viewportZoom: number;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
nodes: TestCaseNode[];
edges: TestCaseEdge[];
}
class TestCaseService {
private readonly baseUrl = '/api/testcases';
// 获取测试用例流程列表请求接口
export interface GetTestCaseFlowsRequest {
searchTerm?: string;
type?: string;
isEnabled?: boolean;
pageNumber?: number;
pageSize?: number;
}
// 获取所有用例
async getAllTestCases(params: GetAllTestCasesRequest = {}): Promise<OperationResult<GetAllTestCasesResponse>> {
const queryParams = new URLSearchParams();
if (params.name) queryParams.append('name', params.name);
if (params.status) queryParams.append('status', params.status);
if (params.priority) queryParams.append('priority', params.priority);
if (params.type) queryParams.append('type', params.type);
if (params.page) queryParams.append('page', params.page.toString());
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
// 获取测试用例流程列表响应接口
export interface GetTestCaseFlowsResponse {
testCaseFlows: TestCaseFlow[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
}
const url = `${this.baseUrl}?${queryParams.toString()}`;
return httpClient.get<GetAllTestCasesResponse>(url);
}
// 获取测试用例流程详情响应接口
export interface GetTestCaseFlowByIdResponse {
testCaseFlow: TestCaseFlowDetail;
}
// 根据ID获取用例
async getTestCaseById(id: string): Promise<OperationResult<TestCase>> {
return httpClient.get<TestCase>(`${this.baseUrl}/${id}`);
}
// 创建测试用例流程节点数据接口
export interface CreateNodeData {
id: string;
type: string;
stepId: string; // 步骤配置ID(必填)
positionX: number;
positionY: number;
data?: any;
width?: number;
height?: number;
selected?: boolean;
positionAbsoluteX?: number;
positionAbsoluteY?: number;
dragging?: boolean;
}
// 创建用例
async createTestCase(data: CreateTestCaseRequest): Promise<OperationResult<TestCase>> {
return httpClient.post<TestCase>(this.baseUrl, data);
}
// 创建测试用例流程连线数据接口
export interface CreateEdgeData {
id: string;
source: string;
target: string;
type: string;
animated?: boolean;
condition?: string;
style?: string;
}
// 更新用例
async updateTestCase(id: string, data: UpdateTestCaseRequest): Promise<OperationResult<TestCase>> {
return httpClient.put<TestCase>(`${this.baseUrl}/${id}`, data);
}
// 创建测试用例流程请求接口
export interface CreateTestCaseFlowRequest {
name: string;
description?: string;
type: TestFlowType;
isEnabled?: boolean;
viewportX?: number;
viewportY?: number;
viewportZoom?: number;
nodes?: CreateNodeData[];
edges?: CreateEdgeData[];
}
// 删除用例
async deleteTestCase(id: string): Promise<OperationResult<void>> {
return httpClient.delete<void>(`${this.baseUrl}/${id}`);
}
// 创建测试用例流程响应接口
export interface CreateTestCaseFlowResponse {
id: string;
name: string;
description?: string;
type: string;
isEnabled: boolean;
viewportX: number;
viewportY: number;
viewportZoom: number;
createdAt: string;
createdBy: string;
}
// 获取用例的测试步骤
async getTestStepsByTestCaseId(testCaseId: string): Promise<OperationResult<TestStep[]>> {
return httpClient.get<TestStep[]>(`${this.baseUrl}/${testCaseId}/steps`);
}
class TestCaseFlowService {
private readonly baseUrl = API_PATHS.TEST_CASE_FLOW;
// 获取所有测试步骤
async getAllTestSteps(params: GetAllTestStepsRequest = {}): Promise<OperationResult<GetAllTestStepsResponse>> {
// 获取测试用例流程列表
async getTestCaseFlows(params: GetTestCaseFlowsRequest = {}): Promise<OperationResult<GetTestCaseFlowsResponse>> {
const queryParams = new URLSearchParams();
if (params.testCaseId) queryParams.append('testCaseId', params.testCaseId);
if (params.status) queryParams.append('status', params.status);
if (params.page) queryParams.append('page', params.page.toString());
if (params.searchTerm) queryParams.append('searchTerm', params.searchTerm);
if (params.type) queryParams.append('type', params.type);
if (params.isEnabled !== undefined) queryParams.append('isEnabled', params.isEnabled.toString());
if (params.pageNumber) queryParams.append('pageNumber', params.pageNumber.toString());
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
const url = `/api/teststeps?${queryParams.toString()}`;
return httpClient.get<GetAllTestStepsResponse>(url);
}
// 根据ID获取测试步骤
async getTestStepById(id: string): Promise<OperationResult<TestStep>> {
return httpClient.get<TestStep>(`/api/teststeps/${id}`);
}
// 创建测试步骤
async createTestStep(data: CreateTestStepRequest): Promise<OperationResult<TestStep>> {
return httpClient.post<TestStep>('/api/teststeps', data);
const url = `${this.baseUrl}?${queryParams.toString()}`;
return httpClient.get<GetTestCaseFlowsResponse>(url);
}
// 更新测试步骤
async updateTestStep(id: string, data: UpdateTestStepRequest): Promise<OperationResult<TestStep>> {
return httpClient.put<TestStep>(`/api/teststeps/${id}`, data);
// 根据ID获取测试用例流程详情
async getTestCaseFlowById(id: string): Promise<OperationResult<GetTestCaseFlowByIdResponse>> {
return httpClient.get<GetTestCaseFlowByIdResponse>(`${this.baseUrl}/${id}`);
}
// 删除测试步骤
async deleteTestStep(id: string): Promise<OperationResult<void>> {
return httpClient.delete<void>(`/api/teststeps/${id}`);
// 创建测试用例流程
async createTestCaseFlow(data: CreateTestCaseFlowRequest): Promise<OperationResult<CreateTestCaseFlowResponse>> {
return httpClient.post<CreateTestCaseFlowResponse>(this.baseUrl, data);
}
// 批量更新测试步骤状态
async updateTestStepsStatus(testCaseId: string, stepIds: string[], status: 'pending' | 'passed' | 'failed' | 'blocked'): Promise<OperationResult<void>> {
return httpClient.put<void>(`${this.baseUrl}/${testCaseId}/steps/status`, { stepIds, status });
// 删除测试用例流程
async deleteTestCaseFlow(id: string): Promise<OperationResult<boolean>> {
return httpClient.delete<boolean>(`${this.baseUrl}/${id}`);
}
}
export const testcaseService = new TestCaseService();
export const testcaseFlowService = new TestCaseFlowService();
// 为了保持向后兼容,保留原有的 testcaseService 导出
export const testcaseService = testcaseFlowService;

14182
src/modify.md

File diff suppressed because it is too large
Loading…
Cancel
Save