feat(接口测试): 接口场景回收站模块、场景查询功能开发以及删除和恢复相关功能开发

This commit is contained in:
song-tianyang 2024-03-15 16:58:55 +08:00 committed by Craftsman
parent 08d7f02df7
commit 7fe9d34b3a
13 changed files with 1074 additions and 37 deletions

View File

@ -144,7 +144,7 @@ public class ApiScenarioController {
//需求补充回收站里的相关操作都不需要发通知
@GetMapping("/recover/{id}")
@Operation(summary = "接口测试-接口场景管理-删除场景到回收站")
@Operation(summary = "接口测试-接口场景管理-恢复场景")
@RequiresPermissions(PermissionConstants.PROJECT_API_SCENARIO_DELETE)
@Log(type = OperationLogType.RESTORE, expression = "#msClass.restoreLog(#id)", msClass = ApiScenarioLogService.class)
@CheckOwner(resourceId = "#id", resourceType = "api_scenario")

View File

@ -2,17 +2,24 @@ import MSR from '@/api/http/index';
import {
AddModuleUrl,
BatchCopyScenarioUrl,
BatchDeleteScenarioUrl,
BatchEditScenarioUrl,
BatchMoveScenarioUrl,
BatchRecoverScenarioUrl,
BatchRecycleScenarioUrl,
DeleteModuleUrl,
DeleteScenarioUrl,
ExecuteHistoryUrl,
GetModuleCountUrl,
GetModuleTreeUrl,
GetTrashModuleCountUrl,
GetTrashModuleTreeUrl,
MoveModuleUrl,
RecoverScenarioUrl,
RecycleScenarioUrl,
ScenarioHistoryUrl,
ScenarioPageUrl,
ScenarioTrashPageUrl,
UpdateModuleUrl,
UpdateScenarioUrl,
} from '@/api/requrls/api-test/scenario';
@ -30,7 +37,7 @@ import {
ScenarioHistoryItem,
ScenarioHistoryPageParams,
} from '@/models/apiTest/scenario';
import { AddModuleParams, CommonList, ModuleTreeNode, MoveModules } from '@/models/common';
import { AddModuleParams, BatchApiParams, CommonList, ModuleTreeNode, MoveModules } from '@/models/common';
// 更新模块
export function updateModule(data: ApiScenarioModuleUpdateParams) {
@ -52,6 +59,16 @@ export function getModuleCount(data: ApiScenarioGetModuleParams) {
return MSR.post({ url: GetModuleCountUrl, data });
}
// 获取回收站模块统计数量
export function getTrashModuleCount(data: ApiScenarioGetModuleParams) {
return MSR.post({ url: GetTrashModuleCountUrl, data });
}
// 获取回收站模块树
export function getTrashModuleTree(data: ApiScenarioGetModuleParams) {
return MSR.post<ModuleTreeNode[]>({ url: GetTrashModuleTreeUrl, data });
}
// 添加模块
export function addModule(data: AddModuleParams) {
return MSR.post({ url: AddModuleUrl, data });
@ -67,6 +84,11 @@ export function getScenarioPage(data: ApiScenarioPageParams) {
return MSR.post<CommonList<ApiScenarioDetail>>({ url: ScenarioPageUrl, data });
}
// 获取回收站的接口场景列表
export function getTrashScenarioPage(data: ApiScenarioPageParams) {
return MSR.post<CommonList<ApiScenarioDetail>>({ url: ScenarioTrashPageUrl, data });
}
// 更新接口场景
export function updateScenario(data: ApiScenarioUpdateDTO) {
return MSR.post({ url: UpdateScenarioUrl, data });
@ -117,3 +139,37 @@ export function getExecuteHistory(data: ExecutePageParams) {
export function getScenarioHistory(data: ScenarioHistoryPageParams) {
return MSR.post<CommonList<ScenarioHistoryItem>>({ url: ScenarioHistoryUrl, data });
}
// 恢复场景
export function recoverScenario(id: string) {
return MSR.get({ url: RecoverScenarioUrl, params: id });
}
// 批量恢复场景
export function batchRecoverScenario(data: {
moduleIds: string[];
selectAll: boolean;
condition: { keyword: string };
excludeIds: any[];
selectIds: any[];
projectId: string;
}) {
return MSR.post({ url: BatchRecoverScenarioUrl, data });
}
// 恢复场景
export function deleteScenario(id: string) {
return MSR.get({ url: DeleteScenarioUrl, params: id });
}
// 批量恢复场景
export function batchDeleteScenario(data: {
moduleIds: string[];
selectAll: boolean;
condition: { keyword: string };
excludeIds: any[];
selectIds: any[];
projectId: string;
}) {
return MSR.post({ url: BatchDeleteScenarioUrl, data });
}

View File

@ -12,27 +12,14 @@ export const BatchMoveScenarioUrl = '/api/scenario/batch-operation/move'; // 批
export const BatchCopyScenarioUrl = '/api/scenario/batch-operation/copy'; // 批量复制接口场景
export const BatchEditScenarioUrl = '/api/scenario/batch-operation/edit'; // 批量编辑接口场景
// export const GetEnvModuleUrl = '/api/scenario/module/env/tree'; // 获取环境的模块树
// export const AddDefinitionUrl = '/api/scenario/add'; // 添加接口场景
// export const GetDefinitionDetailUrl = '/api/scenario/get-detail'; // 获取接口场景详情
// export const TransferFileUrl = '/api/scenario/transfer'; // 文件转存
// export const TransferFileModuleOptionUrl = '/api/scenario/transfer/options'; // 文件转存目录
// export const UploadTempFileUrl = '/api/scenario/upload/temp/file'; // 临时文件上传
// export const DefinitionMockPageUrl = '/api/scenario/mock/page'; // mock列表
// export const UpdateMockStatusUrl = '/api/scenario/mock/enable/'; // 更新mock状态
// export const DeleteDefinitionUrl = '/api/scenario/delete-to-gc'; // 删除接口场景
// export const ImportDefinitionUrl = '/api/scenario/import'; // 导入接口场景
// export const SortDefinitionUrl = '/api/scenario/edit/pos'; // 接口场景拖拽
// export const BatchUpdateDefinitionUrl = '/api/scenario/batch-update'; // 批量更新接口场景
// export const BatchMoveDefinitionUrl = '/api/scenario/batch-move'; // 批量移动接口场景
// export const UpdateDefinitionScheduleUrl = '/api/scenario/schedule/update'; // 接口场景-定时同步-更新
// export const CheckDefinitionScheduleUrl = '/api/scenario/schedule/check'; // 接口场景-定时同步-检查 url 是否存在
// export const AddDefinitionScheduleUrl = '/api/scenario/schedule/add'; // 接口场景-定时同步-添加
// export const SwitchDefinitionScheduleUrl = '/api/scenario/schedule/switch'; // 接口场景-定时同步-开启关闭
// export const GetDefinitionScheduleUrl = '/api/scenario/schedule/get'; // 接口场景-定时同步-查询
// export const DeleteDefinitionScheduleUrl = '/api/scenario/schedule/delete'; // 接口场景-定时同步-删除
// export const DebugDefinitionUrl = '/api/scenario/debug'; // 接口场景-调试
// 回收站相关
export const GetTrashModuleTreeUrl = '/api/scenario/module/trash/tree';
export const GetTrashModuleCountUrl = '/api/scenario/module/trash/count';
export const ScenarioTrashPageUrl = '/api/scenario/trash/page';
export const DeleteScenarioUrl = '/api/scenario/delete';
export const RecoverScenarioUrl = '/api/scenario/recover';
export const BatchRecoverScenarioUrl = '/api/scenario/batch-operation/recover-gc';
export const BatchDeleteScenarioUrl = '/api/scenario/batch-operation/delete';
export const ExecuteHistoryUrl = '/api/scenario/execute/page'; // 场景执行历史
export const ScenarioHistoryUrl = '/api/scenario/operation-history/page'; // 场景变更历史

View File

@ -30,6 +30,7 @@ export default {
'menu.apiTest.management': '定义',
'menu.apiTest.management.definition': '定义',
'menu.apiTest.api': 'API列表',
'menu.apiTest.apiScenario': '场景',
'menu.apiTest.scenario': '场景',
'menu.apiTest.report': '报告',
'menu.uiTest': 'UI测试',

View File

@ -1,4 +1,4 @@
import { ApiTestRouteEnum } from '@/enums/routeEnum';
import { ApiTestRouteEnum, SettingRouteEnum } from '@/enums/routeEnum';
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
@ -78,11 +78,21 @@ const ApiTest: AppRouteRecordRaw = {
{
path: 'scenarioRecycle',
name: ApiTestRouteEnum.API_TEST_SCENARIO_RECYCLE,
component: () => import('@/views/api-test/scenario/index.vue'),
component: () => import('@/views/api-test/scenario/recycle.vue'),
meta: {
locale: 'menu.apiTest.scenario',
roles: ['*'],
isTopMenu: false,
breadcrumbs: [
{
name: ApiTestRouteEnum.API_TEST_SCENARIO,
locale: 'menu.apiTest.apiScenario',
},
{
name: ApiTestRouteEnum.API_TEST_SCENARIO_RECYCLE,
locale: 'common.recycle',
},
],
},
},

View File

@ -97,7 +97,7 @@
</a-option>
</a-select>
</template>
<template #action="{ record }">
<template #operation="{ record }">
<MsButton
v-permission="['PROJECT_API_SCENARIO:READ+EXECUTE']"
type="text"
@ -280,10 +280,11 @@
import useModal from '@/hooks/useModal';
import useTableStore from '@/hooks/useTableStore';
import useAppStore from '@/store/modules/app';
import { hasAnyPermission } from '@/utils/permission';
import { ApiScenarioDetail, ApiScenarioUpdateDTO } from '@/models/apiTest/scenario';
import { ApiScenarioStatus } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
import { ColumnEditTypeEnum, TableKeyEnum } from '@/enums/tableEnum';
const props = defineProps<{
class?: string;
@ -315,7 +316,6 @@
text: 'P3',
},
]);
const folderTreePathMap = inject('folderTreePathMap');
const emit = defineEmits(['refreshModuleTree']);
const keyword = ref('');
const moveModalVisible = ref(false);
@ -334,28 +334,35 @@
},
fixed: 'left',
width: 126,
showTooltip: true,
showInTable: true,
columnSelectorDisabled: true,
},
{
title: 'apiScenario.table.columns.name',
dataIndex: 'name',
showTooltip: true,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 134,
showTooltip: true,
showInTable: true,
columnSelectorDisabled: true,
},
{
title: 'apiScenario.table.columns.level',
dataIndex: 'priority',
slotName: 'priority',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.status',
dataIndex: 'status',
slotName: 'status',
width: 140,
showDrag: true,
},
{
title: 'apiScenario.table.columns.runResult',
@ -363,6 +370,7 @@
dataIndex: 'lastReportStatus',
showTooltip: true,
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.tags',
@ -370,6 +378,7 @@
isTag: true,
isStringTag: true,
width: 456,
showDrag: true,
},
{
title: 'apiScenario.table.columns.scenarioEnv',
@ -380,16 +389,19 @@
title: 'apiScenario.table.columns.steps',
dataIndex: 'stepTotal',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.passRate',
dataIndex: 'requestPassRate',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.module',
dataIndex: 'modulePath',
width: 176,
showDrag: true,
},
{
title: 'apiScenario.table.columns.createTime',
@ -399,6 +411,7 @@
sorter: true,
},
width: 189,
showDrag: true,
},
{
title: 'apiScenario.table.columns.updateTime',
@ -408,22 +421,25 @@
sorter: true,
},
width: 189,
showDrag: true,
},
{
title: 'apiScenario.table.columns.createUser',
dataIndex: 'createUser',
titleSlotName: 'createUser',
width: 109,
showDrag: true,
},
{
title: 'apiScenario.table.columns.updateUser',
dataIndex: 'updateUser',
titleSlotName: 'updateUser',
width: 109,
showDrag: true,
},
{
title: 'common.operation',
slotName: 'action',
slotName: 'operation',
dataIndex: 'operation',
fixed: 'right',
width: 180,
@ -443,7 +459,6 @@
},
(item) => ({
...item,
fullPath: folderTreePathMap?.[item.moduleId],
createTime: dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss'),
updateTime: dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss'),
})

View File

@ -35,8 +35,7 @@
<div class="flex items-center px-[20px]" :class="getActiveClass('recycle')" @click="redirectRecycle()">
<MsIcon type="icon-icon_delete-trash_outlined" class="folder-icon" />
<div class="folder-name mx-[4px]">{{ t('apiScenario.tree.recycleBin') }}</div>
<!-- <div class="folder-count">({{ recycleModulesCount.all || 0 }})</div></div-->
<div class="folder-count">({{ 0 }})</div>
<div class="folder-count">({{ recycleModulesCount || 0 }})</div>
</div>
</div>
</div>
@ -66,7 +65,7 @@
* @description 接口测试-接口场景主页
*/
import { ref } from 'vue';
import { onBeforeMount, ref } from 'vue';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
@ -76,10 +75,15 @@
import scenarioModuleTree from './components/scenarioModuleTree.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 { ApiScenarioGetModuleParams } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
import { ApiTestRouteEnum } from '@/enums/routeEnum';
import useAppStore from '../../../store/modules/app';
//
const detail = defineAsyncComponent(() => import('./detail/index.vue'));
@ -111,13 +115,14 @@
const activeModule = ref<string>('all');
const activeFolder = ref<string>('all');
const offspringIds = ref<string[]>([]);
const recycleModulesCount = ref(0);
const isShowScenario = ref(false);
//
const getActiveClass = (type: string) => {
return activeFolder.value === type ? 'folder-text case-active' : 'folder-text';
};
const appStore = useAppStore();
const recycleModulesCount = ref(0);
const scenarioModuleTreeRef = ref<InstanceType<typeof scenarioModuleTree>>();
@ -135,7 +140,18 @@
scenarioModuleTreeRef.value?.initModuleCount(params);
}
function redirectRecycle() {}
function redirectRecycle() {
router.push({
name: ApiTestRouteEnum.API_TEST_SCENARIO_RECYCLE,
});
}
onBeforeMount(async () => {
const res = await getTrashModuleCount({
projectId: appStore.currentProjectId,
});
recycleModulesCount.value = res.all;
});
</script>
<style scoped lang="less">

