feat(接口测试): csv参数&JSONPath 提取调整&场景步骤树 hook 提取

This commit is contained in:
baiqi 2024-05-16 21:27:38 +08:00 committed by Craftsman
parent 2a3421f91d
commit 663b96aa62
23 changed files with 1468 additions and 770 deletions

View File

@ -1,7 +1,9 @@
<template>
<a-dropdown v-model:popup-visible="dropdownVisible" :disabled="props.disabled" position="tl" trigger="click">
<a-button :disabled="props.disabled" size="mini" type="outline">
<template #icon> <icon-upload class="text-[14px] !text-[rgb(var(--primary-5))]" /> </template>
<template #icon>
<icon-upload class="!hover:text-[rgb(var(--primary-5))] text-[14px] !text-[rgb(var(--primary-5))]" />
</template>
</a-button>
<template #content>
<MsUpload

View File

@ -449,7 +449,12 @@
td {
background-color: white !important;
}
* {
.arco-btn-icon {
border-color: var(--color-text-n8);
color: var(--color-text-4);
}
*:not(.arco-btn-icon) {
border-color: var(--color-text-n8) !important;
color: var(--color-text-4) !important;
}
}

View File

@ -86,7 +86,7 @@
}"
>
<slot :name="item.titleSlotName" :column-config="item">
<div v-if="item.title" class="title-name pl-1 text-[var(--color-text-3)]">
<div v-if="item.title" class="title-name text-[var(--color-text-3)]">
{{ t(item.title as string) }}
</div>
</slot>

View File

@ -234,14 +234,11 @@ export interface AssertionConfig {
assertions: ExecuteAssertionItem[];
}
export interface CsvVariable {
id?: string;
fileId: string;
id: string;
scenarioId: string;
name: string;
fileName: string;
scope: string;
enable: boolean;
association: boolean;
encoding: string;
random: boolean;
variableNames: string;
@ -250,6 +247,14 @@ export interface CsvVariable {
allowQuotedData: boolean;
recycleOnEof: boolean;
stopThreadOnEof: boolean;
file: {
fileId: string;
fileName: string;
local: boolean; // 是否是本地上传的文件
fileAlias: string; // 文件别名
delete: boolean; // 是否删除
[key: string]: any; // 用于前端渲染时填充的自定义信息,后台无此字段
};
// 以下为前端字段
settingVisible: boolean;
}
@ -353,7 +358,7 @@ export interface ScenarioStepItem {
stepType: ScenarioStepType;
refType: ScenarioStepRefType;
config: ScenarioStepDetail; // 存储步骤列表需要展示的信息
csvFileIds?: string[];
csvIds?: string[];
projectId?: string;
versionId?: string;
children?: ScenarioStepItem[];

View File

@ -426,13 +426,11 @@ export const defaultNormalParamItem = {
};
// 场景-csv参数默认值
export const defaultCsvParamItem: CsvVariable = {
fileId: '',
id: '',
scenarioId: '',
name: '',
fileName: '',
scope: 'SCENARIO',
enable: true,
association: false,
enable: false,
encoding: 'UTF-8',
random: false,
variableNames: '',
@ -442,4 +440,11 @@ export const defaultCsvParamItem: CsvVariable = {
recycleOnEof: false,
stopThreadOnEof: false,
settingVisible: false,
file: {
fileId: '',
fileName: '',
local: false,
fileAlias: '',
delete: false,
},
};

View File

@ -252,7 +252,13 @@
JSONPath({
json: parseJson.value,
path: expressionForm.value.expression,
})?.map((e: any) => JSON.stringify(e).replace(/Number\(([^)]+)\)|Number\(([^)]+)\)/g, '$1$2')) || [];
})?.map((e: any) =>
typeof e === 'string'
? e
: JSON.stringify(e)
.replace(/Number\(([^)]+)\)/g, '$1')
.replace(/^"|"$/g, '')
) || [];
} catch (error) {
matchResult.value = JSONPath({ json: props.response || '', path: expressionForm.value.expression }) || [];
}

View File

