feat(全局): 接口调试-响应内容&部分组件调整

This commit is contained in:
baiqi 2024-01-22 19:41:53 +08:00 committed by Craftsman
parent 7e5967a688
commit 31562ac774
33 changed files with 861 additions and 213 deletions

View File

@ -1,4 +1,5 @@
/** 主题变量覆盖 **/ /** 主题变量覆盖 **/
@border-radius-mini: 2px;
@border-radius-small: 4px; @border-radius-small: 4px;
@border-radius-medium: 6px; @border-radius-medium: 6px;
@border-radius-large: 12px; @border-radius-large: 12px;

View File

@ -1,15 +1,23 @@
<template> <template>
<div ref="fullRef" class="h-full rounded-[4px] bg-[var(--color-fill-1)] p-[12px]"> <div ref="fullRef" class="h-full rounded-[4px] bg-[var(--color-fill-1)] p-[12px]">
<div v-if="showTitleLine" class="mb-[12px] flex items-center justify-between pr-[12px]"> <div v-if="showTitleLine" class="mb-[12px] flex items-center justify-between">
<div> <div class="flex flex-wrap gap-[4px]">
<a-select <a-select
v-if="showLanguageChange" v-if="showLanguageChange"
v-model:model-value="currentLanguage" v-model:model-value="currentLanguage"
:options="languageOptions" :options="languageOptions"
class="mr-[4px] w-[100px]" class="w-[100px]"
size="small" size="small"
@change="(val) => handleLanguageChange(val as Language)" @change="(val) => handleLanguageChange(val as Language)"
/> />
<a-select
v-if="showCharsetChange"
v-model:model-value="currentCharset"
:options="charsetOptions"
class="w-[100px]"
size="small"
@change="(val) => handleCharsetChange(val as string)"
/>
<a-select <a-select
v-if="showThemeChange" v-if="showThemeChange"
v-model:model-value="currentTheme" v-model:model-value="currentTheme"
@ -34,9 +42,9 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 这里的 32px 是顶部标题的 32px --> <!-- 这里的 40px 是顶部标题的 40px -->
<div :class="`flex ${showTitleLine ? 'h-[calc(100%-32px)]' : 'h-full'} w-full flex-row`"> <div :class="`flex ${showTitleLine ? 'h-[calc(100%-40px)]' : 'h-full'} w-full flex-row`">
<div ref="codeEditBox" :class="['ms-code-editor', isFullscreen ? 'ms-code-editor-full-screen' : '']"></div> <div ref="codeContainerRef" :class="['ms-code-editor', isFullscreen ? 'ms-code-editor-full-screen' : '']"></div>
<slot name="rightBox"> </slot> <slot name="rightBox"> </slot>
</div> </div>
</div> </div>
@ -46,7 +54,9 @@
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useFullscreen } from '@vueuse/core'; import { useFullscreen } from '@vueuse/core';
import { codeCharset } from '@/config/apiTest';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { decodeStringToCharset } from '@/utils';
import './userWorker'; import './userWorker';
import MsCodeEditorTheme from './themes'; import MsCodeEditorTheme from './themes';
@ -59,9 +69,34 @@
emits: ['update:modelValue', 'change'], emits: ['update:modelValue', 'change'],
setup(props, { emit }) { setup(props, { emit }) {
const { t } = useI18n(); const { t } = useI18n();
//
let editor: monaco.editor.IStandaloneCodeEditor; let editor: monaco.editor.IStandaloneCodeEditor;
const codeEditBox = ref(); const codeContainerRef = ref();
const init = () => {
//
Object.keys(MsCodeEditorTheme).forEach((e) => {
monaco.editor.defineTheme(e, MsCodeEditorTheme[e as CustomTheme]);
});
editor = monaco.editor.create(codeContainerRef.value, {
value: props.modelValue,
automaticLayout: true,
padding: {
top: 12,
bottom: 12,
},
...props,
});
//
editor.onDidBlurEditorText(() => {
const value = editor.getValue(); //
emit('update:modelValue', value);
emit('change', value);
});
};
// ref // ref
const fullRef = ref<HTMLElement | null>(); const fullRef = ref<HTMLElement | null>();
// //
@ -77,6 +112,12 @@
value: item, value: item,
})) }))
); );
function handleThemeChange(val: Theme) {
editor.updateOptions({
theme: val,
});
}
// //
const currentLanguage = ref<Language>(props.language); const currentLanguage = ref<Language>(props.language);
// //
@ -98,10 +139,29 @@
}; };
}) })
.filter(Boolean) as { label: string; value: Language }[]; .filter(Boolean) as { label: string; value: Language }[];
function handleLanguageChange(val: Language) {
monaco.editor.setModelLanguage(editor.getModel()!, val);
}
//
const currentCharset = ref('UTF-8');
//
const charsetOptions = codeCharset.map((e) => ({
label: e,
value: e,
}));
function handleCharsetChange(val: string) {
editor.setValue(decodeStringToCharset(props.modelValue, val));
}
// //
const showTitleLine = computed( const showTitleLine = computed(
() => props.title || props.showThemeChange || props.showLanguageChange || props.showFullScreen () =>
props.title ||
props.showThemeChange ||
props.showLanguageChange ||
props.showCharsetChange ||
props.showFullScreen
); );
watch( watch(
@ -111,33 +171,6 @@
} }
); );
function handleThemeChange(val: Theme) {
monaco.editor.setTheme(val);
}
function handleLanguageChange(val: Language) {
monaco.editor.setModelLanguage(editor.getModel()!, val);
}
const init = () => {
//
Object.keys(MsCodeEditorTheme).forEach((e) => {
monaco.editor.defineTheme(e, MsCodeEditorTheme[e as CustomTheme]);
});
editor = monaco.editor.create(codeEditBox.value, {
value: props.modelValue,
automaticLayout: true,
...props,
});
//
editor.onDidBlurEditorText(() => {
const value = editor.getValue(); //
emit('update:modelValue', value);
emit('change', value);
});
};
const setEditBoxBg = () => { const setEditBoxBg = () => {
const codeBgEl = document.querySelector('.monaco-editor-background'); const codeBgEl = document.querySelector('.monaco-editor-background');
if (codeBgEl) { if (codeBgEl) {
@ -146,7 +179,7 @@
// //
const { backgroundColor } = computedStyle; const { backgroundColor } = computedStyle;
codeEditBox.value.style.backgroundColor = backgroundColor; codeContainerRef.value.style.backgroundColor = backgroundColor;
} }
}; };
@ -222,18 +255,21 @@
}); });
return { return {
codeEditBox, codeContainerRef,
fullRef, fullRef,
isFullscreen, isFullscreen,
currentTheme, currentTheme,
themeOptions, themeOptions,
currentLanguage, currentLanguage,
languageOptions, languageOptions,
currentCharset,
charsetOptions,
showTitleLine, showTitleLine,
toggle, toggle,
t, t,
handleThemeChange, handleThemeChange,
handleLanguageChange, handleLanguageChange,
handleCharsetChange,
insertContent, insertContent,
undo, undo,
redo, redo,
@ -244,11 +280,11 @@
<style lang="less" scoped> <style lang="less" scoped>
.ms-code-editor { .ms-code-editor {
@apply z-10; @apply z-10 overflow-hidden;
padding: 16px 0;
width: v-bind(width); width: v-bind(width);
height: v-bind(height); height: v-bind(height);
border-radius: var(--border-radius-small);
&[data-mode-id='plaintext'] { &[data-mode-id='plaintext'] {
:deep(.mtk1) { :deep(.mtk1) {
color: rgb(var(--primary-5)); color: rgb(var(--primary-5));

View File

@ -94,10 +94,17 @@ export const editorProps = {
type: Array as PropType<Array<Language>>, type: Array as PropType<Array<Language>>,
default: undefined, default: undefined,
}, },
// 是否代码语言切换
showLanguageChange: { showLanguageChange: {
type: Boolean as PropType<boolean>, type: Boolean as PropType<boolean>,
default: false, default: false,
}, },
// 是否显示字符集切换
showCharsetChange: {
type: Boolean as PropType<boolean>,
default: true,
},
// 是否显示主题切换
showThemeChange: { showThemeChange: {
type: Boolean as PropType<boolean>, type: Boolean as PropType<boolean>,
default: true, default: true,

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="tab-container"> <div class="ms-editable-tab-container">
<a-tooltip v-if="!isNotOverflow" :content="t('ms.editableTab.arrivedLeft')" :disabled="!arrivedState.left"> <a-tooltip v-if="!isNotOverflow" :content="t('ms.editableTab.arrivedLeft')" :disabled="!arrivedState.left">
<MsButton <MsButton
type="icon" type="icon"
@ -11,21 +11,22 @@
<MsIcon type="icon-icon_left_outlined" /> <MsIcon type="icon-icon_left_outlined" />
</MsButton> </MsButton>
</a-tooltip> </a-tooltip>
<div ref="tabNav" class="tab-nav"> <div ref="tabNav" class="ms-editable-tab-nav">
<div <div
v-for="tab in props.tabs" v-for="tab in props.tabs"
:key="tab.id" :key="tab.id"
class="tab" class="ms-editable-tab"
:class="{ active: innerActiveTab === tab.id }" :class="{ active: innerActiveTab === tab.id }"
@click="handleTabClick(tab)" @click="handleTabClick(tab)"
> >
<div class="flex items-center"> <div class="flex items-center">
<slot name="label" :tab="tab">{{ tab.label }}</slot> <slot name="label" :tab="tab">{{ tab.label }}</slot>
<div v-if="tab.unSaved" class="ml-[8px] h-[8px] w-[8px] rounded-full bg-[rgb(var(--primary-5))]"></div>
<MsButton <MsButton
v-if="tab.closable" v-if="props.atLeastOne ? props.tabs.length > 1 && tab.closable : tab.closable"
type="icon" type="icon"
status="secondary" status="secondary"
class="tab-close-button" class="ms-editable-tab-close-button"
@click="() => close(tab)" @click="() => close(tab)"
> >
<MsIcon type="icon-icon_close_outlined" size="12" /> <MsIcon type="icon-icon_close_outlined" size="12" />
@ -37,7 +38,7 @@
<MsButton <MsButton
type="icon" type="icon"
status="secondary" status="secondary"
class="tab-button !mr-[8px]" class="ms-editable-tab-button !mr-[8px]"
:disabled="arrivedState.right" :disabled="arrivedState.right"
@click="scrollTabs('right')" @click="scrollTabs('right')"
> >
@ -51,15 +52,19 @@
<MsButton <MsButton
type="icon" type="icon"
status="secondary" status="secondary"
class="tab-button !mr-[4px]" class="ms-editable-tab-button !mr-[4px]"
:disabled="!!props.limit && props.tabs.length >= props.limit" :disabled="!!props.limit && props.tabs.length >= props.limit"
@click="addTab" @click="addTab"
> >
<MsIcon type="icon-icon_add_outlined" /> <MsIcon type="icon-icon_add_outlined" />
</MsButton> </MsButton>
</a-tooltip> </a-tooltip>
<MsMoreAction v-if="props.moreActionList" :list="props.moreActionList"> <MsMoreAction
<MsButton type="icon" status="secondary" class="tab-button"> v-if="props.moreActionList"
:list="props.moreActionList"
@select="(val) => emit('moreActionSelect', val)"
>
<MsButton type="icon" status="secondary" class="ms-editable-tab-button">
<MsIcon type="icon-icon_more_outlined" /> <MsIcon type="icon-icon_more_outlined" />
</MsButton> </MsButton>
</MsMoreAction> </MsMoreAction>
@ -69,6 +74,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue'; import { nextTick, onMounted, ref, watch } from 'vue';
import { useScroll, useVModel } from '@vueuse/core'; import { useScroll, useVModel } from '@vueuse/core';
import { useDraggable } from 'vue-draggable-plus';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue'; import MsIcon from '@/components/pure/ms-icon-font/index.vue';
@ -84,17 +90,21 @@
activeTab: string | number; activeTab: string | number;
moreActionList?: ActionsItem[]; moreActionList?: ActionsItem[];
limit?: number; // tab limit?: number; // tab
atLeastOne?: boolean; // tab
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:tabs', activeTab: string | number): void;
(e: 'update:activeTab', activeTab: string | number): void; (e: 'update:activeTab', activeTab: string | number): void;
(e: 'add'): void; (e: 'add'): void;
(e: 'close', item: TabItem): void; (e: 'close', item: TabItem): void;
(e: 'change', item: TabItem): void; (e: 'change', item: TabItem): void;
(e: 'moreActionSelect', item: ActionsItem): void;
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const innerActiveTab = useVModel(props, 'activeTab', emit); const innerActiveTab = useVModel(props, 'activeTab', emit);
const innerTabs = useVModel(props, 'tabs', emit);
const tabNav = ref<HTMLElement | null>(null); const tabNav = ref<HTMLElement | null>(null);
const { arrivedState } = useScroll(tabNav); const { arrivedState } = useScroll(tabNav);
const isNotOverflow = computed(() => arrivedState.left && arrivedState.right); // const isNotOverflow = computed(() => arrivedState.left && arrivedState.right); //
@ -139,6 +149,9 @@
); );
watch(props.tabs, () => { watch(props.tabs, () => {
useDraggable('.ms-editable-tab-nav', innerTabs, {
ghostClass: 'ms-editable-tab-ghost',
});
nextTick(() => { nextTick(() => {
scrollToActiveTab(); scrollToActiveTab();
}); });
@ -169,17 +182,17 @@
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.tab-container { .ms-editable-tab-container {
@apply flex items-center; @apply flex items-center;
height: 32px; height: 32px;
.tab-nav { .ms-editable-tab-nav {
@apply relative flex overflow-x-auto whitespace-nowrap; @apply relative flex overflow-x-auto whitespace-nowrap;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0; /* 宽度为0隐藏垂直滚动条 */ width: 0; /* 宽度为0隐藏垂直滚动条 */
height: 0; /* 高度为0隐藏水平滚动条 */ height: 0; /* 高度为0隐藏水平滚动条 */
} }
.tab { .ms-editable-tab {
@apply flex cursor-pointer items-center; @apply flex cursor-pointer items-center;
margin-right: 4px; margin-right: 4px;
@ -191,18 +204,18 @@
&:hover { &:hover {
color: rgb(var(--primary-5)); color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1)); background-color: rgb(var(--primary-1));
.tab-close-button { .ms-editable-tab-close-button {
@apply visible; @apply visible;
} }
} }
.tab-close-button { .ms-editable-tab-close-button {
@apply invisible !rounded-full; @apply invisible !rounded-full;
margin-left: 4px !important; margin-left: 4px !important;
} }
} }
} }
.tab-button { .ms-editable-tab-button {
padding: 8px; padding: 8px;
&:not([disabled='true']) { &:not([disabled='true']) {
padding: 8px; padding: 8px;
@ -213,4 +226,7 @@
} }
} }
} }
.ms-editable-tab-ghost {
opacity: 0.5;
}
</style> </style>

View File

@ -2,5 +2,6 @@ export interface TabItem {
id: string | number; id: string | number;
label: string; label: string;
closable?: boolean; closable?: boolean;
unSaved?: boolean; // 未保存
[key: string]: any; [key: string]: any;
} }

View File

@ -0,0 +1,3 @@
export default {
'ms.jsonpathPicker.xmlNotValid': '非法的XML文本',
};

View File

@ -0,0 +1,3 @@
export default {
'ms.jsonpathPicker.xmlNotValid': '非法的XML文本',
};

View File

@ -1,24 +1,31 @@
<template> <template>
<div v-if="parsedXml" class="container"> <div>
<div v-for="(node, index) in flattenedXml" :key="index"> <div v-if="parsedXml" class="container">
<span style="white-space: pre" @click="copyXPath(node.xpath)" v-html="node.content"></span> <div v-for="(node, index) in flattenedXml" :key="index">
<span style="white-space: pre" @click="copyXPath(node.xpath)" v-html="node.content"></span>
</div>
</div> </div>
<div v-if="!isValidXml">{{ t('ms.jsonpathPicker.xmlNotValid') }}</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { XpathNode } from './types'; import { XpathNode } from './types';
import * as XmlBeautify from 'xml-beautify'; import * as XmlBeautify from 'xml-beautify';
const props = defineProps<{ const props = defineProps<{
xmlString: string; xmlString: string;
}>(); }>();
const emit = defineEmits(['pick']); const emit = defineEmits(['pick']);
const { t } = useI18n();
const parsedXml = ref<Document | null>(null); const parsedXml = ref<Document | null>(null);
const flattenedXml = ref<XpathNode[]>([]); const flattenedXml = ref<XpathNode[]>([]);
const tempXmls = ref<XpathNode[]>([]); const tempXmls = ref<XpathNode[]>([]);
const isValidXml = ref(true); // xml
/** /**
* 获取同名兄弟节点 * 获取同名兄弟节点
@ -68,6 +75,7 @@
emit('pick', xpath); emit('pick', xpath);
} }
} }
/** /**
* 解析xml * 解析xml
*/ */
@ -75,6 +83,12 @@
try { try {
const parser = new DOMParser(); const parser = new DOMParser();
const xmlDoc = parser.parseFromString(props.xmlString, 'application/xml'); const xmlDoc = parser.parseFromString(props.xmlString, 'application/xml');
// parsererror XML
const errors = xmlDoc.getElementsByTagName('parsererror');
if (errors.length > 0) {
isValidXml.value = false;
return;
}
parsedXml.value = xmlDoc; parsedXml.value = xmlDoc;
// XML icon // XML icon
flattenedXml.value = new XmlBeautify({ parser: DOMParser }) flattenedXml.value = new XmlBeautify({ parser: DOMParser })
@ -88,7 +102,7 @@
flattenXml(xmlDoc.documentElement, ''); flattenXml(xmlDoc.documentElement, '');
// XML xpath xpath // XML xpath xpath
flattenedXml.value = flattenedXml.value.map((e) => { flattenedXml.value = flattenedXml.value.map((e) => {
const targetNodeIndex = tempXmls.value.findIndex((t) => e.content.includes(`&lt;${t.content}`)); const targetNodeIndex = tempXmls.value.findIndex((txt) => e.content.includes(`&lt;${txt.content}`));
if (targetNodeIndex >= 0) { if (targetNodeIndex >= 0) {
const { xpath } = tempXmls.value[targetNodeIndex]; const { xpath } = tempXmls.value[targetNodeIndex];
tempXmls.value.splice(targetNodeIndex, 1); // tempXmls tempXmls.value.splice(targetNodeIndex, 1); // tempXmls

View File

@ -90,6 +90,14 @@ export default {
priority: 'Priority', priority: 'Priority',
tag: 'Tag', tag: 'Tag',
}, },
tag: {
case: 'Case',
module: 'Module',
precondition: 'Precondition',
desc: 'Step desc',
expect: 'Expected result',
remark: 'Remark',
},
hotboxMenu: { hotboxMenu: {
expand: 'Expand/Collapse', expand: 'Expand/Collapse',
insetParent: 'Insert one level up', insetParent: 'Insert one level up',

View File

@ -84,6 +84,14 @@ export default {
priority: '优先级', priority: '优先级',
tag: '标签', tag: '标签',
}, },
tag: {
case: '用例',
module: '模块',
precondition: '前置条件',
desc: '步骤描述',
expect: '预期结果',
remark: '备注',
},
hotboxMenu: { hotboxMenu: {
expand: '展开/收起', expand: '展开/收起',
insetParent: '插入上一级', insetParent: '插入上一级',

View File

@ -67,12 +67,14 @@
import MsIcon from '@/components/pure/ms-icon-font/index.vue'; import MsIcon from '@/components/pure/ms-icon-font/index.vue';
export type Direction = 'horizontal' | 'vertical';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
size?: number | string; // /expandDirection right size size size?: number | string; // /expandDirection right size size
min?: number | string; min?: number | string;
max?: number | string; max?: number | string;
direction?: 'horizontal' | 'vertical'; direction?: Direction;
expandDirection?: 'left' | 'right' | 'top'; // TODO: bottom left top expandDirection?: 'left' | 'right' | 'top'; // TODO: bottom left top
disabled?: boolean; // disabled?: boolean; //
firstContainerClass?: string; // first firstContainerClass?: string; // first
@ -214,7 +216,9 @@
@apply h-full bg-white; @apply h-full bg-white;
} }
.vertical-expand-line { .vertical-expand-line {
@apply relative z-10 flex items-center justify-center bg-transparent; @apply relative flex items-center justify-center bg-transparent;
z-index: 1;
&::before { &::before {
@apply absolute w-full bg-transparent; @apply absolute w-full bg-transparent;

View File

@ -8,5 +8,5 @@ export const conditionTypeNameMap = {
waitTime: 'apiTestDebug.waitTime', waitTime: 'apiTestDebug.waitTime',
extract: 'apiTestDebug.extractParameter', extract: 'apiTestDebug.extractParameter',
}; };
// 代码字符集
export default {}; export const codeCharset = ['UTF-8', 'UTF-16', 'GBK', 'GB2312', 'ISO-8859-1', 'Shift_JIS', 'ASCII', 'BIG5', 'KOI8-R'];

View File

@ -95,3 +95,12 @@ export const reviewDefaultDetail: ReviewItem = {
reReviewedCount: 0, reReviewedCount: 0,
followFlag: false, followFlag: false,
}; };
// 脑图-标签
export const minderTagMap = {
CASE: 'minder.tag.case',
MODULE: 'minder.tag.module',
PREREQUISITE: 'minder.tag.precondition',
TEXT_DESCRIPTION: 'minder.tag.desc',
EXPECTED_RESULT: 'minder.tag.expect',
DESCRIPTION: 'minder.tag.remark',
};

View File

@ -43,3 +43,12 @@ export enum RequestContentTypeEnum {
ATOM_XML = 'application/atom+xml', ATOM_XML = 'application/atom+xml',
ECMASCRIPT = 'application/ecmascript', ECMASCRIPT = 'application/ecmascript',
} }
// 接口响应组成部分
export enum ResponseComposition {
BODY = 'BODY',
HEADER = 'HEADER',
REAL_REQUEST = 'REAL_REQUEST', // 实际请求
CONSOLE = 'CONSOLE',
EXTRACT = 'EXTRACT',
ASSERTION = 'ASSERTION',
}

View File

@ -74,6 +74,7 @@ export default {
'common.collapseAll': 'Collapse all', 'common.collapseAll': 'Collapse all',
'common.expandAll': 'Expand all', 'common.expandAll': 'Expand all',
'common.copy': 'Copy', 'common.copy': 'Copy',
'common.copySuccess': 'Copy successfully',
'common.fork': 'Fork', 'common.fork': 'Fork',
'common.forked': 'Forked', 'common.forked': 'Forked',
'common.more': 'More', 'common.more': 'More',
@ -95,4 +96,6 @@ export default {
'common.revoke': 'Revoke', 'common.revoke': 'Revoke',
'common.clear': 'Clear', 'common.clear': 'Clear',
'common.tag': 'Tag', 'common.tag': 'Tag',
'common.success': 'Success',
'common.fail': 'Failed',
}; };

View File

@ -75,6 +75,7 @@ export default {
'common.collapseAll': '收起全部', 'common.collapseAll': '收起全部',
'common.expandAll': '展开全部', 'common.expandAll': '展开全部',
'common.copy': '复制', 'common.copy': '复制',
'common.copySuccess': '复制成功',
'common.fork': '关注', 'common.fork': '关注',
'common.forked': '已关注', 'common.forked': '已关注',
'common.more': '更多', 'common.more': '更多',
@ -98,4 +99,6 @@ export default {
'common.revoke': '撤销', 'common.revoke': '撤销',
'common.clear': '清空', 'common.clear': '清空',
'common.tag': '标签', 'common.tag': '标签',
'common.success': '成功',
'common.fail': '失败',
}; };

View File

@ -11,3 +11,15 @@ export interface ExpressionConfig {
specifyMatchNum?: number; // 指定匹配下标 specifyMatchNum?: number; // 指定匹配下标
xmlMatchContentType?: 'xml' | 'html'; // 响应内容格式 xmlMatchContentType?: 'xml' | 'html'; // 响应内容格式
} }
// 响应时间信息
export interface ResponseTiming {
ready: number;
socketInit: number;
dnsQuery: number;
tcpHandshake: number;
sslHandshake: number;
waitingTTFB: number;
downloadContent: number;
deal: number;
total: number;
}

View File

@ -1,5 +1,7 @@
import JSEncrypt from 'jsencrypt'; import JSEncrypt from 'jsencrypt';
import { codeCharset } from '@/config/apiTest';
import { isObject } from './is'; import { isObject } from './is';
type TargetContext = '_self' | '_parent' | '_blank' | '_top'; type TargetContext = '_self' | '_parent' | '_blank' | '_top';
@ -324,7 +326,7 @@ export const getHashParameters = (): Record<string, string> => {
}; };
/** /**
* id * id
* @returns * @returns
*/ */
export const getGenerateId = () => { export const getGenerateId = () => {
@ -357,3 +359,14 @@ export const downloadByteFile = (byte: BlobPart, fileName: string) => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(link); document.body.removeChild(link);
}; };
/**
*
* @param str
* @param charset
*/
export function decodeStringToCharset(str: string, charset = 'UTF-8') {
const encoder = new TextEncoder();
const decoder = new TextDecoder(charset);
return decoder.decode(encoder.encode(str));
}

View File

@ -655,6 +655,9 @@ org.apache.http.client.method . . . '' at line number 2
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
:deep(.arco-table-th) {
background-color: var(--color-text-n9);
}
.condition-content { .condition-content {
@apply flex-1 overflow-y-auto; @apply flex-1 overflow-y-auto;
.ms-scroll-bar(); .ms-scroll-bar();

View File

@ -257,7 +257,7 @@
} }
.code-container { .code-container {
padding: 12px; padding: 12px;
height: 400px; max-height: 400px;
border-radius: var(--border-radius-small); border-radius: var(--border-radius-small);
background-color: var(--color-text-n9); background-color: var(--color-text-n9);
} }

View File

@ -567,7 +567,7 @@
background-color: var(--color-text-n9); background-color: var(--color-text-n9);
} }
:deep(.arco-table-cell-align-left) { :deep(.arco-table-cell-align-left) {
padding: 16px 12px; padding: 16px 2px;
} }
:deep(.arco-table-td) { :deep(.arco-table-td) {
.arco-table-cell { .arco-table-cell {

View File

@ -0,0 +1,97 @@
<template>
<div class="flex w-full gap-[8px] rounded-[var(--border-radius-small)] bg-[var(--color-text-n9)] p-[12px]">
<div class="text-item-wrapper">
<div class="light-item">{{ t('apiTestDebug.responseStage') }}</div>
<div class="light-item">{{ t('apiTestDebug.ready') }}</div>
<div class="normal-item">{{ t('apiTestDebug.socketInit') }}</div>
<div class="normal-item">{{ t('apiTestDebug.dnsQuery') }}</div>
<div class="normal-item">{{ t('apiTestDebug.tcpHandshake') }}</div>
<div class="normal-item">{{ t('apiTestDebug.sslHandshake') }}</div>
<div class="normal-item">{{ t('apiTestDebug.waitingTTFB') }}</div>
<div class="normal-item">{{ t('apiTestDebug.downloadContent') }}</div>
<div class="light-item">{{ t('apiTestDebug.deal') }}</div>
<div class="total-item">{{ t('apiTestDebug.total') }}</div>
</div>
<a-divider direction="vertical" margin="0" />
<div class="flex flex-1 flex-col">
<div class="h-full"></div>
<div v-for="line of timingLines" :key="line.key" class="flex h-full items-center bg-transparent">
<div
class="h-[12px] rounded-[var(--border-radius-mini)] bg-[rgb(var(--success-7))]"
:style="{
width: line.width,
marginLeft: line.left,
}"
></div>
</div>
<div class="h-full"></div>
</div>
<a-divider direction="vertical" margin="0" />
<div class="text-item-wrapper--right">
<div class="light-item">{{ t('apiTestDebug.time') }}</div>
<div class="light-item">{{ props.responseTiming.ready }} ms</div>
<div class="normal-item">{{ props.responseTiming.socketInit }} ms</div>
<div class="normal-item">{{ props.responseTiming.dnsQuery }} ms</div>
<div class="normal-item">{{ props.responseTiming.tcpHandshake }} ms</div>
<div class="normal-item">{{ props.responseTiming.sslHandshake }} ms</div>
<div class="normal-item">{{ props.responseTiming.waitingTTFB }} ms</div>
<div class="normal-item">{{ props.responseTiming.downloadContent }} ms</div>
<div class="light-item">{{ props.responseTiming.deal }} ms</div>
<div class="total-item">{{ props.responseTiming.total }} ms</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { ResponseTiming } from '@/models/apiTest/debug';
const props = defineProps<{
responseTiming: ResponseTiming;
}>();
const { t } = useI18n();
const timingLines = computed(() => {
const arr: { key: string; width: string; left: string }[] = [];
const keys = Object.keys(props.responseTiming).filter((key) => key !== 'total');
let preLinesTotalLeft = 0;
keys.forEach((key, index) => {
const itemWidth = (props.responseTiming[key] / props.responseTiming.total) * 100;
arr.push({
key,
width: `${itemWidth}%`,
left: index !== 0 ? `${preLinesTotalLeft}%` : '',
});
preLinesTotalLeft += itemWidth;
});
return arr;
});
</script>
<style lang="less" scoped>
.text-item-wrapper,
.text-item-wrapper--right {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
.light-item {
color: var(--color-text-4);
line-height: 16px;
}
.normal-item {
color: var(--color-text-1);
line-height: 16px;
}
.total-item {
font-weight: 600;
color: rgb(var(--success-7));
line-height: 16px;
}
}
.text-item-wrapper--right {
align-items: flex-end;
}
</style>

View File

@ -28,7 +28,7 @@
v-model:model-value="batchParamsCode" v-model:model-value="batchParamsCode"
class="flex-1" class="flex-1"
theme="MS-text" theme="MS-text"
height="calc(100% - 12px)" height="100%"
:show-full-screen="false" :show-full-screen="false"
> >
<template #title> <template #title>

View File

@ -51,7 +51,7 @@
v-model:model-value="currentBodyCode" v-model:model-value="currentBodyCode"
class="flex-1" class="flex-1"
theme="vs-dark" theme="vs-dark"
height="calc(100% - 12px)" height="100%"
:show-full-screen="false" :show-full-screen="false"
:language="currentCodeLanguage" :language="currentCodeLanguage"
> >

View File

@ -1,17 +1,18 @@
<template> <template>
<div class="border-b border-[var(--color-text-n8)] p-[24px_24px_16px_24px]"> <div class="border-b border-[var(--color-text-n8)] p-[24px_24px_16px_24px]">
<MsEditableTab <MsEditableTab
v-model:active-tab="activeTab" v-model:active-tab="activeRequestTab"
:tabs="debugTabs" v-model:tabs="debugTabs"
:more-action-list="moreActionList" :more-action-list="moreActionList"
at-least-one
@add="addDebugTab" @add="addDebugTab"
@close="closeDebugTab" @close="closeDebugTab"
@change="setActiveDebug" @change="setActiveDebug"
@more-action-select="handleMoreActionSelect"
> >
<template #label="{ tab }"> <template #label="{ tab }">
<apiMethodName :method="tab.method" class="mr-[4px]" /> <apiMethodName :method="tab.method" class="mr-[4px]" />
{{ tab.label }} {{ tab.label }}
<div v-if="tab.unSave" class="ml-[8px] h-[8px] w-[8px] rounded-full bg-[rgb(var(--primary-5))]"></div>
</template> </template>
</MsEditableTab> </MsEditableTab>
</div> </div>
@ -34,7 +35,7 @@
</a-option> </a-option>
</a-select> </a-select>
<a-input <a-input
v-model:model-value="debugUrl" v-model:model-value="activeDebug.url"
:placeholder="t('apiTestDebug.urlPlaceholder')" :placeholder="t('apiTestDebug.urlPlaceholder')"
@change="handleActiveDebugChange" @change="handleActiveDebugChange"
/> />
@ -129,65 +130,89 @@
</div> </div>
</template> </template>
<template #second> <template #second>
<div class="min-w-[290px] bg-[var(--color-text-n9)] p-[8px_16px]"> <response
<div class="flex items-center"> v-model:active-layout="activeLayout"
<template v-if="activeLayout === 'vertical'"> v-model:active-tab="activeDebug.responseActiveTab"
<MsButton :is-expanded="isExpanded"
v-if="isExpanded" :response="activeDebug.response"
type="icon" @change-expand="changeExpand"
class="!mr-0 !rounded-full bg-[rgb(var(--primary-1))]" @change-layout="handleActiveLayoutChange"
@click="changeExpand(false)" />
>
<icon-down :size="12" />
</MsButton>
<MsButton v-else type="icon" status="secondary" class="!mr-0 !rounded-full" @click="changeExpand(true)">
<icon-right :size="12" />
</MsButton>
</template>
<div class="ml-[4px] mr-[24px] font-medium">{{ t('apiTestDebug.responseContent') }}</div>
<a-radio-group
v-model:model-value="activeLayout"
type="button"
size="small"
@change="handleActiveLayoutChange"
>
<a-radio value="vertical">{{ t('apiTestDebug.vertical') }}</a-radio>
<a-radio value="horizontal">{{ t('apiTestDebug.horizontal') }}</a-radio>
</a-radio-group>
</div>
</div>
<div class="p-[16px]"></div>
</template> </template>
</MsSplitBox> </MsSplitBox>
</div> </div>
<a-modal
v-model:visible="saveModalVisible"
:title="t('common.save')"
:ok-loading="saveLoading"
class="ms-modal-form"
title-align="start"
body-class="!p-0"
@before-ok="handleSave"
>
<a-form ref="saveModalFormRef" :model="saveModalForm" layout="vertical">
<a-form-item
field="name"
:label="t('apiTestDebug.requestName')"
:rules="[{ required: true, message: t('apiTestDebug.requestNameRequired') }]"
asterisk-position="end"
>
<a-input v-model:model-value="saveModalForm.name" :placeholder="t('apiTestDebug.requestNamePlaceholder')" />
</a-form-item>
<a-form-item
field="url"
:label="t('apiTestDebug.requestUrl')"
:rules="[{ required: true, message: t('apiTestDebug.requestUrlRequired') }]"
asterisk-position="end"
>
<a-input v-model:model-value="saveModalForm.url" :placeholder="t('apiTestDebug.commonPlaceholder')" />
</a-form-item>
<a-form-item :label="t('apiTestDebug.requestModule')" class="mb-0">
<a-tree-select
v-model:modelValue="saveModalForm.module"
:data="props.moduleTree"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
allow-search
/>
</a-form-item>
</a-form>
</a-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import { cloneDeep, debounce } from 'lodash-es'; import { cloneDeep, debounce } from 'lodash-es';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue'; import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
import { TabItem } from '@/components/pure/ms-editable-tab/types'; import { TabItem } from '@/components/pure/ms-editable-tab/types';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue'; import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import apiMethodName from '../../../components/apiMethodName.vue'; import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import debugAuth from './auth.vue'; import debugAuth from './auth.vue';
import debugBody, { BodyParams } from './body.vue'; import debugBody, { BodyParams } from './body.vue';
import debugHeader from './header.vue'; import debugHeader from './header.vue';
import postcondition from './postcondition.vue'; import postcondition from './postcondition.vue';
import precondition from './precondition.vue'; import precondition from './precondition.vue';
import debugQuery from './query.vue'; import debugQuery from './query.vue';
import response from './response.vue';
import debugRest from './rest.vue'; import debugRest from './rest.vue';
import debugSetting from './setting.vue'; import debugSetting from './setting.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { registerCatchSaveShortcut, removeCatchSaveShortcut } from '@/utils/event'; import { registerCatchSaveShortcut, removeCatchSaveShortcut } from '@/utils/event';
import { RequestBodyFormat, RequestComposition, RequestMethods } from '@/enums/apiEnum'; import type { ModuleTreeNode } from '@/models/projectManagement/file';
import { RequestBodyFormat, RequestComposition, RequestMethods, ResponseComposition } from '@/enums/apiEnum';
const props = defineProps<{
module: string; //
moduleTree: ModuleTreeNode[]; //
}>();
const { t } = useI18n(); const { t } = useI18n();
const initDefaultId = `debug-${Date.now()}`; const initDefaultId = `debug-${Date.now()}`;
const activeTab = ref<string | number>(initDefaultId); const activeRequestTab = ref<string | number>(initDefaultId);
const defaultBodyParams: BodyParams = { const defaultBodyParams: BodyParams = {
format: RequestBodyFormat.NONE, format: RequestBodyFormat.NONE,
formData: [], formData: [],
@ -201,12 +226,14 @@
}; };
const defaultDebugParams = { const defaultDebugParams = {
id: initDefaultId, id: initDefaultId,
module: 'root',
moduleProtocol: 'http', moduleProtocol: 'http',
url: '',
activeTab: RequestComposition.HEADER, activeTab: RequestComposition.HEADER,
label: t('apiTestDebug.newApi'), label: t('apiTestDebug.newApi'),
closable: true, closable: true,
method: RequestMethods.GET, method: RequestMethods.GET,
unSave: false, unSaved: false,
headerParams: [], headerParams: [],
bodyParams: cloneDeep(defaultBodyParams), bodyParams: cloneDeep(defaultBodyParams),
queryParams: [], queryParams: [],
@ -224,81 +251,74 @@
certificateAlias: '', certificateAlias: '',
redirect: 'follow', redirect: 'follow',
}, },
responseActiveTab: ResponseComposition.BODY,
response: { response: {
status: 200, status: 200,
headers: [], headers: [],
body: `{ timing: 12938,
"type": "team", size: 8734,
"test": { env: 'Mock',
"testPage": "tools/testing/run-tests.htm", resource: '66',
"enabled": true timingInfo: {
}, ready: 10,
"search": { socketInit: 50,
"excludeFolders": [ dnsQuery: 20,
".git", tcpHandshake: 80,
"node_modules", sslHandshake: 40,
"tools/bin", waitingTTFB: 30,
"tools/counts", downloadContent: 10,
"tools/policheck", deal: 10,
"tools/tfs_build_extensions", total: 250,
"tools/testing/jscoverage", },
"tools/testing/qunit", extract: {
"tools/testing/chutzpah", a: 'asdasd',
"server.net" b: 'asdasdasd43f43',
] },
}, console: `GET https://qa-release.fit2cloud.com/test`,
"languages": { content: `请求地址:
"vs.languages.typescript": { https://qa-release.fit2cloud.com/test
"validationSettings": [{ 请求头:
"scope":"/", Connection: keep-alive
"noImplicitAny":true, Content-Length: 0
"noLib":false, Content-Type: application/x-www-form-urlencoded; charset=UTF-8
"extraLibs":[], Host: qa-release.fit2cloud.com
"semanticValidation":true, User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.9)
"syntaxValidation":true,
"codeGenTarget":"ES5", Body:
"moduleGenTarget":"", POST https://qa-release.fit2cloud.com/test
"lint": {
"emptyBlocksWithoutComment": "warning", POST data:
"curlyBracketsMustNotBeOmitted": "warning",
"comparisonOperatorsNotStrict": "warning",
"missingSemicolon": "warning", [no cookies]
"unknownTypeOfResults": "warning", `,
"semicolonsInsteadOfBlocks": "warning", header: `HTTP/ 1.1 200 OK
"functionsInsideLoops": "warning", Content-Length: 2381
"functionsWithoutReturnType": "warning", Content-Type: text/html
"tripleSlashReferenceAlike": "warning", Server: bfe
"unusedImports": "warning", Date: Wed, 13 Dec 2023 08:53:25 GMTHTTP/ 1.1 200 OK
"unusedVariables": "warning", Content-Length: 2381
"unusedFunctions": "warning", Content-Type: text/html
"unusedMembers": "warning" Server: bfe
} Date: Wed, 13 Dec 2023 08:53:25 GMT`,
}, body: `<?xml version="1.0"?>
{ <configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
"scope":"/client", <connectionStrings>
"baseUrl":"/client", <add name="MyDB"
"moduleGenTarget":"amd" connectionString="value for the deployed Web.config file"
}, xdt:Transform="SetAttributes" xdt:Locator="Match(name)"/>
{ </connectionStrings>
"scope":"/server", <a>哈哈哈哈哈哈哈</a>
"moduleGenTarget":"commonjs" <system.web>
}, <customErrors defaultRedirect="GenericError.htm"
{ mode="RemoteOnly" xdt:Transform="Replace">
"scope":"/build", <error statusCode="500" redirect="InternalError.htm"/>
"moduleGenTarget":"commonjs" </customErrors>
}, </system.web>
{ </configuration>`,
"scope":"/node_modules/nake",
"moduleGenTarget":"commonjs"
}],
"allowMultipleWorkers": true
}
}
}`,
}, // }, //
}; };
const debugTabs = ref<TabItem[]>([cloneDeep(defaultDebugParams)]); const debugTabs = ref<TabItem[]>([cloneDeep(defaultDebugParams)]);
const debugUrl = ref('');
const activeDebug = ref<TabItem>(debugTabs.value[0]); const activeDebug = ref<TabItem>(debugTabs.value[0]);
function setActiveDebug(item: TabItem) { function setActiveDebug(item: TabItem) {
@ -306,37 +326,40 @@
} }
function handleActiveDebugChange() { function handleActiveDebugChange() {
activeDebug.value.unSave = true; activeDebug.value.unSaved = true;
} }
function addDebugTab() { function addDebugTab() {
const id = `debug-${Date.now()}`; const id = `debug-${Date.now()}`;
debugTabs.value.push({ debugTabs.value.push({
...cloneDeep(defaultDebugParams), ...cloneDeep(defaultDebugParams),
module: props.module,
id, id,
}); });
activeTab.value = id; activeRequestTab.value = id;
} }
function closeDebugTab(tab: TabItem) { function closeDebugTab(tab: TabItem) {
const index = debugTabs.value.findIndex((item) => item.id === tab.id); const index = debugTabs.value.findIndex((item) => item.id === tab.id);
debugTabs.value.splice(index, 1); debugTabs.value.splice(index, 1);
if (activeTab.value === tab.id) { if (activeRequestTab.value === tab.id) {
activeTab.value = debugTabs.value[0]?.id || ''; activeRequestTab.value = debugTabs.value[0]?.id || '';
} }
} }
const moreActionList = [ const moreActionList = [
{ {
key: 'add', eventTag: 'closeOther',
label: t('common.add'), label: t('apiTestDebug.closeOther'),
},
{
key: 'delete',
label: t('common.delete'),
}, },
]; ];
function handleMoreActionSelect(event: ActionsItem) {
if (event.eventTag === 'closeOther') {
debugTabs.value = debugTabs.value.filter((item) => item.id === activeRequestTab.value);
}
}
const contentTabList = [ const contentTabList = [
{ {
value: RequestComposition.HEADER, value: RequestComposition.HEADER,
@ -427,16 +450,60 @@
splitBoxRef.value?.expand(0.6); splitBoxRef.value?.expand(0.6);
} }
function saveDebug() { const saveModalVisible = ref(false);
activeDebug.value.unSave = false; const saveModalForm = ref({
name: '',
url: activeDebug.value.url,
module: activeDebug.value.module,
});
const saveModalFormRef = ref<FormInstance>();
const saveLoading = ref(false);
watch(
() => saveModalVisible.value,
(val) => {
if (!val) {
saveModalFormRef.value?.resetFields();
}
}
);
function handleSaveShortcut() {
saveModalForm.value = {
name: '',
url: activeDebug.value.url,
module: activeDebug.value.module,
};
saveModalVisible.value = true;
}
function handleSave(done: (closed: boolean) => void) {
saveModalFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
saveLoading.value = true;
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 2000));
saveLoading.value = false;
saveModalVisible.value = false;
done(true);
activeDebug.value.unSaved = false;
Message.success(t('common.saveSuccess'));
} catch (error) {
saveLoading.value = false;
}
} else {
done(false);
}
});
} }
onMounted(() => { onMounted(() => {
registerCatchSaveShortcut(saveDebug); registerCatchSaveShortcut(handleSaveShortcut);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeCatchSaveShortcut(saveDebug); removeCatchSaveShortcut(handleSaveShortcut);
}); });
defineExpose({ defineExpose({

View File

@ -1,7 +1,7 @@
<template> <template>
<condition <condition
v-model:list="postConditions" v-model:list="postConditions"
:condition-types="['script', 'sql', 'waitTime', 'extract']" :condition-types="['script', 'sql', 'extract']"
add-text="apiTestDebug.postCondition" add-text="apiTestDebug.postCondition"
:response="props.response" :response="props.response"
:height-used="heightUsed" :height-used="heightUsed"
@ -23,7 +23,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import condition from '../../../components/condition/index.vue'; import condition from '@/views/api-test/components/condition/index.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';

View File

@ -21,7 +21,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import condition from '../../../components/condition/index.vue'; import condition from '@/views/api-test/components/condition/index.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';

View File

@ -23,8 +23,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import paramTable, { type ParamTableColumn } from '../../../components/paramTable.vue';
import batchAddKeyVal from './batchAddKeyVal.vue'; import batchAddKeyVal from './batchAddKeyVal.vue';
import paramTable, { type ParamTableColumn } from '@/views/api-test/components/paramTable.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';

View File

@ -1,7 +1,301 @@
<template> <template>
<div> </div> <div class="flex h-full min-w-[300px] flex-col">
<div class="flex flex-wrap items-center justify-between gap-[8px] bg-[var(--color-text-n9)] p-[8px_16px]">
<div class="flex items-center">
<template v-if="props.activeLayout === 'vertical'">
<MsButton
v-if="props.isExpanded"
type="icon"
class="!mr-0 !rounded-full bg-[rgb(var(--primary-1))]"
@click="emit('changeExpand', false)"
>
<icon-down :size="12" />
</MsButton>
<MsButton
v-else
type="icon"
status="secondary"
class="!mr-0 !rounded-full"
@click="emit('changeExpand', true)"
>
<icon-right :size="12" />
</MsButton>
</template>
<div class="ml-[4px] mr-[24px] font-medium">{{ t('apiTestDebug.responseContent') }}</div>
<a-radio-group
v-model:model-value="innerLayout"
type="button"
size="small"
@change="(val) => emit('changeLayout', val as Direction)"
>
<a-radio value="vertical">{{ t('apiTestDebug.vertical') }}</a-radio>
<a-radio value="horizontal">{{ t('apiTestDebug.horizontal') }}</a-radio>
</a-radio-group>
</div>
<div v-if="props.response.status" class="flex items-center justify-between gap-[24px]">
<a-popover position="left" content-class="response-popover-content">
<div class="text-[rgb(var(--danger-7))]">{{ props.response.status }}</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.statusCode') }}</div>
<div class="text-[rgb(var(--danger-7))]">{{ props.response.status }}</div>
</div>
</template>
</a-popover>
<a-popover position="left" content-class="w-[400px]">
<div class="one-line-text text-[rgb(var(--success-7))]">{{ props.response.timing }} ms</div>
<template #content>
<div class="mb-[8px] flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.responseTime') }}</div>
<div class="text-[rgb(var(--success-7))]">{{ props.response.timing }} ms</div>
</div>
<responseTimeLine :response-timing="$props.response.timingInfo" />
</template>
</a-popover>
<a-popover position="left" content-class="response-popover-content">
<div class="one-line-text text-[rgb(var(--success-7))]">{{ props.response.size }} bytes</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.responseSize') }}</div>
<div class="one-line-text text-[rgb(var(--success-7))]">{{ props.response.size }} bytes</div>
</div>
</template>
</a-popover>
<a-popover position="left" content-class="response-popover-content">
<div class="text-[var(--color-text-1)]">{{ props.response.env }}</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.runningEnv') }}</div>
<div class="text-[var(--color-text-1)]">{{ props.response.env }}</div>
</div>
</template>
</a-popover>
<a-popover position="left" content-class="response-popover-content">
<div class="text-[var(--color-text-1)]">{{ props.response.resource }}</div>
<template #content>
<div class="flex items-center gap-[8px] text-[14px]">
<div class="text-[var(--color-text-4)]">{{ t('apiTestDebug.resourcePool') }}</div>
<div class="text-[var(--color-text-1)]">{{ props.response.resource }}</div>
</div>
</template>
</a-popover>
</div>
</div>
<div class="h-[calc(100%-42px)] px-[16px] pb-[16px]">
<a-tabs v-model:active-key="activeTab" class="no-content">
<a-tab-pane v-for="item of responseTabList" :key="item.value" :title="item.label" />
</a-tabs>
<div class="response-container">
<MsCodeEditor
v-if="activeTab === ResponseComposition.BODY"
:model-value="props.response.body"
language="json"
theme="vs"
height="100%"
:languages="['json', 'html', 'xml', 'plaintext']"
:show-full-screen="false"
:show-theme-change="false"
show-language-change
show-charset-change
read-only
>
<template #title>
<a-button type="outline" class="arco-btn-outline--secondary p-[0_8px]" size="mini" @click="copyScript">
<template #icon>
<MsIcon type="icon-icon_copy_outlined" class="text-var(--color-text-4)" size="12" />
</template>
</a-button>
</template>
</MsCodeEditor>
<div
v-else-if="
activeTab === 'HEADER' || activeTab === 'REAL_REQUEST' || activeTab === 'CONSOLE' || activeTab === 'EXTRACT'
"
class="h-full rounded-[var(--border-radius-small)] bg-[var(--color-text-n9)] p-[12px]"
>
<pre class="response-header-pre">{{ getResponsePreContent(activeTab) }}</pre>
</div>
<MsBaseTable v-else-if="activeTab === 'ASSERTION'" v-bind="propsRes" v-on="propsEvent">
<template #status="{ record }">
<MsTag :type="record.status === 1 ? 'success' : 'danger'" theme="light">
{{ record.status === 1 ? t('common.success') : t('common.fail') }}
</MsTag>
</template>
</MsBaseTable>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
import { useClipboard, useVModel } from '@vueuse/core';
import { Message } from '@arco-design/web-vue';
<style lang="less" scoped></style> import MsButton from '@/components/pure/ms-button/index.vue';
import MsCodeEditor from '@/components/pure/ms-code-editor/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import type { Direction } from '@/components/pure/ms-split-box/index.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import { MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import responseTimeLine from '@/views/api-test/components/responseTimeLine.vue';
import { useI18n } from '@/hooks/useI18n';
import { ResponseTiming } from '@/models/apiTest/debug';
import { ResponseComposition } from '@/enums/apiEnum';
export interface Response {
status: number;
timing: number;
size: number;
env: string;
resource: string;
body: string;
header: string;
content: string;
console: string;
extract: Record<string, any>;
timingInfo: ResponseTiming;
}
const props = defineProps<{
activeTab: keyof typeof ResponseComposition;
activeLayout: Direction;
isExpanded: boolean;
response: Response;
}>();
const emit = defineEmits<{
(e: 'update:activeLayout', value: Direction): void;
(e: 'update:activeTab', value: keyof typeof ResponseComposition): void;
(e: 'changeExpand', value: boolean): void;
(e: 'changeLayout', value: Direction): void;
}>();
const { t } = useI18n();
const innerLayout = useVModel(props, 'activeLayout', emit);
const activeTab = useVModel(props, 'activeTab', emit);
const responseTabList = [
{
label: t('apiTestDebug.responseBody'),
value: ResponseComposition.BODY,
},
{
label: t('apiTestDebug.responseHeader'),
value: ResponseComposition.HEADER,
},
{
label: t('apiTestDebug.realRequest'),
value: ResponseComposition.REAL_REQUEST,
},
{
label: t('apiTestDebug.console'),
value: ResponseComposition.CONSOLE,
},
{
label: t('apiTestDebug.extract'),
value: ResponseComposition.EXTRACT,
},
{
label: t('apiTestDebug.assertion'),
value: ResponseComposition.ASSERTION,
},
];
const { copy, isSupported } = useClipboard();
function copyScript() {
if (isSupported) {
copy(props.response.body);
Message.success(t('common.copySuccess'));
} else {
Message.warning(t('apiTestDebug.copyNotSupport'));
}
}
function getResponsePreContent(type: keyof typeof ResponseComposition) {
switch (type) {
case ResponseComposition.HEADER:
return props.response.header.trim();
case ResponseComposition.REAL_REQUEST:
return props.response.content.trim();
case ResponseComposition.CONSOLE:
return props.response.console.trim();
case ResponseComposition.EXTRACT:
return Object.keys(props.response.extract)
.map((e) => `${e}: ${props.response.extract[e]}`)
.join('\n');
default:
return '';
}
}
const columns: MsTableColumn = [
{
title: 'apiTestDebug.content',
dataIndex: 'content',
showTooltip: true,
},
{
title: 'apiTestDebug.status',
dataIndex: 'status',
slotName: 'status',
width: 80,
},
{
title: '',
dataIndex: 'desc',
showTooltip: true,
},
];
const { propsRes, propsEvent } = useTable(() => Promise.resolve([]), {
scroll: { x: '100%' },
columns,
});
propsRes.value.data = [
{
id: new Date().getTime(),
content: 'Response Code equals: 200',
status: 1,
desc: '',
},
{
id: new Date().getTime(),
content: '$.users[1].age REGEX: 31',
status: 0,
desc: `Value expected to match regexp '31', but it did not match: '30' match: '30'`,
},
] as any;
</script>
<style lang="less">
.response-popover-content {
padding: 4px 8px;
.arco-popover-content {
@apply mt-0;
font-size: 14px;
line-height: 22px;
}
}
</style>
<style lang="less" scoped>
.response-container {
margin-top: 16px;
height: calc(100% - 66px);
.response-header-pre {
@apply h-full overflow-auto bg-white;
.ms-scroll-bar();
padding: 12px;
border-radius: var(--border-radius-small);
}
}
:deep(.arco-table-th) {
background-color: var(--color-text-n9);
}
</style>

View File

@ -23,8 +23,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import paramTable, { type ParamTableColumn } from '../../../components/paramTable.vue';
import batchAddKeyVal from './batchAddKeyVal.vue'; import batchAddKeyVal from './batchAddKeyVal.vue';
import paramTable, { type ParamTableColumn } from '@/views/api-test/components/paramTable.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';

View File

@ -10,7 +10,7 @@
</template> </template>
</a-dropdown> </a-dropdown>
</div> </div>
<div v-if="!props.isModal" class="folder"> <div class="folder">
<div class="folder-text"> <div class="folder-text">
<MsIcon type="icon-icon_folder_filled1" class="folder-icon" /> <MsIcon type="icon-icon_folder_filled1" class="folder-icon" />
<div class="folder-name">{{ t('apiTestDebug.allRequest') }}</div> <div class="folder-name">{{ t('apiTestDebug.allRequest') }}</div>
@ -33,17 +33,18 @@
</popConfirm> </popConfirm>
</div> </div>
</div> </div>
<a-divider v-if="!props.isModal" class="my-[8px]" /> <a-divider class="my-[8px]" />
<a-spin class="min-h-[400px] w-full" :loading="loading"> <a-spin class="min-h-[400px] w-full" :loading="loading">
<MsTree <MsTree
v-model:focus-node-key="focusNodeKey" v-model:focus-node-key="focusNodeKey"
v-model:selected-keys="selectedKeys"
:data="folderTree" :data="folderTree"
:keyword="moduleKeyword" :keyword="moduleKeyword"
:node-more-actions="folderMoreActions" :node-more-actions="folderMoreActions"
:default-expand-all="isExpandAll" :default-expand-all="isExpandAll"
:expand-all="isExpandAll" :expand-all="isExpandAll"
:empty-text="t('apiTestDebug.noMatchModule')" :empty-text="t('apiTestDebug.noMatchModule')"
:draggable="!props.isModal" :draggable="true"
:virtual-list-props="virtualListProps" :virtual-list-props="virtualListProps"
:field-names="{ :field-names="{
title: 'name', title: 'name',
@ -61,10 +62,10 @@
<template #title="nodeData"> <template #title="nodeData">
<div class="inline-flex w-full"> <div class="inline-flex w-full">
<div class="one-line-text w-[calc(100%-32px)] text-[var(--color-text-1)]">{{ nodeData.name }}</div> <div class="one-line-text w-[calc(100%-32px)] text-[var(--color-text-1)]">{{ nodeData.name }}</div>
<div v-if="!props.isModal" class="ml-[4px] text-[var(--color-text-4)]">({{ nodeData.count || 0 }})</div> <div class="ml-[4px] text-[var(--color-text-4)]">({{ nodeData.count || 0 }})</div>
</div> </div>
</template> </template>
<template v-if="!props.isModal" #extra="nodeData"> <template #extra="nodeData">
<!-- 默认模块的 id 是root默认模块不可编辑不可添加子模块 --> <!-- 默认模块的 id 是root默认模块不可编辑不可添加子模块 -->
<popConfirm <popConfirm
v-if="nodeData.id !== 'root'" v-if="nodeData.id !== 'root'"
@ -116,11 +117,10 @@
import { ModuleTreeNode } from '@/models/projectManagement/file'; import { ModuleTreeNode } from '@/models/projectManagement/file';
const props = defineProps<{ const props = defineProps<{
isModal?: boolean; //
modulesCount?: Record<string, number>; // modulesCount?: Record<string, number>; //
isExpandAll?: boolean; // isExpandAll?: boolean; //
}>(); }>();
const emit = defineEmits(['init', 'folderNodeSelect', 'newApi']); const emit = defineEmits(['init', 'change', 'newApi']);
const appStore = useAppStore(); const appStore = useAppStore();
const { t } = useI18n(); const { t } = useI18n();
@ -140,11 +140,6 @@
} }
const virtualListProps = computed(() => { const virtualListProps = computed(() => {
if (props.isModal) {
return {
height: 'calc(60vh - 190px)',
};
}
return { return {
height: 'calc(100vh - 325px)', height: 'calc(100vh - 325px)',
}; };
@ -169,8 +164,16 @@
const moduleKeyword = ref(''); const moduleKeyword = ref('');
const folderTree = ref<ModuleTreeNode[]>([]); const folderTree = ref<ModuleTreeNode[]>([]);
const focusNodeKey = ref<string | number>(''); const focusNodeKey = ref<string | number>('');
const selectedKeys = ref<string[]>([]);
const loading = ref(false); const loading = ref(false);
watch(
() => selectedKeys.value,
(arr) => {
emit('change', arr[0]);
}
);
function setFocusNodeKey(node: MsTreeNodeData) { function setFocusNodeKey(node: MsTreeNodeData) {
focusNodeKey.value = node.id || ''; focusNodeKey.value = node.id || '';
} }
@ -200,8 +203,8 @@
return { return {
...e, ...e,
hideMoreAction: e.id === 'root', hideMoreAction: e.id === 'root',
draggable: e.id !== 'root' && !props.isModal, draggable: e.id !== 'root',
disabled: e.id === activeFolder.value && props.isModal, disabled: e.id === activeFolder.value,
}; };
}); });
emit('init', folderTree.value); emit('init', folderTree.value);

View File

@ -3,12 +3,12 @@
<MsSplitBox :size="0.25" :max="0.5"> <MsSplitBox :size="0.25" :max="0.5">
<template #first> <template #first>
<div class="p-[24px]"> <div class="p-[24px]">
<moduleTree @new-api="newApi" /> <moduleTree @init="(val) => (folderTree = val)" @new-api="newApi" @change="(val) => (activeModule = val)" />
</div> </div>
</template> </template>
<template #second> <template #second>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<debug ref="debugRef" /> <debug ref="debugRef" :module="activeModule" :module-tree="folderTree" />
</div> </div>
</template> </template>
</MsSplitBox> </MsSplitBox>
@ -21,7 +21,11 @@
import debug from './components/debug/index.vue'; import debug from './components/debug/index.vue';
import moduleTree from './components/moduleTree.vue'; import moduleTree from './components/moduleTree.vue';
import { ModuleTreeNode } from '@/models/projectManagement/file';
const debugRef = ref<InstanceType<typeof debug>>(); const debugRef = ref<InstanceType<typeof debug>>();
const activeModule = ref<string>('root');
const folderTree = ref<ModuleTreeNode[]>([]);
function newApi() { function newApi() {
debugRef.value?.addDebugTab(); debugRef.value?.addDebugTab();

View File

@ -68,7 +68,7 @@ export default {
'apiTestDebug.quote': '引用公共脚本', 'apiTestDebug.quote': '引用公共脚本',
'apiTestDebug.commonScriptList': '公共脚本列表', 'apiTestDebug.commonScriptList': '公共脚本列表',
'apiTestDebug.scriptEx': '脚本案例', 'apiTestDebug.scriptEx': '脚本案例',
'apiTestDebug.copyNotSupport': '您的浏览器不支持自动复制,请您手动复制脚本案例', 'apiTestDebug.copyNotSupport': '您的浏览器不支持自动复制,请您手动复制',
'apiTestDebug.scriptExCopySuccess': '脚本案例已复制', 'apiTestDebug.scriptExCopySuccess': '脚本案例已复制',
'apiTestDebug.parameters': '传递参数', 'apiTestDebug.parameters': '传递参数',
'apiTestDebug.scriptContent': '脚本内容', 'apiTestDebug.scriptContent': '脚本内容',
@ -137,4 +137,34 @@ export default {
'apiTestDebug.allMatch': '全部匹配', 'apiTestDebug.allMatch': '全部匹配',
'apiTestDebug.allMatchTip': '正则返回的是一个匹配结果数组', 'apiTestDebug.allMatchTip': '正则返回的是一个匹配结果数组',
'apiTestDebug.contentType': '响应内容格式', 'apiTestDebug.contentType': '响应内容格式',
'apiTestDebug.responseTime': '响应时间',
'apiTestDebug.responseStage': '阶段',
'apiTestDebug.time': '时间',
'apiTestDebug.ready': '准备',
'apiTestDebug.socketInit': 'Socket 初始化',
'apiTestDebug.dnsQuery': 'DNS 查询',
'apiTestDebug.tcpHandshake': 'TCP 握手',
'apiTestDebug.sslHandshake': 'SSL 握手',
'apiTestDebug.waitingTTFB': '等待中 (TTFB)',
'apiTestDebug.downloadContent': '下载内容',
'apiTestDebug.deal': '处理',
'apiTestDebug.total': '总共',
'apiTestDebug.responseBody': '响应体',
'apiTestDebug.responseHeader': '响应头',
'apiTestDebug.realRequest': '实际请求',
'apiTestDebug.console': '控制台',
'apiTestDebug.extract': '提取',
'apiTestDebug.statusCode': '状态码',
'apiTestDebug.responseSize': '响应大小',
'apiTestDebug.runningEnv': '运行环境',
'apiTestDebug.resourcePool': '资源池',
'apiTestDebug.content': '内容',
'apiTestDebug.status': '状态',
'apiTestDebug.requestName': '请求名称',
'apiTestDebug.requestNameRequired': '请求名称不能为空',
'apiTestDebug.requestNamePlaceholder': '请输入请求名称',
'apiTestDebug.requestUrl': '请求 URL',
'apiTestDebug.requestUrlRequired': '请求 URL不能为空',
'apiTestDebug.requestModule': '请求所属模块',
'apiTestDebug.closeOther': '关闭其他请求',
}; };