Browse Source

feat: 修复场景测试用例绑定功能实现

- 修复 ScenarioBindingView.tsx 中绑定接口调用问题
- 完善 handleBindTestCases 函数,实现真正的API调用
- 添加完整的错误处理和用户反馈机制
- 实现绑定成功后的数据刷新功能
- 构建正确的绑定请求数据格式,包含执行顺序、循环次数等字段
- 支持批量绑定测试用例到场景
- 提供详细的绑定结果反馈(成功数量、失败数量、错误信息)
- 更新修改记录文档,记录本次修复详情

技术细节:
- 调用 scenarioService.createScenarioTestCase API
- 数据格式与后端 CreateScenarioTestCaseCommand 完全匹配
- 支持按选择顺序设置执行顺序
- 默认循环次数为1,默认启用状态
release/web-ui-v1.0.0
root 4 months ago
parent
commit
4c30176824
  1. 3
      src/X1.Application/DependencyInjection.cs
  2. 62
      src/X1.Application/Features/Common/Dtos/ScenarioTestCaseDto.cs
  3. 78
      src/X1.Application/Features/ScenarioTestCases/Commands/CreateScenarioTestCase/CreateScenarioTestCaseCommandValidator.cs
  4. 22
      src/X1.Application/Features/ScenarioTestCases/Commands/CreateScenarioTestCase/CreateScenarioTestCaseRequest.cs
  5. 1
      src/X1.Application/Features/ScenarioTestCases/Queries/GetScenarioTestCases/GetScenarioTestCasesQueryHandler.cs
  6. 63
      src/X1.Application/Features/ScenarioTestCases/Queries/GetScenarioTestCases/GetScenarioTestCasesResponse.cs
  7. 2
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesQuery.cs
  8. 10
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesQueryHandler.cs
  9. 2
      src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesResponse.cs
  10. 6
      src/X1.Application/Features/TestScenarios/Commands/CreateTestScenario/CreateTestScenarioCommandHandler.cs
  11. 12
      src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesQuery.cs
  12. 41
      src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesQueryHandler.cs
  13. 38
      src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesResponse.cs
  14. 6
      src/X1.Application/Features/TestScenarios/Queries/GetTestScenarioById/GetTestScenarioByIdQueryHandler.cs
  15. 48
      src/X1.Application/Features/TestScenarios/Queries/GetTestScenarioById/GetTestScenarioByIdResponse.cs
  16. 94
      src/X1.Domain/Common/ScenarioTypeConverter.cs
  17. 48
      src/X1.Domain/Entities/TestCase/ScenarioType.cs
  18. 2122
      src/X1.Infrastructure/Migrations/20250824112436_AddTestScenarioTables.Designer.cs
  19. 114
      src/X1.Infrastructure/Migrations/20250824112436_AddTestScenarioTables.cs
  20. 144
      src/X1.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
  21. 6
      src/X1.Presentation/Controllers/TestCaseFlowController.cs
  22. 51
      src/X1.Presentation/Controllers/TestScenariosController.cs
  23. 6
      src/X1.WebUI/src/components/layout/Content.tsx
  24. 16
      src/X1.WebUI/src/components/testcases/SaveTestCaseForm.tsx
  25. 2
      src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx
  26. 5
      src/X1.WebUI/src/constants/api.ts
  27. 12
      src/X1.WebUI/src/constants/menuConfig.ts
  28. 2
      src/X1.WebUI/src/contexts/AuthContext.tsx
  29. 155
      src/X1.WebUI/src/pages/scenarios/ScenarioForm.tsx
  30. 253
      src/X1.WebUI/src/pages/scenarios/ScenariosView.tsx
  31. 263
      src/X1.WebUI/src/pages/scenarios/scenario-binding/ScenarioBindingView.tsx
  32. 81
      src/X1.WebUI/src/pages/scenarios/scenario-binding/ScenarioCategoryTree.tsx
  33. 191
      src/X1.WebUI/src/pages/scenarios/scenario-binding/TestCaseList.tsx
  34. 157
      src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigForm.tsx
  35. 156
      src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigTable.tsx
  36. 383
      src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigView.tsx
  37. 39
      src/X1.WebUI/src/pages/scenarios/scenarios-list/ScenarioTable.tsx
  38. 200
      src/X1.WebUI/src/pages/scenarios/scenarios-list/ScenariosListView.tsx
  39. 24
      src/X1.WebUI/src/pages/testcases/TestCasesView.tsx
  40. 20
      src/X1.WebUI/src/routes/AppRouter.tsx
  41. 335
      src/X1.WebUI/src/services/scenarioService.ts
  42. 17
      src/X1.WebUI/src/services/testcaseService.ts
  43. 2
      src/X1.WebUI/src/services/teststepsService.ts
  44. 1402
      src/modify.md

3
src/X1.Application/DependencyInjection.cs

@ -42,6 +42,9 @@ public static class DependencyInjection
// 注册验证器
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
// 注册 FluentValidation 验证器
services.AddValidatorsFromAssembly(assembly);
// 注册测试用例流程构建器
services.AddScoped<TestCaseFlowBuilder>();

62
src/X1.Application/Features/Common/Dtos/ScenarioTestCaseDto.cs

