feat(环境管理): 断言文档添加验证项

This commit is contained in:
RubyLiu 2024-02-26 18:22:05 +08:00 committed by 刘瑞斌
parent 1dd0eaef55
commit 739fe5f18c
5 changed files with 340 additions and 149 deletions

View File

@ -14,7 +14,7 @@
:columns="jsonPathColumns"
:scroll="{ minWidth: '700px' }"
:default-param-item="jsonPathDefaultParamItem"
@change="handleChange"
@change="(data, isInit) => handleChange(data, !!isInit, 'jsonPath')"
@more-action-select="(e,r)=> handleExtractParamMoreActionSelect(e,r as ExpressionConfig)"
>
<template #expression="{ record, rowIndex }">
@ -99,7 +99,7 @@
:columns="xPathColumns"
:scroll="{ minWidth: '700px' }"
:default-param-item="xPathDefaultParamItem"
@change="handleChange"
@change="(data, isInit) => handleChange(data, !!isInit, 'xPath')"
@more-action-select="(e,r)=> handleExtractParamMoreActionSelect(e,r as ExpressionConfig)"
>
<template #expression="{ record, rowIndex }">
@ -168,38 +168,64 @@
</paramsTable>
</div>
<div v-if="activeTab === 'document'" class="relative mt-[16px]">
<paramsTable
v-model:params="innerParams.document.data"
:selectable="false"
:columns="documentColumns"
:scroll="{
minWidth: '700px',
}"
:height-used="580"
:default-param-item="documentDefaultParamItem"
:span-method="documentSpanMethod"
@change="handleChange"
@more-action-select="(e,r)=> handleExtractParamMoreActionSelect(e,r as ExpressionConfig)"
>
<template #operationPre="{ record }">
<a-tooltip v-if="['object', 'array'].includes(record.paramType)" :content="t('ms.assertion.addChild')">
<div
class="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-[rgb(var(--primary-5))] hover:bg-[rgb(var(--primary-1))]"
@click="addChild(record)"
<div class="text-[var(--color-text-1)]">
{{ t('ms.assertion.responseContentType') }}
</div>
<a-radio-group v-model:model-value="innerParams.document.responseFormat" class="mt-[16px]" size="small">
<a-radio value="JSON">JSON</a-radio>
<a-radio value="XML">XML</a-radio>
</a-radio-group>
<div class="mt-[16px]">
<a-checkbox v-model:model-value="innerParams.document.followApi">
<span class="text-[var(--color-text-1)]">{{ t('ms.assertion.followApi') }}</span>
</a-checkbox>
</div>
<div class="mt-[16px]">
<paramsTable
v-model:params="innerParams.document.data"
:selectable="false"
:columns="documentColumns"
:scroll="{
minWidth: '700px',
}"
:height-used="580"
:default-param-item="documentDefaultParamItem"
:span-method="documentSpanMethod"
is-tree-table
@tree-delete="deleteAllParam"
@change="(data, isInit) => handleChange(data, !!isInit, 'document')"
>
<template #matchValueDelete="{ record }">
<icon-minus-circle
v-if="showDeleteSingle && (record.rowSpan > 1 || record.groupId)"
class="ml-[8px] cursor-pointer text-[var(--color-text-4)]"
size="20"
@click="deleteSingleParam(record)"
/>
</template>
<template #operationPre="{ record }">
<a-tooltip v-if="['object', 'array'].includes(record.paramType)" :content="t('ms.assertion.addChild')">
<div
class="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-[rgb(var(--primary-5))] hover:bg-[rgb(var(--primary-1))]"
@click="addChild(record)"
>
<icon-plus size="16" />
</div>
</a-tooltip>
<a-tooltip
v-else-if="['string', 'integer', 'number', 'boolean'].includes(record.paramType) && record.id !== rootId"
:content="t('ms.assertion.validateChild')"
>
<icon-plus size="16" />
</div>
</a-tooltip>
<a-tooltip v-else :content="t('ms.assertion.validateChild')">
<div
class="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-[rgb(var(--primary-5))] hover:bg-[rgb(var(--primary-1))]"
@click="addValidateChild(record)"
>
<icon-bookmark size="16" />
</div>
</a-tooltip>
</template>
</paramsTable>
<div
class="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-[rgb(var(--primary-5))] hover:bg-[rgb(var(--primary-1))]"
@click="addValidateChild(record)"
>
<icon-bookmark size="16" />
</div>
</a-tooltip>
</template>
</paramsTable>
</div>
</div>
<div v-if="activeTab === 'regular'" class="mt-[16px]">
<paramsTable
@ -208,7 +234,7 @@
:columns="xPathColumns"
:scroll="{ minWidth: '700px' }"
:default-param-item="xPathDefaultParamItem"
@change="handleChange"
@change="(data, isInit) => handleChange(data, !!isInit, 'regular')"
@more-action-select="(e,r)=> handleExtractParamMoreActionSelect(e,r as ExpressionConfig)"
>
<template #expression="{ record, rowIndex }">
@ -301,7 +327,14 @@
import paramsTable, { type ParamTableColumn } from '@/views/api-test/components/paramTable.vue';
import { useI18n } from '@/hooks/useI18n';
import { findFirstByGroupId, insertTreeByCurrentId, insertTreeByGroupId } from '@/utils/tree';
import {
countNodes,
countNodesByGroupId,
deleteNodeById,
deleteNodesByGroupId,
findFirstByGroupId,
insertNode,
} from '@/utils/tree';
import {
ExecuteConditionProcessor,
@ -332,6 +365,7 @@
(e: 'change'): void;
}>();
const { t } = useI18n();
const rootId = 0; // 1970-01-01 00:00:00 UTC
// const innerParams = defineModel<Param>('modelValue', {
// default: {
@ -350,7 +384,17 @@
jsonPath: [],
xPath: { responseFormat: 'XML', data: [] },
document: {
data: [],
data: [
{
id: rootId,
paramsName: 'root',
mustInclude: false,
typeChecking: false,
paramType: 'object',
matchCondition: '',
matchValue: '',
},
],
responseFormat: 'JSON',
followApi: false,
},
@ -439,8 +483,19 @@
matchValue: '',
enable: true,
};
const handleChange = () => {
emit('change');
const handleChange = (data: any[], isInit: boolean, type: string) => {
if (isInit) {
return;
}
if (type === 'jsonPath') {
innerParams.value.jsonPath = data;
} else if (type === 'xPath') {
innerParams.value.xPath.data = data;
} else if (type === 'document') {
innerParams.value.document.data = data;
} else if (type === 'regular') {
innerParams.value.regular = data;
}
};
function handleExpressionChange(rowIndex: number) {
extractParamsTableRef.value?.addTableLine(rowIndex);
@ -481,6 +536,7 @@
title: 'ms.assertion.paramsName',
dataIndex: 'paramsName',
slotName: 'key',
addLineDisabled: true,
width: 300,
},
{
@ -506,6 +562,7 @@
showInTable: true,
showDrag: true,
columnSelectorDisabled: true,
addLineDisabled: true,
typeOptions: [
{ label: 'object', value: 'object' },
{ label: 'array', value: 'array' },
@ -516,24 +573,27 @@
],
titleSlotName: 'typeTitle',
typeTitleTooltip: t('project.environmental.paramTypeTooltip'),
width: 100,
},
{
title: 'ms.assertion.matchCondition',
dataIndex: 'matchCondition',
slotName: 'matchCondition',
options: statusCodeOptions,
width: 120,
},
{
title: 'ms.assertion.matchValue',
dataIndex: 'matchValue',
slotName: 'matchValue',
hasRequired: true,
showDelete: true,
},
{
title: '',
slotName: 'operation',
fixed: 'right',
width: 60,
width: 100,
align: 'right',
},
];
@ -626,29 +686,65 @@
if (record.groupId) {
//
const parent = innerParams.value.document.data.find((item: any) => item.id === record.groupId);
insertTreeByCurrentId(innerParams.value.document.data, record.id, {
...documentDefaultParamItem,
id: new Date().getTime(),
groupId: parent ? parent.id : record.groupId,
});
insertNode(
innerParams.value.document.data,
{ id: record.id, groupId: record.groupId },
{
...record,
id: new Date().getTime(),
groupId: parent ? parent.id : record.groupId,
matchValue: '',
matchCondition: '',
}
);
if (parent) {
parent.rowSpan = parent.rowSpan ? parent.rowSpan + 1 : 2;
} else {
//
const fisrtChildNode = findFirstByGroupId(innerParams.value.document.data, record.groupId);
if (fisrtChildNode) {
fisrtChildNode.rowSpan = fisrtChildNode.rowSpan ? fisrtChildNode.rowSpan + 1 : 2;
fisrtChildNode.rowSpan = fisrtChildNode.rowSpan
? fisrtChildNode.rowSpan + 1
: countNodesByGroupId(innerParams.value.document.data, record.groupId) + 1;
}
}
} else {
//
insertTreeByCurrentId(innerParams.value.document.data, record.id, {
...documentDefaultParamItem,
id: new Date().getTime(),
groupId: record.id,
});
record.rowSpan = record.rowSpan ? record.rowSpan + 1 : 2;
//
insertNode(
innerParams.value.document.data,
{ id: record.id, groupId: record.groupId },
{
...record,
id: new Date().getTime(),
groupId: record.id,
matchValue: '',
matchCondition: '',
}
);
}
};
const showDeleteSingle = computed(() => {
return countNodes(innerParams.value.document.data) > 1;
});
const deleteSingleParam = (record: Record<string, any>) => {
deleteNodeById(innerParams.value.document.data, record.id);
};
const deleteAllParam = (record: Record<string, any>) => {
if (record.groupId) {
// ,groupId
deleteNodesByGroupId(innerParams.value.document.data, record.groupId);
} else if (record.rowspan > 2) {
// , id groupId
deleteNodesByGroupId(innerParams.value.document.data, record.id);
//
deleteNodeById(innerParams.value.document.data, record.id);
} else {
//
deleteNodeById(innerParams.value.document.data, record.id);
}
};
const documentSpanMethod = (data: {
record: TableData;
column: TableColumnData | TableOperationColumn;
@ -660,25 +756,12 @@
const { record, column } = data;
const currentColumn = column as TableColumnData;
if (record.rowSpan > 1) {
if (currentColumn.slotName === 'key') {
const mergeColumns = ['key', 'mustContain', 'typeChecking', 'paramType', 'operation'];
if (mergeColumns.includes(currentColumn.title as string)) {
return {
rowspan: record.rowSpan,
colspan: 1,
};
}
if (currentColumn.slotName === 'operation') {
return {
rowspan: record.rowSpan,
colspan: 1,
};
}
if (currentColumn.slotName === 'mustContain') {
return {
rowspan: record.rowSpan,
colspan: 3,
};
}
}
return { rowspan: record.rowSpan, colspan: 1 };
};
</script>

View File

@ -15,7 +15,8 @@ export default {
'ms.assertion.regular': '正则',
'ms.assertion.script': '脚本',
'ms.assertion.expression': '表达式',
'ms.assertion.responseContentType': '响应内容类型',
'ms.assertion.responseContentType': '响应内容格式',
'ms.assertion.followApi': '跟随API定义',
'ms.assertion.paramsName': '参数名',
'ms.assertion.mustInclude': '必含',
'ms.assertion.typeChecking': '类型校验',

View File

@ -15,7 +15,7 @@ const useProjectEnvStore = defineStore(
'projectEnv',
() => {
// 项目中的key值
const currentId = ref<string>(ALL_PARAM);
const currentId = ref<string>('1052215449649153');
// 项目组选中的key值
const currentGroupId = ref<string>('');
const currentEnvDetailInfo = ref<EnvDetailItem>({ projectId: '', name: '', config: {} }); // 当前选中的环境详情

View File

@ -1,72 +1,159 @@
interface TreeData {
interface Tree {
id: number;
groupId?: number;
children?: TreeData[];
children?: Tree[];
[key: string]: any;
}
export function insertTreeByCurrentId(tree: TreeData[], currentId: number, newData: TreeData) {
const stack: Array<{ node: TreeData; parent: TreeData | null }> = tree.map((node) => ({ node, parent: null }));
while (stack.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { node, parent } = stack.pop()!;
if (parent && parent.children) {
const index = parent.children.findIndex((child) => child.id === currentId);
if (index !== -1) {
// 在目标节点后面插入新数据
parent.children.splice(index + 1, 0, newData);
return true;
// 插入节点
export function insertNode(tree: Tree[], currentNode: Tree, nodeToInsert: Tree): boolean {
// 查找并插入节点的辅助函数
function findAndInsert(node: Tree, parentNode: Tree | null = null): boolean {
if (node.id === currentNode.id) {
// 如果 parentNode 为 null说明当前节点是根节点
if (parentNode === null) {
const index = tree.findIndex((n) => n.id === currentNode.id);
// 根据 currentNode 的 groupId 是否存在,决定插入位置
if (currentNode.groupId !== undefined) {
// 插入到当前节点的后面
tree.splice(index + 1, 0, nodeToInsert);
} else {
// 插入到同级节点的末尾
tree.push(nodeToInsert);
}
} else {
// 当前节点不是根节点
const index = parentNode.children?.findIndex((n) => n.id === currentNode.id) ?? -1;
// 根据 currentNode 的 groupId 是否存在,决定插入位置
if (currentNode.groupId !== undefined) {
// 插入到当前节点的后面
parentNode.children?.splice(index + 1, 0, nodeToInsert);
} else {
// 插入到同级节点的末尾
parentNode.children?.push(nodeToInsert);
}
}
return true; // 插入成功
}
if (node.children) {
node.children.forEach((child) => {
stack.push({ node: child, parent: node });
});
}
// 递归搜索子节点
node.children?.forEach((child) => {
if (findAndInsert(child, node)) {
return true; // 如果在子树中插入成功,提前结束
}
});
return false; // 未找到匹配的节点或插入失败
}
return false;
// 从根节点开始搜索并尝试插入
return tree.some((node) => findAndInsert(node));
}
export function insertTreeByGroupId(tree: TreeData[], currentId: number, newData: TreeData) {
const stack: Array<{ node: TreeData; parent: TreeData | null }> = tree.map((node) => ({ node, parent: null }));
// 根据 groupId 查找第一个匹配的节点
export function findFirstByGroupId(tree: Tree[], groupId: number): Tree | null {
const foundNode = tree.find((node) => node.groupId === groupId); // Use array.find() method to find the first node with matching groupId
if (foundNode) {
return foundNode;
}
while (stack.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { node, parent } = stack.pop()!;
if (parent && parent.children) {
const index = parent.children.findIndex((child) => child.groupId === currentId);
if (index !== -1) {
// 在目标节点后面插入新数据
parent.children.splice(index + 1, 0, newData);
return true;
tree.forEach((node) => {
if (node.children) {
const found = findFirstByGroupId(node.children, groupId);
if (found) {
return found;
}
}
});
if (node.children) {
node.children.forEach((child) => {
stack.push({ node: child, parent: node });
});
}
}
return false;
}
export function findFirstByGroupId(tree: TreeData[], groupId: number): TreeData | null {
const queue: TreeData[] = [...tree];
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = queue.shift()!; // 取出队列的第一个元素
if (node.groupId === groupId) {
return node; // 如果匹配,返回当前节点
}
if (node.children) {
queue.push(...node.children);
}
}
return null;
}
// 根据id删除node
export function deleteNodeById(tree: Tree[], nodeId: number): void {
// 查找并删除节点的辅助函数
function findAndDelete(node: Tree, parentNode: Tree | null = null): boolean {
// 如果当前节点就是要删除的节点
if (node.id === nodeId) {
if (parentNode) {
// 如果存在父节点,从父节点的 children 中删除当前节点
parentNode.children = parentNode.children?.filter((child) => child.id !== nodeId) ?? [];
} else {
// 如果不存在父节点,说明当前节点是根节点,直接从树中删除
const index = tree.findIndex((n) => n.id === nodeId);
if (index !== -1) {
tree.splice(index, 1);
}
}
return true; // 删除成功
}
// 递归搜索子节点
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
if (findAndDelete(node.children[i], node)) {
return true; // 如果在子树中删除成功,提前结束
}
}
}
return false; // 未找到匹配的节点或删除失败
}
// 从根节点开始搜索并尝试删除
tree.slice().forEach((rootNode) => {
findAndDelete(rootNode);
});
}
// 统计整棵树的节点数
export function countNodes(tree: Tree[]): number {
// 统计节点数的辅助函数
function count(node: Tree): number {
// 计算当前节点的子节点数
let childCount = 0;
if (node.children) {
childCount = node.children.reduce((acc, child) => acc + count(child), 0);
}
// 返回当前节点加上子节点的总数
return 1 + childCount; // 当前节点自身也算一个节点
}
// 对树的每个根节点调用 count 函数,并累加结果
return tree.reduce((acc, rootNode) => acc + count(rootNode), 0);
}
// 根据 groupId 统计元素数量
export function countNodesByGroupId(tree: Tree[], targetGroupId: number): number {
// 统计符合条件的节点数的辅助函数
function count(node: Tree): number {
// 检查当前节点的 groupId 是否符合条件
const matches = node.groupId === targetGroupId ? 1 : 0;
// 计算当前节点的子节点中符合条件的节点数
let childMatches = 0;
if (node.children) {
childMatches = node.children.reduce((acc, child) => acc + count(child), 0);
}
// 返回当前节点和子节点中符合条件的节点总数
return matches + childMatches;
}
// 对树的每个根节点调用 count 函数,并累加结果
return tree.reduce((acc, rootNode) => acc + count(rootNode), 0);
}
// 根据 groupId 删除所有符合条件的节点
export function deleteNodesByGroupId(tree: Tree[], targetGroupId: number): void {
// 递归删除符合条件的节点的辅助函数
function deleteMatchingNodes(nodes: Tree[]): Tree[] {
return nodes
.map((node) => {
// 先处理子节点
if (node.children) {
node.children = deleteMatchingNodes(node.children);
}
return node;
})
.filter((node) => node.groupId !== targetGroupId); // 过滤掉符合条件的节点
}
// 对树的每个根节点调用 deleteMatchingNodes 函数,并更新树
tree.splice(0, tree.length, ...deleteMatchingNodes(tree));
}

View File

@ -81,7 +81,7 @@
:placeholder="t('apiTestDebug.paramNamePlaceholder')"
class="param-input"
:max-length="255"
@input="() => addTableLine(rowIndex)"
@input="() => addTableLine(rowIndex, columnConfig.addLineDisabled)"
/>
</a-popover>
</template>
@ -105,7 +105,7 @@
v-model:model-value="record.paramType"
:options="columnConfig.typeOptions || []"
class="param-input w-full"
@change="(val) => handleTypeChange(val, record, rowIndex)"
@change="(val) => handleTypeChange(val, record, rowIndex, columnConfig.addLineDisabled)"
/>
</template>
<template #expressionType="{ record, columnConfig, rowIndex }">
@ -251,7 +251,7 @@
/>
</template>
<template #operation="{ record, rowIndex, columnConfig }">
<div class="flex flex-row items-center">
<div class="flex flex-row items-center" :class="{ 'justify-end': columnConfig.align === 'right' }">
<a-switch
v-if="columnConfig.hasDisable"
v-model:model-value="record.disable"
@ -280,12 +280,17 @@
</div>
</template>
</a-trigger>
<icon-minus-circle
v-if="paramsLength > 1 && rowIndex !== paramsLength - 1"
v-if="props.isTreeTable && record.id !== 0"
class="ml-[8px] cursor-pointer text-[var(--color-text-4)]"
size="20"
@click="deleteParam(rowIndex)"
@click="deleteParam(record, rowIndex)"
/>
<icon-minus-circle
v-else-if="paramsLength > 1 && rowIndex !== paramsLength - 1"
class="ml-[8px] cursor-pointer text-[var(--color-text-4)]"
size="20"
@click="deleteParam(record, rowIndex)"
/>
</div>
</template>
@ -300,22 +305,25 @@
</a-select>
</template>
<template #matchValue="{ record, rowIndex, columnConfig }">
<a-tooltip
v-if="columnConfig.hasRequired"
:content="t(record.required ? 'apiTestDebug.paramRequired' : 'apiTestDebug.paramNotRequired')"
>
<MsButton
type="icon"
:class="[
record.required ? '!text-[rgb(var(--danger-5))]' : '!text-[var(--color-text-brand)]',
'!mr-[4px] !p-[4px]',
]"
@click="toggleRequired(record, rowIndex)"
<div class="flex flex-row items-center justify-between">
<a-tooltip
v-if="columnConfig.hasRequired"
:content="t(record.required ? 'apiTestDebug.paramRequired' : 'apiTestDebug.paramNotRequired')"
>
<div>*</div>
</MsButton>
</a-tooltip>
<a-input v-model="record.matchValue" class="param-input" />
<MsButton
type="icon"
:class="[
record.required ? '!text-[rgb(var(--danger-5))]' : '!text-[var(--color-text-brand)]',
'!mr-[4px] !p-[4px]',
]"
@click="toggleRequired(record, rowIndex)"
>
<div>*</div>
</MsButton>
</a-tooltip>
<a-input v-model="record.matchValue" class="param-input" />
<slot name="matchValueDelete" v-bind="{ record, rowIndex, columnConfig }"></slot>
</div>
</template>
<template #project="{ record, columnConfig, rowIndex }">
<a-select
@ -418,6 +426,7 @@
hasDisable?: boolean; // operation enable
moreAction?: ActionsItem[]; // operation
format?: RequestBodyFormat; // operation
addLineDisabled?: boolean; //
};
const props = withDefaults(
@ -440,6 +449,7 @@
showSelectorAll?: boolean; //
isSimpleSetting?: boolean; // Column
response?: string; //
isTreeTable?: boolean; //
spanMethod?: (data: {
record: TableData;
column: TableColumnData | TableOperationColumn;
@ -474,6 +484,7 @@
(e: 'change', data: any[], isInit?: boolean): void; //
(e: 'moreActionSelect', event: ActionsItem, record: Record<string, any>): void;
(e: 'projectChange', projectId: string): void;
(e: 'treeDelete', record: Record<string, any>): void;
}>();
const appStore = useAppStore();
@ -537,7 +548,11 @@
const paramsLength = computed(() => propsRes.value.data.length);
function deleteParam(rowIndex: number) {
function deleteParam(record: Record<string, any>, rowIndex: number) {
if (props.isTreeTable) {
emit('treeDelete', record);
return;
}
propsRes.value.data.splice(rowIndex, 1);
emit('change', propsRes.value.data);
}
@ -611,7 +626,10 @@
* @param key 当前列的 key
* @param isForce 是否强制添加
*/
function addTableLine(rowIndex: number) {
function addTableLine(rowIndex: number, addLineDisabled?: boolean) {
if (addLineDisabled) {
return;
}
if (rowIndex === propsRes.value.data.length - 1) {
//
const id = new Date().getTime().toString();
@ -741,9 +759,10 @@
function handleTypeChange(
val: string | number | boolean | Record<string, any> | (string | number | boolean | Record<string, any>)[],
record: Record<string, any>,
rowIndex: number
rowIndex: number,
addLineDisabled?: boolean
) {
addTableLine(rowIndex);
addTableLine(rowIndex, addLineDisabled);
// Content-Type
if (record.contentType) {
if (val === 'file') {
@ -754,6 +773,7 @@
record.contentType = RequestContentTypeEnum.TEXT;
}
}
emit('change', propsRes.value.data);
}
/**