@ -222,7 +222,7 @@
:options="columnConfig.typeOptions || []"
class="ms-form-table-input w-[180px]"
size="mini"
@change="() => addTableLine(rowIndex)"
@change="(val) => handleScopeChange(val, record, rowIndex, columnConfig.addLineDisabled)"
/>
</template>
<!-- 参数值 -->
@ -282,7 +282,7 @@
<!-- 文件 -->
<template #file="{ record, rowIndex }">
<MsAddAttachment
:file-list="[record]"
:file-list="[record.file]"
:disabled="props.disabledParamValue"
:multiple="false"
mode="input"
@ -500,6 +500,9 @@
size="small"
type="line"
class="ml-[8px]"
:before-change="
(newValue) => (props.enableChangeIntercept ? props.enableChangeIntercept(record, newValue) : undefined)
"
@change="() => addTableLine(rowIndex)"
/>
</template>
@ -624,6 +627,7 @@
import { groupCategoryEnvList, groupProjectEnv } from '@/api/modules/project-management/envManagement';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { getGenerateId } from '@/utils';
import { ModuleTreeNode, TransferFileParams } from '@/models/common';
import { HttpForm, ProjectOptionItem } from '@/models/projectManagement/environmental';
@ -686,6 +690,9 @@
fileSaveAsSourceId?: string | number; // id
fileSaveAsApi?: (params: TransferFileParams) => Promise<string>; //
fileModuleOptionsApi?: (projectId: string) => Promise<ModuleTreeNode[]>; //
deleteIntercept?: (record: any, deleteCall: () => void) => void; //
typeChangeIntercept?: (record: any, doChange: () => void) => void; // type
enableChangeIntercept?: (record: any, val: string | number | boolean) => boolean | Promise<boolean>; // enable
}>(),
{
params: () => [],
@ -734,8 +741,15 @@
emit('treeDelete', record);
return;
}
paramsData.value.splice(rowIndex, 1);
emitChange('deleteParam');
if (props.deleteIntercept) {
props.deleteIntercept(record, () => {
paramsData.value.splice(rowIndex, 1);
emitChange('deleteParam');
});
} else {
paramsData.value.splice(rowIndex, 1);
emitChange('deleteParam');
}
}
/** 断言-文档-Begin */
@ -866,13 +880,13 @@
if (rowIndex === paramsData.value.length - 1) {
// Don't change this!!!
//
const id = new Date().getTime().toString();
const id = getGenerateId();
const lastLineData = paramsData.value[rowIndex]; //
const selectColumnKeys = props.columns.filter((e) => e.typeOptions).map((e) => e.dataIndex); //
const nextLine = {
id,
...cloneDeep(props.defaultParamItem), //
enable: true, //
...cloneDeep(props.defaultParamItem), //
} as any;
selectColumnKeys.forEach((key) => {
// 便
@ -908,7 +922,7 @@
hasNoIdItem = true;
return {
...cloneDeep(props.defaultParamItem),
id: new Date().getTime() + i,
id: getGenerateId(),
};
}
if (!item.id) {
@ -916,7 +930,7 @@
hasNoIdItem = true;
return {
...item,
id: new Date().getTime() + i,
id: getGenerateId(),
};
}
return item;
@ -926,12 +940,12 @@
}
} else {
if (props.disabledExceptParam) return;
const id = new Date().getTime().toString();
const id = getGenerateId();
paramsData.value = [
{
id, // id props.defaultParamItem id
...cloneDeep(props.defaultParamItem),
enable: true, //
...cloneDeep(props.defaultParamItem),
},
] as any[];
emitChange('watch props.params', true);
@ -1006,20 +1020,18 @@
if (props.uploadTempFileApi && file?.local) {
appStore.showLoading();
const res = await props.uploadTempFileApi(file.file);
record = {
...record,
record.file = {
...file,
fileId: res.data,
fileName: file.name || '',
fileAlias: file.name || '',
};
} else if (file) {
record = {
...record,
...file,
fileId: file.uid || file.fileId || '',
fileName: file.originalName || '',
fileAlias: file.name || '',
} else if (files[0]) {
record.file = {
...files[0],
fileId: files[0].uid || files[0].fileId || '',
fileName: files[0].originalName || '',
fileAlias: files[0].name || '',
};
}
addTableLine(rowIndex);
@ -1101,6 +1113,23 @@
emitChange('handleTypeChange');
}
function handleScopeChange(
val: string | number | boolean | Record<string, any> | (string | number | boolean | Record<string, any>)[],
record: Record<string, any>,
rowIndex: number,
addLineDisabled?: boolean
) {
if (props.typeChangeIntercept) {
props.typeChangeIntercept(record, () => {
addTableLine(rowIndex, addLineDisabled);
emitChange('handleScopeChange');
});
} else {
addTableLine(rowIndex, addLineDisabled);
emitChange('handleScopeChange');
}
}
/**
* 获取更多操作按钮列表
* @param actions 按钮列表

View File

@ -33,7 +33,7 @@
:file-save-as-api="props.fileSaveAsApi"
:file-module-options-api="props.fileModuleOptionsApi"
@change="handleParamTableChange"
@batch-add="batchAddKeyValVisible = true"
@batch-add="() => (batchAddKeyValVisible = true)"
/>
<paramTable
v-else-if="innerParams.bodyType === RequestBodyFormat.WWW_FORM"
@ -47,7 +47,7 @@
:table-key="TableKeyEnum.API_TEST_DEBUG_FORM_URL_ENCODE"
:default-param-item="defaultBodyParamsItem"
@change="handleParamTableChange"
@batch-add="batchAddKeyValVisible = true"
@batch-add="() => (batchAddKeyValVisible = true)"
/>
<div v-else-if="innerParams.bodyType === RequestBodyFormat.BINARY">
<div class="mb-[16px] flex justify-between gap-[8px] bg-[var(--color-text-n9)] p-[12px]">

View File

@ -7,15 +7,17 @@
:scroll="props.scroll"
>
<template #assertionItem="{ record }">
<div class="flex items-center gap-[4px]">
{{
t(
responseAssertionTypeMap[(record as ResponseAssertionTableItem).assertionType] ||
'apiTestDebug.responseBody'
)
}}
{{ record.name }}
</div>
<a-tooltip :content="record.name">
<div class="flex items-center gap-[4px]">
{{
t(
responseAssertionTypeMap[(record as ResponseAssertionTableItem).assertionType] ||
'apiTestDebug.responseBody'
)
}}
{{ record.name }}
</div>
</a-tooltip>
</template>
<template #condition="{ record }">
{{
@ -61,7 +63,6 @@
{
title: 'apiTestDebug.assertionItem',
dataIndex: 'assertionItem',
showTooltip: true,
slotName: 'assertionItem',
width: 200,
},

View File

@ -0,0 +1,351 @@
<template>
<paramTable
v-model:params="csvVariables"
:columns="csvColumns"
:default-param-item="defaultCsvParamItem"
:draggable="false"
:selectable="false"
:delete-intercept="deleteIntercept"
:type-change-intercept="typeChangeIntercept"
:enable-change-intercept="enableChangeIntercept"
:upload-temp-file-api="uploadTempFile"
:file-save-as-source-id="props.scenarioId"
:file-save-as-api="transferFile"
:file-module-options-api="getTransferOptions"
@change="handleCsvVariablesChange"
>
<template #operationPre="{ record }">
<a-trigger
v-model:popup-visible="record.settingVisible"
trigger="click"
position="br"
class="scenario-csv-trigger"
>
<MsButton type="text" class="!mr-0" @click="handleRecordConfig(record)">
{{ t('apiScenario.params.config') }}
</MsButton>
<template #content>
<div class="scenario-csv-trigger-content">
<div class="mb-[16px] flex items-center">
<div class="font-semibold text-[var(--color-text-1)]">{{ t('apiScenario.params.csvConfig') }}</div>
<!-- <div class="text-[var(--color-text-4)]">({{ record.key }})</div> -->
</div>
<div class="scenario-csv-trigger-content-scroll">
<a-form ref="paramFormRef" :model="paramForm" layout="vertical" scroll-to-first-error>
<a-form-item
field="name"
:label="t('apiScenario.params.csvName')"
:rules="[{ required: true, message: t('apiScenario.params.csvNameNotNull') }]"
asterisk-position="end"
class="mb-[16px]"
>
<a-input v-model:model-value="paramForm.name" :max-length="255"></a-input>
</a-form-item>
<a-form-item field="variableNames" :label="t('apiScenario.params.csvParamName')" class="mb-[16px]">
<a-input
v-model:model-value="paramForm.variableNames"
:placeholder="t('apiScenario.params.csvParamNamePlaceholder')"
></a-input>
</a-form-item>
<a-form-item field="encoding" :label="t('apiScenario.params.csvFileCode')" class="mb-[16px]">
<a-select
v-model:model-value="paramForm.encoding"
:options="encodingOptions"
class="w-[120px]"
></a-select>
</a-form-item>
<a-form-item field="delimiter" :label="t('apiScenario.params.csvSplitChar')" class="mb-[16px]">
<a-input
v-model:model-value="paramForm.delimiter"
:placeholder="t('common.pleaseInput')"
:max-length="64"
class="w-[120px]"
></a-input>
</a-form-item>
<a-form-item
field="ignoreFirstLine"
:label="t('apiScenario.params.csvIgnoreFirstLine')"
class="mb-[16px]"
>
<a-radio-group v-model:model-value="paramForm.ignoreFirstLine">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="random" :label="t('apiScenario.params.csvIsRandom')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.random">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="allowQuotedData" :label="t('apiScenario.params.csvQuoteAllow')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.allowQuotedData">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="recycleOnEof" :label="t('apiScenario.params.csvRecycle')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.recycleOnEof">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="stopThreadOnEof" :label="t('apiScenario.params.csvStop')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.stopThreadOnEof">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</div>
<div class="flex items-center justify-end gap-[8px]">
<a-button type="secondary" @click="cancelConfig(record)">{{ t('common.cancel') }}</a-button>
<a-button type="primary" @click="applyConfig">{{ t('ms.paramsInput.apply') }}</a-button>
</div>
</div>
</template>
</a-trigger>
</template>
</paramTable>
</template>
<script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue';
import paramTable, { ParamTableColumn } from '@/views/api-test/components/paramTable.vue';
import { getTransferOptions, transferFile, uploadTempFile } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import { getGenerateId } from '@/utils';
import { CsvVariable } from '@/models/apiTest/scenario';
import { defaultCsvParamItem } from '@/views/api-test/components/config';
const props = defineProps<{
scenarioId?: string | number;
}>();
const emit = defineEmits<{
(e: 'change'): void; //
}>();
const { t } = useI18n();
const { openModal } = useModal();
const csvVariables = defineModel<CsvVariable[]>('csvVariables', {
required: true,
});
const csvColumns: ParamTableColumn[] = [
{
title: 'apiScenario.params.csvName',
dataIndex: 'name',
slotName: 'name',
needValidRepeat: true,
},
{
title: 'apiScenario.params.csvScoped',
dataIndex: 'scope',
slotName: 'scope',
typeOptions: [
{
label: t('apiScenario.scenario'),
value: 'SCENARIO',
},
{
label: t('apiScenario.step'),
value: 'STEP',
},
],
width: 80,
titleSlotName: 'typeTitle',
typeTitleTooltip: [t('apiScenario.params.csvScopedTip1'), t('apiScenario.params.csvScopedTip2')],
},
{
title: 'apiScenario.params.file',
dataIndex: 'file',
slotName: 'file',
},
{
title: 'apiScenario.table.columns.status',
dataIndex: 'enable',
slotName: 'enable',
},
{
title: '',
slotName: 'operation',
dataIndex: 'operation',
width: 90,
},
];
const paramFormRef = ref<FormInstance>();
const paramForm = ref<CsvVariable>(cloneDeep(defaultCsvParamItem));
const encodingOptions = [
{
label: 'UTF-8',
value: 'UTF-8',
},
{
label: 'UTF-16',
value: 'UTF-16',
},
{
label: 'GBK',
value: 'GBK',
},
{
label: 'ISO-8859-15',
value: 'ISO-8859-15',
},
{
label: 'US-ASCII',
value: 'US-ASCII',
},
];
function handleCsvVariablesChange(resultArr: any[], isInit?: boolean) {
csvVariables.value = [...resultArr];
if (!isInit) {
emit('change');
}
}
function handleRecordConfig(record: CsvVariable) {
paramForm.value = cloneDeep(record);
}
function cancelConfig(record: CsvVariable) {
paramFormRef.value?.resetFields();
record.settingVisible = false;
}
function applyConfig() {
paramFormRef.value?.validate((errors) => {
if (!errors) {
let newArr = csvVariables.value.map((e) => {
if (e.id === paramForm.value.id) {
return {
...paramForm.value,
settingVisible: false,
};
}
return e;
});
if (newArr.findIndex((e) => e.id === paramForm.value.id) === newArr.length - 1) {
newArr = newArr.concat({
...cloneDeep(defaultCsvParamItem),
id: getGenerateId(),
});
}
csvVariables.value = newArr;
emit('change');
}
});
}
function deleteIntercept(record: CsvVariable, deleteCall: () => void) {
if (!!record.name && !!record.file.fileId) {
//
openModal({
type: 'error',
title: t('apiScenario.deleteCsvConfirm', { name: record.name }),
content: t('apiScenario.deleteCsvConfirmContent'),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
onBeforeOk: async () => {
deleteCall();
Message.success(t('apiScenario.deleteCsvSuccess'));
},
hideCancel: false,
});
} else {
deleteCall();
}
}
function typeChangeIntercept(record: CsvVariable, doChange: () => void) {
if (!!record.name && !!record.file.fileId) {
//
record.scope = record.scope === 'SCENARIO' ? 'STEP' : 'SCENARIO'; //
openModal({
type: 'warning',
title: t('apiScenario.changeScopeConfirm', {
type: record.scope === 'SCENARIO' ? t('apiScenario.step') : t('apiScenario.scenario'),
}),
content:
record.scope === 'SCENARIO'
? t('apiScenario.changeScopeToStepConfirmContent')
: t('apiScenario.changeScopeToScenarioConfirmContent'),
okText: t('apiScenario.confirmChange'),
cancelText: t('common.cancel'),
onBeforeOk: async () => {
record.scope = record.scope === 'SCENARIO' ? 'STEP' : 'SCENARIO';
doChange();
Message.success(t('apiScenario.changeScopeSuccess'));
},
hideCancel: false,
});
} else {
doChange();
}
}
function enableChangeIntercept(record: CsvVariable, val: string | number | boolean) {
if (val) {
if (!record.name) {
Message.warning(t('apiScenario.csvNameNotNull'));
return false;
}
if (!record.file.fileId) {
Message.warning(t('apiScenario.csvFileNotNull'));
return false;
}
}
return true;
}
</script>
<style lang="less">
.scenario-csv-trigger {
@apply bg-white;
.scenario-csv-trigger-content {
padding: 16px;
width: 400px;
border-radius: var(--border-radius-medium);
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 10%), 0 8px 10px 1px rgb(0 0 0 / 6%), 0 3px 14px 2px rgb(0 0 0 / 5%);
&::before {
@apply absolute left-0 top-0;
content: '';
z-index: -1;
width: 200%;
height: 200%;
border: 1px solid var(--color-text-input-border);
border-radius: 12px;
transform-origin: 0 0;
transform: scale(0.5, 0.5);
}
.scenario-csv-trigger-content-scroll {
.ms-scroll-bar();
overflow-y: auto;
margin-right: -6px;
max-height: 400px;
.scenario-csv-trigger-content-scroll-preview {
@apply w-full overflow-y-auto overflow-x-hidden break-all;
.ms-scroll-bar();
max-height: 100px;
color: var(--color-text-1);
}
}
}
}
</style>

View File

@ -0,0 +1,104 @@
<template>
<MsDrawer
v-model:visible="visible"
:width="680"
:title="t('apiScenario.quoteCsv')"
:ok-text="t('common.quote')"
:ok-disabled="propsRes.selectedKeys.size === 0 && !selectedKey"
@confirm="handleConfirm"
@close="handleClose"
>
<MsBaseTable v-bind="propsRes" v-model:selected-key="selectedKey" v-on="propsEvent">
<template #scope="{ record }">
{{ record.scope === 'scenario' ? t('apiScenario.scenario') : t('apiScenario.step') }}
</template>
<template #file="{ record }">
<a-tooltip :content="record.file?.name">
<div>{{ record.file?.name || '-' }}</div>
</a-tooltip>
</template>
</MsBaseTable>
</MsDrawer>
</template>
<script setup lang="ts">
import MsDrawer from '@/components/pure/ms-drawer/index.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import { MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import { useI18n } from '@/hooks/useI18n';
import { CsvVariable } from '@/models/apiTest/scenario';
import { defaultCsvParamItem } from '@/views/api-test/components/config';
import { filterKeyValParams } from '@/views/api-test/components/utils';
const props = defineProps<{
csvVariables: CsvVariable[];
excludeKeys?: string[];
isSingle?: boolean;
}>();
const emit = defineEmits<{
(e: 'confirm', selectedKeys: string[]): void;
}>();
const { t } = useI18n();
const visible = defineModel<boolean>('visible', {
required: true,
});
const selectedKey = ref('');
const columns: MsTableColumn = [
{
title: 'apiScenario.params.csvName',
dataIndex: 'name',
showTooltip: true,
},
{
title: 'apiScenario.params.csvScoped',
dataIndex: 'scope',
slotName: 'scope',
width: 80,
},
{
title: 'apiScenario.params.file',
dataIndex: 'file',
slotName: 'file',
},
];
const { propsRes, propsEvent } = useTable(undefined, {
columns,
scroll: { x: '100%' },
selectable: true,
showSelectorAll: false,
selectorType: props.isSingle ? 'radio' : 'checkbox',
firstColumnWidth: 44,
heightUsed: 122,
showPagination: false,
excludeKeys: new Set(props.excludeKeys || []),
});
watchEffect(() => {
propsRes.value.data = filterKeyValParams(props.csvVariables, defaultCsvParamItem).validParams.filter(
(e) => e.enable && !props.excludeKeys?.includes(e.id)
);
selectedKey.value = '';
propsRes.value.selectorType = props.isSingle ? 'radio' : 'checkbox';
});
function handleConfirm() {
emit('confirm', props.isSingle ? [selectedKey.value] : Array.from(propsRes.value.selectedKeys));
visible.value = false;
}
function handleClose() {
selectedKey.value = '';
propsRes.value.selectedKeys.clear();
}
</script>
<style lang="less" scoped></style>

View File

@ -76,6 +76,7 @@ export const defaultStepItemCommon = {
executeStatus: undefined,
isRefScenarioStep: false,
isQuoteScenarioStep: false,
csvIds: [],
};
export const defaultScenario: Scenario = {

View File

@ -30,109 +30,12 @@
@change="handleCommonVariablesChange"
@batch-add="() => (batchAddKeyValVisible = true)"
/>
<paramTable
<csvParamsTable
v-else
v-model:params="csvVariables"
:columns="csvColumns"
:default-param-item="defaultCsvParamItem"
:draggable="false"
:selectable="false"
@change="handleCsvVariablesChange"
@batch-add="() => (batchAddKeyValVisible = true)"
>
<template #operationPre="{ record }">
<a-trigger
v-model:popup-visible="record.settingVisible"
trigger="click"
position="br"
class="scenario-csv-trigger"
>
<MsButton type="text" class="!mr-0" @click="handleRecordConfig(record)">
{{ t('apiScenario.params.config') }}
</MsButton>
<template #content>
<div class="scenario-csv-trigger-content">
<div class="mb-[16px] flex items-center">
<div class="font-semibold text-[var(--color-text-1)]">{{ t('apiScenario.params.csvConfig') }}</div>
<!-- <div class="text-[var(--color-text-4)]">({{ record.key }})</div> -->
</div>
<div class="scenario-csv-trigger-content-scroll">
<a-form ref="paramFormRef" :model="paramForm" layout="vertical">
<a-form-item
field="name"
:label="t('apiScenario.params.csvName')"
:rules="[{ required: true, message: t('apiScenario.params.csvNameNotNull') }]"
asterisk-position="end"
class="mb-[16px]"
>
<a-input v-model:model-value="paramForm.name" :max-length="255"></a-input>
</a-form-item>
<a-form-item field="variableNames" :label="t('apiScenario.params.csvParamName')" class="mb-[16px]">
<a-input
v-model:model-value="paramForm.variableNames"
:placeholder="t('apiScenario.params.csvParamNamePlaceholder')"
></a-input>
</a-form-item>
<a-form-item field="encoding" :label="t('apiScenario.params.csvFileCode')" class="mb-[16px]">
<a-select
v-model:model-value="paramForm.encoding"
:options="encodingOptions"
class="w-[120px]"
></a-select>
</a-form-item>
<a-form-item field="delimiter" :label="t('apiScenario.params.csvSplitChar')" class="mb-[16px]">
<a-input
v-model:model-value="paramForm.delimiter"
:placeholder="t('common.pleaseInput')"
:max-length="64"
class="w-[120px]"
></a-input>
</a-form-item>
<a-form-item
field="ignoreFirstLine"
:label="t('apiScenario.params.csvIgnoreFirstLine')"
class="mb-[16px]"
>
<a-radio-group v-model:model-value="paramForm.ignoreFirstLine">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="random" :label="t('apiScenario.params.csvIsRandom')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.random">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="allowQuotedData" :label="t('apiScenario.params.csvQuoteAllow')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.allowQuotedData">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="recycleOnEof" :label="t('apiScenario.params.csvRecycle')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.recycleOnEof">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="stopThreadOnEof" :label="t('apiScenario.params.csvStop')" class="mb-[16px]">
<a-radio-group v-model:model-value="paramForm.stopThreadOnEof">
<a-radio :value="false">False</a-radio>
<a-radio :value="true">True</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</div>
<div class="flex items-center justify-end gap-[8px]">
<a-button type="secondary" @click="cancelConfig">{{ t('common.cancel') }}</a-button>
<a-button type="primary" @click="applyConfig">{{ t('ms.paramsInput.apply') }}</a-button>
</div>
</div>
</template>
</a-trigger>
</template>
</paramTable>
v-model:csvVariables="csvVariables"
:scenario-id="props.scenarioId"
@change="() => emit('change')"
/>
<batchAddKeyVal
v-model:visible="batchAddKeyValVisible"
:params="commonVariables"
@ -144,18 +47,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { FormInstance } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue';
import csvParamsTable from './common/csvParamsTable.vue';
import batchAddKeyVal from '@/views/api-test/components/batchAddKeyVal.vue';
import paramTable, { ParamTableColumn } from '@/views/api-test/components/paramTable.vue';
import { CommonVariable, CsvVariable } from '@/models/apiTest/scenario';
import { defaultCsvParamItem, defaultNormalParamItem } from '@/views/api-test/components/config';
import { defaultNormalParamItem } from '@/views/api-test/components/config';
import { filterKeyValParams } from '@/views/api-test/components/utils';
const props = defineProps<{
scenarioId?: string | number;
}>();
const emit = defineEmits<{
(e: 'change'): void; //
}>();
@ -168,6 +72,7 @@
const csvVariables = defineModel<CsvVariable[]>('csvVariables', {
required: true,
});
const searchValue = ref('');
const firstSearch = ref(true);
const backupParams = ref(commonVariables.value);
@ -235,14 +140,6 @@
}
}
function handleCsvVariablesChange(resultArr: any[], isInit?: boolean) {
csvVariables.value = [...resultArr];
if (!isInit) {
emit('change');
firstSearch.value = true;
}
}
//
function handleSearch() {
if (firstSearch.value) {
@ -271,135 +168,6 @@
}
emit('change');
}
const csvColumns: ParamTableColumn[] = [
{
title: 'apiScenario.params.csvName',
dataIndex: 'name',
slotName: 'name',
needValidRepeat: true,
},
{
title: 'apiScenario.params.csvScoped',
dataIndex: 'scope',
slotName: 'scope',
typeOptions: [
{
label: t('apiScenario.scenario'),
value: 'SCENARIO',
},
{
label: t('apiScenario.step'),
value: 'STEP',
},
],
width: 80,
titleSlotName: 'typeTitle',
typeTitleTooltip: [t('apiScenario.params.csvScopedTip1'), t('apiScenario.params.csvScopedTip2')],
},
{
title: 'apiScenario.params.file',
dataIndex: 'file',
slotName: 'file',
},
{
title: 'apiScenario.table.columns.status',
dataIndex: 'enable',
slotName: 'enable',
},
{
title: '',
slotName: 'operation',
dataIndex: 'operation',
width: 90,
},
];
const paramFormRef = ref<FormInstance>();
const paramForm = ref<CsvVariable>(cloneDeep(defaultCsvParamItem));
const encodingOptions = [
{
label: 'UTF-8',
value: 'UTF-8',
},
{
label: 'UTF-16',
value: 'UTF-16',
},
{
label: 'GBK',
value: 'GBK',
},
{
label: 'ISO-8859-15',
value: 'ISO-8859-15',
},
{
label: 'US-ASCII',
value: 'US-ASCII',
},
];
function handleRecordConfig(record: CsvVariable) {
paramForm.value = cloneDeep(record);
}
function cancelConfig() {
paramFormRef.value?.resetFields();
}
function applyConfig() {
paramFormRef.value?.validate((errors) => {
if (!errors) {
csvVariables.value = csvVariables.value.map((e) => {
if (e.id === paramForm.value.id) {
return {
...paramForm.value,
settingVisible: false,
};
}
return e;
});
emit('change');
}
});
}
</script>
<style lang="less">
.scenario-csv-trigger {
@apply bg-white;
.scenario-csv-trigger-content {
padding: 16px;
width: 400px;
border-radius: var(--border-radius-medium);
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 10%), 0 8px 10px 1px rgb(0 0 0 / 6%), 0 3px 14px 2px rgb(0 0 0 / 5%);
&::before {
@apply absolute left-0 top-0;
content: '';
z-index: -1;
width: 200%;
height: 200%;
border: 1px solid var(--color-text-input-border);
border-radius: 12px;
transform-origin: 0 0;
transform: scale(0.5, 0.5);
}
.scenario-csv-trigger-content-scroll {
.ms-scroll-bar();
overflow-y: auto;
margin-right: -6px;
max-height: 400px;
.scenario-csv-trigger-content-scroll-preview {
@apply w-full overflow-y-auto overflow-x-hidden break-all;
.ms-scroll-bar();
max-height: 100px;
color: var(--color-text-1);
}
}
}
}
</style>
<style lang="less"></style>

View File

@ -145,6 +145,7 @@ export default function useCreateActions() {
children: item.children || [],
stepType,
refType,
csvIds: [],
originProjectId: item.originProjectId,
copyFromStepId: item.copyFromStepId,
...resourceField,

View File

@ -0,0 +1,99 @@
<template>
<a-popover
v-model:popup-visible="popoverVisible"
position="bl"
:disabled="!props.step.csvIds || props.step.csvIds.length === 0"
content-class="csv-popover"
arrow-class="hidden"
:popup-offset="0"
>
<div
v-if="
!props.step.isRefScenarioStep &&
props.step.stepType === ScenarioStepType.LOOP_CONTROLLER &&
props.step.csvIds?.length
"
class="csv-tag"
>
{{ `CSV ${props.step.csvIds?.length}` }}
</div>
<template #content>
<div class="mb-[4px] font-medium text-[var(--color-text-4)]">
{{ `${t('apiScenario.csvQuote')}${props.step.csvIds?.length}` }}
</div>
<div v-for="csv of csvList" :key="csv.id" class="flex items-center justify-between px-[8px] py-[4px]">
<a-tooltip :content="csv.name">
<div class="one-line-text w-[142px] text-[var(--color-text-1)]">
{{ csv.name }}
</div>
</a-tooltip>
<div class="flex items-center">
<MsButton type="text" size="mini" class="!mr-0" @click="() => emit('replace', csv.id)">
{{ t('common.replace') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
<MsButton type="text" size="mini" class="!mr-0" @click="() => emit('remove', csv.id)">
{{ t('common.remove') }}
</MsButton>
</div>
</div>
</template>
</a-popover>
</template>
<script setup lang="ts">
import MsButton from '@/components/pure/ms-button/index.vue';
import { useI18n } from '@/hooks/useI18n';
import { CsvVariable, ScenarioStepItem } from '@/models/apiTest/scenario';
import { ScenarioStepType } from '@/enums/apiEnum';
const props = defineProps<{
step: ScenarioStepItem;
csvVariables: CsvVariable[];
}>();
const emit = defineEmits<{
(e: 'replace', id?: string): void;
(e: 'remove', id?: string): void;
}>();
const { t } = useI18n();
const popoverVisible = ref(false);
const csvList = computed(() => {
if (props.step.csvIds) {
return props.step.csvIds
.map((id) => props.csvVariables.find((csv) => csv.id === id))
.filter((e) => e !== undefined) as CsvVariable[];
}
return [];
});
</script>
<style lang="less">
.csv-popover {
padding: 6px;
.arco-popover-content {
margin-top: 0;
}
}
</style>
<style lang="less" scoped>
.csv-tag {
@apply cursor-pointer bg-transparent;
padding: 0 4px;
font-size: 12px;
border: 1px solid var(--color-text-input-border);
border-radius: var(--border-radius-small);
color: var(--color-text-4);
line-height: 18px;
&:hover {
border-color: rgb(var(--primary-5));
color: rgb(var(--primary-5));
}
}
</style>

View File

@ -95,6 +95,12 @@
@change="handleStepContentChange($event, step)"
@click.stop
/>
<csvTag
:step="step"
:csv-variables="scenario.scenarioConfig.variable.csvVariables"
@remove="(id) => removeCsv(step, id)"
@replace="(id) => replaceCsv(step, id)"
/>
<!-- 自定义请求APICASE场景步骤名称 -->
<template v-if="checkStepIsApi(step)">
<apiMethodName v-if="checkStepShowMethod(step)" :method="step.config.method" />
@ -176,9 +182,9 @@
v-model:steps="steps"
v-permission="['PROJECT_API_DEBUG:READ+ADD', 'PROJECT_API_DEFINITION:READ+UPDATE']"
:step="step"
@click="setFocusNodeKey(step.uniqueId)"
@click="() => setFocusNodeKey(step.uniqueId)"
@other-create="handleOtherCreate"
@close="setFocusNodeKey('')"
@close="() => setFocusNodeKey('')"
@add-done="handleAddStepDone"
/>
</template>
@ -223,9 +229,9 @@
:permission-map="permissionMap"
:steps="steps"
@add-step="addCustomApiStep"
@delete-step="deleteStep(activeStep)"
@delete-step="() => deleteStep(activeStep)"
@apply-step="applyApiStep"
@stop-debug="handleStopExecute(activeStep)"
@stop-debug="() => handleStopExecute(activeStep)"
@execute="handleApiExecute"
@replace="handleReplaceStep"
/>
@ -239,8 +245,8 @@
:step-responses="scenario.stepResponses"
:permission-map="permissionMap"
@apply-step="applyApiStep"
@delete-step="deleteStep(activeStep)"
@stop-debug="handleStopExecute(activeStep)"
@delete-step="() => deleteStep(activeStep)"
@stop-debug="() => handleStopExecute(activeStep)"
@execute="(request, executeType) => handleApiExecute((request as unknown as RequestParam), executeType)"
@replace="handleReplaceStep"
/>
@ -439,6 +445,13 @@
</a-form-item>
</a-form>
</a-modal>
<quoteCsvDrawer
v-model:visible="csvDrawerVisible"
:csv-variables="scenario.scenarioConfig.variable.csvVariables"
:exclude-keys="activeStep?.csvIds"
:is-single="!!replaceCsvId"
@confirm="handleQuoteCsvConfirm"
/>
</div>
</template>
@ -451,12 +464,14 @@
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTagsInput from '@/components/pure/ms-tags-input/index.vue';
import MsTree from '@/components/business/ms-tree/index.vue';
import { MsTreeExpandedData, MsTreeNodeData } from '@/components/business/ms-tree/types';
import { MsTreeNodeData } from '@/components/business/ms-tree/types';
import { ImportData } from '../common/importApiDrawer/index.vue';
import quoteCsvDrawer from '../common/quoteCsvDrawer.vue';
import stepType from '../common/stepType/stepType.vue';
import createStepActions from './createAction/createStepActions.vue';
import stepInsertStepTrigger from './createAction/stepInsertStepTrigger.vue';
import conditionContent from './stepNodeComposition/conditionContent.vue';
import csvTag from './stepNodeComposition/csvTag.vue';
import loopControlContent from './stepNodeComposition/loopContent.vue';
import quoteContent from './stepNodeComposition/quoteContent.vue';
import waitTimeContent from './stepNodeComposition/waitTimeContent.vue';
@ -464,37 +479,23 @@
import { RequestParam as CaseRequestParam } from '@/views/api-test/components/requestComposition/index.vue';
import saveAsApiModal from '@/views/api-test/components/saveAsApiModal.vue';
import { localExecuteApiDebug } from '@/api/modules/api-test/common';
import { addCase, getDefinitionDetail } from '@/api/modules/api-test/management';
import { debugScenario, getScenarioDetail, getScenarioStep } from '@/api/modules/api-test/scenario';
import { getSocket } from '@/api/modules/project-management/commonScript';
import { getScenarioDetail, getScenarioStep } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import {
deleteNode,
findNodeByKey,
getGenerateId,
handleTreeDragDrop,
insertNodes,
mapTree,
traverseTree,
TreeNode,
} from '@/utils';
import { deleteNode, findNodeByKey, getGenerateId, insertNodes, mapTree, TreeNode } from '@/utils';
import {
ExecuteApiRequestFullParams,
ExecuteConditionProcessor,
ExecutePluginRequestParams,
RequestResult,
} from '@/models/apiTest/common';
import { AddApiCaseParams } from '@/models/apiTest/management';
import {
ApiScenarioDebugRequest,
CreateStepAction,
Scenario,
ScenarioStepConfig,
ScenarioStepDetail,
ScenarioStepDetails,
ScenarioStepFileParams,
ScenarioStepItem,
@ -508,8 +509,10 @@
} from '@/enums/apiEnum';
import type { RequestParam } from '../common/customApiDrawer.vue';
import updateStepStatus from '../utils';
import useCreateActions from './createAction/useCreateActions';
import useStepExecute from './useStepExecute';
import useStepNodeEdit from './useStepNodeEdit';
import useStepOperation from './useStepOperation';
import { casePriorityOptions, caseStatusOptions } from '@/views/api-test/components/config';
import { parseRequestBodyFiles } from '@/views/api-test/components/utils';
import getStepType from '@/views/api-test/scenario/components/common/stepType/utils';
@ -564,6 +567,15 @@
const activeStep = ref<ScenarioStepItem>(); //
const activeStepByCreate = ref<ScenarioStepItem | undefined>(); //
const { executeStep, handleApiExecute, handleStopExecute } = useStepExecute({
scenario,
steps,
stepDetails,
activeStep,
isPriorityLocalExec,
localExecuteUrl,
});
function setFocusNodeKey(id: string | number) {
focusStepKey.value = id || '';
}
@ -705,6 +717,14 @@
},
];
}
if ((node as ScenarioStepItem).stepType === ScenarioStepType.LOOP_CONTROLLER) {
const arr = [...stepMoreActions];
arr.splice(1, 0, {
label: 'apiScenario.quoteCsv',
eventTag: 'quoteCsv',
});
return arr;
}
return stepMoreActions;
}
@ -938,6 +958,33 @@
});
}
const csvDrawerVisible = ref(false);
const replaceCsvId = ref('');
function removeCsv(step: ScenarioStepItem, id?: string) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (id && realStep) {
realStep.csvIds = realStep.csvIds.filter((item: string) => item !== id);
}
}
function replaceCsv(step: ScenarioStepItem, id?: string) {
csvDrawerVisible.value = true;
activeStep.value = step;
replaceCsvId.value = id || '';
}
function handleQuoteCsvConfirm(keys: string[]) {
if (activeStep.value) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, activeStep.value.uniqueId, 'uniqueId');
if (replaceCsvId.value && realStep) {
const index = realStep.csvIds.findIndex((item: string) => item === replaceCsvId.value);
realStep.csvIds?.splice(index, 1, keys[0]);
} else if (realStep) {
realStep.csvIds?.push(...keys);
}
}
}
function handleStepMoreActionSelect(item: ActionsItem, node: MsTreeNodeData) {
switch (item.eventTag) {
case 'copy':
@ -1048,6 +1095,11 @@
activeStep.value = node as ScenarioStepItem;
saveCaseModalVisible.value = true;
break;
case 'quoteCsv':
activeStep.value = node as ScenarioStepItem;
replaceCsvId.value = '';
csvDrawerVisible.value = true;
break;
default:
break;
}
@ -1062,87 +1114,11 @@
*/
const showStepNameEditInputStepId = ref<string | number>('');
const tempStepName = ref('');
function handleStepNameClick(step: ScenarioStepItem) {
tempStepName.value = step.name;
showStepNameEditInputStepId.value = step.uniqueId;
nextTick(() => {
//
const input = treeRef.value?.$el.querySelector('.name-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.draggable = false; //
}
}
function applyStepNameChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.name = tempStepName.value || realStep.name;
realStep.draggable = true; //
}
showStepNameEditInputStepId.value = '';
scenario.value.unSaved = !!tempStepName.value;
}
/**
* 处理非 apicase场景步骤名称编辑
*/
const showStepDescEditInputStepId = ref<string | number>('');
const tempStepDesc = ref('');
function handleStepDescClick(step: ScenarioStepItem) {
tempStepDesc.value = step.name;
showStepDescEditInputStepId.value = step.uniqueId;
nextTick(() => {
//
const input = treeRef.value?.$el.querySelector('.desc-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.draggable = false; //
}
}
function applyStepDescChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.name = tempStepDesc.value || realStep.name;
realStep.draggable = true; //
}
showStepDescEditInputStepId.value = '';
scenario.value.unSaved = !!tempStepDesc.value;
}
function handleStepContentChange($event: Record<string, any>, step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
Object.keys($event).forEach((key) => {
realStep.config[key] = $event[key];
});
scenario.value.unSaved = true;
}
}
/**
* 处理步骤展开折叠
*/
function handleStepExpand(data: MsTreeExpandedData) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, data.node?.uniqueId, 'uniqueId');
if (realStep) {
realStep.expanded = !realStep.expanded;
}
}
function handleStepToggleEnable(data: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, data.uniqueId, 'uniqueId');
if (realStep) {
realStep.enable = !realStep.enable;
scenario.value.unSaved = true;
}
}
const importApiDrawerVisible = ref(false);
const customCaseDrawerVisible = ref(false);
const customApiDrawerVisible = ref(false);
@ -1167,240 +1143,17 @@
scenario.value.unSaved = true;
}
/**
* 处理步骤选中事件
* @param _selectedKeys 选中的 key集合
* @param step 点击的步骤节点
*/
async function handleStepSelect(_selectedKeys: Array<string | number>, step: ScenarioStepItem) {
const _stepType = getStepType(step);
const offspringIds: string[] = [];
mapTree(step.children || [], (e) => {
offspringIds.push(e.uniqueId);
return e;
});
selectedKeys.value = [step.uniqueId, ...offspringIds];
if (_stepType.isCopyApi || _stepType.isQuoteApi || step.stepType === ScenarioStepType.CUSTOM_REQUEST) {
// api api api
activeStep.value = step;
if (
(stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew)
) {
//
await getStepDetail(step);
}
customApiDrawerVisible.value = true;
} else if (step.stepType === ScenarioStepType.API_CASE) {
activeStep.value = step;
if (
_stepType.isCopyCase &&
((stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew))
) {
// case
//
await getStepDetail(step);
}
customCaseDrawerVisible.value = true;
} else if (step.stepType === ScenarioStepType.SCRIPT) {
activeStep.value = step;
if (
(stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew)
) {
//
await getStepDetail(step);
}
scriptOperationDrawerVisible.value = true;
}
}
const websocketMap: Record<string | number, WebSocket> = {};
/**
* 开启websocket监听接收执行结果
*/
function debugSocket(step: ScenarioStepItem, _scenario: Scenario, reportId: string | number) {
websocketMap[reportId] = getSocket(
reportId || '',
scenario.value.executeType === 'localExec' ? '/ws/debug' : '',
scenario.value.executeType === 'localExec' ? localExecuteUrl?.value : ''
);
websocketMap[reportId].addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.msgType === 'EXEC_RESULT') {
if (step.reportId === data.reportId) {
// tabtab
data.taskResult.requestResults.forEach((result: RequestResult) => {
if (_scenario.stepResponses[result.stepId] === undefined) {
_scenario.stepResponses[result.stepId] = [];
}
_scenario.stepResponses[result.stepId].push({
...result,
console: data.taskResult.console,
});
});
}
} else if (data.msgType === 'EXEC_END') {
// websocket
websocketMap[reportId]?.close();
if (step.reportId === data.reportId) {
step.isExecuting = false;
updateStepStatus([step], _scenario.stepResponses, step.uniqueId);
}
}
});
}
async function realExecute(
executeParams: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId' | 'stepFileParam'>
) {
const [currentStep] = executeParams.steps;
try {
currentStep.isExecuting = true;
currentStep.executeStatus = ScenarioExecuteStatus.EXECUTING;
debugSocket(currentStep, scenario.value, executeParams.reportId); // websocket
const res = await debugScenario({
id: scenario.value.id || '',
grouped: false,
environmentId: appStore.currentEnvConfig?.id || '',
projectId: appStore.currentProjectId,
scenarioConfig: scenario.value.scenarioConfig,
frontendDebug: scenario.value.executeType === 'localExec',
...executeParams,
steps: mapTree(executeParams.steps, (node) => {
return {
...node,
enable: node.uniqueId === currentStep.uniqueId || node.enable, //
parent: null, // axios
};
}),
});
if (scenario.value.executeType === 'localExec' && localExecuteUrl?.value) {
await localExecuteApiDebug(localExecuteUrl.value, res);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
websocketMap[executeParams.reportId].close();
currentStep.isExecuting = false;
updateStepStatus([currentStep], scenario.value.stepResponses, currentStep.uniqueId);
}
}
/**
* 单个步骤执行调试
*/
function executeStep(node: MsTreeNodeData) {
if (node.isExecuting) {
return;
}
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, node.uniqueId, 'uniqueId');
if (realStep) {
realStep.reportId = getGenerateId();
const _stepDetails: Record<string, any> = {};
const stepFileParam = scenario.value.stepFileParam[realStep.id];
traverseTree(
realStep,
(step) => {
if (step.enable || step.uniqueId === realStep.uniqueId) {
//
_stepDetails[step.id] = stepDetails.value[step.id];
step.executeStatus = ScenarioExecuteStatus.EXECUTING;
} else {
step.executeStatus = undefined;
}
delete scenario.value.stepResponses[step.uniqueId]; //
},
(step) => {
//
return step.enable || step.uniqueId === realStep.uniqueId;
}
);
scenario.value.executeType = isPriorityLocalExec?.value ? 'localExec' : 'serverExec';
realExecute({
steps: [realStep as ScenarioStepItem],
stepDetails: _stepDetails,
reportId: realStep.reportId,
stepFileParam: {
[realStep.id]: stepFileParam,
},
});
}
}
/**
* 处理 api 详情抽屉的执行动作
* @param request 抽屉内的请求参数
* @param executeType 执行类型
*/
function handleApiExecute(request: RequestParam, executeType?: 'localExec' | 'serverExec') {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, request.stepId, 'uniqueId');
if (realStep) {
delete scenario.value.stepResponses[realStep.uniqueId]; //
realStep.reportId = getGenerateId();
realStep.executeStatus = ScenarioExecuteStatus.EXECUTING;
request.executeLoading = true;
scenario.value.executeType = executeType;
realExecute({
steps: [realStep as ScenarioStepItem],
stepDetails: {
[realStep.id]: request,
},
reportId: realStep.reportId,
stepFileParam: {
[realStep.uniqueId]: {
uploadFileIds: request.uploadFileIds || [],
linkFileIds: request.linkFileIds || [],
},
},
});
} else {
//
delete scenario.value.stepResponses[request.stepId]; //
const reportId = getGenerateId();
request.executeLoading = true;
activeStep.value = {
id: request.stepId,
name: t('apiScenario.customApi'),
stepType: ScenarioStepType.CUSTOM_REQUEST,
refType: ScenarioStepRefType.DIRECT,
sort: 1,
enable: true,
isNew: true,
config: {},
projectId: appStore.currentProjectId,
isExecuting: false,
reportId,
uniqueId: request.stepId,
};
realExecute({
steps: [activeStep.value],
stepDetails: {
[request.stepId]: request,
},
reportId,
stepFileParam: {
[request.stepId]: {
uploadFileIds: request.uploadFileIds || [],
linkFileIds: request.linkFileIds || [],
},
},
});
}
}
async function handleStopExecute(step?: ScenarioStepItem) {
if (step?.reportId) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
websocketMap[step.reportId].close();
if (realStep) {
realStep.isExecuting = false;
updateStepStatus([realStep as ScenarioStepItem], scenario.value.stepResponses, realStep.uniqueId);
}
}
}
const { handleStepExpand, handleStepSelect, deleteStep, handleDrop } = useStepOperation({
scenario,
steps,
stepDetails,
activeStep,
selectedKeys,
customApiDrawerVisible,
customCaseDrawerVisible,
scriptOperationDrawerVisible,
loading,
});
function handleReplaceStep(newStep: ScenarioStepItem) {
if (activeStep.value) {
@ -1616,34 +1369,6 @@
}
}
/**
* 删除
*/
function deleteStep(step?: ScenarioStepItem) {
if (step) {
openModal({
type: 'error',
title: t('common.tip'),
content: t('apiScenario.deleteStepConfirm', { name: step.name }),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
customCaseDrawerVisible.value = false;
customApiDrawerVisible.value = false;
deleteNode(steps.value, step.uniqueId, 'uniqueId');
activeStep.value = undefined;
scenario.value.unSaved = true;
Message.success(t('common.deleteSuccess'));
},
hideCancel: false,
});
}
}
/**
* 添加脚本操作步骤
*/
@ -1693,126 +1418,33 @@
}
}
/**
* 释放允许拖拽步骤到释放的节点内
* @param dropNode 释放节点
*/
function isAllowDropInside(dropNode: MsTreeNodeData) {
return (
//
[
ScenarioStepType.LOOP_CONTROLLER,
ScenarioStepType.IF_CONTROLLER,
ScenarioStepType.ONCE_ONLY_CONTROLLER,
].includes(dropNode.stepType) ||
//
(dropNode.stepType === ScenarioStepType.API_SCENARIO && dropNode.refType === ScenarioStepRefType.COPY)
);
}
/**
* 处理步骤节点拖拽事件
* @param tree 树数据
* @param dragNode 拖拽节点
* @param dropNode 释放节点
* @param dropPosition 释放位置取值-1,0,1 -1dropNodeId节点之前 0:dropNodeId节点内 1dropNodeId节点后
*/
function handleDrop(
tree: MsTreeNodeData[],
dragNode: MsTreeNodeData,
dropNode: MsTreeNodeData,
dropPosition: number
) {
try {
if (dropPosition === 0 && !isAllowDropInside(dropNode)) {
// Message.error(t('apiScenario.notAllowDropInside')); TODO:
return;
}
loading.value = true;
const offspringIds: string[] = [];
mapTree(cloneDeep(dragNode.children || []), (e) => {
offspringIds.push(e.uniqueId);
return e;
});
const stepIdAndOffspringIds = [dragNode.uniqueId, ...offspringIds];
if (dropPosition === 0) {
//
if (selectedKeys.value.includes(dropNode.uniqueId)) {
//
selectedKeys.value = selectedKeys.value.concat(stepIdAndOffspringIds);
}
} else if (dropNode.parent && selectedKeys.value.includes(dropNode.parent.uniqueId)) {
//
selectedKeys.value = selectedKeys.value.concat(stepIdAndOffspringIds);
} else if (dragNode.parent && selectedKeys.value.includes(dragNode.parent.uniqueId)) {
//
selectedKeys.value = selectedKeys.value.filter((e) => {
for (let i = 0; i < stepIdAndOffspringIds.length; i++) {
const id = stepIdAndOffspringIds[i];
if (e === id) {
stepIdAndOffspringIds.splice(i, 1);
return false;
}
}
return true;
});
}
const dragResult = handleTreeDragDrop(steps.value, dragNode, dropNode, dropPosition, 'uniqueId');
if (dragResult) {
Message.success(t('common.moveSuccess'));
scenario.value.unSaved = true;
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
nextTick(() => {
loading.value = false;
});
}
}
const showQuickInput = ref(false);
const quickInputParamValue = ref<any>('');
const quickInputDataKey = ref('');
function setQuickInput(step: ScenarioStepItem, dataKey: keyof ScenarioStepDetail) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
activeStep.value = realStep as ScenarioStepItem;
}
quickInputDataKey.value = dataKey;
quickInputParamValue.value = step.config?.[dataKey] || '';
if (quickInputDataKey.value === 'msWhileVariableValue' && activeStep.value?.config.whileController) {
quickInputParamValue.value = activeStep.value.config.whileController.msWhileVariable.value;
} else if (quickInputDataKey.value === 'msWhileVariableScriptValue' && activeStep.value?.config.whileController) {
quickInputParamValue.value = activeStep.value.config.whileController.msWhileScript.scriptValue;
} else if (quickInputDataKey.value === 'conditionValue' && activeStep.value?.config) {
quickInputParamValue.value = activeStep.value.config.value || '';
}
showQuickInput.value = true;
}
function clearQuickInput() {
activeStep.value = undefined;
quickInputParamValue.value = '';
quickInputDataKey.value = '';
}
function applyQuickInput() {
if (activeStep.value) {
if (quickInputDataKey.value === 'msWhileVariableValue' && activeStep.value.config.whileController) {
activeStep.value.config.whileController.msWhileVariable.value = quickInputParamValue.value;
} else if (quickInputDataKey.value === 'msWhileVariableScriptValue' && activeStep.value.config.whileController) {
activeStep.value.config.whileController.msWhileScript.scriptValue = quickInputParamValue.value;
} else if (quickInputDataKey.value === 'conditionValue' && activeStep.value.config) {
activeStep.value.config.value = quickInputParamValue.value;
}
showQuickInput.value = false;
clearQuickInput();
scenario.value.unSaved = true;
}
}
const {
setQuickInput,
clearQuickInput,
applyQuickInput,
handleStepDescClick,
applyStepDescChange,
handleStepContentChange,
handleStepToggleEnable,
handleStepNameClick,
applyStepNameChange,
} = useStepNodeEdit({
steps,
scenario,
activeStep,
quickInputDataKey,
quickInputParamValue,
showQuickInput,
treeRef,
tempStepDesc,
showStepDescEditInputStepId,
tempStepName,
showStepNameEditInputStepId,
});
const dbClick = ref({
e: null as MouseEvent | null,
@ -1937,8 +1569,6 @@
}
.ms-tree-node-extra {
@apply !visible !w-auto;
margin-right: 24px;
}
}
.ms-form {

View File

@ -0,0 +1,227 @@
import type { MsTreeNodeData } from '@/components/business/ms-tree/types';
import { localExecuteApiDebug } from '@/api/modules/api-test/common';
import { debugScenario } from '@/api/modules/api-test/scenario';
import { getSocket } from '@/api/modules/project-management/commonScript';
import { t } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { findNodeByKey, getGenerateId, mapTree, traverseTree } from '@/utils';
import type { RequestResult } from '@/models/apiTest/common';
import type { ApiScenarioDebugRequest, Scenario, ScenarioStepItem } from '@/models/apiTest/scenario';
import { ScenarioExecuteStatus, ScenarioStepRefType, ScenarioStepType } from '@/enums/apiEnum';
import type { RequestParam } from '../common/customApiDrawer.vue';
import updateStepStatus from '../utils';
/**
*
*/
export default function useStepExecute({
scenario,
steps,
stepDetails,
activeStep,
isPriorityLocalExec,
localExecuteUrl,
}: {
scenario: Ref<Scenario>;
steps: Ref<ScenarioStepItem[]>;
stepDetails: Ref<Record<string, any>>;
activeStep: Ref<ScenarioStepItem | undefined>;
isPriorityLocalExec: Ref<boolean> | undefined;
localExecuteUrl: Ref<string> | undefined;
}) {
const appStore = useAppStore();
const websocketMap: Record<string | number, WebSocket> = {};
/**
* websocket监听
*/
function debugSocket(step: ScenarioStepItem, _scenario: Scenario, reportId: string | number) {
websocketMap[reportId] = getSocket(
reportId || '',
scenario.value.executeType === 'localExec' ? '/ws/debug' : '',
scenario.value.executeType === 'localExec' ? localExecuteUrl?.value : ''
);
websocketMap[reportId].addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.msgType === 'EXEC_RESULT') {
if (step.reportId === data.reportId) {
// 判断当前查看的tab是否是当前返回的报告的tab是的话直接赋值
data.taskResult.requestResults.forEach((result: RequestResult) => {
if (_scenario.stepResponses[result.stepId] === undefined) {
_scenario.stepResponses[result.stepId] = [];
}
_scenario.stepResponses[result.stepId].push({
...result,
console: data.taskResult.console,
});
});
}
} else if (data.msgType === 'EXEC_END') {
// 执行结束关闭websocket
websocketMap[reportId]?.close();
if (step.reportId === data.reportId) {
step.isExecuting = false;
updateStepStatus([step], _scenario.stepResponses, step.uniqueId);
}
}
});
}
async function realExecute(
executeParams: Pick<ApiScenarioDebugRequest, 'steps' | 'stepDetails' | 'reportId' | 'stepFileParam'>
) {
const [currentStep] = executeParams.steps;
try {
currentStep.isExecuting = true;
currentStep.executeStatus = ScenarioExecuteStatus.EXECUTING;
debugSocket(currentStep, scenario.value, executeParams.reportId); // 开启websocket
const res = await debugScenario({
id: scenario.value.id || '',
grouped: false,
environmentId: appStore.currentEnvConfig?.id || '',
projectId: appStore.currentProjectId,
scenarioConfig: scenario.value.scenarioConfig,
frontendDebug: scenario.value.executeType === 'localExec',
...executeParams,
steps: mapTree(executeParams.steps, (node) => {
return {
...node,
enable: node.uniqueId === currentStep.uniqueId || node.enable, // 单步骤执行,则临时无视顶层启用禁用状态
parent: null, // 原树形结构存在循环引用,这里要去掉以免 axios 序列化失败
};
}),
});
if (scenario.value.executeType === 'localExec' && localExecuteUrl?.value) {
await localExecuteApiDebug(localExecuteUrl.value, res);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
websocketMap[executeParams.reportId].close();
currentStep.isExecuting = false;
updateStepStatus([currentStep], scenario.value.stepResponses, currentStep.uniqueId);
}
}
/**
*
*/
function executeStep(node: MsTreeNodeData) {
if (node.isExecuting) {
return;
}
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, node.uniqueId, 'uniqueId');
if (realStep) {
realStep.reportId = getGenerateId();
const _stepDetails: Record<string, any> = {};
const stepFileParam = scenario.value.stepFileParam[realStep.id];
traverseTree(
realStep,
(step) => {
if (step.enable || step.uniqueId === realStep.uniqueId) {
// 启用的步骤才执行;如果点击的是禁用步骤也执行,但是禁用的子步骤不执行
_stepDetails[step.id] = stepDetails.value[step.id];
step.executeStatus = ScenarioExecuteStatus.EXECUTING;
} else {
step.executeStatus = undefined;
}
delete scenario.value.stepResponses[step.uniqueId]; // 先移除上一次的执行结果
},
(step) => {
// 当前步骤是启用的情或是在禁用的步骤上点击执行,才需要继续递归子孙步骤;否则无需向下递归
return step.enable || step.uniqueId === realStep.uniqueId;
}
);
scenario.value.executeType = isPriorityLocalExec?.value ? 'localExec' : 'serverExec';
realExecute({
steps: [realStep as ScenarioStepItem],
stepDetails: _stepDetails,
reportId: realStep.reportId,
stepFileParam: {
[realStep.id]: stepFileParam,
},
});
}
}
/**
* api
* @param request
* @param executeType
*/
function handleApiExecute(request: RequestParam, executeType?: 'localExec' | 'serverExec') {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, request.stepId, 'uniqueId');
if (realStep) {
delete scenario.value.stepResponses[realStep.uniqueId]; // 先移除上一次的执行结果
realStep.reportId = getGenerateId();
realStep.executeStatus = ScenarioExecuteStatus.EXECUTING;
request.executeLoading = true;
scenario.value.executeType = executeType;
realExecute({
steps: [realStep as ScenarioStepItem],
stepDetails: {
[realStep.id]: request,
},
reportId: realStep.reportId,
stepFileParam: {
[realStep.uniqueId]: {
uploadFileIds: request.uploadFileIds || [],
linkFileIds: request.linkFileIds || [],
},
},
});
} else {
// 步骤列表找不到该步骤,说明是新建的自定义请求还未保存,则临时创建一个步骤进行调试(不保存步骤信息)
delete scenario.value.stepResponses[request.stepId]; // 先移除上一次的执行结果
const reportId = getGenerateId();
request.executeLoading = true;
activeStep.value = {
id: request.stepId,
name: t('apiScenario.customApi'),
stepType: ScenarioStepType.CUSTOM_REQUEST,
refType: ScenarioStepRefType.DIRECT,
sort: 1,
enable: true,
isNew: true,
config: {},
projectId: appStore.currentProjectId,
isExecuting: false,
reportId,
uniqueId: request.stepId,
};
realExecute({
steps: [activeStep.value],
stepDetails: {
[request.stepId]: request,
},
reportId,
stepFileParam: {
[request.stepId]: {
uploadFileIds: request.uploadFileIds || [],
linkFileIds: request.linkFileIds || [],
},
},
});
}
}
async function handleStopExecute(step?: ScenarioStepItem) {
if (step?.reportId) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
websocketMap[step.reportId].close();
if (realStep) {
realStep.isExecuting = false;
updateStepStatus([realStep as ScenarioStepItem], scenario.value.stepResponses, realStep.uniqueId);
}
}
}
return {
executeStep,
handleApiExecute,
handleStopExecute,
};
}

