feat(接口管理): 创建用例&用例详情

This commit is contained in:
teukkk 2024-03-15 17:53:38 +08:00 committed by Craftsman
parent 7e6edf8352
commit 1f1b43f108
15 changed files with 740 additions and 102 deletions

View File

@ -16,6 +16,7 @@ import {
BatchUpdateDefinitionUrl,
CasePageUrl,
CheckDefinitionScheduleUrl,
DebugCaseUrl,
DebugDefinitionUrl,
DefinitionMockPageUrl,
DefinitionPageUrl,
@ -28,6 +29,7 @@ import {
DeleteRecycleApiUrl,
DeleteRecycleCaseUrl,
ExecuteCaseUrl,
GetCaseDetailUrl,
GetChangeHistoryUrl,
GetDefinitionDetailUrl,
GetDefinitionScheduleUrl,
@ -52,14 +54,18 @@ import {
SortDefinitionUrl,
SwitchDefinitionScheduleUrl,
ToggleFollowDefinitionUrl,
TransferFileCaseUrl,
TransferFileModuleOptionCaseUrl,
TransferFileModuleOptionUrl,
TransferFileUrl,
UpdateCasePriorityUrl,
UpdateCaseStatusUrl,
UpdateCaseUrl,
UpdateDefinitionScheduleUrl,
UpdateDefinitionUrl,
UpdateMockStatusUrl,
UpdateModuleUrl,
UploadTempFileCaseUrl,
UploadTempFileUrl,
} from '@/api/requrls/api-test/management';
@ -68,8 +74,11 @@ import {
AddApiCaseParams,
ApiCaseBatchEditParams,
ApiCaseBatchExecuteParams,
ApiCaseBatchParams, ApiCaseChangeHistoryParams, ApiCaseDependencyParams,
ApiCaseDetail, ApiCaseExecuteHistoryParams,
ApiCaseBatchParams,
ApiCaseChangeHistoryParams,
ApiCaseDependencyParams,
ApiCaseDetail,
ApiCaseExecuteHistoryParams,
ApiCasePageParams,
ApiDefinitionBatchDeleteParams,
ApiDefinitionBatchMoveParams,
@ -102,7 +111,8 @@ import {
CommonList,
DragSortParams,
ModuleTreeNode,
MoveModules, TableQueryParams,
MoveModules,
TableQueryParams,
TransferFileParams,
} from '@/models/common';
@ -358,6 +368,36 @@ export function dragSort(data: DragSortParams) {
return MSR.post({ url: SortCaseUrl, data });
}
// 更新接口用例
export function updateCase(data: AddApiCaseParams) {
return MSR.post({ url: UpdateCaseUrl, data });
}
// 接口用例调试
export function debugCase(data: ExecuteRequestParams) {
return MSR.post({ url: DebugCaseUrl, data });
}
// 文件转存
export function transferFileCase(data: TransferFileParams) {
return MSR.post({ url: TransferFileCaseUrl, data });
}
// 文件转存目录
export function getTransferOptionsCase(projectId: string) {
return MSR.get<ModuleTreeNode[]>({ url: TransferFileModuleOptionCaseUrl, params: projectId });
}
// 上传文件
export function uploadTempFileCase(file: File) {
return MSR.uploadFile({ url: UploadTempFileCaseUrl }, { fileList: [file] }, 'file');
}
// 获取接口用例详情
export function getCaseDetail(id: string) {
return MSR.get<ApiCaseDetail>({ url: GetCaseDetailUrl, params: id });
}
/**
*
*/
@ -419,4 +459,4 @@ export function getApiCaseChangeHistory(data: ApiCaseChangeHistoryParams) {
// 获取接口用例-依赖关系
export function getApiCaseDependency(data: ApiCaseDependencyParams) {
return MSR.post({ url: GetDependencyUrl, data });
}
}

View File

@ -55,12 +55,18 @@ export const GetTrashModuleCountUrl = '/api/definition/module/trash/count'; //
// --------------------用例
export const CasePageUrl = '/api/case/page'; // 接口用例列表
export const UpdateCaseUrl = '/api/case/update'; // 接口用例更新
export const UpdateCaseStatusUrl = '/api/case/update-status'; // 接口用例更新状态
export const UpdateCasePriorityUrl = '/api/case/update-priority'; // 接口用例更新等级
export const DeleteCaseUrl = '/api/case/delete-to-gc'; // 删除接口用例
export const BatchDeleteCaseUrl = '/api/case/batch/delete-to-gc'; // 批量删除接口用例
export const BatchEditCaseUrl = '/api/case/batch/edit'; // 批量编辑接口用例
export const SortCaseUrl = '/api/case/edit/pos'; // 接口用例拖拽
export const DebugCaseUrl = '/api/case/debug'; // 接口用例调试
export const TransferFileCaseUrl = '/api/case/transfer'; // 文件转存
export const TransferFileModuleOptionCaseUrl = '/api/case/transfer/options'; // 文件转存目录
export const UploadTempFileCaseUrl = '/api/case/upload/temp/file'; // 临时文件上传
export const GetCaseDetailUrl = '/api/case/get-detail'; // 获取接口用例详情
export const GetEnvListUrl = '/api/test/env-list'; // 接口测试-环境列表
export const BatchExecuteCaseUrl = '/api/case/batch/run'; // 批量执行接口用例
export const ExecuteCaseUrl = '/api/case/run/'; // 单独执行接口用例
@ -68,8 +74,6 @@ export const GetExecuteHistoryUrl = 'api/case/execute/page'; // 获取用的执
export const GetDependencyUrl = '/api/case/get-reference'; // 获取用例的依赖关系
export const GetChangeHistoryUrl = '/api/case/operation-history/page'; // 获取用例的依赖关系
/**
*
*/

View File

@ -1,6 +1,6 @@
<template>
<div class="ms-detail-card">
<div class="flex items-center justify-between">
<div class="ms-detail-card-title flex items-center justify-between">
<div class="flex items-center gap-[4px]">
<a-tooltip :content="t(props.title)">
<div class="one-line-text flex-1 font-medium text-[var(--color-text-1)]">

View File

@ -294,8 +294,8 @@ export interface ApiCasePageParams extends TableQueryParams {
moduleIds?: string[];
apiDefinitionId?: string;
}
// 用例列表
export interface ApiCaseDetail {
// 用例列表和用例详情
export interface ApiCaseDetail extends ExecuteRequestParams {
id: string;
name: string;
priority: string;
@ -327,7 +327,7 @@ export interface ApiCaseDetail {
// 批量操作参数
export interface ApiCaseBatchParams extends BatchApiParams {
protocol: string;
apiDefinitionId?: string[];
apiDefinitionId?: string;
versionId?: string;
}
// 用例批量编辑参数

View File

@ -9,7 +9,7 @@
</template>
</a-empty>
<div v-show="!pluginError || isHttpProtocol" class="flex h-full flex-col">
<div class="px-[18px] pt-[8px]">
<div v-if="!props.isCase" class="px-[18px] pt-[8px]">
<div class="flex flex-wrap items-center justify-between gap-[12px]">
<div class="flex flex-1 items-center gap-[16px]">
<a-select
@ -68,6 +68,7 @@
<template
v-if="
(!props.isDefinition || (props.isDefinition && requestVModel.mode === 'debug')) &&
props.permissionMap &&
hasAnyPermission([props.permissionMap.execute])
"
>
@ -95,8 +96,8 @@
v-if="
props.isDefinition &&
(requestVModel.isNew
? hasAnyPermission([props.permissionMap.create])
: hasAnyPermission([props.permissionMap.update]))
? props.permissionMap && hasAnyPermission([props.permissionMap.create])
: props.permissionMap && hasAnyPermission([props.permissionMap.update]))
"
>
<!-- 接口定义-调试模式可保存或保存为新用例 -->
@ -122,8 +123,8 @@
<a-button
v-else-if="
requestVModel.isNew
? hasAnyPermission([props.permissionMap.create])
: hasAnyPermission([props.permissionMap.update])
? props.permissionMap && hasAnyPermission([props.permissionMap.create])
: props.permissionMap && hasAnyPermission([props.permissionMap.update])
"
type="secondary"
:disabled="isHttpProtocol && !requestVModel.url"
@ -138,7 +139,7 @@
</div>
</div>
</div>
<div class="px-[16px]">
<div class="request-params-tab px-[16px]">
<MsTab
v-model:active-key="requestVModel.activeTab"
:content-tab-list="contentTabList"
@ -146,15 +147,15 @@
class="no-content relative mt-[8px] border-b"
/>
</div>
<div ref="splitContainerRef" class="h-[calc(100%-87px)]">
<div ref="splitContainerRef" class="request-and-response h-[calc(100%-87px)]">
<MsSplitBox
ref="horizontalSplitBoxRef"
:size="props.isDefinition ? 0.7 : 1"
:max="props.isDefinition ? 0.9 : 1"
:min="props.isDefinition ? 0.7 : 1"
:disabled="!props.isDefinition"
:class="!props.isDefinition ? 'hidden-second' : ''"
:first-container-class="!props.isDefinition ? 'border-r-0' : ''"
:size="!props.isCase && props.isDefinition ? 0.7 : 1"
:max="props.isDefinition && !props.isCase ? 0.9 : 1"
:min="props.isDefinition && !props.isCase ? 0.7 : 1"
:disabled="props.isCase && !props.isDefinition"
:class="props.isCase && !props.isDefinition ? 'hidden-second' : ''"
:first-container-class="props.isCase && !props.isDefinition ? 'border-r-0' : ''"
direction="horizontal"
expand-direction="right"
>
@ -286,7 +287,7 @@
</template>
</MsSplitBox>
</template>
<template v-if="props.isDefinition" #second>
<template v-if="!props.isCase && props.isDefinition" #second>
<div class="p-[16px]">
<!-- TODO:第一版没有模板 -->
<!-- <MsFormCreate v-model:api="fApi" :rule="currentApiTemplateRules" :option="options" /> -->
@ -308,7 +309,7 @@
<a-form-item :label="t('apiTestManagement.belongModule')" class="mb-[16px]">
<a-tree-select
v-model:modelValue="requestVModel.moduleId"
:data="selectTree"
:data="selectTree as ModuleTreeNode[]"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:tree-props="{
virtualListProps: {
@ -395,6 +396,7 @@
</div>
</div>
<a-modal
v-if="!isCase"
v-model:visible="saveModalVisible"
:title="t('common.save')"
:ok-loading="saveLoading"
@ -433,7 +435,7 @@
<a-form-item :label="t('apiTestDebug.requestModule')" class="mb-0">
<a-tree-select
v-model:modelValue="saveModalForm.moduleId"
:data="selectTree"
:data="selectTree as ModuleTreeNode[]"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:tree-props="{
virtualListProps: {
@ -447,6 +449,7 @@
</a-form>
</a-modal>
<a-modal
v-if="!isCase"
v-model:visible="saveCaseModalVisible"
:title="t('common.save')"
:ok-loading="saveCaseLoading"
@ -563,7 +566,7 @@
isNew: boolean;
protocol: string;
activeTab: RequestComposition;
mode?: 'definition' | 'debug';
mode?: 'definition' | 'debug' | 'case';
executeLoading: boolean; // loading
isCopy?: boolean; //
isExecute?: boolean; //
@ -576,21 +579,22 @@
const props = defineProps<{
request: RequestParam; //
moduleTree: ModuleTreeNode[]; //
moduleTree?: ModuleTreeNode[]; //
isCase?: boolean; //
detailLoading?: boolean; //
isDefinition?: boolean; //
hideResponseLayoutSwitch?: boolean; //
otherParams?: Record<string, any>; //
currentEnvConfig?: EnvConfig;
executeApi: (params: ExecuteRequestParams) => Promise<any>; //
localExecuteApi: (url: string, params: ExecuteRequestParams) => Promise<any>; //
createApi: (...args) => Promise<any>; //
updateApi: (...args) => Promise<any>; //
executeApi?: (params: ExecuteRequestParams) => Promise<any>; //
localExecuteApi?: (url: string, params: ExecuteRequestParams) => Promise<any>; //
createApi?: (...args) => Promise<any>; //
updateApi?: (...args) => Promise<any>; //
uploadTempFileApi?: (...args) => Promise<any>; //
fileSaveAsSourceId?: string | number; // id
fileSaveAsApi?: (params: TransferFileParams) => Promise<string>; //
fileModuleOptionsApi?: (projectId: string) => Promise<ModuleTreeNode[]>; //
permissionMap: {
permissionMap?: {
execute: string;
create: string;
update: string;
@ -1145,10 +1149,11 @@
async function execute(executeType?: 'localExec' | 'serverExec') {
if (isHttpProtocol.value) {
try {
if (!props.executeApi) return;
requestVModel.value.executeLoading = true;
requestVModel.value.response = cloneDeep(defaultResponse);
const res = await props.executeApi(makeRequestParams(executeType));
if (executeType === 'localExec') {
if (executeType === 'localExec' && props.localExecuteApi) {
await props.localExecuteApi(localExecuteUrl.value, res);
}
} catch (error) {
@ -1161,10 +1166,11 @@
fApi.value?.validate(async (valid) => {
if (valid === true) {
try {
if (!props.executeApi) return;
requestVModel.value.executeLoading = true;
requestVModel.value.response = cloneDeep(defaultResponse);
const res = await props.executeApi(makeRequestParams(executeType));
if (executeType === 'localExec') {
if (executeType === 'localExec' && props.localExecuteApi) {
await props.localExecuteApi(localExecuteUrl.value, res);
}
} catch (error) {
@ -1217,6 +1223,7 @@
async function updateRequest() {
try {
if (!props.updateApi) return;
saveLoading.value = true;
await props.updateApi({
...makeRequestParams(),
@ -1238,6 +1245,7 @@
*/
async function realSave(fullParams?: Record<string, any>, silence?: boolean) {
try {
if (!props.createApi) return;
if (!silence) {
saveLoading.value = true;
}
@ -1485,6 +1493,10 @@
removeCatchSaveShortcut(handleSaveShortcut);
}
});
defineExpose({
makeRequestParams,
});
</script>
<style lang="less" scoped>
@ -1513,6 +1525,9 @@
:deep(.arco-tabs-tab) {
@apply leading-none;
}
.request-params-tab :deep(.arco-tabs-nav-tab) {
border-bottom: 1px solid var(--color-text-n8) !important;
}
.hidden-second {
:deep(.arco-split-trigger) {
@apply hidden;

View File

@ -0,0 +1,88 @@
<template>
<div>
<a-select
v-model:model-value="currentEnv"
:options="envOptions"
class="!w-[200px] pl-0 pr-[8px]"
:loading="envLoading"
allow-search
@change="initEnvironment"
>
<template #prefix>
<div class="flex cursor-pointer p-[8px]" @click.stop="goEnv">
<icon-location class="text-[var(--color-text-4)]" />
</div>
</template>
</a-select>
</div>
</template>
<script setup lang="ts">
import { SelectOptionData } from '@arco-design/web-vue';
import { getEnvironment, getEnvList } from '@/api/modules/api-test/common';
import router from '@/router';
import useAppStore from '@/store/modules/app';
import { EnvConfig } from '@/models/projectManagement/environmental';
import { ProjectManagementRouteEnum } from '@/enums/routeEnum';
const appStore = useAppStore();
const currentEnv = ref('');
const currentEnvConfig = ref<EnvConfig>();
const envLoading = ref(false);
const envOptions = ref<SelectOptionData[]>([]);
async function initEnvironment() {
try {
currentEnvConfig.value = await getEnvironment(currentEnv.value);
currentEnvConfig.value.id = currentEnv.value;
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
async function initEnvList() {
try {
envLoading.value = true;
const res = await getEnvList(appStore.currentProjectId);
envOptions.value = res.map((item) => ({
label: item.name,
value: item.id,
}));
currentEnv.value = res[0]?.id || '';
initEnvironment();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
envLoading.value = false;
}
}
function goEnv() {
router.push({
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_ENVIRONMENT_MANAGEMENT,
});
}
onBeforeMount(() => {
initEnvList();
});
defineExpose({
currentEnvConfig,
});
</script>
<style lang="less" scoped>
.ms-input-group--prepend();
:deep(.arco-select-view-prefix) {
margin-right: 8px;
padding-right: 0;
border-right: 1px solid var(--color-text-input-border);
}
</style>

View File

@ -51,6 +51,12 @@
/>
</a-tab-pane>
<a-tab-pane v-if="!activeApiTab.isNew" key="case" :title="t('apiTestManagement.case')" class="ms-api-tab-pane">
<caseTable
:is-api="true"
:active-module="props.activeModule"
:protocol="props.protocol"
:api-detail="activeApiTab"
/>
</a-tab-pane>
<!-- <a-tab-pane v-if="!activeApiTab.isNew" key="mock" title="MOCK" class="ms-api-tab-pane"> </a-tab-pane> -->
</a-tabs>
@ -63,6 +69,7 @@
// import MsButton from '@/components/pure/ms-button/index.vue';
import { TabItem } from '@/components/pure/ms-editable-tab/types';
import caseTable from '../case/caseTable.vue';
// import MsFormCreate from '@/components/pure/ms-form-create/formCreate.vue';
import apiTable from './apiTable.vue';

View File

@ -2,6 +2,19 @@
<div class="h-full w-full overflow-hidden">
<div class="px-[18px] pt-[16px]">
<MsDetailCard
v-if="props.isCaseDetail"
:title="`【${previewDetail.num}】${previewDetail.name}`"
:description="description"
>
<template #type="{ value }">
<apiMethodName :method="value as RequestMethods" tag-size="small" is-tag />
</template>
<template #priority="{ value }">
<caseLevel :case-level="value as CaseLevel" />
</template>
</MsDetailCard>
<MsDetailCard
v-else
:title="`【${previewDetail.num}】${previewDetail.name}`"
:description="description"
:simple-show-count="4"
@ -65,6 +78,8 @@
import MsDetailCard from '@/components/pure/ms-detail-card/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import caseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import type { CaseLevel } from '@/components/business/ms-case-associate/types';
import detailTab from './detail.vue';
import history from './history.vue';
import quote from './quote.vue';
@ -85,6 +100,7 @@
detail: RequestParam;
moduleTree: ModuleTreeNode[];
protocols: ProtocolItem[];
isCaseDetail?: boolean; //
}>();
const emit = defineEmits(['updateFollow']);
@ -95,6 +111,7 @@
watchEffect(() => {
previewDetail.value = cloneDeep(props.detail); // props.detailprops.detail
if (props.isCaseDetail) return;
const tableParam = getValidRequestTableParams(previewDetail.value); // props.detail
previewDetail.value = {
...previewDetail.value,
@ -114,49 +131,66 @@
};
});
const description = computed(() => [
{
key: 'type',
locale: 'apiTestManagement.apiType',
value: previewDetail.value.method,
},
{
key: 'path',
locale: 'apiTestManagement.path',
value: previewDetail.value.url || previewDetail.value.path,
},
{
key: 'tags',
locale: 'common.tag',
value: previewDetail.value.tags,
},
{
key: 'description',
locale: 'common.desc',
value: previewDetail.value.description,
width: '100%',
},
{
key: 'belongModule',
locale: 'apiTestManagement.belongModule',
value: findNodeByKey<ModuleTreeNode>(props.moduleTree, previewDetail.value.moduleId, 'id')?.path,
},
{
key: 'creator',
locale: 'common.creator',
value: previewDetail.value.createUserName,
},
{
key: 'createTime',
locale: 'apiTestManagement.createTime',
value: dayjs(previewDetail.value.createTime).format('YYYY-MM-DD HH:mm:ss'),
},
{
key: 'updateTime',
locale: 'apiTestManagement.updateTime',
value: dayjs(previewDetail.value.updateTime).format('YYYY-MM-DD HH:mm:ss'),
},
]);
const description = computed(() => {
const commonDescription = [
{
key: 'type',
locale: 'apiTestManagement.apiType',
value: previewDetail.value.method,
},
{
key: 'path',
locale: 'apiTestManagement.path',
value: previewDetail.value.url || previewDetail.value.path,
},
{
key: 'tags',
locale: 'common.tag',
value: previewDetail.value.tags,
},
];
if (!props.isCaseDetail) {
return [
...commonDescription,
...[
{
key: 'description',
locale: 'common.desc',
value: previewDetail.value.description,
width: '100%',
},
{
key: 'belongModule',
locale: 'apiTestManagement.belongModule',
value: findNodeByKey<ModuleTreeNode>(props.moduleTree, previewDetail.value.moduleId, 'id')?.path,
},
{
key: 'creator',
locale: 'common.creator',
value: previewDetail.value.createUserName,
},
{
key: 'createTime',
locale: 'apiTestManagement.createTime',
value: dayjs(previewDetail.value.createTime).format('YYYY-MM-DD HH:mm:ss'),
},
{
key: 'updateTime',
locale: 'apiTestManagement.updateTime',
value: dayjs(previewDetail.value.updateTime).format('YYYY-MM-DD HH:mm:ss'),
},
],
];
}
//
const caseDescription = commonDescription.slice();
caseDescription.splice(1, 0, {
key: 'priority',
locale: 'case.caseLevel',
value: previewDetail.value.priority,
});
return caseDescription;
});
const followLoading = ref(false);
async function toggleFollowReview() {

View File

@ -0,0 +1,45 @@
<template>
<preview
:detail="activeApiTab"
:module-tree="props.moduleTree"
:protocols="protocols"
is-case-detail
@update-follow="activeApiTab.follow = !activeApiTab.follow"
/>
</template>
<script setup lang="ts">
import { getProtocolList } from '@/api/modules/api-test/common';
import useAppStore from '@/store/modules/app';
import { ProtocolItem } from '@/models/apiTest/common';
import { ModuleTreeNode } from '@/models/common';
import type { RequestParam } from '@/views/api-test/components/requestComposition/index.vue';
const preview = defineAsyncComponent(() => import('../api/preview/index.vue'));
const props = defineProps<{
moduleTree: ModuleTreeNode[]; //
}>();
const appStore = useAppStore();
const activeApiTab = defineModel<RequestParam>('activeApiTab', {
required: true,
});
const protocols = ref<ProtocolItem[]>([]);
async function initProtocolList() {
try {
protocols.value = await getProtocolList(appStore.currentOrgId);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
onBeforeMount(() => {
initProtocolList();
});
</script>

View File

@ -1,19 +1,29 @@
<template>
<div class="p-[16px_22px]">
<div class="mb-[16px] flex items-center gap-[8px]">
<a-input-search
v-model:model-value="keyword"
:placeholder="t('apiTestManagement.searchPlaceholder')"
allow-clear
class="mr-[8px] w-[240px]"
@search="loadCaseList"
@press-enter="loadCaseList"
/>
<a-button type="outline" class="arco-btn-outline--secondary !p-[8px]" @click="loadCaseList">
<template #icon>
<icon-refresh class="text-[var(--color-text-4)]" />
</template>
<div class="overflow-hidden p-[16px_22px]">
<div class="mb-[16px] flex items-center justify-between">
<a-button
v-show="props.isApi"
v-permission="['PROJECT_API_DEFINITION_CASE:READ+ADD']"
type="primary"
@click="createCase"
>
{{ t('caseManagement.featureCase.creatingCase') }}
</a-button>
<div class="flex gap-[8px]">
<a-input-search
v-model:model-value="keyword"
:placeholder="t('apiTestManagement.searchPlaceholder')"
allow-clear
class="mr-[8px] w-[240px]"
@search="loadCaseList"
@press-enter="loadCaseList"
/>
<a-button type="outline" class="arco-btn-outline--secondary !p-[8px]" @click="loadCaseList">
<template #icon>
<icon-refresh class="text-[var(--color-text-4)]" />
</template>
</a-button>
</div>
</div>
<ms-base-table
v-bind="propsRes"
@ -27,13 +37,14 @@
@drag-change="handleDragChange"
>
<template #num="{ record }">
<MsButton type="text">{{ record.num }}</MsButton>
<MsButton type="text" @click="openCaseTab(record)">{{ record.num }}</MsButton>
</template>
<template #caseLevel="{ record }">
<a-select
v-model:model-value="record.priority"
:placeholder="t('common.pleaseSelect')"
class="param-input w-full"
size="mini"
@change="() => handleCaseLevelChange(record)"
>
<template #label>
@ -68,13 +79,14 @@
v-model:model-value="record.status"
:placeholder="t('common.pleaseSelect')"
class="param-input w-full"
size="mini"
@change="() => handleStatusChange(record)"
>
<template #label>
<apiStatus :status="record.status" />
<apiStatus :status="record.status" size="small" />
</template>
<a-option v-for="item of Object.values(RequestDefinitionStatus)" :key="item" :value="item">
<apiStatus :status="item" />
<apiStatus :status="item" size="small" />
</a-option>
</a-select>
</template>
@ -144,7 +156,7 @@
{{ t('apiTestManagement.execute') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
<MsButton type="text" class="!mr-0">
<MsButton type="text" class="!mr-0" @click="copyCase(record)">
{{ t('common.copy') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
@ -223,6 +235,13 @@
</a-button>
</template>
</a-modal>
<createAndEditCaseDrawer
v-if="props.isApi"
ref="createAndEditCaseDrawerRef"
:protocol="props.protocol"
:api-detail="apiDetail as RequestParam"
@load-case="loadCaseListAndResetSelector()"
/>
<a-modal v-model:visible="showBatchExecute" title-align="start" class="ms-modal-upload ms-modal-medium" :width="480">
<template #title>
{{ t('report.trigger.batch.execution') }}
@ -327,6 +346,7 @@
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTagsInput from '@/components/pure/ms-tags-input/index.vue';
import caseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import createAndEditCaseDrawer from './createAndEditCaseDrawer.vue';
import apiStatus from '@/views/api-test/components/apiStatus.vue';
import {
@ -354,9 +374,17 @@
import { RequestDefinitionStatus } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
import type { RequestParam } from '@/views/api-test/components/requestComposition/index.vue';
const props = defineProps<{
isApi: boolean; // case tab
activeModule: string;
protocol: string; //
apiDetail?: RequestParam;
}>();
const emit = defineEmits<{
(e: 'openCaseTab', record: ApiCaseDetail): void;
}>();
const appStore = useAppStore();
@ -378,7 +406,9 @@
sorter: true,
},
fixed: 'left',
width: 100,
width: 130,
ellipsis: true,
showTooltip: true,
},
{
title: 'case.caseName',
@ -500,6 +530,7 @@
selectable: true,
showSelectAll: true,
draggable: { type: 'handle', width: 32 },
heightUsed: 308,
});
const batchActions = {
baseAction: [
@ -551,6 +582,7 @@
});
function loadCaseList() {
const params = {
apiDefinitionId: props.apiDetail?.id,
keyword: keyword.value,
projectId: appStore.currentProjectId,
moduleIds: moduleIds.value,
@ -673,6 +705,7 @@
projectId: appStore.currentProjectId,
protocol: props.protocol,
moduleIds: moduleIds.value,
apiDefinitionId: props.apiDetail?.id as string,
};
});
@ -909,6 +942,18 @@
break;
}
}
const createAndEditCaseDrawerRef = ref<InstanceType<typeof createAndEditCaseDrawer>>();
function createCase() {
createAndEditCaseDrawerRef.value?.open();
}
function copyCase(record: ApiCaseDetail) {
createAndEditCaseDrawerRef.value?.open(record, true);
}
function openCaseTab(record: ApiCaseDetail) {
emit('openCaseTab', record);
}
</script>
<style lang="less" scoped>

View File

@ -0,0 +1,218 @@
<template>
<MsDrawer
v-model:visible="innerVisible"
:title="t('case.createCase')"
:width="894"
no-content-padding
:ok-text="t('common.create')"
:ok-loading="drawerLoading"
:save-continue-text="t('case.saveContinueText')"
:show-continue="true"
@confirm="handleDrawerConfirm"
@continue="handleDrawerConfirm(true)"
@cancel="handleSaveCaseCancel"
>
<template #headerLeft>
<environmentSelect ref="environmentSelectRef" class="ml-[16px]" />
</template>
<div class="flex h-full flex-col overflow-hidden">
<div class="px-[16px] pt-[16px]">
<MsDetailCard
:title="`【${apiDataDetail.num}】${apiDataDetail.name}`"
:description="description"
class="!flex-row justify-between"
>
<template #type="{ value }">
<apiMethodName :method="value as RequestMethods" tag-size="small" is-tag />
</template>
</MsDetailCard>
<a-form ref="formRef" class="mt-[16px]" :model="caseModalForm" layout="vertical">
<a-form-item field="name" label="" :rules="[{ required: true, message: t('case.caseNameRequired') }]">
<div class="flex w-full items-center gap-[8px]">
<a-input
v-model:model-value="caseModalForm.name"
:placeholder="t('case.caseNamePlaceholder')"
allow-clear
:max-length="255"
show-word-limit
/>
<a-button type="primary">
{{ t('apiTestManagement.execute') }}
</a-button>
</div>
</a-form-item>
<div class="flex gap-[16px]">
<a-form-item field="priority" :label="t('case.caseLevel')">
<a-select v-model:model-value="caseModalForm.priority" :placeholder="t('common.pleaseSelect')">
<template #label>
<span class="text-[var(--color-text-2)]"> <caseLevel :case-level="caseModalForm.priority" /></span>
</template>
<a-option v-for="item of casePriorityOptions" :key="item.value" :value="item.value">
<caseLevel :case-level="item.label as CaseLevel" />
</a-option>
</a-select>
</a-form-item>
<a-form-item field="status" :label="t('apiTestManagement.apiStatus')">
<a-select v-model:model-value="caseModalForm.status" :placeholder="t('common.pleaseSelect')">
<template #label>
<apiStatus :status="caseModalForm.status" />
</template>
<a-option v-for="item of Object.values(RequestDefinitionStatus)" :key="item" :value="item">
<apiStatus :status="item" />
</a-option>
</a-select>
</a-form-item>
<a-form-item field="tags" :label="t('common.tag')">
<MsTagsInput v-model:model-value="caseModalForm.tags" />
</a-form-item>
</div>
</a-form>
</div>
<div class="px-[16px] font-medium">{{ t('apiTestManagement.requestParams') }}</div>
<div class="flex-1 overflow-hidden">
<requestComposition
ref="requestCompositionRef"
v-model:request="apiDataDetail"
:is-case="true"
hide-response-layout-switch
:upload-temp-file-api="uploadTempFileCase"
:file-save-as-source-id="apiDataDetail.id"
:file-module-options-api="getTransferOptionsCase"
:file-save-as-api="transferFileCase"
:current-env-config="currentEnvConfig"
:is-definition="true"
/>
</div>
</div>
</MsDrawer>
</template>
<script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import MsDetailCard from '@/components/pure/ms-detail-card/index.vue';
import MsDrawer from '@/components/pure/ms-drawer/index.vue';
import MsTagsInput from '@/components/pure/ms-tags-input/index.vue';
import caseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import type { CaseLevel } from '@/components/business/ms-case-associate/types';
import environmentSelect from '../../environmentSelect.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import apiStatus from '@/views/api-test/components/apiStatus.vue';
import requestComposition, { RequestParam } from '@/views/api-test/components/requestComposition/index.vue';
import {
addCase,
getTransferOptionsCase,
transferFileCase,
uploadTempFileCase,
} from '@/api/modules/api-test/management';
import { useI18n } from '@/hooks/useI18n';
import { ApiCaseDetail } from '@/models/apiTest/management';
import { EnvConfig } from '@/models/projectManagement/environmental';
import { RequestDefinitionStatus, RequestMethods } from '@/enums/apiEnum';
import { casePriorityOptions } from '@/views/api-test/components/config';
const props = defineProps<{
apiDetail: RequestParam;
}>();
const emit = defineEmits(['loadCase']);
const apiDataDetail = ref<RequestParam>(cloneDeep(props.apiDetail));
const { t } = useI18n();
const innerVisible = ref(false);
const drawerLoading = ref(false);
const description = computed(() => [
{
key: 'type',
locale: 'apiTestManagement.apiType',
value: apiDataDetail.value.method,
},
{
key: 'path',
locale: 'apiTestManagement.path',
value: apiDataDetail.value.url || apiDataDetail.value.path,
},
]);
const environmentSelectRef = ref<InstanceType<typeof environmentSelect>>();
const currentEnvConfig = computed<EnvConfig | undefined>(() => environmentSelectRef.value?.currentEnvConfig);
const formRef = ref<FormInstance>();
const initForm: any = {
apiDefinitionId: apiDataDetail.value.id as string,
name: '',
priority: 'P0',
tags: [],
status: RequestDefinitionStatus.PROCESSING,
};
const caseModalForm = ref({ ...initForm });
const requestCompositionRef = ref<InstanceType<typeof requestComposition>>();
function open(record?: ApiCaseDetail, isCopy?: boolean) {
innerVisible.value = true;
if (isCopy) {
caseModalForm.value.name = record?.name;
}
}
function handleSaveCaseCancel() {
innerVisible.value = false;
formRef.value?.resetFields();
caseModalForm.value = { ...initForm };
}
function handleDrawerConfirm(isContinue: boolean) {
formRef.value?.validate(async (errors) => {
if (!errors) {
drawerLoading.value = true;
const params = { ...requestCompositionRef.value?.makeRequestParams(), ...caseModalForm.value };
try {
await addCase(params);
Message.success(t('common.updateSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
if (!isContinue) {
emit('loadCase');
handleSaveCaseCancel();
}
caseModalForm.value = { ...initForm };
drawerLoading.value = false;
}
});
}
defineExpose({
open,
});
</script>
<style lang="less" scoped>
:deep(.arco-select-view-value) {
font-weight: 400;
}
:deep(.ms-detail-card-title) {
width: 50%;
}
:deep(.ms-detail-card-desc) {
gap: 16px;
& > div {
width: auto;
}
}
:deep(.arco-form > .arco-form-item):nth-child(1) .arco-form-item-label-col {
display: none;
}
:deep(.request-and-response) {
height: calc(100% - 56px);
}
</style>

View File

@ -1,22 +1,156 @@
<template>
<div class="flex h-full flex-col">
<div v-show="activeApiTab.id === 'all'" class="flex-1">
<caseTable :active-module="props.activeModule" :protocol="props.protocol" />
<div class="flex flex-1 flex-col overflow-hidden">
<div v-show="activeApiTab.id === 'all'" class="flex-1 overflow-hidden">
<caseTable
:is-api="false"
:active-module="props.activeModule"
:protocol="props.protocol"
@open-case-tab="openCaseTab"
/>
</div>
<div v-show="activeApiTab.id !== 'all'" class="flex-1 overflow-hidden">
<caseDetail :active-api-tab="activeApiTab" :module-tree="props.moduleTree" />
</div>
</div>
</template>
<script setup lang="ts">
import { cloneDeep } from 'lodash-es';
import { TabItem } from '@/components/pure/ms-editable-tab/types';
import caseDetail from './caseDetail.vue';
import caseTable from './caseTable.vue';
import { getCaseDetail } from '@/api/modules/api-test/management';
import { ApiCaseDetail } from '@/models/apiTest/management';
import { ModuleTreeNode } from '@/models/common';
import { RequestAuthType, RequestComposition, RequestMethods, ResponseComposition } from '@/enums/apiEnum';
import { defaultBodyParams, defaultResponse, defaultResponseItem } from '@/views/api-test/components/config';
import type { RequestParam } from '@/views/api-test/components/requestComposition/index.vue';
import { parseRequestBodyFiles } from '@/views/api-test/components/utils';
const props = defineProps<{
activeModule: string;
protocol: string;
moduleTree: ModuleTreeNode[]; //
}>();
const apiTabs = defineModel<RequestParam[]>('apiTabs', {
required: true,
});
const activeApiTab = defineModel<RequestParam>('activeApiTab', {
required: true,
});
const initDefaultId = `case-${Date.now()}`;
const defaultCaseParams: RequestParam = {
id: initDefaultId,
moduleId: props.activeModule === 'all' ? 'root' : props.activeModule,
protocol: 'HTTP',
tags: [],
description: '',
url: '',
activeTab: RequestComposition.HEADER,
closable: true,
method: RequestMethods.GET,
headers: [],
body: cloneDeep(defaultBodyParams),
query: [],
rest: [],
polymorphicName: '',
name: '',
path: '',
projectId: '',
uploadFileIds: [],
linkFileIds: [],
authConfig: {
authType: RequestAuthType.NONE,
basicAuth: {
userName: '',
password: '',
},
digestAuth: {
userName: '',
password: '',
},
},
children: [
{
polymorphicName: 'MsCommonElement', // MsCommonElement
assertionConfig: {
enableGlobal: false,
assertions: [],
},
postProcessorConfig: {
enableGlobal: false,
processors: [],
},
preProcessorConfig: {
enableGlobal: false,
processors: [],
},
},
],
otherConfig: {
connectTimeout: 60000,
responseTimeout: 60000,
certificateAlias: '',
followRedirects: true,
autoRedirects: false,
},
responseActiveTab: ResponseComposition.BODY,
response: cloneDeep(defaultResponse),
responseDefinition: [cloneDeep(defaultResponseItem)],
isNew: true,
mode: 'case',
executeLoading: false,
preDependency: [], //
postDependency: [], //
};
function addTab(defaultProps?: Partial<TabItem>) {
apiTabs.value.push({
...cloneDeep(defaultCaseParams),
...defaultProps,
});
activeApiTab.value = apiTabs.value[apiTabs.value.length - 1];
}
const loading = ref(false);
async function openCaseTab(apiInfo: ApiCaseDetail) {
const isLoadedTabIndex = apiTabs.value.findIndex(
(e) => e.id === (typeof apiInfo === 'string' ? apiInfo : apiInfo.id)
);
if (isLoadedTabIndex > -1) {
// tabtab
activeApiTab.value = apiTabs.value[isLoadedTabIndex] as RequestParam;
return;
}
try {
loading.value = true;
const res = await getCaseDetail(typeof apiInfo === 'string' ? apiInfo : apiInfo.id);
const parseRequestBodyResult = parseRequestBodyFiles(res.request.body); // id ;
// if (res.protocol === 'HTTP') { // TODO: protocol
// parseRequestBodyResult = parseRequestBodyFiles(res.request.body); // id
// }
addTab({
...res.request,
...res,
response: cloneDeep(defaultResponse),
// responseDefinition: res.response.map((e) => ({ ...e, responseActiveTab: ResponseComposition.BODY })), // TODO: response
url: res.path,
...parseRequestBodyResult,
});
nextTick(() => {
loading.value = false; // loading
});
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
loading.value = false;
}
}
</script>

View File

@ -5,6 +5,7 @@
v-model:active-tab="activeApiTab"
v-model:tabs="apiTabs"
class="flex-1 overflow-hidden"
:show-add="currentTab === 'api'"
@add="newTab"
>
<template #label="{ tab }">
@ -36,7 +37,7 @@
</a-select>
</div>
<api
v-if="currentTab === 'api'"
v-show="(activeApiTab.id === 'all' && currentTab === 'api') || activeApiTab.mode === 'definition'"
ref="apiRef"
v-model:active-api-tab="activeApiTab"
v-model:api-tabs="apiTabs"
@ -46,10 +47,12 @@
:module-tree="props.moduleTree"
/>
<apiCase
v-show="currentTab === 'case'"
v-show="(activeApiTab.id === 'all' && currentTab === 'case') || activeApiTab.mode === 'case'"
v-model:api-tabs="apiTabs"
v-model:active-api-tab="activeApiTab"
:active-module="props.activeModule"
:protocol="props.protocol"
:module-tree="props.moduleTree"
/>
</template>
@ -124,6 +127,7 @@
//
function currentTabChange(val: any) {
apiTabs.value[0].label = val === 'api' ? t('apiTestManagement.allApi') : t('case.allCase');
activeApiTab.value = apiTabs.value[0] as RequestParam;
}
watch(

View File

@ -190,6 +190,8 @@ export default {
'case.batchRecoverCaseTip': 'Are you sure you want to recover {count} selected cases?',
'case.recycle.recoverCaseTip': 'When restoring the case, the deleted API will be restored simultaneously.',
'case.recycle.confirmRecovery': 'Confirm recovery',
'case.createCase': 'Create Case',
'case.saveContinueText': 'Save & continue',
'case.detail.changeHistoryTip': `View and compare historical changes. According to the administrator's setting rules, historical changes will be automatically deleted`,
'case.detail.noReminders': 'No longer remind',
'case.detail.changeNumber': 'Change sequence',

View File

@ -182,6 +182,8 @@ export default {
'case.batchRecoverCaseTip': '确认恢复已选中的 {count} 个用例吗?',
'case.recycle.recoverCaseTip': '恢复case时会同步恢复被删除的api',
'case.recycle.confirmRecovery': '确认恢复',
'case.createCase': '创建用例',
'case.saveContinueText': '保存并继续创建',
'case.detail.changeHistoryTip': '查看、对比历史修改,根据管理员设置规则,变更历史数据将自动删除',
'case.detail.noReminders': '不再提醒',
'case.detail.changeNumber': '变更序号',