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

This commit is contained in:
baiqi 2024-03-20 19:20:54 +08:00 committed by Craftsman
parent 7b288a30e2
commit 5674d80bb5
29 changed files with 1503 additions and 484 deletions

View File

@ -27,10 +27,10 @@ import {
import {
ApiScenarioBatchDeleteParams,
ApiScenarioBatchEditParams,
ApiScenarioDetail,
ApiScenarioGetModuleParams,
ApiScenarioModuleUpdateParams,
ApiScenarioPageParams,
ApiScenarioTableItem,
ApiScenarioUpdateDTO,
ExecuteHistoryItem,
ExecutePageParams,
@ -81,12 +81,12 @@ export function deleteModule(id: string) {
// 获取接口场景列表
export function getScenarioPage(data: ApiScenarioPageParams) {
return MSR.post<CommonList<ApiScenarioDetail>>({ url: ScenarioPageUrl, data });
return MSR.post<CommonList<ApiScenarioTableItem>>({ url: ScenarioPageUrl, data });
}
// 获取回收站的接口场景列表
export function getTrashScenarioPage(data: ApiScenarioPageParams) {
return MSR.post<CommonList<ApiScenarioDetail>>({ url: ScenarioTrashPageUrl, data });
return MSR.post<CommonList<ApiScenarioTableItem>>({ url: ScenarioTrashPageUrl, data });
}
// 更新接口场景

View File

@ -33,13 +33,7 @@
<slot name="title" v-bind="_props"></slot>
</template>
<template v-if="$slots['extra'] || props.nodeMoreActions" #extra="_props">
<div
v-if="_props.hideMoreAction !== true"
:class="[
'ms-tree-node-extra',
focusNodeKey === _props[props.fieldNames.key] ? 'ms-tree-node-extra--focus' : '', // TODO:
]"
>
<div v-if="_props.hideMoreAction !== true" class="ms-tree-node-extra">
<slot name="extra" v-bind="_props"></slot>
<MsTableMoreAction
v-if="props.nodeMoreActions"
@ -110,7 +104,7 @@
virtualListProps?: VirtualListProps; //
disabledTitleTooltip?: boolean; // tooltip
actionOnNodeClick?: 'expand'; //
nodeHighlightBackgroundColor?: string; //
nodeHighlightClass?: string; //
titleTooltipPosition?:
| 'top'
| 'tl'
@ -352,10 +346,10 @@
if (val?.toString() !== '') {
focusEl.value = treeRef.value?.$el.querySelector(`[data-key="${val}"]`);
if (focusEl.value) {
focusEl.value.style.backgroundColor = props.nodeHighlightBackgroundColor || 'rgb(var(--primary-1))';
focusEl.value.classList.add(props.nodeHighlightClass || 'ms-tree-node-focus');
}
} else if (focusEl.value) {
focusEl.value.style.backgroundColor = '';
focusEl.value.classList.remove(props.nodeHighlightClass || 'ms-tree-node-focus');
}
}
);
@ -465,7 +459,7 @@
width: 60%;
}
.ms-tree-node-extra {
@apply invisible relative flex w-0 items-center;
@apply invisible relative sticky right-0 flex w-0 items-center;
margin-left: -4px;
height: 32px;
@ -489,13 +483,19 @@
margin-right: 4px;
}
}
.ms-tree-node-extra--focus {
@apply visible w-auto;
}
.arco-tree-node-custom-icon {
@apply hidden;
}
}
.ms-tree-node-focus {
background-color: rgb(var(--primary-1));
.arco-tree-node-title {
background-color: rgb(var(--primary-1));
}
.ms-tree-node-extra {
@apply visible w-auto;
}
}
.arco-tree-node-selected {
background-color: rgb(var(--primary-1));
.arco-tree-node-minus-icon,
@ -519,6 +519,9 @@
}
}
}
.arco-tree-node-title {
background-color: rgb(var(--primary-1));
}
}
.arco-tree-node-disabled {
&:hover {

View File

@ -115,6 +115,7 @@ export default {
'common.batchEdit': 'Batch Edit',
'common.tagsInputPlaceholder': 'Enter the content and press Enter to directly add tags',
'common.move': 'Move',
'common.moveSuccess': 'Move successful',
'common.batchMove': 'Batch move',
'common.batchCopy': 'Batch copy',
'common.batchMoveSuccess': 'Batch move successful',

View File

@ -116,6 +116,7 @@ export default {
'common.batchEdit': '批量编辑',
'common.tagsInputPlaceholder': '输入内容后回车可直接添加标签',
'common.move': '移动',
'common.moveSuccess': '移动成功',
'common.batchMove': '批量移动',
'common.batchCopy': '批量复制',
'common.batchMoveSuccess': '批量移动成功',

View File

@ -1,5 +1,7 @@
import { ScenarioStepInfo } from '@/views/api-test/scenario/components/step/index.vue';
import { ApiDefinitionCustomField } from '@/models/apiTest/management';
import { ApiScenarioStatus, RequestComposition, RequestDefinitionStatus, RequestImportFormat } from '@/enums/apiEnum';
import { ApiScenarioStatus, RequestComposition, RequestDefinitionStatus } from '@/enums/apiEnum';
import { BatchApiParams, TableQueryParams } from '../common';
import { ExecuteApiRequestFullParams, ResponseDefinition } from './common';
@ -40,7 +42,7 @@ export interface ApiScenarioUpdateDTO {
}
// 场景详情
export interface ApiScenarioDetail {
export interface ApiScenarioTableItem {
id: string;
name: string;
method: string;
@ -177,3 +179,25 @@ export type CustomApiStep = ExecuteApiRequestFullParams & {
activeTab: RequestComposition;
useEnv: string;
};
// 场景步骤-循环控制器类型
export type ScenarioStepLoopType = 'num' | 'while' | 'forEach';
// 场景步骤-循环控制器-循环类型
export type ScenarioStepLoopWhileType = 'condition' | 'expression';
// 场景步骤-步骤插入类型
export type CreateStepAction = 'addChildStep' | 'insertBefore' | 'insertAfter' | undefined;
// 场景步骤
export interface Scenario {
id: string;
name: string;
moduleId: string | number;
stepInfo: ScenarioStepInfo;
status: RequestDefinitionStatus;
tags: string[];
params: Record<string, any>[];
// 前端渲染字段
label: string;
closable: boolean;
isNew: boolean;
unSaved: boolean;
executeLoading: boolean; // 执行loading
}

View File

@ -1,3 +1,4 @@
import { cloneDeep } from 'lodash-es';
import JSEncrypt from 'jsencrypt';
import { BatchActionQueryParams, MsTableColumnData } from '@/components/pure/ms-table/type';
@ -6,7 +7,6 @@ import { BugEditCustomField, CustomFieldItem } from '@/models/bug-management';
import type { CustomAttributes } from '@/models/caseManagement/featureCase';
import { isObject } from './is';
import { json } from 'stream/consumers';
type TargetContext = '_self' | '_parent' | '_blank' | '_top';
@ -194,6 +194,9 @@ export interface TreeNode<T> {
* @param tree
* @param customNodeFn
* @param customChildrenKey key
* @param parent
* @param parentPath
* @param level
* @returns
*/
export function mapTree<T>(
@ -201,33 +204,40 @@ export function mapTree<T>(
customNodeFn: (node: TreeNode<T>, path: string) => TreeNode<T> | null = (node) => node,
customChildrenKey = 'children',
parentPath = '',
level = 0
level = 0,
parent: TreeNode<T> | null = null
): T[] {
if (!Array.isArray(tree)) {
tree = [tree];
let cloneTree = cloneDeep(tree);
if (!Array.isArray(cloneTree)) {
cloneTree = [cloneTree];
}
return tree
.map((node: TreeNode<T>) => {
const fullPath = node.path ? `${parentPath}/${node.path}`.replace(/\/+/g, '/') : '';
const newNode = typeof customNodeFn === 'function' ? customNodeFn(node, fullPath) : node;
if (newNode) {
newNode.level = level;
if (newNode[customChildrenKey] && newNode[customChildrenKey].length > 0) {
newNode[customChildrenKey] = mapTree(
newNode[customChildrenKey],
customNodeFn,
customChildrenKey,
fullPath,
level + 1
);
function mapFunc(
_tree: TreeNode<T> | TreeNode<T>[] | T | T[],
_parentPath = '',
_level = 0,
_parent: TreeNode<T> | null = null
): T[] {
if (!Array.isArray(_tree)) {
_tree = [_tree];
}
return _tree
.map((node: TreeNode<T>, i: number) => {
const fullPath = node.path ? `${_parentPath}/${node.path}`.replace(/\/+/g, '/') : '';
node.order = i + 1; // order从 1 开始
node.parent = _parent || undefined; // 没有父节点说明是树的第一层
const newNode = typeof customNodeFn === 'function' ? customNodeFn(node, fullPath) : node;
if (newNode) {
newNode.level = _level;
if (newNode[customChildrenKey] && newNode[customChildrenKey].length > 0) {
newNode[customChildrenKey] = mapFunc(newNode[customChildrenKey], fullPath, _level + 1, node);
}
}
}
return newNode;
})
.filter(Boolean);
return newNode;
})
.filter(Boolean);
}
return mapFunc(cloneTree, parentPath, level, parent);
}
/**
@ -347,7 +357,7 @@ export function findNodePathByKey<T>(
return null;
}
/**
*
* /
* @param treeArr
* @param targetKey
* @param newNode
@ -356,22 +366,49 @@ export function findNodePathByKey<T>(
*/
export function insertNode<T>(
treeArr: TreeNode<T>[],
targetKey: string,
targetKey: string | number,
newNode: TreeNode<T>,
position: 'before' | 'after',
position: 'before' | 'after' | 'inside',
customFunc?: (node: TreeNode<T>, parent?: TreeNode<T>) => void,
customKey = 'key'
): void {
function insertNodeInTree(tree: TreeNode<T>[], parent?: TreeNode<T>): boolean {
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
if (node[customKey] === targetKey) {
// 如果当前节点的 customKey 与目标 customKey 匹配,则在当前节点前/后插入新节点
// 如果当前节点的 customKey 与目标 customKey 匹配,则在当前节点前/后/内部插入新节点
const childrenArray = parent ? parent.children || [] : treeArr; // 父节点没有 children 属性,说明是树的第一层,使用 treeArr
const index = childrenArray.findIndex((item) => item[customKey] === node[customKey]);
if (position === 'before') {
newNode.parent = parent || node.parent;
newNode.order = node.order;
childrenArray.splice(index, 0, newNode);
for (let j = index + 1; j < childrenArray.length; j++) {
// 更新插入节点之后的节点的 order
if (childrenArray[j].order !== undefined) {
childrenArray[j].order += 1;
}
}
} else if (position === 'after') {
newNode.parent = parent || node.parent;
newNode.order = node.order + 1;
childrenArray.splice(index + 1, 0, newNode);
// 更新插入节点之后的节点的 order
for (let j = index + 2; j < childrenArray.length; j++) {
if (childrenArray[j].order !== undefined) {
childrenArray[j].order += 1;
}
}
} else if (position === 'inside') {
if (!node.children) {
node.children = [];
}
newNode.parent = node;
newNode.order = node.children.length + 1;
node.children.push(newNode);
}
if (typeof customFunc === 'function') {
customFunc(newNode, parent);
}
// 插入后返回 true
return true;
@ -386,6 +423,54 @@ export function insertNode<T>(
insertNodeInTree(treeArr);
}
/**
*
* @param treeArr
* @param dragNode
* @param dropNode
* @param dropPosition -1: before, 0: inside, 1: after
* @param customKey key
*/
export function handleTreeDragDrop<T>(
treeArr: TreeNode<T>[],
dragNode: TreeNode<T>,
dropNode: TreeNode<T>,
dropPosition: number,
customKey = 'key'
): boolean {
// 把 dragNode 从原来的位置删除
const parentChildren = dragNode.parent?.children || treeArr;
if (dragNode.parent?.[customKey] === dropNode[customKey] && dropPosition === 0) {
// 如果拖动的节点释放到自己的父节点上,不做任何操作
return false;
}
const index = parentChildren.findIndex((node: TreeNode<T>) => node[customKey] === dragNode[customKey]);
if (index !== -1) {
parentChildren.splice(index, 1);
// 更新删除节点后的节点的 order
for (let i = index; i < parentChildren.length; i++) {
parentChildren[i].order -= 1;
}
}
// 拖动节点插入到目标节点的 children 数组中
if (dropPosition === 0) {
insertNode(dropNode.parent?.children || treeArr, dropNode[customKey], dragNode, 'inside', undefined, customKey);
} else {
// 拖动节点插入到目标节点的前/后
insertNode(
dropNode.parent?.children || treeArr,
dropNode[customKey],
dragNode,
dropPosition === -1 ? 'before' : 'after',
undefined,
customKey
);
}
return true;
}
/**
*
* @param treeArr

View File

@ -1,5 +1,5 @@
interface Tree {
id: number;
id: string | number;
groupId?: number;
children?: Tree[];
[key: string]: any;

View File

@ -459,7 +459,7 @@
>
<template #rightTitle>
<div class="flex justify-between">
<div class="text-[var(--color-text-1)]">
<div class="text-[var(--color-text-4)]">
{{ t('apiTestDebug.quickInputParamsTip') }}
</div>
</div>

View File

@ -103,7 +103,8 @@
<!-- 接口定义-调试模式可保存或保存为新用例 -->
<a-dropdown
v-if="requestVModel.mode === 'debug'"
:loading="saveLoading || (isHttpProtocol && !requestVModel.url)"
:loading="saveLoading"
:disabled="isHttpProtocol && !requestVModel.url"
@select="handleSelect"
>
<a-button type="secondary">
@ -1401,7 +1402,7 @@
}
//
function saveAsCase() {
function saveAsCase(done: (closed: boolean) => void) {
saveCaseModalFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
@ -1421,12 +1422,14 @@
};
await addCase(params);
emit('addDone');
done(true);
Message.success(t('common.saveSuccess'));
saveCaseModalVisible.value = false;
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
done(false);
} finally {
saveCaseLoading.value = false;
}

View File

@ -37,7 +37,7 @@ export default {
'apiTestDebug.batchAddParamsTip1': '书写格式:参数名:参数值;如 nama:natural多条记录以换行分隔',
'apiTestDebug.batchAddParamsTip2': '默认添加为 string 类型file 类型参数无法在此编辑',
'apiTestDebug.batchAddParamsTip3': '批量添加里的参数名重复,默认以最后一条数据为最新数据',
'apiTestDebug.quickInputParamsTip': '支持Mock/JMeter/Json/Text/String等',
'apiTestDebug.quickInputParamsTip': '支持 Mock/JMeter/Json/Text/String 等',
'apiTestDebug.descPlaceholder': '请输入内容',
'apiTestDebug.noneBody': '请求没有 Body',
'apiTestDebug.sendAsMainText': '作为正文发送',

View File

@ -110,7 +110,9 @@
</div>
<div v-else :id="nodeData.id" class="inline-flex w-full">
<div class="one-line-text w-[calc(100%-32px)] text-[var(--color-text-1)]">{{ nodeData.name }}</div>
<div v-if="!props.isModal" class="ml-[4px] text-[var(--color-text-4)]">({{ nodeData.count || 0 }})</div>
<div v-if="!props.isModal" class="ml-[4px] text-[var(--color-text-4)]"
>({{ modulesCount[nodeData.id] || 0 }})</div
>
</div>
</template>
<template v-if="!props.readOnly && !props.isModal" #extra="nodeData">
@ -450,9 +452,9 @@
}
}
async function handleProtocolChange() {
function handleProtocolChange() {
emit('changeProtocol', moduleProtocol.value);
await initModules();
initModules();
initModuleCount();
}
@ -467,9 +469,9 @@
isExpandAll.value = !isExpandAll.value;
}
async function changeApiExpand() {
function changeApiExpand() {
isExpandApi.value = !isExpandApi.value;
await initModules();
initModules();
initModuleCount();
}
@ -515,7 +517,7 @@
}
Message.success(t('apiTestDebug.deleteSuccess'));
emit('deleteNode', node.id, node.type === 'MODULE');
await initModules();
initModules();
initModuleCount();
} catch (error) {
// eslint-disable-next-line no-console
@ -629,8 +631,8 @@
}
}
async function handleAddFinish() {
await initModules();
function handleAddFinish() {
initModules();
initModuleCount();
}
@ -646,14 +648,14 @@
}
}
onBeforeMount(async () => {
onBeforeMount(() => {
initProtocolList();
await initModules();
initModules();
initModuleCount();
});
async function refresh() {
await initModules();
function refresh() {
initModules();
initModuleCount();
}

View File

@ -184,7 +184,10 @@
() => visible.value,
(val) => {
if (val) {
resetModuleAndTable();
// 使 v-if tick
nextTick(() => {
resetModuleAndTable();
});
}
},
{

View File

@ -1,6 +1,6 @@
<template>
<div
class="rounded-[0_999px_999px_0] border border-solid px-[8px] py-[2px] text-[12px] leading-[16px]"
class="text-nowrap rounded-[0_999px_999px_0] border border-solid px-[8px] py-[2px] text-[12px] leading-[16px]"
:style="{
borderColor: type.color,
color: type.color,

View File

@ -1,7 +1,63 @@
import { ScenarioStepLoopType, ScenarioStepLoopWhileType } from '@/models/apiTest/scenario';
export const defaultStepItemCommon = {
checked: false,
expanded: false,
enabled: true,
children: [],
loopNum: 0,
loopType: 'num' as ScenarioStepLoopType,
loopSpace: 0,
variableName: '',
variablePrefix: '',
loopWhileType: 'condition' as ScenarioStepLoopWhileType,
variableVal: '',
condition: 'equal',
overTime: 0,
expression: '',
waitTime: 0,
description: '',
};
export default {};
export const conditionOptions = [
{
value: 'equal',
label: 'apiScenario.equal',
},
{
value: 'notEqualTo',
label: 'apiScenario.notEqualTo',
},
{
value: 'greater',
label: 'apiScenario.greater',
},
{
value: 'less',
label: 'apiScenario.less',
},
{
value: 'greaterOrEqual',
label: 'apiScenario.greaterOrEqual',
},
{
value: 'lessOrEqual',
label: 'apiScenario.lessOrEqual',
},
{
value: 'include',
label: 'apiScenario.include',
},
{
value: 'notInclude',
label: 'apiScenario.notInclude',
},
{
value: 'null',
label: 'apiScenario.null',
},
{
value: 'notNull',
label: 'apiScenario.notNull',
},
];

View File

@ -270,7 +270,7 @@
import useTableStore from '@/hooks/useTableStore';
import useAppStore from '@/store/modules/app';
import { ApiScenarioDetail, ApiScenarioUpdateDTO } from '@/models/apiTest/scenario';
import { ApiScenarioTableItem, ApiScenarioUpdateDTO } from '@/models/apiTest/scenario';
import { ApiScenarioStatus } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
@ -563,7 +563,7 @@
/**
* 删除接口
*/
function deleteScenario(record?: ApiScenarioDetail, isBatch?: boolean, params?: BatchActionQueryParams) {
function deleteScenario(record?: ApiScenarioTableItem, isBatch?: boolean, params?: BatchActionQueryParams) {
let title = t('api_scenario.table.deleteScenarioTipTitle', { name: record?.name });
let selectIds = [record?.id || ''];
if (isBatch) {
@ -613,7 +613,7 @@
* 处理表格更多按钮事件
* @param item
*/
function handleTableMoreActionSelect(item: ActionsItem, record: ApiScenarioDetail) {
function handleTableMoreActionSelect(item: ActionsItem, record: ApiScenarioTableItem) {
switch (item.eventTag) {
case 'delete':
deleteScenario(record);
@ -734,7 +734,7 @@
const selectedBatchOptModuleName = ref(''); //
const batchOptionType = ref(''); //
const batchOptionScenarioCount = ref<number>(0);
const activeScenario = ref<ApiScenarioDetail | null>(null); //
const activeScenario = ref<ApiScenarioTableItem | null>(null); //
const scenarioBatchOptTreeLoading = ref(false); // loading
/**
@ -834,7 +834,7 @@
}
}
function openScenarioTab(record: ApiScenarioDetail) {
function openScenarioTab(record: ApiScenarioTableItem) {
Message.info('// todo @ba1q1');
}

View File

@ -1,72 +0,0 @@
<template>
<a-dropdown
v-model:popup-visible="visible"
:position="props.position || 'bottom'"
:popup-translate="props.popupTranslate"
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 { TriggerPopupTranslate } from '@arco-design/web-vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import { useI18n } from '@/hooks/useI18n';
import { ScenarioAddStepActionType } from '@/enums/apiEnum';
import { DropdownPosition } from '@arco-design/web-vue/es/dropdown/interface';
const props = defineProps<{
position?: DropdownPosition;
popupTranslate?: TriggerPopupTranslate;
}>();
const emit = defineEmits<{
(e: 'select', val: ScenarioAddStepActionType): void;
}>();
const { t } = useI18n();
const visible = defineModel<boolean>('visible', {
default: false,
});
function openTutorial() {
window.open('https://zhuanlan.zhihu.com/p/597905464?utm_id=0', '_blank');
}
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,260 @@
<template>
<a-dropdown
v-model:popup-visible="visible"
:position="props.position || 'bottom'"
:popup-translate="props.popupTranslate"
class="scenario-action-dropdown"
@select="(val) => handleCreateActionSelect(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 { TriggerPopupTranslate } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue';
import { ScenarioStepItem } from '../stepTree.vue';
import { useI18n } from '@/hooks/useI18n';
import { findNodeByKey, getGenerateId, insertNode, TreeNode } from '@/utils';
import { CreateStepAction } from '@/models/apiTest/scenario';
import { ScenarioAddStepActionType, ScenarioStepType } from '@/enums/apiEnum';
import { defaultStepItemCommon } from '@/views/api-test/scenario/components/config';
import { DropdownPosition } from '@arco-design/web-vue/es/dropdown/interface';
const props = defineProps<{
position?: DropdownPosition;
popupTranslate?: TriggerPopupTranslate;
createStepAction?: CreateStepAction;
}>();
const emit = defineEmits<{
(e: 'close');
(
e: 'otherCreate',
type:
| ScenarioAddStepActionType.IMPORT_SYSTEM_API
| ScenarioAddStepActionType.CUSTOM_API
| ScenarioAddStepActionType.SCRIPT_OPERATION,
step?: ScenarioStepItem
);
}>();
const { t } = useI18n();
const visible = defineModel<boolean>('visible', {
default: false,
});
const steps = defineModel<ScenarioStepItem[]>('steps', {
required: true,
});
const selectedKeys = defineModel<(string | number)[]>('selectedKeys', {
required: true,
});
const step = defineModel<ScenarioStepItem>('step', {
default: undefined,
});
/**
* 增加步骤时判断父节点是否选中如果选中则需要把新节点也选中
*/
function isParentSelected(parent?: TreeNode<ScenarioStepItem>) {
if (parent && selectedKeys.value.includes(parent.id)) {
//
selectedKeys.value.push(step.value.id);
}
}
/**
* 处理添加子步骤插入步骤前/后操作
*/
function handleCreateStep(defaultStepInfo: ScenarioStepItem) {
switch (props.createStepAction) {
case 'addChildStep':
const id = getGenerateId();
if (step.value?.children) {
step.value.children.push({
...cloneDeep(defaultStepItemCommon),
...defaultStepInfo,
id,
order: step.value.children.length + 1,
});
} else {
step.value.children = [
{
...cloneDeep(defaultStepItemCommon),
...defaultStepInfo,
id,
order: 1,
},
];
}
if (selectedKeys.value.includes(step.value.id)) {
//
selectedKeys.value.push(id);
}
break;
case 'insertBefore':
insertNode<ScenarioStepItem>(
step.value.children || steps.value,
step.value.id,
{
...cloneDeep(defaultStepItemCommon),
...defaultStepInfo,
id: getGenerateId(),
order: step.value.order,
},
'before',
isParentSelected,
'id'
);
break;
case 'insertAfter':
insertNode<ScenarioStepItem>(
step.value.children || steps.value,
step.value.id,
{
...cloneDeep(defaultStepItemCommon),
...defaultStepInfo,
id: getGenerateId(),
order: step.value.order + 1,
},
'after',
isParentSelected,
'id'
);
break;
default:
break;
}
}
/**
* 处理创建步骤操作
* @param val 创建步骤类型
*/
function handleCreateActionSelect(val: ScenarioAddStepActionType) {
switch (val) {
case ScenarioAddStepActionType.LOOP_CONTROL:
if (step.value) {
handleCreateStep({
type: ScenarioStepType.LOOP_CONTROL,
name: t('apiScenario.loopControl'),
} as ScenarioStepItem);
} else {
steps.value.push({
...cloneDeep(defaultStepItemCommon),
id: getGenerateId(),
order: steps.value.length + 1,
type: ScenarioStepType.LOOP_CONTROL,
name: t('apiScenario.loopControl'),
});
}
break;
case ScenarioAddStepActionType.CONDITION_CONTROL:
if (step.value) {
handleCreateStep({
type: ScenarioStepType.CONDITION_CONTROL,
name: t('apiScenario.conditionControl'),
} as ScenarioStepItem);
} else {
steps.value.push({
...cloneDeep(defaultStepItemCommon),
id: getGenerateId(),
order: steps.value.length + 1,
type: ScenarioStepType.CONDITION_CONTROL,
name: t('apiScenario.conditionControl'),
});
}
break;
case ScenarioAddStepActionType.ONLY_ONCE_CONTROL:
if (step.value) {
handleCreateStep({
type: ScenarioStepType.ONLY_ONCE_CONTROL,
name: t('apiScenario.onlyOnceControl'),
} as ScenarioStepItem);
} else {
steps.value.push({
...cloneDeep(defaultStepItemCommon),
id: getGenerateId(),
order: steps.value.length + 1,
type: ScenarioStepType.ONLY_ONCE_CONTROL,
name: t('apiScenario.onlyOnceControl'),
});
}
break;
case ScenarioAddStepActionType.WAIT_TIME:
if (step.value) {
handleCreateStep({
type: ScenarioStepType.WAIT_TIME,
name: t('apiScenario.waitTime'),
} as ScenarioStepItem);
} else {
steps.value.push({
...cloneDeep(defaultStepItemCommon),
id: getGenerateId(),
order: steps.value.length + 1,
type: ScenarioStepType.WAIT_TIME,
name: t('apiScenario.waitTime'),
});
}
break;
case ScenarioAddStepActionType.IMPORT_SYSTEM_API:
case ScenarioAddStepActionType.CUSTOM_API:
case ScenarioAddStepActionType.SCRIPT_OPERATION:
if (step.value) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.value.id, 'id');
if (realStep) {
emit('otherCreate', val, realStep as ScenarioStepItem);
}
} else {
emit('otherCreate', val);
}
break;
default:
break;
}
if (step.value) {
document.getElementById(step.value.id.toString())?.click();
}
}
function openTutorial() {
window.open('https://zhuanlan.zhihu.com/p/597905464?utm_id=0', '_blank');
}
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,132 @@
<template>
<a-trigger
trigger="click"
class="arco-trigger-menu absolute"
content-class="w-[160px]"
position="br"
@popup-visible-change="handleActionTriggerChange"
>
<MsButton :id="step.id" type="icon" class="ms-tree-node-extra__btn !mr-[4px]" @click="emit('click')">
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton>
<template #content>
<createStepActions
v-model:visible="innerStep.actionDropdownVisible"
v-model:selected-keys="selectedKeys"
v-model:steps="steps"
v-model:step="innerStep"
:create-step-action="activeCreateAction"
position="br"
:popup-translate="[-7, -10]"
@other-create="(type, step) => emit('otherCreate', type, step)"
@close="emit('close')"
>
<span></span>
</createStepActions>
<div class="arco-trigger-menu-inner">
<div
v-if="showAddChildStep"
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeCreateAction === 'addChildStep' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick('addChildStep')"
>
<icon-plus size="12" />
{{ t('apiScenario.addChildStep') }}
</div>
<div
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeCreateAction === 'insertBefore' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick('insertBefore')"
>
<icon-left size="12" />
{{ t('apiScenario.insertBefore') }}
</div>
<div
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeCreateAction === 'insertAfter' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick('insertAfter')"
>
<icon-left size="12" />
{{ t('apiScenario.insertAfter') }}
</div>
</div>
</template>
</a-trigger>
</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 { ScenarioStepItem } from '../stepTree.vue';
import createStepActions from './createStepActions.vue';
import { useI18n } from '@/hooks/useI18n';
import { CreateStepAction } from '@/models/apiTest/scenario';
import { ScenarioAddStepActionType, ScenarioStepType } from '@/enums/apiEnum';
const props = defineProps<{
step: ScenarioStepItem;
}>();
const emit = defineEmits<{
(e: 'close');
(e: 'click');
(
e: 'otherCreate',
type:
| ScenarioAddStepActionType.IMPORT_SYSTEM_API
| ScenarioAddStepActionType.CUSTOM_API
| ScenarioAddStepActionType.SCRIPT_OPERATION,
step?: ScenarioStepItem
);
}>();
const { t } = useI18n();
const steps = defineModel<ScenarioStepItem[]>('steps', {
required: true,
});
const selectedKeys = defineModel<(string | number)[]>('selectedKeys', {
required: true,
});
const innerStep = ref<ScenarioStepItem>(props.step);
watch(
() => props.step,
(val) => {
innerStep.value = val;
}
);
const showAddChildStep = computed(() => {
return [
ScenarioStepType.LOOP_CONTROL,
ScenarioStepType.CONDITION_CONTROL,
ScenarioStepType.ONLY_ONCE_CONTROL,
ScenarioStepType.COPY_SCENARIO,
].includes(innerStep.value.type);
});
const activeCreateAction = ref<CreateStepAction>();
function handleTriggerActionClick(action: CreateStepAction) {
innerStep.value.actionDropdownVisible = true;
activeCreateAction.value = action;
}
function handleActionTriggerChange(val: boolean) {
if (!val) {
// TODO:
activeCreateAction.value = undefined;
innerStep.value.actionDropdownVisible = false;
emit('close');
}
}
</script>
<style lang="less" scoped></style>

View File

@ -10,7 +10,7 @@
/>
<div class="flex items-center gap-[4px]">
{{ t('apiScenario.sum') }}
<div class="text-[rgb(var(--primary-5))]">{{ stepInfo.steps.length }}</div>
<div class="text-[rgb(var(--primary-5))]">{{ totalStepCount }}</div>
{{ t('apiScenario.steps') }}
</div>
</div>
@ -89,19 +89,37 @@
/>
</div>
</div>
<a-modal
v-model:visible="batchToggleVisible"
:title="isBatchEnable ? t('common.batchEnable') : t('common.batchDisable')"
:width="480"
:ok-text="isBatchEnable ? t('common.enable') : t('common.disable')"
class="ms-modal-form"
title-align="start"
body-class="!p-0"
@close="resetBatchToggle"
@before-ok="handleBeforeBatchToggle"
>
<div class="mb-[8px] text-[var(--color-text-1)]">{{ t('apiScenario.range') }}</div>
<a-radio-group v-model:model-value="batchToggleRange">
<a-radio value="top">{{ t('apiScenario.topStep') }}</a-radio>
<a-radio value="all">{{ t('apiScenario.allStep') }}</a-radio>
</a-radio-group>
</a-modal>
</template>
<script setup lang="ts">
// import dayjs from 'dayjs';
import { Message } from '@arco-design/web-vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import stepTree, { ScenarioStepItem } from './stepTree.vue';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { RequestMethods, ScenarioExecuteStatus, ScenarioStepType } from '@/enums/apiEnum';
import { countNodes } from '@/utils/tree';
export interface ScenarioStepInfo {
id: string | number;
@ -118,91 +136,18 @@
const appStore = useAppStore();
const { t } = useI18n();
const stepInfo = defineModel<ScenarioStepInfo>('step', {
required: true,
});
const checkedAll = ref(false); //
const indeterminate = ref(false); //
const isExpandAll = ref(false); //
const checkedKeys = ref<string[]>([]); // key
const checkedKeys = ref<(string | number)[]>([]); // key
const stepTreeRef = ref<InstanceType<typeof stepTree>>();
const keyword = ref('');
const stepInfo = ref<ScenarioStepInfo>({
id: new Date().getTime(),
steps: [
{
id: 1,
num: 10086,
order: 1,
checked: false,
expanded: false,
enabled: true,
type: ScenarioStepType.QUOTE_API,
name: 'API1',
description: 'API1描述',
method: RequestMethods.GET,
belongProjectId: appStore.currentProjectId,
belongProjectName: '项目名称',
actionDropdownVisible: false,
children: [
{
id: 11,
order: 1,
checked: false,
expanded: false,
enabled: true,
type: ScenarioStepType.QUOTE_CASE,
name: 'API11',
description: 'API11描述',
status: ScenarioExecuteStatus.SUCCESS,
num: 100861,
belongProjectId: '989d23d23d',
belongProjectName: '项目名称1',
actionDropdownVisible: false,
},
{
id: 12,
order: 2,
checked: false,
expanded: false,
enabled: true,
type: ScenarioStepType.QUOTE_SCENARIO,
name: 'API12',
description: 'API12描述',
status: ScenarioExecuteStatus.SUCCESS,
num: 100862,
belongProjectId: '989d23d23d',
belongProjectName: '项目名称2',
actionDropdownVisible: false,
},
],
},
{
id: 2,
order: 2,
checked: false,
expanded: false,
enabled: true,
type: ScenarioStepType.LOOP_CONTROL,
name: 'API1',
description: 'API1描述',
status: ScenarioExecuteStatus.SUCCESS,
actionDropdownVisible: false,
},
{
id: 3,
order: 3,
checked: false,
expanded: false,
enabled: true,
type: ScenarioStepType.ONLY_ONCE_CONTROL,
name: 'API1',
description: 'API1描述',
status: ScenarioExecuteStatus.SUCCESS,
actionDropdownVisible: false,
},
],
executeTime: '',
executeSuccessCount: 0,
executeFailCount: 0,
});
const totalStepCount = computed(() => countNodes(stepInfo.value.steps));
function handleChangeAll(value: boolean | (string | number | boolean)[]) {
indeterminate.value = false;
@ -231,12 +176,44 @@
isExpandAll.value = !isExpandAll.value;
}
// /
const isBatchEnable = ref(false);
const batchToggleVisible = ref(false);
const batchToggleRange = ref('top');
function batchEnable() {
console.log('批量启用');
batchToggleVisible.value = true;
isBatchEnable.value = true;
}
function batchDisable() {
console.log('批量禁用');
batchToggleVisible.value = true;
isBatchEnable.value = false;
}
function resetBatchToggle() {
batchToggleVisible.value = false;
batchToggleRange.value = 'top';
isBatchEnable.value = false;
}
async function handleBeforeBatchToggle(done: (closed: boolean) => void) {
try {
let ids = checkedKeys.value;
if (batchToggleRange.value === 'top') {
ids = stepInfo.value.steps.map((item) => item.id);
}
console.log('ids', ids);
await new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
done(true);
Message.success(isBatchEnable.value ? t('common.enableSuccess') : t('common.disableSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
function batchDebug() {

View File

@ -1,7 +1,91 @@
<template>
<div>condition </div>
<div class="flex items-center gap-[4px]" draggable="false">
<a-tooltip :content="innerData.variableName" :disabled="!innerData.variableName">
<a-input
v-model:model-value="innerData.variableName"
size="mini"
class="w-[100px] px-[8px]"
:max-length="255"
:placeholder="t('apiScenario.variableName', { suffix: '${var}' })"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
<a-select
v-model:model-value="innerData.condition"
size="mini"
class="w-[90px] px-[8px]"
@change="handleInputChange"
>
<a-option v-for="opt of conditionOptions" :key="opt.value" :value="opt.value">
{{ t(opt.label) }}
</a-option>
</a-select>
<a-tooltip :content="innerData.variableVal" :disabled="!innerData.variableVal">
<a-input
:id="innerData.id"
v-model:model-value="innerData.variableVal"
size="mini"
class="w-[110px] px-[8px]"
:placeholder="t('apiScenario.variableVal')"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { conditionOptions } from '@/views/api-test/scenario/components/config';
export interface ConditionContentProps {
id: string;
variableName: string;
condition: string;
variableVal: string;
}
const props = defineProps<{
data: ConditionContentProps;
}>();
const emit = defineEmits<{
(e: 'change', innerData: ConditionContentProps): void;
(e: 'quickInput', dataKey: keyof ConditionContentProps): void;
}>();
const { t } = useI18n();
const innerData = ref(props.data);
watchEffect(() => {
innerData.value = props.data;
});
//
const dbClick = inject<
Ref<{
e: MouseEvent | null;
timeStamp: number;
}>
>('dbClick');
watch(
() => dbClick?.value.timeStamp,
() => {
// @ts-ignore
if ((dbClick?.value.e?.target as Element).parentNode?.id.includes(innerData.value.id)) {
emit('quickInput', 'variableVal');
}
}
);
function handleInputChange() {
nextTick(() => {
emit('change', innerData.value);
});
}
</script>
<style lang="less" scoped></style>

View File

@ -1,7 +1,243 @@
<template>
<div>loop </div>
<div class="flex items-center gap-[4px]" draggable="false">
<a-input-group>
<a-select
v-model:model-value="innerData.loopType"
:options="loopOptions"
size="mini"
class="w-[85px] px-[8px]"
@change="handleInputChange"
/>
<a-tooltip
v-if="innerData.loopType === 'num'"
:content="innerData.loopNum.toString()"
:disabled="!innerData.loopNum"
>
<a-input-number
v-model:model-value="innerData.loopNum"
class="w-[80px] px-[8px]"
size="mini"
:step="1"
:min="0"
hide-button
:precision="0"
model-event="input"
@blur="handleInputChange"
>
<template #prefix>
<div class="text-[12px] text-[var(--color-text-4)]">{{ t('apiScenario.num') }}:</div>
</template>
</a-input-number>
</a-tooltip>
</a-input-group>
<template v-if="innerData.loopType === 'forEach'">
<a-tooltip :content="innerData.variableName" :disabled="!innerData.variableName">
<a-input
v-model:model-value="innerData.variableName"
size="mini"
class="w-[110px] px-[8px]"
:max-length="255"
:placeholder="t('apiScenario.variableName')"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
<div class="font-medium">in</div>
<a-tooltip :content="innerData.variablePrefix" :disabled="!innerData.variablePrefix">
<a-input
v-model:model-value="innerData.variablePrefix"
size="mini"
class="w-[110px] px-[8px]"
:placeholder="t('apiScenario.variablePrefix')"
:max-length="255"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
</template>
<template v-else-if="innerData.loopType === 'while'">
<a-select
v-model:model-value="innerData.loopWhileType"
:options="whileOptions"
size="mini"
class="w-[75px] px-[8px]"
@change="handleInputChange"
/>
<template v-if="innerData.loopWhileType === 'condition'">
<a-tooltip :content="innerData.variableName" :disabled="!innerData.variableName">
<a-input
v-model:model-value="innerData.variableName"
size="mini"
class="w-[100px] px-[8px]"
:max-length="255"
:placeholder="t('apiScenario.variableName', { suffix: '${var}' })"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
<a-select
v-model:model-value="innerData.condition"
size="mini"
class="w-[90px] px-[8px]"
@change="handleInputChange"
>
<a-option v-for="opt of conditionOptions" :key="opt.value" :value="opt.value">
{{ t(opt.label) }}
</a-option>
</a-select>
<a-tooltip :content="innerData.variableVal" :disabled="!innerData.variableVal">
<a-input
:id="innerData.id"
v-model:model-value="innerData.variableVal"
size="mini"
class="w-[110px] px-[8px]"
:placeholder="t('apiScenario.variableVal')"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
</template>
<a-tooltip v-else :content="innerData.expression" :disabled="!innerData.expression">
<a-input
:id="innerData.id"
v-model:model-value="innerData.expression"
size="mini"
class="w-[200px] px-[8px]"
:placeholder="t('apiScenario.expression')"
@change="handleInputChange"
>
</a-input>
</a-tooltip>
<a-tooltip :content="innerData.overTime.toString()" :disabled="!innerData.overTime">
<a-input-number
v-model:model-value="innerData.overTime"
class="w-[100px] px-[8px]"
size="mini"
:step="1"
:min="0"
hide-button
:precision="0"
model-event="input"
@blur="handleInputChange"
>
<template #prefix>
<div class="text-[12px] text-[var(--color-text-4)]">{{ t('apiScenario.overTime') }}:</div>
</template>
</a-input-number>
</a-tooltip>
</template>
<a-tooltip
v-if="innerData.loopType !== 'while'"
:content="innerData.loopSpace.toString()"
:disabled="!innerData.loopSpace"
>
<a-input-number
v-model:model-value="innerData.loopSpace"
size="mini"
:step="1"
:min="0"
:precision="0"
hide-button
class="w-[110px] px-[8px]"
model-event="input"
@blur="handleInputChange"
>
<template #prefix>
<div class="text-[12px] text-[var(--color-text-4)]">{{ t('apiScenario.space') }}:</div>
</template>
</a-input-number>
</a-tooltip>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { ScenarioStepLoopType, ScenarioStepLoopWhileType } from '@/models/apiTest/scenario';
import { ScenarioStepType } from '@/enums/apiEnum';
import { conditionOptions } from '@/views/api-test/scenario/components/config';
export interface LoopContentProps {
id: string | number;
num: number;
name: string;
type: ScenarioStepType;
loopNum: number;
loopType: ScenarioStepLoopType;
loopSpace: number;
variableName: string;
variablePrefix: string;
loopWhileType: ScenarioStepLoopWhileType;
variableVal: string;
condition: string;
overTime: number;
expression: string;
}
const props = defineProps<{
data: LoopContentProps;
}>();
const emit = defineEmits<{
(e: 'change', innerData: LoopContentProps): void;
(e: 'quickInput', dataKey: keyof LoopContentProps): void;
}>();
const { t } = useI18n();
const innerData = ref(props.data);
const loopOptions = [
{
value: 'num',
label: t('apiScenario.num'),
},
{
value: 'while',
label: 'while',
},
{
value: 'forEach',
label: 'forEach',
},
];
const whileOptions = [
{
value: 'condition',
label: t('apiScenario.condition'),
},
{
value: 'expression',
label: t('apiScenario.expression'),
},
];
watchEffect(() => {
innerData.value = props.data;
});
//
const dbClick = inject<
Ref<{
e: MouseEvent | null;
timeStamp: number;
}>
>('dbClick');
watch(
() => dbClick?.value.timeStamp,
() => {
// @ts-ignore
if ((dbClick?.value.e?.target as Element).parentNode?.id.includes(innerData.value.id)) {
emit('quickInput', innerData.value.loopWhileType === 'condition' ? 'variableVal' : 'expression');
}
}
);
function handleInputChange() {
nextTick(() => {
emit('change', innerData.value);
});
}
</script>
<style lang="less" scoped></style>

View File

@ -1,7 +0,0 @@
<template>
<div>only once </div>
</template>
<script setup lang="ts"></script>
<style lang="less" scoped></style>

View File

@ -1,7 +1,53 @@
<template>
<div>waitTime </div>
<div class="flex items-center gap-[4px]" draggable="false">
<a-tooltip :content="innerData.waitTime.toString()" :disabled="!innerData.waitTime">
<a-input-number
v-model:model-value="innerData.waitTime"
class="max-w-[500px] px-[8px]"
size="mini"
:step="1"
:min="0"
hide-button
:precision="0"
model-event="input"
@blur="handleInputChange"
>
<template #prefix>
<div class="text-[12px] text-[var(--color-text-4)]">{{ t('apiScenario.waitTimeMs') }}:</div>
</template>
</a-input-number>
</a-tooltip>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
export interface WaitTimeContentProps {
id: string | number;
waitTime: number;
}
const props = defineProps<{
data: WaitTimeContentProps;
}>();
const emit = defineEmits<{
(e: 'change', innerData: WaitTimeContentProps): void;
}>();
const { t } = useI18n();
const innerData = ref(props.data);
watchEffect(() => {
innerData.value = props.data;
});
function handleInputChange() {
nextTick(() => {
emit('change', innerData.value);
});
}
</script>
<style lang="less" scoped></style>

View File

@ -10,6 +10,7 @@
:keyword="props.stepKeyword"
:expand-all="props.expandAll"
:node-more-actions="stepMoreActions"
:filter-more-action-func="setStepMoreAction"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:virtual-list-props="{
height: '100%',
@ -18,7 +19,7 @@
buffer: 15, // 10 padding
}"
title-class="step-tree-node-title"
node-highlight-background-color="var(--color-text-n9)"
node-highlight-class="step-tree-node-focus"
action-on-node-click="expand"
disabled-title-tooltip
checkable
@ -28,6 +29,7 @@
@expand="handleStepExpand"
@more-actions-close="() => setFocusNodeKey('')"
@more-action-select="handleStepMoreActionSelect"
@drop="handleDrop"
>
<template #title="step">
<div class="flex w-full items-center gap-[8px]">
@ -41,7 +43,11 @@
<!-- 步骤展开折叠按钮 -->
<a-tooltip
v-if="step.children?.length > 0"
:content="t('apiScenario.expandStepTip', { count: step.children.length })"
:content="
t(step.expanded ? 'apiScenario.collapseStepTip' : 'apiScenario.expandStepTip', {
count: step.children.length,
})
"
>
<div class="flex cursor-pointer items-center gap-[2px] text-[var(--color-text-1)]">
<MsIcon
@ -71,28 +77,33 @@
<!-- 步骤整体内容 -->
<div class="relative flex flex-1 items-center gap-[4px]">
<!-- 步骤差异内容按步骤类型展示不同组件 -->
<component :is="getStepContent(step)" :data="getStepContentData(step)" />
<component
:is="getStepContent(step)"
:data="step"
@quick-input="setQuickInput(step, $event)"
@change="handleStepContentChange($event, step)"
@click.stop
/>
<!-- APICASE场景步骤名称 -->
<template v-if="checkStepIsApi(step)">
<apiMethodName v-if="checkStepShowMethod(step)" :method="step.method" />
<div
v-if="step.id === showStepNameEditInputStepId"
class="absolute left-0 top-[-2px] z-10 w-[calc(100%-24px)]"
class="name-warp absolute left-0 top-[-2px] z-10 w-[calc(100%-24px)]"
@click.stop
>
<a-input
:id="step.id"
v-model:model-value="tempStepName"
:placeholder="t('apiScenario.pleaseInputStepName')"
:max-length="255"
size="small"
@press-enter="applyStepChange(step)"
@blur="applyStepChange(step)"
@press-enter="applyStepNameChange(step)"
@blur="applyStepNameChange(step)"
/>
</div>
<a-tooltip :content="step.name">
<div class="step-name-container">
<div class="one-line-text mr-[4px] max-w-[250px] font-medium text-[var(--color-text-1)]">
<div class="one-line-text mr-[4px] max-w-[150px] font-medium text-[var(--color-text-1)]">
{{ step.name }}
</div>
<MsIcon
@ -103,70 +114,57 @@
</div>
</a-tooltip>
</template>
<!-- 其他步骤描述 -->
<template v-else>
<div
v-if="step.id === showStepDescEditInputStepId"
class="desc-warp absolute left-0 top-[-2px] z-10 w-[calc(100%-24px)]"
>
<a-input
v-model:model-value="tempStepDesc"
:default-value="step.description || t('apiScenario.pleaseInputStepDesc')"
:placeholder="t('apiScenario.pleaseInputStepDesc')"
:max-length="255"
size="small"
@press-enter="applyStepDescChange(step)"
@blur="applyStepDescChange(step)"
@click.stop
>
<template #prefix>
{{ t('common.desc') }}
</template>
</a-input>
</div>
<a-tooltip :content="step.description" :disabled="!step.description">
<div class="step-name-container">
<div
:class="`one-line-text mr-[4px] ${
step.type === ScenarioStepType.ONLY_ONCE_CONTROL ? 'max-w-[750px]' : 'max-w-[150px]'
} font-normal text-[var(--color-text-4)]`"
>
{{ step.description || t('apiScenario.pleaseInputStepDesc') }}
</div>
<MsIcon
type="icon-icon_edit_outlined"
class="edit-script-name-icon"
@click.stop="handleStepDescClick(step)"
/>
</div>
</a-tooltip>
</template>
</div>
</div>
</div>
</template>
<template #extra="step">
<a-trigger
trigger="click"
class="arco-trigger-menu absolute"
content-class="w-[160px]"
position="br"
@popup-visible-change="handleActionTriggerChange($event, step)"
>
<MsButton
:id="step.id"
type="icon"
class="ms-tree-node-extra__btn !mr-[4px]"
@click="setFocusNodeKey(step.id)"
>
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton>
<template #content>
<actionDropdown
v-model:visible="step.actionDropdownVisible"
position="br"
class="scenario-action-dropdown"
:popup-translate="[-7, -10]"
@select="(val) => handleActionSelect(val as ScenarioAddStepActionType, step)"
>
<span></span>
</actionDropdown>
<div class="arco-trigger-menu-inner">
<div
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeAction === 'addChildStep' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick(step, 'addChildStep')"
>
<icon-plus size="12" />
{{ t('apiScenario.addChildStep') }}
</div>
<div
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeAction === 'insertBefore' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick(step, 'insertBefore')"
>
<icon-left size="12" />
{{ t('apiScenario.insertBefore') }}
</div>
<div
:class="[
'arco-trigger-menu-item !mx-0 !w-full',
activeAction === 'insertAfter' ? 'step-tree-active-action' : '',
]"
@click="handleTriggerActionClick(step, 'insertAfter')"
>
<icon-left size="12" />
{{ t('apiScenario.insertAfter') }}
</div>
</div>
</template>
</a-trigger>
<stepInsertStepTrigger
v-model:selected-keys="selectedKeys"
v-model:steps="steps"
:step="step"
@click="setFocusNodeKey(step.id)"
@other-create="handleOtherCreate"
@close="setFocusNodeKey('')"
/>
</template>
<template #extraEnd="step">
<executeStatus v-if="step.status" :status="step.status" size="small" />
@ -180,59 +178,88 @@
</template>
</MsTree>
</a-spin>
<actionDropdown
class="scenario-action-dropdown"
@select="(val) => handleActionSelect(val as ScenarioAddStepActionType)"
>
<createStepActions v-model:selected-keys="selectedKeys" v-model:steps="steps" @other-create="handleOtherCreate">
<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" />
</createStepActions>
<!-- todo 执行上传文件转存文件等需要传入相关方法 当前场景环境使用的是假数据 add-step暂时只是将数据传递到当前组件的customDemoStep对象中用于再次打开的时候测试编辑功能 -->
<customApiDrawer
v-if="customApiDrawerVisible"
v-model:visible="customApiDrawerVisible"
:env-detail-item="{ id: 'demp-id-112233', projectId: '123456', name: 'demo环境' }"
:request="customDemoStep"
@add-step="addCustomApiStep"
/>
<scriptOperationDrawer v-model:visible="scriptOperationDrawerVisible" />
<importApiDrawer v-if="importApiDrawerVisible" v-model:visible="importApiDrawerVisible" />
<scriptOperationDrawer v-if="scriptOperationDrawerVisible" v-model:visible="scriptOperationDrawerVisible" />
<a-modal
v-model:visible="showQuickInput"
:title="quickInputDataKey ? t(`apiScenario.${quickInputDataKey}`) : ''"
:ok-text="t('apiTestDebug.apply')"
:ok-button-props="{ disabled: !quickInputParamValue || quickInputParamValue.trim() === '' }"
class="ms-modal-form"
body-class="!p-0"
:width="680"
title-align="start"
@ok="applyQuickInput"
@close="clearQuickInput"
>
<MsCodeEditor
v-if="showQuickInput"
v-model:model-value="quickInputParamValue"
theme="MS-text"
height="300px"
:show-full-screen="false"
>
<template #rightTitle>
<div class="flex justify-between">
<div class="text-[var(--color-text-4)]">
{{ t('apiTestDebug.quickInputParamsTip') }}
</div>
</div>
</template>
</MsCodeEditor>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core';
import { Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
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 { MsTreeExpandedData, MsTreeNodeData } from '@/components/business/ms-tree/types';
import customApiDrawer from '../common/customApiDrawer.vue';
import executeStatus from '../common/executeStatus.vue';
import importApiDrawer from '../common/importApiDrawer/index.vue';
import scriptOperationDrawer from '../common/scriptOperationDrawer.vue';
import stepType from '../common/stepType.vue';
import actionDropdown from './actionDropdown.vue';
import createStepActions from './createAction/createStepActions.vue';
import stepInsertStepTrigger from './createAction/stepInsertStepTrigger.vue';
import conditionContent from './stepNodeComposition/conditionContent.vue';
import customApiContent from './stepNodeComposition/customApiContent.vue';
import loopControlContent from './stepNodeComposition/loopContent.vue';
import onlyOnceControlContent from './stepNodeComposition/onlyOnceContent.vue';
import quoteContent from './stepNodeComposition/quoteContent.vue';
import waitTimeContent from './stepNodeComposition/waitTimeContent.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { deleteNode, findNodeByKey, getGenerateId, insertNode, mapTree } from '@/utils';
import { deleteNode, findNodeByKey, getGenerateId, handleTreeDragDrop, insertNode, mapTree, TreeNode } from '@/utils';
import { CustomApiStep } from '@/models/apiTest/scenario';
import { CustomApiStep, ScenarioStepLoopWhileType } from '@/models/apiTest/scenario';
import { RequestMethods, ScenarioAddStepActionType, ScenarioExecuteStatus, ScenarioStepType } from '@/enums/apiEnum';
import { defaultStepItemCommon } from '../config';
//
const MsCodeEditor = defineAsyncComponent(() => import('@/components/pure/ms-code-editor/index.vue'));
const customApiDrawer = defineAsyncComponent(() => import('../common/customApiDrawer.vue'));
const importApiDrawer = defineAsyncComponent(() => import('../common/importApiDrawer/index.vue'));
const scriptOperationDrawer = defineAsyncComponent(() => import('../common/scriptOperationDrawer.vue'));
export interface ScenarioStepItem {
id: string | number;
@ -253,6 +280,16 @@
checked: boolean; //
expanded: boolean; //
actionDropdownVisible?: boolean; //
parent?: ScenarioStepItem | ScenarioStepItem[]; // undefined
loopNum: number;
loopType: 'num' | 'while' | 'forEach';
loopSpace: number;
variableName: string;
variablePrefix: string;
loopWhileType: ScenarioStepLoopWhileType;
variableVal: string;
condition: string;
overTime: number;
}
const props = defineProps<{
@ -266,15 +303,18 @@
const steps = defineModel<ScenarioStepItem[]>('steps', {
required: true,
});
const checkedKeys = defineModel<string[]>('checkedKeys', {
const checkedKeys = defineModel<(string | number)[]>('checkedKeys', {
required: true,
});
const selectedKeys = ref<string[]>([]); //
const selectedKeys = ref<(string | number)[]>([]); //
const loading = ref(false);
const treeRef = ref<InstanceType<typeof MsTree>>();
const focusStepKey = ref<string>(''); // key
/**
* 根据步骤类型获取步骤内容组件
*/
function getStepContent(step: ScenarioStepItem) {
switch (step.type) {
case ScenarioStepType.QUOTE_API:
@ -287,41 +327,10 @@
return loopControlContent;
case ScenarioStepType.CONDITION_CONTROL:
return conditionContent;
case ScenarioStepType.ONLY_ONCE_CONTROL:
return onlyOnceControlContent;
case ScenarioStepType.WAIT_TIME:
return waitTimeContent;
default:
return '';
}
}
function getStepContentData(step: ScenarioStepItem) {
switch (step.type) {
case ScenarioStepType.QUOTE_API:
case ScenarioStepType.QUOTE_CASE:
case ScenarioStepType.QUOTE_SCENARIO:
return {
id: step.id,
belongProjectId: step.belongProjectId,
belongProjectName: step.belongProjectName,
num: step.num,
name: step.name,
type: step.type,
currentProjectId: appStore.currentProjectId,
};
case ScenarioStepType.CUSTOM_API:
return {};
case ScenarioStepType.LOOP_CONTROL:
return {};
case ScenarioStepType.CONDITION_CONTROL:
return {};
case ScenarioStepType.ONLY_ONCE_CONTROL:
return {};
case ScenarioStepType.WAIT_TIME:
return {};
default:
return '';
return () => null;
}
}
@ -351,44 +360,86 @@
].includes(step.type);
}
const activeAction = ref('');
function handleTriggerActionClick(step: ScenarioStepItem, action: string) {
step.actionDropdownVisible = true;
activeAction.value = action;
}
function handleActionTriggerChange(val: boolean, step: ScenarioStepItem) {
if (!val) {
activeAction.value = '';
step.actionDropdownVisible = false;
setFocusNodeKey('');
/**
* 增加步骤时判断父节点是否选中如果选中则需要把新节点也选中
*/
function isParentSelected(step: TreeNode<ScenarioStepItem>, parent?: TreeNode<ScenarioStepItem>) {
if (parent && selectedKeys.value.includes(parent.id)) {
//
selectedKeys.value.push(step.id);
}
}
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 setStepMoreAction(items: ActionsItem[], node: MsTreeNodeData) {
if ((node as ScenarioStepItem).type === ScenarioStepType.CUSTOM_API) {
//
return [
{
label: 'common.copy',
eventTag: 'copy',
},
{
label: 'apiScenario.saveAsApi',
eventTag: 'saveAsApi',
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
}
if ((node as ScenarioStepItem).type === ScenarioStepType.QUOTE_SCENARIO) {
return [
{
label: 'common.copy',
eventTag: 'copy',
},
{
label: 'apiScenario.scenarioConfig',
eventTag: 'config',
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
}
if ((node as ScenarioStepItem).type === ScenarioStepType.QUOTE_CASE) {
return [
{
label: 'common.copy',
eventTag: 'copy',
},
{
label: 'apiScenario.saveAsCase',
eventTag: 'saveAsCase',
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
}
return stepMoreActions;
}
function handleStepMoreActionSelect(item: ActionsItem, node: MsTreeNodeData) {
switch (item.eventTag) {
case 'execute':
console.log('执行步骤', node);
break;
case 'copy':
const id = getGenerateId();
insertNode<ScenarioStepItem>(
@ -397,27 +448,20 @@
{
...cloneDeep(
mapTree<ScenarioStepItem>(node, (childNode) => {
const childId = getGenerateId();
if (selectedKeys.value.includes(node.id)) {
//
selectedKeys.value.push(childId);
}
return {
...childNode,
id: childId,
id: getGenerateId(), // TODO: ID
};
})[0]
),
name: `copy-${node.name}`,
order: node.order + 1,
id,
},
'after',
isParentSelected,
'id'
);
if (selectedKeys.value.includes(node.id)) {
//
selectedKeys.value.push(id);
}
break;
case 'config':
console.log('config', node);
@ -443,12 +487,13 @@
tempStepName.value = step.name;
showStepNameEditInputStepId.value = step.id;
nextTick(() => {
const input = treeRef.value?.$el.querySelector('.arco-input') as HTMLInputElement;
//
const input = treeRef.value?.$el.querySelector('.name-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
}
function applyStepChange(step: ScenarioStepItem) {
function applyStepNameChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.id, 'id');
if (realStep) {
realStep.name = tempStepName.value;
@ -456,6 +501,38 @@
showStepNameEditInputStepId.value = '';
}
/**
* 处理步骤名称编辑
*/
const showStepDescEditInputStepId = ref<string | number>('');
const tempStepDesc = ref('');
function handleStepDescClick(step: ScenarioStepItem) {
tempStepDesc.value = step.description;
showStepDescEditInputStepId.value = step.id;
nextTick(() => {
//
const input = treeRef.value?.$el.querySelector('.desc-warp .arco-input-wrapper .arco-input') as HTMLInputElement;
input?.focus();
});
}
function applyStepDescChange(step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.id, 'id');
if (realStep) {
realStep.description = tempStepDesc.value;
}
showStepDescEditInputStepId.value = '';
}
function handleStepContentChange($event, step: ScenarioStepItem) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.id, 'id');
if (realStep) {
Object.keys($event).forEach((key) => {
realStep[key] = $event[key];
});
}
}
/**
* 处理步骤展开折叠
*/
@ -482,63 +559,70 @@
const importApiDrawerVisible = ref(false);
const customApiDrawerVisible = ref(false);
const scriptOperationDrawerVisible = ref(false);
const activeStep = ref<ScenarioStepItem>(); //
function handleActionSelect(val: ScenarioAddStepActionType, step?: ScenarioStepItem) {
switch (val) {
function handleOtherCreate(
type:
| ScenarioAddStepActionType.IMPORT_SYSTEM_API
| ScenarioAddStepActionType.CUSTOM_API
| ScenarioAddStepActionType.SCRIPT_OPERATION,
step?: ScenarioStepItem
) {
activeStep.value = step;
switch (type) {
case ScenarioAddStepActionType.IMPORT_SYSTEM_API:
importApiDrawerVisible.value = true;
break;
case ScenarioAddStepActionType.CUSTOM_API:
customApiDrawerVisible.value = true;
break;
case ScenarioAddStepActionType.LOOP_CONTROL:
steps.value.push({
...defaultStepItemCommon,
id: Date.now(),
order: steps.value.length + 1,
type: ScenarioStepType.LOOP_CONTROL,
name: '循环控制',
description: '循环控制描述',
});
break;
case ScenarioAddStepActionType.CONDITION_CONTROL:
steps.value.push({
...defaultStepItemCommon,
id: Date.now(),
order: steps.value.length + 1,
type: ScenarioStepType.CONDITION_CONTROL,
name: '条件控制',
description: '条件控制描述',
});
break;
case ScenarioAddStepActionType.ONLY_ONCE_CONTROL:
steps.value.push({
...defaultStepItemCommon,
id: Date.now(),
order: steps.value.length + 1,
type: ScenarioStepType.ONLY_ONCE_CONTROL,
name: '仅执行一次',
description: '仅执行一次描述',
});
break;
case ScenarioAddStepActionType.SCRIPT_OPERATION:
scriptOperationDrawerVisible.value = true;
break;
case ScenarioAddStepActionType.WAIT_TIME:
steps.value.push({
...defaultStepItemCommon,
id: Date.now(),
order: steps.value.length + 1,
type: ScenarioStepType.WAIT_TIME,
name: '等待时间',
description: '等待时间描述',
});
break;
default:
break;
}
if (step) {
document.getElementById(step.id.toString())?.click();
}
/**
* 处理文件夹树节点拖拽事件
* @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 {
loading.value = true;
if (dropPosition === 0) {
//
if (selectedKeys.value.includes(dropNode.id)) {
//
selectedKeys.value.push(dragNode.id);
}
} else if (dropNode.parent && selectedKeys.value.includes(dropNode.parent.id)) {
//
selectedKeys.value.push(dragNode.id);
} else if (dragNode.parent && selectedKeys.value.includes(dragNode.parent.id)) {
//
selectedKeys.value = selectedKeys.value.filter((e) => e !== dragNode.id);
}
const dragResult = handleTreeDragDrop(steps.value, dragNode, dropNode, dropPosition, 'id');
if (dragResult) {
Message.success(t('common.moveSuccess'));
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
nextTick(() => {
loading.value = false;
});
}
}
@ -549,6 +633,49 @@
customDemoStep.value = { ...step };
}
const showQuickInput = ref(false);
const quickInputParamValue = ref('');
const quickInputDataKey = ref('');
function setQuickInput(step: ScenarioStepItem, dataKey: string) {
const realStep = findNodeByKey<ScenarioStepItem>(steps.value, step.id, 'id');
if (realStep) {
activeStep.value = realStep as ScenarioStepItem;
}
quickInputDataKey.value = dataKey;
quickInputParamValue.value = step.variableVal;
showQuickInput.value = true;
}
function clearQuickInput() {
activeStep.value = undefined;
quickInputParamValue.value = '';
quickInputDataKey.value = '';
}
function applyQuickInput() {
if (activeStep.value) {
activeStep.value[quickInputDataKey.value] = quickInputParamValue.value;
showQuickInput.value = false;
clearQuickInput();
}
}
const dbClick = ref({
e: null as MouseEvent | null,
timeStamp: 0,
});
onMounted(() => {
useEventListener(treeRef.value?.$el, 'dblclick', (e) => {
dbClick.value.e = e;
dbClick.value.timeStamp = Date.now();
});
});
//
provide('dbClick', readonly(dbClick));
defineExpose({
checkAll,
});
@ -585,6 +712,7 @@
.loop-levels(0, 99); //
:deep(.arco-tree-node) {
padding: 0 8px;
min-width: 1000px;
border: 1px solid var(--color-text-n8);
border-radius: var(--border-radius-medium) !important;
&:not(:first-child) {
@ -597,7 +725,7 @@
}
}
.arco-tree-node-title {
@apply !cursor-pointer;
@apply !cursor-pointer bg-white;
padding: 12px 4px;
&:hover {
@ -624,11 +752,6 @@
color: rgb(var(--primary-5));
}
}
&[draggable='true']:hover {
.step-node-content {
padding-left: 20px;
}
}
.arco-tree-node-title-text {
@apply flex-1;
}
@ -640,19 +763,28 @@
@apply hidden;
}
.arco-tree-node-drag-icon {
@apply ml-0;
top: 13px;
left: 24px;
width: 16px;
height: 16px;
.arco-icon {
font-size: 16px !important;
}
@apply hidden;
}
.ms-tree-node-extra {
gap: 4px;
background-color: var(--color-text-n9) !important;
}
}
:deep(.arco-tree-node-selected) {
.arco-tree-node-title {
.step-tree-node-title {
font-weight: 400;
color: var(--color-text-1);
}
}
}
:deep(.step-tree-node-focus) {
background-color: var(--color-text-n9) !important;
.arco-tree-node-title {
background-color: var(--color-text-n9) !important;
}
.ms-tree-node-extra {
@apply !visible !w-auto;
}
}
</style>

View File

@ -3,7 +3,7 @@
<template #first>
<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]">
<step v-if="activeKey === ScenarioCreateComposition.STEP" is-new />
<step v-if="activeKey === ScenarioCreateComposition.STEP" v-model:step="scenario.stepInfo" is-new />
</a-tab-pane>
<a-tab-pane :key="ScenarioCreateComposition.PARAMS" :title="t('apiScenario.params')" class="p-[16px]">
<params v-if="activeKey === ScenarioCreateComposition.PARAMS" v-model:params="scenario.params" />
@ -124,10 +124,9 @@
import { useI18n } from '@/hooks/useI18n';
import { Scenario } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
import { ApiScenarioStatus, RequestCaseStatus, ScenarioCreateComposition } from '@/enums/apiEnum';
import type { ScenarioStepInfo } from '@/views/api-test/scenario/components/step/index.vue';
import { ApiScenarioStatus, ScenarioCreateComposition } from '@/enums/apiEnum';
//
const step = defineAsyncComponent(() => import('../components/step/index.vue'));
@ -143,13 +142,8 @@
const { t } = useI18n();
const activeKey = ref<ScenarioCreateComposition>(ScenarioCreateComposition.STEP);
const scenario = ref<any>({
name: '',
moduleId: 'root',
stepInfo: {} as ScenarioStepInfo,
status: RequestCaseStatus.PROCESSING,
tags: [],
params: [],
const scenario = defineModel<Scenario>('scenario', {
required: true,
});
</script>

View File

@ -44,7 +44,7 @@
BASE_INFO
</a-tab-pane>
<a-tab-pane :key="ScenarioCreateComposition.STEP" :title="t('apiScenario.step')" class="px-[24px] py-[16px]">
<step v-if="activeKey === ScenarioCreateComposition.STEP" />
<step v-if="activeKey === ScenarioCreateComposition.STEP" :step="previewDetail.step" />
</a-tab-pane>
<a-tab-pane
:key="ScenarioCreateComposition.PARAMS"

View File

@ -1,8 +1,8 @@
<template>
<MsCard no-content-padding simple>
<div class="p-[24px_24px_8px_24px]">
<div class="flex items-center justify-between p-[24px_24px_8px_24px]">
<MsEditableTab
v-model:active-tab="activeApiTab"
v-model:active-tab="activeScenarioTab"
v-model:tabs="apiTabs"
class="flex-1 overflow-hidden"
@add="newTab"
@ -15,9 +15,14 @@
</a-tooltip>
</template>
</MsEditableTab>
<div class="flex items-center gap-[8px]">
<a-button type="primary" :loading="saveLoading" @click="saveScenario">
{{ t('common.save') }}
</a-button>
</div>
</div>
<a-divider class="!my-0" />
<div v-if="activeApiTab.id === 'all'" class="pageWrap">
<div v-if="activeScenarioTab.id === 'all'" class="pageWrap">
<MsSplitBox :size="300" :max="0.5">
<template #first>
<div class="flex h-full flex-col">
@ -52,11 +57,11 @@
</template>
</MsSplitBox>
</div>
<div v-else-if="activeApiTab.is" class="pageWrap">
<detail :detail="activeApiTab"></detail>
<div v-else-if="activeScenarioTab.isNew" class="pageWrap">
<create v-model:scenario="activeScenarioTab" :module-tree="folderTree"></create>
</div>
<div v-else class="pageWrap">
<create :module-tree="folderTree"></create>
<detail :detail="activeScenarioTab"></detail>
</div>
</MsCard>
</template>
@ -66,49 +71,63 @@
* @description 接口测试-接口场景主页
*/
import { onBeforeMount, ref } from 'vue';
import { Message } from '@arco-design/web-vue';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
import { TabItem } from '@/components/pure/ms-editable-tab/types';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import scenarioModuleTree from './components/scenarioModuleTree.vue';
import { ScenarioStepInfo } from './components/step/index.vue';
import ScenarioTable from '@/views/api-test/scenario/components/scenarioTable.vue';
import { getTrashModuleCount } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import router from '@/router';
import useAppStore from '@/store/modules/app';
import { ApiScenarioGetModuleParams } from '@/models/apiTest/scenario';
import { ApiScenarioGetModuleParams, Scenario } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
import { RequestDefinitionStatus } from '@/enums/apiEnum';
import { ApiTestRouteEnum } from '@/enums/routeEnum';
import useAppStore from '../../../store/modules/app';
//
const detail = defineAsyncComponent(() => import('./detail/index.vue'));
const create = defineAsyncComponent(() => import('./create/index.vue'));
const { t } = useI18n();
const apiTabs = ref<TabItem[]>([
const apiTabs = ref<Scenario[]>([
{
id: 'all',
label: t('apiScenario.allScenario'),
closable: false,
},
} as Scenario,
]);
const activeApiTab = ref<TabItem>(apiTabs.value[0]);
const activeScenarioTab = ref<Scenario>(apiTabs.value[0]);
function newTab() {
apiTabs.value.push({
id: `newTab${apiTabs.value.length}`,
label: `New Tab ${apiTabs.value.length}`,
id: `${t('apiScenario.createScenario')}${apiTabs.value.length}`,
label: `${t('apiScenario.createScenario')}${apiTabs.value.length}`,
closable: true,
isNew: true,
name: '',
moduleId: 'root',
stepInfo: {
id: new Date().getTime(),
steps: [],
executeTime: '',
executeSuccessCount: 0,
executeFailCount: 0,
} as ScenarioStepInfo,
status: RequestDefinitionStatus.PROCESSING,
tags: [],
params: [],
executeLoading: false,
unSaved: false,
});
activeApiTab.value = apiTabs.value[apiTabs.value.length - 1];
activeScenarioTab.value = apiTabs.value[apiTabs.value.length - 1];
}
const folderTree = ref<ModuleTreeNode[]>([]);
@ -153,6 +172,21 @@
});
recycleModulesCount.value = res.all;
});
const saveLoading = ref(false);
async function saveScenario() {
saveLoading.value = true;
await new Promise((resolve) => {
setTimeout(() => {
resolve('');
}, 1000);
});
Message.success(activeScenarioTab.value.isNew ? t('common.createSuccess') : t('common.saveSuccess'));
activeScenarioTab.value.isNew = false;
activeScenarioTab.value.unSaved = false;
saveLoading.value = false;
}
</script>
<style scoped lang="less">

View File

@ -92,7 +92,7 @@ export default {
'apiScenario.executeTime': '执行时间:',
'apiScenario.executeResult': '执行结果',
'apiScenario.checkReport': '查看报告',
'apiScenario.searchByName': '通过名称搜索',
'apiScenario.searchByName': '通过步骤名称/描述搜索',
'apiScenario.api': '接口',
'apiScenario.case': '用例',
'apiScenario.scenario': '场景',
@ -104,9 +104,34 @@ export default {
'apiScenario.detailName': '名称',
'apiScenario.crossProject': '跨项目',
'apiScenario.expandStepTip': '展开 {count} 个子步骤',
'apiScenario.collapseStepTip': '折叠 {count} 个子步骤',
'apiScenario.addChildStep': '添加子步骤',
'apiScenario.insertBefore': '在之前插入步骤',
'apiScenario.insertAfter': '在之后插入步骤',
'apiScenario.num': '次数',
'apiScenario.space': '间隔(ms)',
'apiScenario.overTime': '超时(ms)',
'apiScenario.waitTimeMs': '等待(ms)',
'apiScenario.pleaseInputStepDesc': '请输入步骤描述',
'apiScenario.variableName': '变量名称{suffix}',
'apiScenario.variablePrefix': '变量前缀',
'apiScenario.variableVal': '变量值',
'apiScenario.condition': '条件',
'apiScenario.expression': '表达式',
'apiScenario.equal': '等于',
'apiScenario.notEqualTo': '不等于',
'apiScenario.greater': '大于',
'apiScenario.less': '小于',
'apiScenario.greaterOrEqual': '大于等于',
'apiScenario.lessOrEqual': '小于等于',
'apiScenario.include': '包含',
'apiScenario.notInclude': '不包含',
'apiScenario.null': '空',
'apiScenario.notNull': '非空',
'apiScenario.range': '范围',
'apiScenario.topStep': '一级步骤',
'apiScenario.allStep': '所有子步骤',
'apiScenario.saveAsApi': '保存为新接口',
// 执行历史
'apiScenario.executeHistory.searchPlaceholder': '通过ID或名称搜索',
'apiScenario.executeHistory.num': '序号',

View File

@ -109,7 +109,7 @@
import useTableStore from '@/hooks/useTableStore';
import useAppStore from '@/store/modules/app';
import { ApiScenarioDetail } from '@/models/apiTest/scenario';
import { ApiScenarioTableItem } from '@/models/apiTest/scenario';
import { ApiScenarioStatus } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
@ -331,7 +331,7 @@
});
//
async function recover(record?: ApiScenarioDetail, isBatch?: boolean) {
async function recover(record?: ApiScenarioTableItem, isBatch?: boolean) {
try {
if (isBatch) {
recoverLoading.value = true;
@ -360,7 +360,7 @@
/**
* 删除接口
*/
function deleteOperation(record?: ApiScenarioDetail, isBatch?: boolean, params?: BatchActionQueryParams) {
function deleteOperation(record?: ApiScenarioTableItem, isBatch?: boolean, params?: BatchActionQueryParams) {
let title = t('api_scenario.table.deleteScenarioTipTitle', { name: record?.name });
let selectIds = [record?.id || ''];
if (isBatch) {