View File

@ -0,0 +1,176 @@
import MsTree from '@/components/business/ms-tree/index.vue';
import { findNodeByKey } from '@/utils';
import type { Scenario, ScenarioStepDetail, ScenarioStepItem } from '@/models/apiTest/scenario';
/**
*
*/
export default function useStepNodeEdit({
steps,
scenario,
activeStep,
quickInputDataKey,
quickInputParamValue,
showQuickInput,
treeRef,
tempStepDesc,
showStepDescEditInputStepId,
tempStepName,
showStepNameEditInputStepId,
}: {
steps: Ref<ScenarioStepItem[]>;
scenario: Ref<Scenario>;
activeStep: Ref<ScenarioStepItem | undefined>;
quickInputDataKey: Ref<string>;
quickInputParamValue: Ref<any>;
showQuickInput: Ref<boolean>;
treeRef: Ref<InstanceType<typeof MsTree> | undefined>;
tempStepDesc: Ref<string>;
showStepDescEditInputStepId: Ref<string | number>;
tempStepName: Ref<string>;
showStepNameEditInputStepId: Ref<string | number>;
}) {
/**
*
* @param dataKey key
*/
function setQuickInput(step: ScenarioStepItem, dataKey: keyof ScenarioStepDetail) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
activeStep.value = realStep as ScenarioStepItem;
}
quickInputDataKey.value = dataKey;
quickInputParamValue.value = step.config?.[dataKey] || '';
if (quickInputDataKey.value === 'msWhileVariableValue' && activeStep.value?.config.whileController) {
quickInputParamValue.value = activeStep.value.config.whileController.msWhileVariable.value;
} else if (quickInputDataKey.value === 'msWhileVariableScriptValue' && activeStep.value?.config.whileController) {
quickInputParamValue.value = activeStep.value.config.whileController.msWhileScript.scriptValue;
} else if (quickInputDataKey.value === 'conditionValue' && activeStep.value?.config) {
quickInputParamValue.value = activeStep.value.config.value || '';
}
showQuickInput.value = true;
}
function clearQuickInput() {
activeStep.value = undefined;
quickInputParamValue.value = '';
quickInputDataKey.value = '';
}
/**
*
*/
function applyQuickInput() {
if (activeStep.value) {
if (quickInputDataKey.value === 'msWhileVariableValue' && activeStep.value.config.whileController) {
activeStep.value.config.whileController.msWhileVariable.value = quickInputParamValue.value;
} else if (quickInputDataKey.value === 'msWhileVariableScriptValue' && activeStep.value.config.whileController) {
activeStep.value.config.whileController.msWhileScript.scriptValue = quickInputParamValue.value;
} else if (quickInputDataKey.value === 'conditionValue' && activeStep.value.config) {
activeStep.value.config.value = quickInputParamValue.value;
}
showQuickInput.value = false;
clearQuickInput();
scenario.value.unSaved = true;
}
}
/**
*
*/
function handleStepDescClick(step: ScenarioStepItem) {
tempStepDesc.value = step.name;
showStepDescEditInputStepId.value = step.uniqueId;
nextTick(() => {
// 等待输入框渲染完成后聚焦
const input = treeRef.value?.$el.querySelector('.desc-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.draggable = false; // 编辑时禁止拖拽
}
}
/**
*
*/
function applyStepDescChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.name = tempStepDesc.value || realStep.name;
realStep.draggable = true; // 编辑完恢复拖拽
}
showStepDescEditInputStepId.value = '';
scenario.value.unSaved = !!tempStepDesc.value;
}
/**
*
* @param $event
*/
function handleStepContentChange($event: Record<string, any>, step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
Object.keys($event).forEach((key) => {
realStep.config[key] = $event[key];
});
scenario.value.unSaved = true;
}
}
/**
*
*/
function handleStepToggleEnable(data: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, data.uniqueId, 'uniqueId');
if (realStep) {
realStep.enable = !realStep.enable;
scenario.value.unSaved = true;
}
}
/**
*
*/
function handleStepNameClick(step: ScenarioStepItem) {
tempStepName.value = step.name;
showStepNameEditInputStepId.value = step.uniqueId;
nextTick(() => {
// 等待输入框渲染完成后聚焦
const input = treeRef.value?.$el.querySelector('.name-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.draggable = false; // 编辑时禁止拖拽
}
}
/**
*
*/
function applyStepNameChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.uniqueId, 'uniqueId');
if (realStep) {
realStep.name = tempStepName.value || realStep.name;
realStep.draggable = true; // 编辑完恢复拖拽
}
showStepNameEditInputStepId.value = '';
scenario.value.unSaved = !!tempStepName.value;
}
return {
setQuickInput,
clearQuickInput,
applyQuickInput,
handleStepDescClick,
applyStepDescChange,
handleStepContentChange,
handleStepToggleEnable,
handleStepNameClick,
applyStepNameChange,
};
}

