feat: 高级参数设置
This commit is contained in:
parent
e8c682e402
commit
00ef9b7db9
|
@ -25,7 +25,11 @@
|
||||||
"vue-router": "^3.1.3",
|
"vue-router": "^3.1.3",
|
||||||
"vuedraggable": "^2.23.2",
|
"vuedraggable": "^2.23.2",
|
||||||
"vuex": "^3.1.2",
|
"vuex": "^3.1.2",
|
||||||
"vue-calendar-heatmap": "^0.8.4"
|
"vue-calendar-heatmap": "^0.8.4",
|
||||||
|
"mockjs": "^1.1.0",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"sha.js": "^2.4.11",
|
||||||
|
"js-base64": "^3.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/cli-plugin-babel": "^4.1.0",
|
"@vue/cli-plugin-babel": "^4.1.0",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<span class="kv-description" v-if="description">
|
<span class="kv-description" v-if="description">
|
||||||
{{description}}
|
{{ description }}
|
||||||
</span>
|
</span>
|
||||||
<div class="kv-row" v-for="(item, index) in items" :key="index">
|
<div class="kv-row" v-for="(item, index) in items" :key="index">
|
||||||
<el-row type="flex" :gutter="20" justify="space-between" align="middle">
|
<el-row type="flex" :gutter="20" justify="space-between" align="middle">
|
||||||
|
@ -15,8 +15,18 @@
|
||||||
|
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col>
|
<el-col>
|
||||||
<el-input :disabled="isReadOnly" v-model="item.value" size="small" @change="change"
|
<el-autocomplete
|
||||||
:placeholder="valueText" show-word-limit/>
|
:disabled="isReadOnly"
|
||||||
|
size="small"
|
||||||
|
class="input-with-autocomplete"
|
||||||
|
v-model="item.value"
|
||||||
|
:fetch-suggestions="funcSearch"
|
||||||
|
:placeholder="valueText"
|
||||||
|
value-key="name"
|
||||||
|
highlight-first-item
|
||||||
|
@select="change">
|
||||||
|
<i slot="suffix" class="el-input__icon el-icon-edit" style="cursor: pointer;" @click="advanced(item)"></i>
|
||||||
|
</el-autocomplete>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col class="kv-delete">
|
<el-col class="kv-delete">
|
||||||
<el-button size="mini" class="el-icon-delete-solid" circle @click="remove(index)"
|
<el-button size="mini" class="el-icon-delete-solid" circle @click="remove(index)"
|
||||||
|
@ -24,13 +34,58 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog :title="$t('api_test.request.parameters_advance')"
|
||||||
|
:visible.sync="itemValueVisible"
|
||||||
|
class="advanced-item-value"
|
||||||
|
width="50%">
|
||||||
|
<el-form>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input :autosize="{ minRows: 2, maxRows: 4}" type="textarea" :placeholder="valueText"
|
||||||
|
v-model="itemValue"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div>
|
||||||
|
<el-row type="flex" align="middle">
|
||||||
|
<el-col :span="3">
|
||||||
|
<el-button class="save-button" type="success" plain @click="showPreview(itemValue)">
|
||||||
|
{{ $t('api_test.request.parameters_preview') }}
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col>
|
||||||
|
<div> {{ itemValuePreview }}</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-tip">
|
||||||
|
<div>
|
||||||
|
<p>{{ $t('api_test.request.parameters_filter') }}:
|
||||||
|
<el-tag size="mini" v-for="func in funcs" :key="func" @click="appendFunc(func)"
|
||||||
|
style="margin-left: 2px;cursor: pointer;">
|
||||||
|
<span>{{ func }}</span>
|
||||||
|
</el-tag>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{{ $t('api_test.request.parameters_filter_desc') }}:
|
||||||
|
<el-link href="http://mockjs.com/examples.html" target="_blank">http://mockjs.com/examples.html</el-link>
|
||||||
|
</span>
|
||||||
|
<p>{{ $t('api_test.request.parameters_filter_example') }}:@string(10) | md5 | substr: 1, 3</p>
|
||||||
|
<p>{{ $t('api_test.request.parameters_filter_example') }}:@integer(1, 5) | concat:_metersphere</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {KeyValue} from "../model/ScenarioModel";
|
import {KeyValue} from "../model/ScenarioModel";
|
||||||
|
import {MOCKJS_FUNC} from "@/common/js/constants";
|
||||||
|
import Mock from "mockjs";
|
||||||
|
import {funcFilters} from "@/common/js/func-filter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "MsApiKeyValue",
|
name: "MsApiKeyValue",
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
|
@ -45,6 +100,16 @@
|
||||||
suggestions: Array
|
suggestions: Array
|
||||||
},
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
itemValueVisible: false,
|
||||||
|
itemValue: null,
|
||||||
|
funcs: ["md5", "sha1", "sha224", "sha256", "sha384", "sha512", "base64",
|
||||||
|
"unbase64", "substr", "concat", "lconcat", "lower", "upper", "length", "number"],
|
||||||
|
itemValuePreview: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
keyText() {
|
keyText() {
|
||||||
return this.keyPlaceholder || this.$t("api_test.key");
|
return this.keyPlaceholder || this.$t("api_test.key");
|
||||||
|
@ -91,29 +156,89 @@
|
||||||
return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
|
return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
funcSearch(queryString, cb) {
|
||||||
|
let funcs = MOCKJS_FUNC;
|
||||||
|
let results = queryString ? funcs.filter(this.funcFilter(queryString)) : funcs;
|
||||||
|
// 调用 callback 返回建议列表的数据
|
||||||
|
cb(results);
|
||||||
|
},
|
||||||
|
funcFilter(queryString) {
|
||||||
|
return (func) => {
|
||||||
|
return (func.name.toLowerCase().indexOf(queryString.toLowerCase()) > -1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
showPreview(itemValue) {
|
||||||
|
if (!itemValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let funcs = itemValue.split("|");
|
||||||
|
let value = Mock.mock(funcs[0].trim());
|
||||||
|
if (funcs.length === 1) {
|
||||||
|
this.itemValuePreview = value;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < funcs.length; i++) {
|
||||||
|
let func = funcs[i].trim();
|
||||||
|
let args = func.split(":");
|
||||||
|
let strings = [];
|
||||||
|
if (args[1]) {
|
||||||
|
strings = args[1].split(",");
|
||||||
|
}
|
||||||
|
value = funcFilters[args[0].trim()](value, ...strings);
|
||||||
|
}
|
||||||
|
this.itemValuePreview = value;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
appendFunc(func) {
|
||||||
|
if (this.itemValue) {
|
||||||
|
this.itemValue += " | " + func;
|
||||||
|
} else {
|
||||||
|
this.$warning(this.$t("api_test.request.parameters_preview_warning"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
advanced(item) {
|
||||||
|
this.itemValueVisible = true;
|
||||||
|
this.itemValue = item.value;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
if (this.items.length === 0) {
|
if (this.items.length === 0) {
|
||||||
this.items.push(new KeyValue());
|
this.items.push(new KeyValue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.kv-description {
|
.kv-description {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-row {
|
.kv-row {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-delete {
|
.kv-delete {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-autocomplete {
|
.el-autocomplete {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.advanced-item-value >>> .el-dialog__body {
|
||||||
|
padding: 15px 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-tip {
|
||||||
|
background: #EDEDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-tip {
|
||||||
|
border: solid #E1E1E1 1px;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -57,3 +57,57 @@ export const REQUEST_HEADERS = [
|
||||||
{value: 'Via'},
|
{value: 'Via'},
|
||||||
{value: 'Warning'}
|
{value: 'Warning'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const MOCKJS_FUNC = [
|
||||||
|
{name: '@boolean'},
|
||||||
|
{name: '@natural'},
|
||||||
|
{name: '@integer'},
|
||||||
|
{name: '@float'},
|
||||||
|
{name: '@character'},
|
||||||
|
{name: '@string'},
|
||||||
|
{name: '@range'},
|
||||||
|
{name: '@date'},
|
||||||
|
{name: '@time'},
|
||||||
|
{name: '@datetime'},
|
||||||
|
{name: '@now'},
|
||||||
|
{name: '@img'},
|
||||||
|
{name: '@dataImage'},
|
||||||
|
{name: '@color'},
|
||||||
|
{name: '@hex'},
|
||||||
|
{name: '@rgb'},
|
||||||
|
{name: '@rgba'},
|
||||||
|
{name: '@hsl'},
|
||||||
|
{name: '@paragraph'},
|
||||||
|
{name: '@sentence'},
|
||||||
|
{name: '@word'},
|
||||||
|
{name: '@title'},
|
||||||
|
{name: '@cparagraph'},
|
||||||
|
{name: '@csentence'},
|
||||||
|
{name: '@cword'},
|
||||||
|
{name: '@ctitle'},
|
||||||
|
{name: '@first'},
|
||||||
|
{name: '@last'},
|
||||||
|
{name: '@name'},
|
||||||
|
{name: '@cfirst'},
|
||||||
|
{name: '@clast'},
|
||||||
|
{name: '@cname'},
|
||||||
|
{name: '@url'},
|
||||||
|
{name: '@domain'},
|
||||||
|
{name: '@protocol'},
|
||||||
|
{name: '@tld'},
|
||||||
|
{name: '@email'},
|
||||||
|
{name: '@ip'},
|
||||||
|
{name: '@region'},
|
||||||
|
{name: '@province'},
|
||||||
|
{name: '@city'},
|
||||||
|
{name: '@county'},
|
||||||
|
{name: '@zip'},
|
||||||
|
{name: '@capitalize'},
|
||||||
|
{name: '@upper'},
|
||||||
|
{name: '@lower'},
|
||||||
|
{name: '@pick'},
|
||||||
|
{name: '@shuffle'},
|
||||||
|
{name: '@guid'},
|
||||||
|
{name: '@id'},
|
||||||
|
{name: '@increment'}
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1,199 @@
|
||||||
|
const aUniqueVerticalStringNotFoundInData = '___UNIQUE_VERTICAL___';
|
||||||
|
const aUniqueCommaStringNotFoundInData = '___UNIQUE_COMMA___';
|
||||||
|
const segmentSeparateChar = '|';
|
||||||
|
const methodAndArgsSeparateChar = ':';
|
||||||
|
const argsSeparateChar = ',';
|
||||||
|
|
||||||
|
const md5 = require('md5');
|
||||||
|
const sha = require('sha.js');
|
||||||
|
const Base64 = require('js-base64').Base64;
|
||||||
|
|
||||||
|
export const funcFilters = {
|
||||||
|
md5: function (str) {
|
||||||
|
return md5(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
sha: function (str, arg) {
|
||||||
|
return sha(arg)
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* type: sha1 sha224 sha256 sha384 sha512
|
||||||
|
*/
|
||||||
|
sha1: function (str) {
|
||||||
|
return sha('sha1')
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
sha224: function (str) {
|
||||||
|
return sha('sha224')
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
sha256: function (str) {
|
||||||
|
return sha('sha256')
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
sha384: function (str) {
|
||||||
|
return sha('sha384')
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
sha512: function (str) {
|
||||||
|
return sha('sha512')
|
||||||
|
.update(str)
|
||||||
|
.digest('hex');
|
||||||
|
},
|
||||||
|
|
||||||
|
base64: function (str) {
|
||||||
|
return Base64.encode(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
unbase64: function (str) {
|
||||||
|
return Base64.decode(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
substr: function (str, ...args) {
|
||||||
|
return str.substr(...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
concat: function (str, ...args) {
|
||||||
|
args.forEach(item => {
|
||||||
|
str += item;
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
|
||||||
|
lconcat: function (str, ...args) {
|
||||||
|
args.forEach(item => {
|
||||||
|
str = item + this._string;
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
|
||||||
|
lower: function (str) {
|
||||||
|
return str.toLowerCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
upper: function (str) {
|
||||||
|
return str.toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
length: function (str) {
|
||||||
|
return str.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
number: function (str) {
|
||||||
|
return !isNaN(str) ? +str : str;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let handleValue = function (str) {
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleValue = function (str) {
|
||||||
|
if (str[0] === str[str.length - 1] && (str[0] === '"' || str[0] === "'")) {
|
||||||
|
str = str.substr(1, str.length - 2);
|
||||||
|
}
|
||||||
|
return handleValue(
|
||||||
|
str
|
||||||
|
.replace(new RegExp(aUniqueVerticalStringNotFoundInData, 'g'), segmentSeparateChar)
|
||||||
|
.replace(new RegExp(aUniqueCommaStringNotFoundInData, 'g'), argsSeparateChar)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
class PowerString {
|
||||||
|
constructor(str) {
|
||||||
|
this._string = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this._string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMethod(method, fn) {
|
||||||
|
PowerString.prototype[method] = function (...args) {
|
||||||
|
args.unshift(this._string + '');
|
||||||
|
this._string = fn.apply(this, args);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function importMethods(handles) {
|
||||||
|
for (let method in handles) {
|
||||||
|
addMethod(method, handles[method]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importMethods(funcFilters);
|
||||||
|
|
||||||
|
function handleOriginStr(str, handleValueFn) {
|
||||||
|
if (!str) return str;
|
||||||
|
if (typeof handleValueFn === 'function') {
|
||||||
|
handleValue = handleValueFn;
|
||||||
|
}
|
||||||
|
str = str
|
||||||
|
.replace('\\' + segmentSeparateChar, aUniqueVerticalStringNotFoundInData)
|
||||||
|
.replace('\\' + argsSeparateChar, aUniqueCommaStringNotFoundInData)
|
||||||
|
.split(segmentSeparateChar)
|
||||||
|
.map(handleSegment)
|
||||||
|
.reduce(execute, null)
|
||||||
|
.toString();
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(str, curItem, index) {
|
||||||
|
if (index === 0) {
|
||||||
|
return new PowerString(curItem);
|
||||||
|
}
|
||||||
|
return str[curItem.method].apply(str, curItem.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSegment(str, index) {
|
||||||
|
str = str.trim();
|
||||||
|
if (index === 0) {
|
||||||
|
return _handleValue(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
let method,
|
||||||
|
args = [];
|
||||||
|
if (str.indexOf(methodAndArgsSeparateChar) > 0) {
|
||||||
|
str = str.split(methodAndArgsSeparateChar);
|
||||||
|
method = str[0].trim();
|
||||||
|
args = str[1].split(argsSeparateChar).map(item => _handleValue(item.trim()));
|
||||||
|
} else {
|
||||||
|
method = str;
|
||||||
|
}
|
||||||
|
if (typeof funcFilters[method] !== 'function') {
|
||||||
|
throw new Error(`This method name(${method}) is not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
args
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// module.exports = {
|
||||||
|
// utils: stringHandles,
|
||||||
|
// PowerString,
|
||||||
|
// /**
|
||||||
|
// * 类似于 angularJs的 filter 功能
|
||||||
|
// * @params string
|
||||||
|
// * @params fn 处理参数值函数,默认是一个返回原有参数值函数
|
||||||
|
// *
|
||||||
|
// * @expamle
|
||||||
|
// * filter('string | substr: 1, 10 | md5 | concat: hello ')
|
||||||
|
// */
|
||||||
|
// filter: handleOriginStr
|
||||||
|
// };
|
|
@ -386,6 +386,10 @@ export default {
|
||||||
url_description: "etc: https://fit2cloud.com",
|
url_description: "etc: https://fit2cloud.com",
|
||||||
path_description: "etc:/login",
|
path_description: "etc:/login",
|
||||||
parameters: "Query parameters",
|
parameters: "Query parameters",
|
||||||
|
parameters_filter_example: "Example",
|
||||||
|
parameters_advance: "Advanced parameter settings",
|
||||||
|
parameters_preview: "Preview",
|
||||||
|
parameters_preview_warning: "Please enter the template first",
|
||||||
parameters_desc: "Parameters will be appended to the URL e.g. https://fit2cloud.com?Name=Value&Name2=Value2",
|
parameters_desc: "Parameters will be appended to the URL e.g. https://fit2cloud.com?Name=Value&Name2=Value2",
|
||||||
headers: "Headers",
|
headers: "Headers",
|
||||||
body: "Body",
|
body: "Body",
|
||||||
|
|
|
@ -384,6 +384,12 @@ export default {
|
||||||
path_description: "例如:/login",
|
path_description: "例如:/login",
|
||||||
url_invalid: "URL无效",
|
url_invalid: "URL无效",
|
||||||
parameters: "请求参数",
|
parameters: "请求参数",
|
||||||
|
parameters_filter: "内置函数",
|
||||||
|
parameters_filter_desc: "使用方法",
|
||||||
|
parameters_filter_example: "示例",
|
||||||
|
parameters_advance: "高级参数设置",
|
||||||
|
parameters_preview: "预览",
|
||||||
|
parameters_preview_warning: "请先输入模版",
|
||||||
parameters_desc: "参数追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2",
|
parameters_desc: "参数追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2",
|
||||||
headers: "请求头",
|
headers: "请求头",
|
||||||
body: "请求内容",
|
body: "请求内容",
|
||||||
|
@ -493,9 +499,9 @@ export default {
|
||||||
actual_result: ": 实际结果为空",
|
actual_result: ": 实际结果为空",
|
||||||
|
|
||||||
case: {
|
case: {
|
||||||
input_test_case:'请输入关联用例名称',
|
input_test_case: '请输入关联用例名称',
|
||||||
test_name:'测试名称',
|
test_name: '测试名称',
|
||||||
other:"--其他--",
|
other: "--其他--",
|
||||||
test_case: "测试用例",
|
test_case: "测试用例",
|
||||||
move: "移动用例",
|
move: "移动用例",
|
||||||
case_list: "用例列表",
|
case_list: "用例列表",
|
||||||
|
|
|
@ -385,6 +385,10 @@ export default {
|
||||||
path_description: "例如:/login",
|
path_description: "例如:/login",
|
||||||
url_invalid: "URL無效",
|
url_invalid: "URL無效",
|
||||||
parameters: "請求參數",
|
parameters: "請求參數",
|
||||||
|
parameters_filter_example: "示例",
|
||||||
|
parameters_advance: "高級參數設置",
|
||||||
|
parameters_preview: "預覽",
|
||||||
|
parameters_preview_warning: "請先輸入模版",
|
||||||
parameters_desc: "參數追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2",
|
parameters_desc: "參數追加到URL,例如https://fit2cloud.com/entries?key1=Value1&Key2=Value2",
|
||||||
headers: "請求頭",
|
headers: "請求頭",
|
||||||
body: "請求內容",
|
body: "請求內容",
|
||||||
|
|
Loading…
Reference in New Issue