Merge remote-tracking branch 'origin/master'

This commit is contained in:
wenyann 2020-09-23 11:47:09 +08:00
commit 8e19cc9ca3
23 changed files with 260 additions and 163 deletions

View File

@ -12,7 +12,6 @@ import io.metersphere.commons.utils.PageUtils;
import io.metersphere.commons.utils.Pager; import io.metersphere.commons.utils.Pager;
import io.metersphere.commons.utils.SessionUtils; import io.metersphere.commons.utils.SessionUtils;
import io.metersphere.controller.request.QueryScheduleRequest; import io.metersphere.controller.request.QueryScheduleRequest;
import io.metersphere.dto.LicenseDTO;
import io.metersphere.dto.ScheduleDao; import io.metersphere.dto.ScheduleDao;
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;
@ -127,10 +126,4 @@ 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);
} }
@GetMapping("/license/valid")
public LicenseDTO valid() {
return apiTestService.validateLicense();
}
} }

View File

@ -20,7 +20,6 @@ import io.metersphere.commons.constants.ScheduleType;
import io.metersphere.commons.exception.MSException; import io.metersphere.commons.exception.MSException;
import io.metersphere.commons.utils.*; import io.metersphere.commons.utils.*;
import io.metersphere.controller.request.QueryScheduleRequest; import io.metersphere.controller.request.QueryScheduleRequest;
import io.metersphere.dto.LicenseDTO;
import io.metersphere.dto.ScheduleDao; import io.metersphere.dto.ScheduleDao;
import io.metersphere.i18n.Translator; import io.metersphere.i18n.Translator;
import io.metersphere.job.sechedule.ApiTestJob; import io.metersphere.job.sechedule.ApiTestJob;
@ -437,13 +436,4 @@ public class APITestService {
quotaService.checkAPITestQuota(); quotaService.checkAPITestQuota();
} }
} }
public LicenseDTO validateLicense() {
LicenseService licenseService = CommonBeanFactory.getBean(LicenseService.class);
if (licenseService != null) {
return licenseService.valid();
}
return null;
}
} }

View File

@ -66,5 +66,4 @@ public class LoginController {
public String getDefaultLanguage() { public String getDefaultLanguage() {
return userService.getDefaultLanguage(); return userService.getDefaultLanguage();
} }
} }

View File

@ -1,14 +0,0 @@
package io.metersphere.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class LicenseDTO implements Serializable {
private String status;
private LicenseInfoDTO license;
}

View File

@ -1,21 +0,0 @@
package io.metersphere.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class LicenseInfoDTO implements Serializable {
// 客户名称
private String corporation;
// 授权截止时间
private String expired;
//产品名称
private String product;
//产品版本
private String edition;
//icense版本
private String licenseVersion;
//授权数量
private int licenseCount;
}

View File

@ -1,10 +0,0 @@
package io.metersphere.service;
import io.metersphere.dto.LicenseDTO;
public interface LicenseService {
public LicenseDTO valid();
public LicenseDTO addValidLicense(String reqLicenseCode);
}

View File

@ -34,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;

@ -1 +1 @@
Subproject commit 321c869938357e8c2253e5bd86c963828664ae23 Subproject commit cf6b06526324326a563d933e07118fac014a63b4

View File

