Merge remote-tracking branch 'origin/v1.4' into v1.4

This commit is contained in:
wenyann 2020-10-28 18:54:37 +08:00
commit 753b9da5c1
59 changed files with 2968 additions and 1681 deletions

View File

@ -363,6 +363,13 @@
<version>4.5.6</version> <version>4.5.6</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.3</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -14,6 +14,7 @@ import io.metersphere.commons.utils.SessionUtils;
import io.metersphere.controller.request.QueryScheduleRequest; import io.metersphere.controller.request.QueryScheduleRequest;
import io.metersphere.dto.ScheduleDao; import io.metersphere.dto.ScheduleDao;
import io.metersphere.service.CheckOwnerService; import io.metersphere.service.CheckOwnerService;
import org.apache.shiro.authz.annotation.Logical; import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresRoles; import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -21,8 +22,12 @@ import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List; import java.util.List;
import static io.metersphere.commons.utils.JsonPathUtils.getListJson;
@RestController @RestController
@RequestMapping(value = "/api") @RequestMapping(value = "/api")
@RequiresRoles(value = {RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER, RoleConstants.TEST_VIEWER}, logical = Logical.OR) @RequiresRoles(value = {RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER, RoleConstants.TEST_VIEWER}, logical = Logical.OR)
@ -79,7 +84,6 @@ public class APITestController {
public void mergeCreate(@RequestPart("request") SaveAPITestRequest request, @RequestPart(value = "file") MultipartFile file, @RequestPart(value = "selectIds") List<String> selectIds) { public void mergeCreate(@RequestPart("request") SaveAPITestRequest request, @RequestPart(value = "file") MultipartFile file, @RequestPart(value = "selectIds") List<String> selectIds) {
apiTestService.mergeCreate(request, file, selectIds); apiTestService.mergeCreate(request, file, selectIds);
} }
@PostMapping(value = "/update", consumes = {"multipart/form-data"}) @PostMapping(value = "/update", consumes = {"multipart/form-data"})
public void update(@RequestPart("request") SaveAPITestRequest request, @RequestPart(value = "file") MultipartFile file, @RequestPart(value = "files") List<MultipartFile> bodyFiles) { public void update(@RequestPart("request") SaveAPITestRequest request, @RequestPart(value = "file") MultipartFile file, @RequestPart(value = "files") List<MultipartFile> bodyFiles) {
checkownerService.checkApiTestOwner(request.getId()); checkownerService.checkApiTestOwner(request.getId());
@ -141,4 +145,9 @@ public class APITestController {
public List<ScheduleDao> listSchedule(@RequestBody QueryScheduleRequest request) { public List<ScheduleDao> listSchedule(@RequestBody QueryScheduleRequest request) {
return apiTestService.listSchedule(request); return apiTestService.listSchedule(request);
} }
@PostMapping("/getJsonPaths")
public List<HashMap> getJsonPaths(@RequestBody QueryJsonPathRequest request) {
return getListJson(request.getJsonPath());
}
} }

View File

@ -0,0 +1,12 @@
package io.metersphere.api.dto;
import java.io.Serializable;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class QueryJsonPathRequest implements Serializable {
private String jsonPath;
}

View File

@ -0,0 +1,20 @@
package io.metersphere.api.dto.scenario.assertions;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class AssertionJSR223 extends AssertionType {
private String variable;
private String operator;
private String value;
private String desc;
private String name;
private String script;
private String language;
public AssertionJSR223() {
setType(AssertionType.JSR223);
}
}

View File

@ -7,6 +7,7 @@ public class AssertionType {
public final static String REGEX = "Regex"; public final static String REGEX = "Regex";
public final static String DURATION = "Duration"; public final static String DURATION = "Duration";
public final static String JSON_PATH = "JSONPath"; public final static String JSON_PATH = "JSONPath";
public final static String JSR223 = "JSR223";
public final static String TEXT = "Text"; public final static String TEXT = "Text";
private String type; private String type;

View File

@ -8,5 +8,6 @@ import java.util.List;
public class Assertions { public class Assertions {
private List<AssertionRegex> regex; private List<AssertionRegex> regex;
private List<AssertionJsonPath> jsonPath; private List<AssertionJsonPath> jsonPath;
private List<AssertionJSR223> jsr223;
private AssertionDuration duration; private AssertionDuration duration;
} }

View File

@ -0,0 +1,17 @@
package io.metersphere.base.mapper.ext;
import io.metersphere.track.dto.TestCaseCommentDTO;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ExtTestCaseCommentMapper {
/**
* 获取用例的评论
* @param caseId
* @return
*/
List<TestCaseCommentDTO> getCaseComments(@Param("caseId") String caseId);
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="io.metersphere.base.mapper.ext.ExtTestCaseCommentMapper">
<select id="getCaseComments" resultType="io.metersphere.track.dto.TestCaseCommentDTO"
parameterType="java.lang.String">
select *, user.name as authorName from test_case_comment, user
where test_case_comment.author = user.id and case_id = #{caseId}
order by test_case_comment.update_time desc
</select>
</mapper>

View File

@ -6,11 +6,11 @@
parameterType="io.metersphere.track.request.testreview.QueryCaseReviewRequest"> parameterType="io.metersphere.track.request.testreview.QueryCaseReviewRequest">
select distinct test_case_review.id, test_case_review.name, test_case_review.creator, test_case_review.status, select distinct test_case_review.id, test_case_review.name, test_case_review.creator, test_case_review.status,
test_case_review.create_time, test_case_review.update_time, test_case_review.end_time, test_case_review.create_time, test_case_review.update_time, test_case_review.end_time,
test_case_review.description test_case_review.description, user.name as creatorName
from test_case_review, project, test_case_review_project from test_case_review join test_case_review_project on test_case_review.id = test_case_review_project.review_id
join project on test_case_review_project.project_id = project.id
left join user on test_case_review.creator = user.id
<where> <where>
test_case_review.id = test_case_review_project.review_id
and test_case_review_project.project_id = project.id
<if test="request.name != null"> <if test="request.name != null">
and test_case_review.name like CONCAT('%', #{request.name},'%') and test_case_review.name like CONCAT('%', #{request.name},'%')
</if> </if>

View File

@ -94,6 +94,12 @@
<property name="object" value="${condition}.creator"/> <property name="object" value="${condition}.creator"/>
</include> </include>
</if> </if>
<if test="${condition}.executor != null">
and test_plan_test_case.executor
<include refid="condition">
<property name="object" value="${condition}.executor"/>
</include>
</if>
</sql> </sql>
<select id="getReportMetric" parameterType="java.lang.String" <select id="getReportMetric" parameterType="java.lang.String"

View File

@ -1,6 +1,6 @@
package io.metersphere.commons.constants; package io.metersphere.commons.constants;
public enum ReportKeys { public enum ReportKeys {
LoadChart, ResponseTimeChart, Errors, ErrorsTop5, RequestStatistics, Overview, TimeInfo, ResultStatus LoadChart, ResponseTimeChart, ResponseCodeChart, Errors, ErrorsTop5, RequestStatistics, Overview, TimeInfo, ResultStatus, ErrorsChart
} }

View File

@ -0,0 +1,126 @@
package io.metersphere.commons.utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONPath;
public class JsonPathUtils {
public static List<HashMap> getListJson(String jsonString) {
JSONObject jsonObject = JSONObject.parseObject(jsonString);
List<HashMap> allJsons =new ArrayList<>();
// 获取到所有jsonpath后获取所有的key
List<String> jsonPaths = JSONPath.paths(jsonObject).keySet()
.stream()
.collect(Collectors.toList());
//去掉根节点key
List<String> parentNode = new ArrayList<>();
//根节点key
parentNode.add("/");
//循环获取父节点key只保留叶子节点
for (int i = 0; i < jsonPaths.size(); i++) {
if (jsonPaths.get(i).lastIndexOf("/") > 0) {
parentNode.add(jsonPaths.get(i).substring(0, jsonPaths.get(i).lastIndexOf("/")));
}
}
//remove父节点key
for (String parentNodeJsonPath : parentNode) {
jsonPaths.remove(parentNodeJsonPath);
}
Iterator<String> jsonPath = jsonPaths.iterator();
///替换为点.
while (jsonPath.hasNext()) {
Map<String,String> item = new HashMap<>();
String o_json_path = "$" + jsonPath.next().replaceAll("/", ".");
String value = JSONPath.eval(jsonObject, o_json_path).toString();
if(o_json_path.toLowerCase().contains("id")) {
continue;
}
if(value.equals("") || value.equals("[]") || o_json_path.equals("")) {
continue;
}
String json_path = formatJson(o_json_path);
item.put("json_path", json_path);
item.put("json_value", addEscapeForString(value));
allJsons.add((HashMap)item);
}
Collections.sort(allJsons, (a, b) ->
( (String)a.get("json_path") )
.compareTo( (String)b.get("json_path") )
);
return allJsons;
}
private static String formatJson(String json_path){
String ret="";
String reg = ".(\\d{1,3}).{0,1}";
Boolean change_flag = false;
Matcher m1 = Pattern.compile(reg).matcher(json_path);
String newStr="";
int rest = 0;
String tail = "";
while (m1.find()) {
int start = m1.start();
int end = m1.end() - 1;
if(json_path.charAt(start) != '.' || json_path.charAt(end) != '.') {
continue;
}
newStr += json_path.substring(rest,m1.start()) +"[" + json_path.substring(start + 1, end) + "]." ;
rest = m1.end();
tail = json_path.substring(m1.end());
change_flag = true;
}
if(change_flag) {
ret = newStr + tail;
} else {
ret = json_path;
}
return ret;
}
private static String addEscapeForString(String input) {
String ret="";
String reg = "[?*/]";
Boolean change_flag = false;
Matcher m1 = Pattern.compile(reg).matcher(input);
String newStr="";
int rest = 0;
String tail = "";
while (m1.find()) {
newStr += input.substring(rest,m1.start()) + "\\" + m1.group(0) ;
rest = m1.end();
tail = input.substring(m1.end());
change_flag = true;
}
if(change_flag) {
ret = newStr + tail;
} else {
ret = input;
}
return ret;
}
}

View File

@ -0,0 +1,84 @@
package io.metersphere.ldap.service;
import javax.net.SocketFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
public class CustomSSLSocketFactory extends SSLSocketFactory {
private SSLSocketFactory socketFactory;
public CustomSSLSocketFactory() {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{new DummyTrustmanager()}, new SecureRandom());
socketFactory = ctx.getSocketFactory();
} catch (Exception ex) {
ex.printStackTrace(System.err);
}
}
public static SocketFactory getDefault() {
return new CustomSSLSocketFactory();
}
@Override
public String[] getDefaultCipherSuites() {
return socketFactory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return socketFactory.getSupportedCipherSuites();
}
@Override
public Socket createSocket(Socket socket, String string, int num, boolean bool) throws IOException {
return socketFactory.createSocket(socket, string, num, bool);
}
@Override
public Socket createSocket(String string, int num) throws IOException, UnknownHostException {
return socketFactory.createSocket(string, num);
}
@Override
public Socket createSocket(String string, int num, InetAddress netAdd, int i) throws IOException, UnknownHostException {
return socketFactory.createSocket(string, num, netAdd, i);
}
@Override
public Socket createSocket(InetAddress netAdd, int num) throws IOException {
return socketFactory.createSocket(netAdd, num);
}
@Override
public Socket createSocket(InetAddress netAdd1, int num, InetAddress netAdd2, int i) throws IOException {
return socketFactory.createSocket(netAdd1, num, netAdd2, i);
}
/**
* 证书
*/
public static class DummyTrustmanager implements X509TrustManager {
public void checkClientTrusted(X509Certificate[] cert, String string) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] cert, String string) throws CertificateException {
}
public X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[0];
}
}
}

View File

