feat(接口场景): 场景步骤 5%

This commit is contained in:
baiqi 2024-03-16 17:46:27 +08:00 committed by 刘瑞斌
parent e76308113e
commit f624884fd7
13 changed files with 554 additions and 229 deletions

View File

@ -37,7 +37,7 @@ import {
ScenarioHistoryItem, ScenarioHistoryItem,
ScenarioHistoryPageParams, ScenarioHistoryPageParams,
} from '@/models/apiTest/scenario'; } from '@/models/apiTest/scenario';
import { AddModuleParams, BatchApiParams, CommonList, ModuleTreeNode, MoveModules } from '@/models/common'; import { AddModuleParams, CommonList, ModuleTreeNode, MoveModules } from '@/models/common';
// 更新模块 // 更新模块
export function updateModule(data: ApiScenarioModuleUpdateParams) { export function updateModule(data: ApiScenarioModuleUpdateParams) {

View File

@ -5,7 +5,8 @@
v-bind="props" v-bind="props"
ref="treeRef" ref="treeRef"
v-model:expanded-keys="expandedKeys" v-model:expanded-keys="expandedKeys"
v-model:selected-keys="innerSelectedKeys" v-model:selected-keys="selectedKeys"
v-model:checked-keys="checkedKeys"
:data="treeData" :data="treeData"
class="ms-tree" class="ms-tree"
:allow-drop="handleAllowDrop" :allow-drop="handleAllowDrop"
@ -20,6 +21,7 @@
:content="_props[props.fieldNames.title]" :content="_props[props.fieldNames.title]"
:mouse-enter-delay="800" :mouse-enter-delay="800"
:position="props.titleTooltipPosition" :position="props.titleTooltipPosition"
:disabled="props.disabledTitleTooltip"
> >
<slot name="title" v-bind="_props"></slot> <slot name="title" v-bind="_props"></slot>
</a-tooltip> </a-tooltip>
@ -32,11 +34,8 @@
v-if="_props.hideMoreAction !== true" v-if="_props.hideMoreAction !== true"
:class="[ :class="[
'ms-tree-node-extra', 'ms-tree-node-extra',
innerFocusNodeKey === _props[props.fieldNames.key] ? 'ms-tree-node-extra--focus' : '', // TODO: focusNodeKey === _props[props.fieldNames.key] ? 'ms-tree-node-extra--focus' : '', // TODO:
]" ]"
>
<div
class="ml-[-4px] flex h-[32px] items-center rounded-[var(--border-radius-small)] bg-[rgb(var(--primary-1))]"
> >
<slot name="extra" v-bind="_props"></slot> <slot name="extra" v-bind="_props"></slot>
<MsTableMoreAction <MsTableMoreAction
@ -52,14 +51,14 @@
> >
<MsButton <MsButton
type="text" type="text"
size="mini" :size="props.nodeMoreActionSize || 'mini'"
class="ms-tree-node-extra__more" class="ms-tree-node-extra__more"
@click="innerFocusNodeKey = _props[props.fieldNames.key]" @click="focusNodeKey = _props[props.fieldNames.key]"
> >
<MsIcon type="icon-icon_more_outlined" size="14" class="text-[var(--color-text-4)]" /> <MsIcon type="icon-icon_more_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton> </MsButton>
</MsTableMoreAction> </MsTableMoreAction>
</div> <slot name="extraEnd" v-bind="_props"></slot>
</div> </div>
</template> </template>
</a-tree> </a-tree>
@ -75,8 +74,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, nextTick, onBeforeMount, Ref, ref, watch, watchEffect } from 'vue'; import { nextTick, onBeforeMount, Ref, ref, watch, watchEffect } from 'vue';
import { useVModel } from '@vueuse/core';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';
@ -101,15 +99,14 @@
defaultExpandAll?: boolean; // defaultExpandAll?: boolean; //
selectable?: boolean | ((node: MsTreeNodeData, info: { level: number; isLeaf: boolean }) => boolean); // selectable?: boolean | ((node: MsTreeNodeData, info: { level: number; isLeaf: boolean }) => boolean); //
fieldNames?: MsTreeFieldNames; // fieldNames?: MsTreeFieldNames; //
focusNodeKey?: string | number; // key
selectedKeys?: Array<string | number>; // key
nodeMoreActions?: ActionsItem[]; // nodeMoreActions?: ActionsItem[]; //
nodeMoreActionSize?: 'medium' | 'mini' | 'small' | 'large'; //
expandAll?: boolean; // /true false expandAll?: boolean; // /true false
emptyText?: string; // emptyText?: string; //
checkable?: boolean; // checkable?: boolean; //
checkedStrategy?: 'all' | 'parent' | 'child'; // checkedStrategy?: 'all' | 'parent' | 'child'; //
checkedKeys?: Array<string | number>; // key
virtualListProps?: VirtualListProps; // virtualListProps?: VirtualListProps; //
disabledTitleTooltip?: boolean; // tooltip
titleTooltipPosition?: titleTooltipPosition?:
| 'top' | 'top'
| 'tl' | 'tl'
@ -138,6 +135,7 @@
children: 'children', children: 'children',
isLeaf: 'isLeaf', isLeaf: 'isLeaf',
}), }),
disabledTitleTooltip: false,
} }
); );
@ -151,12 +149,23 @@
dropPosition: number // -1 1 0 dropPosition: number // -1 1 0
): void; ): void;
(e: 'moreActionSelect', item: ActionsItem, node: MsTreeNodeData): void; (e: 'moreActionSelect', item: ActionsItem, node: MsTreeNodeData): void;
(e: 'update:focusNodeKey', val: string | number): void;
(e: 'update:selectedKeys', val: Array<string | number>): void;
(e: 'moreActionsClose'): void; (e: 'moreActionsClose'): void;
(e: 'check', val: Array<string | number>): void; (e: 'check', val: Array<string | number>): void;
}>(); }>();
const selectedKeys = defineModel<(string | number)[]>('selectedKeys', {
default: [],
});
const checkedKeys = defineModel<(string | number)[]>('checkedKeys', {
default: [],
});
const expandedKeys = defineModel<(string | number)[]>('expandedKeys', {
default: [],
});
const focusNodeKey = defineModel<string | number>('focusNodeKey', {
default: '',
});
const treeContainerRef: Ref = ref(null); const treeContainerRef: Ref = ref(null);
const treeRef: Ref = ref(null); const treeRef: Ref = ref(null);
const { isInitListener, containerStatusClass, setContainer, initScrollListener } = useContainerShadow({ const { isInitListener, containerStatusClass, setContainer, initScrollListener } = useContainerShadow({
@ -166,21 +175,22 @@
const originalTreeData = ref<MsTreeNodeData[]>([]); const originalTreeData = ref<MsTreeNodeData[]>([]);
function init(isFirstInit = false) { function init(isFirstInit = false) {
originalTreeData.value = mapTree<MsTreeNodeData>(props.data, (node: MsTreeNodeData) => { originalTreeData.value = mapTree<MsTreeNodeData>(props.data);
if (!props.showLine) { // (node: MsTreeNodeData) => {
// 线线 switcherIcon switcherIcon // // if (!props.showLine) {
node.icon = () => h('span', { class: 'hidden' }); // // // 线线 switcherIcon switcherIcon
} // // node.icon = () => h('span', { class: 'hidden' });
if ( // // }
node[props.fieldNames.isLeaf || 'isLeaf'] || // // if (
!node[props.fieldNames.children] || // // node[props.fieldNames.isLeaf || 'isLeaf'] ||
node[props.fieldNames.children]?.length === 0 // // !node[props.fieldNames.children] ||
) { // // node[props.fieldNames.children]?.length === 0
// icon线 switcherIcon 线 icon // // ) {
node[props.showLine ? 'switcherIcon' : 'icon'] = () => h('span', { class: 'hidden' }); // // // icon线 switcherIcon 线 icon
} // // node[props.showLine ? 'switcherIcon' : 'icon'] = () => h('span', { class: 'hidden' });
return node; // // }
}); // return node;
// });
nextTick(() => { nextTick(() => {
if (isFirstInit) { if (isFirstInit) {
if (props.defaultExpandAll) { if (props.defaultExpandAll) {
@ -209,8 +219,6 @@
} }
); );
const expandedKeys = ref<(string | number)[]>([]);
/** /**
* 根据关键字过滤树节点 * 根据关键字过滤树节点
* @param keyword 搜索关键字 * @param keyword 搜索关键字
@ -327,20 +335,18 @@
/** /**
* 处理树节点选中非复选框 * 处理树节点选中非复选框
*/ */
function select(selectedKeys: Array<string | number>, data: MsTreeSelectedData) { function select(_selectedKeys: Array<string | number>, data: MsTreeSelectedData) {
emit('select', selectedKeys, data.selectedNodes[0]); emit('select', _selectedKeys, data.selectedNodes[0]);
} }
function checked(checkedKeys: Array<string | number>) { function checked(_checkedKeys: Array<string | number>) {
emit('check', checkedKeys); emit('check', _checkedKeys);
} }
const innerFocusNodeKey = useVModel(props, 'focusNodeKey', emit); //
const focusEl = ref<HTMLElement | null>(); // const focusEl = ref<HTMLElement | null>(); //
watch( watch(
() => innerFocusNodeKey.value, () => focusNodeKey.value,
(val) => { (val) => {
if (val?.toString() !== '') { if (val?.toString() !== '') {
focusEl.value = treeRef.value?.$el.querySelector(`[data-key="${val}"]`); focusEl.value = treeRef.value?.$el.querySelector(`[data-key="${val}"]`);
@ -374,7 +380,18 @@
} }
); );
const innerSelectedKeys = useVModel(props, 'selectedKeys', emit); function checkAll(val: boolean) {
treeRef.value?.checkAll(val);
}
function expandNode(key: string | number, expanded: boolean) {
treeRef.value?.expandNode(key, expanded);
}
defineExpose({
checkAll,
expandNode,
});
</script> </script>
<style lang="less"> <style lang="less">
@ -443,7 +460,12 @@
width: 60%; width: 60%;
} }
.ms-tree-node-extra { .ms-tree-node-extra {
@apply invisible relative w-0; @apply invisible relative flex w-0 items-center;
margin-left: -4px;
height: 32px;
border-radius: var(--border-radius-small);
background-color: rgb(var(--primary-1));
&:hover { &:hover {
@apply visible w-auto; @apply visible w-auto;
} }

View File

@ -138,4 +138,5 @@ export default {
'common.batchDelete': 'Batch delete', 'common.batchDelete': 'Batch delete',
'common.batchDebug': 'Batch debug', 'common.batchDebug': 'Batch debug',
'common.quote': 'Quote', 'common.quote': 'Quote',
'common.execute': '执行',
}; };

View File

@ -141,4 +141,5 @@ export default {
'common.batchDelete': '批量删除', 'common.batchDelete': '批量删除',
'common.batchDebug': '批量调试', 'common.batchDebug': '批量调试',
'common.quote': '引用', 'common.quote': '引用',
'common.execute': '执行',
}; };

View File

@ -199,7 +199,8 @@ export function mapTree<T>(
tree: TreeNode<T> | TreeNode<T>[] | T | T[], tree: TreeNode<T> | TreeNode<T>[] | T | T[],
customNodeFn: (node: TreeNode<T>, path: string) => TreeNode<T> | null = (node) => node, customNodeFn: (node: TreeNode<T>, path: string) => TreeNode<T> | null = (node) => node,
customChildrenKey = 'children', customChildrenKey = 'children',
parentPath = '' parentPath = '',
level = 0
): T[] { ): T[] {
if (!Array.isArray(tree)) { if (!Array.isArray(tree)) {
tree = [tree]; tree = [tree];
@ -210,8 +211,17 @@ export function mapTree<T>(
const fullPath = node.path ? `${parentPath}/${node.path}`.replace(/\/+/g, '/') : ''; const fullPath = node.path ? `${parentPath}/${node.path}`.replace(/\/+/g, '/') : '';
const newNode = typeof customNodeFn === 'function' ? customNodeFn(node, fullPath) : node; const newNode = typeof customNodeFn === 'function' ? customNodeFn(node, fullPath) : node;
if (newNode && newNode[customChildrenKey] && newNode[customChildrenKey].length > 0) { if (newNode) {
newNode[customChildrenKey] = mapTree(newNode[customChildrenKey], customNodeFn, customChildrenKey); newNode.level = level;
if (newNode[customChildrenKey] && newNode[customChildrenKey].length > 0) {
newNode[customChildrenKey] = mapTree(
newNode[customChildrenKey],
customNodeFn,
customChildrenKey,
fullPath,
level + 1
);
}
} }
return newNode; return newNode;

View File

@ -25,7 +25,13 @@
@change="resetModuleAndTable" @change="resetModuleAndTable"
/> />
</div> </div>
<moduleTree ref="moduleTreeRef" :type="activeKey" :protocol="protocol" @select="handleModuleSelect" /> <moduleTree
ref="moduleTreeRef"
:type="activeKey"
:project-id="currentProject"
:protocol="protocol"
@select="handleModuleSelect"
/>
</div> </div>
</div> </div>
<div class="table-container"> <div class="table-container">
@ -143,7 +149,7 @@
const moduleIds = ref<(string | number)[]>([]); const moduleIds = ref<(string | number)[]>([]);
function resetModuleAndTable() { function resetModuleAndTable() {
moduleTreeRef.value?.init(); moduleTreeRef.value?.init(activeKey.value);
apiTableRef.value?.loadPage(['root']); // id apiTableRef.value?.loadPage(['root']); // id
} }
@ -180,6 +186,9 @@
if (val) { if (val) {
resetModuleAndTable(); resetModuleAndTable();
} }
},
{
immediate: true,
} }
); );

View File

@ -54,8 +54,11 @@
import { MsTreeNodeData } from '@/components/business/ms-tree/types'; import { MsTreeNodeData } from '@/components/business/ms-tree/types';
import { getModuleCount, getModuleTreeOnlyModules } from '@/api/modules/api-test/management'; import { getModuleCount, getModuleTreeOnlyModules } from '@/api/modules/api-test/management';
import {
getModuleCount as getScenarioModuleCount,
getModuleTree as getScenarioModuleTree,
} from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { mapTree } from '@/utils'; import { mapTree } from '@/utils';
import { ModuleTreeNode } from '@/models/common'; import { ModuleTreeNode } from '@/models/common';
@ -64,6 +67,7 @@
defineProps<{ defineProps<{
type: 'api' | 'case' | 'scenario'; type: 'api' | 'case' | 'scenario';
protocol: string; protocol: string;
projectId: string;
}>(), }>(),
{ {
type: 'api', type: 'api',
@ -73,7 +77,6 @@
(e: 'select', ids: (string | number)[], node: MsTreeNodeData): void; (e: 'select', ids: (string | number)[], node: MsTreeNodeData): void;
}>(); }>();
const appStore = useAppStore();
const { t } = useI18n(); const { t } = useI18n();
const moduleKeyword = ref(''); const moduleKeyword = ref('');
@ -86,16 +89,21 @@
/** /**
* 初始化模块树 * 初始化模块树
*/ */
async function initModules() { async function initModules(type = props.type) {
try { try {
loading.value = true; loading.value = true;
folderTree.value = await getModuleTreeOnlyModules({ const params = {
//
keyword: moduleKeyword.value, keyword: moduleKeyword.value,
protocol: props.protocol, protocol: props.protocol,
projectId: appStore.currentProjectId, projectId: props.projectId,
moduleIds: [], moduleIds: [],
}); };
if (type === 'api' || type === 'case') {
// case api
folderTree.value = await getModuleTreeOnlyModules(params);
} else if (type === 'scenario') {
folderTree.value = await getScenarioModuleTree(params);
}
selectedKeys.value = [folderTree.value[0]?.id]; selectedKeys.value = [folderTree.value[0]?.id];
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -105,14 +113,20 @@
} }
} }
async function initModuleCount() { async function initModuleCount(type = props.type) {
try { try {
moduleCountMap.value = await getModuleCount({ const params = {
keyword: moduleKeyword.value, keyword: moduleKeyword.value,
protocol: props.protocol, protocol: props.protocol,
projectId: appStore.currentProjectId, projectId: props.projectId,
moduleIds: [], moduleIds: [],
}); };
if (type === 'api' || type === 'case') {
// case api
moduleCountMap.value = await getModuleCount(params);
} else if (type === 'scenario') {
moduleCountMap.value = await getScenarioModuleCount(params);
}
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(error); console.log(error);
@ -128,9 +142,9 @@
emit('select', [keys[0], ...offspringIds], node); emit('select', [keys[0], ...offspringIds], node);
} }
function init() { function init(type = props.type) {
initModules(); initModules(type);
initModuleCount(); initModuleCount(type);
} }
defineExpose({ defineExpose({

View File

@ -129,7 +129,7 @@
sorter: true, sorter: true,
}, },
fixed: 'left', fixed: 'left',
width: 100, width: 120,
}, },
{ {
title: 'apiTestManagement.apiName', title: 'apiTestManagement.apiName',

View File

@ -0,0 +1,55 @@
<template>
<a-dropdown class="scenario-action-dropdown" @select="(val) => emit('select', val as ScenarioAddStepActionType)">
<slot></slot>
<template #content>
<a-dgroup :title="t('apiScenario.requestScenario')">
<a-doption :value="ScenarioAddStepActionType.IMPORT_SYSTEM_API">
{{ t('apiScenario.importSystemApi') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.CUSTOM_API">
{{ t('apiScenario.customApi') }}
</a-doption>
</a-dgroup>
<a-dgroup :title="t('apiScenario.logicControl')">
<a-doption :value="ScenarioAddStepActionType.LOOP_CONTROL">
<div class="flex w-full items-center justify-between">
{{ t('apiScenario.loopControl') }}
<MsButton type="text" @click="openTutorial">{{ t('apiScenario.tutorial') }}</MsButton>
</div>
</a-doption>
<a-doption :value="ScenarioAddStepActionType.CONDITION_CONTROL">
{{ t('apiScenario.conditionControl') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.ONLY_ONCE_CONTROL">
{{ t('apiScenario.onlyOnceControl') }}
</a-doption>
</a-dgroup>
<a-dgroup :title="t('apiScenario.other')">
<a-doption :value="ScenarioAddStepActionType.SCRIPT_OPERATION">
{{ t('apiScenario.scriptOperation') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.WAIT_TIME">{{ t('apiScenario.waitTime') }}</a-doption>
</a-dgroup>
</template>
</a-dropdown>
</template>
<script setup lang="ts">
import MsButton from '@/components/pure/ms-button/index.vue';
import { useI18n } from '@/hooks/useI18n';
import { ScenarioAddStepActionType } from '@/enums/apiEnum';
const emit = defineEmits<{
(e: 'select', val: ScenarioAddStepActionType): void;
}>();
const { t } = useI18n();
function openTutorial() {
window.open('https://zhuanlan.zhihu.com/p/597905464?utm_id=0', '_blank');
}
</script>
<style lang="less" scoped></style>

View File

@ -3,8 +3,9 @@
<div class="action-line"> <div class="action-line">
<div class="action-group"> <div class="action-group">
<a-checkbox <a-checkbox
v-model:model-value="stepInfo.checkedAll" v-show="stepInfo.steps.length > 0"
:indeterminate="stepInfo.indeterminate" v-model:model-value="checkedAll"
:indeterminate="indeterminate"
@change="handleChangeAll" @change="handleChangeAll"
/> />
<div class="flex items-center gap-[4px]"> <div class="flex items-center gap-[4px]">
@ -13,18 +14,20 @@
{{ t('apiScenario.steps') }} {{ t('apiScenario.steps') }}
</div> </div>
</div> </div>
<div v-if="stepInfo.checkedAll || stepInfo.indeterminate" class="action-group"> <div class="action-group">
<a-tooltip :content="stepInfo.isExpand ? t('apiScenario.collapseAllStep') : t('apiScenario.expandAllStep')"> <a-tooltip :content="isExpandAll ? t('apiScenario.collapseAllStep') : t('apiScenario.expandAllStep')">
<a-button <a-button
v-show="stepInfo.steps.length > 0"
type="outline" type="outline"
class="expand-step-btn arco-btn-outline--secondary" class="expand-step-btn arco-btn-outline--secondary"
size="mini" size="mini"
@click="expandAllStep" @click="expandAllStep"
> >
<MsIcon v-if="stepInfo.isExpand" type="icon-icon_comment_collapse_text_input" /> <MsIcon v-if="isExpandAll" type="icon-icon_comment_collapse_text_input" />
<MsIcon v-else type="icon-icon_comment_expand_text_input" /> <MsIcon v-else type="icon-icon_comment_expand_text_input" />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<template v-if="checkedAll || indeterminate">
<a-button type="outline" size="mini" @click="batchEnable"> <a-button type="outline" size="mini" @click="batchEnable">
{{ t('common.batchEnable') }} {{ t('common.batchEnable') }}
</a-button> </a-button>
@ -37,8 +40,9 @@
<a-button type="outline" size="mini" @click="batchDelete"> <a-button type="outline" size="mini" @click="batchDelete">
{{ t('common.batchDelete') }} {{ t('common.batchDelete') }}
</a-button> </a-button>
</template>
</div> </div>
<template v-else> <template v-if="stepInfo.executeTime">
<div class="action-group"> <div class="action-group">
<div class="text-[var(--color-text-4)]">{{ t('apiScenario.executeTime') }}</div> <div class="text-[var(--color-text-4)]">{{ t('apiScenario.executeTime') }}</div>
<div class="text-[var(--color-text-4)]">{{ stepInfo.executeTime }}</div> <div class="text-[var(--color-text-4)]">{{ stepInfo.executeTime }}</div>
@ -55,7 +59,8 @@
</div> </div>
<MsButton type="text" @click="checkReport">{{ t('apiScenario.checkReport') }}</MsButton> <MsButton type="text" @click="checkReport">{{ t('apiScenario.checkReport') }}</MsButton>
</div> </div>
<div class="action-group ml-auto"> </template>
<div v-if="!checkedAll && !indeterminate" class="action-group ml-auto">
<a-input-search <a-input-search
v-model:model-value="keyword" v-model:model-value="keyword"
:placeholder="t('apiScenario.searchByName')" :placeholder="t('apiScenario.searchByName')"
@ -64,117 +69,127 @@
@search="searchStep" @search="searchStep"
@press-enter="searchStep" @press-enter="searchStep"
/> />
<a-button type="outline" class="arco-btn-outline--secondary !mr-0 !p-[8px]" @click="refreshStepInfo"> <a-button
v-if="!props.isNew"
type="outline"
class="arco-btn-outline--secondary !mr-0 !p-[8px]"
@click="refreshStepInfo"
>
<template #icon> <template #icon>
<icon-refresh class="text-[var(--color-text-4)]" /> <icon-refresh class="text-[var(--color-text-4)]" />
</template> </template>
</a-button> </a-button>
</div> </div>
</template>
</div> </div>
<a-dropdown <div>
class="scenario-action-dropdown" <stepTree ref="stepTreeRef" v-model:checked-keys="checkedKeys" :steps="stepInfo.steps" />
@select="(val) => handleActionSelect(val as ScenarioAddStepActionType)"
>
<a-button type="dashed" class="add-step-btn" long>
<div class="flex items-center gap-[8px]">
<icon-plus />
{{ t('apiScenario.addStep') }}
</div> </div>
</a-button>
<template #content>
<a-dgroup :title="t('apiScenario.requestScenario')">
<a-doption :value="ScenarioAddStepActionType.IMPORT_SYSTEM_API">
{{ t('apiScenario.importSystemApi') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.CUSTOM_API">
{{ t('apiScenario.customApi') }}
</a-doption>
</a-dgroup>
<a-dgroup :title="t('apiScenario.logicControl')">
<a-doption :value="ScenarioAddStepActionType.LOOP_CONTROL">
<div class="flex w-full items-center justify-between">
{{ t('apiScenario.loopControl') }}
<MsButton type="text" @click="openTutorial">{{ t('apiScenario.tutorial') }}</MsButton>
</div>
</a-doption>
<a-doption :value="ScenarioAddStepActionType.CONDITION_CONTROL">
{{ t('apiScenario.conditionControl') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.ONLY_ONCE_CONTROL">
{{ t('apiScenario.onlyOnceControl') }}
</a-doption>
</a-dgroup>
<a-dgroup :title="t('apiScenario.other')">
<a-doption :value="ScenarioAddStepActionType.SCRIPT_OPERATION">
{{ t('apiScenario.scriptOperation') }}
</a-doption>
<a-doption :value="ScenarioAddStepActionType.WAIT_TIME">{{ t('apiScenario.waitTime') }}</a-doption>
</a-dgroup>
</template>
</a-dropdown>
<importApiDrawer v-model:visible="importApiDrawerVisible" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs'; // import dayjs from 'dayjs';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue'; import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import executeStatus from '../common/executeStatus.vue'; import stepTree, { ScenarioStepItem } from './stepTree.vue';
import importApiDrawer from '../common/importApiDrawer/index.vue';
import stepType from '../common/stepType.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { ScenarioAddStepActionType, ScenarioExecuteStatus, ScenarioStepType } from '@/enums/apiEnum'; import { ScenarioExecuteStatus, ScenarioStepType } from '@/enums/apiEnum';
export interface ScenarioStepItem {
id: string | number;
type: ScenarioStepType;
name: string;
description: string;
status: ScenarioExecuteStatus;
children?: ScenarioStepItem[];
}
export interface ScenarioStepInfo { export interface ScenarioStepInfo {
id: string | number; id: string | number;
steps: ScenarioStepItem[]; steps: ScenarioStepItem[];
checkedAll: boolean; // executeTime?: string; //
indeterminate: boolean; //
isExpand: boolean; //
executeTime: string; //
executeSuccessCount?: number; // executeSuccessCount?: number; //
executeFailCount?: number; // executeFailCount?: number; //
} }
const props = defineProps<{
isNew?: boolean; //
}>();
const { t } = useI18n(); const { t } = useI18n();
const checkedAll = ref(false); //
const indeterminate = ref(false); //
const isExpandAll = ref(false); //
const checkedKeys = ref<string[]>([]); // key
const stepTreeRef = ref<InstanceType<typeof stepTree>>();
const keyword = ref(''); const keyword = ref('');
const stepInfo = ref<ScenarioStepInfo>({ const stepInfo = ref<ScenarioStepInfo>({
id: new Date().getTime(), id: new Date().getTime(),
steps: [], steps: [
checkedAll: false, {
indeterminate: false, id: 1,
isExpand: false, order: 1,
executeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), checked: false,
type: ScenarioStepType.CUSTOM_API,
name: 'API1',
description: 'API1描述',
status: ScenarioExecuteStatus.SUCCESS,
children: [
{
id: 11,
order: 1,
checked: false,
type: ScenarioStepType.CUSTOM_API,
name: 'API11',
description: 'API11描述',
status: ScenarioExecuteStatus.SUCCESS,
},
{
id: 12,
order: 2,
checked: false,
type: ScenarioStepType.CUSTOM_API,
name: 'API12',
description: 'API12描述',
status: ScenarioExecuteStatus.SUCCESS,
},
],
},
{
id: 2,
order: 2,
checked: false,
type: ScenarioStepType.CUSTOM_API,
name: 'API1',
description: 'API1描述',
status: ScenarioExecuteStatus.SUCCESS,
},
],
executeTime: '',
executeSuccessCount: 0, executeSuccessCount: 0,
executeFailCount: 0, executeFailCount: 0,
}); });
function handleChangeAll(value: boolean | (string | number | boolean)[]) { function handleChangeAll(value: boolean | (string | number | boolean)[]) {
stepInfo.value.indeterminate = false; indeterminate.value = false;
if (value) { if (value) {
stepInfo.value.checkedAll = true; checkedAll.value = true;
} else { } else {
stepInfo.value.checkedAll = false; checkedAll.value = false;
} }
stepTreeRef.value?.checkAll(checkedAll.value);
} }
watch(checkedKeys, (val) => {
if (val.length === 0) {
checkedAll.value = false;
indeterminate.value = false;
} else if (val.length === stepInfo.value.steps.length) {
checkedAll.value = true;
indeterminate.value = false;
} else {
checkedAll.value = false;
indeterminate.value = true;
}
});
function expandAllStep() { function expandAllStep() {
stepInfo.value.isExpand = !stepInfo.value.isExpand; isExpandAll.value = !isExpandAll.value;
} }
function batchEnable() { function batchEnable() {
@ -204,40 +219,6 @@
function searchStep(val: string) { function searchStep(val: string) {
stepInfo.value.steps = stepInfo.value.steps.filter((item) => item.name.includes(val)); stepInfo.value.steps = stepInfo.value.steps.filter((item) => item.name.includes(val));
} }
const importApiDrawerVisible = ref(false);
function handleActionSelect(val: ScenarioAddStepActionType) {
switch (val) {
case ScenarioAddStepActionType.IMPORT_SYSTEM_API:
importApiDrawerVisible.value = true;
break;
case ScenarioAddStepActionType.CUSTOM_API:
console.log('自定义API');
break;
case ScenarioAddStepActionType.LOOP_CONTROL:
console.log('循环控制');
break;
case ScenarioAddStepActionType.CONDITION_CONTROL:
console.log('条件控制');
break;
case ScenarioAddStepActionType.ONLY_ONCE_CONTROL:
console.log('仅执行一次');
break;
case ScenarioAddStepActionType.SCRIPT_OPERATION:
console.log('脚本操作');
break;
case ScenarioAddStepActionType.WAIT_TIME:
console.log('等待时间');
break;
default:
break;
}
}
function openTutorial() {
window.open('https://zhuanlan.zhihu.com/p/597905464?utm_id=0', '_blank');
}
</script> </script>
<style lang="less"> <style lang="less">
@ -277,17 +258,4 @@
} }
} }
} }
.add-step-btn {
@apply bg-white;
padding: 4px;
border: 1px dashed rgb(var(--primary-3));
color: rgb(var(--primary-5));
&:hover,
&:focus {
border: 1px dashed rgb(var(--primary-5));
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
}
</style> </style>

View File

@ -0,0 +1,241 @@
<template>
<div class="flex flex-col gap-[16px]">
<MsTree
ref="treeRef"
v-model:checked-keys="checkedKeys"
v-model:focus-node-key="focusStepKey"
:data="props.steps"
:node-more-actions="stepMoreActions"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:selectable="false"
disabled-title-tooltip
checkable
block-node
draggable
>
<template #title="step">
<div class="flex items-center gap-[8px]">
<div
class="flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[var(--color-text-brand)] px-[2px] !text-white"
>
{{ step.order }}
</div>
<div class="step-node-first">
<div
v-show="step.children?.length > 0"
class="flex cursor-pointer items-center gap-[2px] text-[var(--color-text-1)]"
@click.stop="toggleNodeExpand(step)"
>
<MsIcon
:type="step.expanded ? 'icon-icon_split_turn-down_arrow' : 'icon-icon_split-turn-down-left'"
:size="14"
/>
{{ step.children?.length || 0 }}
</div>
<div class="text-[var(--color-text-1)]">{{ step.name }}</div>
</div>
</div>
</template>
<template #extra="step">
<MsButton :id="step.key" type="icon" class="ms-tree-node-extra__btn !mr-[4px]" @click="setFocusNodeKey(step)">
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton>
</template>
<template #extraEnd="step">
<executeStatus :status="step.status" size="small" />
</template>
</MsTree>
<actionDropdown
class="scenario-action-dropdown"
@select="(val) => handleActionSelect(val as ScenarioAddStepActionType)"
>
<a-button type="dashed" class="add-step-btn" long>
<div class="flex items-center gap-[8px]">
<icon-plus />
{{ t('apiScenario.addStep') }}
</div>
</a-button>
</actionDropdown>
<importApiDrawer v-model:visible="importApiDrawerVisible" />
</div>
</template>
<script setup lang="ts">
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTree from '@/components/business/ms-tree/index.vue';
import { MsTreeNodeData } from '@/components/business/ms-tree/types';
import executeStatus from '../common/executeStatus.vue';
import importApiDrawer from '../common/importApiDrawer/index.vue';
import stepType from '../common/stepType.vue';
import actionDropdown from './actionDropdown.vue';
import { useI18n } from '@/hooks/useI18n';
import { ScenarioAddStepActionType, ScenarioExecuteStatus, ScenarioStepType } from '@/enums/apiEnum';
export interface ScenarioStepItem {
id: string | number;
order: number;
checked: boolean;
type: ScenarioStepType;
name: string;
description: string;
status: ScenarioExecuteStatus;
children?: ScenarioStepItem[];
}
const props = defineProps<{
steps: ScenarioStepItem[];
}>();
const { t } = useI18n();
const checkedKeys = defineModel<string[]>('checkedKeys', {
required: true,
});
const treeRef = ref<InstanceType<typeof MsTree>>();
const focusStepKey = ref<string>(''); // key
const stepMoreActions: ActionsItem[] = [
{
label: 'common.execute',
eventTag: 'execute',
},
{
label: 'common.copy',
eventTag: 'copy',
},
{
label: 'apiScenario.scenarioConfig',
eventTag: 'config',
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
function setFocusNodeKey(node: MsTreeNodeData) {
focusStepKey.value = node.id || '';
}
function toggleNodeExpand(node: MsTreeNodeData) {
if (node.id) {
treeRef.value?.expandNode(node.id, !node.expanded);
}
}
function checkAll(val: boolean) {
treeRef.value?.checkAll(val);
}
const importApiDrawerVisible = ref(false);
function handleActionSelect(val: ScenarioAddStepActionType) {
switch (val) {
case ScenarioAddStepActionType.IMPORT_SYSTEM_API:
importApiDrawerVisible.value = true;
break;
case ScenarioAddStepActionType.CUSTOM_API:
console.log('自定义API');
break;
case ScenarioAddStepActionType.LOOP_CONTROL:
console.log('循环控制');
break;
case ScenarioAddStepActionType.CONDITION_CONTROL:
console.log('条件控制');
break;
case ScenarioAddStepActionType.ONLY_ONCE_CONTROL:
console.log('仅执行一次');
break;
case ScenarioAddStepActionType.SCRIPT_OPERATION:
console.log('脚本操作');
break;
case ScenarioAddStepActionType.WAIT_TIME:
console.log('等待时间');
break;
default:
break;
}
}
defineExpose({
checkAll,
});
</script>
<style lang="less" scoped>
.add-step-btn {
@apply bg-white;
padding: 4px;
border: 1px dashed rgb(var(--primary-3));
color: rgb(var(--primary-5));
&:hover,
&:focus {
border: 1px dashed rgb(var(--primary-5));
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
}
//
.loop-levels(@index, @max) when (@index <= @max) {
:deep(.arco-tree-node[data-level='@{index}']) {
margin-left: @index * 32px;
}
.loop-levels(@index + 1, @max); //
}
.loop-levels(0, 99); //
:deep(.arco-tree-node) {
padding: 7px 8px;
border: 1px solid var(--color-text-n8);
border-radius: var(--border-radius-medium) !important;
&:not(:first-child) {
margin-top: 4px;
}
&:hover {
background-color: var(--color-text-n9) !important;
.arco-tree-node-title {
background-color: var(--color-text-n9) !important;
}
}
.arco-tree-node-title {
&:hover {
background-color: var(--color-text-n9) !important;
}
.step-node-first {
@apply flex items-center;
gap: 8px;
}
&[draggable='true']:hover {
.step-node-first {
padding-left: 20px;
}
}
}
.arco-tree-node-indent {
@apply hidden;
}
.arco-tree-node-switcher {
@apply hidden;
}
.arco-tree-node-drag-icon {
@apply ml-0;
top: 6px;
left: 24px;
width: 16px;
height: 16px;
.arco-icon {
font-size: 16px !important;
}
}
.ms-tree-node-extra {
gap: 4px;
background-color: var(--color-text-n9) !important;
}
}
</style>

View File

@ -3,7 +3,7 @@
<template #first> <template #first>
<a-tabs v-model:active-key="activeKey" class="h-full" animation lazy-load> <a-tabs v-model:active-key="activeKey" class="h-full" animation lazy-load>
<a-tab-pane :key="ScenarioCreateComposition.STEP" :title="t('apiScenario.step')" class="p-[16px]"> <a-tab-pane :key="ScenarioCreateComposition.STEP" :title="t('apiScenario.step')" class="p-[16px]">
<step v-if="activeKey === ScenarioCreateComposition.STEP" /> <step v-if="activeKey === ScenarioCreateComposition.STEP" is-new />
</a-tab-pane> </a-tab-pane>
<a-tab-pane :key="ScenarioCreateComposition.PARAMS" :title="t('apiScenario.params')" class="p-[16px]"> <a-tab-pane :key="ScenarioCreateComposition.PARAMS" :title="t('apiScenario.params')" class="p-[16px]">
<params v-if="activeKey === ScenarioCreateComposition.PARAMS" v-model:params="scenario.params" /> <params v-if="activeKey === ScenarioCreateComposition.PARAMS" v-model:params="scenario.params" />
@ -127,6 +127,8 @@
import { ModuleTreeNode } from '@/models/common'; import { ModuleTreeNode } from '@/models/common';
import { ApiScenarioStatus, RequestCaseStatus, ScenarioCreateComposition } from '@/enums/apiEnum'; import { ApiScenarioStatus, RequestCaseStatus, ScenarioCreateComposition } from '@/enums/apiEnum';
import type { ScenarioStepInfo } from '@/views/api-test/scenario/components/step/index.vue';
// //
const step = defineAsyncComponent(() => import('../components/step/index.vue')); const step = defineAsyncComponent(() => import('../components/step/index.vue'));
const params = defineAsyncComponent(() => import('../components/params.vue')); const params = defineAsyncComponent(() => import('../components/params.vue'));
@ -144,6 +146,7 @@
const scenario = ref<any>({ const scenario = ref<any>({
name: '', name: '',
moduleId: 'root', moduleId: 'root',
stepInfo: {} as ScenarioStepInfo,
status: RequestCaseStatus.PROCESSING, status: RequestCaseStatus.PROCESSING,
tags: [], tags: [],
params: [], params: [],

View File

@ -98,6 +98,7 @@ export default {
'apiScenario.case': '用例', 'apiScenario.case': '用例',
'apiScenario.scenario': '场景', 'apiScenario.scenario': '场景',
'apiScenario.sumSelected': '共选择', 'apiScenario.sumSelected': '共选择',
'apiScenario.scenarioConfig': '场景配置',
// 执行历史 // 执行历史
'apiScenario.executeHistory.searchPlaceholder': '通过ID或名称搜索', 'apiScenario.executeHistory.searchPlaceholder': '通过ID或名称搜索',
'apiScenario.executeHistory.num': '序号', 'apiScenario.executeHistory.num': '序号',