@ -1,15 +1,11 @@
<template> <template>
<el-col v-if="auth"> <el-col v-if="auth">
<el-row id="header-top1" type="flex" justify="space-between" align="middle"> <el-row v-if="licenseHeader != null">
<el-col> <el-col>
<div class="license-head" v-if="validData.status == 'expired'">License has expired since <component :is="licenseHeader"></component>
{{(validData!= undefined && validData.license!= undefined) ? validData.license.expired:''}},please
update license.
</div>
</el-col> </el-col>
</el-row> </el-row>
<el-row id="header-top" type="flex" justify="space-between" align="middle"> <el-row id="header-top" type="flex" justify="space-between" align="middle">
<el-col :span="12"> <el-col :span="12">
<a class="logo"/> <a class="logo"/>
<ms-top-menus/> <ms-top-menus/>
@ -34,15 +30,18 @@
import MsHeaderOrgWs from "./components/common/head/HeaderOrgWs"; import MsHeaderOrgWs from "./components/common/head/HeaderOrgWs";
import MsLanguageSwitch from "./components/common/head/LanguageSwitch"; import MsLanguageSwitch from "./components/common/head/LanguageSwitch";
import {saveLocalStorage} from "../common/js/utils"; import {saveLocalStorage} from "../common/js/utils";
import {saveLicense} from "../common/js/utils";
import Setting from "@/business/components/settings/router"; const requireComponent = require.context('@/business/components/xpack/', true, /\.vue$/);
const header = requireComponent.keys().length > 0 ? requireComponent("./license/LicenseMessage.vue") : {};
export default { export default {
name: 'app', name: 'app',
props: {},
data() { data() {
return { return {
validData: {}, licenseHeader: null,
auth: false auth: false,
header: {},
} }
}, },
beforeCreate() { beforeCreate() {
@ -51,6 +50,10 @@
this.$setLang(response.data.data.language); this.$setLang(response.data.data.language);
saveLocalStorage(response.data); saveLocalStorage(response.data);
this.auth = true; this.auth = true;
//
if (header.default !== undefined) {
this.licenseHeader = "LicenseMessage";
}
} else { } else {
window.location.href = "/login" window.location.href = "/login"
} }
@ -58,21 +61,18 @@
window.location.href = "/login" window.location.href = "/login"
}); });
}, },
beforeMount() { components: {
// license MsLanguageSwitch,
this.result = this.$get("/api/license/valid", response => { MsUser,
let data = response.data; MsView,
if (data != undefined && data != null) { MsTopMenus,
this.validData = response.data; MsHeaderOrgWs,
saveLicense(response.data); "LicenseMessage": header.default
} }
});
},
components: {MsLanguageSwitch, MsUser, MsView, MsTopMenus, MsHeaderOrgWs},
methods: {}
} }
</script> </script>
<style scoped> <style scoped>
#header-top { #header-top {
width: 100%; width: 100%;
@ -128,4 +128,3 @@
color: white; color: white;
} }
</style> </style>

View File

@ -0,0 +1,136 @@
<template>
<el-card>
<div class="report-title title">接口测试报告</div>
<ms-metric-chart :content="content" :totalTime="totalTime"/>
<div class="scenario-result" v-for="(scenario, index) in content.scenarios" :key="index" :scenario="scenario">
<div>
<el-card >
<template v-slot:header>
{{$t('api_report.scenario_name')}}{{scenario.name}}
</template>
<div class="ms-border" v-for="(request, index) in scenario.requestResults" :key="index" :request="request">
<div class="request-left">
<api-report-reqest-header-item :title="request.name">
<span class="url"> {{request.url}}</span>
</api-report-reqest-header-item>
</div>
<div class="request-right">
<api-report-reqest-header-item :title="$t('api_test.request.method')">
<span class="method"> {{request.method}}</span>
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.response_time')">
{{request.responseResult.responseTime}}
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.latency')">
{{request.responseResult.latency}} ms
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.request_size')">
{{request.requestSize}} bytes
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.response_size')">
{{request.responseResult.latency}} ms
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.error')">
{{request.responseResult.responseSize}} bytes
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.assertions')">
{{request.passAssertions + " / " + request.totalAssertions}}
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.response_code')">
{{request.responseResult.responseCode}}
</api-report-reqest-header-item>
<api-report-reqest-header-item :title="$t('api_report.result')">
<el-tag size="mini" type="success" v-if="request.success">
{{$t('api_report.success')}}
</el-tag>
<el-tag size="mini" type="danger" v-else>
{{$t('api_report.fail')}}
</el-tag>
</api-report-reqest-header-item>
</div>
</div>
</el-card>
</div>
</div>
</el-card>
</template>
<script>
import MsScenarioResult from "./components/ScenarioResult";
import MsRequestResultTail from "./components/RequestResultTail";
import ApiReportReqestHeaderItem from "./ApiReportReqestHeaderItem";
import MsMetricChart from "./components/MetricChart";
export default {
name: "MsApiReportExport",
components: {MsMetricChart, ApiReportReqestHeaderItem, MsRequestResultTail, MsScenarioResult},
props: {
content: Object,
totalTime: Number
},
data() {
return {
}
},
}
</script>
<style scoped>
.scenario-result {
margin-top: 20px;
margin-bottom: 20px;
}
.method {
color: #1E90FF;
font-size: 14px;
font-weight: 500;
}
.request-right {
float: right;
}
.request-left {
float: left;
margin-left: 20px;
}
.ms-border {
height: 60px;
}
.report-title {
font-size: 30px;
font-weight: bold;
height: 50px;
text-align: center;
margin-bottom: 20px;
}
.url {
color: #409EFF;
font-size: 14px;
font-weight: bold;
font-style: italic;
}
.el-card {
padding: 10px;
padding: 30px;
border-style: none;
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<div class="item">
<div class="item-title">
{{title}}
</div>
<div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: "ApiReportReqestHeaderItem",
props: {title: String}
}
</script>
<style scoped>
.item {
width: 120px;
height: 50px;
display: inline-block;
}
.item-title {
margin-bottom: 20px;
}
</style>