@ -146,8 +146,17 @@ public class LdapService {
preConnect(url, dn, password); preConnect(url, dn, password);
String credentials = EncryptUtils.aesDecrypt(password).toString(); String credentials = EncryptUtils.aesDecrypt(password).toString();
LdapContextSource sourceLdapCtx;
LdapContextSource sourceLdapCtx = new LdapContextSource(); if (StringUtils.startsWith(url, "ldaps://")) {
sourceLdapCtx = new SSLLdapContextSource();
// todo 这里加上strategy 会报错
// DefaultTlsDirContextAuthenticationStrategy strategy = new DefaultTlsDirContextAuthenticationStrategy();
// strategy.setShutdownTlsGracefully(true);
// strategy.setHostnameVerifier((hostname, session) -> true);
// sourceLdapCtx.setAuthenticationStrategy(strategy);
} else {
sourceLdapCtx = new LdapContextSource();
}
sourceLdapCtx.setUrl(url); sourceLdapCtx.setUrl(url);
sourceLdapCtx.setUserDn(dn); sourceLdapCtx.setUserDn(dn);
sourceLdapCtx.setPassword(credentials); sourceLdapCtx.setPassword(credentials);

View File

@ -0,0 +1,16 @@
package io.metersphere.ldap.service;
import org.springframework.ldap.core.support.LdapContextSource;
import javax.naming.Context;
import java.util.Hashtable;
public class SSLLdapContextSource extends LdapContextSource {
public Hashtable<String, Object> getAnonymousEnv() {
Hashtable<String, Object> anonymousEnv = super.getAnonymousEnv();
anonymousEnv.put("java.naming.security.protocol", "ssl");
anonymousEnv.put("java.naming.ldap.factory.socket", CustomSSLSocketFactory.class.getName());
anonymousEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
return anonymousEnv;
}
}

View File

@ -95,6 +95,16 @@ public class PerformanceReportController {
return reportService.getResponseTimeChartData(reportId); return reportService.getResponseTimeChartData(reportId);
} }
@GetMapping("/content/error_chart/{reportId}")
public List<ChartsData> getErrorChartData(@PathVariable String reportId) {
return reportService.getErrorChartData(reportId);
}
@GetMapping("/content/response_code_chart/{reportId}")
public List<ChartsData> getResponseCodeChartData(@PathVariable String reportId) {
return reportService.getResponseCodeChartData(reportId);
}
@GetMapping("/{reportId}") @GetMapping("/{reportId}")
public LoadTestReportWithBLOBs getLoadTestReport(@PathVariable String reportId) { public LoadTestReportWithBLOBs getLoadTestReport(@PathVariable String reportId) {
return reportService.getLoadTestReport(reportId); return reportService.getLoadTestReport(reportId);

View File

@ -256,4 +256,16 @@ public class ReportService {
List<String> ids = reportRequest.getIds(); List<String> ids = reportRequest.getIds();
ids.forEach(this::deleteReport); ids.forEach(this::deleteReport);
} }
public List<ChartsData> getErrorChartData(String id) {
checkReportStatus(id);
String content = getContent(id, ReportKeys.ErrorsChart);
return JSON.parseArray(content, ChartsData.class);
}
public List<ChartsData> getResponseCodeChartData(String id) {
checkReportStatus(id);
String content = getContent(id, ReportKeys.ResponseCodeChart);
return JSON.parseArray(content, ChartsData.class);
}
} }

View File