View File

@ -0,0 +1,244 @@
import { Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import type { MsTreeExpandedData, MsTreeNodeData } from '@/components/business/ms-tree/types';
import { getScenarioStep } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { deleteNode, findNodeByKey, handleTreeDragDrop, mapTree } from '@/utils';
import type { Scenario, ScenarioStepItem } from '@/models/apiTest/scenario';
import { ScenarioStepRefType, ScenarioStepType } from '@/enums/apiEnum';
import getStepType from '../common/stepType/utils';
import { parseRequestBodyFiles } from '@/views/api-test/components/utils';
/**
*
*/
export default function useStepOperation({
scenario,
steps,
stepDetails,
activeStep,
selectedKeys,
customApiDrawerVisible,
customCaseDrawerVisible,
scriptOperationDrawerVisible,
loading,
}: {
scenario: Ref<Scenario>;
steps: Ref<ScenarioStepItem[]>;
stepDetails: Ref<Record<string, any>>;
activeStep: Ref<ScenarioStepItem | undefined>;
selectedKeys: Ref<Array<string | number>>;
customApiDrawerVisible: Ref<boolean>;
customCaseDrawerVisible: Ref<boolean>;
scriptOperationDrawerVisible: Ref<boolean>;
loading: Ref<boolean>;
}) {
const appStore = useAppStore();
const { t } = useI18n();
const { openModal } = useModal();
/**
*
*/
function handleStepExpand(data: MsTreeExpandedData) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, data.node?.uniqueId, 'uniqueId');
if (realStep) {
realStep.expanded = !realStep.expanded;
}
}
async function getStepDetail(step: ScenarioStepItem) {
try {
appStore.showLoading();
const res = await getScenarioStep(step.copyFromStepId || step.id);
let parseRequestBodyResult;
if (step.config.protocol === 'HTTP' && res.body) {
parseRequestBodyResult = parseRequestBodyFiles(res.body); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件
}
stepDetails.value[step.id] = {
...res,
stepId: step.id,
protocol: step.config.protocol || '',
method: step.config.method || '',
...parseRequestBodyResult,
};
scenario.value.stepFileParam[step.id] = {
...parseRequestBodyResult,
};
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
appStore.hideLoading();
}
}
/**
*
* @param _selectedKeys key集合
* @param step
*/
async function handleStepSelect(_selectedKeys: Array<string | number>, step: ScenarioStepItem) {
const _stepType = getStepType(step);
const offspringIds: string[] = [];
mapTree(step.children || [], (e) => {
offspringIds.push(e.uniqueId);
return e;
});
selectedKeys.value = [step.uniqueId, ...offspringIds];
if (_stepType.isCopyApi || _stepType.isQuoteApi || step.stepType === ScenarioStepType.CUSTOM_REQUEST) {
// 复制 api、引用 api、自定义 api打开抽屉
activeStep.value = step;
if (
(stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew)
) {
// 查看场景详情时,详情映射中没有对应数据,初始化步骤详情(复制的步骤没有加载详情前就被复制,打开复制后的步骤就初始化被复制步骤的详情)
await getStepDetail(step);
}
customApiDrawerVisible.value = true;
} else if (step.stepType === ScenarioStepType.API_CASE) {
activeStep.value = step;
if (
_stepType.isCopyCase &&
((stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew))
) {
// 只有复制的 case 需要查看步骤详情,引用的无法更改所以不需要在此初始化详情
// 查看场景详情时,详情映射中没有对应数据,初始化步骤详情(复制的步骤没有加载详情前就被复制,打开复制后的步骤就初始化被复制步骤的详情)
await getStepDetail(step);
}
customCaseDrawerVisible.value = true;
} else if (step.stepType === ScenarioStepType.SCRIPT) {
activeStep.value = step;
if (
(stepDetails.value[step.id] === undefined && step.copyFromStepId) ||
(stepDetails.value[step.id] === undefined && !step.isNew)
) {
// 查看场景详情时,详情映射中没有对应数据,初始化步骤详情(复制的步骤没有加载详情前就被复制,打开复制后的步骤就初始化被复制步骤的详情)
await getStepDetail(step);
}
scriptOperationDrawerVisible.value = true;
}
}
/**
*
*/
function deleteStep(step?: ScenarioStepItem) {
if (step) {
openModal({
type: 'error',
title: t('common.tip'),
content: t('apiScenario.deleteStepConfirm', { name: step.name }),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
customCaseDrawerVisible.value = false;
customApiDrawerVisible.value = false;
deleteNode(steps.value, step.uniqueId, 'uniqueId');
activeStep.value = undefined;
scenario.value.unSaved = true;
Message.success(t('common.deleteSuccess'));
},
hideCancel: false,
});
}
}
/**
*
* @param dropNode
*/
function isAllowDropInside(dropNode: MsTreeNodeData) {
return (
// 逻辑控制器内可以拖拽任意类型的步骤
[
ScenarioStepType.LOOP_CONTROLLER,
ScenarioStepType.IF_CONTROLLER,
ScenarioStepType.ONCE_ONLY_CONTROLLER,
].includes(dropNode.stepType) ||
// 复制的场景内可以释放任意类型的步骤
(dropNode.stepType === ScenarioStepType.API_SCENARIO && dropNode.refType === ScenarioStepRefType.COPY)
);
}
/**
*
* @param tree
* @param dragNode
* @param dropNode
* @param dropPosition -1,0,1 -1dropNodeId节点之前 0:dropNodeId节点内 1dropNodeId节点后
*/
function handleDrop(
tree: MsTreeNodeData[],
dragNode: MsTreeNodeData,
dropNode: MsTreeNodeData,
dropPosition: number
) {
try {
if (dropPosition === 0 && !isAllowDropInside(dropNode)) {
// Message.error(t('apiScenario.notAllowDropInside')); TODO:不允许释放提示
return;
}
loading.value = true;
const offspringIds: string[] = [];
mapTree(cloneDeep(dragNode.children || []), (e) => {
offspringIds.push(e.uniqueId);
return e;
});
const stepIdAndOffspringIds = [dragNode.uniqueId, ...offspringIds];
if (dropPosition === 0) {
// 拖拽到节点内
if (selectedKeys.value.includes(dropNode.uniqueId)) {
// 释放位置的节点已选中,则需要把拖动的节点及其子孙节点也需要选中(因为父级选中子级也会展示选中状态)
selectedKeys.value = selectedKeys.value.concat(stepIdAndOffspringIds);
}
} else if (dropNode.parent && selectedKeys.value.includes(dropNode.parent.uniqueId)) {
// 释放位置的节点的父节点已选中,则需要把拖动的节点及其子孙节点也需要选中(因为父级选中子级也会展示选中状态)
selectedKeys.value = selectedKeys.value.concat(stepIdAndOffspringIds);
} else if (dragNode.parent && selectedKeys.value.includes(dragNode.parent.uniqueId)) {
// 如果被拖动的节点的父节点在选中的节点中,则需要把被拖动的节点及其子孙节点从选中的节点中移除
selectedKeys.value = selectedKeys.value.filter((e) => {
for (let i = 0; i < stepIdAndOffspringIds.length; i++) {
const id = stepIdAndOffspringIds[i];
if (e === id) {
stepIdAndOffspringIds.splice(i, 1);
return false;
}
}
return true;
});
}
const dragResult = handleTreeDragDrop(steps.value, dragNode, dropNode, dropPosition, 'uniqueId');
if (dragResult) {
Message.success(t('common.moveSuccess'));
scenario.value.unSaved = true;
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
nextTick(() => {
loading.value = false;
});
}
}
return {
handleStepExpand,
handleStepSelect,
deleteStep,
handleDrop,
};
}