View File

@ -9,10 +9,10 @@
<span>{{ report.projectName }} / </span> <span>{{ report.projectName }} / </span>
<router-link :to="path">{{ report.testName }}</router-link> <router-link :to="path">{{ report.testName }}</router-link>
<span class="time">{{ report.createTime | timestampFormatDate }}</span> <span class="time">{{ report.createTime | timestampFormatDate }}</span>
<!--<el-button plain type="primary" size="mini" @click="handleExport(report.name)" <el-button class="export-button" plain type="primary" size="mini" @click="handleExport(report.name)"
style="margin-left: 1200px"> style="margin-left: 1200px">
{{$t('test_track.plan_view.export_report')}} {{$t('test_track.plan_view.export_report')}}
</el-button>--> </el-button>
</el-col> </el-col>
</el-row> </el-row>
</header> </header>
@ -36,6 +36,7 @@
<ms-request-result-tail v-if="isRequestResult" :request="request" :scenario-name="scenarioName"/> <ms-request-result-tail v-if="isRequestResult" :request="request" :scenario-name="scenarioName"/>
</el-col> </el-col>
</el-row> </el-row>
<ms-api-report-export v-if="reportExportVisible" id="apiTestReport" :content="content" :total-time="totalTime"/>
</main> </main>
</section> </section>
</el-card> </el-card>
@ -52,10 +53,14 @@ import MsMetricChart from "./components/MetricChart";
import MsScenarioResults from "./components/ScenarioResults"; import MsScenarioResults from "./components/ScenarioResults";
import MsContainer from "@/business/components/common/components/MsContainer"; import MsContainer from "@/business/components/common/components/MsContainer";
import MsMainContainer from "@/business/components/common/components/MsMainContainer"; import MsMainContainer from "@/business/components/common/components/MsMainContainer";
import MsApiReportExport from "./ApiReportExport";
import {exportPdf} from "../../../../common/js/utils";
import html2canvas from "html2canvas";
export default { export default {
name: "MsApiReportView", name: "MsApiReportView",
components: { components: {
MsApiReportExport,
MsMainContainer, MsMainContainer,
MsContainer, MsScenarioResults, MsRequestResultTail, MsMetricChart, MsScenarioResult, MsRequestResult MsContainer, MsScenarioResults, MsRequestResultTail, MsMetricChart, MsScenarioResult, MsRequestResult
}, },
@ -70,6 +75,7 @@ export default {
isRequestResult: false, isRequestResult: false,
request: {}, request: {},
scenarioName: null, scenarioName: null,
reportExportVisible: false
} }
}, },
activated() { activated() {
@ -139,6 +145,26 @@ export default {
this.request = requestResult.request; this.request = requestResult.request;
this.scenarioName = requestResult.scenarioName; this.scenarioName = requestResult.scenarioName;
}); });
},
handleExport(name) {
this.loading = true;
this.reportExportVisible = true;
let reset = this.exportReportReset;
this.$nextTick(function () {
setTimeout(() => {
html2canvas(document.getElementById('apiTestReport'), {
scale: 2
}).then(function(canvas) {
exportPdf(name, [canvas]);
reset();
});
}, 1000);
});
},
exportReportReset() {
this.reportExportVisible = false;
this.loading = false;
} }
}, },
@ -170,30 +196,36 @@ export default {
</style> </style>
<style scoped> <style scoped>
.report-container {
.report-container {
height: calc(100vh - 155px); height: calc(100vh - 155px);
min-height: 600px; min-height: 600px;
overflow-y: auto; overflow-y: auto;
} }
.report-header { .report-header {
font-size: 15px; font-size: 15px;
} }
.report-header a { .report-header a {
text-decoration: none; text-decoration: none;
} }
.report-header .time { .report-header .time {
color: #909399; color: #909399;
margin-left: 10px; margin-left: 10px;
} }
.report-container .fail { .report-container .fail {
color: #F56C6C; color: #F56C6C;
} }
.report-container .is-active .fail { .report-container .is-active .fail {
color: inherit; color: inherit;
} }
.export-button {
float: right;
}
</style> </style>

View File

@ -88,18 +88,24 @@
{max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'} {max: 300, message: this.$t('commons.input_limit', [0, 300]), trigger: 'blur'}
], ],
driver: [ driver: [
{required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, {required: true, message: this.$t('commons.cannot_be_null'), trigger: 'blur'},
], ],
password: [ password: [
{max: 200, message: this.$t('commons.input_limit', [0, 200]), trigger: 'blur'} {max: 200, message: this.$t('commons.input_limit', [0, 200]), trigger: 'blur'}
], ],
dbUrl: [ dbUrl: [
{required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, {required: true, message: this.$t('commons.cannot_be_null'), trigger: 'blur'},
{max: 500, message: this.$t('commons.input_limit', [0, 500]), trigger: 'blur'} {max: 500, message: this.$t('commons.input_limit', [0, 500]), trigger: 'blur'}
], ],
username: [ username: [
{required: true, message: this.$t('commons.input_name'), trigger: 'blur'}, {required: true, message: this.$t('commons.cannot_be_null'), trigger: 'blur'},
{max: 200, message: this.$t('commons.input_limit', [0, 200]), trigger: 'blur'} {max: 200, message: this.$t('commons.input_limit', [0, 200]), trigger: 'blur'}
],
poolMax: [
{required: true, message: this.$t('commons.cannot_be_null'), trigger: 'blur'},
],
timeout: [
{required: true, message: this.$t('commons.cannot_be_null'), trigger: 'blur'},
] ]
} }
} }