@ -0,0 +1,62 @@
namespace X1.Application.Features.Common.Dtos;
/// <summary>
/// 场景测试用例数据传输对象
/// </summary>
public class ScenarioTestCaseDto
{
/// <summary>
/// 场景测试用例ID
/// </summary>
public string ScenarioTestCaseId { get; set; } = null!;
/// <summary>
/// 场景ID
/// </summary>
public string ScenarioId { get; set; } = null!;
/// <summary>
/// 测试用例流程ID
/// </summary>
public string TestCaseFlowId { get; set; } = null!;
/// <summary>
/// 测试用例流程名称
/// </summary>
public string? TestCaseFlowName { get; set; }
/// <summary>
/// 执行顺序
/// </summary>
public int ExecutionOrder { get; set; }
/// <summary>
/// 循环次数
/// </summary>
public int LoopCount { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { 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!;
}

78
src/X1.Application/Features/ScenarioTestCases/Commands/CreateScenarioTestCase/CreateScenarioTestCaseCommandValidator.cs

@ -0,0 +1,78 @@
using FluentValidation;
namespace X1.Application.Features.ScenarioTestCases.Commands.CreateScenarioTestCase;
/// <summary>
/// 创建场景测试用例命令验证器
/// </summary>
public sealed class CreateScenarioTestCaseCommandValidator : AbstractValidator<CreateScenarioTestCaseCommand>
{
public CreateScenarioTestCaseCommandValidator()
{
// 验证场景ID
RuleFor(x => x.ScenarioId)
.NotEmpty().WithMessage("场景ID不能为空")
.MaximumLength(50).WithMessage("场景ID长度不能超过50个字符");
// 验证测试用例列表
RuleFor(x => x.TestCases)
.NotEmpty().WithMessage("测试用例列表不能为空")
.Must(testCases => testCases != null && testCases.Count > 0)
.WithMessage("至少需要包含一个测试用例");
// 验证每个测试用例项
RuleForEach(x => x.TestCases)
.SetValidator(new ScenarioTestCaseItemValidator());
}
}
/// <summary>
/// 创建场景测试用例请求验证器
/// </summary>
public sealed class CreateScenarioTestCaseRequestValidator : AbstractValidator<CreateScenarioTestCaseRequest>
{
public CreateScenarioTestCaseRequestValidator()
{
// 验证场景ID
RuleFor(x => x.ScenarioId)
.NotEmpty().WithMessage("场景ID不能为空")
.MaximumLength(50).WithMessage("场景ID长度不能超过50个字符");
// 验证测试用例列表
RuleFor(x => x.TestCases)
.NotEmpty().WithMessage("测试用例列表不能为空")
.Must(testCases => testCases != null && testCases.Count > 0)
.WithMessage("至少需要包含一个测试用例");
// 验证每个测试用例项
RuleForEach(x => x.TestCases)
.SetValidator(new ScenarioTestCaseItemValidator());
}
}
/// <summary>
/// 场景测试用例项验证器
/// </summary>
public sealed class ScenarioTestCaseItemValidator : AbstractValidator<ScenarioTestCaseItem>
{
public ScenarioTestCaseItemValidator()
{
// 验证测试用例流程ID
RuleFor(x => x.TestCaseFlowId)
.NotEmpty().WithMessage("测试用例流程ID不能为空")
.MaximumLength(50).WithMessage("测试用例流程ID长度不能超过50个字符");
// 验证执行顺序
RuleFor(x => x.ExecutionOrder)
.GreaterThanOrEqualTo(0).WithMessage("执行顺序必须大于等于0");
// 验证循环次数
RuleFor(x => x.LoopCount)
.GreaterThan(0).WithMessage("循环次数必须大于0")
.LessThanOrEqualTo(1000).WithMessage("循环次数不能超过1000");
// 验证是否启用(布尔值,通常不需要特殊验证)
RuleFor(x => x.IsEnabled)
.NotNull().WithMessage("启用状态不能为空");
}
}

22
src/X1.Application/Features/ScenarioTestCases/Commands/CreateScenarioTestCase/CreateScenarioTestCaseRequest.cs

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace X1.Application.Features.ScenarioTestCases.Commands.CreateScenarioTestCase;
/// <summary>
/// 创建场景测试用例请求模型
/// </summary>
public class CreateScenarioTestCaseRequest
{
/// <summary>
/// 场景ID
/// </summary>
[Required(ErrorMessage = "场景ID不能为空")]
public string ScenarioId { get; set; } = null!;
/// <summary>
/// 测试用例列表
/// </summary>
[Required(ErrorMessage = "测试用例列表不能为空")]
[MinLength(1, ErrorMessage = "至少需要包含一个测试用例")]
public List<ScenarioTestCaseItem> TestCases { get; set; } = new();
}

1
src/X1.Application/Features/ScenarioTestCases/Queries/GetScenarioTestCases/GetScenarioTestCasesQueryHandler.cs

@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
using X1.Domain.Repositories.TestCase;
using X1.Application.Features.Common.Dtos;
namespace X1.Application.Features.ScenarioTestCases.Queries.GetScenarioTestCases;

63
src/X1.Application/Features/ScenarioTestCases/Queries/GetScenarioTestCases/GetScenarioTestCasesResponse.cs

@ -1,3 +1,5 @@
using X1.Application.Features.Common.Dtos;
namespace X1.Application.Features.ScenarioTestCases.Queries.GetScenarioTestCases;
/// <summary>
@ -20,64 +22,3 @@ public class GetScenarioTestCasesResponse
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 场景测试用例数据传输对象
/// </summary>
public class ScenarioTestCaseDto
{
/// <summary>
/// 场景测试用例ID
/// </summary>
public string ScenarioTestCaseId { get; set; } = null!;
/// <summary>
/// 场景ID
/// </summary>
public string ScenarioId { get; set; } = null!;
/// <summary>
/// 测试用例流程ID
/// </summary>
public string TestCaseFlowId { get; set; } = null!;
/// <summary>
/// 测试用例流程名称
/// </summary>
public string? TestCaseFlowName { get; set; }
/// <summary>
/// 执行顺序
/// </summary>
public int ExecutionOrder { get; set; }
/// <summary>
/// 循环次数
/// </summary>
public int LoopCount { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { 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!;
}

2
src/X1.Application/Features/TestCaseFlow/Queries/GetFormTypeStepTypeMapping/GetFormTypeStepTypeMappingQuery.cs → src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesQuery.cs

@ -6,7 +6,7 @@ namespace X1.Application.Features.TestCaseFlow.Queries.GetFormTypeStepTypeMappin
/// <summary>
/// 获取表单类型到步骤类型映射查询
/// </summary>
public class GetFormTypeStepTypeMappingQuery : IRequest<OperationResult<GetFormTypeStepTypeMappingResponse>>
public class GetTestFlowTypesQuery : IRequest<OperationResult<GetTestFlowTypesResponse>>
{
// 无需额外参数,返回所有映射关系
}

10
src/X1.Application/Features/TestCaseFlow/Queries/GetFormTypeStepTypeMapping/GetFormTypeStepTypeMappingQueryHandler.cs → src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesQueryHandler.cs

@ -6,7 +6,7 @@ namespace X1.Application.Features.TestCaseFlow.Queries.GetFormTypeStepTypeMappin
/// <summary>
/// 获取表单类型到步骤类型映射查询处理器
/// </summary>
public class GetFormTypeStepTypeMappingQueryHandler : IRequestHandler<GetFormTypeStepTypeMappingQuery, OperationResult<GetFormTypeStepTypeMappingResponse>>
public class GetTestFlowTypesQueryHandler : IRequestHandler<GetTestFlowTypesQuery, OperationResult<GetTestFlowTypesResponse>>
{
/// <summary>
/// 处理查询
@ -14,12 +14,12 @@ public class GetFormTypeStepTypeMappingQueryHandler : IRequestHandler<GetFormTyp
/// <param name="request">查询请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
public async Task<OperationResult<GetFormTypeStepTypeMappingResponse>> Handle(GetFormTypeStepTypeMappingQuery request, CancellationToken cancellationToken)
public async Task<OperationResult<GetTestFlowTypesResponse>> Handle(GetTestFlowTypesQuery request, CancellationToken cancellationToken)
{
try
{
// 构建响应对象
var response = new GetFormTypeStepTypeMappingResponse();
var response = new GetTestFlowTypesResponse();
// 获取测试流程类型列表
response.TestFlowTypes = TestFlowTypeConverter.GetTestFlowTypes().Select(tft => new TestFlowTypeDto
@ -29,11 +29,11 @@ public class GetFormTypeStepTypeMappingQueryHandler : IRequestHandler<GetFormTyp
Description = tft.Description
}).ToList();
return await Task.FromResult(OperationResult<GetFormTypeStepTypeMappingResponse>.CreateSuccess(response));
return await Task.FromResult(OperationResult<GetTestFlowTypesResponse>.CreateSuccess(response));
}
catch (Exception ex)
{
return await Task.FromResult(OperationResult<GetFormTypeStepTypeMappingResponse>.CreateFailure($"获取测试流程类型失败: {ex.Message}"));
return await Task.FromResult(OperationResult<GetTestFlowTypesResponse>.CreateFailure($"获取测试流程类型失败: {ex.Message}"));
}
}
}

2
src/X1.Application/Features/TestCaseFlow/Queries/GetFormTypeStepTypeMapping/GetFormTypeStepTypeMappingResponse.cs → src/X1.Application/Features/TestCaseFlow/Queries/GetTestFlowTypesQueryMapping/GetTestFlowTypesResponse.cs

@ -3,7 +3,7 @@ namespace X1.Application.Features.TestCaseFlow.Queries.GetFormTypeStepTypeMappin
/// <summary>
/// 获取测试流程类型响应
/// </summary>
public class GetFormTypeStepTypeMappingResponse
public class GetTestFlowTypesResponse
{
/// <summary>
/// 测试流程类型列表

6
src/X1.Application/Features/TestScenarios/Commands/CreateTestScenario/CreateTestScenarioCommandHandler.cs

@ -101,9 +101,9 @@ public class CreateTestScenarioCommandHandler : IRequestHandler<CreateTestScenar
{
ScenarioType.Functional => "FUNC",
ScenarioType.Performance => "PERF",
ScenarioType.Security => "SEC",
ScenarioType.Integration => "INT",
ScenarioType.Regression => "REG",
//ScenarioType.Security => "SEC",
//ScenarioType.Integration => "INT",
//ScenarioType.Regression => "REG",
_ => "GEN"
};

12
src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesQuery.cs

@ -0,0 +1,12 @@
using X1.Domain.Common;
using MediatR;
namespace X1.Application.Features.TestScenarios.Queries.GetScenarioTypes;
/// <summary>
/// 获取测试场景类型查询
/// </summary>
public class GetScenarioTypesQuery : IRequest<OperationResult<GetScenarioTypesResponse>>
{
// 无需额外参数,返回所有测试场景类型
}

41
src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesQueryHandler.cs

@ -0,0 +1,41 @@
using MediatR;
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
namespace X1.Application.Features.TestScenarios.Queries.GetScenarioTypes;
/// <summary>
/// 获取测试场景类型查询处理器
/// </summary>
public class GetScenarioTypesQueryHandler : IRequestHandler<GetScenarioTypesQuery, OperationResult<GetScenarioTypesResponse>>
{
/// <summary>
/// 处理查询
/// </summary>
/// <param name="request">查询请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
public async Task<OperationResult<GetScenarioTypesResponse>> Handle(GetScenarioTypesQuery request, CancellationToken cancellationToken)
{
try
{
// 构建响应对象
var response = new GetScenarioTypesResponse();
// 获取测试场景类型列表
response.ScenarioTypes = ScenarioTypeConverter.GetScenarioTypes().Select(st => new ScenarioTypeDto
{
Value = st.Value,
EnumValue = st.Name, // 枚举的字符串值,如 "Functional"
Name = ((ScenarioType)st.Value).GetDisplayName(), // 显示名称,如 "功能测试"
Description = st.Description
}).ToList();
return await Task.FromResult(OperationResult<GetScenarioTypesResponse>.CreateSuccess(response));
}
catch (Exception ex)
{
return await Task.FromResult(OperationResult<GetScenarioTypesResponse>.CreateFailure($"获取测试场景类型失败: {ex.Message}"));
}
}
}

38
src/X1.Application/Features/TestScenarios/Queries/GetScenarioTypes/GetScenarioTypesResponse.cs

@ -0,0 +1,38 @@
namespace X1.Application.Features.TestScenarios.Queries.GetScenarioTypes;
/// <summary>
/// 获取测试场景类型响应
/// </summary>
public class GetScenarioTypesResponse
{
/// <summary>
/// 测试场景类型列表
/// </summary>
public List<ScenarioTypeDto> ScenarioTypes { get; set; } = new();
}
/// <summary>
/// 测试场景类型DTO
/// </summary>
public class ScenarioTypeDto
{
/// <summary>
/// 测试场景类型值
/// </summary>
public int Value { get; set; }
/// <summary>
/// 测试场景类型枚举字符串值
/// </summary>
public string EnumValue { get; set; } = null!;
/// <summary>
/// 测试场景类型名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 测试场景类型描述
/// </summary>
public string Description { get; set; } = null!;
}

6
src/X1.Application/Features/TestScenarios/Queries/GetTestScenarioById/GetTestScenarioByIdQueryHandler.cs

@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using X1.Domain.Common;
using X1.Domain.Entities.TestCase;
using X1.Domain.Repositories.TestCase;
using X1.Application.Features.Common.Dtos;
namespace X1.Application.Features.TestScenarios.Queries.GetTestScenarioById;
@ -75,13 +76,16 @@ public class GetTestScenarioByIdQueryHandler : IRequestHandler<GetTestScenarioBy
.Select(x => new ScenarioTestCaseDto
{
ScenarioTestCaseId = x.Id,
ScenarioId = x.ScenarioId,
TestCaseFlowId = x.TestCaseFlowId,
TestCaseFlowName = x.TestCaseFlow?.Name ?? "未知",
ExecutionOrder = x.ExecutionOrder,
LoopCount = x.LoopCount,
IsEnabled = x.IsEnabled,
CreatedAt = x.CreatedAt,
CreatedBy = x.CreatedBy
CreatedBy = x.CreatedBy,
UpdatedAt = x.UpdatedAt,
UpdatedBy = x.UpdatedBy
})
.ToList();
}

48
src/X1.Application/Features/TestScenarios/Queries/GetTestScenarioById/GetTestScenarioByIdResponse.cs

@ -1,3 +1,5 @@
using X1.Application.Features.Common.Dtos;
namespace X1.Application.Features.TestScenarios.Queries.GetTestScenarioById;
/// <summary>
@ -60,49 +62,3 @@ public class GetTestScenarioByIdResponse
/// </summary>
public List<ScenarioTestCaseDto>? TestCases { get; set; }
}
/// <summary>
/// 场景测试用例数据传输对象
/// </summary>
public class ScenarioTestCaseDto
{
/// <summary>
/// 场景测试用例ID
/// </summary>
public string ScenarioTestCaseId { get; set; } = null!;
/// <summary>
/// 测试用例流程ID
/// </summary>
public string TestCaseFlowId { get; set; } = null!;
/// <summary>
/// 测试用例流程名称
/// </summary>
public string TestCaseFlowName { get; set; } = null!;
/// <summary>
/// 执行顺序
/// </summary>
public int ExecutionOrder { get; set; }
/// <summary>
/// 循环次数
/// </summary>
public int LoopCount { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 创建人
/// </summary>
public string CreatedBy { get; set; } = null!;
}

94
src/X1.Domain/Common/ScenarioTypeConverter.cs

@ -0,0 +1,94 @@
using X1.Domain.Entities.TestCase;
using System.ComponentModel;
using System.Reflection;
using System.ComponentModel.DataAnnotations;
namespace X1.Domain.Common;
/// <summary>
/// 测试场景类型转换器
/// </summary>
public static class ScenarioTypeConverter
{
/// <summary>
/// 获取所有测试场景类型
/// </summary>
/// <returns>测试场景类型列表</returns>
public static List<EnumValueObject> GetScenarioTypes()
{
var scenarioTypes = new List<EnumValueObject>();
foreach (ScenarioType scenarioType in Enum.GetValues(typeof(ScenarioType)))
{
var fieldInfo = typeof(ScenarioType).GetField(scenarioType.ToString());
if (fieldInfo != null)
{
var displayAttribute = fieldInfo.GetCustomAttribute<DisplayAttribute>();
var descriptionAttribute = fieldInfo.GetCustomAttribute<DescriptionAttribute>();
var name = displayAttribute?.Name ?? scenarioType.ToString();
var description = descriptionAttribute?.Description ?? scenarioType.ToString();
scenarioTypes.Add(new EnumValueObject
{
Value = (int)scenarioType,
Name = scenarioType.ToString(), // 使用枚举的字符串值
Description = description
});
}
}
return scenarioTypes;
}
/// <summary>
/// 获取所有测试场景类型及其描述
/// </summary>
/// <returns>测试场景类型描述字典</returns>
public static Dictionary<ScenarioType, string> GetScenarioTypeDescriptions()
{
return new Dictionary<ScenarioType, string>
{
{ ScenarioType.Functional, ScenarioType.Functional.GetDisplayName() },
{ ScenarioType.Performance, ScenarioType.Performance.GetDisplayName() },
{ ScenarioType.Stress, ScenarioType.Stress.GetDisplayName() },
{ ScenarioType.Compatibility, ScenarioType.Compatibility.GetDisplayName() },
//{ ScenarioType.Regression, ScenarioType.Regression.GetDisplayName() },
//{ ScenarioType.Integration, ScenarioType.Integration.GetDisplayName() },
//{ ScenarioType.Security, ScenarioType.Security.GetDisplayName() },
//{ ScenarioType.UserExperience, ScenarioType.UserExperience.GetDisplayName() }
};
}
/// <summary>
/// 根据测试场景类型获取显示名称
/// </summary>
/// <param name="scenarioType">测试场景类型</param>
/// <returns>显示名称</returns>
public static string GetDisplayName(this ScenarioType scenarioType)
{
var fieldInfo = typeof(ScenarioType).GetField(scenarioType.ToString());
if (fieldInfo != null)
{
var displayAttribute = fieldInfo.GetCustomAttribute<DisplayAttribute>();
return displayAttribute?.Name ?? scenarioType.ToString();
}
return scenarioType.ToString();
}
/// <summary>
/// 根据测试场景类型获取描述
/// </summary>
/// <param name="scenarioType">测试场景类型</param>
/// <returns>描述</returns>
public static string GetDescription(this ScenarioType scenarioType)
{
var fieldInfo = typeof(ScenarioType).GetField(scenarioType.ToString());
if (fieldInfo != null)
{
var descriptionAttribute = fieldInfo.GetCustomAttribute<DescriptionAttribute>();
return descriptionAttribute?.Description ?? scenarioType.ToString();
}
return scenarioType.ToString();
}
}

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

@ -36,31 +36,31 @@ public enum ScenarioType
[Description("验证系统与不同设备和环境的兼容性的测试场景")]
Compatibility = 4,
/// <summary>
/// 回归测试
/// </summary>
[Display(Name = "回归测试")]
[Description("验证系统修改后原有功能是否正常的测试场景")]
Regression = 5,
// /// <summary>
// /// 回归测试
// /// </summary>
// [Display(Name = "回归测试")]
// [Description("验证系统修改后原有功能是否正常的测试场景")]
// Regression = 5,
/// <summary>
/// 集成测试
/// </summary>
[Display(Name = "集成测试")]
[Description("验证系统各模块间集成是否正常的测试场景")]
Integration = 6,
// /// <summary>
// /// 集成测试
// /// </summary>
// [Display(Name = "集成测试")]
// [Description("验证系统各模块间集成是否正常的测试场景")]
// Integration = 6,
/// <summary>
/// 安全测试
/// </summary>
[Display(Name = "安全测试")]
[Description("验证系统安全性和防护能力的测试场景")]
Security = 7,
// /// <summary>
// /// 安全测试
// /// </summary>
// [Display(Name = "安全测试")]
// [Description("验证系统安全性和防护能力的测试场景")]
// Security = 7,
/// <summary>
/// 用户体验测试
/// </summary>
[Display(Name = "用户体验测试")]
[Description("验证系统用户体验和界面友好性的测试场景")]
UserExperience = 8
// /// <summary>
// /// 用户体验测试
// /// </summary>
// [Display(Name = "用户体验测试")]
// [Description("验证系统用户体验和界面友好性的测试场景")]
// UserExperience = 8
}

2122
src/X1.Infrastructure/Migrations/20250824112436_AddTestScenarioTables.Designer.cs

File diff suppressed because it is too large

114
src/X1.Infrastructure/Migrations/20250824112436_AddTestScenarioTables.cs

@ -0,0 +1,114 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace X1.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTestScenarioTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tb_testscenarios",
columns: table => new
{
Id = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ScenarioCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ScenarioName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
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(50)", maxLength: 50, nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tb_testscenarios", x => x.Id);
});
migrationBuilder.CreateTable(
name: "tb_scenariotestcases",
columns: table => new
{
Id = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ScenarioId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TestCaseFlowId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ExecutionOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
LoopCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
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(50)", maxLength: 50, nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tb_scenariotestcases", x => x.Id);
table.ForeignKey(
name: "FK_tb_scenariotestcases_tb_testcaseflow_TestCaseFlowId",
column: x => x.TestCaseFlowId,
principalTable: "tb_testcaseflow",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_tb_scenariotestcases_tb_testscenarios_ScenarioId",
column: x => x.ScenarioId,
principalTable: "tb_testscenarios",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_tb_scenariotestcases_ScenarioId",
table: "tb_scenariotestcases",
column: "ScenarioId");
migrationBuilder.CreateIndex(
name: "IX_tb_scenariotestcases_ScenarioId_ExecutionOrder",
table: "tb_scenariotestcases",
columns: new[] { "ScenarioId", "ExecutionOrder" });
migrationBuilder.CreateIndex(
name: "IX_tb_scenariotestcases_ScenarioId_TestCaseFlowId",
table: "tb_scenariotestcases",
columns: new[] { "ScenarioId", "TestCaseFlowId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_tb_scenariotestcases_TestCaseFlowId",
table: "tb_scenariotestcases",
column: "TestCaseFlowId");
migrationBuilder.CreateIndex(
name: "IX_tb_testscenarios_IsEnabled",
table: "tb_testscenarios",
column: "IsEnabled");
migrationBuilder.CreateIndex(
name: "IX_tb_testscenarios_ScenarioCode",
table: "tb_testscenarios",
column: "ScenarioCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_tb_testscenarios_Type",
table: "tb_testscenarios",
column: "Type");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tb_scenariotestcases");
migrationBuilder.DropTable(
name: "tb_testscenarios");
}
}
}

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