View File

@ -22,6 +22,8 @@
v-if="activeKey === ScenarioCreateComposition.PARAMS"
v-model:commonVariables="scenario.scenarioConfig.variable.commonVariables"
v-model:csvVariables="scenario.scenarioConfig.variable.csvVariables"
:scenario-id="scenario.id"
@change="() => (scenario.unSaved = true)"
/>
</a-tab-pane>
<a-tab-pane
@ -40,7 +42,7 @@
<assertion
v-if="activeKey === ScenarioCreateComposition.ASSERTION"
v-model:assertion-config="scenario.scenarioConfig.assertionConfig"
@change="scenario.unSaved = true"
@change="() => (scenario.unSaved = true)"
/>
<template #title>
<div class="flex items-center">

View File

@ -146,9 +146,14 @@
import { ScenarioExecuteStatus, ScenarioStepRefType, ScenarioStepType } from '@/enums/apiEnum';
import { ApiTestRouteEnum } from '@/enums/routeEnum';
import { defaultCsvParamItem, defaultNormalParamItem } from '../components/config';
import { defaultScenario } from './components/config';
import updateStepStatus from './components/utils';
import { filterAssertions, filterConditionsSqlValidParams } from '@/views/api-test/components/utils';
import {
filterAssertions,
filterConditionsSqlValidParams,
filterKeyValParams,
} from '@/views/api-test/components/utils';
//
const detail = defineAsyncComponent(() => import('./detail/index.vue'));
@ -510,6 +515,16 @@
preProcessorConfig: filterConditionsSqlValidParams(
activeScenarioTab.value.scenarioConfig.preProcessorConfig
),
variable: {
commonVariables: filterKeyValParams(
activeScenarioTab.value.scenarioConfig.variable.commonVariables,
defaultNormalParamItem
).validParams,
csvVariables: filterKeyValParams(
activeScenarioTab.value.scenarioConfig.variable.csvVariables,
defaultCsvParamItem
).validParams,
},
},
projectId: appStore.currentProjectId,
environmentId: appStore.getCurrentEnvId || '',