View File

@ -66,28 +66,21 @@
</div> </div>
<div class="report-export" v-show="reportExportVisible"> <div class="report-export" v-show="reportExportVisible">
<!--<div class="report-export">-->
<el-card id="testOverview"> <el-card id="testOverview">
<template v-slot:header > <template v-slot:header >
<slot name="header"> <span class="title">{{$t('report.test_overview')}}</span>
<span class="title">{{$t('report.test_overview')}}</span>
</slot>
</template> </template>
<ms-report-test-overview :report="report" ref="testOverview"/> <ms-report-test-overview :report="report" ref="testOverview"/>
</el-card> </el-card>
<el-card id="requestStatistics" title="'requestStatistics'"> <el-card id="requestStatistics" title="'requestStatistics'">
<template v-slot:header > <template v-slot:header >
<slot name="header"> <span class="title">{{$t('report.test_request_statistics')}}</span>
<span class="title">{{$t('report.test_request_statistics')}}</span>
</slot>
</template> </template>
<ms-report-request-statistics :report="report" ref="requestStatistics"/> <ms-report-request-statistics :report="report" ref="requestStatistics"/>
</el-card> </el-card>
<el-card id="errorLog" title="'errorLog'"> <el-card id="errorLog" title="'errorLog'">
<template v-slot:header > <template v-slot:header >
<slot name="header"> <span class="title">{{$t('report.test_error_log')}}</span>
<span class="title">{{$t('report.test_error_log')}}</span>
</slot>
</template> </template>
<ms-report-error-log :report="report" ref="errorLog"/> <ms-report-error-log :report="report" ref="errorLog"/>
</el-card> </el-card>

