diff --git a/frontend/src/api/modules/api-test/scenario.ts b/frontend/src/api/modules/api-test/scenario.ts index bdd38d4759..8c030170a4 100644 --- a/frontend/src/api/modules/api-test/scenario.ts +++ b/frontend/src/api/modules/api-test/scenario.ts @@ -36,6 +36,7 @@ import { ScenarioAssociateExportUrl, ScenarioBatchEditScheduleUrl, ScenarioBatchExportLogUrl, + ScenarioCopyStepFilesUrl, ScenarioExportLogUrl, ScenarioHistoryUrl, ScenarioPageUrl, @@ -71,6 +72,7 @@ import { ExecuteHistoryItem, ExecutePageParams, type ExportScenarioParams, + type GetScenarioUnSaveStepParams, GetSystemRequestParams, type ImportScenarioParams, ImportSystemData, @@ -376,3 +378,11 @@ export function scenarioBatchEditSchedule(data: ApiScenarioBatchScheduleConfig) export function getScenarioStatistics(data: string[]) { return MSR.post({ url: ScenarioStatisticsUrl, data }); } + +// 复制步骤时复制文件 +export function scenarioCopyStepFiles(data: GetScenarioUnSaveStepParams) { + return MSR.post>({ + url: ScenarioCopyStepFilesUrl, + data, + }); +} diff --git a/frontend/src/api/requrls/api-test/scenario.ts b/frontend/src/api/requrls/api-test/scenario.ts index ee1ac629d5..51c7865c89 100644 --- a/frontend/src/api/requrls/api-test/scenario.ts +++ b/frontend/src/api/requrls/api-test/scenario.ts @@ -29,6 +29,7 @@ export const BatchRunScenarioUrl = '/api/scenario/batch-operation/run'; // 批 export const UpdateScenarioPriorityUrl = '/api/scenario/update-priority'; // 场景更新等级 export const UpdateScenarioStatusUrl = '/api/scenario/update-status'; // 场景更新状态 export const ScenarioStatisticsUrl = '/api/scenario/statistics'; // 场景执行率统计 +export const ScenarioCopyStepFilesUrl = '/api/scenario/step/file/copy'; // 复制步骤时复制文件 // 场景导入导出相关 export const ImportScenarioUrl = '/api/scenario/import'; // 导入场景 diff --git a/frontend/src/models/apiTest/scenario.ts b/frontend/src/models/apiTest/scenario.ts index 135a89b43d..4c117936a7 100644 --- a/frontend/src/models/apiTest/scenario.ts +++ b/frontend/src/models/apiTest/scenario.ts @@ -571,3 +571,13 @@ export interface ScenarioStatisticsItem { id: string; execPassRate: string; } + +// 场景未保存步骤请求参数 +export interface GetScenarioUnSaveStepParams { + copyFromStepId?: string; + resourceId?: string; + stepType?: string; + refType: string; + isTempFile: boolean; // 复制未保存的步骤时 true + fileIds?: string[]; // 未保存的步骤文件 id,复制未加载/修改过详情的步骤时无需传 +} diff --git a/frontend/src/views/api-test/components/utils.ts b/frontend/src/views/api-test/components/utils.ts index 89d93e3105..a18f1f5102 100644 --- a/frontend/src/views/api-test/components/utils.ts +++ b/frontend/src/views/api-test/components/utils.ts @@ -31,12 +31,17 @@ export interface ParseResult { /** * 解析接口请求/Mock body 内的文件列表 * @param body body 参数对象 + * @param response 响应列表 + * @param saveUploadFileIds 已保存的上传文件 id 集合 + * @param saveLinkFileIds 已保存的关联文件 id 集合 + * @param newFileMap 新文件 id 映射 */ export function parseRequestBodyFiles( body: ExecuteBody | MockBody, response?: ResponseDefinition[], saveUploadFileIds?: string[], - saveLinkFileIds?: string[] + saveLinkFileIds?: string[], + newFileMap?: Record ): ParseResult { const { binaryBody } = body; const uploadFileIds = new Set(); // 存储本地上传的文件 id 集合 @@ -45,49 +50,58 @@ export function parseRequestBodyFiles( const tempSaveLinkFileIds = new Set(); // 临时存储 body 内已保存的关联文件 id 集合,用于对比 saveLinkFileIds 以判断有哪些文件被取消关联 // 获取上传文件和关联文件 const formValues = - ((body as ExecuteBody).formDataBody?.formValues || (body as MockBody).formDataBody?.matchRules || []).filter( - (e) => e - ) || []; + (body as ExecuteBody).formDataBody?.formValues || (body as MockBody).formDataBody?.matchRules || [] || []; for (let i = 0; i < formValues.length; i++) { const item = formValues[i]; - if (item.paramType === RequestParamsType.FILE) { - if (item.files) { - for (let j = 0; j < item.files.length; j++) { - const file = item.files[j]; - if (file.local) { - // 本地上传的文件 - if (saveUploadFileIds) { - // 如果有已保存的上传文件id集合 - if (saveUploadFileIds.includes(file.fileId)) { - // 当前文件是已保存的文件,存入 tempSaveUploadFileIds - tempSaveUploadFileIds.add(file.fileId); + if (item) { + if (item.paramType === RequestParamsType.FILE) { + if (item.files) { + for (let j = 0; j < item.files.length; j++) { + const file = item.files[j]; + let { fileId } = file; + if (newFileMap && newFileMap[fileId]) { + fileId = newFileMap[fileId]; + file.fileId = fileId; + } + if (file.local) { + // 本地上传的文件 + if (saveUploadFileIds) { + // 如果有已保存的上传文件id集合 + if (saveUploadFileIds.includes(fileId)) { + // 当前文件是已保存的文件,存入 tempSaveUploadFileIds + tempSaveUploadFileIds.add(fileId); + } else { + // 当前文件不是已保存的文件,存入 uploadFileIds + uploadFileIds.add(fileId); + } + } else { + // 没有已保存的文件id集合,直接存入 uploadFileIds + uploadFileIds.add(fileId); + } + } else if (saveLinkFileIds) { + // 如果有已保存的关联文件id集合 + if (saveLinkFileIds.includes(fileId)) { + // 当前文件是已保存的文件,存入 + tempSaveLinkFileIds.add(fileId); } else { // 当前文件不是已保存的文件,存入 uploadFileIds - uploadFileIds.add(file.fileId); + linkFileIds.add(fileId); } } else { - // 没有已保存的文件id集合,直接存入 uploadFileIds - uploadFileIds.add(file.fileId); + // 关联的文件 + linkFileIds.add(fileId); } - } else if (saveLinkFileIds) { - // 如果有已保存的关联文件id集合 - if (saveLinkFileIds.includes(file.fileId)) { - // 当前文件是已保存的文件,存入 - tempSaveLinkFileIds.add(file.fileId); - } else { - // 当前文件不是已保存的文件,存入 uploadFileIds - linkFileIds.add(file.fileId); - } - } else { - // 关联的文件 - linkFileIds.add(file.fileId); } } } } } if (binaryBody && binaryBody.file) { - const { fileId } = binaryBody.file; + let { fileId } = binaryBody.file; + if (newFileMap && newFileMap[fileId]) { + fileId = newFileMap[fileId]; + binaryBody.file.fileId = fileId; + } if (binaryBody.file?.local) { if (saveUploadFileIds) { // 如果有已保存的上传文件id集合 @@ -119,7 +133,11 @@ export function parseRequestBodyFiles( if (response) { response.forEach((res) => { if (res.body.binaryBody && res.body.binaryBody.file) { - const { fileId } = res.body.binaryBody.file; + let { fileId } = res.body.binaryBody.file; + if (newFileMap && newFileMap[fileId]) { + fileId = newFileMap[fileId]; + res.body.binaryBody.file.fileId = fileId; + } if (res.body.binaryBody.file?.local) { if (saveUploadFileIds) { // 如果有已保存的上传文件id集合 diff --git a/frontend/src/views/api-test/scenario/components/common/customApiDrawer.vue b/frontend/src/views/api-test/scenario/components/common/customApiDrawer.vue index 958facd65e..34d3541521 100644 --- a/frontend/src/views/api-test/scenario/components/common/customApiDrawer.vue +++ b/frontend/src/views/api-test/scenario/components/common/customApiDrawer.vue @@ -375,7 +375,12 @@ import { getPluginScript, getProtocolList } from '@/api/modules/api-test/common'; import { getDefinitionDetail } from '@/api/modules/api-test/management'; - import { getTransferOptions, stepTransferFile, uploadTempFile } from '@/api/modules/api-test/scenario'; + import { + getTransferOptions, + scenarioCopyStepFiles, + stepTransferFile, + uploadTempFile, + } from '@/api/modules/api-test/scenario'; import { useI18n } from '@/hooks/useI18n'; import { useAppStore } from '@/store'; import { getGenerateId, parseQueryParams } from '@/utils'; @@ -403,6 +408,7 @@ RequestComposition, RequestMethods, ResponseComposition, + ScenarioStepRefType, ScenarioStepType, } from '@/enums/apiEnum'; @@ -564,6 +570,7 @@ }; const requestVModel = ref(defaultApiParams); + const copyStepFileIdsMap = ref>({}); // 步骤类型判断 const _stepType = computed(() => { if (props.step) { @@ -994,7 +1001,8 @@ requestVModel.value.body, undefined, props.fileParams?.uploadFileIds || requestVModel.value.uploadFileIds, // 外面解析详情的时候传入,或引用 api 在requestVModel内存储 - props.fileParams?.linkFileIds || requestVModel.value.linkFileIds // 外面解析详情的时候传入,或引用 api 在requestVModel内存储 + props.fileParams?.linkFileIds || requestVModel.value.linkFileIds, // 外面解析详情的时候传入,或引用 api 在requestVModel内存储 + copyStepFileIdsMap.value ); requestParams = { authConfig: requestVModel.value.authConfig, @@ -1208,7 +1216,20 @@ const res = await getDefinitionDetail(props.step?.resourceId || ''); let parseRequestBodyResult; if (res.protocol === 'HTTP') { - parseRequestBodyResult = parseRequestBodyFiles(res.request.body, res.response); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件 + if ((props.step?.copyFromStepId || props.step?.refType === ScenarioStepRefType.COPY) && props.step?.isNew) { + // 复制的步骤需要复制文件 + copyStepFileIdsMap.value = await scenarioCopyStepFiles({ + copyFromStepId: props.step?.copyFromStepId, + resourceId: props.step?.resourceId, + stepType: props.step?.stepType, + refType: props.step?.refType, + isTempFile: false, // 复制未保存的步骤时 true + fileIds: Object.values(parseRequestBodyFiles(res.request.body, [], [], [])).flat(), + }); + parseRequestBodyFiles(res.body, [], [], [], copyStepFileIdsMap.value); + } else { + parseRequestBodyResult = parseRequestBodyFiles(res.request.body, [], [], [], copyStepFileIdsMap.value); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件 + } } requestVModel.value = { executeLoading: false, diff --git a/frontend/src/views/api-test/scenario/components/step/stepTree.vue b/frontend/src/views/api-test/scenario/components/step/stepTree.vue index 7355817858..c4c48ac9e1 100644 --- a/frontend/src/views/api-test/scenario/components/step/stepTree.vue +++ b/frontend/src/views/api-test/scenario/components/step/stepTree.vue @@ -482,6 +482,7 @@ import saveAsApiModal from '@/views/api-test/components/saveAsApiModal.vue'; import { addCase, getDefinitionDetail } from '@/api/modules/api-test/management'; + import { scenarioCopyStepFiles } from '@/api/modules/api-test/scenario'; import { useI18n } from '@/hooks/useI18n'; import useModal from '@/hooks/useModal'; import useAppStore from '@/store/modules/app'; @@ -514,6 +515,7 @@ import useStepNodeEdit from './useStepNodeEdit'; import useStepOperation from './useStepOperation'; import { casePriorityOptions, caseStatusOptions } from '@/views/api-test/components/config'; + import { parseRequestBodyFiles } from '@/views/api-test/components/utils'; import getStepType from '@/views/api-test/scenario/components/common/stepType/utils'; import { defaultStepItemCommon } from '@/views/api-test/scenario/components/config'; @@ -919,73 +921,101 @@ * 复制步骤 * @param node 复制的节点 */ - function copyStep(node: MsTreeNodeData) { - const id = getGenerateId(); - const stepDetail = stepDetails.value[node.id]; - const stepFileParam = scenario.value.stepFileParam[node.id]; - const { isQuoteScenario } = getStepType(node as ScenarioStepItem); - if (stepDetail) { - // 如果复制的步骤还有详情数据,则也复制详情数据 - stepDetails.value[id] = cloneDeep({ - ...stepDetail, - stepId: id, - uniqueId: id, - }); - } - if (stepFileParam) { - // 如果复制的步骤还有详情数据,则也复制详情数据 - scenario.value.stepFileParam[id] = cloneDeep(stepFileParam); - } - insertNodes( - steps.value, - node.uniqueId, - { - ...cloneDeep( - mapTree(node, (childNode) => { - const childId = getGenerateId(); - const childStepDetail = stepDetails.value[childNode.id]; - const childStepFileParam = scenario.value.stepFileParam[childNode.id]; - let childCopyFromStepId = childNode.id; - if (childStepDetail) { - // 如果复制的步骤下子步骤还有详情数据,则也复制详情数据 - stepDetails.value[childId] = cloneDeep(childStepDetail); - } - if (childStepFileParam) { - // 如果复制的步骤下子步骤还有详情数据,则也复制详情数据 - scenario.value.stepFileParam[childNode.id] = cloneDeep(childStepFileParam); - } - if (!isQuoteScenario) { - // 非引用场景才处理复制来源 id - if (childStepDetail || (childNode.isNew && childNode.stepRefType === ScenarioStepRefType.REF)) { - // 如果子步骤查看过详情,则复制来源直接取它的 id - // 如果子步骤没有查看过详情,且是新建的步骤,且子步骤是引用的步骤,则还是取它本身的 id - childCopyFromStepId = childNode.id; - } else if (childNode.isNew && childNode.stepRefType === ScenarioStepRefType.COPY) { - // 如果子步骤没有查看过详情,且是新建的步骤,且子步骤是复制的步骤,则取它的来源 id - childCopyFromStepId = childNode.copyFromStepId; + async function copyStep(node: MsTreeNodeData) { + loading.value = true; + try { + const id = getGenerateId(); + const stepDetail = stepDetails.value[node.id]; + const { isQuoteScenario } = getStepType(node as ScenarioStepItem); + let { copyFromStepId } = node; + if (stepDetail || node.isNew !== true || !node.copyFromStepId) { + // 如果复制的步骤查看过详情,则复制来源直接取它的 id + // 如果复制的步骤没有查看过详情,且是新建的步骤,则取它本身的 id + copyFromStepId = node.id; + } + let parseRequestBodyResult: Record = { + uploadFileIds: [], + linkFileIds: [], + deleteFileIds: [], // 存储对比已保存的文件后,需要删除的文件 id 集合 + unLinkFileIds: [], // 存储对比已保存的文件后,需要取消关联的文件 id 集合 + }; + let newFileRes; + if (node.config.protocol === 'HTTP' && (stepDetail as RequestParam)?.body) { + if (node.copyFromStepId || node.refType === ScenarioStepRefType.COPY) { + // 复制的步骤需要复制文件 + newFileRes = await scenarioCopyStepFiles({ + copyFromStepId, + resourceId: node.resourceId, + stepType: node.stepType, + refType: node.refType, + isTempFile: !!stepDetail, // 复制未保存的步骤时 true + fileIds: Object.values(parseRequestBodyFiles((stepDetail as RequestParam).body, [], [], [])).flat(), + }); + parseRequestBodyFiles((stepDetail as RequestParam).body, [], [], [], newFileRes); + } else { + parseRequestBodyResult = parseRequestBodyFiles((stepDetail as RequestParam).body, [], [], [], newFileRes); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件 + } + } + if (stepDetail) { + // 如果复制的步骤还有详情数据,则也复制详情数据 + stepDetails.value[id] = cloneDeep({ + ...stepDetail, + stepId: id, + uniqueId: id, + ...parseRequestBodyResult, + }); + } + insertNodes( + steps.value, + node.uniqueId, + { + ...cloneDeep( + mapTree(node, (childNode) => { + const childId = getGenerateId(); + const childStepDetail = stepDetails.value[childNode.id]; + let childCopyFromStepId = childNode.id; + if (childStepDetail) { + // 如果复制的步骤下子步骤还有详情数据,则也复制详情数据 + stepDetails.value[childId] = cloneDeep(childStepDetail); } - } - return { - ...cloneDeep(childNode), - executeStatus: undefined, - copyFromStepId: childCopyFromStepId, - id: childId, - uniqueId: childId, - }; - })[0] - ), - name: `copy_${node.name}`.substring(0, 255), - copyFromStepId: stepDetail || node.isNew !== true ? node.id : node.copyFromStepId, - sort: node.sort + 1, - isNew: true, - id, - uniqueId: id, - }, - 'after', - selectedIfNeed, - 'uniqueId' - ); - scenario.value.unSaved = true; + if (!isQuoteScenario) { + // 非引用场景才处理复制来源 id + if (childStepDetail || (childNode.isNew && childNode.stepRefType === ScenarioStepRefType.REF)) { + // 如果子步骤查看过详情,则复制来源直接取它的 id + // 如果子步骤没有查看过详情,且是新建的步骤,且子步骤是引用的步骤,则还是取它本身的 id + childCopyFromStepId = childNode.id; + } else if (childNode.isNew && childNode.stepRefType === ScenarioStepRefType.COPY) { + // 如果子步骤没有查看过详情,且是新建的步骤,且子步骤是复制的步骤,则取它的来源 id + childCopyFromStepId = childNode.copyFromStepId; + } + } + return { + ...cloneDeep(childNode), + executeStatus: undefined, + copyFromStepId: childCopyFromStepId, + id: childId, + uniqueId: childId, + }; + })[0] + ), + name: `copy_${node.name}`.substring(0, 255), + copyFromStepId, + sort: node.sort + 1, + isNew: true, + id, + uniqueId: id, + }, + 'after', + selectedIfNeed, + 'uniqueId' + ); + scenario.value.unSaved = true; + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + } } async function handleStepMoreActionSelect(item: ActionsItem, node: MsTreeNodeData) { @@ -1189,6 +1219,14 @@ appStore.currentProjectId ); const insertSteps = insertApiSteps.concat(insertCaseSteps).concat(insertScenarioSteps); + insertSteps.forEach((step) => { + scenario.value.stepFileParam[step.id] = { + linkFileIds: [], + uploadFileIds: [], + deleteFileIds: [], + unLinkFileIds: [], + }; + }); if (activeStepByCreate.value && activeCreateAction.value) { handleCreateSteps( activeStepByCreate.value, diff --git a/frontend/src/views/api-test/scenario/components/step/useStepOperation.ts b/frontend/src/views/api-test/scenario/components/step/useStepOperation.ts index 5825296634..548ec3ea2b 100644 --- a/frontend/src/views/api-test/scenario/components/step/useStepOperation.ts +++ b/frontend/src/views/api-test/scenario/components/step/useStepOperation.ts @@ -3,7 +3,7 @@ import { cloneDeep } from 'lodash-es'; import type { MsTreeExpandedData, MsTreeNodeData } from '@/components/business/ms-tree/types'; -import { getScenarioStep } from '@/api/modules/api-test/scenario'; +import { getScenarioStep, scenarioCopyStepFiles } from '@/api/modules/api-test/scenario'; import { useI18n } from '@/hooks/useI18n'; import useModal from '@/hooks/useModal'; import useAppStore from '@/store/modules/app'; @@ -12,6 +12,7 @@ import { deleteNode, findNodeByKey, handleTreeDragDrop, mapTree } from '@/utils' import type { Scenario, ScenarioStepItem } from '@/models/apiTest/scenario'; import { ScenarioStepRefType, ScenarioStepType } from '@/enums/apiEnum'; +import type { RequestParam } from '../common/customApiDrawer.vue'; import getStepType from '../common/stepType/utils'; import { parseRequestBodyFiles } from '@/views/api-test/components/utils'; @@ -57,9 +58,28 @@ export default function useStepOperation({ try { appStore.showLoading(); const res = await getScenarioStep(step.copyFromStepId || step.id); - let parseRequestBodyResult; + let parseRequestBodyResult: Record = { + uploadFileIds: [], + linkFileIds: [], + deleteFileIds: [], // 存储对比已保存的文件后,需要删除的文件 id 集合 + unLinkFileIds: [], // 存储对比已保存的文件后,需要取消关联的文件 id 集合 + }; + let newFileRes; if (step.config.protocol === 'HTTP' && res.body) { - parseRequestBodyResult = parseRequestBodyFiles(res.body); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件 + if ((step.copyFromStepId || step.refType === ScenarioStepRefType.COPY) && step.isNew) { + // 复制的步骤需要复制文件 + newFileRes = await scenarioCopyStepFiles({ + copyFromStepId: step.copyFromStepId, + resourceId: step.resourceId, + stepType: step.stepType, + refType: step.refType, + isTempFile: false, // 复制未保存的步骤时 true + fileIds: Object.values(parseRequestBodyFiles((res as RequestParam).body, [], [], [])).flat(), + }); + parseRequestBodyFiles(res.body, [], [], [], newFileRes); + } else { + parseRequestBodyResult = parseRequestBodyFiles(res.body, [], [], [], newFileRes); // 解析请求体中的文件,将详情中的文件 id 集合收集,更新时以判断文件是否删除以及是否新上传的文件 + } } stepDetails.value[step.id] = { ...res, @@ -68,9 +88,12 @@ export default function useStepOperation({ method: step.config.method || '', ...parseRequestBodyResult, }; - scenario.value.stepFileParam[step.id] = { - ...parseRequestBodyResult, - }; + if (!step.copyFromStepId && step.refType !== ScenarioStepRefType.COPY) { + // 复制的步骤文件都是新的,不需要记录,等详情抽屉关闭时会处理 + scenario.value.stepFileParam[step.id] = { + ...parseRequestBodyResult, + }; + } } catch (error) { // eslint-disable-next-line no-console console.log(error); diff --git a/frontend/src/views/api-test/scenario/index.vue b/frontend/src/views/api-test/scenario/index.vue index 893a885fb5..f17e879a65 100644 --- a/frontend/src/views/api-test/scenario/index.vue +++ b/frontend/src/views/api-test/scenario/index.vue @@ -153,7 +153,7 @@ import router from '@/router'; import useAppStore from '@/store/modules/app'; import useCacheStore from '@/store/modules/cache/cache'; - import { filterTree, getGenerateId, mapTree } from '@/utils'; + import { filterTree, getGenerateId, mapTree, traverseTree } from '@/utils'; import { hasAnyPermission } from '@/utils/permission'; import { RequestResult } from '@/models/apiTest/common'; @@ -425,6 +425,7 @@ if (isCopy) { // 场景被复制,递归处理节点,增加copyFromStepId copySteps = mapTree(defaultScenarioInfo.steps, (node) => { + node.isNew = true; node.copyFromStepId = node.id; if ( node.parent && @@ -677,6 +678,10 @@ }), }); } + traverseTree(activeScenarioTab.value.steps, (node) => { + node.isNew = false; + }); + activeScenarioTab.value.stepFileParam = {}; refreshTree(tempTableQueryParams.value); Message.success(activeScenarioTab.value.isNew ? t('common.createSuccess') : t('common.saveSuccess')); activeScenarioTab.value.unSaved = false;