View File

@ -277,4 +277,19 @@ export default {
'apiScenario.execute.no.step.tips': 'No open step',
'apiScenario.preConditionTip': 'Execute once before the scene step',
'apiScenario.postConditionTip': 'Execute once after the scene step',
'apiScenario.deleteCsvConfirm': 'Are you sure you want to delete {name}?',
'apiScenario.deleteCsvConfirmContent':
'After deletion, all scenes using the CSV file will be updated, please operate with caution!',
'apiScenario.deleteCsvSuccess': 'Deleted',
'apiScenario.changeScopeConfirm': 'Are you sure you want to change the scope to {type}?',
'apiScenario.changeScopeToScenarioConfirmContent':
'After being changed, the parameters in the CSV file will take effect for the entire scene, so please operate with caution!',
'apiScenario.changeScopeToStepConfirmContent':
'After modification, the parameters in the CSV file will only take effect for the steps, please operate with caution!',
'apiScenario.confirmChange': 'Confirm changes',
'apiScenario.changeScopeSuccess': 'Change successful',
'apiScenario.quoteCsv': 'Quote CSV',
'apiScenario.csvQuote': 'CSV quote',
'apiScenario.csvNameNotNull': 'CSV name cannot be empty',
'apiScenario.csvFileNotNull': 'CSV file cannot be empty',
};

