diff --git a/backend/pom.xml b/backend/pom.xml index cfdce1542f..a54a86c0df 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -165,6 +165,30 @@ ${jmeter.version} + + org.apache.jmeter + ApacheJMeter_jdbc + ${jmeter.version} + + + + com.microsoft.sqlserver + mssql-jdbc + 7.4.1.jre8 + + + + org.postgresql + postgresql + 42.2.14 + + + + com.oracle.database.jdbc + ojdbc8 + 19.7.0.0 + + org.apache.dubbo @@ -297,6 +321,31 @@ 0.15.2 + + org.apache.commons + commons-compress + 1.20 + + + + org.dom4j + dom4j + 2.1.3 + + + + jaxen + jaxen + 1.2.0 + + + + org.json + json + 20171018 + + + diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/DatabaseConfig.java b/backend/src/main/java/io/metersphere/api/dto/scenario/DatabaseConfig.java new file mode 100644 index 0000000000..e9f557b7d7 --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/DatabaseConfig.java @@ -0,0 +1,16 @@ +package io.metersphere.api.dto.scenario; + +import lombok.Data; + +@Data +public class DatabaseConfig { + + private String id; + private String name; + private long poolMax; + private long timeout; + private String driver; + private String dbUrl; + private String username; + private String password; +} diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/Scenario.java b/backend/src/main/java/io/metersphere/api/dto/scenario/Scenario.java index 8418015b40..cad9efb3d8 100644 --- a/backend/src/main/java/io/metersphere/api/dto/scenario/Scenario.java +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/Scenario.java @@ -16,5 +16,6 @@ public class Scenario { private List headers; private List requests; private DubboConfig dubboConfig; + private List databaseConfigs; private Boolean enable; } diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/request/Request.java b/backend/src/main/java/io/metersphere/api/dto/scenario/request/Request.java index 58d0baf1a3..94be3e48d1 100644 --- a/backend/src/main/java/io/metersphere/api/dto/scenario/request/Request.java +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/request/Request.java @@ -7,8 +7,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = HttpRequest.class, name = RequestType.HTTP), - @JsonSubTypes.Type(value = DubboRequest.class, name = RequestType.DUBBO) + @JsonSubTypes.Type(value = DubboRequest.class, name = RequestType.DUBBO), + @JsonSubTypes.Type(value = SqlRequest.class, name = RequestType.SQL) }) -@JSONType(seeAlso = {HttpRequest.class, DubboRequest.class}, typeKey = "type") +@JSONType(seeAlso = {HttpRequest.class, DubboRequest.class, SqlRequest.class}, typeKey = "type") public interface Request { } diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/request/RequestType.java b/backend/src/main/java/io/metersphere/api/dto/scenario/request/RequestType.java index 16d151f95e..3be7d36eb6 100644 --- a/backend/src/main/java/io/metersphere/api/dto/scenario/request/RequestType.java +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/request/RequestType.java @@ -5,4 +5,6 @@ public class RequestType { public static final String HTTP = "HTTP"; public static final String DUBBO = "DUBBO"; + + public static final String SQL = "SQL"; } diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/request/SqlRequest.java b/backend/src/main/java/io/metersphere/api/dto/scenario/request/SqlRequest.java new file mode 100644 index 0000000000..bcebba7ccd --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/request/SqlRequest.java @@ -0,0 +1,40 @@ +package io.metersphere.api.dto.scenario.request; + +import com.alibaba.fastjson.annotation.JSONField; +import com.alibaba.fastjson.annotation.JSONType; +import io.metersphere.api.dto.scenario.assertions.Assertions; +import io.metersphere.api.dto.scenario.extract.Extract; +import io.metersphere.api.dto.scenario.processor.JSR223PostProcessor; +import io.metersphere.api.dto.scenario.processor.JSR223PreProcessor; +import lombok.Data; + +@Data +@JSONType(typeName = RequestType.SQL) +public class SqlRequest implements Request { + // type 必须放最前面,以便能够转换正确的类 + private String type = RequestType.SQL; + @JSONField(ordinal = 1) + private String id; + @JSONField(ordinal = 2) + private String name; + @JSONField(ordinal = 3) + private String dataSource; + @JSONField(ordinal = 4) + private String query; + @JSONField(ordinal = 5) + private long queryTimeout; + @JSONField(ordinal = 6) + private Boolean useEnvironment; + @JSONField(ordinal = 7) + private Assertions assertions; + @JSONField(ordinal = 8) + private Extract extract; + @JSONField(ordinal = 9) + private Boolean enable; + @JSONField(ordinal = 10) + private Boolean followRedirects; + @JSONField(ordinal = 11) + private JSR223PreProcessor jsr223PreProcessor; + @JSONField(ordinal = 12) + private JSR223PostProcessor jsr223PostProcessor; +} diff --git a/backend/src/main/java/io/metersphere/api/jmeter/APIBackendListenerClient.java b/backend/src/main/java/io/metersphere/api/jmeter/APIBackendListenerClient.java index 40fc87a64c..0549b16b61 100644 --- a/backend/src/main/java/io/metersphere/api/jmeter/APIBackendListenerClient.java +++ b/backend/src/main/java/io/metersphere/api/jmeter/APIBackendListenerClient.java @@ -3,16 +3,20 @@ package io.metersphere.api.jmeter; import io.metersphere.api.service.APIReportService; import io.metersphere.api.service.APITestService; import io.metersphere.base.domain.ApiTestReport; +import io.metersphere.base.domain.Notice; import io.metersphere.commons.constants.APITestStatus; import io.metersphere.commons.constants.ApiRunMode; import io.metersphere.commons.utils.CommonBeanFactory; import io.metersphere.commons.utils.LogUtil; +import io.metersphere.notice.service.MailService; +import io.metersphere.notice.service.NoticeService; import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.assertions.AssertionResult; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.visualizers.backend.AbstractBackendListenerClient; import org.apache.jmeter.visualizers.backend.BackendListenerContext; +import javax.annotation.Resource; import java.io.Serializable; import java.util.*; @@ -113,9 +117,12 @@ public class APIBackendListenerClient extends AbstractBackendListenerClient impl report = apiReportService.getRunningReport(testResult.getTestId()); } apiReportService.complete(testResult, report); - queue.clear(); super.teardownTest(context); + NoticeService noticeService = CommonBeanFactory.getBean(NoticeService.class); + List notice = noticeService.queryNotice(testResult.getTestId()); + MailService mailService = CommonBeanFactory.getBean(MailService.class); + mailService.sendHtml(report.getId(), notice, report.getStatus(), "api"); } private RequestResult getRequestResult(SampleResult result) { @@ -170,7 +177,7 @@ public class APIBackendListenerClient extends AbstractBackendListenerClient impl this.runMode = context.getParameter("runMode"); this.debugReportId = context.getParameter("debugReportId"); if (StringUtils.isBlank(this.runMode)) { - this.runMode = ApiRunMode.RUN.name(); + this.runMode = ApiRunMode.RUN.name(); } } diff --git a/backend/src/main/java/io/metersphere/api/service/APITestService.java b/backend/src/main/java/io/metersphere/api/service/APITestService.java index 70c46f1f9e..ab2d8e2302 100644 --- a/backend/src/main/java/io/metersphere/api/service/APITestService.java +++ b/backend/src/main/java/io/metersphere/api/service/APITestService.java @@ -12,6 +12,7 @@ import io.metersphere.api.parse.JmeterDocumentParser; import io.metersphere.base.domain.*; import io.metersphere.base.mapper.ApiTestFileMapper; import io.metersphere.base.mapper.ApiTestMapper; +import io.metersphere.base.mapper.UserMapper; import io.metersphere.base.mapper.ext.ExtApiTestMapper; import io.metersphere.commons.constants.APITestStatus; import io.metersphere.commons.constants.FileType; @@ -23,9 +24,12 @@ import io.metersphere.controller.request.QueryScheduleRequest; import io.metersphere.dto.ScheduleDao; import io.metersphere.i18n.Translator; import io.metersphere.job.sechedule.ApiTestJob; +import io.metersphere.notice.service.MailService; +import io.metersphere.notice.service.NoticeService; import io.metersphere.service.FileService; import io.metersphere.service.QuotaService; import io.metersphere.service.ScheduleService; +import io.metersphere.service.UserService; import io.metersphere.track.service.TestCaseService; import org.apache.dubbo.common.URL; import org.apache.dubbo.common.constants.CommonConstants; @@ -44,7 +48,8 @@ import java.util.stream.Collectors; @Service @Transactional(rollbackFor = Exception.class) public class APITestService { - + @Resource + private UserService userService; @Resource private ApiTestMapper apiTestMapper; @Resource @@ -61,6 +66,10 @@ public class APITestService { private ScheduleService scheduleService; @Resource private TestCaseService testCaseService; + @Resource + private MailService mailService; + @Resource + private NoticeService noticeService; private static final String BODY_FILE_DIR = "/opt/metersphere/data/body"; @@ -214,8 +223,11 @@ public class APITestService { apiTest.setUserId(request.getUserId()); } String reportId = apiReportService.create(apiTest, request.getTriggerMode()); + /*if (request.getTriggerMode().equals("SCHEDULE")) { + List notice = noticeService.queryNotice(request.getId()); + mailService.sendHtml(reportId,notice,"api"); + }*/ changeStatus(request.getId(), APITestStatus.Running); - jMeterService.run(request.getId(), null, is); return reportId; } diff --git a/backend/src/main/java/io/metersphere/base/domain/ApiTestEnvironmentWithBLOBs.java b/backend/src/main/java/io/metersphere/base/domain/ApiTestEnvironmentWithBLOBs.java index 363762f824..d64fe59265 100644 --- a/backend/src/main/java/io/metersphere/base/domain/ApiTestEnvironmentWithBLOBs.java +++ b/backend/src/main/java/io/metersphere/base/domain/ApiTestEnvironmentWithBLOBs.java @@ -1,11 +1,10 @@ package io.metersphere.base.domain; +import java.io.Serializable; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; -import java.io.Serializable; - @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @@ -14,8 +13,9 @@ public class ApiTestEnvironmentWithBLOBs extends ApiTestEnvironment implements S private String headers; - private String customData; + private String config; private String hosts; + private static final long serialVersionUID = 1L; } \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/base/domain/Notice.java b/backend/src/main/java/io/metersphere/base/domain/Notice.java index 3096905b54..7d00dd04e0 100644 --- a/backend/src/main/java/io/metersphere/base/domain/Notice.java +++ b/backend/src/main/java/io/metersphere/base/domain/Notice.java @@ -15,5 +15,9 @@ public class Notice implements Serializable { private String enable; + private String[] names; + + private String[] emails; + private static final long serialVersionUID = 1L; } \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/base/domain/TestPlan.java b/backend/src/main/java/io/metersphere/base/domain/TestPlan.java index 6fc474d077..9e73da87bc 100644 --- a/backend/src/main/java/io/metersphere/base/domain/TestPlan.java +++ b/backend/src/main/java/io/metersphere/base/domain/TestPlan.java @@ -7,8 +7,6 @@ import lombok.Data; public class TestPlan implements Serializable { private String id; - private String projectId; - private String workspaceId; private String reportId; diff --git a/backend/src/main/java/io/metersphere/base/domain/TestPlanExample.java b/backend/src/main/java/io/metersphere/base/domain/TestPlanExample.java index 3c75a52576..f7ecdc3069 100644 --- a/backend/src/main/java/io/metersphere/base/domain/TestPlanExample.java +++ b/backend/src/main/java/io/metersphere/base/domain/TestPlanExample.java @@ -174,76 +174,6 @@ public class TestPlanExample { return (Criteria) this; } - public Criteria andProjectIdIsNull() { - addCriterion("project_id is null"); - return (Criteria) this; - } - - public Criteria andProjectIdIsNotNull() { - addCriterion("project_id is not null"); - return (Criteria) this; - } - - public Criteria andProjectIdEqualTo(String value) { - addCriterion("project_id =", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdNotEqualTo(String value) { - addCriterion("project_id <>", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdGreaterThan(String value) { - addCriterion("project_id >", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdGreaterThanOrEqualTo(String value) { - addCriterion("project_id >=", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdLessThan(String value) { - addCriterion("project_id <", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdLessThanOrEqualTo(String value) { - addCriterion("project_id <=", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdLike(String value) { - addCriterion("project_id like", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdNotLike(String value) { - addCriterion("project_id not like", value, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdIn(List values) { - addCriterion("project_id in", values, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdNotIn(List values) { - addCriterion("project_id not in", values, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdBetween(String value1, String value2) { - addCriterion("project_id between", value1, value2, "projectId"); - return (Criteria) this; - } - - public Criteria andProjectIdNotBetween(String value1, String value2) { - addCriterion("project_id not between", value1, value2, "projectId"); - return (Criteria) this; - } - public Criteria andWorkspaceIdIsNull() { addCriterion("workspace_id is null"); return (Criteria) this; @@ -385,72 +315,72 @@ public class TestPlanExample { } public Criteria andNameIsNull() { - addCriterion("name is null"); + addCriterion("`name` is null"); return (Criteria) this; } public Criteria andNameIsNotNull() { - addCriterion("name is not null"); + addCriterion("`name` is not null"); return (Criteria) this; } public Criteria andNameEqualTo(String value) { - addCriterion("name =", value, "name"); + addCriterion("`name` =", value, "name"); return (Criteria) this; } public Criteria andNameNotEqualTo(String value) { - addCriterion("name <>", value, "name"); + addCriterion("`name` <>", value, "name"); return (Criteria) this; } public Criteria andNameGreaterThan(String value) { - addCriterion("name >", value, "name"); + addCriterion("`name` >", value, "name"); return (Criteria) this; } public Criteria andNameGreaterThanOrEqualTo(String value) { - addCriterion("name >=", value, "name"); + addCriterion("`name` >=", value, "name"); return (Criteria) this; } public Criteria andNameLessThan(String value) { - addCriterion("name <", value, "name"); + addCriterion("`name` <", value, "name"); return (Criteria) this; } public Criteria andNameLessThanOrEqualTo(String value) { - addCriterion("name <=", value, "name"); + addCriterion("`name` <=", value, "name"); return (Criteria) this; } public Criteria andNameLike(String value) { - addCriterion("name like", value, "name"); + addCriterion("`name` like", value, "name"); return (Criteria) this; } public Criteria andNameNotLike(String value) { - addCriterion("name not like", value, "name"); + addCriterion("`name` not like", value, "name"); return (Criteria) this; } public Criteria andNameIn(List values) { - addCriterion("name in", values, "name"); + addCriterion("`name` in", values, "name"); return (Criteria) this; } public Criteria andNameNotIn(List values) { - addCriterion("name not in", values, "name"); + addCriterion("`name` not in", values, "name"); return (Criteria) this; } public Criteria andNameBetween(String value1, String value2) { - addCriterion("name between", value1, value2, "name"); + addCriterion("`name` between", value1, value2, "name"); return (Criteria) this; } public Criteria andNameNotBetween(String value1, String value2) { - addCriterion("name not between", value1, value2, "name"); + addCriterion("`name` not between", value1, value2, "name"); return (Criteria) this; } @@ -525,72 +455,72 @@ public class TestPlanExample { } public Criteria andStatusIsNull() { - addCriterion("status is null"); + addCriterion("`status` is null"); return (Criteria) this; } public Criteria andStatusIsNotNull() { - addCriterion("status is not null"); + addCriterion("`status` is not null"); return (Criteria) this; } public Criteria andStatusEqualTo(String value) { - addCriterion("status =", value, "status"); + addCriterion("`status` =", value, "status"); return (Criteria) this; } public Criteria andStatusNotEqualTo(String value) { - addCriterion("status <>", value, "status"); + addCriterion("`status` <>", value, "status"); return (Criteria) this; } public Criteria andStatusGreaterThan(String value) { - addCriterion("status >", value, "status"); + addCriterion("`status` >", value, "status"); return (Criteria) this; } public Criteria andStatusGreaterThanOrEqualTo(String value) { - addCriterion("status >=", value, "status"); + addCriterion("`status` >=", value, "status"); return (Criteria) this; } public Criteria andStatusLessThan(String value) { - addCriterion("status <", value, "status"); + addCriterion("`status` <", value, "status"); return (Criteria) this; } public Criteria andStatusLessThanOrEqualTo(String value) { - addCriterion("status <=", value, "status"); + addCriterion("`status` <=", value, "status"); return (Criteria) this; } public Criteria andStatusLike(String value) { - addCriterion("status like", value, "status"); + addCriterion("`status` like", value, "status"); return (Criteria) this; } public Criteria andStatusNotLike(String value) { - addCriterion("status not like", value, "status"); + addCriterion("`status` not like", value, "status"); return (Criteria) this; } public Criteria andStatusIn(List values) { - addCriterion("status in", values, "status"); + addCriterion("`status` in", values, "status"); return (Criteria) this; } public Criteria andStatusNotIn(List values) { - addCriterion("status not in", values, "status"); + addCriterion("`status` not in", values, "status"); return (Criteria) this; } public Criteria andStatusBetween(String value1, String value2) { - addCriterion("status between", value1, value2, "status"); + addCriterion("`status` between", value1, value2, "status"); return (Criteria) this; } public Criteria andStatusNotBetween(String value1, String value2) { - addCriterion("status not between", value1, value2, "status"); + addCriterion("`status` not between", value1, value2, "status"); return (Criteria) this; } diff --git a/backend/src/main/java/io/metersphere/base/mapper/ApiTestEnvironmentMapper.xml b/backend/src/main/java/io/metersphere/base/mapper/ApiTestEnvironmentMapper.xml index 8e2e5ebe45..de93fbca18 100644 --- a/backend/src/main/java/io/metersphere/base/mapper/ApiTestEnvironmentMapper.xml +++ b/backend/src/main/java/io/metersphere/base/mapper/ApiTestEnvironmentMapper.xml @@ -13,7 +13,7 @@ - + @@ -78,7 +78,7 @@ id, `name`, project_id, protocol, socket, `domain`, port - `variables`, headers, custom_data,hosts + `variables`, headers, config, `hosts` + SELECT r.*, t.name AS test_name, project.name AS project_name, user.name AS user_name + FROM api_test_report r JOIN api_test t ON r.test_id = t.id + LEFT JOIN project ON project.id = t.project_id + LEFT JOIN user ON user.id = r.user_id + + r.id = #{id} + + ORDER BY r.update_time DESC + - + + select param_value from system_parameter where param_key=#{smtp.account} + + \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestCaseMapper.xml b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestCaseMapper.xml index fd4af9a938..3fea349a04 100644 --- a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestCaseMapper.xml +++ b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestCaseMapper.xml @@ -224,6 +224,9 @@ #{id} + + and test_case.project_id=#{request.projectId} + order by diff --git a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.java b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.java index a72875174c..53e57c3256 100644 --- a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.java +++ b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.java @@ -11,4 +11,6 @@ public interface ExtTestPlanMapper { List list(@Param("request") QueryTestPlanRequest params); List listRelate(@Param("request") QueryTestPlanRequest params); + + List planList(@Param("request") QueryTestPlanRequest params); } diff --git a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.xml b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.xml index ff443b4b68..2b2a4a80c1 100644 --- a/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.xml +++ b/backend/src/main/java/io/metersphere/base/mapper/ext/ExtTestPlanMapper.xml @@ -143,6 +143,18 @@ + + + diff --git a/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java b/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java index 0844c34bb1..79172d7cc6 100644 --- a/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java +++ b/backend/src/main/java/io/metersphere/commons/constants/TestCaseConstants.java @@ -6,7 +6,7 @@ import java.util.stream.Collectors; public class TestCaseConstants { - public static final int MAX_NODE_DEPTH = 5; + public static final int MAX_NODE_DEPTH = 8; public enum Type { Functional("functional"), Performance("performance"), Aapi("api"); diff --git a/backend/src/main/java/io/metersphere/job/sechedule/ApiTestJob.java b/backend/src/main/java/io/metersphere/job/sechedule/ApiTestJob.java index bb4ce5bcb0..bb855d77e7 100644 --- a/backend/src/main/java/io/metersphere/job/sechedule/ApiTestJob.java +++ b/backend/src/main/java/io/metersphere/job/sechedule/ApiTestJob.java @@ -2,6 +2,7 @@ package io.metersphere.job.sechedule; import io.metersphere.api.dto.SaveAPITestRequest; import io.metersphere.api.service.APITestService; +import io.metersphere.notice.service.MailService; import io.metersphere.commons.constants.ReportTriggerMode; import io.metersphere.commons.constants.ScheduleGroup; import io.metersphere.commons.utils.CommonBeanFactory; @@ -13,7 +14,7 @@ import org.quartz.TriggerKey; public class ApiTestJob extends MsScheduleJob { private APITestService apiTestService; - + private MailService mailService; public ApiTestJob() { apiTestService = (APITestService) CommonBeanFactory.getBean(APITestService.class); } diff --git a/backend/src/main/java/io/metersphere/notice/controller/NoticeController.java b/backend/src/main/java/io/metersphere/notice/controller/NoticeController.java new file mode 100644 index 0000000000..34b210f703 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/controller/NoticeController.java @@ -0,0 +1,28 @@ +package io.metersphere.notice.controller; + +import io.metersphere.base.domain.Notice; +import io.metersphere.notice.controller.request.NoticeRequest; +import io.metersphere.notice.service.MailService; +import io.metersphere.notice.service.NoticeService; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +@RestController +@RequestMapping("notice") +public class NoticeController { + @Resource + private NoticeService noticeService; + + @PostMapping("/save") + public void saveNotice(@RequestBody NoticeRequest noticeRequest) { + noticeService.saveNotice(noticeRequest); + } + + @GetMapping("/query/{testId}") + public List queryNotice(@PathVariable String testId) { + return noticeService.queryNotice(testId); + } + +} diff --git a/backend/src/main/java/io/metersphere/notice/controller/request/NoticeRequest.java b/backend/src/main/java/io/metersphere/notice/controller/request/NoticeRequest.java new file mode 100644 index 0000000000..9206cfe465 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/controller/request/NoticeRequest.java @@ -0,0 +1,12 @@ +package io.metersphere.notice.controller.request; + +import io.metersphere.base.domain.Notice; +import lombok.Data; + +import java.util.List; + +@Data +public class NoticeRequest extends Notice { + private String testId; + private List notices; +} diff --git a/backend/src/main/java/io/metersphere/notice/domain/Mail.java b/backend/src/main/java/io/metersphere/notice/domain/Mail.java new file mode 100644 index 0000000000..126e903e93 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/domain/Mail.java @@ -0,0 +1,18 @@ +package io.metersphere.notice.domain; + +import lombok.Data; + +@Data +public class Mail { + // 发送给谁 + private String to; + + // 发送主题 + private String subject; + + // 发送内容 + private String content; + + // 附件地址 + private String filePath; +} diff --git a/backend/src/main/java/io/metersphere/notice/service/ApiAndPerformanceHelper.java b/backend/src/main/java/io/metersphere/notice/service/ApiAndPerformanceHelper.java new file mode 100644 index 0000000000..f74f04bc49 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/service/ApiAndPerformanceHelper.java @@ -0,0 +1,54 @@ +package io.metersphere.notice.service; + +import io.metersphere.api.dto.APIReportResult; +import io.metersphere.base.domain.ApiTestReportDetail; +import io.metersphere.base.domain.Schedule; +import io.metersphere.base.mapper.ApiTestReportDetailMapper; +import io.metersphere.base.mapper.ext.ExtApiTestReportMapper; +import io.metersphere.base.mapper.ext.ExtLoadTestMapper; +import io.metersphere.commons.constants.ScheduleGroup; +import io.metersphere.dto.LoadTestDTO; +import io.metersphere.service.ScheduleService; +import io.metersphere.track.request.testplan.QueryTestPlanRequest; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.nio.charset.StandardCharsets; +import java.util.List; + + +@Service +public class ApiAndPerformanceHelper { + @Resource + private ExtLoadTestMapper extLoadTestMapper; + @Resource + private ExtApiTestReportMapper extApiTestReportMapper; + @Resource + private ApiTestReportDetailMapper apiTestReportDetailMapper; + @Resource + private ScheduleService scheduleService; + + public APIReportResult getApi(String reportId) { + APIReportResult result = extApiTestReportMapper.get(reportId); + ApiTestReportDetail detail = apiTestReportDetailMapper.selectByPrimaryKey(reportId); + if (detail != null) { + result.setContent(new String(detail.getContent(), StandardCharsets.UTF_8)); + } + return result; + } + + public LoadTestDTO getPerformance(String testId) { + QueryTestPlanRequest request = new QueryTestPlanRequest(); + request.setId(testId); + List testDTOS = extLoadTestMapper.list(request); + if (!CollectionUtils.isEmpty(testDTOS)) { + LoadTestDTO loadTestDTO = testDTOS.get(0); + Schedule schedule = scheduleService.getScheduleByResource(loadTestDTO.getId(), ScheduleGroup.PERFORMANCE_TEST.name()); + loadTestDTO.setSchedule(schedule); + return loadTestDTO; + } + return null; + } +} + diff --git a/backend/src/main/java/io/metersphere/notice/service/MailService.java b/backend/src/main/java/io/metersphere/notice/service/MailService.java new file mode 100644 index 0000000000..8a27cd23b3 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/service/MailService.java @@ -0,0 +1,138 @@ +package io.metersphere.notice.service; + +import io.metersphere.api.dto.APIReportResult; +import io.metersphere.base.domain.Notice; +import io.metersphere.base.domain.SystemParameter; +import io.metersphere.commons.constants.ParamConstants; +import io.metersphere.commons.utils.EncryptUtils; +import io.metersphere.commons.utils.LogUtil; +import io.metersphere.dto.LoadTestDTO; +import io.metersphere.service.SystemParameterService; +import io.metersphere.service.UserService; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + + +import javax.annotation.Resource; +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +@Service +public class MailService { + @Resource + private ApiAndPerformanceHelper apiAndPerformanceHelper; + @Resource + private UserService userService; + @Resource + private SystemParameterService systemParameterService; + + public void sendHtml(String id, List notice, String status, String type) { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + List paramList = systemParameterService.getParamList(ParamConstants.Classify.MAIL.getValue()); + javaMailSender.setDefaultEncoding("UTF-8"); + javaMailSender.setProtocol("smtps"); + for (SystemParameter p : paramList) { + if (p.getParamKey().equals("smtp.host")) { + javaMailSender.setHost(p.getParamValue()); + } + if (p.getParamKey().equals("smtp.port")) { + javaMailSender.setPort(Integer.parseInt(p.getParamValue())); + } + if (p.getParamKey().equals("smtp.account")) { + javaMailSender.setUsername(p.getParamValue()); + } + if (p.getParamKey().equals("smtp.password")) { + javaMailSender.setPassword(EncryptUtils.aesDecrypt(p.getParamValue()).toString()); + } + } + Properties props = new Properties(); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + props.put("mail.smtp.timeout", "30000"); + props.put("mail.smtp.connectiontimeout", "5000"); + javaMailSender.setJavaMailProperties(props); + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + String testName = ""; + if (type.equals("api")) { + APIReportResult reportResult = apiAndPerformanceHelper.getApi(id); + testName = reportResult.getTestName(); + } else if (type.equals("performance")) { + LoadTestDTO performanceResult = apiAndPerformanceHelper.getPerformance(id); + testName = performanceResult.getName(); + } + String html1 = "\n" + + "\n" + + "\n" + + " \n" + + " MeterSphere\n" + + "\n" + + "\n" + + "
\n" + + "

" + type + "定时任务结果通知

\n" + + "

尊敬的用户:您好,您所执行的" + testName + "运行失败,请点击报告链接查看

\n" + + "
\n" + + "\n" + + ""; + String html2 = "\n" + + "\n" + + "\n" + + " \n" + + " MeterSphere\n" + + "\n" + + "\n" + + "
\n" + + "

" + type + "定时任务结果通知

\n" + + "

尊敬的用户:您好," + testName + "运行成功,请点击报告链接查看

\n" + + "
\n" + + "\n" + + ""; + try { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true); + helper.setFrom(javaMailSender.getUsername()); + helper.setSubject("MeterSphere定时任务结果通知"); + String users[] = {}; + List successEmailList = new ArrayList<>(); + List failEmailList = new ArrayList<>(); + if (notice.size() > 0) { + for (Notice n : notice) { + if (n.getEnable().equals("true") && n.getEvent().equals("执行成功")) { + successEmailList = userService.queryEmail(n.getNames()); + } + if (n.getEnable().equals("true") && n.getEvent().equals("执行失败")) { + failEmailList = userService.queryEmail(n.getNames()); + } + + } + } else { + LogUtil.error("Recipient information is empty"); + } + + if (status.equals("Success")) { + users = successEmailList.toArray(new String[successEmailList.size()]); + helper.setText(html2, true); + } else { + users = failEmailList.toArray(new String[failEmailList.size()]); + helper.setText(html1, true); + + } + helper.setTo(users); + + } catch (MessagingException e) { + e.printStackTrace(); + } + try { + javaMailSender.send(mimeMessage); + } catch (MailException e) { + e.printStackTrace(); + } + } + + +} + diff --git a/backend/src/main/java/io/metersphere/notice/service/NoticeService.java b/backend/src/main/java/io/metersphere/notice/service/NoticeService.java new file mode 100644 index 0000000000..07cc829ce8 --- /dev/null +++ b/backend/src/main/java/io/metersphere/notice/service/NoticeService.java @@ -0,0 +1,107 @@ +package io.metersphere.notice.service; + +import io.metersphere.base.domain.Notice; +import io.metersphere.base.domain.NoticeExample; +import io.metersphere.base.mapper.NoticeMapper; +import io.metersphere.notice.controller.request.NoticeRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +@Service +public class NoticeService { + @Resource + private NoticeMapper noticeMapper; + + public void saveNotice(NoticeRequest noticeRequest) { + Notice notice = new Notice(); + NoticeExample example = new NoticeExample(); + example.createCriteria().andTestIdEqualTo(noticeRequest.getTestId()); + List notices = noticeMapper.selectByExample(example); + if (notices.size() > 0) { + noticeMapper.deleteByExample(example); + noticeRequest.getNotices().forEach(n -> { + if (n.getNames().length > 0) { + for (String x : n.getNames()) { + notice.setEvent(n.getEvent()); + notice.setEmail(n.getEmail()); + notice.setEnable(n.getEnable()); + notice.setTestId(noticeRequest.getTestId()); + notice.setName(x); + noticeMapper.insert(notice); + } + } else { + notice.setEvent(n.getEvent()); + notice.setEmail(n.getEmail()); + notice.setEnable(n.getEnable()); + notice.setTestId(noticeRequest.getTestId()); + notice.setName(""); + noticeMapper.insert(notice); + } + }); + } else { + noticeRequest.getNotices().forEach(n -> { + if (n.getNames().length > 0) { + for (String x : n.getNames()) { + notice.setEvent(n.getEvent()); + notice.setEmail(n.getEmail()); + notice.setEnable(n.getEnable()); + notice.setTestId(noticeRequest.getTestId()); + notice.setName(x); + noticeMapper.insert(notice); + } + } else { + notice.setEvent(n.getEvent()); + notice.setEmail(n.getEmail()); + notice.setEnable(n.getEnable()); + notice.setTestId(noticeRequest.getTestId()); + notice.setName(""); + noticeMapper.insert(notice); + } + }); + } + } + + public List queryNotice(String id) { + NoticeExample example = new NoticeExample(); + example.createCriteria().andTestIdEqualTo(id); + List notices = noticeMapper.selectByExample(example); + List notice = new ArrayList<>(); + List success = new ArrayList<>(); + List fail = new ArrayList<>(); + String[] successArray = new String[success.size()]; + String[] failArray = new String[fail.size()]; + Notice notice1 = new Notice(); + Notice notice2 = new Notice(); + if (notices.size() > 0) { + for (Notice n : notices) { + if (n.getEvent().equals("执行成功")) { + success.add(n.getName()); + notice1.setEnable(n.getEnable()); + notice1.setTestId(id); + notice1.setEvent(n.getEvent()); + notice1.setEmail(n.getEmail()); + } + if (n.getEvent().equals("执行失败")) { + fail.add(n.getName()); + notice2.setEnable(n.getEnable()); + notice2.setTestId(id); + notice2.setEvent(n.getEvent()); + notice2.setEmail(n.getEmail()); + } + } + successArray = success.toArray(new String[success.size()]); + failArray = fail.toArray(new String[fail.size()]); + notice1.setNames(successArray); + notice2.setNames(failArray); + notice.add(notice1); + notice.add(notice2); + } + return notice; + } + +} diff --git a/backend/src/main/java/io/metersphere/performance/parse/xml/reader/jmx/JmeterDocumentParser.java b/backend/src/main/java/io/metersphere/performance/parse/xml/reader/jmx/JmeterDocumentParser.java index 5cdd5b3a54..3b19b1b30b 100644 --- a/backend/src/main/java/io/metersphere/performance/parse/xml/reader/jmx/JmeterDocumentParser.java +++ b/backend/src/main/java/io/metersphere/performance/parse/xml/reader/jmx/JmeterDocumentParser.java @@ -439,6 +439,14 @@ public class JmeterDocumentParser implements DocumentParser { item.appendChild(ele.getOwnerDocument().createTextNode(context.getProperty("timeout").toString())); } } + // 增加一个response_timeout,避免目标网站不反回结果导致测试不能结束 + if (item instanceof Element && nodeNameEquals(item, STRING_PROP) + && StringUtils.equals(((Element) item).getAttribute("name"), "HTTPSampler.response_timeout")) { + if (context.getProperty("responseTimeout") != null) { + removeChildren(item); + item.appendChild(ele.getOwnerDocument().createTextNode(context.getProperty("responseTimeout").toString())); + } + } } } diff --git a/backend/src/main/java/io/metersphere/performance/service/PerformanceTestService.java b/backend/src/main/java/io/metersphere/performance/service/PerformanceTestService.java index 64e71fa08a..75cbb42b16 100644 --- a/backend/src/main/java/io/metersphere/performance/service/PerformanceTestService.java +++ b/backend/src/main/java/io/metersphere/performance/service/PerformanceTestService.java @@ -19,6 +19,8 @@ import io.metersphere.dto.LoadTestDTO; import io.metersphere.dto.ScheduleDao; import io.metersphere.i18n.Translator; import io.metersphere.job.sechedule.PerformanceTestJob; +import io.metersphere.notice.service.MailService; +import io.metersphere.notice.service.NoticeService; import io.metersphere.performance.engine.Engine; import io.metersphere.performance.engine.EngineFactory; import io.metersphere.service.FileService; @@ -80,6 +82,10 @@ public class PerformanceTestService { private TestCaseMapper testCaseMapper; @Resource private TestCaseService testCaseService; + @Resource + private NoticeService noticeService; + @Resource + private MailService mailService; public List list(QueryTestPlanRequest request) { request.setOrders(ServiceUtils.getDefaultOrder(request.getOrders())); @@ -231,7 +237,10 @@ public class PerformanceTestService { } startEngine(loadTest, engine, request.getTriggerMode()); - + if (request.getTriggerMode().equals("SCHEDULE")) { + List notice = noticeService.queryNotice(request.getId()); + mailService.sendHtml(engine.getReportId(), notice, "success", "performance"); + } return engine.getReportId(); } diff --git a/backend/src/main/java/io/metersphere/service/SystemParameterService.java b/backend/src/main/java/io/metersphere/service/SystemParameterService.java index 3fdc9e461f..4042a65832 100644 --- a/backend/src/main/java/io/metersphere/service/SystemParameterService.java +++ b/backend/src/main/java/io/metersphere/service/SystemParameterService.java @@ -3,6 +3,7 @@ package io.metersphere.service; import io.metersphere.base.domain.SystemParameter; import io.metersphere.base.domain.SystemParameterExample; import io.metersphere.base.mapper.SystemParameterMapper; +import io.metersphere.base.mapper.ext.ExtSystemParameterMapper; import io.metersphere.commons.constants.ParamConstants; import io.metersphere.commons.exception.MSException; import io.metersphere.commons.utils.EncryptUtils; @@ -24,7 +25,12 @@ public class SystemParameterService { @Resource private SystemParameterMapper systemParameterMapper; + @Resource + private ExtSystemParameterMapper extSystemParameterMapper; + public String searchEmail(){ + return extSystemParameterMapper.email(); + } public String getSystemLanguage() { String result = StringUtils.EMPTY; SystemParameterExample example = new SystemParameterExample(); diff --git a/backend/src/main/java/io/metersphere/service/UserService.java b/backend/src/main/java/io/metersphere/service/UserService.java index ebeb4155b9..98312d541a 100644 --- a/backend/src/main/java/io/metersphere/service/UserService.java +++ b/backend/src/main/java/io/metersphere/service/UserService.java @@ -61,6 +61,9 @@ public class UserService { @Resource private WorkspaceService workspaceService; + public List queryEmail(String[] names){ + return extUserMapper.queryEmails(names); + } public UserDTO insert(UserRequest user) { checkUserParam(user); // diff --git a/backend/src/main/java/io/metersphere/track/controller/TestCaseController.java b/backend/src/main/java/io/metersphere/track/controller/TestCaseController.java index 1bf4427b6d..b74ed6424c 100644 --- a/backend/src/main/java/io/metersphere/track/controller/TestCaseController.java +++ b/backend/src/main/java/io/metersphere/track/controller/TestCaseController.java @@ -99,10 +99,10 @@ public class TestCaseController { return testCaseService.deleteTestCase(testCaseId); } - @PostMapping("/import/{projectId}") + @PostMapping("/import/{projectId}/{userId}") @RequiresRoles(value = {RoleConstants.TEST_USER, RoleConstants.TEST_MANAGER}, logical = Logical.OR) - public ExcelResponse testCaseImport(MultipartFile file, @PathVariable String projectId) throws NoSuchFieldException { - return testCaseService.testCaseImport(file, projectId); + public ExcelResponse testCaseImport(MultipartFile file, @PathVariable String projectId,@PathVariable String userId) throws NoSuchFieldException { + return testCaseService.testCaseImport(file, projectId,userId); } @GetMapping("/export/template") @@ -110,6 +110,11 @@ public class TestCaseController { public void testCaseTemplateExport(HttpServletResponse response) { testCaseService.testCaseTemplateExport(response); } + @GetMapping("/export/xmindTemplate") + @RequiresRoles(value = {RoleConstants.TEST_USER, RoleConstants.TEST_MANAGER}, logical = Logical.OR) + public void xmindTemplate(HttpServletResponse response) { + testCaseService.testCaseXmindTemplateExport(response); + } @PostMapping("/export/testcase") @RequiresRoles(value = {RoleConstants.TEST_USER, RoleConstants.TEST_MANAGER}, logical = Logical.OR) diff --git a/backend/src/main/java/io/metersphere/track/controller/TestCaseIssuesController.java b/backend/src/main/java/io/metersphere/track/controller/TestCaseIssuesController.java index 79a51257f5..905b408b5e 100644 --- a/backend/src/main/java/io/metersphere/track/controller/TestCaseIssuesController.java +++ b/backend/src/main/java/io/metersphere/track/controller/TestCaseIssuesController.java @@ -1,6 +1,7 @@ package io.metersphere.track.controller; import io.metersphere.base.domain.Issues; +import io.metersphere.track.domain.TapdUser; import io.metersphere.track.service.IssuesService; import io.metersphere.track.request.testcase.IssuesRequest; import org.springframework.web.bind.annotation.*; @@ -35,4 +36,9 @@ public class TestCaseIssuesController { issuesService.closeLocalIssue(id); } + @GetMapping("/tapd/user/{caseId}") + public List getTapdUsers(@PathVariable String caseId) { + return issuesService.getTapdProjectUsers(caseId); + } + } diff --git a/backend/src/main/java/io/metersphere/track/controller/TestPlanController.java b/backend/src/main/java/io/metersphere/track/controller/TestPlanController.java index 4f43c564d1..6fbea3d40d 100644 --- a/backend/src/main/java/io/metersphere/track/controller/TestPlanController.java +++ b/backend/src/main/java/io/metersphere/track/controller/TestPlanController.java @@ -47,7 +47,7 @@ public class TestPlanController { QueryTestPlanRequest request = new QueryTestPlanRequest(); request.setWorkspaceId(workspaceId); request.setProjectId(projectId); - return testPlanService.listTestPlan(request); + return testPlanService.listTestPlanByProject(request); } @PostMapping("/list/all") diff --git a/backend/src/main/java/io/metersphere/track/domain/TapdUser.java b/backend/src/main/java/io/metersphere/track/domain/TapdUser.java new file mode 100644 index 0000000000..53d193c088 --- /dev/null +++ b/backend/src/main/java/io/metersphere/track/domain/TapdUser.java @@ -0,0 +1,12 @@ +package io.metersphere.track.domain; + +import lombok.Data; +import java.io.Serializable; +import java.util.List; + +@Data +public class TapdUser implements Serializable { + private List roleId; + private String name; + private String user; +} diff --git a/backend/src/main/java/io/metersphere/track/request/testcase/IssuesRequest.java b/backend/src/main/java/io/metersphere/track/request/testcase/IssuesRequest.java index e5658e3684..88fb097185 100644 --- a/backend/src/main/java/io/metersphere/track/request/testcase/IssuesRequest.java +++ b/backend/src/main/java/io/metersphere/track/request/testcase/IssuesRequest.java @@ -3,6 +3,8 @@ package io.metersphere.track.request.testcase; import lombok.Getter; import lombok.Setter; +import java.util.List; + @Getter @Setter public class IssuesRequest { @@ -10,4 +12,5 @@ public class IssuesRequest { private String content; private String projectId; private String testCaseId; + private List tapdUsers; } diff --git a/backend/src/main/java/io/metersphere/track/request/testcase/QueryTestPlanRequest.java b/backend/src/main/java/io/metersphere/track/request/testcase/QueryTestPlanRequest.java index d03e3da703..6997e42691 100644 --- a/backend/src/main/java/io/metersphere/track/request/testcase/QueryTestPlanRequest.java +++ b/backend/src/main/java/io/metersphere/track/request/testcase/QueryTestPlanRequest.java @@ -21,4 +21,6 @@ public class QueryTestPlanRequest extends TestPlan { private Map> filters; private Map combine; + + private String projectId; } diff --git a/backend/src/main/java/io/metersphere/track/request/testcase/TestCaseBatchRequest.java b/backend/src/main/java/io/metersphere/track/request/testcase/TestCaseBatchRequest.java index 2faea63877..aa9b8e9871 100644 --- a/backend/src/main/java/io/metersphere/track/request/testcase/TestCaseBatchRequest.java +++ b/backend/src/main/java/io/metersphere/track/request/testcase/TestCaseBatchRequest.java @@ -12,4 +12,5 @@ import java.util.List; public class TestCaseBatchRequest extends TestCaseWithBLOBs { private List ids; private List orders; + private String projectId; } diff --git a/backend/src/main/java/io/metersphere/track/service/IssuesService.java b/backend/src/main/java/io/metersphere/track/service/IssuesService.java index 3426bd6084..b46ffc2496 100644 --- a/backend/src/main/java/io/metersphere/track/service/IssuesService.java +++ b/backend/src/main/java/io/metersphere/track/service/IssuesService.java @@ -1,6 +1,7 @@ package io.metersphere.track.service; import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import io.metersphere.base.domain.*; import io.metersphere.base.mapper.IssuesMapper; @@ -17,6 +18,7 @@ import io.metersphere.controller.ResultHolder; import io.metersphere.controller.request.IntegrationRequest; import io.metersphere.service.IntegrationService; import io.metersphere.service.ProjectService; +import io.metersphere.track.domain.TapdUser; import io.metersphere.track.request.testcase.IssuesRequest; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; @@ -188,10 +190,17 @@ public class IssuesService { MSException.throwException("未关联Tapd 项目ID"); } + List tapdUsers = issuesRequest.getTapdUsers(); + String usersStr = String.join(";", tapdUsers); + + String username = SessionUtils.getUser().getName(); + MultiValueMap paramMap = new LinkedMultiValueMap<>(); paramMap.add("title", issuesRequest.getTitle()); paramMap.add("workspace_id", tapdId); paramMap.add("description", issuesRequest.getContent()); + paramMap.add("reporter", username); + paramMap.add("current_owner", usersStr); ResultHolder result = call(url, HttpMethod.POST, paramMap); @@ -535,4 +544,19 @@ public class IssuesService { issuesMapper.updateByPrimaryKeySelective(issues); } + public List getTapdProjectUsers(String caseId) { + List users = new ArrayList<>(); + String projectId = getTapdProjectId(caseId); + String url = "https://api.tapd.cn/workspaces/users?workspace_id=" + projectId; + ResultHolder call = call(url); + String listJson = JSON.toJSONString(call.getData()); + JSONArray jsonArray = JSON.parseArray(listJson); + for (int i = 0; i < jsonArray.size(); i++) { + JSONObject o = jsonArray.getJSONObject(i); + TapdUser user = o.getObject("UserWorkspace", TapdUser.class); + users.add(user); + } + return users; + } + } diff --git a/backend/src/main/java/io/metersphere/track/service/TestCaseService.java b/backend/src/main/java/io/metersphere/track/service/TestCaseService.java index 314ac151e3..c379736508 100644 --- a/backend/src/main/java/io/metersphere/track/service/TestCaseService.java +++ b/backend/src/main/java/io/metersphere/track/service/TestCaseService.java @@ -27,6 +27,7 @@ import io.metersphere.i18n.Translator; import io.metersphere.track.dto.TestCaseDTO; import io.metersphere.track.request.testcase.QueryTestCaseRequest; import io.metersphere.track.request.testcase.TestCaseBatchRequest; +import io.metersphere.xmind.XmindToTestCaseParser; import org.apache.commons.lang3.StringUtils; import org.apache.ibatis.session.ExecutorType; import org.apache.ibatis.session.SqlSession; @@ -38,6 +39,8 @@ import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.URLEncoder; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -67,9 +70,6 @@ public class TestCaseService { @Resource TestCaseNodeService testCaseNodeService; - @Resource - UserMapper userMapper; - @Resource UserRoleMapper userRoleMapper; @@ -236,10 +236,10 @@ public class TestCaseService { return projectMapper.selectByPrimaryKey(testCaseWithBLOBs.getProjectId()); } - public ExcelResponse testCaseImport(MultipartFile file, String projectId) { + + public ExcelResponse testCaseImport(MultipartFile multipartFile, String projectId, String userId) { ExcelResponse excelResponse = new ExcelResponse(); - String currentWorkspaceId = SessionUtils.getCurrentWorkspaceId(); QueryTestCaseRequest queryTestCaseRequest = new QueryTestCaseRequest(); queryTestCaseRequest.setProjectId(projectId); @@ -247,25 +247,44 @@ public class TestCaseService { Set testCaseNames = testCases.stream() .map(TestCase::getName) .collect(Collectors.toSet()); - - UserRoleExample userRoleExample = new UserRoleExample(); - userRoleExample.createCriteria() - .andRoleIdIn(Arrays.asList(RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER)) - .andSourceIdEqualTo(currentWorkspaceId); - - Set userIds = userRoleMapper.selectByExample(userRoleExample).stream().map(UserRole::getUserId).collect(Collectors.toSet()); - - EasyExcelListener easyExcelListener = null; List> errList = null; - try { - easyExcelListener = new TestCaseDataListener(this, projectId, testCaseNames, userIds); - EasyExcelFactory.read(file.getInputStream(), TestCaseExcelData.class, easyExcelListener).sheet().doRead(); - errList = easyExcelListener.getErrList(); - } catch (Exception e) { - LogUtil.error(e.getMessage(), e); - MSException.throwException(e.getMessage()); - } finally { - easyExcelListener.close(); + + if (multipartFile.getOriginalFilename().endsWith(".xmind")) { + try { + errList = new ArrayList<>(); + String processLog = new XmindToTestCaseParser(this, userId, projectId, testCaseNames).importXmind(multipartFile); + if (!StringUtils.isEmpty(processLog)) { + excelResponse.setSuccess(false); + ExcelErrData excelErrData = new ExcelErrData(null, 1, Translator.get("upload_fail")+":"+ processLog); + errList.add(excelErrData); + excelResponse.setErrList(errList); + } else { + excelResponse.setSuccess(true); + } + } catch (Exception e) { + e.printStackTrace(); + } + + } else { + UserRoleExample userRoleExample = new UserRoleExample(); + userRoleExample.createCriteria() + .andRoleIdIn(Arrays.asList(RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER)) + .andSourceIdEqualTo(currentWorkspaceId); + + Set userIds = userRoleMapper.selectByExample(userRoleExample).stream().map(UserRole::getUserId).collect(Collectors.toSet()); + + EasyExcelListener easyExcelListener = null; + try { + easyExcelListener = new TestCaseDataListener(this, projectId, testCaseNames, userIds); + EasyExcelFactory.read(multipartFile.getInputStream(), TestCaseExcelData.class, easyExcelListener).sheet().doRead(); + errList = easyExcelListener.getErrList(); + } catch (Exception e) { + LogUtil.error(e.getMessage(), e); + MSException.throwException(e.getMessage()); + } finally { + easyExcelListener.close(); + } + } //如果包含错误信息就导出错误信息 if (!errList.isEmpty()) { @@ -309,6 +328,34 @@ public class TestCaseService { } } + public void download(HttpServletResponse res) throws IOException { + // 发送给客户端的数据 + byte[] buff = new byte[1024]; + try (OutputStream outputStream = res.getOutputStream(); + BufferedInputStream bis = new BufferedInputStream(this.getClass().getResourceAsStream("/template/testcase.xmind"));) { + int i = bis.read(buff); + while (i != -1) { + outputStream.write(buff, 0, buff.length); + outputStream.flush(); + i = bis.read(buff); + } + } catch (Exception ex) { + LogUtil.error(ex.getMessage()); + MSException.throwException("下载思维导图模版失败"); + } + } + + public void testCaseXmindTemplateExport(HttpServletResponse response) { + try { + response.setContentType("application/octet-stream"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode("思维导图用例模版", "UTF-8") + ".xmind"); + download(response); + } catch (Exception ex) { + + } + } + private List generateExportTemplate() { List list = new ArrayList<>(); StringBuilder path = new StringBuilder(""); @@ -406,18 +453,18 @@ public class TestCaseService { } else if (t.getMethod().equals("auto") && t.getType().equals("api")) { data.setStepDesc(""); data.setStepResult(""); - if(t.getTestId().equals("other")){ + if (t.getTestId().equals("other")) { data.setRemark(t.getOtherTestName()); - }else{ + } else { data.setRemark(t.getApiName()); } } else if (t.getMethod().equals("auto") && t.getType().equals("performance")) { data.setStepDesc(""); data.setStepResult(""); - if(t.getTestId().equals("other")){ + if (t.getTestId().equals("other")) { data.setRemark(t.getOtherTestName()); - }else{ + } else { data.setRemark(t.getPerformName()); } } diff --git a/backend/src/main/java/io/metersphere/track/service/TestPlanService.java b/backend/src/main/java/io/metersphere/track/service/TestPlanService.java index c7b378f935..bdd198c510 100644 --- a/backend/src/main/java/io/metersphere/track/service/TestPlanService.java +++ b/backend/src/main/java/io/metersphere/track/service/TestPlanService.java @@ -146,6 +146,10 @@ public class TestPlanService { return extTestPlanMapper.list(request); } + public List listTestPlanByProject(QueryTestPlanRequest request) { + return extTestPlanMapper.planList(request); + } + public void testPlanRelevance(PlanCaseRelevanceRequest request) { List testCaseIds = request.getTestCaseIds(); diff --git a/backend/src/main/java/io/metersphere/xmind/XmindToTestCaseParser.java b/backend/src/main/java/io/metersphere/xmind/XmindToTestCaseParser.java new file mode 100644 index 0000000000..135fe835f0 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/XmindToTestCaseParser.java @@ -0,0 +1,304 @@ +package io.metersphere.xmind; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.metersphere.base.domain.TestCaseWithBLOBs; +import io.metersphere.commons.constants.TestCaseConstants; +import io.metersphere.commons.utils.BeanUtils; +import io.metersphere.commons.utils.LogUtil; +import io.metersphere.excel.domain.TestCaseExcelData; +import io.metersphere.i18n.Translator; +import io.metersphere.track.service.TestCaseService; +import io.metersphere.xmind.parser.XmindParser; +import io.metersphere.xmind.parser.domain.Attached; +import io.metersphere.xmind.parser.domain.JsonRootBean; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 数据转换 + * 1 解析Xmind文件 XmindParser.parseJson + * 2 解析后的JSON 转成测试用例 + */ +public class XmindToTestCaseParser { + + private TestCaseService testCaseService; + private String maintainer; + private String projectId; + private StringBuffer process; // 过程校验记录 + private Set testCaseNames; + + public XmindToTestCaseParser(TestCaseService testCaseService, String userId, String projectId, Set testCaseNames) { + this.testCaseService = testCaseService; + this.maintainer = userId; + this.projectId = projectId; + this.testCaseNames = testCaseNames; + testCaseWithBLOBs = new LinkedList<>(); + xmindDataList = new ArrayList<>(); + process = new StringBuffer(); + } + + // 案例详情 + private List testCaseWithBLOBs; + // 用于重复对比 + protected List xmindDataList; + + // 递归处理案例数据 + private void makeXmind(StringBuffer processBuffer, Attached parent, int level, String nodePath, List attacheds) { + for (Attached item : attacheds) { + if (isBlack(item.getTitle(), "(?:tc|tc)")) { // 用例 + item.setParent(parent); + this.newTestCase(item.getTitle(), parent.getPath(), item.getChildren() != null ? item.getChildren().getAttached() : null); + } else { + nodePath = parent.getPath() + "/" + item.getTitle(); + item.setPath(nodePath); + if (item.getChildren() != null && !item.getChildren().getAttached().isEmpty()) { + item.setParent(parent); + makeXmind(processBuffer, item, level + 1, nodePath, item.getChildren().getAttached()); + } + } + } + } + + private boolean isBlack(String str, String regex) { + // regex = "(?:tc:|tc:)" + if (StringUtils.isEmpty(str) || StringUtils.isEmpty(regex)) + return false; + Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + Matcher result = pattern.matcher(str); + return result.find(); + } + + private String replace(String str, String regex) { + if (StringUtils.isEmpty(str) || StringUtils.isEmpty(regex)) + return str; + Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + Matcher result = pattern.matcher(str); + str = result.replaceAll(""); + return str; + } + + // 获取步骤数据 + public String getSteps(List attacheds) { + JSONArray jsonArray = new JSONArray(); + for (int i = 0; i < attacheds.size(); i++) { + // 保持插入顺序,判断用例是否有相同的steps + JSONObject step = new JSONObject(true); + step.put("num", i + 1); + step.put("desc", attacheds.get(i).getTitle()); + if (attacheds.get(i).getChildren() != null && !attacheds.get(i).getChildren().getAttached().isEmpty()) { + step.put("result", attacheds.get(i).getChildren().getAttached().get(0).getTitle()); + } + jsonArray.add(step); + } + return jsonArray.toJSONString(); + } + + // 初始化一个用例 + private void newTestCase(String title, String nodePath, List attacheds) { + TestCaseWithBLOBs testCase = new TestCaseWithBLOBs(); + testCase.setProjectId(projectId); + testCase.setMaintainer(maintainer); + testCase.setPriority("P0"); + testCase.setMethod("manual"); + testCase.setType("functional"); + + String tc = title.replace(":", ":"); + String tcArr[] = tc.split(":"); + if (tcArr.length != 2) { + process.append(Translator.get("test_case_name") + "【 " + title + " 】" + Translator.get("incorrect_format")); + return; + } + // 用例名称 + testCase.setName(this.replace(tcArr[1], "tc:|tc:|tc")); + + if (!nodePath.startsWith("/")) { + nodePath = "/" + nodePath; + } + if (nodePath.endsWith("/")) { + nodePath = nodePath.substring(0, nodePath.length() - 1); + } + testCase.setNodePath(nodePath); + + // 用例等级和用例性质处理 + if (tcArr[0].indexOf("-") != -1) { + String otArr[] = tcArr[0].split("-"); + for (int i = 0; i < otArr.length; i++) { + if (otArr[i].startsWith("P") || otArr[i].startsWith("p")) { + testCase.setPriority(otArr[i].toUpperCase()); + } else if (otArr[i].endsWith("功能测试")) { + testCase.setType("functional"); + } else if (otArr[i].endsWith("性能测试")) { + testCase.setType("performance"); + } else if (otArr[i].endsWith("接口测试")) { + testCase.setType("api"); + } + } + } + // 测试步骤处理 + List steps = new LinkedList<>(); + if (attacheds != null && !attacheds.isEmpty()) { + attacheds.forEach(item -> { + if (isBlack(item.getTitle(), "(?:pc:|pc:)")) { + testCase.setPrerequisite(replace(item.getTitle(), "(?:pc:|pc:)")); + } else if (isBlack(item.getTitle(), "(?:rc:|rc:)")) { + testCase.setRemark(replace(item.getTitle(), "(?:rc:|rc:)")); + } else { + steps.add(item); + } + }); + } + if (!steps.isEmpty()) { + testCase.setSteps(this.getSteps(steps)); + } else { + JSONArray jsonArray = new JSONArray(); + // 保持插入顺序,判断用例是否有相同的steps + JSONObject step = new JSONObject(true); + step.put("num", 1); + step.put("desc", ""); + step.put("result", ""); + jsonArray.add(step); + testCase.setSteps(jsonArray.toJSONString()); + } + TestCaseExcelData compartData = new TestCaseExcelData(); + BeanUtils.copyBean(compartData, testCase); + if (xmindDataList.contains(compartData)) { + process.append(Translator.get("test_case_already_exists_excel") + ":" + testCase.getName() + "; "); + } else if (validate(testCase)) { + testCase.setId(UUID.randomUUID().toString()); + testCase.setCreateTime(System.currentTimeMillis()); + testCase.setUpdateTime(System.currentTimeMillis()); + testCaseWithBLOBs.add(testCase); + } + xmindDataList.add(compartData); + } + + //获取流文件 + private static void inputStreamToFile(InputStream ins, File file) { + try (OutputStream os = new FileOutputStream(file);) { + int bytesRead = 0; + byte[] buffer = new byte[8192]; + while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) { + os.write(buffer, 0, bytesRead); + } + } catch (Exception e) { + LogUtil.error(e.getMessage()); + } + } + + /** + * MultipartFile 转 File + * + * @param file + * @throws Exception + */ + private File multipartFileToFile(MultipartFile file) throws Exception { + if (file != null && file.getSize() > 0) { + try (InputStream ins = file.getInputStream();) { + File toFile = new File(file.getOriginalFilename()); + inputStreamToFile(ins, toFile); + return toFile; + } + } + return null; + } + + + public boolean validate(TestCaseWithBLOBs data) { + String nodePath = data.getNodePath(); + StringBuilder stringBuilder = new StringBuilder(); + + if (nodePath != null) { + String[] nodes = nodePath.split("/"); + if (nodes.length > TestCaseConstants.MAX_NODE_DEPTH + 1) { + stringBuilder.append(Translator.get("test_case_node_level_tip") + + TestCaseConstants.MAX_NODE_DEPTH + Translator.get("test_case_node_level") + "; "); + } + for (int i = 0; i < nodes.length; i++) { + if (i != 0 && org.apache.commons.lang3.StringUtils.equals(nodes[i].trim(), "")) { + stringBuilder.append(Translator.get("module_not_null") + "; "); + break; + } + } + } + + if (org.apache.commons.lang3.StringUtils.equals(data.getType(), TestCaseConstants.Type.Functional.getValue()) && org.apache.commons.lang3.StringUtils.equals(data.getMethod(), TestCaseConstants.Method.Auto.getValue())) { + stringBuilder.append(Translator.get("functional_method_tip") + "; "); + } + + if (testCaseNames.contains(data.getName())) { + boolean dbExist = testCaseService.exist(data); + boolean excelExist = false; + + if (dbExist) { + // db exist + stringBuilder.append(Translator.get("test_case_already_exists_excel") + ":" + data.getName() + "; "); + } + + } else { + testCaseNames.add(data.getName()); + } + if (!StringUtils.isEmpty(stringBuilder.toString())) { + process.append(stringBuilder.toString()); + return false; + } + return true; + } + + // 导入思维导图处理 + public String importXmind(MultipartFile multipartFile) { + StringBuffer processBuffer = new StringBuffer(); + File file = null; + try { + file = multipartFileToFile(multipartFile); + if (file == null || !file.exists()) + return Translator.get("incorrect_format"); + + // 获取思维导图内容 + String content = XmindParser.parseJson(file); + if (StringUtils.isEmpty(content) || content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length == 1) { + return Translator.get("import_xmind_not_found"); + } + if (!StringUtils.isEmpty(content) && content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length > 500) { + return Translator.get("import_xmind_count_error"); + } + JsonRootBean root = JSON.parseObject(content, JsonRootBean.class); + + if (root != null && root.getRootTopic() != null && root.getRootTopic().getChildren() != null) { + // 判断是模块还是用例 + for (Attached item : root.getRootTopic().getChildren().getAttached()) { + if (isBlack(item.getTitle(), "(?:tc:|tc:|tc)")) { // 用例 + return replace(item.getTitle(), "(?:tc:|tc:|tc)") + ":" + Translator.get("test_case_create_module_fail"); + } else { + item.setPath(item.getTitle()); + if (item.getChildren() != null && !item.getChildren().getAttached().isEmpty()) { + item.setPath(item.getTitle()); + makeXmind(processBuffer, item, 1, item.getPath(), item.getChildren().getAttached()); + } + } + } + } + if (StringUtils.isEmpty(process.toString()) && !testCaseWithBLOBs.isEmpty()) { + testCaseService.saveImportData(testCaseWithBLOBs, projectId); + } + } catch (Exception ex) { + processBuffer.append(Translator.get("incorrect_format")); + LogUtil.error(ex.getMessage()); + ex.printStackTrace(); + } finally { + if (file != null) + file.delete(); + testCaseWithBLOBs.clear(); + } + return process.toString(); + } +} diff --git a/backend/src/main/java/io/metersphere/xmind/parser/XmindLegacy.java b/backend/src/main/java/io/metersphere/xmind/parser/XmindLegacy.java new file mode 100644 index 0000000000..1a6ae73728 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/XmindLegacy.java @@ -0,0 +1,79 @@ +package io.metersphere.xmind.parser; + +import org.dom4j.*; +import org.json.JSONObject; +import org.json.XML; + +import java.io.IOException; +import java.util.List; + +public class XmindLegacy { + + /** + * 返回content.xml和comments.xml合并后的json + * + * @param xmlContent + * @param xmlComments + * @return + * @throws IOException + * @throws DocumentException + */ + public static String getContent(String xmlContent, String xmlComments) throws IOException, DocumentException { + // 删除content.xml里面不能识别的字符串 + xmlContent = xmlContent.replace("xmlns=\"urn:xmind:xmap:xmlns:content:2.0\"", ""); + xmlContent = xmlContent.replace("xmlns:fo=\"http://www.w3.org/1999/XSL/Format\"", ""); + // 删除节点 + xmlContent = xmlContent.replace("", ""); + xmlContent = xmlContent.replace("", ""); + + // 去除title中svg:width属性 + xmlContent = xmlContent.replaceAll("", "<title>"); + + Document document = DocumentHelper.parseText(xmlContent);// 读取XML文件,获得document对象 + Element root = document.getRootElement(); + List<Node> topics = root.selectNodes("//topic"); + + if (xmlComments != null) { + // 删除comments.xml里面不能识别的字符串 + xmlComments = xmlComments.replace("xmlns=\"urn:xmind:xmap:xmlns:comments:2.0\"", ""); + + // 添加评论到content中 + Document commentDocument = DocumentHelper.parseText(xmlComments); + List<Node> commentsList = commentDocument.selectNodes("//comment"); + + for (Node topic : topics) { + for (Node commentNode : commentsList) { + Element commentElement = (Element) commentNode; + Element topicElement = (Element) topic; + if (topicElement.attribute("id").getValue() + .equals(commentElement.attribute("object-id").getValue())) { + Element comment = topicElement.addElement("comments"); + comment.addAttribute("creationTime", commentElement.attribute("time").getValue()); + comment.addAttribute("author", commentElement.attribute("author").getValue()); + comment.addAttribute("content", commentElement.element("content").getText()); + } + } + + } + } + + // 第一个topic转换为json中的rootTopic + Node rootTopic = root.selectSingleNode("/xmap-content/sheet/topic"); + rootTopic.setName("rootTopic"); + + // 将xml中topic节点转换为attached节点 + List<Node> topicList = rootTopic.selectNodes("//topic"); + + for (Node node : topicList) { + node.setName("attached"); + } + // 选取第一个sheet + Element sheet = root.elements("sheet").get(0); + String res = sheet.asXML(); + // 将xml转为json + JSONObject xmlJSONObj = XML.toJSONObject(res); + JSONObject jsonObject = xmlJSONObj.getJSONObject("sheet"); + // 设置缩进 + return jsonObject.toString(4); + } +} diff --git a/backend/src/main/java/io/metersphere/xmind/parser/XmindParser.java b/backend/src/main/java/io/metersphere/xmind/parser/XmindParser.java new file mode 100644 index 0000000000..44e1cd5055 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/XmindParser.java @@ -0,0 +1,116 @@ +package io.metersphere.xmind.parser; + +import com.alibaba.fastjson.JSON; +import io.metersphere.xmind.parser.domain.JsonRootBean; +import org.apache.commons.compress.archivers.ArchiveException; +import org.dom4j.DocumentException; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * @Description 解析主体 + */ +public class XmindParser { + public static final String xmindZenJson = "content.json"; + public static final String xmindLegacyContent = "content.xml"; + public static final String xmindLegacyComments = "comments.xml"; + + /** + * 解析脑图文件,返回content整合后的内容 + * + * @param file + * @return + * @throws IOException + * @throws ArchiveException + * @throws DocumentException + */ + public static String parseJson(File file) throws IOException, ArchiveException, DocumentException { + String res = ZipUtils.extract(file); + + String content = null; + if (isXmindZen(res, file)) { + content = getXmindZenContent(file, res); + } else { + content = getXmindLegacyContent(file, res); + } + + // 删除生成的文件夹 + File dir = new File(res); + boolean flag = deleteDir(dir); + if (flag) { + // do something + } + JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class); + return (JSON.toJSONString(jsonRootBean, false)); + } + + public static JsonRootBean parseObject(File file) throws DocumentException, ArchiveException, IOException { + String content = parseJson(file); + JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class); + return jsonRootBean; + } + + public static boolean deleteDir(File dir) { + if (dir.isDirectory()) { + String[] children = dir.list(); + // 递归删除目录中的子目录下 + for (int i = 0; i < children.length; i++) { + boolean success = deleteDir(new File(dir, children[i])); + if (!success) { + return false; + } + } + } + // 目录此时为空,可以删除 + return dir.delete(); + } + + /** + * @return + */ + public static String getXmindZenContent(File file, String extractFileDir) + throws IOException, ArchiveException { + List<String> keys = new ArrayList<>(); + keys.add(xmindZenJson); + Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir); + String content = map.get(xmindZenJson); + content = XmindZen.getContent(content); + return content; + } + + /** + * @return + */ + public static String getXmindLegacyContent(File file, String extractFileDir) + throws IOException, ArchiveException, DocumentException { + List<String> keys = new ArrayList<>(); + keys.add(xmindLegacyContent); + keys.add(xmindLegacyComments); + Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir); + + String contentXml = map.get(xmindLegacyContent); + String commentsXml = map.get(xmindLegacyComments); + String xmlContent = XmindLegacy.getContent(contentXml, commentsXml); + + return xmlContent; + } + + private static boolean isXmindZen(String res, File file) throws IOException, ArchiveException { + // 解压 + File parent = new File(res); + if (parent.isDirectory()) { + String[] files = parent.list(new ZipUtils.FileFilter()); + for (int i = 0; i < Objects.requireNonNull(files).length; i++) { + if (files[i].equals(xmindZenJson)) { + return true; + } + } + } + return false; + } +} diff --git a/backend/src/main/java/io/metersphere/xmind/parser/XmindZen.java b/backend/src/main/java/io/metersphere/xmind/parser/XmindZen.java new file mode 100644 index 0000000000..6ba9d2021b --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/XmindZen.java @@ -0,0 +1,65 @@ +package io.metersphere.xmind.parser; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.dom4j.DocumentException; + +import java.io.IOException; + +public class XmindZen { + + /** + * @param jsonContent + * @return + * @throws IOException + * @throws DocumentException + */ + public static String getContent(String jsonContent) { + JSONObject jsonObject = JSONArray.parseArray(jsonContent).getJSONObject(0); + JSONObject rootTopic = jsonObject.getJSONObject("rootTopic"); + transferNotes(rootTopic); + JSONObject children = rootTopic.getJSONObject("children"); + recursionChildren(children); + return jsonObject.toString(); + } + + /** + * 递归转换children + * + * @param children + */ + private static void recursionChildren(JSONObject children) { + if (children == null) { + return; + } + JSONArray attachedArray = children.getJSONArray("attached"); + if (attachedArray == null) { + return; + } + for (Object attached : attachedArray) { + JSONObject attachedObject = (JSONObject) attached; + transferNotes(attachedObject); + JSONObject childrenObject = attachedObject.getJSONObject("children"); + if (childrenObject == null) { + continue; + } + recursionChildren(childrenObject); + } + } + + private static void transferNotes(JSONObject object) { + JSONObject notes = object.getJSONObject("notes"); + if (notes == null) { + return; + } + JSONObject plain = notes.getJSONObject("plain"); + if (plain != null) { + String content = plain.getString("content"); + notes.remove("plain"); + notes.put("content", content); + } else { + notes.put("content", null); + } + } + +} diff --git a/backend/src/main/java/io/metersphere/xmind/parser/ZipUtils.java b/backend/src/main/java/io/metersphere/xmind/parser/ZipUtils.java new file mode 100644 index 0000000000..440575096b --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/ZipUtils.java @@ -0,0 +1,87 @@ +package io.metersphere.xmind.parser; + +import org.apache.commons.compress.archivers.ArchiveException; +import org.apache.commons.compress.archivers.examples.Expander; + +import java.io.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * @Description zip解压工具 + */ +public class ZipUtils { + + private static final String currentPath = System.getProperty("user.dir"); + + /** + * 找到压缩文件中匹配的子文件,返回的为 getContents("comments.xml, unzip + * + * @param subFileNames + * @param file + */ + public static Map<String, String> getContents(List<String> subFileNames, File file, String extractFileDir) + throws IOException, ArchiveException { + String destFilePath = extractFileDir; + Map<String, String> map = new HashMap<>(); + File destFile = new File(destFilePath); + if (destFile.isDirectory()) { + String[] res = destFile.list(new FileFilter()); + for (int i = 0; i < Objects.requireNonNull(res).length; i++) { + if (subFileNames.contains(res[i])) { + String s = destFilePath + File.separator + res[i]; + String content = getFileContent(s); + map.put(res[i], content); + } + } + } + return map; + } + + /** + * 返回解压后的文件夹名字 + * + * @return + * @throws IOException + * @throws ArchiveException + */ + public static String extract(File file) throws IOException, ArchiveException { + Expander expander = new Expander(); + String destFileName = currentPath + File.separator + "XMind" + System.currentTimeMillis(); // 目标文件夹名字 + expander.expand(file, new File(destFileName)); + return destFileName; + } + + // 这是一个内部类过滤器,策略模式 + static class FileFilter implements FilenameFilter { + @Override + public boolean accept(File dir, String name) { + // String的 endsWith(String str)方法 筛选出以str结尾的字符串 + if (name.endsWith(".xml") || name.endsWith(".json")) { + return true; + } + return false; + } + } + + public static String getFileContent(String fileName) throws IOException { + File file; + try { + file = new File(fileName); + } catch (Exception e) { + throw new RuntimeException("找不到该文件"); + } + FileReader fileReader = new FileReader(file); + BufferedReader bufferedReder = new BufferedReader(fileReader); + StringBuilder stringBuffer = new StringBuilder(); + while (bufferedReder.ready()) { + stringBuffer.append(bufferedReder.readLine()); + } + // 打开的文件需关闭,在unix下可以删除,否则在windows下不能删除(file.delete()) + bufferedReder.close(); + fileReader.close(); + return stringBuffer.toString(); + } +} diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/Attached.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/Attached.java new file mode 100755 index 0000000000..df7932a26d --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/Attached.java @@ -0,0 +1,19 @@ + +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +import java.util.List; + +@Data +public class Attached { + + private String id; + private String title; + private Notes notes; + private String path; + private Attached parent; + private List<Comments> comments; + private Children children; + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/Children.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/Children.java new file mode 100755 index 0000000000..761a5fd9ce --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/Children.java @@ -0,0 +1,12 @@ +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +import java.util.List; + +@Data +public class Children { + + private List<Attached> attached; + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/Comments.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/Comments.java new file mode 100755 index 0000000000..e96dda1065 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/Comments.java @@ -0,0 +1,12 @@ +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +@Data +public class Comments { + + private long creationTime; + private String author; + private String content; + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/JsonRootBean.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/JsonRootBean.java new file mode 100755 index 0000000000..f69dc6d9d4 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/JsonRootBean.java @@ -0,0 +1,12 @@ +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +@Data +public class JsonRootBean { + + private String id; + private String title; + private RootTopic rootTopic; + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/Notes.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/Notes.java new file mode 100755 index 0000000000..882ee5682d --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/Notes.java @@ -0,0 +1,10 @@ +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +@Data +public class Notes { + + private String content; + +} \ No newline at end of file diff --git a/backend/src/main/java/io/metersphere/xmind/parser/domain/RootTopic.java b/backend/src/main/java/io/metersphere/xmind/parser/domain/RootTopic.java new file mode 100755 index 0000000000..74deb34695 --- /dev/null +++ b/backend/src/main/java/io/metersphere/xmind/parser/domain/RootTopic.java @@ -0,0 +1,16 @@ +package io.metersphere.xmind.parser.domain; + +import lombok.Data; + +import java.util.List; + +@Data +public class RootTopic { + + private String id; + private String title; + private Notes notes; + private List<Comments> comments; + private Children children; + +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index e94db0ae4d..a3a405615e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -69,13 +69,13 @@ jmeter.home=/opt/jmeter # quartz quartz.enabled=true quartz.scheduler-name=msServerJob - # file upload spring.servlet.multipart.max-file-size=500MB spring.servlet.multipart.max-request-size=500MB - # actuator management.server.port=8083 management.endpoints.web.exposure.include=* +#spring.freemarker.checkTemplateLocation=false + + -spring.freemarker.checkTemplateLocation=false diff --git a/backend/src/main/resources/db/migration/V21__modify_test_plan.sql b/backend/src/main/resources/db/migration/V21__modify_test_plan.sql new file mode 100644 index 0000000000..a8e42eafde --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__modify_test_plan.sql @@ -0,0 +1 @@ +alter table test_plan drop column project_id; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V22__modify_api_test_environment.sql b/backend/src/main/resources/db/migration/V22__modify_api_test_environment.sql new file mode 100644 index 0000000000..de866f5cb1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__modify_api_test_environment.sql @@ -0,0 +1,4 @@ +ALTER TABLE api_test_environment MODIFY COLUMN protocol varchar(20) NULL COMMENT 'Api Test Protocol'; +ALTER TABLE api_test_environment MODIFY COLUMN socket varchar(225) NULL COMMENT 'Api Test Socket'; +ALTER TABLE api_test_environment MODIFY COLUMN `domain` varchar(225) NULL COMMENT 'Api Test Domain'; +ALTER TABLE api_test_environment CHANGE custom_data `config` longtext COMMENT 'Config Data (JSON format)'; diff --git a/backend/src/main/resources/i18n/messages_en_US.properties b/backend/src/main/resources/i18n/messages_en_US.properties index ee6353c9f8..a9be69b2aa 100644 --- a/backend/src/main/resources/i18n/messages_en_US.properties +++ b/backend/src/main/resources/i18n/messages_en_US.properties @@ -22,7 +22,7 @@ user_already_exists=The user already exists in the current member list cannot_remove_current=Unable to remove the currently logged in user password_is_incorrect=Incorrect password user_not_exist=user does not exist: -user_has_been_disabled=the user has been disabled. +user_has_been_disabled=the user has been disabled. excessive_attempts=Excessive attempts user_locked=the user has been locked. user_expires=user expires. @@ -151,5 +151,7 @@ quota_max_threads_excess_workspace=The maximum number of concurrent threads exce quota_max_threads_excess_organization=The maximum number of concurrent threads exceeds the organization quota quota_duration_excess_workspace=The stress test duration exceeds the work space quota quota_duration_excess_organization=The stress test duration exceeds the organization quota -license_valid_license_error=valid license error -license_valid_license_code=The authorization code already exists + +email_subject=Metersphere timing task result notification +import_xmind_count_error=The number of use cases imported into the mind map cannot exceed 500 +import_xmind_not_found=Test case not found \ No newline at end of file diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index b30883829a..87ed767fb7 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -151,7 +151,9 @@ quota_max_threads_excess_workspace=最大并发数超过工作空间限额 quota_max_threads_excess_organization=最大并发数超过组织限额 quota_duration_excess_workspace=压测时长超过工作空间限额 quota_duration_excess_organization=压测时长超过组织限额 -license_valid_license_error=授权验证失败 -license_valid_license_code=授权码已经存在 +email_subject=MeterSphere定时任务结果通知 +import_xmind_count_error=思维导图导入用例数量不能超过 500 条 +import_xmind_not_found=未找到测试用例 + diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 985bb05a70..d2fdb48ea6 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -154,3 +154,7 @@ quota_duration_excess_organization=壓測時長超過組織限額 license_valid_license_error=授權驗證失敗 license_valid_license_code=授權碼已經存在 + +email_subject=MeterSphere定時任務結果通知 +import_xmind_count_error=思維導圖導入用例數量不能超過 500 條 +import_xmind_not_found=未找到测试用例 \ No newline at end of file diff --git a/backend/src/main/resources/template/testcase.xmind b/backend/src/main/resources/template/testcase.xmind new file mode 100644 index 0000000000..f33ee16788 Binary files /dev/null and b/backend/src/main/resources/template/testcase.xmind differ diff --git a/docker/jmeter-base/Dockerfile b/docker/jmeter-base/Dockerfile index 5faaa2b149..f5723be152 100644 --- a/docker/jmeter-base/Dockerfile +++ b/docker/jmeter-base/Dockerfile @@ -1,29 +1,29 @@ FROM alpine:latest LABEL maintainer="support@fit2cloud.com" -ENV JMETER_VERSION "5.2.1" - +ENV JMETER_VERSION "5.3" +ENV KAFKA_BACKEND_LISTENER_VERSION "1.0.4" #定义时区参数 ENV TZ=Asia/Shanghai RUN apk update && \ apk upgrade && \ - apk add --update openjdk8-jre wget tar bash && \ + apk add --update openjdk8 wget tar bash && \ wget https://mirrors.tuna.tsinghua.edu.cn/apache/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz && \ wget https://jmeter-plugins.org/files/packages/jpgc-casutg-2.9.zip && \ wget https://jmeter-plugins.org/files/packages/jpgc-tst-2.5.zip && \ - wget https://github.com/metersphere/jmeter-backend-listener-kafka/releases/download/v1.0.2/jmeter.backendlistener.kafka-1.0.2.jar && \ + wget https://github.com/metersphere/jmeter-backend-listener-kafka/releases/download/v${KAFKA_BACKEND_LISTENER_VERSION}/jmeter.backendlistener.kafka-${KAFKA_BACKEND_LISTENER_VERSION}.jar && \ wget https://github.com/metersphere/jmeter-plugins-for-apache-dubbo/releases/download/2.7.7/jmeter-plugins-dubbo-2.7.7-jar-with-dependencies.jar && \ mkdir -p /opt/jmeter && \ tar -zxf apache-jmeter-${JMETER_VERSION}.tgz -C /opt/jmeter/ --strip-components=1 && \ unzip -o jpgc-casutg-2.9.zip -d /tmp/ && mv /tmp/lib/ext/jmeter-plugins-casutg-2.9.jar /opt/jmeter/lib/ext && \ unzip -o jpgc-tst-2.5.zip -d /tmp/ && mv /tmp/lib/ext/jmeter-plugins-tst-2.5.jar /opt/jmeter/lib/ext && \ - mv jmeter.backendlistener.kafka-1.0.2.jar /opt/jmeter/lib/ext && \ + mv jmeter.backendlistener.kafka-${KAFKA_BACKEND_LISTENER_VERSION}.jar /opt/jmeter/lib/ext && \ mv jmeter-plugins-dubbo-2.7.7-jar-with-dependencies.jar /opt/jmeter/lib/ext && \ rm -rf apache-jmeter-${JMETER_VERSION}.tgz && \ rm -rf jpgc-casutg-2.9.zip && \ rm -rf jpgc-tst-2.5.zip && \ - rm -rf jmeter.backendlistener.kafka-1.0.2.jar && \ + rm -rf jmeter.backendlistener.kafka-${KAFKA_BACKEND_LISTENER_VERSION}.jar && \ rm -rf jmeter-plugins-dubbo-2.7.7-jar-with-dependencies.jar && \ rm -rf /var/cache/apk/* && \ wget -O /usr/bin/tpl https://github.com/schneidexe/tpl/releases/download/v0.5.0/tpl-linux-amd64 && \ @@ -31,7 +31,7 @@ RUN apk update && \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo "$TZ" > /etc/timezone ENV JMETER_HOME /opt/jmeter -ENV PATH $PATH:$JMETER_HOME/bin +ENV PATH $PATH:$JMETER_HOME/bin:/usr/lib/jvm/java-1.8-openjdk/bin ADD log4j2.xml $JMETER_HOME/bin/log4j2.xml ADD jmeter.properties $JMETER_HOME/bin/jmeter.properties \ No newline at end of file diff --git a/docker/jmeter-master/Dockerfile b/docker/jmeter-master/Dockerfile index d88bbbba44..372f8784d9 100644 --- a/docker/jmeter-master/Dockerfile +++ b/docker/jmeter-master/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.fit2cloud.com/metersphere/jmeter-base:latest +FROM registry.fit2cloud.com/metersphere/jmeter-base:0.0.1 LABEL maintainer="support@fit2cloud.com" EXPOSE 60000 diff --git a/frontend/src/assets/xmind.jpg b/frontend/src/assets/xmind.jpg new file mode 100644 index 0000000000..3cd0340ada Binary files /dev/null and b/frontend/src/assets/xmind.jpg differ diff --git a/frontend/src/business/components/api/test/ApiTestConfig.vue b/frontend/src/business/components/api/test/ApiTestConfig.vue index cf2cb8f524..9d90a5b12d 100644 --- a/frontend/src/business/components/api/test/ApiTestConfig.vue +++ b/frontend/src/business/components/api/test/ApiTestConfig.vue @@ -53,7 +53,7 @@ <ms-api-report-dialog :test-id="id" ref="reportDialog"/> <ms-schedule-config :schedule="test.schedule" :is-read-only="isReadOnly" :save="saveCronExpression" - @scheduleChange="saveSchedule" :check-open="checkScheduleEdit"/> + @scheduleChange="saveSchedule" :test-id="id" :check-open="checkScheduleEdit"/> </el-row> </el-header> <ms-api-scenario-config :debug-report-id="debugReportId" @runDebug="runDebug" :is-read-only="isReadOnly" diff --git a/frontend/src/business/components/api/test/components/ApiEnvironmentConfig.vue b/frontend/src/business/components/api/test/components/ApiEnvironmentConfig.vue index 659816c81e..86bd3aa750 100644 --- a/frontend/src/business/components/api/test/components/ApiEnvironmentConfig.vue +++ b/frontend/src/business/components/api/test/components/ApiEnvironmentConfig.vue @@ -21,6 +21,7 @@ import MsAsideItem from "../../../common/components/MsAsideItem"; import EnvironmentEdit from "./environment/EnvironmentEdit"; import {listenGoBack, removeGoBackListener} from "../../../../../common/js/utils"; + import {Environment, parseEnvironment} from "../model/EnvironmentModel"; export default { name: "ApiEnvironmentConfig", @@ -35,7 +36,7 @@ visible: false, projectId: '', environments: [], - currentEnvironment: {variables: [{}], headers: [{}], protocol: 'https', projectId: this.projectId, hosts: [{}]}, + currentEnvironment: new Environment(), environmentOperators: [ { icon: 'el-icon-document-copy', @@ -68,7 +69,7 @@ }, copyEnvironment(environment) { if (!environment.id) { - this.$warning(this.$t('commons.please_save')) + this.$warning(this.$t('commons.please_save')); return; } let newEnvironment = {}; @@ -102,7 +103,9 @@ return name; }, addEnvironment() { - let newEnvironment = this.getDefaultEnvironment(); + let newEnvironment = new Environment({ + projectId: this.projectId + }); this.environments.push(newEnvironment); this.$refs.environmentItems.itemSelected(this.environments.length - 1, newEnvironment); }, @@ -116,7 +119,9 @@ if (this.environments.length > 0) { this.$refs.environmentItems.itemSelected(0, this.environments[0]); } else { - let item = this.getDefaultEnvironment(); + let item = new Environment({ + projectId: this.projectId + }); this.environments.push(item); this.$refs.environmentItems.itemSelected(0, item); } @@ -124,25 +129,9 @@ } }, getEnvironment(environment) { - if (!(environment.variables instanceof Array)) { - environment.variables = JSON.parse(environment.variables); - } - if (!(environment.headers instanceof Array)) { - environment.headers = JSON.parse(environment.headers); - } - if(environment.hosts === undefined || environment.hosts ===null || environment.hosts ===''){ - environment.hosts = []; - environment.enable =false; - } - else if (!(environment.hosts instanceof Array)) { - environment.hosts = JSON.parse(environment.hosts); - environment.enable =true; - } + parseEnvironment(environment); this.currentEnvironment = environment; }, - getDefaultEnvironment() { - return {variables: [{}], headers: [{}], protocol: 'https', projectId: this.projectId, hosts: [{}]}; - }, close() { this.$emit('close'); this.visible = false; diff --git a/frontend/src/business/components/api/test/components/ApiHostTable.vue b/frontend/src/business/components/api/test/components/ApiHostTable.vue index 7601c9315e..8242fd086d 100644 --- a/frontend/src/business/components/api/test/components/ApiHostTable.vue +++ b/frontend/src/business/components/api/test/components/ApiHostTable.vue @@ -1,47 +1,42 @@ <template> - <div> - <el-card class="table-card"> - <el-table :data="hostTable" style="width: 100%" @cell-dblclick="dblHostTable" class="ht-tb"> - <el-table-column prop="ip" label="IP"> - <template slot-scope="scope"> - <el-input v-if="scope.row.status" v-model="scope.row.ip"></el-input> - <span v-else>{{scope.row.ip}}</span> - </template> - </el-table-column> + <div class="ms-border"> + <el-table :data="hostTable" style="width: 100%" @cell-dblclick="dblHostTable" class="ht-tb"> + <el-table-column prop="ip" label="IP"> + <template slot-scope="scope"> + <el-input v-if="scope.row.status" v-model="scope.row.ip"></el-input> + <span v-else>{{scope.row.ip}}</span> + </template> + </el-table-column> - <el-table-column prop="domain" :label="$t('load_test.domain')"> - <template slot-scope="scope"> - <el-input v-if="scope.row.status" v-model="scope.row.domain"></el-input> - <span v-else>{{scope.row.domain}}</span> - </template> - </el-table-column> + <el-table-column prop="domain" :label="$t('load_test.domain')"> + <template slot-scope="scope"> + <el-input v-if="scope.row.status" v-model="scope.row.domain"></el-input> + <span v-else>{{scope.row.domain}}</span> + </template> + </el-table-column> - <el-table-column prop="annotation" :label="$t('commons.annotation')"> - <template slot-scope="scope"> - <el-input v-if="scope.row.status" v-model="scope.row.annotation"></el-input> - <span v-else>{{scope.row.annotation}}</span> - </template> - </el-table-column> + <el-table-column prop="annotation" :label="$t('commons.annotation')"> + <template slot-scope="scope"> + <el-input v-if="scope.row.status" v-model="scope.row.annotation"></el-input> + <span v-else>{{scope.row.annotation}}</span> + </template> + </el-table-column> - <el-table-column :label="$t('commons.operating')" width="100"> - <template v-slot:default="scope"> - <span> - <el-button size="mini" p="$t('commons.remove')" icon="el-icon-close" circle @click="remove(scope.row)" - class="ht-btn-remove"/> - <el-button size="mini" p="$t('commons.save')" icon="el-icon-check" circle @click="confirm(scope.row)" - class="ht-btn-confirm"/> - </span> - </template> + <el-table-column :label="$t('commons.operating')" width="100"> + <template v-slot:default="scope"> + <span> + <el-button size="mini" p="$t('commons.remove')" icon="el-icon-close" circle @click="remove(scope.row)" + class="ht-btn-remove"/> + <el-button size="mini" p="$t('commons.save')" icon="el-icon-check" circle @click="confirm(scope.row)" + class="ht-btn-confirm"/> + </span> + </template> - </el-table-column> - </el-table> - - <el-button class="ht-btn-add" size="mini" p="$t('commons.add')" icon="el-icon-circle-plus-outline" @click="add" - >添加 - </el-button> - - </el-card> + </el-table-column> + </el-table> + <el-button class="ht-btn-add" size="mini" p="$t('commons.add')" icon="el-icon-circle-plus-outline" @click="add">添加 + </el-button> </div> </template> diff --git a/frontend/src/business/components/api/test/components/ApiScenarioConfig.vue b/frontend/src/business/components/api/test/components/ApiScenarioConfig.vue index 34f42086ef..4848e32744 100644 --- a/frontend/src/business/components/api/test/components/ApiScenarioConfig.vue +++ b/frontend/src/business/components/api/test/components/ApiScenarioConfig.vue @@ -75,6 +75,7 @@ import MsApiScenarioForm from "./ApiScenarioForm"; import {Request, Scenario} from "../model/ScenarioModel"; import draggable from 'vuedraggable'; import MsApiScenarioSelect from "@/business/components/api/test/components/ApiScenarioSelect"; +import {parseEnvironment} from "../model/EnvironmentModel"; export default { name: "MsApiScenarioConfig", @@ -203,6 +204,7 @@ export default { let environments = response.data; let environmentMap = new Map(); environments.forEach(environment => { + parseEnvironment(environment); environmentMap.set(environment.id, environment); }); this.scenarios.forEach(scenario => { diff --git a/frontend/src/business/components/api/test/components/ApiScenarioForm.vue b/frontend/src/business/components/api/test/components/ApiScenarioForm.vue index afe9aaedb5..ae4345a615 100644 --- a/frontend/src/business/components/api/test/components/ApiScenarioForm.vue +++ b/frontend/src/business/components/api/test/components/ApiScenarioForm.vue @@ -9,7 +9,7 @@ <el-select :disabled="isReadOnly" v-model="scenario.environmentId" class="environment-select" @change="environmentChange" clearable> <el-option v-for="(environment, index) in environments" :key="index" - :label="environment.name + ': ' + environment.protocol + '://' + environment.socket" + :label="environment.name + (environment.config.httpConfig.socket ? (': ' + environment.config.httpConfig.protocol + '://' + environment.config.httpConfig.socket) : '')" :value="environment.id"/> <el-button class="environment-button" size="mini" type="primary" @click="openEnvironmentConfig"> {{ $t('api_test.environment.environment_config') }} @@ -29,15 +29,15 @@ <el-tabs v-model="activeName" :disabled="isReadOnly"> <el-tab-pane :label="$t('api_test.scenario.variables')" name="parameters"> - <ms-api-scenario-variables :is-read-only="isReadOnly" :items="scenario.variables" + <ms-api-scenario-variables :isShowEnable="true" :is-read-only="isReadOnly" :items="scenario.variables" :description="$t('api_test.scenario.kv_description')"/> </el-tab-pane> <el-tab-pane :label="$t('api_test.scenario.headers')" name="headers"> - <ms-api-key-value :is-read-only="isReadOnly" :items="scenario.headers" :suggestions="headerSuggestions" + <ms-api-key-value :is-read-only="isReadOnly" :isShowEnable="true" :items="scenario.headers" :suggestions="headerSuggestions" :environment="scenario.environment" :description="$t('api_test.scenario.kv_description')"/> </el-tab-pane> - <el-tab-pane :label="'数据库配置'" name="database"> + <el-tab-pane :label="$t('api_test.environment.database_config')" name="database"> <ms-database-config :configs="scenario.databaseConfigs"/> </el-tab-pane> <el-tab-pane :label="$t('api_test.scenario.dubbo')" name="dubbo"> @@ -66,6 +66,7 @@ import MsDubboRegistryCenter from "@/business/components/api/test/components/req import MsDubboConfigCenter from "@/business/components/api/test/components/request/dubbo/ConfigCenter"; import MsDubboConsumerService from "@/business/components/api/test/components/request/dubbo/ConsumerAndService"; import MsDatabaseConfig from "./request/database/DatabaseConfig"; +import {parseEnvironment} from "../model/EnvironmentModel"; export default { name: "MsApiScenarioForm", @@ -111,6 +112,9 @@ export default { if (this.projectId) { this.result = this.$get('/api/environment/list/' + this.projectId, response => { this.environments = response.data; + this.environments.forEach(environment => { + parseEnvironment(environment); + }); let hasEnvironment = false; for (let i in this.environments) { if (this.environments[i].id === this.scenario.environmentId) { diff --git a/frontend/src/business/components/api/test/components/ApiScenarioVariables.vue b/frontend/src/business/components/api/test/components/ApiScenarioVariables.vue index 92fadac1db..6725d8fc30 100644 --- a/frontend/src/business/components/api/test/components/ApiScenarioVariables.vue +++ b/frontend/src/business/components/api/test/components/ApiScenarioVariables.vue @@ -5,6 +5,11 @@ </span> <div class="kv-row" v-for="(item, index) in items" :key="index"> <el-row type="flex" :gutter="20" justify="space-between" align="middle"> + <el-col v-if="isShowEnable" class="kv-checkbox"> + <input type="checkbox" v-if="!isDisable(index)" @change="change" :value="item.uuid" v-model="checkedValues" + :disabled="isDisable(index) || isReadOnly"/> + </el-col> + <el-col> <ms-api-variable-input :show-variable="showVariable" :is-read-only="isReadOnly" v-model="item.name" size="small" maxlength="200" @change="change" :placeholder="$t('api_test.variable_name')" show-word-limit/> @@ -36,14 +41,27 @@ type: Boolean, default: false }, + isShowEnable: { + type: Boolean, + default: false + }, showVariable: { type: Boolean, default: true }, }, - + data() { + return { + checkedValues: [] + } + }, methods: { remove: function (index) { + if (this.isShowEnable) { + // 移除勾选内容 + let checkIndex = this.checkedValues.indexOf(this.items[index].uuid); + checkIndex != -1 ? this.checkedValues.splice(checkIndex, 1) : this.checkedValues; + } this.items.splice(index, 1); this.$emit('change', this.items); }, @@ -51,6 +69,10 @@ let isNeedCreate = true; let removeIndex = -1; this.items.forEach((item, index) => { + // 启用行赋值 + if (this.isShowEnable) { + item.enable = this.checkedValues.indexOf(item.uuid) != -1 ? true : false; + } if (!item.name && !item.value) { // 多余的空行 if (index !== this.items.length - 1) { @@ -61,11 +83,20 @@ } }); if (isNeedCreate) { + // 往后台送入的复选框值布尔值 + if (this.isShowEnable) { + this.items[this.items.length - 1].enable = true; + // v-model 选中状态 + this.checkedValues.push(this.items[this.items.length - 1].uuid); + } this.items.push(new KeyValue()); } this.$emit('change', this.items); // TODO 检查key重复 }, + uuid: function () { + return (((1 + Math.random()) * 0x100000) | 0).toString(16).substring(1); + }, isDisable: function (index) { return this.items.length - 1 === index; } @@ -74,6 +105,14 @@ created() { if (this.items.length === 0) { this.items.push(new KeyValue()); + }else if (this.isShowEnable) { + this.items.forEach((item, index) => { + let uuid = this.uuid(); + item.uuid = uuid; + if (item.enable) { + this.checkedValues.push(uuid); + } + }) } } } @@ -84,6 +123,11 @@ font-size: 13px; } + .kv-checkbox { + width: 20px; + margin-right: 10px; + } + .kv-row { margin-top: 10px; } diff --git a/frontend/src/business/components/api/test/components/collapse/ApiCollapse.vue b/frontend/src/business/components/api/test/components/collapse/ApiCollapse.vue index 303c06f80a..50ff6488d3 100644 --- a/frontend/src/business/components/api/test/components/collapse/ApiCollapse.vue +++ b/frontend/src/business/components/api/test/components/collapse/ApiCollapse.vue @@ -38,17 +38,16 @@ }, methods: { - setActiveNames(activeNames) { + setActiveNames(activeNames, item) { activeNames = [].concat(activeNames); - let value = this.accordion ? activeNames[0] : activeNames; this.activeNames = activeNames; - this.$emit('input', value); - this.$emit('change', value); + this.$emit('input', item.name); + this.$emit('change', item.name); }, handleItemClick(item) { if (this.accordion) { this.setActiveNames( - (this.activeNames[0] || this.activeNames[0] === 0) && item.name); + (this.activeNames[0] || this.activeNames[0] === 0) && item.name, item); } else { let activeNames = this.activeNames.slice(0); let index = activeNames.indexOf(item.name); @@ -58,7 +57,7 @@ } else { activeNames.push(item.name); } - this.setActiveNames(activeNames); + this.setActiveNames(activeNames, item); } } }, diff --git a/frontend/src/business/components/api/test/components/collapse/ApiCollapseItem.vue b/frontend/src/business/components/api/test/components/collapse/ApiCollapseItem.vue index 9dc50e2a7e..8f7be0628c 100644 --- a/frontend/src/business/components/api/test/components/collapse/ApiCollapseItem.vue +++ b/frontend/src/business/components/api/test/components/collapse/ApiCollapseItem.vue @@ -9,7 +9,6 @@ > <div class="el-collapse-item__header" - @click="handleHeaderClick" role="button" :id="`el-collapse-head-${id}`" :tabindex="disabled ? undefined : 0" @@ -21,7 +20,7 @@ @focus="handleFocus" @blur="focusing = false" > - <i + <i @click="handleHeaderClick" class="el-collapse-item__arrow el-icon-arrow-right" :class="{'is-active': isActive}"> </i> diff --git a/frontend/src/business/components/api/test/components/environment/EnvironmentCommonConfig.vue b/frontend/src/business/components/api/test/components/environment/EnvironmentCommonConfig.vue new file mode 100644 index 0000000000..2dd5c442c4 --- /dev/null +++ b/frontend/src/business/components/api/test/components/environment/EnvironmentCommonConfig.vue @@ -0,0 +1,63 @@ +<template> + <div> + <el-form :model="commonConfig" :rules="rules" ref="commonConfig"> + + <span>{{$t('api_test.environment.globalVariable')}}</span> + <ms-api-scenario-variables :items="commonConfig.variables"/> + + <el-form-item> + <el-switch v-model="commonConfig.enableHost" active-text="Hosts"/> + </el-form-item> + <ms-api-host-table v-if="commonConfig.enableHost" :hostTable="commonConfig.hosts" ref="refHostTable"/> + </el-form> + </div> +</template> + +<script> + import {CommonConfig, Environment} from "../../model/EnvironmentModel"; + import MsApiScenarioVariables from "../ApiScenarioVariables"; + import MsApiHostTable from "../ApiHostTable"; + + export default { + name: "MsEnvironmentCommonConfig", + components: {MsApiHostTable, MsApiScenarioVariables}, + props: { + commonConfig: new CommonConfig(), + }, + data() { + return { + rules: { + + }, + } + }, + methods: { + validate() { + let isValidate = false; + this.$refs['commonConfig'].validate((valid) => { + if (valid) { + // 校验host列表 + let valHost = true; + if (this.commonConfig.enableHost) { + for (let i = 0; i < this.commonConfig.hosts.length; i++) { + valHost = this.$refs['refHostTable'].confirm(this.commonConfig.hosts[i]); + } + } + if (valHost) { + isValidate = true; + } else { + isValidate = false; + } + } else { + isValidate = false; + } + }); + return isValidate; + } + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue b/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue index 684d38ea9e..6f8ac770a0 100644 --- a/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue +++ b/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue @@ -3,37 +3,24 @@ <el-form :model="environment" :rules="rules" ref="environment"> <span>{{$t('api_test.environment.name')}}</span> - <el-form-item - prop="name"> - <el-input v-model="environment.name" :placeholder="this.$t('commons.input_name')" clearable></el-input> - </el-form-item> - <span>{{$t('api_test.environment.socket')}}</span> - <el-form-item - prop="socket"> - <el-input v-model="environment.socket" :placeholder="$t('api_test.request.url_description')" clearable> - <template v-slot:prepend> - <el-select v-model="environment.protocol" class="request-protocol-select"> - <el-option label="http://" value="http"/> - <el-option label="https://" value="https"/> - </el-select> - </template> - </el-input> + <el-form-item prop="name"> + <el-input v-model="environment.name" :placeholder="this.$t('commons.input_name')" clearable/> </el-form-item> - <el-form-item> - <el-switch - v-model="envEnable" - inactive-text="hosts"> - </el-switch> - </el-form-item> - <ms-api-host-table v-if="envEnable" :hostTable="environment.hosts" ref="refHostTable"/> + <el-tabs v-model="activeName"> - <span>{{$t('api_test.environment.globalVariable')}}</span> - <ms-api-scenario-variables :show-variable="false" :items="environment.variables"/> + <el-tab-pane :label="$t('api_test.environment.common_config')" name="common"> + <ms-environment-common-config :common-config="environment.config.commonConfig" ref="commonConfig"/> + </el-tab-pane> - <span>{{$t('api_test.request.headers')}}</span> - <ms-api-key-value :items="environment.headers" :suggestions="headerSuggestions"/> + <el-tab-pane :label="$t('api_test.environment.http_config')" name="http"> + <ms-environment-http-config :http-config="environment.config.httpConfig" ref="httpConfig"/> + </el-tab-pane> + <el-tab-pane :label="$t('api_test.environment.database_config')" name="sql"> + <ms-database-config :configs="environment.config.databaseConfigs"/> + </el-tab-pane> + </el-tabs> <div class="environment-footer"> <ms-dialog-footer @@ -49,23 +36,23 @@ import MsApiKeyValue from "../ApiKeyValue"; import MsDialogFooter from "../../../../common/components/MsDialogFooter"; import {REQUEST_HEADERS} from "../../../../../../common/js/constants"; - import {KeyValue} from "../../model/ScenarioModel"; + import {Environment} from "../../model/EnvironmentModel"; import MsApiHostTable from "../ApiHostTable"; + import MsDatabaseConfig from "../request/database/DatabaseConfig"; + import MsEnvironmentHttpConfig from "./EnvironmentHttpConfig"; + import MsEnvironmentCommonConfig from "./EnvironmentCommonConfig"; export default { name: "EnvironmentEdit", - components: {MsApiHostTable, MsDialogFooter, MsApiKeyValue, MsApiScenarioVariables}, + components: { + MsEnvironmentCommonConfig, + MsEnvironmentHttpConfig, + MsDatabaseConfig, MsApiHostTable, MsDialogFooter, MsApiKeyValue, MsApiScenarioVariables}, props: { - environment: Object, + environment: new Environment(), }, data() { - let socketValidator = (rule, value, callback) => { - if (!this.validateSocket(value)) { - callback(new Error(this.$t('commons.formatErr'))); - } else { - callback(); - } - } + return { result: {}, envEnable: false, @@ -74,9 +61,9 @@ {required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, {max: 64, message: this.$t('commons.input_limit', [1, 64]), trigger: 'blur'} ], - socket: [{required: true, validator: socketValidator, trigger: 'blur'}], }, - headerSuggestions: REQUEST_HEADERS + headerSuggestions: REQUEST_HEADERS, + activeName: 'common' } }, watch: { @@ -87,20 +74,10 @@ methods: { save() { this.$refs['environment'].validate((valid) => { - // 校验host列表 - let valHost = true; - if (this.envEnable) { - for (let i = 0; i < this.environment.hosts.length; i++) { - valHost = this.$refs['refHostTable'].confirm(this.environment.hosts[i]); - } - } - if (valid && valHost) { + if (valid && this.$refs.commonConfig.validate() && this.$refs.httpConfig.validate()) { this._save(this.environment); - } else { - return false; } }); - }, _save(environment) { let param = this.buildParam(environment); @@ -115,17 +92,11 @@ this.$success(this.$t('commons.save_success')); }); }, - buildParam(environment) { + buildParam: function (environment) { let param = {}; Object.assign(param, environment); - if (!(environment.variables instanceof String)) { - param.variables = JSON.stringify(environment.variables); - } - if (!(environment.headers instanceof String)) { - param.headers = JSON.stringify(environment.headers); - } - if (environment.hosts != undefined && !(environment.hosts instanceof String)) { - let hosts = JSON.parse(JSON.stringify(environment.hosts)); + let hosts = param.config.commonConfig.hosts; + if (hosts != undefined) { let validHosts = []; // 去除掉未确认的host hosts.forEach(host => { @@ -133,33 +104,11 @@ validHosts.push(host); } }); - environment.hosts = validHosts; - param.hosts = JSON.stringify(validHosts); - } - if (!this.envEnable) { - param.hosts = null; + param.config.commonConfig.hosts = validHosts; } + param.config = JSON.stringify(param.config); return param; }, - validateSocket(socket) { - if (!socket) return; - let urlStr = this.environment.protocol + '://' + socket; - let url = {}; - try { - url = new URL(urlStr); - } catch (e) { - return false - } - - this.environment.port = url.port; - this.environment.domain = decodeURIComponent(url.hostname); - if (url.port) { - this.environment.socket = this.environment.domain + ':' + url.port + url.pathname; - } else { - this.environment.socket = this.environment.domain + url.pathname; - } - return true; - }, cancel() { this.$emit('close'); }, @@ -175,10 +124,9 @@ .el-main { border: solid 1px #EBEEF5; margin-left: 200px; - } + min-height: 400px; + max-height: 700px; - .request-protocol-select { - width: 90px; } .el-row { diff --git a/frontend/src/business/components/api/test/components/environment/EnvironmentHttpConfig.vue b/frontend/src/business/components/api/test/components/environment/EnvironmentHttpConfig.vue new file mode 100644 index 0000000000..2d7f3e4967 --- /dev/null +++ b/frontend/src/business/components/api/test/components/environment/EnvironmentHttpConfig.vue @@ -0,0 +1,85 @@ +<template> + <el-form :model="httpConfig" :rules="rules" ref="httpConfig"> + <span>{{$t('api_test.environment.socket')}}</span> + <el-form-item prop="socket"> + <el-input v-model="httpConfig.socket" :placeholder="$t('api_test.request.url_description')" clearable> + <template v-slot:prepend> + <el-select v-model="httpConfig.protocol" class="request-protocol-select"> + <el-option label="http://" value="http"/> + <el-option label="https://" value="https"/> + </el-select> + </template> + </el-input> + </el-form-item> + + <span>{{$t('api_test.request.headers')}}</span> + <ms-api-key-value :items="httpConfig.headers" :isShowEnable="true" :suggestions="headerSuggestions"/> + </el-form> +</template> + +<script> + import {HttpConfig} from "../../model/EnvironmentModel"; + import MsApiKeyValue from "../ApiKeyValue"; + import {REQUEST_HEADERS} from "../../../../../../common/js/constants"; + + export default { + name: "MsEnvironmentHttpConfig", + components: {MsApiKeyValue}, + props: { + httpConfig: new HttpConfig(), + }, + data() { + let socketValidator = (rule, value, callback) => { + if (!this.validateSocket(value)) { + callback(new Error(this.$t('commons.formatErr'))); + return false; + } else { + callback(); + return true; + } + } + return { + headerSuggestions: REQUEST_HEADERS, + rules: { + socket: [{required: false, validator: socketValidator, trigger: 'blur'}], + }, + } + }, + methods: { + validateSocket(socket) { + if (!socket) return true; + let urlStr = this.httpConfig.protocol + '://' + socket; + let url = {}; + try { + url = new URL(urlStr); + } catch (e) { + return false; + } + this.httpConfig.domain = decodeURIComponent(url.hostname); + + this.httpConfig.port = url.port; + if (url.port) { + this.httpConfig.socket = this.httpConfig.domain + ':' + url.port + url.pathname; + } else { + this.httpConfig.socket = this.httpConfig.domain + url.pathname; + } + return true; + }, + validate() { + let isValidate = false; + this.$refs['httpConfig'].validate((valid) => { + isValidate = valid; + }); + return isValidate; + } + } + } +</script> + +<style scoped> + + .request-protocol-select { + width: 90px; + } + +</style> diff --git a/frontend/src/business/components/api/test/components/request/ApiHttpRequestForm.vue b/frontend/src/business/components/api/test/components/request/ApiHttpRequestForm.vue index c9a8353c5c..0bbb5cb7a5 100644 --- a/frontend/src/business/components/api/test/components/request/ApiHttpRequestForm.vue +++ b/frontend/src/business/components/api/test/components/request/ApiHttpRequestForm.vue @@ -148,7 +148,7 @@ export default { if (!this.request.path) return; let url = this.getURL(this.displayUrl); let urlStr = url.origin + url.pathname; - let envUrl = this.request.environment.protocol + '://' + this.request.environment.socket; + let envUrl = this.request.environment.config.httpConfig.protocol + '://' + this.request.environment.config.httpConfig.socket; this.request.path = decodeURIComponent(urlStr.substring(envUrl.length, urlStr.length)); }, getURL(urlStr) { @@ -194,7 +194,9 @@ export default { return this.request.method !== "GET"; }, displayUrl() { - return this.request.environment ? this.request.environment.protocol + '://' + this.request.environment.socket + (this.request.path ? this.request.path : '') : ''; + return (this.request.environment && this.request.environment.config.httpConfig.socket) ? + this.request.environment.config.httpConfig.protocol + '://' + this.request.environment.config.httpConfig.socket + (this.request.path ? this.request.path : '') + : ''; } } } diff --git a/frontend/src/business/components/api/test/components/request/ApiRequestConfig.vue b/frontend/src/business/components/api/test/components/request/ApiRequestConfig.vue index 9b2e63abb0..f90f90a9f8 100644 --- a/frontend/src/business/components/api/test/components/request/ApiRequestConfig.vue +++ b/frontend/src/business/components/api/test/components/request/ApiRequestConfig.vue @@ -49,6 +49,7 @@ <el-radio-group v-model="type" @change="createRequest"> <el-radio :label="types.HTTP">HTTP</el-radio> <el-radio :label="types.DUBBO">DUBBO</el-radio> + <el-radio :label="types.SQL">SQL</el-radio> </el-radio-group> <el-button slot="reference" :disabled="isReadOnly" class="request-create" type="primary" size="mini" icon="el-icon-plus" plain/> diff --git a/frontend/src/business/components/api/test/components/request/ApiRequestForm.vue b/frontend/src/business/components/api/test/components/request/ApiRequestForm.vue index 486021ee66..76db3cfeeb 100644 --- a/frontend/src/business/components/api/test/components/request/ApiRequestForm.vue +++ b/frontend/src/business/components/api/test/components/request/ApiRequestForm.vue @@ -9,14 +9,15 @@ <script> import {JSR223Processor, Request, RequestFactory, Scenario} from "../../model/ScenarioModel"; -import MsApiHttpRequestForm from "./ApiHttpRequestForm"; -import MsApiDubboRequestForm from "./ApiDubboRequestForm"; -import MsScenarioResults from "../../../report/components/ScenarioResults"; -import MsRequestResultTail from "../../../report/components/RequestResultTail"; + import MsApiHttpRequestForm from "./ApiHttpRequestForm"; + import MsApiDubboRequestForm from "./ApiDubboRequestForm"; + import MsScenarioResults from "../../../report/components/ScenarioResults"; + import MsRequestResultTail from "../../../report/components/RequestResultTail"; + import MsApiSqlRequestForm from "./ApiSqlRequestForm"; export default { name: "MsApiRequestForm", - components: {MsRequestResultTail, MsScenarioResults, MsApiDubboRequestForm, MsApiHttpRequestForm}, + components: {MsApiSqlRequestForm, MsRequestResultTail, MsScenarioResults, MsApiDubboRequestForm, MsApiHttpRequestForm}, props: { scenario: Scenario, request: Request, @@ -41,6 +42,9 @@ export default { case RequestFactory.TYPES.DUBBO: name = "MsApiDubboRequestForm"; break; + case RequestFactory.TYPES.SQL: + name = "MsApiSqlRequestForm"; + break; default: name = "MsApiHttpRequestForm"; } diff --git a/frontend/src/business/components/api/test/components/request/ApiSqlRequestForm.vue b/frontend/src/business/components/api/test/components/request/ApiSqlRequestForm.vue new file mode 100644 index 0000000000..e6ccc31650 --- /dev/null +++ b/frontend/src/business/components/api/test/components/request/ApiSqlRequestForm.vue @@ -0,0 +1,145 @@ +<template> + <el-form :model="request" :rules="rules" ref="request" label-width="100px" :disabled="isReadOnly"> + + <el-form-item :label="$t('api_test.request.name')" prop="name"> + <el-input v-model="request.name" maxlength="300" show-word-limit/> + </el-form-item> + + <el-form-item :label="$t('api_test.request.sql.dataSource')" prop="dataSource"> + <el-select v-model="request.dataSource"> + <el-option v-for="(item, index) in databaseConfigsOptions" :key="index" :value="item.id" :label="item.name"/> + </el-select> + </el-form-item> + + <!--<el-form-item :label="'查询类型'" prop="protocol">--> + <!--<el-select v-model="request.queryType">--> + <!--<el-option label="dubbo://" :value="protocols.DUBBO"/>--> + <!--</el-select>--> + <!--</el-form-item>--> + + <el-form-item :label="$t('api_test.request.sql.timeout')" prop="queryTimeout"> + <el-input-number :disabled="isReadOnly" size="mini" v-model="request.queryTimeout" :placeholder="$t('commons.millisecond')" :max="1000*10000000" :min="0"/> + </el-form-item> + + <el-form-item> + <el-switch + v-model="request.useEnvironment" + :active-text="$t('api_test.request.refer_to_environment')" @change="getDatabaseConfigsOptions"> + </el-switch> + </el-form-item> + + <el-button :disabled="!request.enable || !scenario.enable || isReadOnly" class="debug-button" size="small" type="primary" @click="runDebug">{{$t('api_test.request.debug')}}</el-button> + + <el-tabs v-model="activeName"> + <el-tab-pane :label="$t('api_test.request.sql.sql_script')" name="sql"> + <div class="sql-content" > + <ms-code-edit mode="sql" :read-only="isReadOnly" :modes="['sql']" :data.sync="request.query" theme="eclipse" ref="codeEdit"/> + </div> + </el-tab-pane> + <el-tab-pane :label="$t('api_test.request.assertions.label')" name="assertions"> + <ms-api-assertions :is-read-only="isReadOnly" :assertions="request.assertions"/> + </el-tab-pane> + <el-tab-pane :label="$t('api_test.request.extract.label')" name="extract"> + <ms-api-extract :is-read-only="isReadOnly" :extract="request.extract"/> + </el-tab-pane> + <el-tab-pane :label="$t('api_test.request.processor.pre_exec_script')" name="beanShellPreProcessor"> + <ms-jsr233-processor :is-read-only="isReadOnly" :jsr223-processor="request.jsr223PreProcessor"/> + </el-tab-pane> + <el-tab-pane :label="$t('api_test.request.processor.post_exec_script')" name="beanShellPostProcessor"> + <ms-jsr233-processor :is-read-only="isReadOnly" :jsr223-processor="request.jsr223PostProcessor"/> + </el-tab-pane> + </el-tabs> + </el-form> +</template> + +<script> + import MsApiKeyValue from "../ApiKeyValue"; + import MsApiAssertions from "../assertion/ApiAssertions"; + import {DubboRequest, Scenario, SqlRequest} from "../../model/ScenarioModel"; + import MsApiExtract from "../extract/ApiExtract"; + import ApiRequestMethodSelect from "../collapse/ApiRequestMethodSelect"; + import MsDubboInterface from "@/business/components/api/test/components/request/dubbo/Interface"; + import MsDubboRegistryCenter from "@/business/components/api/test/components/request/dubbo/RegistryCenter"; + import MsDubboConfigCenter from "@/business/components/api/test/components/request/dubbo/ConfigCenter"; + import MsDubboConsumerService from "@/business/components/api/test/components/request/dubbo/ConsumerAndService"; + import MsJsr233Processor from "../processor/Jsr233Processor"; + import MsCodeEdit from "../../../../common/components/MsCodeEdit"; + + export default { + name: "MsApiSqlRequestForm", + components: { + MsCodeEdit, + MsJsr233Processor, + MsDubboConsumerService, + MsDubboConfigCenter, + MsDubboRegistryCenter, + MsDubboInterface, ApiRequestMethodSelect, MsApiExtract, MsApiAssertions, MsApiKeyValue + }, + props: { + request: SqlRequest, + scenario: Scenario, + isReadOnly: { + type: Boolean, + default: false + } + }, + + data() { + return { + activeName: "sql", + databaseConfigsOptions: [], + rules: { + name: [ + {required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, + {max: 300, message: this.$t('commons.input_limit', [1, 300]), trigger: 'blur'}, + ], + dataSource: [ + {required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, + ], + } + } + }, + + methods: { + getDatabaseConfigsOptions() { + this.databaseConfigsOptions = []; + let names = new Set(); + let ids = new Set(); + this.scenario.databaseConfigs.forEach(config => { + this.databaseConfigsOptions.push(config); + names.add(config.name); + ids.add(config.id); + }); + if (this.request.useEnvironment && this.scenario.environment) { + this.scenario.environment.config.databaseConfigs.forEach(config => { + if (!names.has(config.name)) { + this.databaseConfigsOptions.push(config); + ids.add(config.id); + } + }); + } + if (!ids.has(this.request.dataSource)) { + this.request.dataSource = undefined; + } + }, + runDebug() { + this.$emit('runDebug'); + } + }, + + created() { + this.getDatabaseConfigsOptions(); + }, + activated() { + this.getDatabaseConfigsOptions(); + } + } +</script> + +<style scoped> + + .sql-content { + height: calc(100vh - 570px); + } + +</style> diff --git a/frontend/src/business/components/api/test/components/request/database/DatabaseConfig.vue b/frontend/src/business/components/api/test/components/request/database/DatabaseConfig.vue index cbd209758a..7d12283bdb 100644 --- a/frontend/src/business/components/api/test/components/request/database/DatabaseConfig.vue +++ b/frontend/src/business/components/api/test/components/request/database/DatabaseConfig.vue @@ -1,6 +1,6 @@ <template> <div> - <ms-database-from :config="currentConfig" @save="addConfig" ref="databaseFrom"/> + <ms-database-from :config="currentConfig" :callback="addConfig" ref="databaseFrom"/> <ms-database-config-list v-if="configs.length > 0" :table-data="configs"/> </div> </template> @@ -31,14 +31,16 @@ addConfig(config) { for (let item of this.configs) { if (item.name === config.name) { - this.$warning("名称重复"); + this.$warning(this.$t('commons.already_exists')); return; } } config.id = getUUID(); - this.configs.push(config); - this.currentConfig = new DatabaseConfig(); - } + let item = {}; + Object.assign(item, config); + this.configs.push(item); + this.currentConfig = new DatabaseConfig(); + }, } } </script> diff --git a/frontend/src/business/components/api/test/components/request/database/DatabaseConfigDialog.vue b/frontend/src/business/components/api/test/components/request/database/DatabaseConfigDialog.vue deleted file mode 100644 index e6e81b0b13..0000000000 --- a/frontend/src/business/components/api/test/components/request/database/DatabaseConfigDialog.vue +++ /dev/null @@ -1,58 +0,0 @@ -<template> - <el-dialog :title="'数据库配置'" :visible.sync="visible"> - <ms-database-from :config="config" @save="editConfig"/> - </el-dialog> -</template> - -<script> - import MsDatabaseConfigList from "./DatabaseConfigList"; - import MsDatabaseFrom from "./DatabaseFrom"; - import {DatabaseConfig} from "../../../model/ScenarioModel"; - - export default { - name: "MsDatabaseConfigDialog", - components: {MsDatabaseFrom, MsDatabaseConfigList}, - props: { - configs: Array, - isReadOnly: { - type: Boolean, - default: false - }, - }, - data() { - return { - visible: false, - config: new DatabaseConfig(), - } - }, - methods: { - open(config) { - this.visible = true; - Object.assign(this.config, config); - }, - editConfig(config) { - let currentConfig = undefined; - for (let item of this.configs) { - if (item.name === config.name && item.id != config.id) { - this.$warning("名称重复"); - return; - } - if (item.id === config.id) { - currentConfig = item; - } - } - if (currentConfig) { - Object.assign(currentConfig, config) - } else { - //copy - this.configs.push(config); - } - this.visible = false; - } - } - } -</script> - -<style scoped> - -</style> diff --git a/frontend/src/business/components/api/test/components/request/database/DatabaseConfigList.vue b/frontend/src/business/components/api/test/components/request/database/DatabaseConfigList.vue index ee76b5242a..3bbb714500 100644 --- a/frontend/src/business/components/api/test/components/request/database/DatabaseConfigList.vue +++ b/frontend/src/business/components/api/test/components/request/database/DatabaseConfigList.vue @@ -1,49 +1,44 @@ <template> - <ms-main-container> + <div class="database-config-list"> <el-table border :data="tableData" class="adjust-table table-content" @row-click="handleView"> - <el-table-column prop="name" :label="'连接池名称'" show-overflow-tooltip/> - <el-table-column prop="driver" :label="'数据库驱动'" show-overflow-tooltip/> - <el-table-column prop="dbUrl" :label="'数据库连接URL'" show-overflow-tooltip/> - <el-table-column prop="username" :label="'用户名'" show-overflow-tooltip/> - <el-table-column prop="poolMax" :label="'最大连接数'" show-overflow-tooltip/> - <el-table-column prop="timeout" :label="'最大等待时间'" show-overflow-tooltip/> + <el-table-column type="expand"> + <template slot-scope="props"> + <ms-database-from :callback="editConfig" :config="props.row"/> + </template> + </el-table-column> + <el-table-column prop="name" :label="$t('api_test.request.sql.dataSource')" show-overflow-tooltip/> + <el-table-column prop="driver" :label="$t('api_test.request.sql.database_driver')" show-overflow-tooltip/> + <el-table-column prop="dbUrl" :label="$t('api_test.request.sql.database_url')" show-overflow-tooltip/> + <el-table-column prop="username" :label="$t('api_test.request.sql.username')" show-overflow-tooltip/> + <el-table-column prop="poolMax" :label="$t('api_test.request.sql.pool_max')" show-overflow-tooltip/> + <el-table-column prop="timeout" :label="$t('api_test.request.sql.query_timeout')" show-overflow-tooltip/> - <el-table-column - :label="$t('commons.operating')" min-width="100"> + <el-table-column :label="$t('commons.operating')" min-width="100"> <template v-slot:default="scope"> - <ms-table-operator :is-tester-permission="true" @editClick="handleEdit(scope.row)" - @deleteClick="handleDelete(scope.$index)"> - <template v-slot:middle> - <ms-table-operator-button :is-tester-permission="true" :tip="$t('commons.copy')" - icon="el-icon-document-copy" - type="success" @exec="handleCopy(scope.row)"/> - </template> - </ms-table-operator> + <ms-table-operator-button :is-tester-permission="true" :tip="$t('commons.copy')" icon="el-icon-document-copy" type="success" @exec="handleCopy(scope.$index, scope.row)"/> + <ms-table-operator-button :isTesterPermission="true" :tip="$t('commons.delete')" icon="el-icon-delete" type="danger" @exec="handleDelete(scope.$index)"/> </template> </el-table-column> </el-table> - <ms-database-config-dialog :configs="tableData" ref="databaseConfigEdit"/> - - </ms-main-container> + </div> </template> <script> import {DatabaseConfig} from "../../../model/ScenarioModel"; - import MsMainContainer from "../../../../../common/components/MsMainContainer"; import MsTableOperator from "../../../../../common/components/MsTableOperator"; import MsTableOperatorButton from "../../../../../common/components/MsTableOperatorButton"; - import MsDatabaseConfigDialog from "./DatabaseConfigDialog"; import {getUUID} from "../../../../../../../common/js/utils"; + import MsDatabaseFrom from "./DatabaseFrom"; export default { name: "MsDatabaseConfigList", - components: {MsDatabaseConfigDialog, MsTableOperatorButton, MsTableOperator, MsMainContainer}, + components: {MsDatabaseFrom, MsTableOperatorButton, MsTableOperator}, props: { tableData: Array, isReadOnly: { @@ -63,15 +58,39 @@ handleEdit(config) { this.$refs.databaseConfigEdit.open(config); }, + editConfig(config) { + let index = 0; + for (let i in this.tableData) { + let item = this.tableData[i]; + if (item.name === config.name && item.id != config.id) { + this.$warning(this.$t('commons.already_exists')); + return; + } + if (item.id === config.id) { + index = i; + } + } + Object.assign(this.tableData[index], config); + this.$success(this.$t('commons.save_success')); + }, handleDelete(index) { this.tableData.splice(index, 1); }, - handleCopy(config) { + handleCopy(index, config) { let copy = {}; Object.assign(copy, config); copy.id = getUUID(); - this.$refs.databaseConfigEdit.open(copy); - } + copy.name = this.getNoRepeatName(copy.name); + this.tableData.splice(index + 1, 0, copy); + }, + getNoRepeatName(name) { + for (let i in this.tableData) { + if (this.tableData[i].name === name) { + return this.getNoRepeatName(name + ' copy'); + } + } + return name; + }, } } </script> @@ -82,11 +101,4 @@ float: right; } - .database-from { - padding: 10px; - border: #DCDFE6 solid 1px; - margin: 5px 0; - border-radius: 5px; - } - </style> diff --git a/frontend/src/business/components/api/test/components/request/database/DatabaseFrom.vue b/frontend/src/business/components/api/test/components/request/database/DatabaseFrom.vue index f9adfa69f7..1b2b3f35cf 100644 --- a/frontend/src/business/components/api/test/components/request/database/DatabaseFrom.vue +++ b/frontend/src/business/components/api/test/components/request/database/DatabaseFrom.vue @@ -1,44 +1,44 @@ <template> - <div> - <el-form :model="config" :rules="rules" label-width="150px" size="small" :disabled="isReadOnly" class="database-from" ref="databaseFrom"> + <div class="database-from"> + <el-form :model="currentConfig" :rules="rules" label-width="150px" size="small" :disabled="isReadOnly" ref="databaseFrom"> - <el-form-item :label="'连接池名称'" prop="name"> - <el-input v-model="config.name" maxlength="300" show-word-limit + <el-form-item :label="$t('api_test.request.sql.dataSource')" prop="name"> + <el-input v-model="currentConfig.name" maxlength="300" show-word-limit :placeholder="$t('commons.input_content')"/> </el-form-item> - <el-form-item :label="'数据库连接URL'" prop="dbUrl"> - <el-input v-model="config.dbUrl" maxlength="300" show-word-limit + <el-form-item :label="$t('api_test.request.sql.database_url')" prop="dbUrl"> + <el-input v-model="currentConfig.dbUrl" maxlength="500" show-word-limit :placeholder="$t('commons.input_content')"/> </el-form-item> - <el-form-item :label="'数据库驱动'" prop="driver"> - <el-select v-model="config.driver" class="select-100" clearable> + <el-form-item :label="$t('api_test.request.sql.database_driver')" prop="driver"> + <el-select v-model="currentConfig.driver" class="select-100" clearable> <el-option v-for="p in drivers" :key="p" :label="p" :value="p"/> </el-select> </el-form-item> - <el-form-item :label="'用户名'" prop="username"> - <el-input v-model="config.username" maxlength="300" show-word-limit + <el-form-item :label="$t('api_test.request.sql.username')" prop="username"> + <el-input v-model="currentConfig.username" maxlength="300" show-word-limit :placeholder="$t('commons.input_content')"/> </el-form-item> - <el-form-item :label="'密码'" prop="password"> - <el-input v-model="config.password" maxlength="300" show-word-limit + <el-form-item :label="$t('api_test.request.sql.password')" prop="password"> + <el-input v-model="currentConfig.password" maxlength="200" show-word-limit :placeholder="$t('commons.input_content')"/> </el-form-item> - <el-form-item :label="'最大连接数'" prop="poolMax"> - <el-input-number size="small" :disabled="isReadOnly" v-model="config.poolMax" :placeholder="$t('commons.millisecond')" :max="1000*10000000" :min="0"/> + <el-form-item :label="$t('api_test.request.sql.pool_max')" prop="poolMax"> + <el-input-number size="small" :disabled="isReadOnly" v-model="currentConfig.poolMax" :placeholder="$t('commons.please_select')" :max="1000*10000000" :min="0"/> </el-form-item> - <el-form-item :label="'最大等待时间(ms)'" prop="timeout"> - <el-input-number size="small" :disabled="isReadOnly" v-model="config.timeout" :placeholder="$t('commons.millisecond')" :max="1000*10000000" :min="0"/> + <el-form-item :label="$t('api_test.request.sql.timeout')" prop="timeout"> + <el-input-number size="small" :disabled="isReadOnly" v-model="currentConfig.timeout" :placeholder="$t('commons.millisecond')" :max="1000*10000000" :min="0"/> </el-form-item> <el-form-item> - <el-button type="primary" size="small" class="addButton" @click="save">添加</el-button> + <el-button type="primary" size="small" class="addButton" @click="save">{{currentConfig.id ? $t('commons.save') : $t('commons.add')}}</el-button> </el-form-item> </el-form> @@ -63,11 +63,22 @@ return new DatabaseConfig(); } }, + callback: { + type: Function + }, + }, + watch: { + config() { + Object.assign(this.currentConfig, this.config); + } + }, + mounted() { + Object.assign(this.currentConfig, this.config); }, data() { return { drivers: DatabaseConfig.DRIVER_CLASS, - // config: new DatabaseConfig(), + currentConfig: new DatabaseConfig(), rules: { name: [ {required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, @@ -94,8 +105,9 @@ save() { this.$refs['databaseFrom'].validate((valid) => { if (valid) { - this.$emit('save', this.config); - // this.config = new DatabaseConfig(); + if (this.callback) { + this.callback(this.currentConfig); + } } else { return false; } @@ -111,11 +123,4 @@ float: right; } - .database-from { - padding: 10px; - border: #DCDFE6 solid 1px; - margin: 5px 0; - border-radius: 5px; - } - </style> diff --git a/frontend/src/business/components/api/test/model/EnvironmentModel.js b/frontend/src/business/components/api/test/model/EnvironmentModel.js new file mode 100644 index 0000000000..aa4207d246 --- /dev/null +++ b/frontend/src/business/components/api/test/model/EnvironmentModel.js @@ -0,0 +1,121 @@ +import {BaseConfig, DatabaseConfig, KeyValue} from "./ScenarioModel"; + +export class Environment extends BaseConfig { + constructor(options = {}) { + + super(); + + this.projectId = undefined; + this.name = undefined; + this.id = undefined; + this.config = options.config || new Config(); + + this.set(options); + this.sets({}, options); + } + + initOptions(options = {}) { + return options; + } +} + +export class Config extends BaseConfig { + constructor(options = {}) { + super(); + this.commonConfig = options.commonConfig || new CommonConfig(); + this.httpConfig = options.httpConfig || new HttpConfig(); + this.databaseConfigs = []; + + this.set(options); + this.sets({databaseConfigs: DatabaseConfig}, options); + } + initOptions(options = {}) { + options.databaseConfigs = options.databaseConfigs || []; + return options; + } +} + +export class CommonConfig extends BaseConfig { + constructor(options = {}) { + super(); + this.variables = []; + this.enableHost = false; + this.hosts = []; + + this.set(options); + this.sets({variables: KeyValue, hosts: Host}, options); + } + + initOptions(options = {}) { + options.variables = options.variables || [new KeyValue()]; + options.hosts = options.hosts || []; + return options; + } +} + +export class HttpConfig extends BaseConfig { + constructor(options = {}) { + super(); + + this.socket = undefined; + this.domain = undefined; + this.headers = []; + this.protocol = 'https'; + this.port = undefined; + + this.set(options); + this.sets({headers: KeyValue}, options); + } + + initOptions(options = {}) { + options.headers = options.headers || [new KeyValue()]; + return options; + } +} + +export class Host extends BaseConfig { + constructor(options = {}) { + super(); + + this.ip = undefined; + this.domain = undefined; + this.status = undefined; + this.annotation = undefined; + this.uuid = undefined; + + this.set(options); + } +} + + + +/* ---------- Functions ------- */ + +export function compatibleWithEnvironment(environment) { + //兼容旧版本 + if (!environment.config) { + let config = new Config(); + if (!(environment.variables instanceof Array)) { + config.commonConfig.variables = JSON.parse(environment.variables); + } + if (environment.hosts && !(environment.hosts instanceof Array)) { + config.commonConfig.hosts = JSON.parse(environment.hosts); + config.commonConfig.enableHost = true; + } + if (!(environment.headers instanceof Array)) { + config.httpConfig.headers = JSON.parse(environment.headers); + } + config.httpConfig.port = environment.port; + config.httpConfig.protocol = environment.protocol; + config.httpConfig.domain = environment.domain; + config.httpConfig.socket = environment.socket; + environment.config = JSON.stringify(config); + } +} + +export function parseEnvironment(environment) { + compatibleWithEnvironment(environment); + if (!(environment.config instanceof Config)) { + environment.config = new Config(JSON.parse(environment.config)); + } +} diff --git a/frontend/src/business/components/api/test/model/JMX.js b/frontend/src/business/components/api/test/model/JMX.js index 1ca205b254..6aafa738d3 100644 --- a/frontend/src/business/components/api/test/model/JMX.js +++ b/frontend/src/business/components/api/test/model/JMX.js @@ -185,7 +185,7 @@ export class TestPlan extends DefaultTestElement { props = props || {}; this.boolProp("TestPlan.functional_mode", props.mode, false); - this.boolProp("TestPlan.serialize_threadgroups", props.stg, false); + this.boolProp("TestPlan.serialize_threadgroups", props.stg, true); this.boolProp("TestPlan.tearDown_on_shutdown", props.tos, true); this.stringProp("TestPlan.comments", props.comments); this.stringProp("TestPlan.user_define_classpath", props.classpath); @@ -274,6 +274,38 @@ export class DubboSample extends DefaultTestElement { } } +export class JDBCSampler extends DefaultTestElement { + constructor(testName, request = {}) { + super('JDBCSampler', 'TestBeanGUI', 'JDBCSampler', testName); + + this.stringProp("dataSource", request.dataSource); + this.stringProp("query", request.query); + this.stringProp("queryTimeout", request.queryTimeout); + this.stringProp("queryArguments"); + this.stringProp("queryArgumentsTypes"); + this.stringProp("resultSetMaxRows"); + this.stringProp("resultVariable"); + this.stringProp("variableNames"); + this.stringProp("resultSetHandler", 'Store as String'); + this.stringProp("queryType", 'Callable Statement'); + } +} + +// <JDBCSampler guiclass="TestBeanGUI" testclass="JDBCSampler" testname="JDBC Request" enabled="true"> +// <stringProp name="dataSource">test</stringProp> +// <stringProp name="query">select id from test_plan; +// select name from test_plan; +// </stringProp> +// <stringProp name="queryArguments"></stringProp> +// <stringProp name="queryArgumentsTypes"></stringProp> +// <stringProp name="queryTimeout"></stringProp> +// <stringProp name="queryType">Callable Statement</stringProp> +// <stringProp name="resultSetHandler">Store as String</stringProp> +// <stringProp name="resultSetMaxRows"></stringProp> +// <stringProp name="resultVariable"></stringProp> +// <stringProp name="variableNames"></stringProp> +// </JDBCSampler> + export class HTTPSamplerProxy extends DefaultTestElement { constructor(testName, options = {}) { super('HTTPSamplerProxy', 'HttpTestSampleGui', 'HTTPSamplerProxy', testName); @@ -318,7 +350,7 @@ export class HTTPSamplerArguments extends Element { let collectionProp = this.collectionProp('Arguments.arguments'); this.args.forEach(arg => { - if (arg.enable === true) { // 非禁用的条件加入执行 + if (arg.enable === true || arg.enable === undefined) { // 非禁用的条件加入执行 let elementProp = collectionProp.elementProp(arg.name, 'HTTPArgument'); elementProp.boolProp('HTTPArgument.always_encode', arg.encode, true); elementProp.boolProp('HTTPArgument.use_equals', arg.equals, true); @@ -507,7 +539,7 @@ export class HeaderManager extends DefaultTestElement { let collectionProp = this.collectionProp('HeaderManager.headers'); this.headers.forEach(header => { - if (header.enable === true) { + if (header.enable === true || header.enable === undefined) { let elementProp = collectionProp.elementProp('', 'Header'); elementProp.stringProp('Header.name', header.name); elementProp.stringProp('Header.value', header.value); @@ -517,23 +549,44 @@ export class HeaderManager extends DefaultTestElement { } export class DNSCacheManager extends DefaultTestElement { - constructor(testName, domain, hosts) { + constructor(testName, hosts) { super('DNSCacheManager', 'DNSCachePanel', 'DNSCacheManager', testName); let collectionPropServers = this.collectionProp('DNSCacheManager.servers'); let collectionPropHosts = this.collectionProp('DNSCacheManager.hosts'); hosts.forEach(host => { let elementProp = collectionPropHosts.elementProp(host.domain, 'StaticHost'); - if (host && host.domain.trim().indexOf(domain.trim()) != -1) { - elementProp.stringProp('StaticHost.Name', host.domain); - elementProp.stringProp('StaticHost.Address', host.ip); - } + elementProp.stringProp('StaticHost.Name', host.domain); + elementProp.stringProp('StaticHost.Address', host.ip); }); let boolProp = this.boolProp('DNSCacheManager.isCustomResolver', true); } } +export class JDBCDataSource extends DefaultTestElement { + constructor(testName, datasource) { + super('JDBCDataSource', 'TestBeanGUI', 'JDBCDataSource', testName); + + this.boolProp('autocommit', true); + this.boolProp('keepAlive', true); + this.boolProp('preinit', false); + this.stringProp('dataSource', datasource.name); + this.stringProp('dbUrl', datasource.dbUrl); + this.stringProp('driver', datasource.driver); + this.stringProp('username', datasource.username); + this.stringProp('password', datasource.password); + this.stringProp('poolMax', datasource.poolMax); + this.stringProp('timeout', datasource.timeout); + this.stringProp('connectionAge', '5000'); + this.stringProp('trimInterval', '60000'); + this.stringProp('transactionIsolation', 'DEFAULT'); + this.stringProp('checkQuery'); + this.stringProp('initQuery'); + this.stringProp('connectionProperties'); + } +} + export class Arguments extends DefaultTestElement { constructor(testName, args) { super('Arguments', 'ArgumentsPanel', 'Arguments', testName); @@ -542,7 +595,7 @@ export class Arguments extends DefaultTestElement { let collectionProp = this.collectionProp('Arguments.arguments'); this.args.forEach(arg => { - if (arg.enable === true) { // 非禁用的条件加入执行 + if (arg.enable === true || arg.enable === undefined) { // 非禁用的条件加入执行 let elementProp = collectionProp.elementProp(arg.name, 'Argument'); elementProp.stringProp('Argument.name', arg.name); elementProp.stringProp('Argument.value', arg.value); @@ -567,7 +620,7 @@ export class ElementArguments extends Element { let collectionProp = this.collectionProp('Arguments.arguments'); if (args) { args.forEach(arg => { - if (arg.enable === true) { // 非禁用的条件加入执行 + if (arg.enable === true || arg.enable === undefined) { // 非禁用的条件加入执行 let elementProp = collectionProp.elementProp(arg.name, 'Argument'); elementProp.stringProp('Argument.name', arg.name); elementProp.stringProp('Argument.value', arg.value); diff --git a/frontend/src/business/components/api/test/model/ScenarioModel.js b/frontend/src/business/components/api/test/model/ScenarioModel.js index 6fbfb5072d..22a0296fd2 100644 --- a/frontend/src/business/components/api/test/model/ScenarioModel.js +++ b/frontend/src/business/components/api/test/model/ScenarioModel.js @@ -10,6 +10,8 @@ import { HTTPSamplerArguments, HTTPsamplerFiles, HTTPSamplerProxy, + HTTPSamplerArguments, HTTPsamplerFiles, + HTTPSamplerProxy, JDBCDataSource, JDBCSampler, JSONPathAssertion, JSONPostProcessor, JSR223PostProcessor, @@ -216,7 +218,7 @@ export class Scenario extends BaseConfig { this.environment = undefined; this.enableCookieShare = false; this.enable = true; - this.databaseConfigs = undefined; + this.databaseConfigs = []; this.set(options); this.sets({ @@ -283,6 +285,7 @@ export class RequestFactory { static TYPES = { HTTP: "HTTP", DUBBO: "DUBBO", + SQL: "SQL", } constructor(options = {}) { @@ -290,6 +293,8 @@ export class RequestFactory { switch (options.type) { case RequestFactory.TYPES.DUBBO: return new DubboRequest(options); + case RequestFactory.TYPES.SQL: + return new SqlRequest(options); default: return new HttpRequest(options); } @@ -470,6 +475,62 @@ export class DubboRequest extends Request { } } +export class SqlRequest extends Request { + + constructor(options = {}) { + super(RequestFactory.TYPES.SQL); + this.id = options.id || uuid(); + this.name = options.name; + this.useEnvironment = options.useEnvironment; + this.debugReport = undefined; + this.dataSource = options.dataSource; + this.query = options.query; + // this.queryType = options.queryType; + this.queryTimeout = options.queryTimeout || 60000; + this.enable = options.enable === undefined ? true : options.enable; + this.assertions = new Assertions(options.assertions); + this.extract = new Extract(options.extract); + this.jsr223PreProcessor = new JSR223Processor(options.jsr223PreProcessor); + this.jsr223PostProcessor = new JSR223Processor(options.jsr223PostProcessor); + + this.sets({args: KeyValue, attachmentArgs: KeyValue}, options); + + } + + isValid() { + if (this.enable) { + if (!this.name) { + return { + isValid: false, + info: 'api_test.request.sql.name_cannot_be_empty' + } + } + if (!this.dataSource) { + return { + isValid: false, + info: 'api_test.request.sql.dataSource_cannot_be_empty' + } + } + } + return { + isValid: true + } + } + + showType() { + return "SQL"; + } + + showMethod() { + return "SQL"; + } + + clone() { + return new SqlRequest(this); + } +} + + export class ConfigCenter extends BaseConfig { static PROTOCOLS = ["zookeeper", "nacos", "apollo"]; @@ -492,7 +553,7 @@ export class ConfigCenter extends BaseConfig { } export class DatabaseConfig extends BaseConfig { - static DRIVER_CLASS = ["com.mysql.jdbc.Driver"]; + static DRIVER_CLASS = ["com.mysql.jdbc.Driver", "com.microsoft.sqlserver.jdbc.SQLServerDriver", "org.postgresql.Driver", "oracle.jdbc.OracleDriver"]; constructor(options) { super(); @@ -513,25 +574,6 @@ export class DatabaseConfig extends BaseConfig { return options; } -// <JDBCDataSource guiclass="TestBeanGUI" testclass="JDBCDataSource" testname="JDBC Connection Configurationqqq" enabled="true"> -// <boolProp name="autocommit">true</boolProp> -// <stringProp name="checkQuery"></stringProp> -// <stringProp name="connectionAge">5000</stringProp> -// <stringProp name="connectionProperties"></stringProp> -// <stringProp name="dataSource">test</stringProp> -// <stringProp name="dbUrl">jdbc:mysql://localhost:3306/metersphere?autoReconnect=false&useUnicode=true&characterEncoding=UTF-8&characterSetResults=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true</stringProp> -// <stringProp name="driver">com.mysql.jdbc.Driver</stringProp> -// <stringProp name="initQuery"></stringProp> -// <boolProp name="keepAlive">true</boolProp> -// <stringProp name="password">root</stringProp> -// <stringProp name="poolMax">10</stringProp> -// <boolProp name="preinit">false</boolProp> -// <stringProp name="timeout">10000</stringProp> -// <stringProp name="transactionIsolation">DEFAULT</stringProp> -// <stringProp name="trimInterval">60000</stringProp> -// <stringProp name="username">root</stringProp> -// </JDBCDataSource> - isValid() { return !!this.name || !!this.poolMax || !!this.timeout || !!this.driver || !!this.dbUrl || !!this.username || !!this.password; } @@ -901,10 +943,10 @@ class JMXHttpRequest { this.protocol = url.protocol.split(":")[0]; this.path = this.getPostQueryParameters(request, decodeURIComponent(url.pathname)); } else { - this.domain = environment.domain; - this.port = environment.port; - this.protocol = environment.protocol; - let url = new URL(environment.protocol + "://" + environment.socket); + this.domain = environment.config.httpConfig.domain; + this.port = environment.config.httpConfig.port; + this.protocol = environment.config.httpConfig.protocol; + let url = new URL(environment.config.httpConfig.protocol + "://" + environment.config.commonConfig.socket); this.path = this.getPostQueryParameters(request, decodeURIComponent(url.pathname + (request.path ? request.path : ''))); } this.connectTimeout = request.connectTimeout; @@ -1011,6 +1053,8 @@ class JMXGenerator { // 放在计划或线程组中,不建议放具体某个请求中 this.addDNSCacheManager(threadGroup, scenario.requests[0]); + this.addJDBCDataSources(threadGroup, scenario); + scenario.requests.forEach(request => { if (request.enable) { if (!request.isValid()) return; @@ -1018,9 +1062,7 @@ class JMXGenerator { if (request instanceof DubboRequest) { sampler = new DubboSample(request.name || "", new JMXDubboRequest(request, scenario.dubboConfig)); - } - - if (request instanceof HttpRequest) { + } else if (request instanceof HttpRequest) { sampler = new HTTPSamplerProxy(request.name || "", new JMXHttpRequest(request, scenario.environment)); this.addRequestHeader(sampler, request); if (request.method.toUpperCase() === 'GET') { @@ -1028,6 +1070,9 @@ class JMXGenerator { } else { this.addRequestBody(sampler, request, testId); } + } else if (request instanceof SqlRequest) { + request.dataSource = scenario.databaseConfigMap.get(request.dataSource); + sampler = new JDBCSampler(request.name || "", request); } this.addRequestExtractor(sampler, request); @@ -1062,22 +1107,22 @@ class JMXGenerator { let envArray = environments; if (!(envArray instanceof Array)) { envArray = JSON.parse(environments); - envArray.forEach(item => { - if (item.name && !keys.has(item.name)) { - target.push(new KeyValue(item.name, item.value)); - } - }) } + envArray.forEach(item => { + if (item.name && !keys.has(item.name)) { + target.push(new KeyValue(item.name, item.value)); + } + }) } addScenarioVariables(threadGroup, scenario) { - let environment = scenario.environment; - if (environment) { - this.addEnvironments(environment.variables, scenario.variables) + if (scenario.environment) { + let commonConfig = scenario.environment.config.commonConfig; + this.addEnvironments(commonConfig.variables, scenario.variables) } let args = this.filterKV(scenario.variables); if (args.length > 0) { - let name = scenario.name + " Variables" + let name = scenario.name + " Variables"; threadGroup.put(new Arguments(name, args)); } } @@ -1089,24 +1134,58 @@ class JMXGenerator { } addDNSCacheManager(threadGroup, request) { - if (request.environment && request.environment.hosts) { - let name = request.name + " DNSCacheManager"; - let hosts = JSON.parse(request.environment.hosts); - if (hosts.length > 0) { - //let domain = request.environment.protocol + "://" + request.environment.domain; - threadGroup.put(new DNSCacheManager(name, request.environment.domain, hosts)); + if (request.environment) { + let commonConfig = request.environment.config.commonConfig; + let hosts = commonConfig.hosts; + if (commonConfig.enableHost && hosts.length > 0) { + let name = request.name + " DNSCacheManager"; + // 强化判断,如果未匹配到合适的host则不开启DNSCache + let domain = request.environment.config.httpConfig.domain; + let validHosts = []; + hosts.forEach(item => { + let d = item.domain.trim().replace("http://", "").replace("https://", ""); + if (item && d === domain.trim()) { + item.domain = d; // 域名去掉协议 + validHosts.push(item); + } + }); + if (validHosts.length > 0) { + threadGroup.put(new DNSCacheManager(name, validHosts)); + } } } } + addJDBCDataSources(threadGroup, scenario) { + let names = new Set(); + let databaseConfigMap = new Map(); + scenario.databaseConfigs.forEach(config => { + let name = config.name + "JDBCDataSource"; + threadGroup.put(new JDBCDataSource(name, config)); + names.add(name); + databaseConfigMap.set(config.id, config.name); + }); + if (scenario.environment) { + let envDatabaseConfigs = scenario.environment.config.databaseConfigs; + envDatabaseConfigs.forEach(config => { + if (!names.has(config.name)) { + let name = config.name + "JDBCDataSource"; + threadGroup.put(new JDBCDataSource(name, config)); + databaseConfigMap.set(config.id, config.name); + } + }); + } + scenario.databaseConfigMap = databaseConfigMap; + } + addScenarioHeaders(threadGroup, scenario) { - let environment = scenario.environment; - if (environment) { - this.addEnvironments(environment.headers, scenario.headers) + if (scenario.environment) { + let httpConfig = scenario.environment.config.httpConfig; + this.addEnvironments(httpConfig.headers, scenario.headers) } let headers = this.filterKV(scenario.headers); if (headers.length > 0) { - let name = scenario.name + " Headers" + let name = scenario.name + " Headers"; threadGroup.put(new HeaderManager(name, headers)); } } diff --git a/frontend/src/business/components/common/components/MsScheduleConfig.vue b/frontend/src/business/components/common/components/MsScheduleConfig.vue index 10aedf82dd..fe55e28700 100644 --- a/frontend/src/business/components/common/components/MsScheduleConfig.vue +++ b/frontend/src/business/components/common/components/MsScheduleConfig.vue @@ -6,7 +6,7 @@ <span class="character">SCHEDULER</span> </span> <el-switch :disabled="!schedule.value || isReadOnly" v-model="schedule.enable" @change="scheduleChange"/> - <ms-schedule-edit :is-read-only="isReadOnly" :schedule="schedule" :save="save" :custom-validate="customValidate" + <ms-schedule-edit :is-read-only="isReadOnly" :schedule="schedule" :test-id="testId" :save="save" :custom-validate="customValidate" ref="scheduleEdit"/> </div> @@ -38,6 +38,7 @@ export default { } }, props: { + testId:String, save: Function, schedule: {}, checkOpen: { diff --git a/frontend/src/business/components/common/components/MsScheduleEdit.vue b/frontend/src/business/components/common/components/MsScheduleEdit.vue index b7b0f1d542..cb6d6bd411 100644 --- a/frontend/src/business/components/common/components/MsScheduleEdit.vue +++ b/frontend/src/business/components/common/components/MsScheduleEdit.vue @@ -4,6 +4,7 @@ <template> <div> <el-tabs v-model="activeName" @tab-click="handleClick"> + <el-tab-pane :label="$t('schedule.edit_timer_task')" name="first"> <el-form :model="form" :rules="rules" ref="from"> <el-form-item @@ -26,81 +27,51 @@ </el-tab-pane> <el-tab-pane :label="$t('schedule.task_notification')" name="second"> <template> - <el-select v-model="value" :placeholder="$t('commons.please_select')"> - <el-option - v-for="item in options" - :key="item.value" - :label="item.label" - :value="item.value"> - </el-option> - </el-select> <el-table :data="tableData" style="width: 100%"> <el-table-column - prop="receiver" + prop="event" + :label="$t('schedule.event')" + + > + </el-table-column> + <el-table-column + prop="name" :label="$t('schedule.receiver')" + width="200" > <template v-slot:default="{row}"> - <el-input - size="mini" - type="textarea" - :rows="1" - class="edit-input" - v-model="row.receiver" - :placeholder="$t('schedule.receiver')" - clearable> - </el-input> + <el-select v-model="row.names" filterable multiple placeholder="请选择" @click.native="userList()"> + <el-option + v-for="item in options" + :key="item.id" + :label="item.name" + :value="item.name"> + </el-option> + </el-select> </template> </el-table-column> <el-table-column prop="email" :label="$t('schedule.receiving_mode')" - width="200" - > - <template v-slot:default="{row}"> - <el-input - size="mini" - type="textarea" - :rows="1" - class="edit-input" - v-model="row.email" - :placeholder="$t('schedule.input_email')" - clearable> - </el-input> - </template> + > </el-table-column> <el-table-column - align="center" - prop="enable" :label="$t('test_resource_pool.enable_disable')" - > - <template slot-scope="scope"> + prop="enable" + > + <template v-slot:default="{row}"> <el-switch - v-model="scope.row.enable" - :active-value="true" - :inactive-value="false" - active-color="#13ce66" + v-model="row.enable" + active-value="true" + inactive-value="false" inactive-color="#ff4949" /> </template> </el-table-column> - <el-table-column :label="$t('schedule.operation')"> - <template v-slot:default="scope"> - <el-button - type="primary" - icon="el-icon-plus" - circle size="mini" - @click="handleAddStep(scope.$index)"></el-button> - <el-button - type="danger" - icon="el-icon-delete" - circle size="mini" - @click="handleDeleteStep(scope.$index)"></el-button> - </template> - </el-table-column> </el-table> - <el-button type="primary" @click="saveNotice">{{$t('commons.save')}}</el-button> + <el-button type="primary" @click="saveNotice">{{$t('commons.save')}}</el-button> </template> </el-tab-pane> </el-tabs> @@ -111,19 +82,20 @@ <script> -import Crontab from "../cron/Crontab"; -import CrontabResult from "../cron/CrontabResult"; -import {cronValidate} from "@/common/js/cron"; -import {listenGoBack, removeGoBackListener} from "@/common/js/utils"; + import Crontab from "../cron/Crontab"; + import CrontabResult from "../cron/CrontabResult"; + import {cronValidate} from "@/common/js/cron"; + import {listenGoBack, removeGoBackListener} from "@/common/js/utils"; -function defaultCustomValidate() { - return {pass: true}; -} + function defaultCustomValidate() { + return {pass: true}; + } export default { name: "MsScheduleEdit", components: {CrontabResult, Crontab}, props: { + testId: String, save: Function, schedule: {}, customValidate: { @@ -133,8 +105,10 @@ function defaultCustomValidate() { isReadOnly: { type: Boolean, default: false - } + }, }, + + watch: { 'schedule.value'() { this.form.cronValue = this.schedule.value; @@ -164,25 +138,22 @@ function defaultCustomValidate() { form: { cronValue: "" }, - options: [{ - value: 'success', - label: '执行成功通知' - }, { - value: 'fail', - label: '执行失败通知' - }, { - value: 'all', - label: '全部通知' - }], - value: '', tableData: [ { - receiver: "", - email: "", + event: "执行成功", + names: [], + email: "邮箱", + enable: false + }, + { + event: "执行失败", + names: [], + email: "邮箱", enable: false } ], - enable: false, + options: [{}], + enable: true, email: "", activeName: 'first', rules: { @@ -191,37 +162,31 @@ function defaultCustomValidate() { } }, methods: { - handleClick() { - + userList() { + this.result = this.$get('user/list', response => { + this.options = response.data + }) }, - saveNotice(){ - let param = this.buildParam(); - /* this.result=this.$post("notice/save",param,()=>{ - - })*/ - + handleClick() { + if (this.activeName == "second") { + this.result = this.$get('notice/query/' + this.testId, response => { + if (response.data.length > 0) { + this.tableData = response.data + } + }) + } }, buildParam() { let param = {}; - param.form = this.tableData - param.testId = "" - param.event = this.value + param.notices = this.tableData + param.testId = this.testId return param; }, - handleAddStep(index) { - let form = {} - form.receiver = null; - form.email = null; - form.enable = null - this.tableData.splice(index + 1, 0, form); - }, - handleDeleteStep(index) { - this.tableData.splice(index, 1); - }, open() { this.dialogVisible = true; this.form.cronValue = this.schedule.value; listenGoBack(this.close); + this.handleClick() }, crontabFill(value, resultList) { //确定后回传的值 @@ -243,6 +208,12 @@ function defaultCustomValidate() { } }); }, + saveNotice(){ + let param = this.buildParam(); + this.result = this.$post("notice/save", param, () => { + this.$success(this.$t('commons.save_success')); + }) + }, close() { this.dialogVisible = false; this.form.cronValue = ''; @@ -274,13 +245,13 @@ function defaultCustomValidate() { <style scoped> -.inp { - width: 50%; - margin-right: 20px; -} + .inp { + width: 50%; + margin-right: 20px; + } -.el-form-item { - margin-bottom: 10px; -} + .el-form-item { + margin-bottom: 10px; + } </style> diff --git a/frontend/src/business/components/performance/test/EditPerformanceTestPlan.vue b/frontend/src/business/components/performance/test/EditPerformanceTestPlan.vue index 6c1f611061..8bfd028b70 100644 --- a/frontend/src/business/components/performance/test/EditPerformanceTestPlan.vue +++ b/frontend/src/business/components/performance/test/EditPerformanceTestPlan.vue @@ -30,7 +30,7 @@ </el-button> <ms-schedule-config :schedule="testPlan.schedule" :save="saveCronExpression" @scheduleChange="saveSchedule" - :check-open="checkScheduleEdit" :custom-validate="durationValidate"/> + :check-open="checkScheduleEdit" :test-id="testId" :custom-validate="durationValidate"/> </el-col> </el-row> diff --git a/frontend/src/business/components/performance/test/components/PerformanceAdvancedConfig.vue b/frontend/src/business/components/performance/test/components/PerformanceAdvancedConfig.vue index ea28c41c89..5429c37056 100644 --- a/frontend/src/business/components/performance/test/components/PerformanceAdvancedConfig.vue +++ b/frontend/src/business/components/performance/test/components/PerformanceAdvancedConfig.vue @@ -90,6 +90,20 @@ </el-form-item> </el-form> </el-col> + <el-col :span="8"> + <el-form :inline="true"> + <el-form-item> + <div>{{ $t('load_test.response_timeout') }}</div> + </el-form-item> + <el-form-item> + <el-input-number :disabled="readOnly" size="mini" v-model="responseTimeout" :min="10" + :max="100000"></el-input-number> + </el-form-item> + <el-form-item> + ms + </el-form-item> + </el-form> + </el-col> </el-row> <el-row> <el-col :span="8"> @@ -109,169 +123,170 @@ </template> <script> - import MsTableOperatorButton from "../../../common/components/MsTableOperatorButton"; +import MsTableOperatorButton from "../../../common/components/MsTableOperatorButton"; - export default { - name: "PerformanceAdvancedConfig", - components: {MsTableOperatorButton}, - data() { - return { - timeout: 2000, - statusCode: [], - domains: [], - params: [], - statusCodeStr: '', - } +export default { + name: "PerformanceAdvancedConfig", + components: {MsTableOperatorButton}, + data() { + return { + timeout: 2000, + responseTimeout: null, + statusCode: [], + domains: [], + params: [], + statusCodeStr: '', + } + }, + props: { + readOnly: { + type: Boolean, + default: false }, - props: { - readOnly: { - type: Boolean, - default: false - }, - testId: String, - }, - mounted() { + testId: String, + }, + mounted() { + if (this.testId) { + this.getAdvancedConfig(); + } + }, + watch: { + testId() { if (this.testId) { this.getAdvancedConfig(); } - }, - watch: { - testId() { - if (this.testId) { - this.getAdvancedConfig(); + } + }, + methods: { + getAdvancedConfig() { + this.$get('/performance/get-advanced-config/' + this.testId, (response) => { + if (response.data) { + let data = JSON.parse(response.data); + this.timeout = data.timeout || 10; + this.responseTimeout = data.responseTimeout; + this.statusCode = data.statusCode || []; + this.statusCodeStr = this.statusCode.join(','); + this.domains = data.domains || []; + this.params = data.params || []; } + }); + }, + add(dataName) { + if (dataName === 'domains') { + this[dataName].push({ + domain: 'fit2cloud.com', + enable: true, + ip: '127.0.0.1', + edit: true, + }); + } + if (dataName === 'params') { + this[dataName].push({ + name: 'param1', + enable: true, + value: '0', + edit: true, + }); } }, - methods: { - getAdvancedConfig() { - this.$get('/performance/get-advanced-config/' + this.testId, (response) => { - if (response.data) { - let data = JSON.parse(response.data); - this.timeout = data.timeout || 10; - this.statusCode = data.statusCode || []; - this.statusCodeStr = this.statusCode.join(','); - this.domains = data.domains || []; - this.params = data.params || []; - /*this.domains.forEach(d => d.edit = false); - this.params.forEach(d => d.edit = false);*/ - } - }); - }, - add(dataName) { - if (dataName === 'domains') { - this[dataName].push({ - domain: 'fit2cloud.com', - enable: true, - ip: '127.0.0.1', - edit: true, - }); + edit(row) { + row.edit = !row.edit + }, + del(row, dataName, index) { + this[dataName].splice(index, 1); + }, + confirmEdit(row) { + row.edit = false; + row.enable = true; + }, + groupBy(data, key) { + return data.reduce((p, c) => { + let name = c[key]; + if (!p.hasOwnProperty(name)) { + p[name] = 0; } - if (dataName === 'params') { - this[dataName].push({ - name: 'param1', - enable: true, - value: '0', - edit: true, - }); - } - }, - edit(row) { - row.edit = !row.edit - }, - del(row, dataName, index) { - this[dataName].splice(index, 1); - }, - confirmEdit(row) { - row.edit = false; - row.enable = true; - }, - groupBy(data, key) { - return data.reduce((p, c) => { - let name = c[key]; - if (!p.hasOwnProperty(name)) { - p[name] = 0; - } - p[name]++; - return p; - }, {}); - }, - validConfig() { - let counts = this.groupBy(this.domains, 'domain'); - for (let c in counts) { - if (counts[c] > 1) { - this.$error(this.$t('load_test.domain_is_duplicate')); - return false; - } - } - counts = this.groupBy(this.params, 'name'); - for (let c in counts) { - if (counts[c] > 1) { - this.$error(this.$t('load_test.param_is_duplicate')); - return false; - } - } - if (this.domains.filter(d => !d.domain || !d.ip).length > 0) { - this.$error(this.$t('load_test.domain_ip_is_empty')); + p[name]++; + return p; + }, {}); + }, + validConfig() { + let counts = this.groupBy(this.domains, 'domain'); + for (let c in counts) { + if (counts[c] > 1) { + this.$error(this.$t('load_test.domain_is_duplicate')); return false; } - if (this.params.filter(d => !d.name || !d.value).length > 0) { - this.$error(this.$t('load_test.param_name_value_is_empty')); + } + counts = this.groupBy(this.params, 'name'); + for (let c in counts) { + if (counts[c] > 1) { + this.$error(this.$t('load_test.param_is_duplicate')); return false; } - return true; - }, - checkStatusCode() { - let license_num = this.statusCodeStr; - license_num = license_num.replace(/[^\d,]/g, ''); // 清除“数字”和“.”以外的字符 - this.statusCodeStr = license_num; - }, - cancelAllEdit() { - this.domains.forEach(d => d.edit = false); - this.params.forEach(d => d.edit = false); - }, - configurations() { - let statusCode = []; - if (this.statusCodeStr) { - statusCode = this.statusCodeStr.split(','); - } - return { - timeout: this.timeout, - statusCode: statusCode, - params: this.params, - domains: this.domains, - }; - }, - } + } + if (this.domains.filter(d => !d.domain || !d.ip).length > 0) { + this.$error(this.$t('load_test.domain_ip_is_empty')); + return false; + } + if (this.params.filter(d => !d.name || !d.value).length > 0) { + this.$error(this.$t('load_test.param_name_value_is_empty')); + return false; + } + return true; + }, + checkStatusCode() { + let license_num = this.statusCodeStr; + license_num = license_num.replace(/[^\d,]/g, ''); // 清除“数字”和“.”以外的字符 + this.statusCodeStr = license_num; + }, + cancelAllEdit() { + this.domains.forEach(d => d.edit = false); + this.params.forEach(d => d.edit = false); + }, + configurations() { + let statusCode = []; + if (this.statusCodeStr) { + statusCode = this.statusCodeStr.split(','); + } + return { + timeout: this.timeout, + responseTimeout: this.responseTimeout, + statusCode: statusCode, + params: this.params, + domains: this.domains, + }; + }, } +} </script> <style scoped> - .el-row { - margin-bottom: 10px; - } +.el-row { + margin-bottom: 10px; +} - .edit-input { - padding-right: 0px; - } +.edit-input { + padding-right: 0px; +} - .tb-edit .el-textarea { - display: none; - } +.tb-edit .el-textarea { + display: none; +} - .tb-edit .current-row .el-textarea { - display: block; - } +.tb-edit .current-row .el-textarea { + display: block; +} - .tb-edit .current-row .el-textarea + span { - display: none; - } +.tb-edit .current-row .el-textarea + span { + display: none; +} - .el-col { - text-align: left; - } +.el-col { + text-align: left; +} - .el-col .el-table { - align: center; - } +.el-col .el-table { + align: center; +} </style> diff --git a/frontend/src/business/components/track/case/components/TestCaseImport.vue b/frontend/src/business/components/track/case/components/TestCaseImport.vue index 63d6dd3adc..0c41e97d6e 100644 --- a/frontend/src/business/components/track/case/components/TestCaseImport.vue +++ b/frontend/src/business/components/track/case/components/TestCaseImport.vue @@ -1,123 +1,235 @@ <template> - <el-dialog class="testcase-import" :title="$t('test_track.case.import.case_import')" :visible.sync="dialogVisible" - @close="close"> + <el-dialog class="testcase-import" :title="$t('test_track.case.import.case_import')" :visible.sync="dialogVisible" + @close="close"> - <el-row> - <el-link type="primary" class="download-template" - @click="downloadTemplate" - >{{$t('test_track.case.import.download_template')}}</el-link></el-row> - <el-row> - <el-upload - v-loading="result.loading" - :element-loading-text="$t('test_track.case.import.importing')" - element-loading-spinner="el-icon-loading" - class="upload-demo" - multiple - :limit="1" - action="" - :on-exceed="handleExceed" - :beforeUpload="uploadValidate" - :on-error="handleError" - :show-file-list="false" - :http-request="upload" - :file-list="fileList"> - <template v-slot:trigger> - <el-button size="mini" type="success" plain>{{$t('test_track.case.import.click_upload')}}</el-button> - </template> - <template v-slot:tip> - <div class="el-upload__tip">{{$t('test_track.case.import.upload_limit')}}</div> - </template> - </el-upload> - </el-row> + <el-tabs v-model="activeName" simple> + <el-tab-pane :label="$t('test_track.case.import.excel_title')" name="excelImport"> - <el-row> - <ul> - <li v-for="errFile in errList" :key="errFile.rowNum"> - {{errFile.errMsg}} - </li> - </ul> - </el-row> + <el-row> + <el-link type="primary" class="download-template" + @click="downloadTemplate" + >{{$t('test_track.case.import.download_template')}} + </el-link> + </el-row> + <el-row> + <el-upload + v-loading="result.loading" + :element-loading-text="$t('test_track.case.import.importing')" + element-loading-spinner="el-icon-loading" + class="upload-demo" + multiple + :limit="1" + action="" + :on-exceed="handleExceed" + :beforeUpload="uploadValidate" + :on-error="handleError" + :show-file-list="false" + :http-request="upload" + :file-list="fileList"> + <template v-slot:trigger> + <el-button size="mini" type="success" plain>{{$t('test_track.case.import.click_upload')}}</el-button> + </template> + <template v-slot:tip> + <div class="el-upload__tip">{{$t('test_track.case.import.upload_limit')}}</div> + </template> + </el-upload> + </el-row> - </el-dialog> + + <el-row> + <ul> + <li v-for="errFile in errList" :key="errFile.rowNum"> + {{errFile.errMsg}} + </li> + </ul> + </el-row> + </el-tab-pane> + <!-- Xmind 导入 --> + <el-tab-pane :label="$t('test_track.case.import.xmind_title')" name="xmindImport" style="border: 0px"> + <el-row class="import-row"> + <div class="el-step__icon is-text" style="background-color: #C9E6F8;border-color: #C9E6F8;margin-right: 10px"> + <div class="el-step__icon-inner">1</div> + </div> + <label class="ms-license-label">{{$t('test_track.case.import.import_desc')}}</label> + </el-row> + <el-row class="import-row"> + <el-card :body-style="{ padding: '0px' }"> + <img src="../../../../../assets/xmind.jpg" + class="testcase-import-img"> + </el-card> + + </el-row> + <el-row class="import-row"> + <div class="el-step__icon is-text" + style="background-color: #C9E6F8;border-color: #C9E6F8;margin-right: 10px "> + <div class="el-step__icon-inner">2</div> + </div> + <label class="ms-license-label">{{$t('test_track.case.import.import_file')}}</label> + </el-row> + <el-row class="import-row"> + <el-link type="primary" class="download-template" + @click="downloadXmindTemplate" + >{{$t('test_track.case.import.download_template')}} + </el-link> + </el-row> + <el-row class="import-row"> + <el-upload + v-loading="result.loading" + :element-loading-text="$t('test_track.case.import.importing')" + element-loading-spinner="el-icon-loading" + class="upload-demo" + multiple + :limit="1" + action="" + :on-exceed="handleExceed" + :beforeUpload="uploadValidateXmind" + :on-error="handleError" + :show-file-list="false" + :http-request="uploadXmind" + :file-list="fileList"> + <template v-slot:trigger> + <el-button size="mini" type="success" plain>{{$t('test_track.case.import.click_upload')}}</el-button> + </template> + <template v-slot:tip> + <div class="el-upload__tip">{{$t('test_track.case.import.upload_xmind')}}</div> + </template> + </el-upload> + </el-row> + <el-row> + <ul> + <li v-for="errFile in xmindErrList" :key="errFile.rowNum"> + {{errFile.errMsg}} + </li> + </ul> + </el-row> + </el-tab-pane> + + </el-tabs> + </el-dialog> </template> <script> - import ElUploadList from "element-ui/packages/upload/src/upload-list"; - import MsTableButton from '../../../../components/common/components/MsTableButton'; - import {listenGoBack, removeGoBackListener} from "../../../../../common/js/utils"; + import ElUploadList from "element-ui/packages/upload/src/upload-list"; + import MsTableButton from '../../../../components/common/components/MsTableButton'; + import {listenGoBack, removeGoBackListener} from "../../../../../common/js/utils"; + import {TokenKey, WORKSPACE_ID} from '../../../../../common/js/constants'; - export default { - name: "TestCaseImport", - components: {ElUploadList, MsTableButton}, - data() { - return { - result: {}, - dialogVisible: false, - fileList: [], - errList: [], - isLoading: false - } + export default { + name: "TestCaseImport", + components: {ElUploadList, MsTableButton}, + data() { + return { + result: {}, + activeName: 'excelImport', + dialogVisible: false, + fileList: [], + errList: [], + xmindErrList: [], + isLoading: false + } + }, + props: { + projectId: { + type: String + } + }, + methods: { + handleExceed(files, fileList) { + this.$warning(this.$t('test_track.case.import.upload_limit_count')); }, - props: { - projectId: { - type: String + uploadValidate(file) { + let suffix = file.name.substring(file.name.lastIndexOf('.') + 1); + if (suffix != 'xls' && suffix != 'xlsx') { + this.$warning(this.$t('test_track.case.import.upload_limit_format')); + return false; } - }, - methods: { - handleExceed(files, fileList) { - this.$warning(this.$t('test_track.case.import.upload_limit_count')); - }, - uploadValidate(file) { - let suffix = file.name.substring(file.name.lastIndexOf('.') + 1); - if (suffix != 'xls' && suffix != 'xlsx' && suffix != 'xmind') { - this.$warning(this.$t('test_track.case.import.upload_limit_format')); - return false; - } - if (file.size / 1024 / 1024 > 20) { - this.$warning(this.$t('test_track.case.import.upload_limit_size')); - return false; + if (file.size / 1024 / 1024 > 20) { + this.$warning(this.$t('test_track.case.import.upload_limit_size')); + return false; + } + this.isLoading = true; + this.errList = []; + this.xmindErrList = []; + return true; + }, + uploadValidateXmind(file) { + let suffix = file.name.substring(file.name.lastIndexOf('.') + 1); + if (suffix != 'xmind') { + this.$warning(this.$t('test_track.case.import.upload_xmind_format')); + return false; + } + + if (file.size / 1024 / 1024 > 20) { + this.$warning(this.$t('test_track.case.import.upload_limit_size')); + return false; + } + this.isLoading = true; + this.errList = []; + this.xmindErrList = []; + return true; + }, + handleError(err, file, fileList) { + this.isLoading = false; + this.$error(err.message); + }, + open() { + listenGoBack(this.close); + this.dialogVisible = true; + }, + close() { + removeGoBackListener(this.close); + this.dialogVisible = false; + this.fileList = []; + this.errList = []; + this.xmindErrList = []; + }, + downloadTemplate() { + this.$fileDownload('/test/case/export/template'); + }, + downloadXmindTemplate() { + this.$fileDownload('/test/case/export/xmindTemplate'); + }, + upload(file) { + this.isLoading = false; + this.fileList.push(file.file); + let user = JSON.parse(localStorage.getItem(TokenKey)); + + this.result = this.$fileUpload('/test/case/import/' + this.projectId + '/' + user.id, file.file, null, {}, response => { + let res = response.data; + if (res.success) { + this.$success(this.$t('test_track.case.import.success')); + this.dialogVisible = false; + this.$emit("refresh"); + } else { + this.errList = res.errList; } - this.isLoading = true; - this.errList = []; - return true; - }, - handleError(err, file, fileList) { - this.isLoading = false; - this.$error(err.message); - }, - open() { - listenGoBack(this.close); - this.dialogVisible = true; - }, - close() { - removeGoBackListener(this.close); - this.dialogVisible = false; this.fileList = []; - this.errList = []; - }, - downloadTemplate() { - this.$fileDownload('/test/case/export/template'); - }, - upload(file) { - this.isLoading = false; - this.fileList.push(file.file); - this.result = this.$fileUpload('/test/case/import/' + this.projectId, file.file, null, {}, response => { - let res = response.data; - if (res.success) { - this.$success(this.$t('test_track.case.import.success')); - this.dialogVisible = false; - this.$emit("refresh"); - } else { - this.errList = res.errList; - } - this.fileList = []; - }, erro => { - this.fileList = []; - }); - } + }, erro => { + this.fileList = []; + }); + }, + uploadXmind(file) { + this.isLoading = false; + this.fileList.push(file.file); + let user = JSON.parse(localStorage.getItem(TokenKey)); + + this.result = this.$fileUpload('/test/case/import/' + this.projectId + '/' + user.id, file.file, null, {}, response => { + let res = response.data; + if (res.success) { + this.$success(this.$t('test_track.case.import.success')); + this.dialogVisible = false; + this.$emit("refresh"); + } else { + this.xmindErrList = res.errList; + } + this.fileList = []; + }, erro => { + this.fileList = []; + }); } } + } </script> <style> @@ -130,8 +242,18 @@ padding-bottom: 10px; } + .import-row { + padding-top: 20px; + } + .testcase-import >>> .el-dialog { - width: 400px; + width: 650px; + } + + .testcase-import-img { + width: 614px; + height: 312px; + size: 200px; } diff --git a/frontend/src/business/components/track/case/components/TestCaseList.vue b/frontend/src/business/components/track/case/components/TestCaseList.vue index 5b71cd9c34..7eb79d0494 100644 --- a/frontend/src/business/components/track/case/components/TestCaseList.vue +++ b/frontend/src/business/components/track/case/components/TestCaseList.vue @@ -382,7 +382,7 @@ export default { method: 'post', responseType: 'blob', // data: {ids: [...this.selectIds]} - data: {ids: ids} + data: {ids: ids, projectId: this.currentProject.id} }; this.result = this.$request(config).then(response => { const filename = this.$t('test_track.case.test_case') + ".xlsx"; @@ -399,9 +399,22 @@ export default { }); }, handleBatch(type) { + if (this.selectRows.size < 1) { - this.$warning(this.$t('test_track.plan_view.select_manipulate')); - return; + if (type === 'export') { + this.$alert(this.$t('test_track.case.export_all_cases'), '', { + confirmButtonText: this.$t('commons.confirm'), + callback: (action) => { + if (action === 'confirm') { + this.exportTestCase(); + } + } + }) + return; + } else { + this.$warning(this.$t('test_track.plan_view.select_manipulate')); + return; + } } if (type === 'move') { let ids = Array.from(this.selectRows).map(row => row.id); diff --git a/frontend/src/business/components/track/plan/view/comonents/TestPlanTestCaseEdit.vue b/frontend/src/business/components/track/plan/view/comonents/TestPlanTestCaseEdit.vue index c8a2b56e08..8731050c0c 100644 --- a/frontend/src/business/components/track/plan/view/comonents/TestPlanTestCaseEdit.vue +++ b/frontend/src/business/components/track/plan/view/comonents/TestPlanTestCaseEdit.vue @@ -218,6 +218,12 @@ /> <ckeditor :editor="editor" :disabled="isReadOnly" :config="editorConfig" v-model="testCase.issues.content"/> + <el-row v-if="hasTapdId"> + Tapd平台处理人: + <el-select v-model="testCase.tapdUsers" placeholder="请选择处理人" style="width: 20%" multiple collapse-tags> + <el-option v-for="(userInfo, index) in users" :key="index" :label="userInfo.user" :value="userInfo.user"/> + </el-select> + </el-row> <el-button type="primary" size="small" @click="saveIssues">{{$t('commons.save')}}</el-button> <el-button size="small" @click="issuesSwitch=false">{{$t('commons.cancel')}}</el-button> </el-col> @@ -323,6 +329,8 @@ test: {}, activeTab: 'detail', isFailure: true, + users: [], + hasTapdId: false }; }, props: { @@ -490,6 +498,17 @@ executeResult += this.addPLabel(stepPrefix + (step.executeResult == undefined ? '' : step.executeResult)); }); this.testCase.issues.content = desc + this.addPLabel('') + result + this.addPLabel('') + executeResult + this.addPLabel(''); + + this.$get("/test/case/project/" + this.testCase.caseId, res => { + const project = res.data; + if (project.tapdId) { + this.hasTapdId = true; + this.result = this.$get("/issues/tapd/user/" + this.testCase.caseId, response => { + let data = response.data; + this.users = data; + }) + } + }) } }, addPLabel(str) { @@ -515,6 +534,7 @@ param.title = this.testCase.issues.title; param.content = this.testCase.issues.content; param.testCaseId = this.testCase.caseId; + param.tapdUsers = this.testCase.tapdUsers; this.result = this.$post("/issues/add", param, () => { this.$success(this.$t('commons.save_success')); this.getIssues(param.testCaseId); @@ -522,6 +542,7 @@ this.issuesSwitch = false; this.testCase.issues.title = ""; this.testCase.issues.content = ""; + this.testCase.tapdUsers = []; }, getIssues(caseId) { this.result = this.$get("/issues/get/" + caseId, response => { diff --git a/frontend/src/common/css/main.css b/frontend/src/common/css/main.css index 4b5db2de38..5a45490239 100644 --- a/frontend/src/common/css/main.css +++ b/frontend/src/common/css/main.css @@ -94,3 +94,10 @@ body { border: 1px solid #409EFF; } /* 表格 input 编辑效果 --> */ + +.ms-border { + padding: 10px; + border: #DCDFE6 solid 1px; + margin: 5px 0; + border-radius: 5px; +} diff --git a/frontend/src/i18n/en-US.js b/frontend/src/i18n/en-US.js index ae33e4db48..c847c1f461 100644 --- a/frontend/src/i18n/en-US.js +++ b/frontend/src/i18n/en-US.js @@ -114,6 +114,7 @@ export default { millisecond: 'ms', please_upload: 'Please upload file', reference_documentation: "Reference documentation", + already_exists: 'The name already exists', date: { select_date: 'Select date', start_date: 'Start date', @@ -378,6 +379,7 @@ export default { domain_ip_is_empty: 'Domain and IP cannot be empty', param_name_value_is_empty: 'Parameters cannot be empty', connect_timeout: 'Timeout to establish a connection', + response_timeout: 'Timeout to response', custom_http_code: 'Custom HTTP response success status code', separated_by_commas: 'Separated by commas', create: 'Create Test', @@ -416,6 +418,9 @@ export default { environment: "Environment", select_environment: "Please select environment", please_save_test: "Please Save Test First", + common_config: "Common Config", + http_config: "HTTP Config", + database_config: "Database Config", }, scenario: { scenario: "Scenario", @@ -534,6 +539,19 @@ export default { check_registry_center: "Can't get interface list, please check the registry center", } }, + sql: { + dataSource: "Data Source", + sql_script: "Sql Script", + timeout: "Timeout(ms)", + database_driver: "Driver", + database_url: "Database URL", + username: "Username", + password: "Password", + pool_max: "Max Number of Configuration", + query_timeout: "Max Wait(ms)", + name_cannot_be_empty: "SQL request name cannot be empty", + dataSource_cannot_be_empty: "SQL request datasource cannot be empty", + }, api_import: { label: "Import", title: "API test import", @@ -600,6 +618,7 @@ export default { execution_result: ": Please select the execution result", actual_result: ": The actual result is empty", case: { + export_all_cases: 'Are you sure you want to export all use cases?', input_test_case: 'Please enter the associated case name', test_name: 'TestName', other: '--Other--', @@ -657,13 +676,19 @@ export default { case_import: "Import test case", download_template: "Download template", click_upload: "Upload", - upload_limit: "Only XLS/XLSX files can be uploaded, and no more than 20M", + upload_limit: "Only XLS/XLSX/XMIND files can be uploaded, and no more than 20M", + upload_xmind_format: "Upload files can only be .xmind format", + upload_xmind: "Only xmind files can be uploaded, and no more than 500", upload_limit_count: "Only one file can be uploaded at a time", upload_limit_format: "Upload files can only be XLS, XLSX format!", upload_limit_size: "Upload file size cannot exceed 20MB!", upload_limit_other_size: "Upload file size cannot exceed", success: "Import success!", importing: "Importing...", + excel_title: "Excel ", + xmind_title: "Xmind", + import_desc: "Import instructions", + import_file: "upload files", }, export: { export: "Export cases" diff --git a/frontend/src/i18n/zh-CN.js b/frontend/src/i18n/zh-CN.js index c2be36eac1..03c58b948c 100644 --- a/frontend/src/i18n/zh-CN.js +++ b/frontend/src/i18n/zh-CN.js @@ -114,6 +114,7 @@ export default { id: 'ID', millisecond: '毫秒', cannot_be_null: '不能为空', + already_exists: '名称不能重复', date: { select_date: '选择日期', start_date: '开始日期', @@ -377,6 +378,7 @@ export default { domain_ip_is_empty: '域名和IP不能为空', param_name_value_is_empty: '参数名和参数值不能为空', connect_timeout: '建立连接超时时间', + response_timeout: '响应超时时间', custom_http_code: '自定义 HTTP 响应成功状态码', separated_by_commas: '按逗号分隔', create: '创建测试', @@ -417,6 +419,9 @@ export default { environment: "环境", select_environment: "请选择环境", please_save_test: "请先保存测试", + common_config: "通用配置", + http_config: "HTTP配置", + database_config: "数据库配置", }, scenario: { scenario: "场景", @@ -535,6 +540,19 @@ export default { check_registry_center: "获取失败,请检查Registry Center", form_description: "如果当前配置项无值,则取场景配置项的值", }, + sql: { + dataSource: "数据源名称", + sql_script: "SQL脚本", + timeout: "超时时间(ms)", + database_driver: "数据库驱动", + database_url: "数据库连接URL", + username: "用户名", + password: "密码", + pool_max: "最大连接数", + query_timeout: "最大等待时间(ms)", + name_cannot_be_empty: "SQL请求名称不能为空", + dataSource_cannot_be_empty: "SQL请求数据源不能为空", + } }, api_import: { label: "导入", @@ -603,6 +621,7 @@ export default { actual_result: ": 实际结果为空", case: { + export_all_cases: '确定要导出全部用例吗?', input_test_case: '请输入关联用例名称', test_name: '测试名称', other: "--其他--", @@ -661,12 +680,18 @@ export default { download_template: "下载模版", click_upload: "点击上传", upload_limit: "只能上传xls/xlsx文件,且不超过20M", + upload_xmind: "支持文件类型:.xmind;一次至多导入500 条用例", + upload_xmind_format: "上传文件只能是 .xmind 格式", upload_limit_other_size: "上传文件大小不能超过", upload_limit_count: "一次只能上传一个文件", upload_limit_format: "上传文件只能是 xls、xlsx格式!", upload_limit_size: "上传文件大小不能超过 20MB!", success: "导入成功!", importing: "导入中...", + excel_title: "表格文件", + xmind_title: "思维导图", + import_desc: "导入说明", + import_file: "上传文件", }, export: { export: "导出用例" @@ -853,7 +878,7 @@ export default { schedule: { input_email: "请输入邮箱账号", event: "事件", - receiving_mode: "邮箱", + receiving_mode: "接收方式", receiver: "接收人", operation: "操作", task_notification: "任务通知", diff --git a/frontend/src/i18n/zh-TW.js b/frontend/src/i18n/zh-TW.js index 4b3b572869..5b833eda2d 100644 --- a/frontend/src/i18n/zh-TW.js +++ b/frontend/src/i18n/zh-TW.js @@ -1,7 +1,7 @@ export default { commons: { help_documentation: '幫助文檔', - delete_cancelled: '已取消删除', + delete_cancelled: '已取消刪除', workspace: '工作空間', organization: '組織', setting: '設置', @@ -15,7 +15,7 @@ export default { save: '保存', save_success: '保存成功', delete_success: '刪除成功', - copy_success: '複製成功', + copy_success: '復制成功', modify_success: '修改成功', delete_cancel: '已取消刪除', confirm: '確定', @@ -24,10 +24,10 @@ export default { operating: '操作', input_limit: '長度在 {0} 到 {1} 個字符', login: '登錄', - welcome: '歡迎回來,請輸入用戶名和密碼登錄MeterSphere', - username: '用戶名', + welcome: '歡迎回來,請輸入用戶名和密碼登錄MeterSphere', + username: '姓名', password: '密碼', - input_username: '請輸入用戶名', + input_username: '請輸入用戶姓名', input_password: '請輸入密碼', test: '測試', create_time: '創建時間', @@ -38,6 +38,8 @@ export default { phone: '電話', role: '角色', personal_info: '個人信息', + api_keys: 'API Keys', + quota: '配額管理', status: '狀態', show_all: '顯示全部', show: '顯示', @@ -45,8 +47,6 @@ export default { user: '用戶', system: '系統', personal_setting: '個人設置', - api_keys: 'API Keys', - quota: '配額管理', test_resource_pool: '測試資源池', system_setting: '系統設置', api: '接口測試', @@ -55,7 +55,7 @@ export default { input_content: '請輸入內容', create: '新建', edit: '編輯', - copy: '複製', + copy: '復制', refresh: '刷新', remark: '備註', delete: '刪除', @@ -69,8 +69,8 @@ export default { title: '標題', custom: '自定義', select_date: '選擇日期', - calendar_heatmap: '測試日曆', - months_1: '一月', + calendar_heatmap: '測試日歷', + months_1: '壹月', months_2: '二月', months_3: '三月', months_4: '四月', @@ -80,10 +80,10 @@ export default { months_8: '八月', months_9: '九月', months_10: '十月', - months_11: '十一月', + months_11: '十壹月', months_12: '十二月', weeks_0: '周日', - weeks_1: '周一', + weeks_1: '周壹', weeks_2: '周二', weeks_3: '周三', weeks_4: '周四', @@ -95,23 +95,26 @@ export default { connection_failed: '連接失敗', save_failed: '保存失敗', host_cannot_be_empty: '主機不能為空', - port_cannot_be_empty: '埠號不能為空', + port_cannot_be_empty: '端口號不能為空', account_cannot_be_empty: '帳戶不能為空', remove: '移除', remove_cancel: '移除取消', remove_success: '移除成功', - tips: '认認證資訊已過期,請重新登入', + tips: '認證信息已過期,請重新登錄', not_performed_yet: '尚未執行', incorrect_input: '輸入內容不正確', delete_confirm: '請輸入以下內容,確認刪除:', + login_username: 'ID 或 郵箱', + input_login_username: '請輸入用戶 ID 或 郵箱', input_name: '請輸入名稱', + please_upload: '請上傳文件', formatErr: '格式錯誤', please_save: '請先保存', - id: 'ID', - cannot_be_null: '不能为空', - millisecond: '毫秒', reference_documentation: "參考文檔", - please_upload: '請上傳文件', + id: 'ID', + millisecond: '毫秒', + cannot_be_null: '不能為空', + already_exists: '名稱不能重復', date: { select_date: '選擇日期', start_date: '開始日期', @@ -136,7 +139,7 @@ export default { search: "查詢", reset: "重置", and: '所有', - or: '任意一個', + or: '任意壹個', operators: { like: "包含", not_like: "不包含", @@ -161,45 +164,46 @@ export default { edition: '產品版本', licenseVersion: '授權版本', count: '授權數量', - valid_license: '授權验证', + valid_license: '授權驗證', show_license: '查看授權', - valid_license_error: '授權验证失败', - status: '授權状态', - expired: '已过期', + valid_license_error: '授權驗證失敗', + status: '授權狀態', + valid: '有效', + invalid: '無效', + expired: '已過期', }, - workspace: { create: '創建工作空間', update: '修改工作空間', delete: '刪除工作空間', - delete_confirm: '删除該工作空間會關聯删除該工作空間下的所有資源(如:相關項目,測試用例等),確定要删除嗎?', + delete_confirm: '刪除該工作空間會關聯刪除該工作空間下的所有資源(如:相關項目,測試用例等),確定要刪除嗎?', add: '添加工作空間', input_name: '請輸入工作空間名稱', search_by_name: '根據名稱搜索', organization_name: '所屬組織', please_choose_organization: '請選擇組織', - please_select_a_workspace_first: '請先選擇工作空間! ', + please_select_a_workspace_first: '請先選擇工作空間!', none: '無工作空間', select: '選擇工作空間', special_characters_are_not_supported: '格式錯誤(不支持特殊字符,且不能以\'-\'開頭結尾)', - delete_warning: '删除该工作空间将同步删除该工作空间下所有项目,以及项目中的所有用例、接口测试、性能测试等,确定要删除吗?', + delete_warning: '刪除該工作空間將同步刪除該工作空間下所有項目,以及項目中的所有用例、接口測試、性能測試等,確定要刪除嗎?', }, organization: { create: '創建組織', modify: '修改組織', delete: '刪除組織', - delete_confirm: '删除該組織會關聯删除該組織下的所有資源(如:相關工作空間,項目,測試用例等),確定要删除嗎?', + delete_confirm: '刪除該組織會關聯刪除該組織下的所有資源(如:相關工作空間,項目,測試用例等),確定要刪除嗎?', input_name: '請輸入組織名稱', select_organization: '請選擇組織', search_by_name: '根據名稱搜索', - special_characters_are_not_supported: 'Incorrect format (special characters are not supported and cannot end with \'-\')', + special_characters_are_not_supported: '格式錯誤(不支持特殊字符,且不能以\'-\'開頭結尾)', none: '無組織', select: '選擇組織', - delete_warning: '删除该组织将同步删除该组织下所有相关工作空间和相关工作空间下的所有项目,以及项目中的所有用例、接口测试、性能测试等,确定要删除吗?', + delete_warning: '刪除該組織將同步刪除該組織下所有相關工作空間和相關工作空間下的所有項目,以及項目中的所有用例、接口測試、性能測試等,確定要刪除嗎?', service_integration: '服務集成', - defect_manage: '缺陷管理平台', + defect_manage: '缺陷管理平臺', integration: { - select_defect_platform: '請選擇要集成的缺陷管理平台:', + select_defect_platform: '請選擇要集成的缺陷管理平臺:', basic_auth_info: 'Basic Auth 賬號信息:', api_account: 'API 賬號', api_password: 'API 口令', @@ -210,7 +214,7 @@ export default { input_jira_url: '請輸入Jira地址,例:https://metersphere.atlassian.net/', input_jira_issuetype: '請輸入問題類型', use_tip: '使用指引:', - use_tip_tapd: 'Basic Auth 賬號信息在"公司管理-安全與集成-開放平台"中查詢', + use_tip_tapd: 'Tapd Basic Auth 賬號信息在"公司管理-安全與集成-開放平臺"中查詢', use_tip_jira: 'Jira software server 認證信息為 賬號密碼,Jira software cloud 認證信息為 賬號+令牌(賬戶設置-安全-創建API令牌)', use_tip_two: '保存 Basic Auth 賬號信息後,需要在 Metersphere 項目中手動關聯 ID/key', link_the_project_now: '馬上關聯項目', @@ -218,8 +222,8 @@ export default { cancel_integration: '取消集成', cancel_confirm: '確認取消集成 ', successful_operation: '操作成功', - not_integrated: '未集成該平台', - choose_platform: '請選擇集成的平台', + not_integrated: '未集成該平臺', + choose_platform: '請選擇集成的平臺', verified: '驗證通過' } }, @@ -229,7 +233,7 @@ export default { edit: '編輯項目', delete: '刪除項目', delete_confirm: '確定要刪除這個項目嗎?', - delete_tip: '删除該項目,會删除該項目下所有測試資源,確定要删除嗎?', + delete_tip: '刪除該項目,會刪除該項目下所有測試資源,確定要刪除嗎?', search_by_name: '根據名稱搜索', input_name: '請輸入項目名稱', owning_workspace: '所屬工作空間', @@ -252,11 +256,11 @@ export default { special_characters_are_not_supported: '不支持特殊字符', mobile_number_format_is_incorrect: '手機號碼格式不正確', email_format_is_incorrect: '郵箱格式不正確', - password_format_is_incorrect: '有效密碼:8-30 位,英文大小寫字母+數位+特殊字元(可選)', + password_format_is_incorrect: '有效密碼:8-30位,英文大小寫字母+數字+特殊字符(可選)', old_password: '舊密碼', new_password: '新密碼', repeat_password: '確認密碼', - inconsistent_passwords: '兩次輸入的密碼不一致', + inconsistent_passwords: '兩次輸入的密碼不壹致', remove_member: '確定要移除該成員嗎', input_id_or_email: '請輸入用戶 ID, 或者 用戶郵箱', no_such_user: '無此用戶信息, 請輸入正確的用戶 ID 或者 用戶郵箱!', @@ -264,7 +268,7 @@ export default { user: { create: '創建用戶', modify: '修改用戶', - input_name: '請輸入用戶名', + input_name: '請輸入用戶姓名', input_id: '請輸入ID', input_email: '請輸入郵箱', input_password: '請輸入密碼', @@ -287,7 +291,6 @@ export default { add: '添加角色', }, report: { - name: '項目名稱', recent: '最近的報告', search_by_name: '根據名稱搜索', test_name: '所屬測試', @@ -322,8 +325,8 @@ export default { delete_batch_confirm: '確認批量刪除報告', }, load_test: { - same_project_test: '只能運行同一項目內的測試', - run: '一鍵運行', + same_project_test: '只能運行同壹項目內的測試', + already_exists: '測試名稱不能重復', operating: '操作', recent: '最近的測試', search_by_name: '根據名稱搜索', @@ -336,30 +339,30 @@ export default { pressure_config: '壓力配置', advanced_config: '高級配置', runtime_config: '運行配置', - is_running: '正在運行! ', - test_name_is_null: '測試名稱不能為空! ', - project_is_null: '項目不能為空! ', - jmx_is_null: '必需包含一個JMX文件,且只能包含一個JMX文件!', + is_running: '正在運行!', + test_name_is_null: '測試名稱不能為空!', + project_is_null: '項目不能為空!', + jmx_is_null: '必需包含壹個JMX文件,且只能包含壹個JMX文件!', file_name: '文件名', file_size: '文件大小', file_type: '文件類型', file_status: '文件狀態', last_modify_time: '修改時間', - upload_tips: '將文件拖到此處,或<em>點擊上傳</em>', + upload_tips: '將文件拖到此處,或<em>點擊上傳</em>', upload_type: '只能上傳JMX/CSV文件', related_file_not_found: "未找到關聯的測試文件!", delete_file_confirm: '確認刪除文件: ', file_size_limit: "文件個數超出限制!", delete_file: "文件已存在,請先刪除同名文件!", - thread_num: '並髮用戶數:', + thread_num: '並發用戶數:', input_thread_num: '請輸入線程數', duration: '壓測時長(分鐘):', input_duration: '請輸入時長', rps_limit: 'RPS上限:', input_rps_limit: '請輸入限制', ramp_up_time_within: '在', - ramp_up_time_minutes: '分鐘內,分', - ramp_up_time_times: '次增加並髮用戶', + ramp_up_time_minutes: '分鐘內,分', + ramp_up_time_times: '次增加並發用戶', advanced_config_error: '高級配置校驗失敗', domain_bind: '域名綁定', domain: '域名', @@ -370,14 +373,16 @@ export default { params: '自定義屬性', param_name: '屬性名', param_value: '屬性值', - domain_is_duplicate: '域名不能重複', - param_is_duplicate: '參數名不能重複', + domain_is_duplicate: '域名不能重復', + param_is_duplicate: '參數名不能重復', domain_ip_is_empty: '域名和IP不能為空', param_name_value_is_empty: '參數名和參數值不能為空', connect_timeout: '建立連接超時時間', + response_timeout: '響應超時時間', custom_http_code: '自定義 HTTP 響應成功狀態碼', separated_by_commas: '按逗號分隔', create: '創建測試', + run: '壹鍵運行', select_resource_pool: '請選擇資源池', resource_pool_is_null: '資源池為空', download_log_file: '下載完整日誌文件', @@ -395,13 +400,13 @@ export default { reset: "重置", input_name: "請輸入測試名稱", select_project: "請選擇項目", - variable_name: "變數名", - variable: "變數", + variable_name: "變量名", + variable: "變量", copied: "已拷貝", key: "鍵", value: "值", create_performance_test: "創建性能測試", - export_config: "匯出", + export_config: "導出", enable_validate_tip: "沒有可用請求", copy: "復制測試", environment: { @@ -414,21 +419,23 @@ export default { environment: "環境", select_environment: "請選擇環境", please_save_test: "請先保存測試", + common_config: "通用配置", + http_config: "HTTP配置", + database_config: "數據庫配置", }, scenario: { scenario: "場景", - dubbo: "Dubbo配寘", - creator: "創建人", - config: "場景配寘", + dubbo: "Dubbo配置", + config: "場景配置", input_name: "請輸入場景名稱", name: "場景名稱", base_url: "基礎URL", - base_url_description: "基礎URL作為所有請求的URL首碼", - variables: "自定義變數", + base_url_description: "基礎URL作為所有請求的URL前綴", + variables: "自定義變量", headers: "請求頭", - kv_description: "所有請求可以使用自定義變數", - copy: "複製場景", - delete: "删除場景", + kv_description: "所有請求可以使用自定義變量", + copy: "復制場景", + delete: "刪除場景", disable: "禁用", enable: "啟用", create_scenario: "創建新場景", @@ -437,13 +444,13 @@ export default { enable_disable: "啟用/禁用", test_name: "測試名稱", reference: "引用", - clone: "複製", + clone: "復制", cant_reference: '歷史測試文件,重新保存後才可被引用' }, request: { debug: "調試", - copy: "複製請求", - delete: "删除請求", + copy: "復制請求", + delete: "刪除請求", input_name: "請輸入請求名稱", input_url: "請輸入請求URL", input_path: "請輸入請求路徑", @@ -461,7 +468,7 @@ export default { parameters: "請求參數", jmeter_func: "Jmeter 方法", parameters_filter_example: "示例", - parameters_filter_tips: "只支持MockJs函數結果預覽", + parameters_filter_tips: "只支持 MockJs 函數結果預覽", parameters_advance: "高級參數設置", parameters_preview: "預覽", parameters_mock_filter_tips: "請輸入關鍵字進行過濾", @@ -471,47 +478,48 @@ export default { parameters_advance_add_func_limit: "最多支持5個函數", parameters_advance_add_func_error: "請先選擇函數", parameters_advance_add_param_error: "請輸入函數參數", - parameters_desc: "參數追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2", + parameters_desc: "參數追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2", headers: "請求頭", body: "請求內容", body_kv: "鍵值對", - body_text: "文字", + body_text: "文本", timeout_config: "超時設置", connect_timeout: "連接超時", response_timeout: "響應超時", + follow_redirects: "跟隨重定向", body_upload_limit_size: "上傳文件大小不能超過 500 MB!", condition: "條件", condition_variable: "變量,例如: ${var}", wait: "等待", assertions: { label: "斷言", - text: "文字", + text: "文本", regex: "正則", - response_time: "回應時間", + response_time: "響應時間", select_type: "請選擇類型", select_subject: "請選擇對象", select_condition: "請選擇條件", contains: "包含", not_contains: "不包含", equals: "等於", - start_with: "以…開始", - end_with: "以…結束", + start_with: "以...開始", + end_with: "以...結束", value: "值", expect: "期望值", - expression: "Perl型規則運算式", - response_in_time: "回應時間在…毫秒以內", + expression: "Perl型正則表達式", + response_in_time: "響應時間在...毫秒以內", }, extract: { - label: "選取", + label: "提取", select_type: "請選擇類型", - description: "從響應結果中選取數據並將其存儲在變數中,在後續請求中使用變數。", + description: "從響應結果中提取數據並將其存儲在變量中,在後續請求中使用變量。", regex: "正則", - regex_expression: "Perl型規則運算式", - json_path_expression: "JSONPath運算式", - xpath_expression: "XPath運算式", + regex_expression: "Perl型正則表達式", + json_path_expression: "JSONPath表達式", + xpath_expression: "XPath表達式", }, processor: { - pre_exec_script : "預執行腳本", + pre_exec_script: "預執行腳本", post_exec_script: "後執行腳本", code_template: "代碼模版", bean_shell_processor_tip: "僅支持 BeanShell 腳本", @@ -522,14 +530,28 @@ export default { code_template_get_response_result: "獲取響應結果" }, dubbo: { - protocol: "協定", + protocol: "協議", input_interface: "請輸入Interface", input_method: "請輸入Method", input_config_center: "請輸入Config Center", - get_provider_success: "獲取成功", input_registry_center: "請輸入Registry Center", input_consumer_service: "請輸入Consumer & Service", + get_provider_success: "獲取成功", check_registry_center: "獲取失敗,請檢查Registry Center", + form_description: "如果當前配置項無值,則取場景配置項的值", + }, + sql: { + dataSource: "數據源名稱", + sql_script: "SQL腳本", + timeout: "超時時間(ms)", + database_driver: "數據庫驅動", + database_url: "數據庫連接URL", + username: "用戶名", + password: "密碼", + pool_max: "最大連接數", + query_timeout: "最大等待時間(ms)", + name_cannot_be_empty: "SQL請求名稱不能為空", + dataSource_cannot_be_empty: "SQL請求數據源不能為空", } }, api_import: { @@ -539,10 +561,10 @@ export default { file_size_limit: "文件大小不超過 20 M", tip: "說明", export_tip: "導出方法", - ms_tip: "支持 MeterSphere json 格式", - ms_export_tip: "通過 MeterSphere Api 測試頁面或者瀏覽器插件導出 json 格式文件", + ms_tip: "支持 Metersphere json 格式", + ms_export_tip: "通過 Metersphere 接口測試頁面或者瀏覽器插件導出 json 格式文件", postman_tip: "只支持 Postman Collection v2.1 格式的 json 文件", - swagger_tip: "只支持 Swagger2.x 版本的 json 文件", + swagger_tip: "只支持 Swagger 2.x 版本的 json 文件", post_export_tip: "通過 Postman 導出測試集合", swagger_export_tip: "通過 Swagger 頁面導出", suffixFormatErr: "文件格式不符合要求", @@ -556,11 +578,11 @@ export default { request_headers: "請求頭", request_cookie: "Cookie", response: "響應", - delete_confirm: '確認删除報告:', + delete_confirm: '確認刪除報告: ', delete_batch_confirm: '確認批量刪除報告', scenario_name: "場景名稱", - response_time: "回應時間(ms)", - latency: "網路延遲", + response_time: "響應時間(ms)", + latency: "網絡延遲", request_size: "請求大小", response_size: "響應大小", response_code: "狀態碼", @@ -569,7 +591,7 @@ export default { assertions: "斷言", assertions_pass: "成功斷言", assertions_name: "斷言名稱", - assertions_error_message: "錯誤資訊", + assertions_error_message: "錯誤信息", assertions_is_success: "是否成功", result: "結果", success: "成功", @@ -585,7 +607,7 @@ export default { not_exist: "測試報告不存在", }, test_track: { - test_track: "測試跟踪", + test_track: "測試跟蹤", confirm: "確 定", cancel: "取 消", project: "項目", @@ -597,17 +619,19 @@ export default { pass_rate: "通過率", execution_result: ": 請選擇執行結果", actual_result: ": 實際結果為空", + case: { + export_all_cases: '確定要匯出全部用例嗎?', input_test_case: '請輸入關聯用例名稱', test_name: '測試名稱', - other: '--其他--', + other: "--其他--", test_case: "測試用例", move: "移動用例", case_list: "用例列表", create_case: "創建用例", edit_case: "編輯用例", view_case: "查看用例", - no_project: "該工作空間下無項目,請先創建項目", + no_project: "該工作空間下無項目,請先創建項目", priority: "用例等級", type: "類型", method: "測試方式", @@ -630,14 +654,14 @@ export default { input_type: "請選擇用例類型", input_method: "請選擇測試方式", input_prerequisite: "請輸入前置條件", - delete_confirm: "確認刪除測試用例: ", - delete: "删除用例", + delete_confirm: "確認刪除測試用例", + delete: "刪除用例", save_create_continue: "保存並繼續創建", please_create_project: "暫無項目,請先創建項目", create_module_first: "請先新建模塊", relate_test: "關聯測試", relate_test_not_find: '關聯的測試不存在,請檢查用例', - other_relate_test_not_find: '關聯的測試名,請前往協力廠商平臺執行', + other_relate_test_not_find: '關聯的測試名,請前往第三方平臺執行', batch_handle: '批量處理 (選中{0}項)', batch_update: '更新{0}個用例的屬性', select_catalog: '請選擇用例目錄', @@ -655,13 +679,19 @@ export default { case_import: "導入測試用例", download_template: "下載模版", click_upload: "點擊上傳", - upload_limit: "只能上傳xls/xlsx文件,且不超過20M", - upload_limit_count: "一次只能上傳一個文件", + upload_limit: "只能上傳xls/xlsx文件,且不超過20M", + upload_xmind: "支持文件類型:.xmind;壹次至多導入500 條用例", + upload_xmind_format: "上傳文件只能是 .xmind 格式", + upload_limit_other_size: "上傳文件大小不能超過", + upload_limit_count: "壹次只能上傳壹個文件", upload_limit_format: "上傳文件只能是 xls、xlsx格式!", upload_limit_size: "上傳文件大小不能超過 20MB!", - upload_limit_other_size: "上傳文件大小不能超過", success: "導入成功!", importing: "導入中...", + excel_title: "表格文件", + xmind_title: "思維導圖", + import_desc: "導入說明", + import_file: "上傳文件", }, export: { export: "導出用例" @@ -727,13 +757,13 @@ export default { step_result: "步驟執行結果", my_case: "我的用例", all_case: "全部用例", - pre_case: "上一條用例", - next_case: "下一條用例", + pre_case: "上壹條用例", + next_case: "下壹條用例", change_execution_results: "更改執行結果", change_executor: "更改執行人", select_executor: "請選擇執行人", select_execute_result: "選擇執行結果", - cancel_relevance: "取消關聯", + cancel_relevance: "取消用例關聯", confirm_cancel_relevance: "確認取消關聯", select_manipulate: "請選擇需要操作的數據", select_template: "選擇模版", @@ -745,11 +775,11 @@ export default { test_result: "測試結果", result_distribution: "測試結果分布", custom_component: "自定義模塊", + defect_list: "缺陷列表", create_report: "創建測試報告", - defect_list:"缺陷清單", view_report: "查看測試報告", component_library: "組件庫", - component_library_tip: "拖拽組件庫中組件,添加至右側,預覽報告效果,每個系統組件只能添加壹個。", + component_library_tip: "拖拽組件庫中組件,添加至右側,預覽報告效果,每個系統組件只能添加壹個。", delete_component_tip: "請至少保留壹個組件", input_template_name: "輸入模版名稱", template_special_characters: '模版名稱不支持特殊字符', @@ -761,17 +791,17 @@ export default { report_template: "測試報告模版", test_detail: "測試詳情", failure_case: "失敗用例", - export_report: "匯出報告" + export_report: "導出報告" }, issue: { issue: "缺陷", - platform_tip: "在系統設置-組織-服務集成中集成缺陷管理平台可以自動提交缺陷到指定缺陷管理平台", + platform_tip: "在系統設置-組織-服務集成中集成缺陷管理平臺可以自動提交缺陷到指定缺陷管理平臺", input_title: "請輸入標題", id: "缺陷ID", title: "缺陷標題", description: "缺陷描述", status: "缺陷狀態", - platform: "平台", + platform: "平臺", operate: "操作", close: "關閉缺陷", title_description_required: "標題和描述必填", @@ -800,17 +830,17 @@ export default { system_parameter_setting: { mailbox_service_settings: '郵件設置', ldap_setting: 'LDAP設置', - test_connection: '測試連結', + test_connection: '測試連接', SMTP_host: 'SMTP主機', - SMTP_port: 'SMTP埠', - SMTP_account: 'SMTP帳戶', + SMTP_port: 'SMTP端口', + SMTP_account: 'SMTP賬戶', SMTP_password: 'SMTP密碼', - SSL: '開啟SSL(如果SMTP埠是465,通常需要啟用SSL)', - TLS: '開啟TLS(如果SMTP埠是587,通常需要啟用TLS)', + SSL: '開啟SSL(如果SMTP端口是465,通常需要啟用SSL)', + TLS: '開啟TLS(如果SMTP端口是587,通常需要啟用TLS)', SMTP: '是否匿名 SMTP', host: '主機號不能為空', - port: '埠號不能為空', - account: '帳戶不能為空', + port: '端口號不能為空', + account: '賬戶不能為空', }, i18n: { home: '首頁' @@ -842,23 +872,23 @@ export default { dn_cannot_be_empty: 'LDAP DN不能為空', ou_cannot_be_empty: 'LDAP OU不能為空', filter_cannot_be_empty: 'LDAP 用戶過濾器不能為空', - password_cannot_be_empty: 'LDAP 密碼不能為空', mapping_cannot_be_empty: 'LDAP 用戶屬性映射不能為空', + password_cannot_be_empty: 'LDAP 密碼不能為空', }, schedule: { - input_email: "請輸入郵箱帳號", + input_email: "請輸入郵箱賬號", event: "事件", - receiving_mode: "郵箱", + receiving_mode: "接收方式", receiver: "接收人", operation: "操作", task_notification: "任務通知", not_set: "未設置", - next_execution_time: "下次執行時間", - edit_timer_task: "編輯定時任務", test_name: '測試名稱', running_rule: '運行規則', job_status: '任務狀態', running_task: '運行中的任務', + next_execution_time: "下次執行時間", + edit_timer_task: "編輯定時任務", please_input_cron_expression: "請輸入 Cron 表達式", generate_expression: "生成表達式", cron_expression_format_error: "Cron 表達式格式錯誤",