Browse Source
- 修复 ScenarioBindingView.tsx 中绑定接口调用问题 - 完善 handleBindTestCases 函数,实现真正的API调用 - 添加完整的错误处理和用户反馈机制 - 实现绑定成功后的数据刷新功能 - 构建正确的绑定请求数据格式,包含执行顺序、循环次数等字段 - 支持批量绑定测试用例到场景 - 提供详细的绑定结果反馈(成功数量、失败数量、错误信息) - 更新修改记录文档,记录本次修复详情 技术细节: - 调用 scenarioService.createScenarioTestCase API - 数据格式与后端 CreateScenarioTestCaseCommand 完全匹配 - 支持按选择顺序设置执行顺序 - 默认循环次数为1,默认启用状态release/web-ui-v1.0.0
44 changed files with 6001 additions and 693 deletions
@ -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!; |
|||
} |
|||
@ -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("启用状态不能为空"); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
@ -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>> |
|||
{ |
|||
// 无需额外参数,返回所有测试场景类型
|
|||
} |
|||
@ -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}")); |
|||
} |
|||
} |
|||
} |
|||
@ -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!; |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -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"); |
|||
} |
|||
} |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue