feat(接口测试): 请求参数的参数名称唯一的校验(请求头,请求体,Query,REST)

This commit is contained in:
teukkk 2024-04-12 11:52:31 +08:00 committed by Craftsman
parent ce975229c9
commit 087bfb417f
12 changed files with 290 additions and 134 deletions

View File

@ -1,148 +1,168 @@
<template> <template>
<MsBaseTable <a-form ref="formRef" :model="propsRes">
v-bind="propsRes" <MsBaseTable
:hoverable="false" v-bind="propsRes"
is-simple-setting :hoverable="false"
:span-method="props.spanMethod" is-simple-setting
:class="!props.selectable && !props.draggable ? 'ms-form-table-no-left-action' : ''" :span-method="props.spanMethod"
v-on="propsEvent" :class="!props.selectable && !props.draggable ? 'ms-form-table-no-left-action' : ''"
@drag-change="tableChange" v-on="propsEvent"
> @drag-change="tableChange"
<!-- 展开行--> @init-end="validateAndUpdateErrorMessageList"
<template #expand-icon="{ expanded, record }">
<div class="flex items-center gap-[2px] text-[var(--color-text-4)]">
<MsIcon :type="expanded ? 'icon-icon_split_turn-down_arrow' : 'icon-icon_split-turn-down-left'" />
<div v-if="record.children">{{ record.children.length }}</div>
</div>
</template>
<template
v-for="item of props.columns.filter((e) => e.slotName !== undefined)"
#[item.slotName!]="{ record, rowIndex, column }"
> >
<slot :name="item.slotName" v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"> <!-- 展开行-->
<a-tooltip <template #expand-icon="{ expanded, record }">
v-if="item.hasRequired" <div class="flex items-center gap-[2px] text-[var(--color-text-4)]">
:content="t(record.required ? 'msFormTable.paramRequired' : 'msFormTable.paramNotRequired')" <MsIcon :type="expanded ? 'icon-icon_split_turn-down_arrow' : 'icon-icon_split-turn-down-left'" />
<div v-if="record.children">{{ record.children.length }}</div>
</div>
</template>
<template
v-for="item of props.columns.filter((e) => e.slotName !== undefined)"
:key="item.toString()"
#[item.slotName!]="{ record, rowIndex, column }"
>
<a-form-item
:field="`data[${rowIndex}].${item.dataIndex}`"
label=""
:rules="{
validator: (value, callback) => {
validRepeat(rowIndex, item.dataIndex as string, value, callback);
},
}"
> >
<MsButton <slot
type="icon" :name="item.slotName"
:class="[ v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"
record.required ? '!text-[rgb(var(--danger-5))]' : '!text-[var(--color-text-brand)]',
'!mr-[4px] !p-[4px]',
]"
size="mini"
@click="toggleRequired(record, rowIndex, item)"
> >
<div>*</div> <a-tooltip
</MsButton> v-if="item.hasRequired"
</a-tooltip> :content="t(record.required ? 'msFormTable.paramRequired' : 'msFormTable.paramNotRequired')"
<a-input >
v-if="item.inputType === 'input'" <MsButton
v-model:model-value="record[item.dataIndex as string]" type="icon"
:placeholder="t(item.locale)" :class="[
class="ms-form-table-input" record.required ? '!text-[rgb(var(--danger-5))]' : '!text-[var(--color-text-brand)]',
:max-length="255" '!mr-[4px] !p-[4px]',
size="mini" ]"
@input="() => handleFormChange(record, rowIndex, item)" size="mini"
/> @click="toggleRequired(record, rowIndex, item)"
<a-select >
v-else-if="item.inputType === 'select'" <div>*</div>
v-model:model-value="record[item.dataIndex as string]" </MsButton>
:options="item.typeOptions || []" </a-tooltip>
class="ms-form-table-input w-full" <a-input
size="mini" v-if="item.inputType === 'input'"
@change="() => handleFormChange(record, rowIndex, item)" v-model:model-value="record[item.dataIndex as string]"
/> :placeholder="t(item.locale)"
<MsTagsInput class="ms-form-table-input"
v-else-if="item.inputType === 'tags'" :max-length="255"
v-model:model-value="record[item.dataIndex as string]" size="mini"
:max-tag-count="1" @input="() => handleFormChange(record, rowIndex, item)"
input-class="ms-form-table-input" />
size="mini" <a-select
@change="() => handleFormChange(record, rowIndex, item)" v-else-if="item.inputType === 'select'"
@clear="() => handleFormChange(record, rowIndex, item)" v-model:model-value="record[item.dataIndex as string]"
/> :options="item.typeOptions || []"
<a-switch class="ms-form-table-input w-full"
v-else-if="item.inputType === 'switch'" size="mini"
v-model:model-value="record[item.dataIndex as string]" @change="() => handleFormChange(record, rowIndex, item)"
size="small" />
class="ms-form-table-input-switch" <MsTagsInput
type="line" v-else-if="item.inputType === 'tags'"
@change="() => handleFormChange(record, rowIndex, item)" v-model:model-value="record[item.dataIndex as string]"
/> :max-tag-count="1"
<a-checkbox input-class="ms-form-table-input"
v-else-if="item.inputType === 'checkbox'" size="mini"
v-model:model-value="record[item.dataIndex as string]" @change="() => handleFormChange(record, rowIndex, item)"
@change="() => handleFormChange(record, rowIndex, item)" @clear="() => handleFormChange(record, rowIndex, item)"
/> />
<template v-else-if="item.inputType === 'autoComplete'"> <a-switch
<a-auto-complete v-else-if="item.inputType === 'switch'"
v-model:model-value="record[item.dataIndex as string]" v-model:model-value="record[item.dataIndex as string]"
:data="item.autoCompleteParams?.filter((e) => e.isShow === true)" size="small"
class="ms-form-table-input" class="ms-form-table-input-switch"
:trigger-props="{ contentClass: 'ms-form-table-input-trigger' }" type="line"
:filter-option="false" @change="() => handleFormChange(record, rowIndex, item)"
size="small" />
@search="(val) => handleSearchParams(val, item)" <a-checkbox
@change="() => handleFormChange(record, rowIndex, item)" v-else-if="item.inputType === 'checkbox'"
@select="(val) => selectAutoComplete(val, record, item)" v-model:model-value="record[item.dataIndex as string]"
> @change="() => handleFormChange(record, rowIndex, item)"
<template #option="{ data: opt }"> />
<div class="w-[350px]"> <template v-else-if="item.inputType === 'autoComplete'">
{{ opt.raw.value }} <a-auto-complete
<a-tooltip :content="t(opt.raw.desc)" position="bl" :mouse-enter-delay="300"> v-model:model-value="record[item.dataIndex as string]"
<div class="one-line-text max-w-full text-[12px] leading-[16px] text-[var(--color-text-4)]"> :data="item.autoCompleteParams?.filter((e) => e.isShow === true)"
{{ t(opt.raw.desc) }} class="ms-form-table-input"
:trigger-props="{ contentClass: 'ms-form-table-input-trigger' }"
:filter-option="false"
size="small"
@search="(val) => handleSearchParams(val, item)"
@change="() => handleFormChange(record, rowIndex, item)"
@select="(val) => selectAutoComplete(val, record, item)"
>
<template #option="{ data: opt }">
<div class="w-[350px]">
{{ opt.raw.value }}
<a-tooltip :content="t(opt.raw.desc)" position="bl" :mouse-enter-delay="300">
<div class="one-line-text max-w-full text-[12px] leading-[16px] text-[var(--color-text-4)]">
{{ t(opt.raw.desc) }}
</div>
</a-tooltip>
</div> </div>
</a-tooltip> </template>
</a-auto-complete>
</template>
<template v-else-if="item.inputType === 'text'">
{{
typeof item.valueFormat === 'function'
? item.valueFormat(record)
: record[item.dataIndex as string] || '-'
}}
</template>
<template v-else-if="item.dataIndex === 'action'">
<div
:key="item.dataIndex"
class="flex flex-row items-center"
:class="{ 'justify-end': item.align === 'right' }"
>
<slot
name="operationPre"
v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"
></slot>
<MsTableMoreAction
v-if="item.moreAction"
:list="getMoreActionList(item.moreAction, record)"
@select="(e) => handleMoreActionSelect(e, record, item, rowIndex)"
/>
<icon-minus-circle
v-if="dataLength > 1 && rowIndex !== dataLength - 1"
class="ml-[8px] cursor-pointer text-[var(--color-text-4)]"
size="20"
@click="deleteParam(record, rowIndex)"
/>
</div> </div>
</template> </template>
</a-auto-complete> </slot>
</template> </a-form-item>
<template v-else-if="item.inputType === 'text'"> </template>
{{ <template
typeof item.valueFormat === 'function' ? item.valueFormat(record) : record[item.dataIndex as string] || '-' v-for="item of props.columns.filter((e) => e.titleSlotName !== undefined)"
}} #[item.titleSlotName!]="{ record, rowIndex, column }"
</template>
<template v-else-if="item.dataIndex === 'action'">
<div
:key="item.dataIndex"
class="flex flex-row items-center"
:class="{ 'justify-end': item.align === 'right' }"
>
<slot
name="operationPre"
v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"
></slot>
<MsTableMoreAction
v-if="item.moreAction"
:list="getMoreActionList(item.moreAction, record)"
@select="(e) => handleMoreActionSelect(e, record, item, rowIndex)"
/>
<icon-minus-circle
v-if="dataLength > 1 && rowIndex !== dataLength - 1"
class="ml-[8px] cursor-pointer text-[var(--color-text-4)]"
size="20"
@click="deleteParam(record, rowIndex)"
/>
</div>
</template>
</slot>
</template>
<template
v-for="item of props.columns.filter((e) => e.titleSlotName !== undefined)"
#[item.titleSlotName!]="{ record, rowIndex, column }"
>
<slot
:name="item.titleSlotName"
v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"
> >
</slot> <slot
</template> :name="item.titleSlotName"
</MsBaseTable> v-bind="{ record, rowIndex, column, dataIndex: item.dataIndex, columnConfig: item }"
>
</slot>
</template>
</MsBaseTable>
</a-form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FormInstance } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';
@ -167,6 +187,7 @@
required?: boolean; // required?: boolean; //
inputType?: 'input' | 'select' | 'tags' | 'switch' | 'text' | 'checkbox' | 'autoComplete'; // inputType?: 'input' | 'select' | 'tags' | 'switch' | 'text' | 'checkbox' | 'autoComplete'; //
autoCompleteParams?: SelectOptionData[]; // autoCompleteParams?: SelectOptionData[]; //
needValidRepeat?: boolean; //
valueFormat?: (record: Record<string, any>) => string; // inputTypetext valueFormat?: (record: Record<string, any>) => string; // inputTypetext
[key: string]: any; // [key: string]: any; //
} }
@ -258,6 +279,35 @@
const dataLength = computed(() => propsRes.value.data.length); const dataLength = computed(() => propsRes.value.data.length);
//
const formRef = ref<FormInstance>();
async function validRepeat(rowIndex: number, dataIndex: string, value: any, callback: (error?: string) => void) {
const currentColumn = props.columns.find((item) => item.dataIndex === dataIndex);
if (!currentColumn?.needValidRepeat) {
callback();
return;
}
propsRes.value.data?.forEach((row, index) => {
if (row[dataIndex].length && index !== rowIndex && row[dataIndex] === value) {
callback(`${t(currentColumn?.title as string)}${t('msFormTable.paramRepeatMessage')}`);
}
});
callback();
}
//
const setErrorMessageList: ((params: string[]) => void) | undefined = inject('setErrorMessageList', undefined);
const errorMessageList = ref<string[]>([]); //
async function validateAndUpdateErrorMessageList() {
if (typeof setErrorMessageList === 'function' && props.columns.some((item) => item.needValidRepeat)) {
await nextTick();
formRef.value?.validate((errors) => {
errorMessageList.value = !errors ? [] : [...new Set(Object.values(errors).map(({ message }) => message))];
setErrorMessageList(errorMessageList.value);
});
}
}
watch( watch(
() => selectedKeys.value, () => selectedKeys.value,
(arr) => { (arr) => {
@ -276,6 +326,7 @@
() => props.data, () => props.data,
(arr) => { (arr) => {
propsRes.value.data = arr as any[]; propsRes.value.data = arr as any[];
validateAndUpdateErrorMessageList();
}, },
{ {
immediate: true, immediate: true,
@ -468,4 +519,20 @@
line-height: 16px; line-height: 16px;
color: var(--color-text-1); color: var(--color-text-1);
} }
:deep(.arco-form-item) {
margin-bottom: 0;
.arco-form-item-label-col {
display: none;
}
.arco-form-item-content,
.arco-form-item-wrapper-col {
min-height: auto;
}
.arco-form-item-message {
margin-bottom: 0;
}
.arco-form-item-content-flex {
flex-wrap: nowrap;
}
}
</style> </style>

View File

@ -1,4 +1,5 @@
export default { export default {
'msFormTable.paramRequired': '必填', 'msFormTable.paramRequired': '必填',
'msFormTable.paramNotRequired': '非必填', 'msFormTable.paramNotRequired': '非必填',
'msFormTable.paramRepeatMessage': '唯一,不能重复',
}; };

View File

@ -331,6 +331,7 @@
(e: 'clearSelector'): void; (e: 'clearSelector'): void;
(e: 'filterChange', dataIndex: string, value: (string | number)[], multiple: boolean, isCustomParam: boolean): void; (e: 'filterChange', dataIndex: string, value: (string | number)[], multiple: boolean, isCustomParam: boolean): void;
(e: 'moduleChange'): void; (e: 'moduleChange'): void;
(e: 'initEnd'): void;
}>(); }>();
const attrs = useAttrs(); const attrs = useAttrs();
@ -408,6 +409,7 @@
scrollObj.value = {}; scrollObj.value = {};
currentColumns.value = arr || tmpArr; currentColumns.value = arr || tmpArr;
} }
emit('initEnd');
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('InitColumn failed', error); console.error('InitColumn failed', error);