@ -1552,6 +1552,67 @@ namespace X1.Infrastructure.Migrations
});
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.ScenarioTestCase", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("ExecutionOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("LoopCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("ScenarioId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TestCaseFlowId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("ScenarioId");
b.HasIndex("TestCaseFlowId");
b.HasIndex("ScenarioId", "ExecutionOrder");
b.HasIndex("ScenarioId", "TestCaseFlowId")
.IsUnique();
b.ToTable("tb_scenariotestcases", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseEdge", b =>
{
b.Property<string>("Id")
@ -1775,6 +1836,63 @@ namespace X1.Infrastructure.Migrations
b.ToTable("tb_testcasenode", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestScenario", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("ScenarioCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ScenarioName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("IsEnabled");
b.HasIndex("ScenarioCode")
.IsUnique();
b.HasIndex("Type");
b.ToTable("tb_testscenarios", (string)null);
});
modelBuilder.Entity("X1.Domain.Entities.UserRole", b =>
{
b.Property<string>("UserId")
@ -1897,6 +2015,25 @@ namespace X1.Infrastructure.Migrations
b.Navigation("TestCaseNode");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.ScenarioTestCase", b =>
{
b.HasOne("X1.Domain.Entities.TestCase.TestScenario", "Scenario")
.WithMany("ScenarioTestCases")
.HasForeignKey("ScenarioId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("X1.Domain.Entities.TestCase.TestCaseFlow", "TestCaseFlow")
.WithMany("TestScenarioTestCases")
.HasForeignKey("TestCaseFlowId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Scenario");
b.Navigation("TestCaseFlow");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestCaseEdge", b =>
{
b.HasOne("X1.Domain.Entities.TestCase.TestCaseFlow", "TestCase")
@ -1968,6 +2105,13 @@ namespace X1.Infrastructure.Migrations
b.Navigation("Edges");
b.Navigation("Nodes");
b.Navigation("TestScenarioTestCases");
});
modelBuilder.Entity("X1.Domain.Entities.TestCase.TestScenario", b =>
{
b.Navigation("ScenarioTestCases");
});
#pragma warning restore 612, 618
}

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

@ -147,12 +147,12 @@ public class TestCaseFlowController : ApiController
/// 获取测试流程类型列表
/// </summary>
/// <returns>测试流程类型数据</returns>
[HttpGet("form-type-step")]
public async Task<OperationResult<GetFormTypeStepTypeMappingResponse>> GetFormTypeStepTypeMapping()
[HttpGet("test-flow-types")]
public async Task<OperationResult<GetTestFlowTypesResponse>> GetTestFlowTypes()
{
_logger.LogInformation("获取测试流程类型列表");
var query = new GetFormTypeStepTypeMappingQuery();
var query = new GetTestFlowTypesQuery();
var result = await mediator.Send(query);
if (!result.IsSuccess)
{

51
src/X1.Presentation/Controllers/TestScenariosController.cs

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using X1.Application.Features.TestScenarios.Queries.GetTestScenarios;
using X1.Application.Features.TestScenarios.Queries.GetTestScenarioById;
using X1.Application.Features.TestScenarios.Queries.GetScenarioTypes;
using X1.Application.Features.TestScenarios.Commands.CreateTestScenario;
using X1.Application.Features.TestScenarios.Commands.UpdateTestScenario;
using X1.Application.Features.TestScenarios.Commands.DeleteTestScenario;
@ -33,6 +34,27 @@ public class TestScenariosController : ApiController
_logger = logger;
}
/// <summary>
/// 获取测试场景类型列表
/// </summary>
/// <returns>测试场景类型列表</returns>
[HttpGet("types")]
public async Task<OperationResult<GetScenarioTypesResponse>> GetScenarioTypes()
{
_logger.LogInformation("获取测试场景类型列表");
var query = new GetScenarioTypesQuery();
var result = await mediator.Send(query);
if (!result.IsSuccess)
{
_logger.LogWarning("获取测试场景类型列表失败: {ErrorMessages}", string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功获取测试场景类型列表,总数: {Count}", result.Data?.ScenarioTypes?.Count ?? 0);
return result;
}
/// <summary>
/// 获取测试场景列表
/// </summary>
@ -100,7 +122,7 @@ public class TestScenariosController : ApiController
}
_logger.LogInformation("成功获取测试场景详情,ID: {Id}, 名称: {Name}",
id, result.Data?.TestScenario?.ScenarioName);
id, result.Data?.ScenarioName);
return result;
}
@ -124,7 +146,7 @@ public class TestScenariosController : ApiController
}
_logger.LogInformation("成功创建测试场景,ID: {Id}, 名称: {ScenarioName}",
result.Data?.Id, result.Data?.ScenarioName);
result.Data?.TestScenarioId, result.Data?.ScenarioName);
return result;
}
@ -209,39 +231,38 @@ public class TestScenariosController : ApiController
scenarioId, string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功获取场景测试用例列表,场景ID: {ScenarioId}, 测试用例数量: {Count}",
scenarioId, result.Data?.ScenarioTestCases?.Count ?? 0);
return result;
}
/// <summary>
/// 创建场景测试用例
/// </summary>
/// <param name="scenarioId">场景ID</param>
/// <param name="command">创建场景测试用例命令</param>
/// <param name="request">创建场景测试用例请求</param>
/// <returns>创建结果</returns>
[HttpPost("{scenarioId}/testcases")]
[HttpPost("testcases")]
public async Task<OperationResult<CreateScenarioTestCaseResponse>> CreateScenarioTestCase(
string scenarioId,
[FromBody] CreateScenarioTestCaseCommand command)
[FromBody] CreateScenarioTestCaseRequest request)
{
_logger.LogInformation("开始创建场景测试用例,场景ID: {ScenarioId}, 测试用例数量: {TestCaseCount}",
scenarioId, command.TestCases?.Count ?? 0);
request.ScenarioId, request.TestCases?.Count ?? 0);
// 确保命令中的场景ID与路由参数一致
command.ScenarioId = scenarioId;
// 创建命令
var command = new CreateScenarioTestCaseCommand
{
ScenarioId = request.ScenarioId,
TestCases = request.TestCases
};
var result = await mediator.Send(command);
if (!result.IsSuccess)
{
_logger.LogWarning("创建场景测试用例失败,场景ID: {ScenarioId}, 错误: {ErrorMessages}",
scenarioId, string.Join(", ", result.ErrorMessages ?? new List<string>()));
request.ScenarioId, string.Join(", ", result.ErrorMessages ?? new List<string>()));
return result;
}
_logger.LogInformation("成功创建场景测试用例,场景ID: {ScenarioId}, 成功数量: {SuccessCount}, 失败数量: {FailureCount}",
scenarioId, result.Data?.SuccessCount ?? 0, result.Data?.FailureCount ?? 0);
request.ScenarioId, result.Data?.SuccessCount ?? 0, result.Data?.FailureCount ?? 0);
return result;
}
}

6
src/X1.WebUI/src/components/layout/Content.tsx