@ -1,8 +1,5 @@
package io.metersphere.service; package io.metersphere.service;
import static io.metersphere.commons.constants.ResourceStatusEnum.INVALID;
import static io.metersphere.commons.constants.ResourceStatusEnum.VALID;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
import io.metersphere.base.domain.*; import io.metersphere.base.domain.*;
import io.metersphere.base.mapper.LoadTestMapper; import io.metersphere.base.mapper.LoadTestMapper;
@ -25,6 +22,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -32,7 +30,8 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Resource; import static io.metersphere.commons.constants.ResourceStatusEnum.INVALID;
import static io.metersphere.commons.constants.ResourceStatusEnum.VALID;
/** /**
* @author dongbin * @author dongbin
@ -210,7 +209,10 @@ public class TestResourcePoolService {
private void updateTestResource(TestResource testResource) { private void updateTestResource(TestResource testResource) {
testResource.setUpdateTime(System.currentTimeMillis()); testResource.setUpdateTime(System.currentTimeMillis());
testResource.setCreateTime(System.currentTimeMillis()); testResource.setCreateTime(System.currentTimeMillis());
if (StringUtils.isBlank(testResource.getId())) {
testResource.setId(UUID.randomUUID().toString()); testResource.setId(UUID.randomUUID().toString());
}
// 如果是更新操作插入与原来的ID相同的数据
testResourceMapper.insertSelective(testResource); testResourceMapper.insertSelective(testResource);
} }

View File

@ -1,6 +1,6 @@
package io.metersphere.track.controller; package io.metersphere.track.controller;
import io.metersphere.base.domain.TestCaseComment; import io.metersphere.track.dto.TestCaseCommentDTO;
import io.metersphere.track.request.testreview.SaveCommentRequest; import io.metersphere.track.request.testreview.SaveCommentRequest;
import io.metersphere.track.service.TestCaseCommentService; import io.metersphere.track.service.TestCaseCommentService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -13,7 +13,7 @@ import java.util.List;
public class TestCaseCommentController { public class TestCaseCommentController {
@Resource @Resource
TestCaseCommentService testCaseCommentService; private TestCaseCommentService testCaseCommentService;
@PostMapping("/save") @PostMapping("/save")
public void saveComment(@RequestBody SaveCommentRequest request) { public void saveComment(@RequestBody SaveCommentRequest request) {
@ -21,7 +21,17 @@ public class TestCaseCommentController {
} }
@GetMapping("/list/{caseId}") @GetMapping("/list/{caseId}")
public List<TestCaseComment> getComments(@PathVariable String caseId) { public List<TestCaseCommentDTO> getCaseComments(@PathVariable String caseId) {
return testCaseCommentService.getComments(caseId); return testCaseCommentService.getCaseComments(caseId);
}
@GetMapping("/delete/{commentId}")
public void deleteComment(@PathVariable String commentId) {
testCaseCommentService.delete(commentId);
}
@PostMapping("/edit")
public void editComment(@RequestBody SaveCommentRequest request) {
testCaseCommentService.edit(request);
} }
} }

View File

@ -106,7 +106,7 @@ public class TestCaseReviewController {
} }
@PostMapping("/get/{reviewId}") @GetMapping("/get/{reviewId}")
public TestCaseReview getTestReview(@PathVariable String reviewId) { public TestCaseReview getTestReview(@PathVariable String reviewId) {
checkOwnerService.checkTestReviewOwner(reviewId); checkOwnerService.checkTestReviewOwner(reviewId);
return testCaseReviewService.getTestReview(reviewId); return testCaseReviewService.getTestReview(reviewId);

View File

@ -0,0 +1,9 @@
package io.metersphere.track.dto;
import io.metersphere.base.domain.TestCaseComment;
import lombok.Data;
@Data
public class TestCaseCommentDTO extends TestCaseComment {
private String authorName;
}

View File

@ -10,4 +10,5 @@ public class TestCaseReviewDTO extends TestCaseReview {
private String projectName; private String projectName;
private String reviewerName; private String reviewerName;
private String creatorName;
} }

View File

@ -3,21 +3,23 @@ package io.metersphere.track.service;
import io.metersphere.base.domain.TestCaseComment; import io.metersphere.base.domain.TestCaseComment;
import io.metersphere.base.domain.TestCaseCommentExample; import io.metersphere.base.domain.TestCaseCommentExample;
import io.metersphere.base.domain.TestCaseWithBLOBs; import io.metersphere.base.domain.TestCaseWithBLOBs;
import io.metersphere.base.domain.User;
import io.metersphere.base.mapper.TestCaseCommentMapper; import io.metersphere.base.mapper.TestCaseCommentMapper;
import io.metersphere.base.mapper.TestCaseMapper; import io.metersphere.base.mapper.TestCaseMapper;
import io.metersphere.base.mapper.TestCaseReviewMapper; import io.metersphere.base.mapper.ext.ExtTestCaseCommentMapper;
import io.metersphere.base.mapper.UserMapper;
import io.metersphere.commons.constants.NoticeConstants; import io.metersphere.commons.constants.NoticeConstants;
import io.metersphere.commons.exception.MSException;
import io.metersphere.commons.utils.LogUtil; import io.metersphere.commons.utils.LogUtil;
import io.metersphere.commons.utils.SessionUtils; import io.metersphere.commons.utils.SessionUtils;
import io.metersphere.i18n.Translator;
import io.metersphere.notice.domain.MessageDetail; import io.metersphere.notice.domain.MessageDetail;
import io.metersphere.notice.domain.MessageSettingDetail; import io.metersphere.notice.domain.MessageSettingDetail;
import io.metersphere.notice.service.DingTaskService; import io.metersphere.notice.service.DingTaskService;
import io.metersphere.notice.service.MailService; import io.metersphere.notice.service.MailService;
import io.metersphere.notice.service.NoticeService; import io.metersphere.notice.service.NoticeService;
import io.metersphere.notice.service.WxChatTaskService; import io.metersphere.notice.service.WxChatTaskService;
import io.metersphere.track.dto.TestCaseCommentDTO;
import io.metersphere.track.request.testreview.SaveCommentRequest; import io.metersphere.track.request.testreview.SaveCommentRequest;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -33,21 +35,19 @@ import java.util.UUID;
public class TestCaseCommentService { public class TestCaseCommentService {
@Resource @Resource
TestCaseCommentMapper testCaseCommentMapper; private TestCaseCommentMapper testCaseCommentMapper;
@Resource @Resource
private TestCaseReviewMapper testCaseReviewMapper; private MailService mailService;
@Resource @Resource
UserMapper userMapper; private TestCaseMapper testCaseMapper;
@Resource @Resource
MailService mailService; private DingTaskService dingTaskService;
@Resource @Resource
TestCaseMapper testCaseMapper; private WxChatTaskService wxChatTaskService;
@Resource @Resource
DingTaskService dingTaskService; private NoticeService noticeService;
@Resource @Resource
WxChatTaskService wxChatTaskService; private ExtTestCaseCommentMapper extTestCaseCommentMapper;
@Resource
NoticeService noticeService;
public void saveComment(SaveCommentRequest request) { public void saveComment(SaveCommentRequest request) {
@ -86,21 +86,11 @@ public class TestCaseCommentService {
} }
public List<TestCaseComment> getComments(String caseId) { public List<TestCaseCommentDTO> getCaseComments(String caseId) {
TestCaseCommentExample testCaseCommentExample = new TestCaseCommentExample(); return extTestCaseCommentMapper.getCaseComments(caseId);
testCaseCommentExample.setOrderByClause("update_time desc");
testCaseCommentExample.createCriteria().andCaseIdEqualTo(caseId);
List<TestCaseComment> testCaseComments = testCaseCommentMapper.selectByExampleWithBLOBs(testCaseCommentExample);
testCaseComments.forEach(testCaseComment -> {
String authorId = testCaseComment.getAuthor();
User user = userMapper.selectByPrimaryKey(authorId);
String author = user == null ? authorId : user.getName();
testCaseComment.setAuthor(author);
});
return testCaseComments;
} }
public void deleteComment(String caseId) { public void deleteCaseComment(String caseId) {
TestCaseCommentExample testCaseCommentExample = new TestCaseCommentExample(); TestCaseCommentExample testCaseCommentExample = new TestCaseCommentExample();
testCaseCommentExample.createCriteria().andCaseIdEqualTo(caseId); testCaseCommentExample.createCriteria().andCaseIdEqualTo(caseId);
testCaseCommentMapper.deleteByExample(testCaseCommentExample); testCaseCommentMapper.deleteByExample(testCaseCommentExample);
@ -118,4 +108,22 @@ public class TestCaseCommentService {
context = "测试评审任务通知:" + testCaseComment.getAuthor() + "" + start + "" + "'" + testCaseWithBLOBs.getName() + "'" + "添加评论:" + testCaseComment.getDescription(); context = "测试评审任务通知:" + testCaseComment.getAuthor() + "" + start + "" + "'" + testCaseWithBLOBs.getName() + "'" + "添加评论:" + testCaseComment.getDescription();
return context; return context;
} }
public void delete(String commentId) {
checkCommentOwner(commentId);
testCaseCommentMapper.deleteByPrimaryKey(commentId);
}
public void edit(SaveCommentRequest request) {
checkCommentOwner(request.getId());
testCaseCommentMapper.updateByPrimaryKeySelective(request);
}
private void checkCommentOwner(String commentId) {
TestCaseComment comment = testCaseCommentMapper.selectByPrimaryKey(commentId);
if (!StringUtils.equals(comment.getAuthor(), SessionUtils.getUser().getId())) {
MSException.throwException(Translator.get("check_owner_comment"));
}
}
} }

View File

@ -170,7 +170,7 @@ public class TestCaseService {
example.createCriteria().andCaseIdEqualTo(testCaseId); example.createCriteria().andCaseIdEqualTo(testCaseId);
testPlanTestCaseMapper.deleteByExample(example); testPlanTestCaseMapper.deleteByExample(example);
testCaseIssueService.delTestCaseIssues(testCaseId); testCaseIssueService.delTestCaseIssues(testCaseId);
testCaseCommentService.deleteComment(testCaseId); testCaseCommentService.deleteCaseComment(testCaseId);
return testCaseMapper.deleteByPrimaryKey(testCaseId); return testCaseMapper.deleteByPrimaryKey(testCaseId);
} }
@ -458,7 +458,7 @@ public class TestCaseService {
String steps = t.getSteps(); String steps = t.getSteps();
String setp = ""; String setp = "";
if (steps.contains("null")) { if (steps.contains("null")) {
setp = steps.replace("null", ""); setp = steps.replace("null", "\"\"");
} else { } else {
setp = steps; setp = steps;
} }

View File

@ -0,0 +1,2 @@
ALTER TABLE `test_plan_test_case` ADD INDEX index_name ( `case_id` );
ALTER TABLE `test_case_review_test_case` ADD INDEX index_name ( `case_id` );

View File

@ -163,6 +163,7 @@ check_owner_test=The current user does not have permission to operate this test
check_owner_case=The current user does not have permission to operate this use case check_owner_case=The current user does not have permission to operate this use case
check_owner_plan=The current user does not have permission to operate this plan check_owner_plan=The current user does not have permission to operate this plan
check_owner_review=The current user does not have permission to operate this review check_owner_review=The current user does not have permission to operate this review
check_owner_comment=The current user does not have permission to manipulate this comment
upload_content_is_null=Imported content is empty upload_content_is_null=Imported content is empty
test_plan_notification=Test plan notification test_plan_notification=Test plan notification
task_defect_notification=Task defect notification task_defect_notification=Task defect notification

View File

@ -164,6 +164,7 @@ check_owner_test=当前用户没有操作此测试的权限
check_owner_case=当前用户没有操作此用例的权限 check_owner_case=当前用户没有操作此用例的权限
check_owner_plan=当前用户没有操作此计划的权限 check_owner_plan=当前用户没有操作此计划的权限
check_owner_review=当前用户没有操作此评审的权限 check_owner_review=当前用户没有操作此评审的权限
check_owner_comment=当前用户没有操作此评论的权限
upload_content_is_null=导入内容为空 upload_content_is_null=导入内容为空
test_plan_notification=测试计划通知 test_plan_notification=测试计划通知
task_defect_notification=缺陷任务通知 task_defect_notification=缺陷任务通知

View File

@ -165,6 +165,7 @@ check_owner_test=當前用戶沒有操作此測試的權限
check_owner_case=當前用戶沒有操作此用例的權限 check_owner_case=當前用戶沒有操作此用例的權限
check_owner_plan=當前用戶沒有操作此計劃的權限 check_owner_plan=當前用戶沒有操作此計劃的權限
check_owner_review=當前用戶沒有操作此評審的權限 check_owner_review=當前用戶沒有操作此評審的權限
check_owner_comment=當前用戶沒有操作此評論的權限
upload_content_is_null=導入內容為空 upload_content_is_null=導入內容為空
test_plan_notification=測試計畫通知 test_plan_notification=測試計畫通知
task_defect_notification=缺陷任務通知 task_defect_notification=缺陷任務通知

View File

@ -23,7 +23,7 @@
</el-tabs> </el-tabs>
</el-col> </el-col>
<el-col :span="16" style="margin-top: 40px;"> <el-col :span="16" style="margin-top: 40px;">
<ms-request-result-tail v-if="isRequestResult" :request="request" :scenario-name="scenarioName"/> <ms-request-result-tail v-if="isRequestResult" :request-type="requestType" :request="request" :scenario-name="scenarioName"/>
</el-col> </el-col>
</el-row> </el-row>
<ms-api-report-export v-if="reportExportVisible" id="apiTestReport" :title="report.testName" :content="content" :total-time="totalTime"/> <ms-api-report-export v-if="reportExportVisible" id="apiTestReport" :title="report.testName" :content="content" :total-time="totalTime"/>
@ -47,6 +47,7 @@ import MsApiReportExport from "./ApiReportExport";
import {exportPdf} from "../../../../common/js/utils"; import {exportPdf} from "../../../../common/js/utils";
import html2canvas from "html2canvas"; import html2canvas from "html2canvas";
import MsApiReportViewHeader from "./ApiReportViewHeader"; import MsApiReportViewHeader from "./ApiReportViewHeader";
import {RequestFactory} from "../test/model/ScenarioModel";
export default { export default {
name: "MsApiReportViewDetail", name: "MsApiReportViewDetail",
@ -67,7 +68,8 @@ export default {
isRequestResult: false, isRequestResult: false,
request: {}, request: {},
scenarioName: null, scenarioName: null,
reportExportVisible: false reportExportVisible: false,
requestType: undefined,
} }
}, },
props: ['reportId'], props: ['reportId'],
@ -140,6 +142,10 @@ export default {
}, },
requestResult(requestResult) { requestResult(requestResult) {
this.isRequestResult = false; this.isRequestResult = false;
this.requestType = undefined;
if (requestResult.request.body.indexOf('[Callable Statement]') > -1) {
this.requestType = RequestFactory.TYPES.SQL;
}
this.$nextTick(function () { this.$nextTick(function () {
this.isRequestResult = true; this.isRequestResult = true;
this.request = requestResult.request; this.request = requestResult.request;

View File

@ -69,14 +69,14 @@
<ms-request-metric :request="request"/> <ms-request-metric :request="request"/>
<ms-request-text :request="request"/> <ms-request-text :request="request"/>
<br> <br>
<ms-response-text :response="request.responseResult"/> <ms-response-text :request-type="requestType" :response="request.responseResult"/>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<div v-else> <div v-else>
<ms-request-metric :request="request"/> <ms-request-metric :request="request"/>
<ms-request-text v-if="isCodeEditAlive" :request="request"/> <ms-request-text v-if="isCodeEditAlive" :request="request"/>
<br> <br>
<ms-response-text v-if="isCodeEditAlive" :response="request.responseResult"/> <ms-response-text :request-type="requestType" v-if="isCodeEditAlive" :response="request.responseResult"/>
</div> </div>
</div> </div>
</el-collapse-transition> </el-collapse-transition>
@ -95,7 +95,8 @@
components: {MsResponseText, MsRequestText, MsAssertionResults, MsRequestMetric, MsRequestResult}, components: {MsResponseText, MsRequestText, MsAssertionResults, MsRequestMetric, MsRequestResult},
props: { props: {
request: Object, request: Object,
scenarioName: String scenarioName: String,
requestType: String
}, },
data() { data() {

View File

@ -6,8 +6,9 @@
</div> </div>
<el-collapse-transition> <el-collapse-transition>
<el-tabs v-model="activeName" v-show="isActive"> <el-tabs v-model="activeName" v-show="isActive">
<el-tab-pane label="Body" name="body" class="pane"> <el-tab-pane :class="'body-pane'" label="Body" name="body" class="pane">
<ms-code-edit :mode="mode" :read-only="true" :data="response.body" :modes="modes" ref="codeEdit"/> <ms-sql-result-table v-if="isSqlType" :body="response.body"/>
<ms-code-edit v-if="!isSqlType" :mode="mode" :read-only="true" :data="response.body" :modes="modes" ref="codeEdit"/>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="Headers" name="headers" class="pane"> <el-tab-pane label="Headers" name="headers" class="pane">
<pre>{{ response.headers }}</pre> <pre>{{ response.headers }}</pre>
@ -20,7 +21,7 @@
<pre>{{response.vars}}</pre> <pre>{{response.vars}}</pre>
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="activeName == 'body'" :disabled="true" name="mode" class="pane assertions"> <el-tab-pane v-if="activeName == 'body' && !isSqlType" :disabled="true" name="mode" class="pane assertions">
<template v-slot:label> <template v-slot:label>
<ms-dropdown :commands="modes" :default-command="mode" @command="modeChange"/> <ms-dropdown :commands="modes" :default-command="mode" @command="modeChange"/>
</template> </template>
@ -35,18 +36,21 @@
import MsAssertionResults from "./AssertionResults"; import MsAssertionResults from "./AssertionResults";
import MsCodeEdit from "../../../common/components/MsCodeEdit"; import MsCodeEdit from "../../../common/components/MsCodeEdit";
import MsDropdown from "../../../common/components/MsDropdown"; import MsDropdown from "../../../common/components/MsDropdown";
import {BODY_FORMAT} from "../../test/model/ScenarioModel"; import {BODY_FORMAT, RequestFactory, Request, SqlRequest} from "../../test/model/ScenarioModel";
import MsSqlResultTable from "./SqlResultTable";
export default { export default {
name: "MsResponseText", name: "MsResponseText",
components: { components: {
MsSqlResultTable,
MsDropdown, MsDropdown,
MsCodeEdit, MsCodeEdit,
MsAssertionResults, MsAssertionResults,
}, },
props: { props: {
requestType: String,
response: Object response: Object
}, },
@ -75,11 +79,23 @@ export default {
if (this.response.headers.indexOf("Content-Type: application/json") > 0) { if (this.response.headers.indexOf("Content-Type: application/json") > 0) {
this.mode = BODY_FORMAT.JSON; this.mode = BODY_FORMAT.JSON;
} }
},
computed: {
isSqlType() {
return (this.requestType === RequestFactory.TYPES.SQL && this.response.responseCode === '200');
}
} }
} }
</script> </script>
<style scoped> <style scoped>
.body-pane {
padding: 10px !important;
background: white !important;
}
.text-container .icon { .text-container .icon {
padding: 5px; padding: 5px;
} }
@ -110,4 +126,5 @@ export default {
pre { pre {
margin: 0; margin: 0;
} }
</style> </style>

View File

@ -0,0 +1,75 @@
<template>
<el-table
:data="tableData"
border
size="mini"
highlight-current-row>
<el-table-column v-for="(title, index) in titles" :key="index" :label="title" min-width="15%">
<template v-slot:default="scope">
<el-popover
placement="top"
trigger="click">
<el-container>
<div>{{ scope.row[title] }}</div>
</el-container>
<span class="table-content" slot="reference">{{ scope.row[title] }}</span>
</el-popover>
</template>
</el-table-column>
</el-table>
</template>
<script>
export default {
name: "MsSqlResultTable",
data() {
return {
tableData: [],
titles: []
}
},
props: {
body: String
},
created() {
if (!this.body) {
return;
}
let rowArry = this.body.split("\n");
let title;
let result = [];
for (let i = 0; i < rowArry.length; i++) {
let colArray = rowArry[i].split("\t");
if (i === 0) {
title = colArray;
} else {
let item = {};
for (let j = 0; j < colArray.length; j++) {
item[title[j]] = (colArray[j] ? colArray[j] : "");
}
result.push(item);
}
}
this.titles = title;
this.tableData = result;
this.tableData.splice(this.tableData.length - 3, 3);
}
}
</script>
<style scoped>
.el-table >>> .cell {
white-space: nowrap;
}
.table-content {
cursor: pointer;
}
.el-container {
overflow:auto;
max-height: 500px;
}
</style>

View File

@ -7,7 +7,9 @@
</el-col> </el-col>
<el-col class="assertion-btn"> <el-col class="assertion-btn">
<el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove" v-if="edit"/> <el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove" v-if="edit"/>
<el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>Add</el-button> <el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>
{{ $t('api_test.request.assertions.add') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>

View File

@ -11,7 +11,9 @@
</el-col> </el-col>
<el-col class="assertion-btn"> <el-col class="assertion-btn">
<el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove" v-if="edit"/> <el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove" v-if="edit"/>
<el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>Add</el-button> <el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>
{{ $t('api_test.request.assertions.add') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>

View File

@ -0,0 +1,261 @@
<template>
<div>
<el-row type="flex" align="middle" v-if="!edit">
<div class="assertion-item label">
{{ assertion.desc }}
</div>
<div class="assertion-item btn">
<el-button :disabled="isReadOnly" type="success" size="small" @click="detail">
{{ $t('commons.edit') }}
</el-button>
<el-button :disabled="isReadOnly" type="primary" size="small" @click="add">
{{ $t('api_test.request.assertions.add') }}
</el-button>
</div>
</el-row>
<el-row type="flex" justify="space-between" align="middle" v-else>
<div class="assertion-item label">
{{ assertion.desc }}
</div>
<div class="assertion-item btn circle">
<el-button :disabled="isReadOnly" type="success" size="mini" icon="el-icon-edit" circle @click="detail"/>
<el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove"/>
</div>
</el-row>
<el-dialog :title="$t('api_test.request.assertions.script')" :visible.sync="visible" width="900px">
<el-row type="flex" justify="space-between" align="middle" class="quick-script-block">
<div class="assertion-item input">
<el-input size="small" v-model="assertion.variable"
:placeholder="$t('api_test.request.assertions.variable_name')" @change="quickScript"/>
</div>
<div class="assertion-item select">
<el-select v-model="assertion.operator" :placeholder="$t('commons.please_select')" size="small"
@change="changeOperator">
<el-option v-for="o in operators" :key="o.value" :label="$t(o.label)" :value="o.value"/>
</el-select>
</div>
<div class="assertion-item input">
<el-input size="small" v-model="assertion.value" :placeholder="$t('api_test.value')"
@change="quickScript" v-if="!hasEmptyOperator"/>
</div>
</el-row>
<el-input size="small" v-model="assertion.desc" :placeholder="$t('api_test.request.assertions.script_name')"
class="quick-script-block"/>
<ms-jsr233-processor ref="jsr233" :is-read-only="isReadOnly" :jsr223-processor="assertion" :templates="templates"
:height="300"/>
<template v-slot:footer v-if="!edit">
<ms-dialog-footer
@cancel="close"
@confirm="confirm"/>
</template>
</el-dialog>
</div>
</template>
<script>
import {AssertionJSR223} from "../../model/ScenarioModel";
import MsJsr233Processor from "@/business/components/api/test/components/processor/Jsr233Processor";
import MsDialogFooter from "@/business/components/common/components/MsDialogFooter";
export default {
name: "MsApiAssertionJsr223",
components: {MsDialogFooter, MsJsr233Processor},
props: {
assertion: {
type: AssertionJSR223,
default: () => {
return new AssertionJSR223();
}
},
edit: {
type: Boolean,
default: false
},
index: Number,
list: Array,
callback: Function,
isReadOnly: {
type: Boolean,
default: false
}
},
data() {
return {
visible: false,
operators: {
EQ: {
label: "commons.adv_search.operators.equals",
value: "=="
},
NE: {
label: "commons.adv_search.operators.not_equals",
value: "!="
},
CONTAINS: {
label: "commons.adv_search.operators.like",
value: "contains"
},
NOT_CONTAINS: {
label: "commons.adv_search.operators.not_like",
value: "not contains"
},
GT: {
label: "commons.adv_search.operators.gt",
value: ">"
},
LT: {
label: "commons.adv_search.operators.lt",
value: "<"
},
IS_EMPTY: {
label: "commons.adv_search.operators.is_empty",
value: "is empty"
},
IS_NOT_EMPTY: {
label: "commons.adv_search.operators.is_not_empty",
value: "is not empty"
}
},
templates: [
{
title: this.$t('api_test.request.assertions.set_failure_status'),
value: 'AssertionResult.setFailure(true)',
},
{
title: this.$t('api_test.request.assertions.set_failure_msg'),
value: 'AssertionResult.setFailureMessage("msg")',
}
],
}
},
methods: {
add() {
this.list.push(new AssertionJSR223(this.assertion));
this.callback();
},
remove() {
this.list.splice(this.index, 1);
},
changeOperator(value) {
if (value.indexOf("empty") > 0 && !!this.assertion.value) {
this.assertion.value = "";
}
this.quickScript();
},
quickScript() {
if (this.assertion.variable && this.assertion.operator) {
let variable = this.assertion.variable;
let operator = this.assertion.operator;
let value = this.assertion.value || "";
let desc = "${" + variable + "} " + operator + " '" + value + "'";
let script = "value = vars.get(\"" + variable + "\");\n"
switch (this.assertion.operator) {
case "==":
script += "result = \"" + value + "\".equals(value);\n";
break;
case "!=":
script += "result = !\"" + value + "\".equals(value);\n";
break;
case "contains":
script += "result = value.contains(\"" + value + "\");\n";
break;
case "not contains":
script += "result = !value.contains(\"" + value + "\");\n";
break;
case ">":
desc = "${" + variable + "} " + operator + " " + value;
script += "number = Integer.parseInt(value);\n" +
"result = number > " + value + ";\n";
break;
case "<":
desc = "${" + variable + "} " + operator + " " + value;
script += "number = Integer.parseInt(value);\n" +
"result = number < " + value + ";\n";
break;
case "is empty":
desc = "${" + variable + "} " + operator
script += "result = value == void || value.length() == 0;\n";
break;
case "is not empty":
desc = "${" + variable + "} " + operator
script += "result = value != void && value.length() > 0;\n";
break;
}
let msg = "assertion [" + desc + "]: false;"
script += "if (!result){\n" +
"\tmsg = \"" + msg + "\";\n" +
"\tAssertionResult.setFailureMessage(msg);\n" +
"\tAssertionResult.setFailure(true);\n" +
"}";
this.assertion.desc = desc;
this.assertion.script = script;
this.$refs.jsr233.reload();
}
},
detail() {
this.visible = true;
},
close() {
this.visible = false;
},
confirm() {
if (!this.edit) {
this.add();
}
if (!this.assertion.desc) {
this.assertion.desc = this.assertion.script;
}
this.close();
}
},
computed: {
hasEmptyOperator() {
return !!this.assertion.operator && this.assertion.operator.indexOf("empty") > 0;
}
}
}
</script>
<style scoped>
.assertion-item {
display: inline-block;
}
.assertion-item + .assertion-item {
margin-left: 10px;
}
.assertion-item.input {
width: 100%;
}
.assertion-item.select {
min-width: 150px;
}
.assertion-item.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assertion-item.btn {
min-width: 130px;
}
.assertion-item.btn.circle {
text-align: right;
min-width: 80px;
}
.quick-script-block {
margin-bottom: 10px;
}
</style>

View File

@ -21,7 +21,9 @@
<el-col class="assertion-btn"> <el-col class="assertion-btn">
<el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove" <el-button :disabled="isReadOnly" type="danger" size="mini" icon="el-icon-delete" circle @click="remove"
v-if="edit"/> v-if="edit"/>
<el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>Add</el-button> <el-button :disabled="isReadOnly" type="primary" size="small" @click="add" v-else>
{{ $t('api_test.request.assertions.add') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>

View File

@ -29,7 +29,9 @@
</el-checkbox> </el-checkbox>
</el-col> </el-col>
<el-col class="assertion-btn"> <el-col class="assertion-btn">
<el-button :disabled="isReadOnly" type="primary" size="small" @click="add">Add</el-button> <el-button :disabled="isReadOnly" type="primary" size="small" @click="add">
{{ $t('api_test.request.assertions.add') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>

View File

@ -9,6 +9,7 @@
<el-option :label="$t('api_test.request.assertions.regex')" :value="options.REGEX"/> <el-option :label="$t('api_test.request.assertions.regex')" :value="options.REGEX"/>
<el-option :label="'JSONPath'" :value="options.JSON_PATH"/> <el-option :label="'JSONPath'" :value="options.JSON_PATH"/>
<el-option :label="$t('api_test.request.assertions.response_time')" :value="options.DURATION"/> <el-option :label="$t('api_test.request.assertions.response_time')" :value="options.DURATION"/>
<el-option :label="$t('api_test.request.assertions.jsr223')" :value="options.JSR223"/>
</el-select> </el-select>
</el-col> </el-col>
<el-col :span="20"> <el-col :span="20">
@ -17,11 +18,27 @@
<ms-api-assertion-json-path :is-read-only="isReadOnly" :list="assertions.jsonPath" v-if="type === options.JSON_PATH" :callback="after"/> <ms-api-assertion-json-path :is-read-only="isReadOnly" :list="assertions.jsonPath" v-if="type === options.JSON_PATH" :callback="after"/>
<ms-api-assertion-duration :is-read-only="isReadOnly" v-model="time" :duration="assertions.duration" <ms-api-assertion-duration :is-read-only="isReadOnly" v-model="time" :duration="assertions.duration"
v-if="type === options.DURATION" :callback="after"/> v-if="type === options.DURATION" :callback="after"/>
<el-button v-if="!type" :disabled="true" type="primary" size="small">Add</el-button> <ms-api-assertion-jsr223 :is-read-only="isReadOnly" :list="assertions.jsr223" v-if="type === options.JSR223" :callback="after"/>
<el-button v-if="!type" :disabled="true" type="primary" size="small">
{{ $t('api_test.request.assertions.add') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
<div>
<el-row :gutter="10" class="json-path-suggest-button">
<el-button size="small" type="primary" @click="suggestJsonOpen">
{{$t('api_test.request.assertions.json_path_suggest')}}
</el-button>
<el-button size="small" type="danger" @click="clearJson">
{{$t('api_test.request.assertions.json_path_clear')}}
</el-button>
</el-row>
</div>
<ms-api-jsonpath-suggest-list @addJsonpathSuggest="addJsonpathSuggest" :request="request" ref="jsonpathSuggestList"/>
<ms-api-assertions-edit :is-read-only="isReadOnly" :assertions="assertions"/> <ms-api-assertions-edit :is-read-only="isReadOnly" :assertions="assertions"/>
</div> </div>
</template> </template>
@ -30,19 +47,24 @@
import MsApiAssertionText from "./ApiAssertionText"; import MsApiAssertionText from "./ApiAssertionText";
import MsApiAssertionRegex from "./ApiAssertionRegex"; import MsApiAssertionRegex from "./ApiAssertionRegex";
import MsApiAssertionDuration from "./ApiAssertionDuration"; import MsApiAssertionDuration from "./ApiAssertionDuration";
import {ASSERTION_TYPE, Assertions} from "../../model/ScenarioModel"; import {ASSERTION_TYPE, Assertions, HttpRequest, JSONPath} from "../../model/ScenarioModel";
import MsApiAssertionsEdit from "./ApiAssertionsEdit"; import MsApiAssertionsEdit from "./ApiAssertionsEdit";
import MsApiAssertionJsonPath from "./ApiAssertionJsonPath"; import MsApiAssertionJsonPath from "./ApiAssertionJsonPath";
import MsApiAssertionJsr223 from "@/business/components/api/test/components/assertion/ApiAssertionJsr223";
import MsApiJsonpathSuggestList from "./ApiJsonpathSuggestList";
export default { export default {
name: "MsApiAssertions", name: "MsApiAssertions",
components: { components: {
MsApiAssertionJsr223,
MsApiJsonpathSuggestList,
MsApiAssertionJsonPath, MsApiAssertionJsonPath,
MsApiAssertionsEdit, MsApiAssertionDuration, MsApiAssertionRegex, MsApiAssertionText}, MsApiAssertionsEdit, MsApiAssertionDuration, MsApiAssertionRegex, MsApiAssertionText},
props: { props: {
assertions: Assertions, assertions: Assertions,
request: HttpRequest,
isReadOnly: { isReadOnly: {
type: Boolean, type: Boolean,
default: false default: false
@ -60,6 +82,25 @@
methods: { methods: {
after() { after() {
this.type = ""; this.type = "";
},
suggestJsonOpen() {
if (!this.request.debugRequestResult) {
this.$message(this.$t('api_test.request.assertions.debug_first'));
return;
}
this.$refs.jsonpathSuggestList.open();
},
addJsonpathSuggest(jsonPathList) {
jsonPathList.forEach(jsonPath => {
let jsonItem = new JSONPath();
jsonItem.expression = jsonPath.json_path;
jsonItem.expect = jsonPath.json_value;
jsonItem.setJSONPathDescription();
this.assertions.jsonPath.push(jsonItem);
});
},
clearJson() {
this.assertions.jsonPath = [];
} }
} }
@ -77,4 +118,31 @@
margin: 5px 0; margin: 5px 0;
border-radius: 5px; border-radius: 5px;
} }
.bg-purple-dark {
background: #99a9bf;
}
.bg-purple {
background: #d3dce6;
}
.bg-purple-light {
background: #e5e9f2;
}
.grid-content {
border-radius: 4px;
min-height: 36px;
}
.row-bg {
padding: 10px 0;
background-color: #f9fafc;
}
.json-path-suggest-button {
text-align: right;
}
</style> </style>

View File

@ -5,7 +5,8 @@
{{ $t("api_test.request.assertions.regex") }} {{ $t("api_test.request.assertions.regex") }}
</div> </div>
<div class="regex-item" v-for="(regex, index) in assertions.regex" :key="index"> <div class="regex-item" v-for="(regex, index) in assertions.regex" :key="index">
<ms-api-assertion-regex :is-read-only="isReadOnly" :list="assertions.regex" :regex="regex" :edit="true" :index="index"/> <ms-api-assertion-regex :is-read-only="isReadOnly" :list="assertions.regex"
:regex="regex" :edit="true" :index="index"/>
</div> </div>
</div> </div>
@ -14,7 +15,18 @@
{{ 'JSONPath' }} {{ 'JSONPath' }}
</div> </div>
<div class="regex-item" v-for="(jsonPath, index) in assertions.jsonPath" :key="index"> <div class="regex-item" v-for="(jsonPath, index) in assertions.jsonPath" :key="index">
<ms-api-assertion-json-path :is-read-only="isReadOnly" :list="assertions.jsonPath" :json-path="jsonPath" :edit="true" :index="index"/> <ms-api-assertion-json-path :is-read-only="isReadOnly" :list="assertions.jsonPath"
:json-path="jsonPath" :edit="true" :index="index"/>
</div>
</div>
<div class="assertion-item-editing jsr223" v-if="assertions.jsr223.length > 0">
<div>
{{ $t("api_test.request.assertions.script") }}
</div>
<div class="regex-item" v-for="(assertion, index) in assertions.jsr223" :key="index">
<ms-api-assertion-jsr223 :is-read-only="isReadOnly" :list="assertions.jsr223"
:assertion="assertion" :edit="true" :index="index"/>
</div> </div>
</div> </div>
@ -22,7 +34,8 @@
<div> <div>
{{ $t("api_test.request.assertions.response_time") }} {{ $t("api_test.request.assertions.response_time") }}
</div> </div>
<ms-api-assertion-duration :is-read-only="isReadOnly" v-model="assertions.duration.value" :duration="assertions.duration" :edit="true"/> <ms-api-assertion-duration :is-read-only="isReadOnly" v-model="assertions.duration.value"
:duration="assertions.duration" :edit="true"/>
</div> </div>
</div> </div>
@ -33,11 +46,12 @@
import MsApiAssertionDuration from "./ApiAssertionDuration"; import MsApiAssertionDuration from "./ApiAssertionDuration";
import {Assertions} from "../../model/ScenarioModel"; import {Assertions} from "../../model/ScenarioModel";
import MsApiAssertionJsonPath from "./ApiAssertionJsonPath"; import MsApiAssertionJsonPath from "./ApiAssertionJsonPath";
import MsApiAssertionJsr223 from "@/business/components/api/test/components/assertion/ApiAssertionJsr223";
export default { export default {
name: "MsApiAssertionsEdit", name: "MsApiAssertionsEdit",
components: {MsApiAssertionJsonPath, MsApiAssertionDuration, MsApiAssertionRegex}, components: {MsApiAssertionJsr223, MsApiAssertionJsonPath, MsApiAssertionDuration, MsApiAssertionRegex},
props: { props: {
assertions: Assertions, assertions: Assertions,
@ -74,6 +88,10 @@
border-left: 2px solid #DD0240; border-left: 2px solid #DD0240;
} }
.assertion-item-editing.jsr223 {
border-left: 2px solid #1FDD02;
}
.regex-item { .regex-item {
margin-top: 10px; margin-top: 10px;
} }

View File

@ -0,0 +1,115 @@
<template>
<el-dialog :title="$t('api_test.request.assertions.json_path_add')"
:visible.sync="dialogFormVisible"
@close="close"
width="60%" v-loading="result.loading"
:close-on-click-modal="false"
top="50px">
<el-container class="main-content">
<el-container>
<el-main class="case-content">
<el-table
:data="jsonPathList"
row-key="id"
@select-all="handleSelectAll"
@select="handleSelectionChange"
height="50vh"
ref="table">
<el-table-column type="selection"/>
<el-table-column
prop="name"
:label="$t('api_test.request.extract.json_path_expression')"
style="width: 100%">
<template v-slot:default="scope">
{{scope.row.json_path}}
</template>
</el-table-column>
<el-table-column
prop="name"
:label="$t('api_test.request.assertions.value')"
style="width: 100%">
<template v-slot:default="scope">
{{scope.row.json_value}}
</template>
</el-table-column>
</el-table>
</el-main>
</el-container>
</el-container>
<template v-slot:footer>
<ms-dialog-footer @cancel="dialogFormVisible = false" @confirm="commit"/>
</template>
</el-dialog>
</template>
<script>
import MsDialogFooter from "../../../../common/components/MsDialogFooter";
import {HttpRequest} from "../../model/ScenarioModel";
export default {
name: "MsApiJsonpathSuggestList",
components: {MsDialogFooter},
data() {
return {
result: {},
dialogFormVisible: false,
isCheckAll: false,
selectItems: new Set(),
jsonPathList: [],
};
},
props: {
request: HttpRequest,
},
methods: {
close() {
this.selectItems.clear();
},
open() {
this.getJsonPaths();
},
getJsonPaths() {
if (this.request.debugRequestResult) {
let param = {
jsonPath: this.request.debugRequestResult.responseResult.body
};
this.result = this.$post("/api/getJsonPaths", param).then(response => {
this.jsonPathList = response.data.data;
this.dialogFormVisible = true;
}).catch(() => {
this.$warning(this.$t('api_test.request.assertions.json_path_err'));
this.dialogFormVisible = false;
});
}
},
handleSelectAll(selection) {
if (selection.length > 0) {
this.selectItems = new Set(this.jsonPathList);
} else {
this.selectItems = new Set();
}
},
handleSelectionChange(selection, row) {
if (this.selectItems.has(row)) {
this.selectItems.delete(row);
} else {
this.selectItems.add(row);
}
},
commit() {
this.$emit("addJsonpathSuggest", this.selectItems);
this.dialogFormVisible = false;
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,8 +1,10 @@
<template> <template>
<div> <div>
<el-row> <el-row>
<el-col :span="20" class="script-content"> <el-col :span="20">
<ms-code-edit v-if="isCodeEditAlive" :mode="codeEditModeMap[jsr223Processor.language]" :read-only="isReadOnly" :data.sync="jsr223Processor.script" theme="eclipse" :modes="['java','python']" ref="codeEdit"/> <ms-code-edit v-if="isCodeEditAlive" :mode="codeEditModeMap[jsr223Processor.language]" :read-only="isReadOnly"
:data.sync="jsr223Processor.script" theme="eclipse" :modes="['java','python']" ref="codeEdit"
:height="height"/>
</el-col> </el-col>
<el-col :span="4" class="script-index"> <el-col :span="4" class="script-index">
<ms-dropdown :default-command="jsr223Processor.language" :commands="languages" @command="languageChange"/> <ms-dropdown :default-command="jsr223Processor.language" :commands="languages" @command="languageChange"/>
@ -11,7 +13,9 @@
<el-link :disabled="template.disabled" @click="addTemplate(template)">{{ template.title }}</el-link> <el-link :disabled="template.disabled" @click="addTemplate(template)">{{ template.title }}</el-link>
</div> </div>
<div class="document-url"> <div class="document-url">
<el-link href="https://jmeter.apache.org/usermanual/component_reference.html#BeanShell_PostProcessor" type="primary">{{$t('commons.reference_documentation')}}</el-link> <el-link href="https://jmeter.apache.org/usermanual/component_reference.html#BeanShell_PostProcessor"
type="primary">{{ $t('commons.reference_documentation') }}
</el-link>
<ms-instructions-icon :content="$t('api_test.request.processor.bean_shell_processor_tip')"/> <ms-instructions-icon :content="$t('api_test.request.processor.bean_shell_processor_tip')"/>
</div> </div>
</el-col> </el-col>
@ -24,11 +28,33 @@
import MsInstructionsIcon from "../../../../common/components/MsInstructionsIcon"; import MsInstructionsIcon from "../../../../common/components/MsInstructionsIcon";
import MsDropdown from "../../../../common/components/MsDropdown"; import MsDropdown from "../../../../common/components/MsDropdown";
export default { export default {
name: "MsJsr233Processor", name: "MsJsr233Processor",
components: {MsDropdown, MsInstructionsIcon, MsCodeEdit}, components: {MsDropdown, MsInstructionsIcon, MsCodeEdit},
props: {
type: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false
},
jsr223Processor: {
type: Object,
},
isPreProcessor: {
type: Boolean,
default: false
},
templates: {
type: Array,
default: () => []
},
height: {
type: [String, Number],
default: 230
}
},
data() { data() {
return { return {
codeTemplates: [ codeTemplates: [
@ -62,7 +88,8 @@
title: this.$t('api_test.request.processor.code_template_get_response_result'), title: this.$t('api_test.request.processor.code_template_get_response_result'),
value: 'prev.getResponseDataAsString()', value: 'prev.getResponseDataAsString()',
disabled: this.isPreProcessor disabled: this.isPreProcessor
} },
...this.templates
], ],
isCodeEditAlive: true, isCodeEditAlive: true,
languages: [ languages: [
@ -74,22 +101,6 @@
} }
} }
}, },
props: {
type: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false
},
jsr223Processor: {
type: Object,
},
isPreProcessor: {
type: Boolean,
default: false
}
},
watch: { watch: {
jsr223Processor() { jsr223Processor() {
this.reload(); this.reload();
@ -123,10 +134,6 @@
border-radius: 5px; border-radius: 5px;
} }
.script-content {
height: calc(100vh - 570px);
}
.script-index { .script-index {
padding: 0 20px; padding: 0 20px;
} }

View File

@ -1,4 +1,4 @@
<template> <template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<el-form :model="request" :rules="rules" ref="request" label-width="100px" :disabled="isReadOnly"> <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-form-item :label="$t('api_test.request.name')" prop="name">
@ -66,7 +66,7 @@
:environment="scenario.environment"/> :environment="scenario.environment"/>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="$t('api_test.request.assertions.label')" name="assertions"> <el-tab-pane :label="$t('api_test.request.assertions.label')" name="assertions">
<ms-api-assertions :is-read-only="isReadOnly" :assertions="request.assertions"/> <ms-api-assertions :request="request" :is-read-only="isReadOnly" :assertions="request.assertions"/>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="$t('api_test.request.extract.label')" name="extract"> <el-tab-pane :label="$t('api_test.request.extract.label')" name="extract">
<ms-api-extract :is-read-only="isReadOnly" :extract="request.extract"/> <ms-api-extract :is-read-only="isReadOnly" :extract="request.extract"/>
@ -104,6 +104,7 @@ export default {
MsApiVariable, ApiRequestMethodSelect, MsApiExtract, MsApiAssertions, MsApiBody, MsApiKeyValue}, MsApiVariable, ApiRequestMethodSelect, MsApiExtract, MsApiAssertions, MsApiBody, MsApiKeyValue},
props: { props: {
request: HttpRequest, request: HttpRequest,
jsonPathList: Array,
scenario: Scenario, scenario: Scenario,
isReadOnly: { isReadOnly: {
type: Boolean, type: Boolean,

View File

@ -2,10 +2,8 @@
<div class="request-form"> <div class="request-form">
<component @runDebug="runDebug" :is="component" :is-read-only="isReadOnly" :request="request" :scenario="scenario"/> <component @runDebug="runDebug" :is="component" :is-read-only="isReadOnly" :request="request" :scenario="scenario"/>
<el-divider v-if="isCompleted"></el-divider> <el-divider v-if="isCompleted"></el-divider>
<ms-request-result-tail v-loading="debugReportLoading" v-if="isCompleted" <ms-request-result-tail v-loading="debugReportLoading" v-if="isCompleted" :requestType="request.type" :request="request.debugRequestResult ? request.debugRequestResult : {responseResult: {}, subRequestResults: []}"
:request="request.debugRequestResult ? request.debugRequestResult : {responseResult: {}, subRequestResults: []}" :scenario-name="request.debugScenario ? request.debugScenario.name : ''" ref="msDebugResult"/>
:scenario-name="request.debugScenario ? request.debugScenario.name : ''"
ref="msDebugResult"/>
</div> </div>
</template> </template>
@ -20,14 +18,7 @@ import MsApiSqlRequestForm from "./ApiSqlRequestForm";
export default { export default {
name: "MsApiRequestForm", name: "MsApiRequestForm",
components: { components: {MsApiSqlRequestForm, MsRequestResultTail, MsScenarioResults, MsApiDubboRequestForm, MsApiHttpRequestForm},
MsApiSqlRequestForm,
MsRequestResultTail,
MsScenarioResults,
MsApiDubboRequestForm,
MsApiHttpRequestForm,
MsApiTcpRequestForm
},
props: { props: {
scenario: Scenario, scenario: Scenario,
request: Request, request: Request,
@ -42,7 +33,8 @@ export default {
reportId: "", reportId: "",
content: {scenarios:[]}, content: {scenarios:[]},
debugReportLoading: false, debugReportLoading: false,
showDebugReport: false showDebugReport: false,
jsonPathList:[]
} }
}, },
computed: { computed: {
@ -91,6 +83,7 @@ export default {
this.$get(url, response => { this.$get(url, response => {
let report = response.data || {}; let report = response.data || {};
let res = {}; let res = {};
if (response.data) { if (response.data) {
try { try {
res = JSON.parse(report.content); res = JSON.parse(report.content);

View File

@ -501,6 +501,12 @@ export class JSR223Processor extends DefaultTestElement {
} }
} }
export class JSR223Assertion extends JSR223Processor {
constructor(testName, processor) {
super('JSR223Assertion', 'TestBeanGUI', 'JSR223Assertion', testName, processor)
}
}
export class JSR223PreProcessor extends JSR223Processor { export class JSR223PreProcessor extends JSR223Processor {
constructor(testName, processor) { constructor(testName, processor) {
super('JSR223PreProcessor', 'TestBeanGUI', 'JSR223PreProcessor', testName, processor) super('JSR223PreProcessor', 'TestBeanGUI', 'JSR223PreProcessor', testName, processor)

View File

@ -25,7 +25,7 @@ import {
ThreadGroup, ThreadGroup,
XPath2Extractor, XPath2Extractor,
IfController as JMXIfController, IfController as JMXIfController,
ConstantTimer as JMXConstantTimer, TCPSampler, ConstantTimer as JMXConstantTimer, TCPSampler, JSR223Assertion,
} from "./JMX"; } from "./JMX";
import Mock from "mockjs"; import Mock from "mockjs";
import {funcFilters} from "@/common/js/func-filter"; import {funcFilters} from "@/common/js/func-filter";
@ -94,7 +94,8 @@ export const ASSERTION_TYPE = {
TEXT: "Text", TEXT: "Text",
REGEX: "Regex", REGEX: "Regex",
JSON_PATH: "JSON", JSON_PATH: "JSON",
DURATION: "Duration" DURATION: "Duration",
JSR223: "JSR223",
} }
export const ASSERTION_REGEX_SUBJECT = { export const ASSERTION_REGEX_SUBJECT = {
@ -716,16 +717,34 @@ export class KeyValue extends BaseConfig {
} }
} }
export class BeanShellProcessor extends BaseConfig {
constructor(options) {
super();
this.script = undefined;
this.set(options);
}
}
export class JSR223Processor extends BaseConfig {
constructor(options) {
super();
this.script = undefined;
this.language = "beanshell";
this.set(options);
}
}
export class Assertions extends BaseConfig { export class Assertions extends BaseConfig {
constructor(options) { constructor(options) {
super(); super();
this.text = []; this.text = [];
this.regex = []; this.regex = [];
this.jsonPath = []; this.jsonPath = [];
this.jsr223 = [];
this.duration = undefined; this.duration = undefined;
this.set(options); this.set(options);
this.sets({text: Text, regex: Regex, jsonPath: JSONPath}, options); this.sets({text: Text, regex: Regex, jsonPath: JSONPath, jsr223: AssertionJSR223}, options);
} }
initOptions(options) { initOptions(options) {
@ -742,22 +761,23 @@ export class AssertionType extends BaseConfig {
} }
} }
export class BeanShellProcessor extends BaseConfig { export class AssertionJSR223 extends AssertionType {
constructor(options) { constructor(options) {
super(); super(ASSERTION_TYPE.JSR223);
this.script = undefined; this.variable = undefined;
this.set(options); this.operator = undefined;
} this.value = undefined;
} this.desc = undefined;
this.name = undefined;
export class JSR223Processor extends BaseConfig {
constructor(options) {
super();
this.script = undefined; this.script = undefined;
this.language = "beanshell"; this.language = "beanshell";
this.set(options); this.set(options);
} }
isValid() {
return !!this.script && !!this.language;
}
} }
export class Text extends AssertionType { export class Text extends AssertionType {
@ -797,6 +817,10 @@ export class JSONPath extends AssertionType {
this.set(options); this.set(options);
} }
setJSONPathDescription() {
this.description = this.expression + " expect: " + (this.expect ? this.expect : '');
}
isValid() { isValid() {
return !!this.expression; return !!this.expression;
} }
@ -1402,6 +1426,12 @@ class JMXGenerator {
}) })
} }
if (assertions.jsr223.length > 0) {
assertions.jsr223.filter(this.filter).forEach(item => {
httpSamplerProxy.put(this.getJSR223Assertion(item));
})
}
if (assertions.duration.isValid()) { if (assertions.duration.isValid()) {
let name = "Response In Time: " + assertions.duration.value let name = "Response In Time: " + assertions.duration.value
httpSamplerProxy.put(new DurationAssertion(name, assertions.duration.value)); httpSamplerProxy.put(new DurationAssertion(name, assertions.duration.value));
@ -1413,6 +1443,11 @@ class JMXGenerator {
return new JSONPathAssertion(name, jsonPath); return new JSONPathAssertion(name, jsonPath);
} }
getJSR223Assertion(item) {
let name = item.desc;
return new JSR223Assertion(name, item);
}
getResponseAssertion(regex) { getResponseAssertion(regex) {
let name = regex.description; let name = regex.description;
let type = JMX_ASSERTION_CONDITION.CONTAINS; // 固定用Match自己写正则 let type = JMX_ASSERTION_CONDITION.CONTAINS; // 固定用Match自己写正则

View File

@ -1,5 +1,5 @@
<template> <template>
<editor v-model="formatData" :lang="mode" @init="editorInit" :theme="theme"/> <editor v-model="formatData" :lang="mode" @init="editorInit" :theme="theme" :height="height"/>
</template> </template>
<script> <script>
@ -12,6 +12,7 @@
} }
}, },
props: { props: {
height: [String, Number],
data: { data: {
type: String type: String
}, },

View File

@ -143,6 +143,34 @@ export const CREATOR = {
} }
} }
export const EXECUTOR = {
key: "executor",
name: 'MsTableSearchSelect',
label: 'test_track.plan_view.executor',
operator: {
options: [OPERATORS.IN, OPERATORS.NOT_IN, OPERATORS.CURRENT_USER],
change: function (component, value) { // 运算符change事件
if (value === OPERATORS.CURRENT_USER.value) {
component.value = value;
}
}
},
options: { // 异步获取候选项
url: "/user/list",
labelKey: "name",
valueKey: "id",
showLabel: option => {
return option.label + "(" + option.value + ")";
}
},
props: {
multiple: true
},
isShow: operator => {
return operator !== OPERATORS.CURRENT_USER.value;
}
}
export const TRIGGER_MODE = { export const TRIGGER_MODE = {
key: "triggerMode", key: "triggerMode",
name: 'MsTableSearchSelect', name: 'MsTableSearchSelect',
@ -287,6 +315,6 @@ export const TEST_CONFIGS = [NAME, UPDATE_TIME, PROJECT_NAME, CREATE_TIME, STATU
export const REPORT_CONFIGS = [NAME, TEST_NAME, PROJECT_NAME, CREATE_TIME, STATUS, CREATOR, TRIGGER_MODE]; export const REPORT_CONFIGS = [NAME, TEST_NAME, PROJECT_NAME, CREATE_TIME, STATUS, CREATOR, TRIGGER_MODE];
export const TEST_CASE_CONFIGS = [NAME, MODULE, PRIORITY, CREATE_TIME, TYPE, UPDATE_TIME, METHOD, CREATOR]; export const TEST_CASE_CONFIGS = [NAME, MODULE, PRIORITY, CREATE_TIME, TYPE, UPDATE_TIME, METHOD, CREATOR, EXECUTOR];
export const TEST_PLAN_CONFIGS = [NAME, UPDATE_TIME, PROJECT_NAME, CREATE_TIME, PRINCIPAL, TEST_PLAN_STATUS, STAGE]; export const TEST_PLAN_CONFIGS = [NAME, UPDATE_TIME, PROJECT_NAME, CREATE_TIME, PRINCIPAL, TEST_PLAN_STATUS, STAGE];

View File

@ -65,6 +65,14 @@
<ms-chart ref="chart2" :options="resOption" class="chart-config" :autoresize="true"></ms-chart> <ms-chart ref="chart2" :options="resOption" class="chart-config" :autoresize="true"></ms-chart>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<el-col :span="12">
<ms-chart ref="chart3" :options="errorOption" class="chart-config" :autoresize="true"></ms-chart>
</el-col>
<el-col :span="12">
<ms-chart ref="chart3" :options="resCodeOption" class="chart-config" :autoresize="true"></ms-chart>
</el-col>
</el-row>
</div> </div>
</template> </template>
@ -84,6 +92,8 @@ export default {
avgBandwidth: "0", avgBandwidth: "0",
loadOption: {}, loadOption: {},
resOption: {}, resOption: {},
errorOption: {},
resCodeOption: {},
id: '' id: ''
} }
}, },
@ -106,6 +116,12 @@ export default {
this.avgBandwidth = '0'; this.avgBandwidth = '0';
this.$warning(this.$t('report.generation_error')); this.$warning(this.$t('report.generation_error'));
}) })
this.getLoadChart();
this.getResChart();
this.getErrorChart();
this.getResponseCodeChart();
},
getLoadChart() {
this.$get("/performance/report/content/load_chart/" + this.id).then(res => { this.$get("/performance/report/content/load_chart/" + this.id).then(res => {
let data = res.data.data; let data = res.data.data;
let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis); let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis);
@ -181,6 +197,8 @@ export default {
}).catch(() => { }).catch(() => {
this.loadOption = {}; this.loadOption = {};
}) })
},
getResChart() {
this.$get("/performance/report/content/res_chart/" + this.id).then(res => { this.$get("/performance/report/content/res_chart/" + this.id).then(res => {
let data = res.data.data; let data = res.data.data;
let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis); let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis);
@ -264,6 +282,146 @@ export default {
this.resOption = {}; this.resOption = {};
}) })
}, },
getErrorChart() {
this.$get("/performance/report/content/error_chart/" + this.id).then(res => {
let data = res.data.data;
let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis);
let yAxisListMax = this._getChartMax(yAxisList);
let yAxisIndex0List = data.filter(m => m.yAxis2 === -1).map(m => m.groupName);
yAxisIndex0List = this._unique(yAxisIndex0List);
let errorOption = {
title: {
text: 'Errors',
left: 'center',
top: 20,
textStyle: {
color: '#99743C'
},
},
tooltip: {
show: true,
trigger: 'axis',
extraCssText: 'z-index: 999;',
formatter: function (params, ticket, callback) {
let result = "";
let name = params[0].name;
result += name + "<br/>";
for (let i = 0; i < params.length; i++) {
let seriesName = params[i].seriesName;
if (seriesName.length > 100) {
seriesName = seriesName.substring(0, 100);
}
let value = params[i].value;
let marker = params[i].marker;
result += marker + seriesName + ": " + value[1] + "<br/>";
}
return result;
}
},
legend: {},
xAxis: {},
yAxis: [
{
name: 'No',
type: 'value',
min: 0,
max: yAxisListMax,
interval: yAxisListMax / 5
}
],
series: []
}
let setting = {
series: [
{
name: 'users',
color: '#0CA74A',
}
]
}
yAxisIndex0List.forEach(item => {
setting["series"].splice(0, 0, {name: item, yAxisIndex: '0'})
})
this.errorOption = this.generateOption(errorOption, data, setting);
}).catch(() => {
this.errorOption = {};
})
},
getResponseCodeChart() {
this.$get("/performance/report/content/response_code_chart/" + this.id).then(res => {
let data = res.data.data;
let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis);
let yAxisListMax = this._getChartMax(yAxisList);
let yAxisIndex0List = data.filter(m => m.yAxis2 === -1).map(m => m.groupName);
yAxisIndex0List = this._unique(yAxisIndex0List);
let resCodeOption = {
title: {
text: 'Response code',
left: 'center',
top: 20,
textStyle: {
color: '#99743C'
},
},
tooltip: {
show: true,
trigger: 'axis',
extraCssText: 'z-index: 999;',
formatter: function (params, ticket, callback) {
let result = "";
let name = params[0].name;
result += name + "<br/>";
for (let i = 0; i < params.length; i++) {
let seriesName = params[i].seriesName;
if (seriesName.length > 100) {
seriesName = seriesName.substring(0, 100);
}
let value = params[i].value;
let marker = params[i].marker;
result += marker + seriesName + ": " + value[1] + "<br/>";
}
return result;
}
},
legend: {},
xAxis: {},
yAxis: [
{
name: 'No',
type: 'value',
min: 0,
max: yAxisListMax,
interval: yAxisListMax / 5
}
],
series: []
}
let setting = {
series: [
{
name: 'users',
color: '#0CA74A',
}
]
}
yAxisIndex0List.forEach(item => {
setting["series"].splice(0, 0, {name: item, yAxisIndex: '0'})
})
this.resCodeOption = this.generateOption(resCodeOption, data, setting);
}).catch(() => {
this.resCodeOption = {};
})
},
generateOption(option, data, setting) { generateOption(option, data, setting) {
let chartData = data; let chartData = data;
let seriesArray = []; let seriesArray = [];
@ -344,6 +502,8 @@ export default {
this.avgBandwidth = '0'; this.avgBandwidth = '0';
this.loadOption = {}; this.loadOption = {};
this.resOption = {}; this.resOption = {};
this.errorOption = {};
this.resCodeOption = {};
} }
}, },
deep: true deep: true

View File

@ -76,7 +76,6 @@
<ms-delete-confirm :title="$t('project.delete')" @delete="_handleDelete" ref="deleteConfirm"/> <ms-delete-confirm :title="$t('project.delete')" @delete="_handleDelete" ref="deleteConfirm"/>
<api-environment-config ref="environmentConfig"/> <api-environment-config ref="environmentConfig"/>
<ms-jar-config ref="jarConfig"/>
</ms-container> </ms-container>
</template> </template>

View File

@ -3,7 +3,8 @@
<el-card class="table-card" v-loading="result.loading"> <el-card class="table-card" v-loading="result.loading">
<template v-slot:header> <template v-slot:header>
<ms-table-header :condition.sync="condition" @search="search" @create="create" <ms-table-header :condition.sync="condition" @search="search" @create="create"
:create-tip="$t('test_resource_pool.create_resource_pool')" :title="$t('commons.test_resource_pool')"/> :create-tip="$t('test_resource_pool.create_resource_pool')"
:title="$t('commons.test_resource_pool')"/>
</template> </template>
<el-table border class="adjust-table" :data="items" style="width: 100%"> <el-table border class="adjust-table" :data="items" style="width: 100%">
<el-table-column prop="name" :label="$t('commons.name')"/> <el-table-column prop="name" :label="$t('commons.name')"/>
@ -286,7 +287,9 @@
let resources = []; let resources = [];
if (this.form.resources) { if (this.form.resources) {
this.form.resources.forEach(function (resource) { this.form.resources.forEach(function (resource) {
resources.push(JSON.parse(resource.configuration)); let configuration = JSON.parse(resource.configuration);
configuration.id = resource.id
resources.push(configuration);
}) })
} }
this.infoList = resources; this.infoList = resources;
@ -334,7 +337,8 @@
let resources = []; let resources = [];
let poolId = this.form.id; let poolId = this.form.id;
this.infoList.forEach(function (info) { this.infoList.forEach(function (info) {
let resource = {"configuration": JSON.stringify(info)}; let configuration = JSON.stringify(info);
let resource = {"configuration": configuration, id: info.id};
if (poolId) { if (poolId) {
resource.testResourcePoolId = poolId; resource.testResourcePoolId = poolId;
} }

View File

@ -3,18 +3,17 @@
<ms-main-container> <ms-main-container>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="15"> <el-col :span="15">
<el-row>
<related-test-plan-list ref="relatedTestPlanList"/> <related-test-plan-list ref="relatedTestPlanList"/>
</el-row>
<el-row>
<review-list title="我的评审" ref="caseReviewList"/>
</el-row>
</el-col> </el-col>
<el-col :span="9"> <el-col :span="9">
<test-case-side-list :title="$t('test_track.home.recent_test')" ref="testCaseRecentList"/> <test-case-side-list :title="$t('test_track.home.recent_test')" ref="testCaseRecentList"/>
</el-col> </el-col>
</el-row> </el-row>
<div style="margin-top: 10px"/>
<el-row :gutter="20">
<el-col :span="15">
<review-list title="我的评审" ref="caseReviewList"/>
</el-col>
</el-row>
</ms-main-container> </ms-main-container>
</ms-container> </ms-container>
</template> </template>
@ -52,4 +51,7 @@ export default {
cursor: pointer; cursor: pointer;
} }
.el-row {
padding-bottom: 20px;
}
</style> </style>

View File

@ -25,7 +25,7 @@
</el-button> </el-button>
</el-col> </el-col>
<el-col :span="12" class="head-right"> <el-col :span="11" class="head-right">
<span class="head-right-tip" v-if="index + 1 === testCases.length"> <span class="head-right-tip" v-if="index + 1 === testCases.length">
{{ $t('test_track.plan_view.pre_case') }} : {{ {{ $t('test_track.plan_view.pre_case') }} : {{
testCases[index - 1] ? testCases[index - 1].name : '' testCases[index - 1] ? testCases[index - 1].name : ''
@ -44,11 +44,6 @@
<el-button plain size="mini" icon="el-icon-arrow-down" <el-button plain size="mini" icon="el-icon-arrow-down"
:disabled="index + 1 >= testCases.length" :disabled="index + 1 >= testCases.length"
@click="handleNext()"/> @click="handleNext()"/>
<el-divider direction="vertical"></el-divider>
<el-button type="primary" size="mini" :disabled="isReadOnly" @click="saveCase()">
{{ $t('test_track.save') }}
</el-button>
</el-col> </el-col>
</el-row> </el-row>
@ -101,7 +96,7 @@
<el-row> <el-row>
<el-col :offset="1"> <el-col :offset="1">
<span class="cast_label">{{ $t('test_track.case.prerequisite') }}</span> <span class="cast_label">{{ $t('test_track.case.prerequisite') }}</span>
<span class="cast_item">{{ testCase.prerequisite }}</span> <span class="cast_item"><p>{{ testCase.prerequisite }}</p></span>
</el-col> </el-col>
</el-row> </el-row>
@ -577,6 +572,8 @@ export default {
this.isFailure = this.testCase.steptResults.filter(s => { this.isFailure = this.testCase.steptResults.filter(s => {
return s.executeResult === 'Failure' || s.executeResult === 'Blocking'; return s.executeResult === 'Failure' || s.executeResult === 'Blocking';
}).length > 0; }).length > 0;
} else {
this.isFailure = false;
} }
}, },
saveIssues() { saveIssues() {
@ -684,4 +681,9 @@ export default {
.el-switch >>> .el-switch__label.is-active { .el-switch >>> .el-switch__label.is-active {
color: #409EFF; color: #409EFF;
} }
p {
white-space: pre-line;
line-height: 20px;
}
</style> </style>

View File

@ -1,7 +1,10 @@
<template> <template>
<div v-loading="result.loading"> <div v-loading="result.loading">
<div class="comment-list"> <div class="comment-list">
<review-comment-item v-for="(comment,index) in comments" :key="index" :comment="comment"/> <review-comment-item v-for="(comment,index) in comments"
:key="index"
:comment="comment"
@refresh="refresh"/>
<div v-if="comments.length === 0" style="text-align: center"> <div v-if="comments.length === 0" style="text-align: center">
<i class="el-icon-chat-line-square" style="font-size: 15px;color: #8a8b8d;"> <i class="el-icon-chat-line-square" style="font-size: 15px;color: #8a8b8d;">
<span style="font-size: 15px; color: #8a8b8d;"> <span style="font-size: 15px; color: #8a8b8d;">
@ -56,10 +59,13 @@ export default {
} }
this.result = this.$post('/test/case/comment/save', comment, () => { this.result = this.$post('/test/case/comment/save', comment, () => {
this.$success(this.$t('test_track.comment.send_success')); this.$success(this.$t('test_track.comment.send_success'));
this.$emit('getComments'); this.refresh();
this.textarea = ''; this.textarea = '';
}); });
}, },
refresh() {
this.$emit('getComments');
},
} }
} }
</script> </script>

View File

@ -2,30 +2,88 @@
<div class="main"> <div class="main">
<div class="comment-left"> <div class="comment-left">
<div class="icon-title"> <div class="icon-title">
{{ comment.author.substring(0, 1) }} {{ comment.authorName.substring(0, 1) }}
</div> </div>
</div> </div>
<div class="comment-right"> <div class="comment-right">
<span style="font-size: 14px;color: #909399;font-weight: bold">{{ comment.author }}</span> <span style="font-size: 14px;color: #909399;font-weight: bold">{{ comment.authorName }}</span>
<span style="color: #8a8b8d; margin-left: 8px; font-size: 12px"> <span style="color: #8a8b8d; margin-left: 8px; font-size: 12px">
{{ comment.createTime | timestampFormatDate }} {{ comment.createTime | timestampFormatDate }}
</span> </span>
<span class="comment-delete">
<i class="el-icon-edit" style="font-size: 9px;margin-right: 6px;" @click="openEdit"/>
<i class="el-icon-close" @click="deleteComment"/>
</span>
<br/> <br/>
<div class="comment-desc" style="font-size: 10px;color: #303133"> <div class="comment-desc" style="font-size: 10px;color: #303133">
<pre>{{ comment.description }}</pre> <pre>{{ comment.description }}</pre>
</div> </div>
</div> </div>
<el-dialog
:title="$t('commons.edit')"
:visible.sync="visible"
width="30%"
:destroy-on-close="true"
:append-to-body="true"
:close-on-click-modal="false"
show-close>
<el-input
type="textarea"
:rows="5"
v-model="description">
</el-input>
<span slot="footer" class="dialog-footer">
<ms-dialog-footer
@cancel="visible = false"
@confirm="editComment"/>
</span>
</el-dialog>
</div> </div>
</template> </template>
<script> <script>
import MsDialogFooter from "@/business/components/common/components/MsDialogFooter";
import {getCurrentUser} from "@/common/js/utils";
export default { export default {
name: "ReviewCommentItem", name: "ReviewCommentItem",
components: {MsDialogFooter},
props: { props: {
comment: Object comment: Object
}, },
data() { data() {
return {} return {
visible: false,
description: ""
}
},
methods: {
deleteComment() {
if (getCurrentUser().id !== this.comment.author) {
this.$warning(this.$t('test_track.comment.cannot_delete'));
return;
}
this.$parent.result = this.$get("/test/case/comment/delete/" + this.comment.id, () => {
this.$success(this.$t('commons.delete_success'));
this.$emit("refresh");
});
},
openEdit() {
if (getCurrentUser().id !== this.comment.author) {
this.$warning(this.$t('test_track.comment.cannot_edit'));
return;
}
this.description = this.comment.description;
this.visible = true;
},
editComment() {
this.$post("/test/case/comment/edit", {id: this.comment.id, description: this.description}, () => {
this.visible = false;
this.$success(this.$t('commons.modify_success'));
this.$emit("refresh");
});
}
} }
} }
</script> </script>
@ -72,4 +130,10 @@ export default {
pre { pre {
margin: 0 0; margin: 0 0;
} }
.comment-delete {
float: right;
margin-right: 5px;
cursor: pointer;
}
</style> </style>

View File

@ -30,7 +30,7 @@
show-overflow-tooltip> show-overflow-tooltip>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
prop="creator" prop="creatorName"
:label="$t('test_track.review.review_creator')" :label="$t('test_track.review.review_creator')"
show-overflow-tooltip> show-overflow-tooltip>
</el-table-column> </el-table-column>
@ -66,10 +66,6 @@
<template v-slot:default="scope"> <template v-slot:default="scope">
<ms-table-operator :is-tester-permission="true" @editClick="handleEdit(scope.row)" <ms-table-operator :is-tester-permission="true" @editClick="handleEdit(scope.row)"
@deleteClick="handleDelete(scope.row)"> @deleteClick="handleDelete(scope.row)">
<!-- <template v-slot:middle>-->
<!-- <ms-table-operator-button :isTesterPermission="true" type="success" tip="重新发起" icon="el-icon-document"-->
<!-- @exec="reCreate(scope.row)"/>-->
<!-- </template>-->
</ms-table-operator> </ms-table-operator>
</template> </template>
</el-table-column> </el-table-column>

View File

@ -365,7 +365,7 @@ export default {
}, },
getTestReviewById() { getTestReviewById() {
if (this.reviewId) { if (this.reviewId) {
this.$post('/test/case/review/get/' + this.reviewId, {}, response => { this.$get('/test/case/review/get/' + this.reviewId, response => {
this.testReview = response.data; this.testReview = response.data;
this.refreshTestReviewRecent(); this.refreshTestReviewRecent();
}); });

View File

@ -557,7 +557,18 @@ export default {
expect: "Expect Value", expect: "Expect Value",
expression: "Expression", expression: "Expression",
response_in_time: "Response in time", response_in_time: "Response in time",
ignore_status: "Ignore Status" ignore_status: "Ignore Status",
add: "Add",
script_name: "Script name",
script: "Script",
variable_name: "Variable name",
set_failure_status: "Set failure status",
set_failure_msg: "Set failure message",
json_path_add: "Add JONPATH Assertions",
json_path_err: "The response result is not in JSON format",
json_path_suggest: "JSONPath Assertion Suggest",
json_path_clear: "Clear JSONPath Assertion",
debug_first: "First, debug to get the response",
}, },
extract: { extract: {
label: "Extract from response", label: "Extract from response",
@ -848,6 +859,8 @@ export default {
relevance_case: "Relevance Case", relevance_case: "Relevance Case",
last_page: "It's the end", last_page: "It's the end",
execute_result: "Result", execute_result: "Result",
cannot_edit: "Cannot edit this comment",
cannot_delete: "Cannot delete this comment",
}, },
module: { module: {
search: "Search module", search: "Search module",

View File

@ -545,6 +545,7 @@ export default {
text: "文本", text: "文本",
regex: "正则", regex: "正则",
response_time: "响应时间", response_time: "响应时间",
jsr223: "脚本",
select_type: "请选择类型", select_type: "请选择类型",
select_subject: "请选择对象", select_subject: "请选择对象",
select_condition: "请选择条件", select_condition: "请选择条件",
@ -557,7 +558,18 @@ export default {
expect: "期望值", expect: "期望值",
expression: "Perl型正则表达式", expression: "Perl型正则表达式",
response_in_time: "响应时间在...毫秒以内", response_in_time: "响应时间在...毫秒以内",
ignore_status: "忽略状态" json_path_add: "添加 JONPATH 断言",
json_path_err: "响应结果不是 JSON 格式",
json_path_suggest: "推荐JSONPath断言",
json_path_clear: "清空JSONPath断言",
debug_first: "请先执行调试获取响应结果",
ignore_status: "忽略状态",
add: "添加",
script_name: "脚本名称",
script: "脚本",
variable_name: "变量名称",
set_failure_status: "设置失败状态",
set_failure_msg: "设置失败消息",
}, },
extract: { extract: {
label: "提取", label: "提取",
@ -841,6 +853,8 @@ export default {
send: "发送", send: "发送",
description_is_null: "评论内容不能为空!", description_is_null: "评论内容不能为空!",
send_success: "评论成功!", send_success: "评论成功!",
cannot_edit: "无法编辑此评论!",
cannot_delete: "无法删除此评论!",
}, },
review_view: { review_view: {
review: "评审", review: "评审",

View File

@ -217,20 +217,25 @@ export default {
delete_warning: '刪除該組織將同步刪除該組織下所有相關工作空間和相關工作空間下的所有項目,以及項目中的所有用例、接口測試、性能測試等,確定要刪除嗎?', delete_warning: '刪除該組織將同步刪除該組織下所有相關工作空間和相關工作空間下的所有項目,以及項目中的所有用例、接口測試、性能測試等,確定要刪除嗎?',
service_integration: '服務集成', service_integration: '服務集成',
defect_manage: '缺陷管理平臺', defect_manage: '缺陷管理平臺',
message_settings:'消息設', message_settings:'消息設',
message:{ message:{
jenkins_task_notification: 'Jenkins任務通知', jenkins_task_notification: 'Jenkins接口調用任務通知',
test_plan_task_notification: '測試計任務通知', test_plan_task_notification: '測試計任務通知',
test_review_task_notice: '測試評審任務通知', test_review_task_notice: '測試評審任務通知',
defect_task_notification: '缺陷任務通知',
create_new_notification: '創建新通知', create_new_notification: '創建新通知',
select_events: '選擇事件', select_events: '選擇事件',
select_receiving_method: '選擇接收管道', defect_task_notification: '缺陷任務通知',
select_receiving_method: '選擇接收方式',
mail: '郵件', mail: '郵件',
nail_robot: '釘釘機器人', nail_robot: '釘釘機器人',
enterprise_wechat_robot: '企業微信機器人', enterprise_wechat_robot: '企業微信機器人',
message_webhook: '接收管道為釘釘和企業機器人時webhook為必填項\n' + notes: '註意: 1.事件,接收方式,接收人為必填項;\n' +
'\n' ' 2.接收方式除郵件外webhook為必填\n' +
' 3.機器人選擇為群機器人,安全驗證選擇“自定義關鍵詞” "任務通知"',
message: '事件,接收人,接收方式為必填項',
message_webhook: '接收方式為釘釘和企業機器人時webhook為必填項'
}, },
integration: { integration: {
select_defect_platform: '請選擇要集成的缺陷管理平臺:', select_defect_platform: '請選擇要集成的缺陷管理平臺:',
@ -254,17 +259,7 @@ export default {
successful_operation: '操作成功', successful_operation: '操作成功',
not_integrated: '未集成該平臺', not_integrated: '未集成該平臺',
choose_platform: '請選擇集成的平臺', choose_platform: '請選擇集成的平臺',
verified: '驗證通過', verified: '驗證通過'
mail: '郵件',
nail_robot: '釘釘機器人',
enterprise_wechat_robot: '企業微信機器人',
notes: '注意1.事件,接收管道,接收人為必填項;\n' +
'\n' +
'2.接收管道除郵件外webhook為必填\n' +
'\n' +
'3.機器人選擇為群機器人,安全驗證選擇“自定義關鍵字”:“任務通知”',
message: '事件,接收人,接收管道為必填項\n' +
'\n'
} }
}, },
project: { project: {
@ -562,7 +557,18 @@ export default {
expect: "期望值", expect: "期望值",
expression: "Perl型正則表達式", expression: "Perl型正則表達式",
response_in_time: "響應時間在...毫秒以內", response_in_time: "響應時間在...毫秒以內",
ignore_status: "忽略狀態" json_path_add: "添加 JONPATH 斷言",
json_path_err: "響應結果不是 JSON 格式",
json_path_suggest: "推薦JSONPath斷言",
json_path_clear: "清空JSONPath斷言",
debug_first: "請先執行調試獲取響應結果",
ignore_status: "忽略狀態",
add: "添加",
script_name: "腳本名稱",
script: "腳本",
variable_name: "變量名稱",
set_failure_status: "設置失敗狀態",
set_failure_msg: "設置失敗消息",
}, },
extract: { extract: {
label: "提取", label: "提取",
@ -846,6 +852,8 @@ export default {
send: "發送", send: "發送",
description_is_null: "評論內容不能為空!", description_is_null: "評論內容不能為空!",
send_success: "評論成功!", send_success: "評論成功!",
cannot_edit: "無法編輯此評論!",
cannot_delete: "無法刪除此評論!",
}, },
review_view: { review_view: {
review: "評審", review: "評審",