View File

@ -208,6 +208,7 @@
{ {
title: 'apiTestDebug.paramName', title: 'apiTestDebug.paramName',
dataIndex: 'key', dataIndex: 'key',
needValidRepeat: true,
slotName: 'key', slotName: 'key',
width: 240, width: 240,
}, },

View File

@ -59,6 +59,7 @@
dataIndex: 'key', dataIndex: 'key',
slotName: 'key', slotName: 'key',
inputType: 'autoComplete', inputType: 'autoComplete',
needValidRepeat: true,
autoCompleteParams: responseHeaderOption, autoCompleteParams: responseHeaderOption,
}, },
{ {

View File

@ -496,6 +496,12 @@
() => import('@/views/api-test/management/components/addDependencyDrawer.vue') () => import('@/views/api-test/management/components/addDependencyDrawer.vue')
); );
export interface TabErrorMessage {
value: string;
label: string;
messageList: string[];
}
export interface RequestCustomAttr { export interface RequestCustomAttr {
type: 'api' | 'case' | 'mock' | 'doc'; // tab api type: 'api' | 'case' | 'mock' | 'doc'; // tab api
isNew: boolean; isNew: boolean;
@ -505,6 +511,9 @@
executeLoading: boolean; // loading executeLoading: boolean; // loading
isCopy?: boolean; // isCopy?: boolean; //
isExecute?: boolean; // isExecute?: boolean; //
errorMessageInfo?: {
[key: string]: Record<string, any>;
};
} }
export type RequestParam = ExecuteApiRequestFullParams & { export type RequestParam = ExecuteApiRequestFullParams & {
responseDefinition?: ResponseItem[]; responseDefinition?: ResponseItem[];
@ -1346,6 +1355,59 @@
} }
} }
function initErrorMessageInfoItem(key) {
if (requestVModel.value.errorMessageInfo && !requestVModel.value.errorMessageInfo[key]) {
requestVModel.value.errorMessageInfo[key] = {};
}
}
function changeTabErrorMessageList(tabKey: string, formErrorMessageList: string[]) {
if (!requestVModel.value.errorMessageInfo) return;
const label = contentTabList.value.find((item) => item.value === tabKey)?.label ?? '';
const listItem: TabErrorMessage = {
value: tabKey,
label,
messageList: formErrorMessageList,
};
// TODO: sql sql
if (requestVModel.value.activeTab === RequestComposition.BODY) {
initErrorMessageInfoItem(RequestComposition.BODY);
requestVModel.value.errorMessageInfo[RequestComposition.BODY][requestVModel.value.body.bodyType] =
cloneDeep(listItem);
} else {
requestVModel.value.errorMessageInfo[requestVModel.value.activeTab] = cloneDeep(listItem);
}
}
const setErrorMessageList = debounce((list: string[]) => {
changeTabErrorMessageList(requestVModel.value.activeTab, list);
}, 300);
provide('setErrorMessageList', setErrorMessageList);
//
function getFlattenedMessages() {
if (!requestVModel.value.errorMessageInfo) return;
const flattenedMessages: { label: string; messageList: string[] }[] = [];
const { errorMessageInfo } = requestVModel.value;
Object.values(errorMessageInfo).forEach((item) => {
const label = item.label || (Object.values(item)[0] && Object.values(item)[0].label);
const messageList: string[] =
item.messageList || [...new Set(Object.values(item).flatMap((bodyType) => bodyType.messageList))] || [];
if (messageList.length) {
flattenedMessages.push({ label, messageList: [...new Set(messageList)] });
}
});
return flattenedMessages;
}
function showMessage() {
getFlattenedMessages()?.forEach(({ label, messageList }) => {
messageList?.forEach((message) => {
Message.error(`${label}${message}`);
});
});
}
function handleSave(done: (closed: boolean) => void) { function handleSave(done: (closed: boolean) => void) {
saveModalFormRef.value?.validate(async (errors) => { saveModalFormRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
@ -1368,6 +1430,11 @@
// //
await fApi.value?.validate(); await fApi.value?.validate();
} }
//
if (getFlattenedMessages()?.length) {
showMessage();
return;
}
if (!requestVModel.value.isNew) { if (!requestVModel.value.isNew) {
// //
updateRequest(); updateRequest();
@ -1487,6 +1554,11 @@
if (errors) { if (errors) {
requestVModel.value.activeTab = RequestComposition.BASE_INFO; requestVModel.value.activeTab = RequestComposition.BASE_INFO;
} else { } else {
//
if (getFlattenedMessages()?.length) {
showMessage();
return;
}
switch (value) { switch (value) {
case 'save': case 'save':
handleSaveShortcut(); handleSaveShortcut();
@ -1565,6 +1637,8 @@
execute, execute,
makeRequestParams, makeRequestParams,
changeVerticalExpand, changeVerticalExpand,
getFlattenedMessages,
showMessage,
}); });
</script> </script>

View File

@ -68,6 +68,7 @@
title: 'apiTestDebug.paramName', title: 'apiTestDebug.paramName',
dataIndex: 'key', dataIndex: 'key',
slotName: 'key', slotName: 'key',
needValidRepeat: true,
}, },
{ {
title: 'apiTestDebug.paramType', title: 'apiTestDebug.paramType',

View File

@ -69,6 +69,7 @@
title: 'apiTestDebug.paramName', title: 'apiTestDebug.paramName',
dataIndex: 'key', dataIndex: 'key',
slotName: 'key', slotName: 'key',
needValidRepeat: true,
}, },
{ {
title: 'apiTestDebug.paramType', title: 'apiTestDebug.paramType',

View File

@ -212,6 +212,7 @@
response: cloneDeep(defaultResponse), response: cloneDeep(defaultResponse),
isNew: true, isNew: true,
executeLoading: false, executeLoading: false,
errorMessageInfo: {},
}; };
const debugTabs = ref<RequestParam[]>([cloneDeep(defaultDebugParams)]); const debugTabs = ref<RequestParam[]>([cloneDeep(defaultDebugParams)]);
const activeDebug = ref<RequestParam>(debugTabs.value[0]); const activeDebug = ref<RequestParam>(debugTabs.value[0]);

View File

@ -275,6 +275,7 @@
executeLoading: false, executeLoading: false,
preDependency: [], // preDependency: [], //
postDependency: [], // postDependency: [], //
errorMessageInfo: {},
}; };
function addApiTab(defaultProps?: Partial<TabItem>) { function addApiTab(defaultProps?: Partial<TabItem>) {

View File

@ -237,6 +237,11 @@
function handleDrawerConfirm(isContinue: boolean) { function handleDrawerConfirm(isContinue: boolean) {
formRef.value?.validate(async (errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
//
if (requestCompositionRef.value?.getFlattenedMessages()?.length) {
requestCompositionRef.value?.showMessage();
return;
}
drawerLoading.value = true; drawerLoading.value = true;
// //
if (!requestCompositionRef.value?.makeRequestParams()) return; if (!requestCompositionRef.value?.makeRequestParams()) return;

View File

@ -205,6 +205,7 @@
executeLoading: false, executeLoading: false,
preDependency: [], // preDependency: [], //
postDependency: [], // postDependency: [], //
errorMessageInfo: {},
}; };
// id // id