View File

@ -47,7 +47,6 @@
<script> <script>
import {checkoutCurrentOrganization, checkoutCurrentWorkspace} from "@/common/js/utils"; import {checkoutCurrentOrganization, checkoutCurrentWorkspace} from "@/common/js/utils";
import Setting from "@/business/components/settings/router"; import Setting from "@/business/components/settings/router";
import {LicenseKey} from '@/common/js/constants';
export default { export default {
name: "MsSettingMenu", name: "MsSettingMenu",
@ -74,51 +73,14 @@
organizations: getMenus('organization'), organizations: getMenus('organization'),
workspaces: getMenus('workspace'), workspaces: getMenus('workspace'),
persons: getMenus('person'), persons: getMenus('person'),
isValid: valid,
isCurrentOrganizationAdmin: false, isCurrentOrganizationAdmin: false,
isCurrentWorkspaceUser: false, isCurrentWorkspaceUser: false,
} }
}, },
mounted() { mounted() {
if (this.isValid === true) {
this.valid();
}
this.isCurrentOrganizationAdmin = checkoutCurrentOrganization(); this.isCurrentOrganizationAdmin = checkoutCurrentOrganization();
this.isCurrentWorkspaceUser = checkoutCurrentWorkspace(); this.isCurrentWorkspaceUser = checkoutCurrentWorkspace();
}, },
methods: {
valid() {
let data = localStorage.getItem(LicenseKey);
if (data != undefined && data != null) {
data = JSON.parse(data);
}
if (data === undefined || data === null || data.status != "valid") {
this.systems.forEach(item => {
if (item.valid != undefined && item.valid === true) {
this.systems.splice(this.systems.indexOf(item), 1);
}
})
this.organizations.forEach(item => {
if (item.valid != undefined && item.valid === true) {
this.organizations.splice(this.organizations.indexOf(item), 1);
}
})
this.workspaces.forEach(item => {
if (item.valid != undefined && item.valid === true) {
this.workspaces.splice(this.workspaces.indexOf(item), 1);
}
})
this.persons.forEach(item => {
if (item.valid != undefined && item.valid === true) {
this.persons.splice(this.persons.indexOf(item), 1);
}
})
}
}
}
} }
</script> </script>

View File