@ -6,10 +6,8 @@ interface ContentProps {
export function Content({ children }: ContentProps) {
return (
<main className="flex-1 min-h-0 overflow-auto p-4 transition-all duration-300 ease-in-out">
<div className="w-full h-full">
{children}
</div>
<main className="flex-1 min-h-0 overflow-hidden transition-all duration-300 ease-in-out p-4">
{children}
</main>
);
}

16
src/X1.WebUI/src/components/testcases/SaveTestCaseForm.tsx

@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { TestFlowType } from '@/services/testcaseService';
import { TestFlowType, TestFlowTypeDto } from '@/services/testcaseService';
interface SaveTestCaseFormProps {
open: boolean;
@ -13,6 +13,7 @@ interface SaveTestCaseFormProps {
onSave: (data: { name: string; description: string; type: TestFlowType }) => void;
loading?: boolean;
nodeCount?: number;
testFlowTypes?: TestFlowTypeDto[];
}
export default function SaveTestCaseForm({
@ -20,7 +21,8 @@ export default function SaveTestCaseForm({
onOpenChange,
onSave,
loading = false,
nodeCount = 0
nodeCount = 0,
testFlowTypes = []
}: SaveTestCaseFormProps) {
const [formData, setFormData] = useState({
name: '',
@ -85,11 +87,11 @@ export default function SaveTestCaseForm({
<SelectValue placeholder="选择测试类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="4">UI测试</SelectItem>
<SelectItem value="5">API测试</SelectItem>
{testFlowTypes.map((flowType) => (
<SelectItem key={flowType.value} value={flowType.value.toString()}>
{flowType.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

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

@ -406,7 +406,7 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'smoothstep',
type: edge.type,
style: { stroke: '#3b82f6', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed' as const,

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

@ -31,8 +31,8 @@ export const API_PATHS = {
// 用户相关
USERS: '/users',
ROLES: '/roles',
PERMISSIONS: '/api/permissions',
ROLE_PERMISSIONS: '/api/role-permissions',
PERMISSIONS: '/permissions',
ROLE_PERMISSIONS: '/role-permissions',
// 认证相关
AUTH: {
@ -49,6 +49,7 @@ export const API_PATHS = {
// 场景相关
SCENARIOS: '/scenarios',
TEST_SCENARIOS: '/testscenarios',
// 测试用例流程相关
TEST_CASE_FLOW: '/testcaseflow',

12
src/X1.WebUI/src/constants/menuConfig.ts

@ -14,7 +14,6 @@ export type Permission =
// 场景管理权限
| 'scenarios.view'
| 'scenarios.manage'
| 'scenarios.create'
// 用例管理权限
| 'testcases.view'
| 'testcases.manage'
@ -105,9 +104,14 @@ export const menuItems: MenuItem[] = [
permission: 'scenarios.view',
},
{
title: '创建场景',
href: '/dashboard/scenarios/create',
permission: 'scenarios.create',
title: '场景配置',
href: '/dashboard/scenarios/config',
permission: 'scenarios.manage',
},
{
title: '场景绑定',
href: '/dashboard/scenarios/binding',
permission: 'scenarios.manage',
},
],
},

2
src/X1.WebUI/src/contexts/AuthContext.tsx

@ -38,9 +38,9 @@ const getDefaultPermissions = (userPermissions: Record<string, boolean> = {}) =>
"roles.view",
"permissions.view",
'settings.view',
// 场景管理权限
'scenarios.view',
'scenarios.manage',
'scenarios.create',
'testcases.view',
'testcases.manage',
'testcases.create',

155
src/X1.WebUI/src/pages/scenarios/ScenarioForm.tsx

@ -1,155 +0,0 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CreateScenarioRequest, UpdateScenarioRequest } from '@/services/scenarioService';
interface ScenarioFormProps {
onSubmit: (data: CreateScenarioRequest | UpdateScenarioRequest) => void;
initialData?: Partial<CreateScenarioRequest>;
}
export default function ScenarioForm({ onSubmit, initialData }: ScenarioFormProps) {
const [formData, setFormData] = useState<CreateScenarioRequest>({
name: initialData?.name || '',
description: initialData?.description || '',
status: initialData?.status || 'draft',
priority: initialData?.priority || 'medium',
tags: initialData?.tags || [],
requirements: initialData?.requirements || ''
});
const [newTag, setNewTag] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
const addTag = () => {
if (newTag.trim() && !formData.tags.includes(newTag.trim())) {
setFormData(prev => ({
...prev,
tags: [...prev.tags, newTag.trim()]
}));
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags.filter(tag => tag !== tagToRemove)
}));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
required
placeholder="请输入场景名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<textarea
id="description"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
className="w-full min-h-[80px] p-3 border border-input rounded-md resize-vertical"
placeholder="请输入场景描述"
/>
</div>
<div className="space-y-2">
<Label htmlFor="requirements"></Label>
<textarea
id="requirements"
value={formData.requirements}
onChange={e => setFormData({ ...formData, requirements: e.target.value })}
className="w-full min-h-[100px] p-3 border border-input rounded-md resize-vertical"
placeholder="请输入需求说明"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<select
id="status"
value={formData.status}
onChange={e => setFormData({ ...formData, status: e.target.value as any })}
className="w-full p-3 border border-input rounded-md"
>
<option value="draft">稿</option>
<option value="active"></option>
<option value="inactive"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
value={formData.priority}
onChange={e => setFormData({ ...formData, priority: e.target.value as any })}
className="w-full p-3 border border-input rounded-md"
>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2 mb-2">
<Input
value={newTag}
onChange={e => setNewTag(e.target.value)}
placeholder="添加标签"
onKeyPress={handleKeyPress}
/>
<Button type="button" onClick={addTag} variant="outline">
</Button>
</div>
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-red-600"
>
×
</button>
</span>
))}
</div>
</div>
<Button type="submit" className="w-full">
{initialData ? '更新场景' : '创建场景'}
</Button>
</form>
);
}

253
src/X1.WebUI/src/pages/scenarios/ScenariosView.tsx

@ -1,253 +0,0 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { scenarioService } from '@/services/scenarioService';
import ScenarioTable from './ScenarioTable';
import ScenarioForm from './ScenarioForm';
import { Scenario } from '@/services/scenarioService';
import { Input } from '@/components/ui/input';
import PaginationBar from '@/components/ui/PaginationBar';
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar';
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
const defaultColumns = [
{ key: 'name', title: '场景名称', visible: true },
{ key: 'description', title: '描述', visible: true },
{ key: 'status', title: '状态', visible: true },
{ key: 'priority', title: '优先级', visible: true },
{ key: 'tags', title: '标签', visible: true },
{ key: 'createdBy', title: '创建人', visible: true },
{ key: 'createdAt', title: '创建时间', visible: true },
{ key: 'actions', title: '操作', visible: true }
];
// 字段类型声明
type SearchField =
| { key: string; label: string; type: 'input'; placeholder: string }
| { key: string; label: string; type: 'select'; options: { value: string; label: string }[] };
// 第一行字段(收起时只显示这3个)
const firstRowFields: SearchField[] = [
{ key: 'name', label: '场景名称', type: 'input', placeholder: '请输入' },
{ key: 'status', label: '状态', type: 'select', options: [
{ value: '', label: '请选择' },
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '停用' },
{ value: 'draft', label: '草稿' },
] },
{ key: 'priority', label: '优先级', type: 'select', options: [
{ value: '', label: '请选择' },
{ value: 'low', label: '低' },
{ value: 'medium', label: '中' },
{ value: 'high', label: '高' },
] },
];
// 高级字段(展开时才显示)
const advancedFields: SearchField[] = [
{ key: 'description', label: '描述', type: 'input', placeholder: '请输入' },
{ key: 'createdBy', label: '创建人', type: 'input', placeholder: '请输入' },
];
export default function ScenariosView() {
const [scenarios, setScenarios] = useState<Scenario[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [selectedScenario, setSelectedScenario] = useState<Scenario | null>(null);
const [total, setTotal] = useState(0);
const [name, setName] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [density, setDensity] = useState<DensityType>('default');
const [columns, setColumns] = useState(defaultColumns);
const [showAdvanced, setShowAdvanced] = useState(false);
const fetchScenarios = async (params = {}) => {
setLoading(true);
const result = await scenarioService.getAllScenarios({ name, page, pageSize, ...params });
if (result.isSuccess && result.data) {
setScenarios(result.data.scenarios || []);
setTotal(result.data.totalCount || 0);
}
setLoading(false);
};
useEffect(() => {
fetchScenarios();
// eslint-disable-next-line
}, [page, pageSize]);
const handleCreate = async (data: { name: string; description: string; status: 'active' | 'inactive' | 'draft'; priority: 'low' | 'medium' | 'high'; tags: string[]; requirements: string }) => {
const result = await scenarioService.createScenario(data);
if (result.isSuccess) {
setOpen(false);
fetchScenarios();
}
};
const handleEdit = async (data: { name: string; description: string; status: 'active' | 'inactive' | 'draft'; priority: 'low' | 'medium' | 'high'; tags: string[]; requirements: string }) => {
if (!selectedScenario) return;
const result = await scenarioService.updateScenario(selectedScenario.id, data);
if (result.isSuccess) {
setEditOpen(false);
setSelectedScenario(null);
fetchScenarios();
}
};
const handleDelete = async (scenarioId: string) => {
const result = await scenarioService.deleteScenario(scenarioId);
if (result.isSuccess) {
fetchScenarios();
}
};
// 查询按钮
const handleQuery = () => {
setPage(1);
fetchScenarios({ page: 1 });
};
// 重置按钮
const handleReset = () => {
setName('');
setPage(1);
fetchScenarios({ name: '', page: 1 });
};
// 每页条数选择
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
};
return (
<main className="flex-1 p-4 transition-all duration-300 ease-in-out sm:p-6">
<div className="w-full space-y-4">
{/* 丰富美化后的搜索栏 */}
<div className="flex flex-col bg-background p-4 rounded-md border border-border mb-2">
<form
className={`grid gap-x-8 gap-y-4 items-center ${showAdvanced ? 'md:grid-cols-3' : 'md:grid-cols-4'} grid-cols-1`}
onSubmit={e => {
e.preventDefault();
handleQuery();
}}
>
{(showAdvanced ? [...firstRowFields, ...advancedFields] : firstRowFields).map(field => (
<div className="flex flex-row items-center min-w-[200px] flex-1" key={field.key}>
<label
className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right"
style={{ width: 80, minWidth: 80 }}
>
{field.label}
</label>
{field.type === 'input' && (
<Input
className="flex-1"
placeholder={field.placeholder}
value={field.key === 'name' ? name : ''}
onChange={e => {
if (field.key === 'name') setName(e.target.value);
}}
/>
)}
{field.type === 'select' && (
<select className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 flex-1">
{field.options.map(opt => (
<option value={opt.value} key={opt.value}>{opt.label}</option>
))}
</select>
)}
</div>
))}
{/* 按钮组直接作为表单项之一,紧跟在最后一个表单项后面 */}
<div className="flex flex-row items-center min-w-[200px] flex-1 justify-end gap-2">
<Button type="button" variant="outline" onClick={handleReset}></Button>
<Button type="submit"></Button>
<Button type="button" variant="ghost" onClick={() => setShowAdvanced(v => !v)}>
{showAdvanced ? (
<>
<ChevronUpIcon className="inline-block ml-1 w-4 h-4 align-middle" />
</>
) : (
<>
<ChevronDownIcon className="inline-block ml-1 w-4 h-4 align-middle" />
</>
)}
</Button>
</div>
</form>
</div>
{/* 表格整体卡片区域,包括添加按钮、表格、分页 */}
<div className="rounded-md border bg-background p-4">
{/* 顶部操作栏:添加场景+工具栏 */}
<div className="flex items-center justify-between mb-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">+ </Button>
</DialogTrigger>
<DialogContent className="bg-background">
<ScenarioForm onSubmit={handleCreate} />
</DialogContent>
</Dialog>
<TableToolbar
onRefresh={() => fetchScenarios()}
onDensityChange={setDensity}
onColumnsChange={setColumns}
onColumnsReset={() => setColumns(defaultColumns)}
columns={columns}
density={density}
/>
</div>
{/* 表格区域 */}
<ScenarioTable
scenarios={scenarios}
loading={loading}
onDelete={handleDelete}
onEdit={(scenario) => {
setSelectedScenario(scenario);
setEditOpen(true);
}}
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
hideCard={true}
density={density}
columns={columns}
/>
{/* 分页 */}
<PaginationBar
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
{/* 编辑场景对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="bg-background">
{selectedScenario && (
<ScenarioForm
onSubmit={handleEdit}
initialData={{
name: selectedScenario.name,
description: selectedScenario.description,
status: selectedScenario.status,
priority: selectedScenario.priority,
tags: selectedScenario.tags,
requirements: selectedScenario.requirements
}}
/>
)}
</DialogContent>
</Dialog>
</main>
);
}

263
src/X1.WebUI/src/pages/scenarios/scenario-binding/ScenarioBindingView.tsx

@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { RefreshCw } from 'lucide-react';
import { testcaseService, TestCaseFlow } from '@/services/testcaseService';
import { scenarioService, TestScenario } from '@/services/scenarioService';
import ScenarioCategoryTree from './ScenarioCategoryTree';
import TestCaseList from './TestCaseList';
interface Scenario {
id: string;
name: string;
category: string;
}
interface CategoryData {
[category: string]: Scenario[];
}
export default function ScenarioBindingView() {
const [selectedScenario, setSelectedScenario] = useState<Scenario | null>(null);
const [selectedTestCases, setSelectedTestCases] = useState<string[]>([]);
const [testCases, setTestCases] = useState<TestCaseFlow[]>([]);
const [scenarios, setScenarios] = useState<TestScenario[]>([]);
const [scenarioCategories, setScenarioCategories] = useState<CategoryData>({});
const [loading, setLoading] = useState(false);
const [loadingScenarios, setLoadingScenarios] = useState(false);
const { toast } = useToast();
// 加载场景配置数据
const loadScenarios = async () => {
try {
setLoadingScenarios(true);
const result = await scenarioService.getTestScenarios({
pageNumber: 1,
pageSize: 1000 // 获取所有场景用于绑定
});
if (result.isSuccess && result.data) {
const scenariosData = result.data.testScenarios || [];
setScenarios(scenariosData);
// 按场景类型分组
const categories: CategoryData = {};
scenariosData.forEach(scenario => {
const category = scenario.type || '未分类';
if (!categories[category]) {
categories[category] = [];
}
categories[category].push({
id: scenario.testScenarioId,
name: scenario.scenarioName,
category: category
});
});
setScenarioCategories(categories);
} else {
console.error('加载场景配置失败:', result.errorMessages);
toast({
title: '加载失败',
description: '无法加载场景配置数据',
variant: 'destructive',
});
}
} catch (error) {
console.error('加载场景配置出错:', error);
toast({
title: '加载失败',
description: '加载场景配置时发生错误',
variant: 'destructive',
});
} finally {
setLoadingScenarios(false);
}
};
// 加载测试用例数据
const loadTestCases = async () => {
try {
setLoading(true);
const result = await testcaseService.getTestCaseFlows({
pageNumber: 1,
pageSize: 100 // 获取更多数据用于绑定
});
if (result.isSuccess && result.data) {
setTestCases(result.data.testCaseFlows || []);
} else {
console.error('加载测试用例失败:', result.errorMessages);
toast({
title: '加载失败',
description: '无法加载测试用例数据',
variant: 'destructive',
});
}
} catch (error) {
console.error('加载测试用例出错:', error);
toast({
title: '加载失败',
description: '加载测试用例时发生错误',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadScenarios();
loadTestCases();
}, []);
const handleScenarioSelect = (scenario: Scenario) => {
setSelectedScenario(scenario);
toast({
title: '场景已选择',
description: `已选中场景:${scenario.name}`,
});
};
const handleTestCaseSelect = (testCaseIds: string[]) => {
setSelectedTestCases(testCaseIds);
};
const handleBindTestCases = async () => {
if (!selectedScenario) {
toast({
title: '提示',
description: '请先在左侧选择一个具体的场景!',
variant: 'destructive',
});
return;
}
if (selectedTestCases.length === 0) {
toast({
title: '提示',
description: '请至少选择一个测试用例!',
variant: 'destructive',
});
return;
}
try {
setLoading(true);
// 构建绑定请求数据
const bindData = {
testCases: selectedTestCases.map((testCaseId, index) => ({
testCaseFlowId: testCaseId,
executionOrder: index + 1, // 按选择顺序设置执行顺序
loopCount: 1, // 默认循环次数为1
isEnabled: true // 默认启用
}))
};
// 调用API进行绑定
const result = await scenarioService.createScenarioTestCase(selectedScenario.id, bindData);
if (result.isSuccess && result.data) {
const { successCount, failureCount, errorMessages } = result.data;
if (successCount > 0) {
toast({
title: '绑定成功',
description: `成功绑定 ${successCount} 个测试用例到场景 ${selectedScenario.name}${failureCount > 0 ? `${failureCount} 个绑定失败` : ''}`,
});
// 清空选择
setSelectedTestCases([]);
// 重新加载场景数据以显示最新状态
await loadScenarios();
} else {
toast({
title: '绑定失败',
description: '没有成功绑定任何测试用例',
variant: 'destructive',
});
}
// 如果有错误信息,显示详细错误
if (errorMessages && errorMessages.length > 0) {
console.error('绑定错误详情:', errorMessages);
toast({
title: '绑定错误',
description: errorMessages.join('; '),
variant: 'destructive',
});
}
} else {
toast({
title: '绑定失败',
description: result.errorMessages?.join('; ') || '绑定操作失败,请重试',
variant: 'destructive',
});
}
} catch (error) {
console.error('绑定操作出错:', error);
toast({
title: '绑定失败',
description: '绑定操作失败,请重试',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
return (
<div className="flex gap-4 h-full overflow-hidden">
{/* 左侧场景类别面板 */}
<div className="flex-shrink-0 w-72 flex flex-col border rounded-lg bg-card">
<div className="flex items-center justify-between p-3 border-b">
<h3 className="text-sm font-medium"></h3>
<Button
variant="ghost"
size="sm"
onClick={loadScenarios}
disabled={loadingScenarios}
className="h-6 w-6 p-0"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ScenarioCategoryTree
scenarioData={scenarioCategories}
selectedScenario={selectedScenario}
onScenarioSelect={handleScenarioSelect}
/>
</div>
</div>
{/* 右侧测试用例面板 */}
<div className="flex-1 flex flex-col border rounded-lg bg-card">
<div className="flex items-center justify-between p-3 border-b">
<h3 className="text-sm font-medium"></h3>
<Button
variant="ghost"
size="sm"
onClick={loadTestCases}
disabled={loading}
className="h-6 w-6 p-0"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<TestCaseList
testCases={testCases}
loading={loading}
selectedTestCases={selectedTestCases}
onTestCaseSelect={handleTestCaseSelect}
onBind={handleBindTestCases}
selectedScenario={selectedScenario}
/>
</div>
</div>
</div>
);
}

81
src/X1.WebUI/src/pages/scenarios/scenario-binding/ScenarioCategoryTree.tsx

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
interface Scenario {
id: string;
name: string;
category: string;
}
interface CategoryData {
[category: string]: Scenario[];
}
interface ScenarioCategoryTreeProps {
scenarioData: CategoryData;
selectedScenario: Scenario | null;
onScenarioSelect: (scenario: Scenario) => void;
}
export default function ScenarioCategoryTree({
scenarioData,
selectedScenario,
onScenarioSelect
}: ScenarioCategoryTreeProps) {
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
const handleCategoryToggle = (category: string) => {
setExpandedCategories(prev =>
prev.includes(category)
? prev.filter(c => c !== category)
: [...prev, category]
);
};
if (Object.keys(scenarioData).length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
</div>
);
}
return (
<div className="h-full overflow-y-auto p-2" style={{ minHeight: 0 }}>
<div className="space-y-1">
{Object.entries(scenarioData).map(([category, scenarios]) => (
<div key={category} className="border rounded-md">
<button
className="w-full flex items-center justify-between p-2 text-sm font-medium text-foreground hover:bg-accent/50 transition-colors"
onClick={() => handleCategoryToggle(category)}
>
<span className="truncate">{category}</span>
{expandedCategories.includes(category) ? (
<ChevronDown className="h-3 w-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{expandedCategories.includes(category) && (
<div className="border-t bg-muted/30">
{scenarios.map((scenario) => (
<button
key={scenario.id}
className={`w-full text-left p-2 text-xs hover:bg-accent/50 transition-colors ${
selectedScenario?.id === scenario.id
? 'bg-primary/10 text-primary border-l-2 border-primary'
: 'text-muted-foreground'
}`}
onClick={() => onScenarioSelect(scenario)}
>
<div className="truncate">{scenario.name}</div>
</button>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}

191
src/X1.WebUI/src/pages/scenarios/scenario-binding/TestCaseList.tsx

@ -0,0 +1,191 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Save } from 'lucide-react';
import { TestCaseFlow } from '@/services/testcaseService';
interface Scenario {
id: string;
name: string;
category: string;
}
interface TestCaseListProps {
testCases: TestCaseFlow[];
loading: boolean;
selectedTestCases: string[];
onTestCaseSelect: (testCaseIds: string[]) => void;
onBind: () => void;
selectedScenario: Scenario | null;
}
export default function TestCaseList({
testCases,
loading,
selectedTestCases,
onTestCaseSelect,
onBind,
selectedScenario
}: TestCaseListProps) {
const [searchTerm, setSearchTerm] = useState('');
const handleTestCaseSelect = (testCaseId: string, checked: boolean) => {
if (checked) {
onTestCaseSelect([...selectedTestCases, testCaseId]);
} else {
onTestCaseSelect(selectedTestCases.filter(id => id !== testCaseId));
}
};
const handleSelectAllTestCases = (checked: boolean) => {
if (checked) {
onTestCaseSelect(filteredTestCases.map(tc => tc.id));
} else {
onTestCaseSelect([]);
}
};
const filteredTestCases = testCases.filter(testCase =>
testCase.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(testCase.description && testCase.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
const getTypeBadge = (type: string) => {
const typeConfig: Record<string, { className: string; text: string }> = {
'Functional': { className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', text: '功能测试' },
'Performance': { className: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', text: '性能测试' },
'Security': { className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', text: '安全测试' },
'UI': { className: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', text: 'UI测试' },
'API': { className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', text: 'API测试' }
};
const config = typeConfig[type] || { className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', text: type };
return (
<Badge className={`text-xs ${config.className}`}>
{config.text}
</Badge>
);
};
const getStatusBadge = (isEnabled: boolean) => {
const statusConfig = {
true: { className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', text: '启用' },
false: { className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', text: '停用' }
};
const config = statusConfig[isEnabled.toString() as keyof typeof statusConfig];
return (
<Badge className={`text-xs ${config.className}`}>
{config.text}
</Badge>
);
};
return (
<div className="flex flex-col h-full p-3" style={{ minHeight: 0 }}>
{/* 搜索和绑定控制栏 */}
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
<div className="flex-1 max-w-sm">
<Input
placeholder="搜索测试用例..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-8 text-sm"
/>
</div>
<Button
onClick={onBind}
disabled={loading || !selectedScenario || selectedTestCases.length === 0}
size="sm"
className="h-8"
>
<Save className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 选中场景提示 */}
{selectedScenario && (
<div className="p-2 bg-primary/5 border border-primary/20 rounded-md mb-3 flex-shrink-0">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="secondary" className="text-xs">
{selectedScenario.name}
</Badge>
</div>
<Badge variant="outline" className="text-xs">
{selectedTestCases.length}
</Badge>
</div>
</div>
)}
{/* 测试用例列表 */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
...
</div>
) : filteredTestCases.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
</div>
) : (
<div className="h-full flex flex-col">
{/* 全选控制 */}
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded-md mb-2 flex-shrink-0">
<Checkbox
checked={selectedTestCases.length === filteredTestCases.length && filteredTestCases.length > 0}
onCheckedChange={handleSelectAllTestCases}
className="h-3 w-3"
/>
<span className="text-xs font-medium"></span>
<Badge variant="secondary" className="text-xs">
{filteredTestCases.length}
</Badge>
</div>
{/* 用例列表 */}
<div className="flex-1 overflow-y-auto" style={{ minHeight: 0 }}>
<div className="space-y-2">
{filteredTestCases.map((testCase) => (
<div
key={testCase.id}
className="flex items-start gap-3 p-3 border rounded-md hover:bg-accent/30 transition-colors"
>
<Checkbox
checked={selectedTestCases.includes(testCase.id)}
onCheckedChange={(checked) => handleTestCaseSelect(testCase.id, checked as boolean)}
className="mt-0.5 h-3 w-3"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-foreground truncate">
{testCase.name}
</h3>
{getTypeBadge(testCase.type)}
{getStatusBadge(testCase.isEnabled)}
</div>
{testCase.description && (
<p className="text-xs text-muted-foreground mb-1 line-clamp-2">
{testCase.description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {testCase.createdBy}</span>
<span>: {new Date(testCase.createdAt).toLocaleDateString()}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
}

157
src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigForm.tsx

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CreateTestScenarioRequest, UpdateTestScenarioRequest, ScenarioTypeOption } from '@/services/scenarioService';
interface ScenarioConfigFormProps {
onSubmit: (data: CreateTestScenarioRequest | UpdateTestScenarioRequest) => void;
initialData?: Partial<CreateTestScenarioRequest> & { id?: string };
isEdit?: boolean;
isSubmitting?: boolean;
scenarioTypes?: ScenarioTypeOption[];
isLoadingTypes?: boolean;
}
export default function ScenarioConfigForm({
onSubmit,
initialData,
isEdit = false,
isSubmitting = false,
scenarioTypes = [],
isLoadingTypes = false
}: ScenarioConfigFormProps) {
const [formData, setFormData] = useState<CreateTestScenarioRequest>({
scenarioName: initialData?.scenarioName || '',
type: initialData?.type || 1, // 默认选择功能测试 (Functional = 1)
description: initialData?.description || '',
isEnabled: initialData?.isEnabled ?? true // 默认启用
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (isEdit) {
// 编辑模式:只提交可修改的字段
const updateData: UpdateTestScenarioRequest = {
description: formData.description,
isEnabled: formData.isEnabled
};
onSubmit(updateData);
} else {
// 创建模式:提交所有字段
onSubmit(formData as CreateTestScenarioRequest);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto p-4">
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="scenarioName"></Label>
<Input
id="scenarioName"
value={formData.scenarioName}
onChange={e => setFormData({ ...formData, scenarioName: e.target.value })}
placeholder="请输入场景名称"
required
disabled={isSubmitting}
/>
</div>
)}
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="type"></Label>
<select
id="type"
className="h-10 w-full rounded border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-0 focus:border-border transition-all"
value={formData.type.toString()}
onChange={e => setFormData({ ...formData, type: parseInt(e.target.value) || 1 })}
required
disabled={isSubmitting || isLoadingTypes}
>
{isLoadingTypes ? (
<option value="">...</option>
) : (
<>
<option value=""></option>
{scenarioTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.name}
</option>
))}
</>
)}
</select>
{scenarioTypes.length > 0 && formData.type && (
<p className="text-sm text-muted-foreground">
{scenarioTypes.find(t => t.value === formData.type)?.description}
</p>
)}
</div>
)}
{isEdit && (
<>
<div className="space-y-2">
<Label htmlFor="scenarioName"></Label>
<Input
id="scenarioName"
value={formData.scenarioName}
disabled
className="bg-muted"
/>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="type"></Label>
<Input
id="type"
value={formData.type.toString()}
disabled
className="bg-muted"
/>
<p className="text-sm text-muted-foreground"></p>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="请输入场景描述"
rows={3}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="isEnabled"></Label>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isEnabled"
checked={formData.isEnabled}
onChange={e => setFormData({ ...formData, isEnabled: e.target.checked })}
disabled={isSubmitting}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<label htmlFor="isEnabled" className="text-sm text-muted-foreground">
</label>
</div>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : (isEdit ? '更新测试场景' : '创建测试场景')}
</Button>
</form>
);
}

156
src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigTable.tsx

@ -0,0 +1,156 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { TestScenario } from '@/services/scenarioService';
import { Edit, Trash2 } from 'lucide-react';
interface ScenarioConfigTableProps {
testScenarios: TestScenario[];
loading?: boolean;
onEdit: (scenario: TestScenario) => void;
onDelete: (scenario: TestScenario) => void;
density?: 'default' | 'compact' | 'relaxed';
}
export default function ScenarioConfigTable({
testScenarios,
loading,
onEdit,
onDelete,
density = 'default'
}: ScenarioConfigTableProps) {
const getTableCellClass = () => {
return density === 'compact' ? 'py-2' : 'py-4';
};
function ScenarioStatusBadge({ isEnabled }: { isEnabled: boolean }) {
return (
<Badge variant={isEnabled ? "default" : "secondary"}>
{isEnabled ? '启用' : '禁用'}
</Badge>
);
}
function ScenarioTypeBadge({ type }: { type: string }) {
const getTypeColor = (type: string) => {
switch (type) {
case 'Functional':
return 'bg-blue-100 text-blue-800';
case 'Performance':
return 'bg-green-100 text-green-800';
case 'Stress':
return 'bg-yellow-100 text-yellow-800';
case 'Compatibility':
return 'bg-indigo-100 text-indigo-800';
case 'Regression':
return 'bg-orange-100 text-orange-800';
case 'Integration':
return 'bg-purple-100 text-purple-800';
case 'Security':
return 'bg-red-100 text-red-800';
case 'UserExperience':
return 'bg-pink-100 text-pink-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(type)}`}>
{type}
</span>
);
}
const renderCell = (scenario: TestScenario, column: string) => {
switch (column) {
case 'scenarioCode':
return scenario.scenarioCode;
case 'scenarioName':
return scenario.scenarioName;
case 'type':
return <ScenarioTypeBadge type={scenario.type} />;
case 'description':
return scenario.description || '-';
case 'isEnabled':
return <ScenarioStatusBadge isEnabled={scenario.isEnabled} />;
case 'createdAt':
return scenario.createdAt ? new Date(scenario.createdAt).toLocaleString('zh-CN') : '-';
case 'createdBy':
return scenario.createdBy || '-';
case 'actions':
return (
<div className="flex space-x-2">
<button
onClick={() => onEdit(scenario)}
className="text-blue-600 hover:text-blue-700 p-1"
title="编辑"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(scenario)}
className="text-red-600 hover:text-red-700 p-1"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
default:
return '-';
}
};
const columns = [
{ key: 'scenarioCode', label: '场景编码' },
{ key: 'scenarioName', label: '场景名称' },
{ key: 'type', label: '场景类型' },
{ key: 'description', label: '描述' },
{ key: 'isEnabled', label: '启用状态' },
{ key: 'createdAt', label: '创建时间' },
{ key: 'createdBy', label: '创建人' },
{ key: 'actions', label: '操作' }
];
return (
<div className="w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column.key}>{column.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2">...</span>
</div>
</TableCell>
</TableRow>
) : testScenarios.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8">
</TableCell>
</TableRow>
) : (
testScenarios.map((scenario) => (
<TableRow key={scenario.testScenarioId}>
{columns.map((column) => (
<TableCell key={column.key} className={getTableCellClass()}>
{renderCell(scenario, column.key)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

383
src/X1.WebUI/src/pages/scenarios/scenario-config/ScenarioConfigView.tsx

@ -0,0 +1,383 @@
import React, { useState, useEffect } from 'react';
import { scenarioService, TestScenario, GetTestScenariosRequest, CreateTestScenarioRequest, UpdateTestScenarioRequest, ScenarioTypeOption } from '@/services/scenarioService';
import ScenarioConfigTable from './ScenarioConfigTable';
import ScenarioConfigForm from './ScenarioConfigForm';
import { Input } from '@/components/ui/input';
import PaginationBar from '@/components/ui/PaginationBar';
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
const defaultColumns = [
{ key: 'testScenarioId', title: '场景ID', visible: false },
{ key: 'scenarioCode', title: '场景编码', visible: true },
{ key: 'scenarioName', title: '场景名称', visible: true },
{ key: 'type', title: '场景类型', visible: true },
{ key: 'description', title: '描述', visible: true },
{ key: 'isEnabled', title: '启用状态', visible: true },
{ key: 'createdAt', title: '创建时间', visible: true },
{ key: 'createdBy', title: '创建人', visible: true },
{ key: 'actions', title: '操作', visible: true },
];
export default function ScenarioConfigView() {
const [testScenarios, setTestScenarios] = useState<TestScenario[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [density, setDensity] = useState<DensityType>('default');
const [columns, setColumns] = useState(defaultColumns);
// 搜索参数
const [searchTerm, setSearchTerm] = useState('');
const [type, setType] = useState<string>('');
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
// 表单对话框状态
const [open, setOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [selectedScenario, setSelectedScenario] = useState<TestScenario | null>(null);
// 提交状态
const [isSubmitting, setIsSubmitting] = useState(false);
// 场景类型数据
const [scenarioTypes, setScenarioTypes] = useState<ScenarioTypeOption[]>([]);
const [isLoadingTypes, setIsLoadingTypes] = useState(false);
// Toast 提示
const { toast } = useToast();
const fetchScenarioTypes = async () => {
setIsLoadingTypes(true);
try {
const result = await scenarioService.getScenarioTypes();
if (result.isSuccess && result.data) {
setScenarioTypes(result.data.scenarioTypes);
}
} catch (error) {
console.error('获取场景类型失败:', error);
} finally {
setIsLoadingTypes(false);
}
};
const fetchTestScenarios = async (params: Partial<GetTestScenariosRequest> = {}) => {
setLoading(true);
const queryParams: GetTestScenariosRequest = {
pageNumber,
pageSize,
searchTerm,
type: type || undefined,
isEnabled,
...params
};
try {
const result = await scenarioService.getTestScenarios(queryParams);
if (result.isSuccess && result.data) {
setTestScenarios(result.data.testScenarios);
setTotal(result.data.totalCount);
} else {
console.error('获取测试场景列表失败:', result.errorMessages);
setTestScenarios([]);
setTotal(0);
}
} catch (error) {
console.error('获取测试场景列表异常:', error);
setTestScenarios([]);
setTotal(0);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchScenarioTypes();
fetchTestScenarios();
// eslint-disable-next-line
}, [pageNumber, pageSize]);
const handleEdit = (scenario: TestScenario) => {
setSelectedScenario(scenario);
setEditOpen(true);
};
const handleDelete = async (scenario: TestScenario) => {
if (confirm(`确定要删除测试场景 "${scenario.scenarioName}" 吗?`)) {
try {
const result = await scenarioService.deleteTestScenario(scenario.testScenarioId);
if (result.isSuccess) {
toast({
title: "删除成功",
description: `测试场景 "${scenario.scenarioName}" 删除成功`,
});
fetchTestScenarios();
} else {
const errorMessage = result.errorMessages?.join(', ') || "删除测试场景时发生错误";
console.error('删除测试场景失败:', errorMessage);
toast({
title: "删除失败",
description: errorMessage,
variant: "destructive",
});
}
} catch (error) {
console.error('删除测试场景异常:', error);
toast({
title: "删除失败",
description: "网络错误,请稍后重试",
variant: "destructive",
});
}
}
};
const handleCreate = async (data: CreateTestScenarioRequest | UpdateTestScenarioRequest) => {
if (isSubmitting) return;
if ('scenarioName' in data && 'type' in data) {
setIsSubmitting(true);
try {
const result = await scenarioService.createTestScenario(data as CreateTestScenarioRequest);
if (result.isSuccess) {
toast({
title: "创建成功",
description: `测试场景 "${data.scenarioName}" 创建成功`,
});
setOpen(false);
fetchTestScenarios();
} else {
const errorMessage = result.errorMessages?.join(', ') || "创建测试场景时发生错误";
toast({
title: "创建失败",
description: errorMessage,
variant: "destructive",
});
}
} catch (error) {
console.error('创建测试场景异常:', error);
toast({
title: "创建失败",
description: "网络错误,请稍后重试",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
};
const handleUpdate = async (data: UpdateTestScenarioRequest) => {
if (!selectedScenario || isSubmitting) return;
setIsSubmitting(true);
try {
const result = await scenarioService.updateTestScenario(selectedScenario.testScenarioId, data);
if (result.isSuccess) {
toast({
title: "更新成功",
description: `测试场景 "${selectedScenario.scenarioName}" 更新成功`,
});
setEditOpen(false);
setSelectedScenario(null);
fetchTestScenarios();
} else {
const errorMessage = result.errorMessages?.join(', ') || "更新测试场景时发生错误";
toast({
title: "更新失败",
description: errorMessage,
variant: "destructive",
});
}
} catch (error) {
console.error('更新测试场景异常:', error);
toast({
title: "更新失败",
description: "网络错误,请稍后重试",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleQuery = () => {
setPageNumber(1);
fetchTestScenarios({ pageNumber: 1 });
};
const handleReset = () => {
setSearchTerm('');
setType('');
setIsEnabled(undefined);
setPageNumber(1);
fetchTestScenarios({
searchTerm: '',
type: undefined,
isEnabled: undefined,
pageNumber: 1
});
};
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPageNumber(1);
};
return (
<div className="flex flex-col h-full overflow-hidden p-4">
{/* 搜索工具栏 */}
<div className="flex-shrink-0 bg-background p-4 rounded-md border mb-4">
<form
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
onSubmit={e => {
e.preventDefault();
handleQuery();
}}
>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-foreground">
</label>
<Input
className="bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all"
placeholder="请输入场景名称或场景编码"
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-foreground">
</label>
<select
className="h-10 rounded border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-0 focus:border-border transition-all"
value={type}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setType(e.target.value)}
disabled={isLoadingTypes}
>
{isLoadingTypes ? (
<option value="">...</option>
) : (
<>
<option value=""></option>
{scenarioTypes.map((scenarioType) => (
<option key={scenarioType.value} value={scenarioType.name}>
{scenarioType.name}
</option>
))}
</>
)}
</select>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-foreground">
</label>
<select
className="h-10 rounded border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-0 focus:border-border transition-all"
value={isEnabled === undefined ? '' : isEnabled.toString()}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setIsEnabled(value === '' ? undefined : value === 'true');
}}
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="flex flex-col space-y-2 justify-end">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleReset} className="flex-1">
</Button>
<Button type="submit" className="flex-1">
</Button>
</div>
</div>
</form>
</div>
{/* 表格区域 */}
<div className="flex-1 flex flex-col bg-background rounded-md border">
{/* 表格工具栏 */}
<div className="flex-shrink-0 flex items-center justify-between p-4 border-b">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
+
</Button>
</DialogTrigger>
<DialogContent className="bg-background max-w-md">
<ScenarioConfigForm
onSubmit={handleCreate}
isSubmitting={isSubmitting}
scenarioTypes={scenarioTypes}
isLoadingTypes={isLoadingTypes}
/>
</DialogContent>
</Dialog>
<TableToolbar
onRefresh={() => fetchTestScenarios()}
onDensityChange={setDensity}
onColumnsChange={setColumns}
onColumnsReset={() => setColumns(defaultColumns)}
columns={columns}
density={density}
/>
</div>
{/* 表格内容 */}
<div className="flex-1 overflow-hidden">
<ScenarioConfigTable
testScenarios={testScenarios}
loading={loading}
onEdit={handleEdit}
onDelete={handleDelete}
density={density}
/>
</div>
{/* 分页栏 */}
<div className="flex-shrink-0 p-4 border-t">
<PaginationBar
page={pageNumber}
pageSize={pageSize}
total={total}
onPageChange={setPageNumber}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
{/* 编辑对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="bg-background max-w-md">
<ScenarioConfigForm
onSubmit={(data: CreateTestScenarioRequest | UpdateTestScenarioRequest) => handleUpdate(data as UpdateTestScenarioRequest)}
initialData={selectedScenario ? {
id: selectedScenario.testScenarioId,
scenarioName: selectedScenario.scenarioName,
description: selectedScenario.description,
isEnabled: selectedScenario.isEnabled
} : undefined}
isEdit={true}
isSubmitting={isSubmitting}
scenarioTypes={scenarioTypes}
isLoadingTypes={isLoadingTypes}
/>
</DialogContent>
</Dialog>
</div>
);
}

39
src/X1.WebUI/src/pages/scenarios/ScenarioTable.tsx → src/X1.WebUI/src/pages/scenarios/scenarios-list/ScenarioTable.tsx

@ -8,9 +8,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Scenario } from '@/services/scenarioService';
import { TestScenario } from '@/services/scenarioService';
import { formatToBeijingTime } from '@/lib/utils';
import { DensityType } from '@/components/ui/TableToolbar';
interface ColumnConfig {
@ -21,16 +20,16 @@ interface ColumnConfig {
}
interface ScenarioTableProps {
scenarios: Scenario[];
scenarios: TestScenario[];
loading: boolean;
onDelete: (scenarioId: string) => void;
onEdit?: (scenario: Scenario) => void;
onEdit?: (scenario: TestScenario) => void;
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
hideCard?: boolean;
density?: DensityType;
density?: 'compact' | 'default' | 'relaxed';
columns?: ColumnConfig[];
onRefresh?: () => void;
}
@ -121,46 +120,38 @@ export default function ScenarioTable({
</TableRow>
) : (
scenarios.map((scenario) => (
<TableRow key={scenario.id} className={rowClass}>
<TableRow key={scenario.testScenarioId} className={rowClass}>
{visibleColumns.map(col => {
switch (col.key) {
case 'name':
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{scenario.name}</TableCell>;
return <TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>{scenario.scenarioName}</TableCell>;
case 'description':
return (
<TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>
<div className="max-w-xs truncate" title={scenario.description}>
{scenario.description}
<div className="max-w-xs truncate" title={scenario.description || ''}>
{scenario.description || '-'}
</div>
</TableCell>
);
case 'status':
return (
<TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>
{getStatusBadge(scenario.status)}
{getStatusBadge(scenario.isEnabled ? 'active' : 'inactive')}
</TableCell>
);
case 'priority':
return (
<TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>
{getPriorityBadge(scenario.priority)}
{getPriorityBadge('medium')}
</TableCell>
);
case 'tags':
return (
<TableCell key={col.key} className={`text-foreground text-center ${cellPadding}`}>
<div className="flex flex-wrap gap-1 justify-center">
{scenario.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800"
>
{tag}
</span>
))}
{scenario.tags.length > 2 && (
<span className="text-xs text-gray-500">+{scenario.tags.length - 2}</span>
)}
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
{scenario.type}
</span>
</div>
</TableCell>
);
@ -182,7 +173,7 @@ export default function ScenarioTable({
)}
<span
className="cursor-pointer text-red-500 hover:underline select-none"
onClick={() => onDelete(scenario.id)}
onClick={() => onDelete(scenario.testScenarioId)}
>
</span>
@ -200,4 +191,4 @@ export default function ScenarioTable({
</Table>
</Wrapper>
);
}
}

200
src/X1.WebUI/src/pages/scenarios/scenarios-list/ScenariosListView.tsx

@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Search, Filter, RefreshCw, Plus } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { TestScenario, scenarioService } from '@/services/scenarioService';
import ScenarioTable from './ScenarioTable';
import PaginationBar from '@/components/ui/PaginationBar';
export default function ScenariosListView() {
const [scenarios, setScenarios] = useState<TestScenario[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const { toast } = useToast();
const fetchScenarios = async () => {
try {
setLoading(true);
const response = await scenarioService.getTestScenarios({
searchTerm,
type: statusFilter !== 'all' ? statusFilter : undefined,
isEnabled: priorityFilter !== 'all' ? priorityFilter === 'enabled' : undefined,
pageNumber: page,
pageSize,
});
if (response.isSuccess && response.data) {
setScenarios(response.data.testScenarios);
setTotal(response.data.totalCount);
}
} catch (error) {
toast({
title: '错误',
description: '获取场景列表失败',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchScenarios();
}, [page, pageSize, searchTerm, statusFilter, priorityFilter]);
const handleDelete = async (scenarioId: string) => {
try {
await scenarioService.deleteTestScenario(scenarioId);
toast({
title: '成功',
description: '场景删除成功',
});
fetchScenarios();
} catch (error) {
toast({
title: '错误',
description: '删除场景失败',
variant: 'destructive',
});
}
};
const handleEdit = (scenario: TestScenario) => {
// 跳转到编辑页面或打开编辑对话框
console.log('编辑场景:', scenario);
};
const handleRefresh = () => {
fetchScenarios();
};
const handleSearch = (value: string) => {
setSearchTerm(value);
setPage(1);
};
const handleStatusFilterChange = (value: string) => {
setStatusFilter(value);
setPage(1);
};
const handlePriorityFilterChange = (value: string) => {
setPriorityFilter(value);
setPage(1);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{/* 搜索和过滤工具栏 */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜索场景名称或描述..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={handlePriorityFilterChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="优先级" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="high"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="low"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 统计信息 */}
<div className="flex items-center gap-4 mb-4">
<Badge variant="secondary">
: {total}
</Badge>
{statusFilter !== 'all' && (
<Badge variant="outline">
: {statusFilter === 'active' ? '活跃' : statusFilter === 'inactive' ? '停用' : '草稿'}
</Badge>
)}
{priorityFilter !== 'all' && (
<Badge variant="outline">
: {priorityFilter === 'high' ? '高' : priorityFilter === 'medium' ? '中' : '低'}
</Badge>
)}
</div>
{/* 场景表格 */}
<ScenarioTable
scenarios={scenarios}
loading={loading}
onDelete={handleDelete}
onEdit={handleEdit}
page={page}
pageSize={pageSize}
total={total}
onPageChange={handlePageChange}
/>
{/* 分页 */}
<div className="mt-4">
<PaginationBar
page={page}
pageSize={pageSize}
total={total}
onPageChange={handlePageChange}
onPageSizeChange={setPageSize}
/>
</div>
</CardContent>
</Card>
</div>
);
}

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

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { TestStep } from '@/services/teststepsService';
import { testcaseService, CreateTestCaseFlowRequest, TestFlowType } from '@/services/testcaseService';
import { testcaseService, CreateTestCaseFlowRequest, TestFlowType, TestFlowTypeDto } from '@/services/testcaseService';
import TestStepsPanel from './TestStepsPanel';
import ReactFlowDesigner from './ReactFlowDesigner';
import SaveTestCaseForm from '@/components/testcases/SaveTestCaseForm';
@ -13,6 +13,25 @@ export default function TestCasesView() {
const [showSaveForm, setShowSaveForm] = useState(false);
const [pendingNodes, setPendingNodes] = useState<any[]>([]);
const [pendingEdges, setPendingEdges] = useState<any[]>([]);
const [testFlowTypes, setTestFlowTypes] = useState<TestFlowTypeDto[]>([]);
// 获取测试流程类型列表
useEffect(() => {
const fetchTestFlowTypes = async () => {
try {
const result = await testcaseService.getTestFlowTypes();
if (result.isSuccess && result.data) {
setTestFlowTypes(result.data.testFlowTypes);
} else {
console.error('获取测试流程类型失败:', result.errorMessages);
}
} catch (error) {
console.error('获取测试流程类型出错:', error);
}
};
fetchTestFlowTypes();
}, []);
const handleSaveFlow = async (nodes: any[], edges: any[]) => {
console.log('=== TestCasesView 保存流程数据 ===');
@ -141,6 +160,7 @@ export default function TestCasesView() {
onSave={handleSaveFormSubmit}
loading={saving}
nodeCount={pendingNodes.length}
testFlowTypes={testFlowTypes}
/>
</div>
);

20
src/X1.WebUI/src/routes/AppRouter.tsx

@ -14,7 +14,9 @@ const RolesView = lazy(() => import('@/pages/roles/RolesView'));
const UsersView = lazy(() => import('@/pages/users/UsersView'));
// 场景管理页面
const ScenariosView = lazy(() => import('@/pages/scenarios/ScenariosView'));
const ScenariosListView = lazy(() => import('@/pages/scenarios/scenarios-list/ScenariosListView'));
const ScenarioConfigView = lazy(() => import('@/pages/scenarios/scenario-config/ScenarioConfigView'));
const ScenarioBindingView = lazy(() => import('@/pages/scenarios/scenario-binding/ScenarioBindingView'));
const TestCasesView = lazy(() => import('@/pages/testcases/TestCasesView'));
const TestCasesListView = lazy(() => import('@/pages/testcases/TestCasesListView'));
const TestStepsView = lazy(() => import('@/pages/teststeps/TestStepsView'));
@ -122,7 +124,21 @@ export function AppRouter() {
<Route path="list" element={
<ProtectedRoute requiredPermission="scenarios.view">
<AnimatedContainer>
<ScenariosView />
<ScenariosListView />
</AnimatedContainer>
</ProtectedRoute>
} />
<Route path="config" element={
<ProtectedRoute requiredPermission="scenarios.manage">
<AnimatedContainer>
<ScenarioConfigView />
</AnimatedContainer>
</ProtectedRoute>
} />
<Route path="binding" element={
<ProtectedRoute requiredPermission="scenarios.manage">
<AnimatedContainer>
<ScenarioBindingView />
</AnimatedContainer>
</ProtectedRoute>
} />

335
src/X1.WebUI/src/services/scenarioService.ts

@ -1,79 +1,193 @@
import { httpClient } from '@/lib/http-client';
import { OperationResult } from '@/types/auth';
import { API_PATHS } from '@/constants/api';
export interface Scenario {
id: string;
// 场景类型 - 动态从后端获取,不写死
export type ScenarioType = number;
// 场景类型选项接口 - 与后端 ScenarioTypeDto 对应
export interface ScenarioTypeOption {
value: number;
enumValue: string;
name: string;
description: string;
status: 'active' | 'inactive' | 'draft';
priority: 'low' | 'medium' | 'high';
tags: string[];
requirements: string;
}
// 获取场景类型响应接口 - 与后端 GetScenarioTypesResponse 对应
export interface GetScenarioTypesResponse {
scenarioTypes: ScenarioTypeOption[];
}
// 测试场景接口定义 - 与后端 TestScenarioDto 对应
export interface TestScenario {
testScenarioId: string;
scenarioCode: string;
scenarioName: string;
type: string;
description?: string;
isEnabled: boolean;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
export interface GetAllScenariosResponse {
scenarios: Scenario[];
// 获取测试场景列表响应接口 - 与后端 GetTestScenariosResponse 对应
export interface GetTestScenariosResponse {
testScenarios: TestScenario[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
}
export interface CreateScenarioRequest {
name: string;
description: string;
status: 'active' | 'inactive' | 'draft';
priority: 'low' | 'medium' | 'high';
tags: string[];
requirements: string;
// 获取测试场景列表请求参数接口 - 与后端 GetTestScenarios 方法参数对应
export interface GetTestScenariosRequest {
searchTerm?: string;
type?: string;
isEnabled?: boolean;
pageNumber?: number;
pageSize?: number;
}
export interface UpdateScenarioRequest {
name: string;
description: string;
status: 'active' | 'inactive' | 'draft';
priority: 'low' | 'medium' | 'high';
tags: string[];
requirements: string;
// 创建测试场景请求接口 - 与后端 CreateTestScenarioCommand 对应
export interface CreateTestScenarioRequest {
scenarioName: string;
type: ScenarioType;
description?: string;
isEnabled?: boolean;
}
export interface ScenarioService {
getAllScenarios: (params?: { name?: string; status?: string; page?: number; pageSize?: number }) => Promise<OperationResult<GetAllScenariosResponse>>;
getScenario: (scenarioId: string) => Promise<OperationResult<Scenario>>;
createScenario: (data: CreateScenarioRequest) => Promise<OperationResult<Scenario>>;
updateScenario: (scenarioId: string, data: UpdateScenarioRequest) => Promise<OperationResult<Scenario>>;
deleteScenario: (scenarioId: string) => Promise<OperationResult<void>>;
// 创建测试场景响应接口 - 与后端 CreateTestScenarioResponse 对应
export interface CreateTestScenarioResponse {
testScenarioId: string;
scenarioCode: string;
scenarioName: string;
type: string;
description?: string;
isEnabled: boolean;
createdAt: string;
createdBy: string;
}
// 更新测试场景请求接口 - 与后端 UpdateTestScenarioCommand 对应
export interface UpdateTestScenarioRequest {
scenarioName?: string;
description?: string;
isEnabled?: boolean;
}
export const scenarioService: ScenarioService = {
getAllScenarios: async (params = {}) => {
// 更新测试场景响应接口 - 与后端 UpdateTestScenarioResponse 对应
export interface UpdateTestScenarioResponse {
testScenarioId: string;
scenarioCode: string;
scenarioName: string;
type: string;
description?: string;
isEnabled: boolean;
updatedAt: string;
updatedBy: string;
}
// 根据ID获取测试场景详情响应接口 - 与后端 GetTestScenarioByIdResponse 对应
export interface GetTestScenarioByIdResponse {
testScenarioId: string;
scenarioCode: string;
scenarioName: string;
type: string;
description?: string;
isEnabled: boolean;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
testCases?: ScenarioTestCaseDto[];
}
// 场景测试用例DTO - 与后端 ScenarioTestCaseDto 对应
export interface ScenarioTestCaseDto {
scenarioTestCaseId: string;
scenarioId: string;
testCaseFlowId: string;
testCaseFlowName?: string;
executionOrder: number;
loopCount: number;
isEnabled: boolean;
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
// 获取场景测试用例列表响应接口 - 与后端 GetScenarioTestCasesResponse 对应
export interface GetScenarioTestCasesResponse {
scenarioId: string;
testCases: ScenarioTestCaseDto[];
totalCount: number;
}
// 创建场景测试用例请求接口 - 与后端 CreateScenarioTestCaseCommand 对应
export interface CreateScenarioTestCaseRequest {
testCases: {
testCaseFlowId: string;
executionOrder: number;
loopCount: number;
isEnabled: boolean;
}[];
}
// 创建场景测试用例响应接口 - 与后端 CreateScenarioTestCaseResponse 对应
export interface CreateScenarioTestCaseResponse {
successCount: number;
failureCount: number;
totalCount: number;
successIds: string[];
errorMessages: string[];
isAllSuccess: boolean;
}
class ScenarioService {
private readonly baseUrl = API_PATHS.TEST_SCENARIOS;
// 获取测试场景类型列表 - 对应 GET /api/testscenarios/types
async getScenarioTypes(): Promise<OperationResult<GetScenarioTypesResponse>> {
try {
const response = await httpClient.get<GetScenarioTypesResponse>(`${this.baseUrl}/types`);
return {
isSuccess: true,
data: response.data,
successMessage: '获取测试场景类型列表成功',
errorMessages: null
};
} catch (error: any) {
return {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '获取测试场景类型列表失败']
};
}
}
// 获取测试场景列表 - 对应 GET /api/testscenarios
async getTestScenarios(params: GetTestScenariosRequest = {}): Promise<OperationResult<GetTestScenariosResponse>> {
try {
const mappedParams = {
PageNumber: params.page ?? 1,
PageSize: params.pageSize ?? 10,
Name: params.name ?? undefined,
Status: params.status ?? undefined
};
const response = await httpClient.get<{ data: GetAllScenariosResponse }>('/scenarios', { params: mappedParams });
const resultData = (response.data && 'scenarios' in response.data) ? response.data : response.data?.data;
if (resultData && Array.isArray(resultData.scenarios)) {
resultData.scenarios = resultData.scenarios.map(s => ({
...s,
id: s.id ?? s.scenarioId,
status: s.status ?? 'draft',
priority: s.priority ?? 'medium',
tags: s.tags ?? []
}));
}
const queryParams = new URLSearchParams();
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 = `${this.baseUrl}?${queryParams.toString()}`;
const response = await httpClient.get<GetTestScenariosResponse>(url);
return {
isSuccess: true,
data: resultData as GetAllScenariosResponse,
successMessage: '获取场景列表成功',
data: response.data,
successMessage: '获取测试场景列表成功',
errorMessages: null
};
} catch (error: any) {
@ -81,18 +195,24 @@ export const scenarioService: ScenarioService = {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '获取场景列表失败']
errorMessages: [error.response?.data?.message || '获取测试场景列表失败']
};
}
},
}
getScenario: async (scenarioId: string): Promise<OperationResult<Scenario>> => {
// 根据ID获取测试场景详情 - 对应 GET /api/testscenarios/{id}
async getTestScenarioById(id: string, includeTestCases = false): Promise<OperationResult<GetTestScenarioByIdResponse>> {
try {
const response = await httpClient.get<Scenario>(`/scenarios/${scenarioId}`);
const queryParams = new URLSearchParams();
if (includeTestCases) queryParams.append('includeTestCases', 'true');
const url = `${this.baseUrl}/${id}?${queryParams.toString()}`;
const response = await httpClient.get<GetTestScenarioByIdResponse>(url);
return {
isSuccess: true,
data: response.data || null,
successMessage: '获取场景详情成功',
data: response.data,
successMessage: '获取测试场景详情成功',
errorMessages: null
};
} catch (error: any) {
@ -100,18 +220,20 @@ export const scenarioService: ScenarioService = {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '获取场景详情失败']
errorMessages: [error.response?.data?.message || '获取测试场景详情失败']
};
}
},
}
createScenario: async (data: CreateScenarioRequest): Promise<OperationResult<Scenario>> => {
// 创建测试场景 - 对应 POST /api/testscenarios
async createTestScenario(data: CreateTestScenarioRequest): Promise<OperationResult<CreateTestScenarioResponse>> {
try {
const response = await httpClient.post<Scenario>('/scenarios', data);
const response = await httpClient.post<CreateTestScenarioResponse>(this.baseUrl, data);
return {
isSuccess: true,
data: response.data || null,
successMessage: '创建场景成功',
data: response.data,
successMessage: '创建测试场景成功',
errorMessages: null
};
} catch (error: any) {
@ -119,18 +241,20 @@ export const scenarioService: ScenarioService = {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '创建场景失败']
errorMessages: [error.response?.data?.message || '创建测试场景失败']
};
}
},
}
updateScenario: async (scenarioId: string, data: UpdateScenarioRequest): Promise<OperationResult<Scenario>> => {
// 更新测试场景 - 对应 PUT /api/testscenarios/{id}
async updateTestScenario(id: string, data: UpdateTestScenarioRequest): Promise<OperationResult<UpdateTestScenarioResponse>> {
try {
const response = await httpClient.put<Scenario>(`/scenarios/${scenarioId}`, data);
const response = await httpClient.put<UpdateTestScenarioResponse>(`${this.baseUrl}/${id}`, data);
return {
isSuccess: true,
data: response.data || null,
successMessage: '更新场景成功',
data: response.data,
successMessage: '更新测试场景成功',
errorMessages: null
};
} catch (error: any) {
@ -138,18 +262,46 @@ export const scenarioService: ScenarioService = {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '更新场景失败']
errorMessages: [error.response?.data?.message || '更新测试场景失败']
};
}
},
}
deleteScenario: async (scenarioId: string): Promise<OperationResult<void>> => {
// 删除测试场景 - 对应 DELETE /api/testscenarios/{id}
async deleteTestScenario(id: string): Promise<OperationResult<boolean>> {
try {
await httpClient.delete(`/scenarios/${scenarioId}`);
const response = await httpClient.delete<boolean>(`${this.baseUrl}/${id}`);
return {
isSuccess: true,
data: response.data,
successMessage: '删除测试场景成功',
errorMessages: null
};
} catch (error: any) {
return {
isSuccess: false,
data: null,
successMessage: '删除场景成功',
successMessage: null,
errorMessages: [error.response?.data?.message || '删除测试场景失败']
};
}
}
// 获取场景测试用例列表 - 对应 GET /api/testscenarios/{scenarioId}/testcases
async getScenarioTestCases(scenarioId: string, isEnabled?: boolean, includeDetails = false): Promise<OperationResult<GetScenarioTestCasesResponse>> {
try {
const queryParams = new URLSearchParams();
if (isEnabled !== undefined) queryParams.append('isEnabled', isEnabled.toString());
if (includeDetails) queryParams.append('includeDetails', 'true');
const url = `${this.baseUrl}/${scenarioId}/testcases?${queryParams.toString()}`;
const response = await httpClient.get<GetScenarioTestCasesResponse>(url);
return {
isSuccess: true,
data: response.data,
successMessage: '获取场景测试用例列表成功',
errorMessages: null
};
} catch (error: any) {
@ -157,8 +309,37 @@ export const scenarioService: ScenarioService = {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '删除场景失败']
errorMessages: [error.response?.data?.message || '获取场景测试用例列表失败']
};
}
}
};
// 创建场景测试用例 - 对应 POST /api/testscenarios/testcases
async createScenarioTestCase(scenarioId: string, data: CreateScenarioTestCaseRequest): Promise<OperationResult<CreateScenarioTestCaseResponse>> {
try {
// 将scenarioId添加到请求数据中
const requestData = {
...data,
scenarioId: scenarioId
};
const response = await httpClient.post<CreateScenarioTestCaseResponse>(`${this.baseUrl}/testcases`, requestData);
return {
isSuccess: true,
data: response.data,
successMessage: '创建场景测试用例成功',
errorMessages: null
};
} catch (error: any) {
return {
isSuccess: false,
data: null,
successMessage: null,
errorMessages: [error.response?.data?.message || '创建场景测试用例失败']
};
}
}
}
export const scenarioService = new ScenarioService();

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

@ -197,6 +197,18 @@ export interface CreateTestCaseFlowResponse {
createdBy: string;
}
// 测试流程类型DTO接口
export interface TestFlowTypeDto {
value: number;
name: string;
description: string;
}
// 获取测试流程类型响应接口
export interface GetTestFlowTypesResponse {
testFlowTypes: TestFlowTypeDto[];
}
class TestCaseFlowService {
private readonly baseUrl = API_PATHS.TEST_CASE_FLOW;
@ -228,6 +240,11 @@ class TestCaseFlowService {
async deleteTestCaseFlow(id: string): Promise<OperationResult<boolean>> {
return httpClient.delete<boolean>(`${this.baseUrl}/${id}`);
}
// 获取测试流程类型列表
async getTestFlowTypes(): Promise<OperationResult<GetTestFlowTypesResponse>> {
return httpClient.get<GetTestFlowTypesResponse>(`${this.baseUrl}/test-flow-types`);
}
}
export const testcaseFlowService = new TestCaseFlowService();

2
src/X1.WebUI/src/services/teststepsService.ts

@ -117,7 +117,7 @@ class TestStepsService {
// 获取表单类型到步骤类型映射
async getFormTypeMapping(): Promise<OperationResult<GetFormTypeMappingResponse>> {
return httpClient.get<GetFormTypeMappingResponse>(`${this.baseUrl}/form-type-step`);
return httpClient.get<GetFormTypeMappingResponse>(`${this.baseUrl}/test-flow-types`);
}
}

1402
src/modify.md

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