View File

@ -72,4 +72,9 @@ export default {
'apiScenario.type': 'Type',
'apiScenario.operationUser': 'Operator',
'apiScenario.updateTime': 'Update time',
// 回收站
'api_scenario.recycle.recover': 'Recover',
'api_scenario.recycle.list': 'Recycle list',
'api_scenario.recycle.batchCleanOut': 'Delete',
};

View File

@ -119,4 +119,9 @@ export default {
'apiScenario.type': '类型',
'apiScenario.operationUser': '操作人',
'apiScenario.updateTime': '更新时间',
// 回收站
'api_scenario.recycle.recover': '恢复',
'api_scenario.recycle.list': '回收站列表',
'api_scenario.recycle.batchCleanOut': '彻底删除',
};

View File

@ -0,0 +1,170 @@
<template>
<MsCard no-content-padding simple>
<div class="p-[24px_24px_8px_24px]">
<MsEditableTab
v-model:active-tab="activeApiTab"
v-model:tabs="apiTabs"
class="flex-1 overflow-hidden"
:show-add="false"
:readonly="true"
@add="newTab"
>
<template #label="{ tab }">
<a-tooltip :content="tab.label" :mouse-enter-delay="500">
<div class="one-line-text max-w-[144px]">
{{ tab.label }}
</div>
</a-tooltip>
</template>
</MsEditableTab>
</div>
<a-divider class="!my-0" />
<div v-if="activeApiTab.id === 'all'" class="pageWrap">
<MsSplitBox :size="300" :max="0.5">
<template #first>
<div class="flex h-full flex-col">
<div class="p-[16px]">
<recycleTree
ref="recycleTreeRef"
:is-show-scenario="isShowScenario"
@folder-node-select="handleNodeSelect"
@init="handleModuleInit"
></recycleTree>
</div>
</div>
</template>
<template #second>
<RecycleTable
ref="apiTableRef"
:active-module="activeModule"
:offspring-ids="offspringIds"
@refresh-module-tree="refreshTree"
/>
</template>
</MsSplitBox>
</div>
<detail v-else :detail="activeApiTab"></detail>
</MsCard>
</template>
<script setup lang="ts">
/**
* @description 接口测试-接口场景回收站
*/
import { ref } from 'vue';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import detail from './detail/index.vue';
import RecycleTable from '@/views/api-test/scenario/recycle/recycleTable.vue';
import recycleTree from '@/views/api-test/scenario/recycle/recycleTree.vue';
import { useI18n } from '@/hooks/useI18n';
import { ApiScenarioGetModuleParams } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
const { t } = useI18n();
const apiTabs = ref<any[]>([
{
id: 'all',
label: t('api_scenario.recycle.list'),
closable: false,
},
]);
const activeApiTab = ref<any>(apiTabs.value[0]);
function newTab() {
apiTabs.value.push({
id: `newTab${apiTabs.value.length}`,
label: `New Tab ${apiTabs.value.length}`,
closable: true,
});
activeApiTab.value = apiTabs.value[apiTabs.value.length - 1];
}
const folderTree = ref<ModuleTreeNode[]>([]);
const folderTreePathMap = ref<Record<string, any>>({});
const activeModule = ref<string>('all');
const activeFolder = ref<string>('all');
const offspringIds = ref<string[]>([]);
const isShowScenario = ref(false);
const recycleTreeRef = ref<InstanceType<typeof recycleTree>>();
function handleModuleInit(tree: any, _protocol: string, pathMap: Record<string, any>) {
folderTree.value = tree;
folderTreePathMap.value = pathMap;
}
function handleNodeSelect(keys: string[], _offspringIds: string[]) {
[activeModule.value] = keys;
offspringIds.value = _offspringIds;
}
function refreshTree(params: ApiScenarioGetModuleParams) {
recycleTreeRef.value?.initModuleCount(params);
}
</script>
<style scoped lang="less">
.pageWrap {
height: calc(100% - 65px);
border-radius: var(--border-radius-large);
@apply bg-white;
.case {
padding: 8px 4px;
border-radius: var(--border-radius-small);
@apply flex cursor-pointer items-center justify-between;
&:hover {
background-color: rgb(var(--primary-1));
}
.folder-icon {
margin-right: 4px;
color: var(--color-text-4);
}
.folder-name {
color: var(--color-text-1);
}
.folder-count {
margin-left: 4px;
color: var(--color-text-4);
}
.case-active {
.folder-icon,
.folder-name,
.folder-count {
color: rgb(var(--primary-5));
}
}
.back {
margin-right: 8px;
width: 20px;
height: 20px;
border: 1px solid #ffffff;
background: linear-gradient(90deg, rgb(var(--primary-9)) 3.36%, #ffffff 100%);
box-shadow: 0 0 7px rgb(15 0 78 / 9%);
.arco-icon {
color: rgb(var(--primary-5));
}
@apply flex cursor-pointer items-center rounded-full;
}
}
}
.recycle {
@apply absolute bottom-0 bg-white pb-4;
:deep(.arco-divider-horizontal) {
margin: 8px 0;
}
.recycle-bin {
@apply bottom-0 flex items-center bg-white;
.recycle-count {
margin-left: 4px;
color: var(--color-text-4);
}
}
}
</style>

View File

@ -0,0 +1,508 @@
<template>
<div :class="['p-[16px_16px]', props.class]">
<div class="mb-[16px] flex items-center justify-between">
<div>
<span class="flex items-center">
<a-switch
v-model:model-value="showItemFolderScenario"
size="small"
type="line"
@change="loadScenarioList(false)"
/>
<span style="margin-left: 8px; color: #323233; font-family: 'PingFang SC'">{{
t('apiScenario.table.showChildrenModuleScenario')
}}</span>
</span>
</div>
<div class="flex items-center gap-[8px]">
<a-input-search
v-model:model-value="keyword"
:placeholder="t('api_scenario.table.searchPlaceholder')"
allow-clear
class="mr-[8px] w-[240px]"
@search="loadScenarioList(true)"
@press-enter="loadScenarioList"
/>
<a-button type="outline" class="arco-btn-outline--secondary !p-[8px]" @click="loadScenarioList(true)">
<template #icon>
<icon-refresh class="text-[var(--color-text-4)]" />
</template>
</a-button>
</div>
</div>
<a-spin class="w-full" :loading="recoverLoading">
<ms-base-table
v-bind="propsRes"
:action-config="batchActions"
:first-column-width="44"
no-disable
filter-icon-align-left
v-on="propsEvent"
@selected-change="handleTableSelect"
@batch-action="handleTableBatch"
>
<template #statusFilter="{ columnConfig }">
<a-trigger
v-model:popup-visible="statusFilterVisible"
trigger="click"
@popup-visible-change="handleFilterHidden"
>
<MsButton type="text" class="arco-btn-text--secondary ml-[10px]" @click="statusFilterVisible = true">
{{ t(columnConfig.title as string) }}
<icon-down :class="statusFilterVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</MsButton>
<template #content>
<div class="arco-table-filters-content">
<div class="flex items-center justify-center px-[6px] py-[2px]">
<a-checkbox-group v-model:model-value="statusFilters" direction="vertical" size="small">
<a-checkbox v-for="val of Object.values(ApiScenarioStatus)" :key="val" :value="val">
<apiStatus :status="val" />
</a-checkbox>
</a-checkbox-group>
</div>
</div>
</template>
</a-trigger>
</template>
<template #num="{ record }">
<MsButton type="text">{{ record.num }}</MsButton>
</template>
<template #status="{ record }">
<apiStatus :status="record.status" />
</template>
<template #priority="{ record }">
<caseLevel :case-level="record.priority as CaseLevel" />
</template>
<template #operation="{ record }">
<MsButton
v-permission="['PROJECT_API_SCENARIO:READ+DELETED']"
type="text"
class="!mr-0"
@click="recover(record)"
>
{{ t('api_scenario.recycle.recover') }}
</MsButton>
<a-divider v-permission="['PROJECT_API_SCENARIO:READ+DELETED']" direction="vertical" :margin="8"></a-divider>
<MsButton
v-permission="['PROJECT_API_SCENARIO:READ+DELETED']"
type="text"
class="!mr-0"
@click="deleteOperation(record)"
>
{{ t('common.delete') }}
</MsButton>
</template>
</ms-base-table>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { BatchActionParams, BatchActionQueryParams, MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import caseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import type { CaseLevel } from '@/components/business/ms-case-associate/types';
import apiStatus from '@/views/api-test/components/apiStatus.vue';
import {
batchDeleteScenario,
batchRecoverScenario,
batchRecycleScenario,
deleteScenario,
getTrashScenarioPage,
recoverScenario,
recycleScenario,
} from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { ApiScenarioDetail } from '@/models/apiTest/scenario';
import { ApiScenarioStatus } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
const props = defineProps<{
class?: string;
activeModule: string;
offspringIds: string[];
readOnly?: boolean; //
}>();
//
const showItemFolderScenario = ref(false);
const appStore = useAppStore();
const { t } = useI18n();
const { openModal } = useModal();
const scenarioPriorityList = ref([
{
value: 'P0',
text: 'P0',
},
{
value: 'P1',
text: 'P1',
},
{
value: 'P2',
text: 'P2',
},
{
value: 'P3',
text: 'P3',
},
]);
const emit = defineEmits(['refreshModuleTree']);
const keyword = ref('');
const recoverLoading = ref(false);
const columns: MsTableColumn = [
{
title: 'ID',
dataIndex: 'num',
slotName: 'num',
sortIndex: 1,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
fixed: 'left',
width: 126,
showTooltip: true,
showInTable: true,
columnSelectorDisabled: true,
},
{
title: 'apiScenario.table.columns.name',
dataIndex: 'name',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 134,
showTooltip: true,
showInTable: true,
columnSelectorDisabled: true,
},
{
title: 'apiScenario.table.columns.level',
dataIndex: 'priority',
slotName: 'priority',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.status',
dataIndex: 'status',
slotName: 'status',
width: 140,
showDrag: true,
},
{
title: 'apiScenario.table.columns.runResult',
titleSlotName: 'lastReportStatus',
dataIndex: 'lastReportStatus',
showTooltip: true,
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.tags',
dataIndex: 'tags',
isTag: true,
isStringTag: true,
width: 456,
showDrag: true,
},
{
title: 'apiScenario.table.columns.scenarioEnv',
dataIndex: 'environmentName',
width: 159,
showDrag: true,
},
{
title: 'apiScenario.table.columns.steps',
dataIndex: 'stepTotal',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.passRate',
dataIndex: 'requestPassRate',
width: 100,
showDrag: true,
},
{
title: 'apiScenario.table.columns.module',
dataIndex: 'modulePath',
width: 176,
showDrag: true,
},
{
title: 'apiScenario.table.columns.createTime',
dataIndex: 'createTime',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 189,
showDrag: true,
},
{
title: 'apiScenario.table.columns.updateTime',
dataIndex: 'updateTime',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 189,
showDrag: true,
},
{
title: 'apiScenario.table.columns.createUser',
dataIndex: 'createUser',
titleSlotName: 'createUser',
width: 109,
showDrag: true,
},
{
title: 'apiScenario.table.columns.updateUser',
dataIndex: 'updateUser',
titleSlotName: 'updateUser',
width: 109,
showDrag: true,
},
{
title: 'common.operation',
slotName: 'operation',
dataIndex: 'operation',
fixed: 'right',
width: 180,
},
];
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector } = useTable(
getTrashScenarioPage,
{
columns: props.readOnly ? columns : [],
scroll: { x: '100%' },
tableKey: props.readOnly ? undefined : TableKeyEnum.API_TEST,
showSetting: !props.readOnly,
selectable: true,
showSelectAll: !props.readOnly,
draggable: props.readOnly ? undefined : { type: 'handle', width: 32 },
heightUsed: 374,
},
(item) => ({
...item,
createTime: dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss'),
updateTime: dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss'),
})
);
const batchActions = {
baseAction: [
{
label: 'api_scenario.recycle.recover',
eventTag: 'recover',
permission: ['PROJECT_API_SCENARIO:READ+DELETE'],
},
{
label: 'api_scenario.recycle.batchCleanOut',
eventTag: 'delete',
permission: ['PROJECT_API_SCENARIO:READ+DELETE'],
},
],
};
const statusFilterVisible = ref(false);
const statusFilters = ref(Object.keys(ApiScenarioStatus));
const moduleIds = computed(() => {
if (props.activeModule === 'all') {
return [];
}
if (showItemFolderScenario.value) {
return [props.activeModule, ...props.offspringIds];
}
return [props.activeModule];
});
function loadScenarioList(refreshTreeCount?: boolean) {
const params = {
keyword: keyword.value,
projectId: appStore.currentProjectId,
moduleIds: moduleIds.value,
filter: {
status: statusFilters.value.length === Object.keys(ApiScenarioStatus).length ? undefined : statusFilters.value,
},
};
setLoadListParams(params);
loadList();
if (refreshTreeCount) {
emit('refreshModuleTree', params);
}
}
function handleFilterHidden(val: boolean) {
if (!val) {
loadScenarioList(false);
}
}
const tableSelected = ref<(string | number)[]>([]);
const batchParams = ref<BatchActionQueryParams>({
selectedIds: [],
selectAll: false,
excludeIds: [],
currentSelectCount: 0,
});
//
async function recover(record?: ApiScenarioDetail, isBatch?: boolean) {
try {
if (isBatch) {
recoverLoading.value = true;
await batchRecoverScenario({
selectIds: batchParams.value?.selectedIds || [],
selectAll: !!batchParams.value?.selectAll,
excludeIds: batchParams.value?.excludeIds || [],
condition: { keyword: keyword.value },
projectId: appStore.currentProjectId,
moduleIds: props.activeModule === 'all' ? [] : [props.activeModule],
});
} else {
await recoverScenario(record?.id as string);
}
Message.success(t('common.deleteSuccess'));
tableSelected.value = [];
resetSelector();
loadScenarioList(true);
} catch (e) {
console.log(e);
} finally {
recoverLoading.value = false;
}
}
/**
* 删除接口
*/
function deleteOperation(record?: ApiScenarioDetail, isBatch?: boolean, params?: BatchActionQueryParams) {
let title = t('api_scenario.table.deleteScenarioTipTitle', { name: record?.name });
let selectIds = [record?.id || ''];
if (isBatch) {
title = t('api_scenario.table.batchDeleteScenarioTip', {
count: params?.currentSelectCount || tableSelected.value.length,
});
selectIds = tableSelected.value as string[];
}
openModal({
type: 'error',
title,
content: t('api_scenario.table.deleteScenarioTip'),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
try {
if (isBatch) {
await batchDeleteScenario({
selectIds,
selectAll: !!params?.selectAll,
excludeIds: params?.excludeIds || [],
condition: { keyword: keyword.value },
projectId: appStore.currentProjectId,
moduleIds: props.activeModule === 'all' ? [] : [props.activeModule],
});
} else {
await deleteScenario(record?.id as string);
}
Message.success(t('common.deleteSuccess'));
resetSelector();
loadScenarioList(true);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
}
/**
* 处理表格选中
*/
function handleTableSelect(arr: (string | number)[]) {
tableSelected.value = arr;
}
const batchForm = ref({
attr: '',
value: '',
values: [],
});
/**
* 处理表格选中后批量操作
* @param event 批量操作事件对象
*/
function handleTableBatch(event: BatchActionParams, params: BatchActionQueryParams) {
tableSelected.value = params?.selectedIds || [];
batchParams.value = params;
switch (event.eventTag) {
case 'delete':
deleteOperation(undefined, true, batchParams.value);
break;
case 'recover':
recover(undefined, true);
break;
default:
break;
}
}
defineExpose({
loadScenarioList,
});
onBeforeMount(() => {
loadScenarioList();
});
watch(
() => props.activeModule,
() => {
resetSelector();
loadScenarioList();
}
);
watch(
() => batchForm.value.attr,
() => {
batchForm.value.value = '';
}
);
</script>
<style lang="less" scoped>
:deep(.param-input:not(.arco-input-focus, .arco-select-view-focus)) {
&:not(:hover) {
border-color: transparent !important;
.arco-input::placeholder {
@apply invisible;
}
.arco-select-view-icon {
@apply invisible;
}
.arco-select-view-value {
color: var(--color-text-brand);
}
}
}
</style>

View File

@ -0,0 +1,264 @@
<template>
<div>
<div class="folder" @click="setActiveFolder('all')">
<div :class="allFolderClass">
<MsIcon type="icon-icon_folder_filled1" class="folder-icon" />
<div class="folder-name">{{ t('apiScenario.tree.folder.allScenario') }}</div>
<div class="folder-count">({{ allScenarioCount }})</div>
</div>
<div class="ml-auto flex items-center">
<a-tooltip :content="isExpandAll ? t('common.collapseAll') : t('common.expandAll')">
<MsButton type="icon" status="secondary" class="!mr-0 p-[4px]" @click="changeExpand">
<MsIcon :type="isExpandAll ? 'icon-icon_folder_collapse1' : 'icon-icon_folder_expansion1'" />
</MsButton>
</a-tooltip>
</div>
</div>
<a-divider class="my-[8px]" />
<div class="mb-[8px] flex items-center gap-[8px]">
<a-input
v-model:model-value="moduleKeyword"
:placeholder="t('apiScenario.tree.selectorPlaceholder')"
allow-clear
/>
</div>
<a-spin class="w-full" :loading="loading">
<MsTree
v-model:focus-node-key="focusNodeKey"
v-model:selected-keys="selectedKeys"
:data="folderTree"
:keyword="moduleKeyword"
:node-more-actions="folderMoreActions"
:default-expand-all="isExpandAll"
:expand-all="isExpandAll"
:empty-text="t('apiScenario.tree.noMatchModule')"
:virtual-list-props="virtualListProps"
:field-names="{
title: 'name',
key: 'id',
children: 'children',
count: 'count',
}"
block-node
title-tooltip-position="left"
@select="folderNodeSelect"
>
<template #title="nodeData">
<div :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 class="ml-[4px] text-[var(--color-text-4)]">({{ nodeData.count || 0 }})</div>
</div>
</template>
</MsTree>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import type { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTree from '@/components/business/ms-tree/index.vue';
import type { MsTreeNodeData } from '@/components/business/ms-tree/types';
import { getTrashModuleCount, getTrashModuleTree } from '@/api/modules/api-test/scenario';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { mapTree } from '@/utils';
import { ApiScenarioGetModuleParams } from '@/models/apiTest/scenario';
import { ModuleTreeNode } from '@/models/common';
const props = withDefaults(
defineProps<{
isExpandAll?: boolean; //
}>(),
{}
);
const emit = defineEmits(['init', 'newScenario', 'import', 'folderNodeSelect', 'clickScenario', 'changeProtocol']);
const appStore = useAppStore();
const { t } = useI18n();
const { openModal } = useModal();
function handleSelect(value: string | number | Record<string, any> | undefined) {
switch (value) {
case 'newScenario':
emit('newScenario');
break;
case 'import':
emit('import');
break;
case 'addModule':
document.querySelector('#addModulePopSpan')?.dispatchEvent(new Event('click'));
break;
default:
break;
}
}
const virtualListProps = computed(() => {
return {
height: 'calc(100vh - 343px)',
threshold: 200,
fixedSize: true,
buffer: 15, // 10 padding
};
});
const moduleKeyword = ref('');
const folderTree = ref<ModuleTreeNode[]>([]);
const focusNodeKey = ref<string | number>('');
const selectedKeys = ref<Array<string | number>>([]);
const allFolderClass = computed(() =>
selectedKeys.value[0] === 'all' ? 'folder-text folder-text--active' : 'folder-text'
);
const loading = ref(false);
const folderMoreActions: ActionsItem[] = [
{
label: 'common.rename',
eventTag: 'rename',
},
{
isDivider: true,
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
const modulesCount = ref<Record<string, number>>({});
const allScenarioCount = computed(() => modulesCount.value.all || 0);
const isExpandAll = ref(props.isExpandAll);
function setActiveFolder(id: string) {
selectedKeys.value = [id];
emit('folderNodeSelect', selectedKeys.value, []);
}
/**
* 初始化模块树
* @param isSetDefaultKey 是否设置第一个节点为选中节点
*/
async function initModules(isSetDefaultKey = false) {
try {
loading.value = true;
const res = await getTrashModuleTree({
keyword: moduleKeyword.value,
projectId: appStore.currentProjectId,
moduleIds: [],
});
const nodePathObj: Record<string, any> = {};
folderTree.value = mapTree<ModuleTreeNode>(res, (e, fullPath) => {
//
nodePathObj[e.id] = {
path: e.path,
fullPath,
};
return {
...e,
hideMoreAction: e.id === 'root',
};
});
if (isSetDefaultKey) {
selectedKeys.value = [folderTree.value[0].id];
}
emit('init', folderTree.value, nodePathObj);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
}
}
watch(
() => props.isExpandAll,
(val) => {
isExpandAll.value = val;
}
);
function changeExpand() {
isExpandAll.value = !isExpandAll.value;
}
/**
* 处理文件夹树节点选中事件
*/
function folderNodeSelect(_selectedKeys: (string | number)[], node: MsTreeNodeData) {
const offspringIds: string[] = [];
mapTree(node.children || [], (e) => {
offspringIds.push(e.id);
return e;
});
emit('folderNodeSelect', _selectedKeys, offspringIds);
}
async function initModuleCount(params: ApiScenarioGetModuleParams) {
try {
const res = await getTrashModuleCount(params);
modulesCount.value = res;
folderTree.value = mapTree<ModuleTreeNode>(folderTree.value, (node) => {
return {
...node,
count: res[node.id] || 0,
disabled: false,
};
});
} catch (error) {
console.log(error);
}
}
onBeforeMount(async () => {
await initModules();
await initModuleCount({
projectId: appStore.currentProjectId,
});
});
defineExpose({
initModuleCount,
});
</script>
<style lang="less" scoped>
.folder {
@apply flex cursor-pointer items-center justify-between;
padding: 8px 4px;
border-radius: var(--border-radius-small);
&:hover {
background-color: rgb(var(--primary-1));
}
.folder-text {
@apply flex flex-1 cursor-pointer items-center;
.folder-icon {
margin-right: 4px;
color: var(--color-text-4);
}
.folder-name {
color: var(--color-text-1);
}
.folder-count {
margin-left: 4px;
color: var(--color-text-4);
}
}
.folder-text--active {
.folder-icon,
.folder-name,
.folder-count {
color: rgb(var(--primary-5));
}
}
}
:deep(#root ~ .arco-tree-node-drag-icon) {
@apply hidden;
}
</style>

View File

@ -50,7 +50,7 @@ export default {
'caseManagement.featureCase.tableColumnDeleteUser': '删除人',
'caseManagement.featureCase.tableColumnDeleteTime': '删除时间',
'caseManagement.featureCase.tableColumnActions': '操作',
'caseManagement.featureCase.beforeDeleteCase': '删除,用例将放入回收站,可在回收站内进行数据恢复',
'caseManagement.featureCase.beforeDeleteCase': '删除用例将放入回收站,可在回收站内进行数据恢复',
'caseManagement.featureCase.deleteCaseTitle': '确认删除 {name} 用例吗?',
'caseManagement.featureCase.completedDeleteCaseTitle': '确认彻底删除 {name} 用例吗?',
'caseManagement.featureCase.export': '导出',