View File

@ -274,4 +274,16 @@ export default {
'apiScenario.execute.no.step.tips': '没有开启的步骤',
'apiScenario.preConditionTip': '在场景步骤前分别执行一次',
'apiScenario.postConditionTip': '在场景步骤后分别执行一次',
'apiScenario.deleteCsvConfirm': '确认删除 {name} 吗?',
'apiScenario.deleteCsvConfirmContent': '删除后,使用该 CSV 文件的场景则全部更新,请谨慎操作!',
'apiScenario.deleteCsvSuccess': '已删除',
'apiScenario.changeScopeConfirm': '确认更改作用域为 {type} 吗?',
'apiScenario.changeScopeToScenarioConfirmContent': '更改后,该 CSV 文件内的参数对整个场景生效,请谨慎操作!',
'apiScenario.changeScopeToStepConfirmContent': '更改后,该 CSV 文件内的参数仅对步骤生效,请谨慎操作!',
'apiScenario.confirmChange': '确认更改',
'apiScenario.changeScopeSuccess': '已更改',
'apiScenario.quoteCsv': '引用 CSV',
'apiScenario.csvQuote': 'CSV 引用',
'apiScenario.csvNameNotNull': 'CSV 名称不能为空',
'apiScenario.csvFileNotNull': 'CSV 文件不能为空',
};