@ -210,7 +210,7 @@ export default {
}); });
}, },
editTestReview(param) { editTestReview(param) {
this.$post('/test/case/review/' + this.operationType, param, () => { this.result = this.$post('/test/case/review/' + this.operationType, param, () => {
this.$success(this.$t('commons.save_success')); this.$success(this.$t('commons.save_success'));
this.dialogFormVisible = false; this.dialogFormVisible = false;
this.$emit("refresh"); this.$emit("refresh");

View File

@ -20,7 +20,7 @@
<el-row type="flex" class="head-bar"> <el-row type="flex" class="head-bar">
<el-col :span="12"> <el-col :span="8">
<el-button plain size="mini" <el-button plain size="mini"
icon="el-icon-back" icon="el-icon-back"
@click="cancel">{{ $t('test_track.return') }} @click="cancel">{{ $t('test_track.return') }}
@ -166,7 +166,7 @@
type="textarea" type="textarea"
:autosize="{ minRows: 2, maxRows: 4}" :autosize="{ minRows: 2, maxRows: 4}"
:rows="2" :rows="2"
:disabled="isReadOnly" :disabled="true"
v-model="scope.row.actualResult" v-model="scope.row.actualResult"
:placeholder="$t('commons.input_content')" :placeholder="$t('commons.input_content')"
clearable/> clearable/>
@ -175,7 +175,7 @@
<el-table-column :label="$t('test_track.plan_view.step_result')" min-width="12%"> <el-table-column :label="$t('test_track.plan_view.step_result')" min-width="12%">
<template v-slot:default="scope"> <template v-slot:default="scope">
<el-select <el-select
:disabled="isReadOnly" :disabled="true"
v-model="scope.row.executeResult" v-model="scope.row.executeResult"
@change="stepResultChange()" @change="stepResultChange()"
size="mini"> size="mini">

@ -1 +1 @@
Subproject commit f2d5a342c82e629f510550d5778d752bb73bf5e7 Subproject commit 06d935cd1d22ab36f09763745c2aff8ad3fb08c1

View File

@ -136,7 +136,7 @@ export default {
.then(response => { .then(response => {
let fileName = window.decodeURI(response.headers['content-disposition'].split('=')[1]); let fileName = window.decodeURI(response.headers['content-disposition'].split('=')[1]);
let link = document.createElement("a"); let link = document.createElement("a");
link.href = window.URL.createObjectURL(new Blob([response.data], {type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8"})); link.href = window.URL.createObjectURL(new Blob([response.data]));
link.download = fileName; link.download = fileName;
link.click(); link.click();
}); });

View File

@ -94,7 +94,7 @@ export function saveLocalStorage(response) {
export function saveLicense(data) { export function saveLicense(data) {
// 保存License // 保存License
localStorage.setItem(LicenseKey, JSON.stringify(data)); localStorage.setItem(LicenseKey, data);
} }
@ -255,7 +255,7 @@ export function exportPdf(name, canvasList) {
} }
} }
pdf.save(name); pdf.save(name + '.pdf');
} }

View File

@ -279,7 +279,7 @@ export default {
email_format_is_incorrect: 'Email format is incorrect', email_format_is_incorrect: 'Email format is incorrect',
delete_confirm: 'Are you sure you want to delete this User?', delete_confirm: 'Are you sure you want to delete this User?',
apikey_delete_confirm: 'Are you sure you want to delete this API Key?', apikey_delete_confirm: 'Are you sure you want to delete this API Key?',
input_id_placeholder: 'Please enter ID (only supports numbers and English letters)', input_id_placeholder: 'Please enter ID (Chinese characters are not supported)',
source: 'Source' source: 'Source'
}, },
role: { role: {

View File

@ -279,7 +279,7 @@ export default {
email_format_is_incorrect: '邮箱格式不正确', email_format_is_incorrect: '邮箱格式不正确',
delete_confirm: '这个用户确定要删除吗?', delete_confirm: '这个用户确定要删除吗?',
apikey_delete_confirm: '这个 API Key 确定要删除吗?', apikey_delete_confirm: '这个 API Key 确定要删除吗?',
input_id_placeholder: '请输入ID (只支持数字、英文字母)', input_id_placeholder: '请输入ID (不支持中文)',
source: '用户来源' source: '用户来源'
}, },
role: { role: {

View File

@ -279,7 +279,7 @@ export default {
email_format_is_incorrect: '郵箱格式不正確', email_format_is_incorrect: '郵箱格式不正確',
delete_confirm: '這個用戶確定要刪除嗎?', delete_confirm: '這個用戶確定要刪除嗎?',
apikey_delete_confirm: '這個 API Key 確定要刪除嗎?', apikey_delete_confirm: '這個 API Key 確定要刪除嗎?',
input_id_placeholder: '請輸入ID (只支持數字、英文字母)', input_id_placeholder: '請輸入ID (不支持中文字符)',
source: '用戶來源' source: '用戶來源'
}, },
role: { role: {