front/src/components/ApiTest/index.vue
zzh 8a865c0a53 feat: 管理端人文关怀双工作台与接口/H5体验优化
拆分客户/员工工作台,落地登录关怀、待办统计、公告双栏布局;同步接口映射 AI、H5 订单与相关页面优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:06:51 +08:00

3236 lines
99 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { ref, reactive, computed, nextTick, watch, h } from 'vue';
import type { PropType } from 'vue';
import { IconDelete } from '@arco-design/web-vue/es/icon';
import { Message, Modal } from '@arco-design/web-vue';
import TablePro from '@/components/TablePro/index.vue';
import CodeMirror from '@/components/CodeMirror/index.vue';
import { useI18n } from 'vue-i18n';
import { random } from 'lodash';
import { $baseMessage } from '@/utils';
import JSON5 from 'json5';
import { validRulesEdit } from '@/views/api/upstreamInterface/config';
import { getDictionaryGetList } from '@/api/ApplicationServices/dictionary';
import { getInterfaceGetInterfaceById } from '@/api/InterfaceServices/interface';
import { getMethodText } from '@/views/api/upstreamInterface/utils';
import { useTabRowCounts } from '@/utils/useTabRowCounts';
import RequestLog from '@/views/api/requestLog/index.vue';
const { t } = useI18n();
defineOptions({
name: 'ApiTest',
});
// 接收父组件传递的接口和服务商信息(可选)
const props = defineProps({
selectedInterface: {
type: Object as PropType<any>,
default: undefined,
},
selectedServiceProvider: {
type: Object as PropType<any>,
default: undefined,
},
enableDefaults: {
type: Boolean,
default: true, // 默认启用默认值设置
},
});
function getUpstreamInterfaceCode(): string {
return props.selectedInterface?.code || '';
}
// 映射的服务商接口 code(上游接口页面传入的 mapping 行通常在 serviceProviderCode 字段)
function getMappedServiceProviderInterfaceCode(): string {
return (
props.selectedServiceProvider?.serviceProviderCode ||
props.selectedServiceProvider?.code ||
''
);
}
// 导入 cURL 后:如果 query 里包含 standardCode / supplierCode,则按规则覆盖其值
function applyQueryCodeOverrides() {
if (!queryTableProps.data || queryTableProps.data.length === 0) return;
const standardCodeItem = queryTableProps.data.find((item) => item.name === 'standardCode');
const supplierCodeItem = queryTableProps.data.find((item) => item.name === 'supplierCode');
if (standardCodeItem) {
standardCodeItem.value = getUpstreamInterfaceCode();
}
if (supplierCodeItem) {
supplierCodeItem.value = getMappedServiceProviderInterfaceCode();
}
}
// 标记是否已从接口获取地址
const hasLoadedFromInterface = ref(false);
// HTTP 方法列表
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
const selectedMethod = ref('POST');
const apiUrl = ref('');
// 请求体 tabs(Query、Request 和 Header)
const requestTab = ref<'query' | 'request' | 'header'>('request');
const PANEL_LAYOUT_KEY = 'oms-api-test-panel-layout';
const panelLayout = ref<'horizontal' | 'vertical'>(
typeof localStorage !== 'undefined' && localStorage.getItem(PANEL_LAYOUT_KEY) === 'horizontal'
? 'horizontal'
: 'vertical',
);
function setPanelLayout(layout: 'horizontal' | 'vertical') {
panelLayout.value = layout;
try {
localStorage.setItem(PANEL_LAYOUT_KEY, layout);
} catch {
/* ignore */
}
}
function onPanelLayoutSwitch(checked: string | number | boolean) {
setPanelLayout(checked ? 'vertical' : 'horizontal');
}
// 返回体 tabs
const responseTab = ref<'response' | 'curl'>('response');
// Body 相关配置(需要在 watch 之前定义)
const bodyConfig = reactive({
bodyType: 'none',
body: '',
formData: [] as any[],
urlEncoded: [] as any[],
rawBody: '',
});
// 参数类型列表
const ParameterTypeList = ref<any[]>([]);
// 加载参数类型列表
async function loadParameterTypeList() {
try {
const response: any = await getDictionaryGetList({
type: 'ParameterType',
});
ParameterTypeList.value = response?.data || response || [];
} catch {
// 静默忽略加载失败
}
}
// 初始化时加载参数类型列表
loadParameterTypeList();
// 初始化默认参数
function initDefaultParams() {
// 设置 Header 默认值
if (!headerTableProps.data || headerTableProps.data.length === 0) {
headerTableProps.data = [
{
id: random(0, 2099999999),
name: 'appkey',
type: 1, // string类型
value: 'W2507080',
description: '应用密钥'
},
{
id: random(0, 2099999999),
name: 'appSecret',
type: 1, // string类型
value: 'G1rA80mAfGovH7j53t5bIH425M4vKreT',
description: '应用密钥'
},
{
id: random(0, 2099999999),
name: 'followName',
type: 1, // string类型
value: '0',
description: '跟踪名称'
}
];
}
// 设置 Query 默认值
if (!queryTableProps.data || queryTableProps.data.length === 0) {
const interfaceCode = getUpstreamInterfaceCode();
const serviceProviderCode = getMappedServiceProviderInterfaceCode();
queryTableProps.data = [
{
id: random(0, 2099999999),
name: 'standardCode',
type: 1, // string类型
value: interfaceCode,
description: '标准代码'
},
{
id: random(0, 2099999999),
name: 'supplierCode',
type: 1, // string类型
value: serviceProviderCode,
description: '供应商代码'
}
];
}
}
// 初始化默认参数(移到所有变量声明之后)
// initDefaultParams(); // 暂时注释,稍后在合适位置调用
// 将JSON对象扁平化为键值对数组的函数
function flattenJsonToKeyValuePairs(obj: any, prefix: string = '', result: any[] = []): any[] {
if (obj === null || obj === undefined) {
return result;
}
if (typeof obj === 'object' && !Array.isArray(obj)) {
// 对象类型,递归处理
Object.keys(obj).forEach(key => {
const newKey = prefix ? `${prefix}.${key}` : key;
flattenJsonToKeyValuePairs(obj[key], newKey, result);
});
} else if (Array.isArray(obj)) {
// 数组类型,转换为索引形式
obj.forEach((item, index) => {
const newKey = `${prefix}[${index}]`;
if (typeof item === 'object') {
flattenJsonToKeyValuePairs(item, newKey, result);
} else {
result.push({
key: newKey,
value: item,
description: `数组项 ${index}`
});
}
});
} else {
// 基本类型
result.push({
key: prefix,
value: obj,
description: ''
});
}
return result;
}
// 请求发送前去除 // 和 /* */ 注释,返回可用的 JSON 字符串
function stripCommentsForRequest(bodyStr: string): string {
if (!bodyStr || typeof bodyStr !== 'string') return bodyStr || '';
// 先尝试直接解析(用户编辑的、已含真实换行的内容)
try {
const parsed = JSON5.parse(bodyStr);
return JSON.stringify(parsed);
} catch {
// 解析失败时,处理后端返回的转义格式(\n \t \" 等),再解析
try {
const processed = bodyStr
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"');
const parsed = JSON5.parse(processed);
return JSON.stringify(parsed);
} catch {
return bodyStr;
}
}
}
// 处理 inParams 数据的专用函数
function processInParamsData(inParams: any): string {
if (!inParams) return '';
// 如果是对象,直接格式化
if (typeof inParams === 'object') {
return JSON.stringify(inParams, null, 2);
}
// 如果不是字符串,返回原值
if (typeof inParams !== 'string') {
return String(inParams);
}
try {
// 步骤1: 处理转义字符 - 将字面量的转义序列转换为实际字符
let processed = inParams;
// 按顺序处理转义字符(重要:顺序不能错)
processed = processed.replace(/\\r\\n/g, '\n'); // Windows 换行符转义 -> 实际换行
processed = processed.replace(/\\n/g, '\n'); // Unix 换行符转义 -> 实际换行
processed = processed.replace(/\\r/g, '\r'); // 回车符转义 -> 实际回车
processed = processed.replace(/\\t/g, '\t'); // 制表符转义 -> 实际制表符
processed = processed.replace(/\\\\/g, '\\'); // 反斜杠转义 -> 实际反斜杠
processed = processed.replace(/\\"/g, '"'); // 双引号转义 -> 实际双引号
// 步骤2: 使用 JSON5 解析(支持单引号、尾逗号、注释等宽松语法)
const parsedObject = JSON5.parse(processed);
// 步骤3: 格式化为美观的 JSON 字符串(2 空格缩进)
return JSON.stringify(parsedObject, null, 2);
} catch {
// 如果处理失败,返回原始数据
return inParams;
}
}
// 获取参数类型名称
function getParameterTypeName(type: any) {
const paramType = ParameterTypeList.value.find(item => item.key === type);
return paramType ? paramType.value : type;
}
// Query 参数表格配置(使用 InterfaceDetail 的配置)
const queryTableRef = ref();
const queryTableProps = reactive({
columns: [
{
field: 'name',
title: '参数名',
editRender: { name: '$input', props: { placeholder: '请输入参数名' } },
slots: { default: 'name', edit: 'name_edit' },
},
{
field: 'type',
title: '参数类型',
editRender: {},
slots: { default: 'type', edit: 'type_edit' },
},
{
field: 'value',
title: '参数值',
editRender: { name: '$input', props: { placeholder: '请输入参数值' } },
slots: { default: 'value', edit: 'value_edit' },
},
{
field: 'description',
title: '参数描述',
editRender: { name: '$input', props: { placeholder: '请输入描述' } },
slots: { default: 'description', edit: 'description_edit' },
},
{
field: 'action',
title: '操作',
fixed: 'right',
align: 'center',
width: 100,
slots: { default: 'action' },
},
],
isCellSort: false,
showToolbar: false,
isColumnsDrag: false,
showPager: false,
tableConfig: {
editConfig: {
trigger: 'click',
mode: 'row',
showStatus: true,
autoClear: false,
},
},
showIndex: false,
data: [] as any[],
minHeight: 150,
keepSource: true,
size: 'small',
useForm: false,
autoHeight: false,
height: '100%',
editRules: validRulesEdit.value,
toolbarConfig: {
setting: false
},
});
// 判断行是否处于编辑状态(Query)
function isQueryRowEditing(row: any): boolean {
if (!row || !queryTableRef.value) return false;
try {
return queryTableRef.value.isEditByRow(row);
} catch (error) {
return false;
}
}
// 新增 Query 参数
async function addNewQueryParam() {
try {
const record = {
id: random(0, 2099999999),
name: '',
type: 1,
description: '',
};
await nextTick();
const { row: newRow } = await queryTableRef.value!.addRow(record);
await nextTick();
await queryTableRef.value?.editRowEvent(newRow);
refreshTabCounts();
} catch {
// 静默忽略
}
}
// 删除 Query 参数行
async function deleteQueryRow(row: any) {
await queryTableRef.value?.removeRow(row);
refreshTabCounts();
}
// Request 参数表格配置(与 Query 相同结构)
const requestTableRef = ref();
const requestTableProps = reactive({
columns: [
{
field: 'name',
title: '参数名',
editRender: { name: '$input', props: { placeholder: '请输入参数名' } },
slots: { default: 'name', edit: 'name_edit' },
},
{
field: 'type',
title: '参数类型',
editRender: {},
slots: { default: 'type', edit: 'type_edit' },
},
{
field: 'value',
title: '参数值',
editRender: { name: '$input', props: { placeholder: '请输入参数值' } },
slots: { default: 'value', edit: 'value_edit' },
},
{
field: 'description',
title: '参数描述',
editRender: { name: '$input', props: { placeholder: '请输入描述' } },
slots: { default: 'description', edit: 'description_edit' },
},
{
field: 'action',
title: '操作',
fixed: 'right',
align: 'center',
width: 100,
slots: { default: 'action' },
},
],
showToolbar: false,
showPager: false,
tableConfig: {
editConfig: {
trigger: 'click',
mode: 'row',
showStatus: true,
autoClear: false,
},
},
showIndex: false,
data: [] as any[],
minHeight: 200,
keepSource: true,
size: 'small',
useForm: false,
autoHeight: false,
height: '100%',
});
// Header 参数表格配置(使用 Query 的配置)
const headerTableRef = ref();
const headerTableProps = reactive({
columns: [
{
field: 'name',
title: '参数名',
editRender: { name: '$input', props: { placeholder: '请输入参数名' } },
slots: { default: 'name', edit: 'name_edit' },
},
{
field: 'type',
title: '参数类型',
editRender: {},
slots: { default: 'type', edit: 'type_edit' },
},
{
field: 'value',
title: '参数值',
editRender: { name: '$input', props: { placeholder: '请输入参数值' } },
slots: { default: 'value', edit: 'value_edit' },
},
{
field: 'description',
title: '参数描述',
editRender: { name: '$input', props: { placeholder: '请输入描述' } },
slots: { default: 'description', edit: 'description_edit' },
},
{
field: 'action',
title: '操作',
fixed: 'right',
align: 'center',
width: 100,
slots: { default: 'action' },
},
],
isCellSort: false,
showToolbar: false,
isColumnsDrag: false,
showPager: false,
tableConfig: {
editConfig: {
trigger: 'click',
mode: 'row',
showStatus: true,
autoClear: false,
},
},
showIndex: false,
data: [] as any[],
minHeight: 150,
keepSource: true,
size: 'small',
useForm: false,
autoHeight: false,
height: '100%',
editRules: validRulesEdit.value,
toolbarConfig: {
setting: false
},
});
// 判断行是否处于编辑状态(Header)
function isHeaderRowEditing(row: any): boolean {
if (!row || !headerTableRef.value) return false;
try {
return headerTableRef.value.isEditByRow(row);
} catch (error) {
return false;
}
}
// 新增 Header 参数
async function addNewHeaderParam() {
try {
const record = {
id: random(0, 2099999999),
name: '',
type: 1,
description: '',
};
await nextTick();
const { row: newRow } = await headerTableRef.value!.addRow(record);
await nextTick();
await headerTableRef.value?.editRowEvent(newRow);
refreshTabCounts();
} catch {
// 静默忽略
}
}
// 删除 Header 参数行
async function deleteHeaderRow(row: any) {
await headerTableRef.value?.removeRow(row);
refreshTabCounts();
}
// 响应数据
const responseData = ref('');
const curlCommand = ref('');
const isLoading = ref(false);
const response = ref<any>(null);
// 格式化响应状态信息
const responseStatusText = computed(() => {
if (!response.value) return '';
const { status, duration } = response.value;
if (status === 0) {
return `请求失败,耗时: ${duration}ms`;
}
const isSuccess = status >= 200 && status < 300;
return `${isSuccess ? '请求成功' : '请求失败'}!状态码: ${status},耗时: ${duration}ms`;
});
// x-www-form-urlencoded 表格配置(使用 Query 样式)
const urlEncodedTableRef = ref();
const urlEncodedTableProps = reactive({
columns: [
{
field: 'name',
title: '参数名',
editRender: { name: '$input', props: { placeholder: '请输入参数名' } },
slots: { default: 'name', edit: 'name_edit' },
},
{
field: 'type',
title: '参数类型',
editRender: {},
slots: { default: 'type', edit: 'type_edit' },
},
{
field: 'value',
title: '参数值',
editRender: { name: '$input', props: { placeholder: '请输入参数值' } },
slots: { default: 'value', edit: 'value_edit' },
},
{
field: 'description',
title: '参数描述',
editRender: { name: '$input', props: { placeholder: '请输入描述' } },
slots: { default: 'description', edit: 'description_edit' },
},
{
field: 'action',
title: '操作',
fixed: 'right',
align: 'center',
width: 120,
slots: { default: 'action' },
},
],
isCellSort: false,
showToolbar: false,
isColumnsDrag: false,
showPager: false,
tableConfig: {
editConfig: {
trigger: 'click',
mode: 'row',
showStatus: true,
autoClear: false,
},
},
showIndex: false,
data: [] as any[],
minHeight: 150,
keepSource: true,
size: 'small',
useForm: false,
autoHeight: false,
height: '100%',
editRules: validRulesEdit.value,
toolbarConfig: {
setting: false
},
});
// form-data 表格配置(使用 Query 样式)
const formDataTableRef = ref();
const formDataTableProps = reactive({
columns: [
{
field: 'name',
title: '参数名',
editRender: { name: '$input', props: { placeholder: '请输入参数名' } },
slots: { default: 'name', edit: 'name_edit' },
},
{
field: 'type',
title: '参数类型',
editRender: {},
slots: { default: 'type', edit: 'type_edit' },
},
{
field: 'value',
title: '参数值',
editRender: { name: '$input', props: { placeholder: '请输入参数值' } },
slots: { default: 'value', edit: 'value_edit' },
},
{
field: 'description',
title: '参数描述',
editRender: { name: '$input', props: { placeholder: '请输入描述' } },
slots: { default: 'description', edit: 'description_edit' },
},
{
field: 'action',
title: '操作',
fixed: 'right',
align: 'center',
width: 120,
slots: { default: 'action' },
},
],
isCellSort: false,
showToolbar: false,
isColumnsDrag: false,
showPager: false,
tableConfig: {
editConfig: {
trigger: 'click',
mode: 'row',
showStatus: true,
autoClear: false,
},
},
showIndex: false,
data: [] as any[],
minHeight: 150,
keepSource: true,
size: 'small',
useForm: false,
autoHeight: false,
height: '100%',
editRules: validRulesEdit.value,
toolbarConfig: {
setting: false
},
});
const { refresh: refreshTabCounts, countFromTable, countTextBody } = useTabRowCounts();
const queryTabCount = computed(() =>
countFromTable(queryTableRef, () => queryTableProps.data),
);
const headerTabCount = computed(() =>
countFromTable(headerTableRef, () => headerTableProps.data),
);
const bodyTabCount = computed(() => {
const type = bodyConfig.bodyType;
if (type === 'x-www-form-urlencoded') {
return countFromTable(urlEncodedTableRef, () => urlEncodedTableProps.data);
}
if (type === 'form-data') {
return countFromTable(formDataTableRef, () => formDataTableProps.data);
}
if (type === 'json' || type === 'xml') {
return countTextBody(bodyConfig.body);
}
return 0;
});
/** 空态提示(展示在类型行正中) */
const requestEmptyTip = computed(() => {
if (requestTab.value === 'query' && queryTabCount.value === 0) {
return '暂无 Query 参数,可点击右侧「新增」';
}
if (requestTab.value === 'header' && headerTabCount.value === 0) {
return '暂无 Header 参数,可点击右侧「新增」';
}
if (requestTab.value === 'request') {
if (bodyConfig.bodyType === 'none') return '当前为 none,无请求体';
if (
bodyConfig.bodyType === 'x-www-form-urlencoded'
&& countFromTable(urlEncodedTableRef, () => urlEncodedTableProps.data) === 0
) {
return '暂无表单字段,可点击右侧「新增」';
}
if (
bodyConfig.bodyType === 'form-data'
&& countFromTable(formDataTableRef, () => formDataTableProps.data) === 0
) {
return '暂无 FormData 字段,可点击右侧「新增」';
}
}
return '';
});
// 初始化默认参数(在所有变量声明之后调用)
if (props.enableDefaults) {
initDefaultParams();
}
// 监听接口变化,直接使用传递的接口数据
watch(() => props.selectedInterface, async (newInterface, oldInterface) => {
if (newInterface && newInterface !== oldInterface) {
// 0. 设置默认参数(如果启用默认值)
if (props.enableDefaults) {
initDefaultParams();
}
// 1. 切换到Request tab显示demo数据
requestTab.value = 'request';
// 延时确保tab切换完成
nextTick(() => {
// 这里不需要await,直接在nextTick回调中执行
});
// 1. 设置请求方式(从接口对象中读取)
if (newInterface.requestMethod !== undefined && newInterface.requestMethod !== null) {
const methodValue = newInterface.requestMethod;
// 如果已经是字符串形式的 HTTP 方法(GET, POST等),直接使用
if (typeof methodValue === 'string' && ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(methodValue.toUpperCase())) {
selectedMethod.value = methodValue.toUpperCase();
} else {
// requestMethod 是数字(字典key),需要转换为对应的文本值
const methodNum = Number(methodValue);
if (!isNaN(methodNum)) {
const methodText = getMethodText(methodNum as any);
if (methodText && methodText !== '未知') {
selectedMethod.value = methodText;
} else {
// 如果转换失败,尝试直接使用原值并转为大写
selectedMethod.value = String(methodValue).toUpperCase();
}
} else {
// 无法转换为数字,直接使用原值
selectedMethod.value = String(methodValue).toUpperCase();
}
}
}
// 2. 设置请求地址(从接口对象中读取)
if (newInterface.requestAddress) {
apiUrl.value = String(newInterface.requestAddress);
hasLoadedFromInterface.value = true;
}
// 3. 设置请求类型(从接口对象中读取 contentType)
let newBodyType = 'none';
if (newInterface.contentType !== undefined && newInterface.contentType !== null) {
const contentTypeNum = Number(newInterface.contentType);
switch (contentTypeNum) {
case 1:
newBodyType = 'xml';
break;
case 2:
newBodyType = 'x-www-form-urlencoded';
break;
case 3:
newBodyType = 'json';
break;
case 4:
newBodyType = 'form-data';
break;
default:
newBodyType = 'raw';
}
} else {
newBodyType = 'json';
}
bodyConfig.bodyType = newBodyType;
// 强制UI更新 - 多次尝试确保更新生效
nextTick(() => {
if (bodyConfig.bodyType !== newBodyType) {
bodyConfig.bodyType = newBodyType;
}
});
// 4. 不再从接口明细的 inParams/curl 示例加载 body 数据,body 仅通过「导入cURL」或「调试」按钮导入的 cURL 的 --data-raw 显示
// 5. 设置 Header 和 Query 的默认值(只在启用默认值且为空时设置)
if (props.enableDefaults && (!headerTableProps.data || headerTableProps.data.length === 0)) {
headerTableProps.data = [
{
id: random(0, 2099999999),
name: 'appkey',
type: 1, // string类型
value: 'W2507080',
description: '应用密钥'
},
{
id: random(0, 2099999999),
name: 'appSecret',
type: 1, // string类型
value: 'G1rA80mAfGovH7j53t5bIH425M4vKreT',
description: '应用密钥'
},
{
id: random(0, 2099999999),
name: 'followName',
type: 1, // string类型
value: '0',
description: '跟踪名称'
}
];
}
// 设置 Query 默认值(只在启用默认值且为空时设置)
if (props.enableDefaults && (!queryTableProps.data || queryTableProps.data.length === 0)) {
const interfaceCode = newInterface.code || '';
const serviceProviderCode = props.selectedServiceProvider?.serviceProviderCode || '';
queryTableProps.data = [
{
id: random(0, 2099999999),
name: 'standardCode',
type: 1, // string类型
value: interfaceCode,
description: '标准代码'
},
{
id: random(0, 2099999999),
name: 'supplierCode',
type: 1, // string类型
value: serviceProviderCode,
description: '供应商代码'
}
];
} else if (props.enableDefaults) {
// 如果已有数据且启用默认值,更新动态值
const standardCodeItem = queryTableProps.data.find(item => item.name === 'standardCode');
const supplierCodeItem = queryTableProps.data.find(item => item.name === 'supplierCode');
const interfaceCode = newInterface.code || '';
const serviceProviderCode = props.selectedServiceProvider?.serviceProviderCode || '';
if (standardCodeItem) {
standardCodeItem.value = interfaceCode;
}
if (supplierCodeItem) {
supplierCodeItem.value = serviceProviderCode;
}
}
}
}, { immediate: true });
// 监听服务商变化,更新supplierCode(只有启用默认值时)
watch(() => props.selectedServiceProvider?.serviceProviderCode, (newCode) => {
if (props.enableDefaults && newCode && queryTableProps.data) {
const supplierCodeItem = queryTableProps.data.find(item => item.name === 'supplierCode');
if (supplierCodeItem) {
supplierCodeItem.value = newCode;
}
}
}, { immediate: true });
// 判断行是否处于编辑状态(urlEncoded)
function isUrlEncodedRowEditing(row: any): boolean {
if (!row || !urlEncodedTableRef.value) return false;
try {
return urlEncodedTableRef.value.isEditByRow(row);
} catch (error) {
return false;
}
}
// 判断行是否处于编辑状态(formData)
function isFormDataRowEditing(row: any): boolean {
if (!row || !formDataTableRef.value) return false;
try {
return formDataTableRef.value.isEditByRow(row);
} catch (error) {
return false;
}
}
// 解析并导入 cURL 命令(可被外部调用)
function parseCurlCommand(curlCommand: string) {
try {
// 清理命令,但保留数据部分的多行结构
let cleanCurl = curlCommand.trim();
// 先处理多行命令的连接:Linux/Mac 用 \,Windows 用 ^
cleanCurl = cleanCurl.replace(/\\\s*\r?\n\s*/g, ' ');
cleanCurl = cleanCurl.replace(/\^\s*\r?\n\s*/g, ' ');
// 移除多余的空格,但保留数据部分
cleanCurl = cleanCurl.replace(/\s+/g, ' ');
// 解析HTTP方法
let method = 'GET';
const methodMatch = cleanCurl.match(/-X\s+(\w+)|--request\s+(\w+)/i);
if (methodMatch) {
method = (methodMatch[1] || methodMatch[2]).toUpperCase();
} else if (cleanCurl.includes('--location')) {
// 如果有--location但没有-X,可能是GET
method = 'GET';
}
selectedMethod.value = method;
// 解析URL - 支持单引号和双引号包围的URL
const urlMatch = cleanCurl.match(/(?:-X\s+\w+\s+)?['"]?((?:https?|http):\/\/[^'"\s]+)['"]?/);
if (urlMatch) {
const url = urlMatch[1];
try {
// 分离URL和查询参数
const urlObj = new URL(url);
apiUrl.value = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
hasLoadedFromInterface.value = true;
// 解析查询参数
if (urlObj.searchParams.toString()) {
const queryParams: any[] = [];
urlObj.searchParams.forEach((value, key) => {
queryParams.push({
id: random(0, 2099999999),
name: key,
type: 1, // string
value: value,
description: ''
});
});
queryTableProps.data = queryParams;
applyQueryCodeOverrides();
}
} catch {
// 如果URL解析失败,直接使用原URL
apiUrl.value = url;
hasLoadedFromInterface.value = true;
}
}
// 解析Headers - 改进的解析逻辑
const headerRegex = /-H\s+['"]([^:]+):\s*([^'"]*(?:\\.[^'"]*)*)['"]|--header\s+['"]([^:]+):\s*([^'"]*(?:\\.[^'"]*)*)['"]/g;
const headers: any[] = [];
let headerMatch;
while ((headerMatch = headerRegex.exec(cleanCurl)) !== null) {
const name = headerMatch[1] || headerMatch[3];
const value = headerMatch[2] || headerMatch[4];
if (name) {
// 过滤掉Content-Type,因为后面单独处理
if (name.toLowerCase() !== 'content-type') {
headers.push({
id: random(0, 2099999999),
name: name.trim(),
type: 1, // string
value: (value || '').trim(),
description: ''
});
}
}
}
const pushHeaderIfMissing = (name: string, value: string) => {
const n = String(name || '').trim();
const v = String(value || '').trim();
if (!n || n.toLowerCase() === 'content-type') return;
if (headers.some((h) => String(h.name) === n && String(h.value) === v)) return;
headers.push({
id: random(0, 2099999999),
name: n,
type: 1, // string
value: v,
description: ''
});
};
// 兜底解析:-H k:v(不带引号)
// 适用于 value 不包含空格的常见情况;复杂 header 仍建议加引号。
const unquotedHeaderRegex = /(?:-H|--header)\s+([A-Za-z0-9._-]+)\s*:\s*([^\s\\]+)/g;
let unquotedHeaderMatch: RegExpExecArray | null;
while ((unquotedHeaderMatch = unquotedHeaderRegex.exec(cleanCurl)) !== null) {
pushHeaderIfMissing(unquotedHeaderMatch[1], unquotedHeaderMatch[2]);
}
if (headers.length > 0) {
headerTableProps.data = headers;
}
// 解析请求体
let bodyData = '';
let contentType = '';
// 检查Content-Type
const contentTypeMatch =
// -H "content-type:application/json"
cleanCurl.match(/-H\s+['"]content-type:\s*([^'"]+)['"]/i) ||
cleanCurl.match(/--header\s+['"]content-type:\s*([^'"]+)['"]/i) ||
// -H content-type:application/json(不带引号)
cleanCurl.match(/-H\s+content-type\s*:\s*([^\s\\]+)/i) ||
cleanCurl.match(/--header\s+content-type\s*:\s*([^\s\\]+)/i);
if (contentTypeMatch) {
contentType = contentTypeMatch[1].toLowerCase();
}
// 解析数据 - 改进的匹配逻辑
// 先处理 --data-urlencode(支持多个)
const urlEncodeItems: string[] = [];
// 匹配:--data-urlencode 'a=b' 或 --data-urlencode "a=b"
const dataUrlencodeQuotedRegex = /--data-urlencode\s+(['"])(.*?)\1/g;
let duMatch: RegExpExecArray | null;
while ((duMatch = dataUrlencodeQuotedRegex.exec(cleanCurl)) !== null) {
urlEncodeItems.push(duMatch[2]);
}
// 匹配未加引号的:--data-urlencode a=b(直到下一个 flag 或结束)
if (urlEncodeItems.length === 0) {
const dataUrlencodeUnquotedRegex = /--data-urlencode\s+([^\s][^]*?)(?=\s--|\s-$|$)/g;
let du2Match: RegExpExecArray | null;
while ((du2Match = dataUrlencodeUnquotedRegex.exec(cleanCurl)) !== null) {
urlEncodeItems.push(du2Match[1].trim());
}
}
// 查找--data-raw(支持双引号/单引号内含转义 \" 的 JSON,逐字符解析确保正确匹配结束引号)
function extractDataRawValue(str: string): string | null {
const idx = str.indexOf('--data-raw');
if (idx === -1) return null;
let part = str.substring(idx + '--data-raw'.length).trim();
if (!part) return null;
if (part.startsWith('"')) {
let result = '';
for (let i = 1; i < part.length; i++) {
if (part[i] === '\\' && i + 1 < part.length) {
result += part[i] + part[i + 1];
i++;
continue;
}
if (part[i] === '"') return result;
result += part[i];
}
return null;
}
if (part.startsWith("'")) {
let result = '';
for (let i = 1; i < part.length; i++) {
if (part[i] === '\\' && i + 1 < part.length) {
result += part[i] + part[i + 1];
i++;
continue;
}
if (part[i] === "'") return result;
result += part[i];
}
return null;
}
const nextFlag = part.search(/\s--/);
return (nextFlag !== -1 ? part.substring(0, nextFlag) : part).trim() || null;
}
// 查找 -d "...." / -d '....'(你的场景经常是 -d " { ... } ")
// 如果 -d 用双引号包裹,但 body 内部包含未转义的双引号,
// 常规按引号截取会过早截断;因此这里优先按 JSON 结构提取。
function extractDashDValue(str: string): string | null {
const dashMatch = /(?:^|\s)-d\s+/i.exec(str);
if (!dashMatch || typeof dashMatch.index !== 'number') return null;
const start = dashMatch.index + dashMatch[0].length;
const part = str.substring(start).trim();
if (!part) return null;
const firstObjIdx = part.indexOf('{');
const lastObjIdx = part.lastIndexOf('}');
if (firstObjIdx !== -1 && lastObjIdx !== -1 && lastObjIdx > firstObjIdx) {
return part.substring(firstObjIdx, lastObjIdx + 1).trim();
}
const firstArrIdx = part.indexOf('[');
const lastArrIdx = part.lastIndexOf(']');
if (firstArrIdx !== -1 && lastArrIdx !== -1 && lastArrIdx > firstArrIdx) {
return part.substring(firstArrIdx, lastArrIdx + 1).trim();
}
return null;
}
const extracted = extractDataRawValue(cleanCurl);
if (extracted !== null) {
bodyData = extracted.replace(/\\"/g, '"');
}
if (!bodyData) {
const extractedDashD = extractDashDValue(cleanCurl);
if (extractedDashD) {
bodyData = extractedDashD;
}
}
// 若仍未解析到 body,尝试 --data(非 --data-raw)
if (!bodyData && !cleanCurl.includes('--data-raw')) {
const dataIndex = cleanCurl.indexOf('--data');
if (dataIndex !== -1) {
const dataPart = cleanCurl.substring(dataIndex + '--data'.length).trim();
if (dataPart.startsWith("'") || dataPart.startsWith('"')) {
const quote = dataPart.charAt(0);
const endQuoteIndex = dataPart.indexOf(quote, 1);
if (endQuoteIndex !== -1) {
bodyData = dataPart.substring(1, endQuoteIndex);
}
}
}
}
// 如果检测到任何 data 参数,但未显式指定 method,则 curl 实际上会变成 POST
const hasAnyData =
urlEncodeItems.length > 0 ||
cleanCurl.includes('--data-raw') ||
cleanCurl.includes('--data ') ||
cleanCurl.includes('--data=') ||
cleanCurl.includes('-d ') ||
cleanCurl.includes('-d"') ||
cleanCurl.includes("-d'") ||
cleanCurl.includes('--data-urlencode');
if (!methodMatch && hasAnyData) {
selectedMethod.value = 'POST';
}
if (urlEncodeItems.length > 0) {
// --data-urlencode 默认就是 x-www-form-urlencoded
bodyConfig.bodyType = 'x-www-form-urlencoded';
const formData: any[] = [];
urlEncodeItems.forEach((pair) => {
// 常见形式:key=value
const eqIndex = pair.indexOf('=');
if (eqIndex === -1) {
// 兜底:无法拆分时塞到 raw
formData.push({
id: random(0, 2099999999),
name: pair,
type: 1,
value: '',
description: ''
});
return;
}
const key = pair.substring(0, eqIndex);
const value = pair.substring(eqIndex + 1);
formData.push({
id: random(0, 2099999999),
name: key,
type: 1,
value: value,
description: ''
});
});
urlEncodedTableProps.data = formData;
} else if (bodyData) {
// 根据 Content-Type 或内容推断 bodyType
let inferredType: 'json' | 'xml' | 'x-www-form-urlencoded' | 'form-data' | 'raw' = 'raw';
if (contentType.includes('application/json')) {
inferredType = 'json';
} else if (contentType.includes('application/xml') || contentType.includes('text/xml')) {
inferredType = 'xml';
} else if (contentType.includes('application/x-www-form-urlencoded')) {
inferredType = 'x-www-form-urlencoded';
} else if (contentType.includes('multipart/form-data')) {
inferredType = 'form-data';
} else {
// 无 Content-Type 时根据内容推断
const trimmed = bodyData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON5.parse(trimmed);
inferredType = 'json';
} catch {
inferredType = 'raw';
}
} else if (trimmed.startsWith('<?xml') || (trimmed.startsWith('<') && /^<[\w-]+[\s>]/.test(trimmed))) {
inferredType = 'xml';
} else if (trimmed.includes('=') && !trimmed.startsWith('<')) {
try {
const params = new URLSearchParams(trimmed);
if (params.toString()) inferredType = 'x-www-form-urlencoded';
} catch {
/* keep raw */
}
}
}
if (inferredType === 'json') {
bodyConfig.bodyType = 'json';
bodyConfig.body = processInParamsData(bodyData);
} else if (inferredType === 'xml') {
bodyConfig.bodyType = 'xml';
bodyConfig.body = bodyData;
} else if (inferredType === 'x-www-form-urlencoded') {
bodyConfig.bodyType = 'x-www-form-urlencoded';
try {
const formData: any[] = [];
const params = new URLSearchParams(bodyData);
params.forEach((value, key) => {
formData.push({
id: random(0, 2099999999),
name: key,
type: 1,
value: value,
description: ''
});
});
urlEncodedTableProps.data = formData;
} catch {
bodyConfig.bodyType = 'raw';
bodyConfig.rawBody = bodyData;
}
} else if (inferredType === 'form-data') {
bodyConfig.bodyType = 'form-data';
bodyConfig.body = bodyData;
} else {
bodyConfig.bodyType = 'raw';
bodyConfig.rawBody = bodyData;
}
} else {
bodyConfig.bodyType = 'none';
}
// 切换到Request tab
requestTab.value = 'request';
Message.success('cURL命令导入成功!');
// 兜底再覆盖一次(防止后续解析步骤改动 query)
applyQueryCodeOverrides();
refreshTabCounts();
} catch (error) {
Message.error(`cURL命令解析失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 导入 cURL(打开 Modal 让用户粘贴)
const importCurl = () => {
const curlInput = ref('');
Modal.confirm({
title: '导入 cURL 命令',
content: () => h('div', [
h('p', { style: 'margin-bottom: 16px' }, '请粘贴完整的 cURL 命令:'),
h('textarea', {
value: curlInput.value,
onInput: (e: any) => curlInput.value = e.target.value,
style: {
width: '100%',
height: '200px',
padding: '8px',
border: '1px solid var(--color-border-2)',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '12px'
},
placeholder: `curl --location --request POST "https://api.example.com/endpoint" \\
--header "Content-Type: application/json" \\
--header "Authorization: Bearer token" \\
--data-raw '{"key": "value"}'`
})
]),
okText: '导入',
cancelText: '取消',
width: 600,
onOk: () => {
if (!curlInput.value.trim()) {
Message.warning('请输入 cURL 命令');
return false;
}
parseCurlCommand(curlInput.value.trim());
return true;
},
onCancel: () => {
// Modal.confirm() 返回的对象没有 destroy 方法,直接关闭即可
}
});
};
/**
* 开发态:把已知外域 URL 改写到 Vite 代理,避免浏览器 CORS。
* - api.nanafa.com → /nanafa/...
* - 旧 itfx IP → /api/...
* - lsp.globex.cn → /external/...
*/
function rewriteDebuggerUrlForProxy(rawUrl: string): string {
const url = String(rawUrl || '').trim();
if (!url) return url;
// http(s)://api.nanafa.com/... → /nanafa/...
if (/^https?:\/\/api\.nanafa\.com(?::\d+)?(\/|$)/i.test(url)) {
return url.replace(/^https?:\/\/api\.nanafa\.com(?::\d+)?/i, '/nanafa');
}
// 旧 itfx 直连 IP
if (url.startsWith('http://43.139.210.35:8888')) {
return url.replace('http://43.139.210.35:8888', '/api');
}
// 示例外部服务商
if (url.startsWith('https://lsp.globex.cn/') || url === 'https://lsp.globex.cn') {
return url.replace('https://lsp.globex.cn', '/external');
}
return url;
}
// 发送请求(参考上游接口的实现)
const sendRequest = async () => {
if (!apiUrl.value.trim()) {
$baseMessage('请输入API地址', 'warning');
return;
}
isLoading.value = true;
const startTime = Date.now();
let requestUrl = '';
try {
// 构建URL参数 - 从表格数据中获取
const urlParams = new URLSearchParams();
const queryTableData = queryTableRef.value?.getTableData?.()?.fullData || queryTableRef.value?.xGrid?.getTableData()?.fullData || queryTableProps.data || [];
queryTableData.forEach((item: any) => {
if (item.name && item.value) {
urlParams.append(item.name, item.value);
}
});
const url = urlParams.toString()
? `${apiUrl.value}?${urlParams.toString()}`
: apiUrl.value;
// 构建请求头 - 从表格数据中获取
const headers: Record<string, string> = {};
const headerTableData = headerTableRef.value?.getTableData?.()?.fullData || headerTableRef.value?.xGrid?.getTableData()?.fullData || headerTableProps.data || [];
// 浏览器会禁止设置部分“unsafe headers”,如果放进 fetch headers 里可能直接失败
const forbiddenHeaderNames = new Set<string>([
'user-agent',
'referer',
'origin',
'host',
'content-length',
'connection',
// chrome client hints(从 curl 导入时经常会带)
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
]);
headerTableData.forEach((item: any) => {
if (item.name) {
const name = String(item.name || '').trim();
const lower = name.toLowerCase();
if (forbiddenHeaderNames.has(lower)) return;
// Header 参数暂时只使用 name,value 需要后续添加
headers[name] = item.value || '';
}
});
// 构建请求体
let body: string | FormData | URLSearchParams | null = null;
if (['POST', 'PUT', 'PATCH'].includes(selectedMethod.value)) {
if (bodyConfig.bodyType === 'json') {
body = stripCommentsForRequest(bodyConfig.body);
headers['Content-Type'] = 'application/json';
} else if (bodyConfig.bodyType === 'xml') {
body = stripCommentsForRequest(bodyConfig.body);
headers['Content-Type'] = 'application/xml';
} else if (bodyConfig.bodyType === 'x-www-form-urlencoded') {
const usp = new URLSearchParams();
const urlEncodedTableData = urlEncodedTableRef.value?.getTableData?.()?.fullData || urlEncodedTableRef.value?.xGrid?.getTableData()?.fullData || urlEncodedTableProps.data || [];
urlEncodedTableData.forEach((field: any) => {
if (field.name && field.value) {
usp.append(field.name, field.value);
}
});
body = usp;
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
} else if (bodyConfig.bodyType === 'form-data') {
const formData = new FormData();
const formDataTableData = formDataTableRef.value?.getTableData?.()?.fullData || formDataTableRef.value?.xGrid?.getTableData()?.fullData || formDataTableProps.data || [];
formDataTableData.forEach((field: any) => {
if (field.name && field.value) {
formData.append(field.name, field.value);
}
});
body = formData;
delete headers['Content-Type'];
} else if (bodyConfig.bodyType === 'raw') {
body = stripCommentsForRequest(bodyConfig.rawBody);
}
}
// 确保GET请求不设置body
if (['GET', 'HEAD', 'OPTIONS'].includes(selectedMethod.value)) {
body = null;
delete headers['Content-Type'];
}
// 发送请求
const fetchOptions: RequestInit = {
method: selectedMethod.value,
headers,
};
// 只有非GET请求才设置body
if (body !== null && !['GET', 'HEAD', 'OPTIONS'].includes(selectedMethod.value)) {
fetchOptions.body = body instanceof FormData ? body : body;
} else {
fetchOptions.body = undefined;
}
// 开发环境把已知外域改写到 Vite 代理,避免浏览器 CORS
requestUrl = rewriteDebuggerUrlForProxy(url);
// 仍为绝对跨域 URL 时提示(未覆盖的域名)
try {
if (requestUrl.startsWith('http')) {
const targetOrigin = new URL(requestUrl).origin;
const currentOrigin = window.location.origin;
if (targetOrigin !== currentOrigin) {
Message.warning(`检测到跨域请求,浏览器可能因 CORS 拒绝:${targetOrigin}`);
}
}
} catch {
// ignore
}
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 30000); // 30秒超时
const res = await fetch(requestUrl, {
...fetchOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
const responseText = await res.text();
const endTime = Date.now();
const duration = endTime - startTime;
// 设置响应数据
response.value = {
status: res.status,
statusText: res.statusText,
data: responseText,
duration,
size: responseText.length
};
// 格式化响应数据(尝试 JSON 格式化)
try {
const jsonData = JSON.parse(responseText);
responseData.value = JSON.stringify(jsonData, null, 2);
} catch (e) {
responseData.value = responseText;
}
// 生成 cURL 命令
generateCurlCommand({});
// 切换到 Response tab
responseTab.value = 'response';
$baseMessage(`请求成功!状态码: ${res.status},耗时: ${duration}ms`, 'success');
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
$baseMessage('请求超时(30秒)', 'error');
} else if (error.message.includes('Failed to fetch')) {
$baseMessage(`网络连接失败/跨域 CORS 拒绝请求:${requestUrl}`, 'error');
} else {
$baseMessage(`请求失败: ${error.message}`, 'error');
}
} else {
$baseMessage('请求失败: 未知错误', 'error');
}
const endTime = Date.now();
const duration = endTime - startTime;
response.value = {
status: 0,
statusText: 'Error',
data: `请求失败: ${error instanceof Error ? error.message : '未知错误'}`,
duration,
size: 0,
};
responseData.value = `请求失败: ${error instanceof Error ? error.message : '未知错误'}`;
} finally {
isLoading.value = false;
}
};
/** cURL 输出:form 字段值若已 URL 编码则先解码,避免 --data-urlencode 二次编码 */
function decodeFormFieldValueForCurl(value: string): string {
const raw = String(value ?? '');
if (!raw.includes('%')) return raw;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function escapeCurlSingleQuoted(value: string): string {
return String(value ?? '').replace(/'/g, `'\\''`);
}
function getBaseUrlWithoutSearch(url: string): string {
try {
const u = new URL(url);
return `${u.origin}${u.pathname}`;
} catch {
const idx = url.indexOf('?');
return idx >= 0 ? url.slice(0, idx) : url;
}
}
// 生成 cURL 命令(Apifox 风格:form 用 --data-urlencode,避免 query/body 重复)
const generateCurlCommand = (_config: any) => {
if (!apiUrl.value) {
curlCommand.value = '';
return;
}
const lineEnd = ' \\';
const method = selectedMethod.value.toUpperCase();
const baseUrl = getBaseUrlWithoutSearch(apiUrl.value);
const queryTableData =
queryTableRef.value?.getTableData?.()?.fullData ||
queryTableRef.value?.xGrid?.getTableData()?.fullData ||
queryTableProps.data ||
[];
const headerTableData =
headerTableRef.value?.getTableData?.()?.fullData ||
headerTableRef.value?.xGrid?.getTableData()?.fullData ||
headerTableProps.data ||
[];
const urlEncodedTableData =
urlEncodedTableRef.value?.getTableData?.()?.fullData ||
urlEncodedTableRef.value?.xGrid?.getTableData()?.fullData ||
urlEncodedTableProps.data ||
[];
const formDataTableData =
formDataTableRef.value?.getTableData?.()?.fullData ||
formDataTableRef.value?.xGrid?.getTableData()?.fullData ||
formDataTableProps.data ||
[];
const isFormUrlEncoded = bodyConfig.bodyType === 'x-www-form-urlencoded';
const bodyFieldNames = new Set(
urlEncodedTableData.filter((f: any) => f.name).map((f: any) => f.name),
);
// form-urlencoded 时,与 body 同名的参数不应再拼到 URL(避免 ?sign=... 与 body 重复)
const urlQueryItems = queryTableData.filter((item: any) => {
if (!item.name || item.value === undefined || item.value === null || item.value === '') {
return false;
}
if (isFormUrlEncoded && bodyFieldNames.has(item.name)) return false;
return true;
});
let urlForCurl = baseUrl;
if (urlQueryItems.length > 0) {
const qs = urlQueryItems
.map(
(item: any) =>
`${encodeURIComponent(item.name)}=${encodeURIComponent(item.value)}`,
)
.join('&');
urlForCurl = `${baseUrl}?${qs}`;
}
let curl = `curl --location '${escapeCurlSingleQuoted(urlForCurl)}'`;
const hasFormBody =
isFormUrlEncoded &&
urlEncodedTableData.some((f: any) => f.name && f.value !== undefined && f.value !== null && f.value !== '');
const hasJsonOrXmlBody =
(bodyConfig.bodyType === 'json' || bodyConfig.bodyType === 'xml') && bodyConfig.body;
const hasRawBody = bodyConfig.bodyType === 'raw' && bodyConfig.rawBody;
const hasMultipartBody =
bodyConfig.bodyType === 'form-data' &&
formDataTableData.some((f: any) => f.name && f.value);
// POST 且带 body 时不写 --request(与 Apifox 导出一致);其余方法显式声明
if (method !== 'GET' && !(method === 'POST' && (hasFormBody || hasJsonOrXmlBody || hasRawBody || hasMultipartBody))) {
curl += `${lineEnd}\n--request ${method}`;
}
headerTableData.forEach((item: any) => {
if (item.name) {
curl += `${lineEnd}\n--header '${escapeCurlSingleQuoted(item.name)}: ${escapeCurlSingleQuoted(item.value || '')}'`;
}
});
if (['POST', 'PUT', 'PATCH'].includes(selectedMethod.value)) {
if (bodyConfig.bodyType === 'json' && bodyConfig.body) {
const bodyForCurl = stripCommentsForRequest(bodyConfig.body);
curl += `${lineEnd}\n--data-raw '${escapeCurlSingleQuoted(bodyForCurl)}'`;
} else if (bodyConfig.bodyType === 'xml' && bodyConfig.body) {
const bodyForCurl = stripCommentsForRequest(bodyConfig.body);
curl += `${lineEnd}\n--data-raw '${escapeCurlSingleQuoted(bodyForCurl)}'`;
} else if (isFormUrlEncoded) {
urlEncodedTableData
.filter((f: any) => f.name)
.forEach((f: any) => {
const decoded = decodeFormFieldValueForCurl(String(f.value ?? ''));
const pair = `${f.name}=${decoded}`;
curl += `${lineEnd}\n--data-urlencode '${escapeCurlSingleQuoted(pair)}'`;
});
} else if (bodyConfig.bodyType === 'form-data') {
formDataTableData
.filter((f: any) => f.name && f.value)
.forEach((f: any) => {
curl += `${lineEnd}\n--form '${escapeCurlSingleQuoted(`${f.name}=${f.value}`)}'`;
});
} else if (bodyConfig.bodyType === 'raw' && bodyConfig.rawBody) {
const bodyForCurl = stripCommentsForRequest(bodyConfig.rawBody);
curl += `${lineEnd}\n--data-raw '${escapeCurlSingleQuoted(bodyForCurl)}'`;
}
}
curlCommand.value = curl;
};
// 参数类型选项
const paramTypes = ['string', 'number', 'boolean', 'object', 'array'];
// 复制返回体内容
const copyResponseContent = async () => {
let content = '';
if (responseTab.value === 'response') {
content = responseData.value;
} else if (responseTab.value === 'curl') {
content = curlCommand.value;
}
if (!content) {
Message.warning('暂无内容可复制');
return;
}
try {
await navigator.clipboard.writeText(content);
Message.success('已复制到剪贴板');
} catch (error) {
// 降级方案:使用传统方法
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
Message.success('已复制到剪贴板');
} catch (err) {
Message.error('复制失败');
}
document.body.removeChild(textArea);
}
};
/** 从响应 JSON 中递归取 rqId(兼容大小写 / requestId) */
function extractRqIdFromResponse(text: string): string {
const raw = String(text || '').trim();
if (!raw) return '';
const pick = (obj: any): string => {
if (obj == null || typeof obj !== 'object') return '';
const preferred = ['rqId', 'RqId', 'RQId', 'requestId', 'RequestId'];
for (const key of preferred) {
if (obj[key] != null && String(obj[key]).trim()) {
return String(obj[key]).trim();
}
}
for (const [k, v] of Object.entries(obj)) {
if (/^(rqid|requestid)$/i.test(k) && v != null && String(v).trim()) {
return String(v).trim();
}
}
for (const v of Object.values(obj)) {
if (v && typeof v === 'object') {
const hit = pick(v);
if (hit) return hit;
}
}
return '';
};
try {
const hit = pick(JSON.parse(raw));
if (hit) return hit;
} catch {
/* fall through */
}
const m =
raw.match(/"rqId"\s*:\s*"([^"]+)"/i) ||
raw.match(/'rqId'\s*:\s*'([^']+)'/i) ||
raw.match(/"requestId"\s*:\s*"([^"]+)"/i);
return m?.[1]?.trim() || '';
}
const requestLogVisible = ref(false);
const viewPacketRqId = ref('');
function openRequestLogByRqId() {
const rqId = extractRqIdFromResponse(responseData.value);
if (!rqId) {
Message.warning('响应中未找到 rqId,无法查看报文');
return;
}
viewPacketRqId.value = rqId;
requestLogVisible.value = true;
}
// Body 表单字段管理(使用 Query 样式)
async function addUrlEncodedField() {
try {
const record = {
id: random(0, 2099999999),
name: '',
type: 1,
value: '',
description: '',
};
await nextTick();
const { row: newRow } = await urlEncodedTableRef.value!.addRow(record);
await nextTick();
await urlEncodedTableRef.value?.editRowEvent(newRow);
refreshTabCounts();
} catch {
// 静默忽略
}
}
async function deleteUrlEncodedRow(row: any) {
await urlEncodedTableRef.value?.removeRow(row);
refreshTabCounts();
}
async function addFormField() {
try {
const record = {
id: random(0, 2099999999),
name: '',
type: 1,
value: '',
description: '',
};
await nextTick();
const { row: newRow } = await formDataTableRef.value!.addRow(record);
await nextTick();
await formDataTableRef.value?.editRowEvent(newRow);
refreshTabCounts();
} catch {
// 静默忽略
}
}
async function deleteFormDataRow(row: any) {
await formDataTableRef.value?.removeRow(row);
refreshTabCounts();
}
// 格式化请求体内容
const formatBody = () => {
if (bodyConfig.bodyType === 'json' && bodyConfig.body) {
try {
const parsed = JSON.parse(bodyConfig.body);
bodyConfig.body = JSON.stringify(parsed, null, 2);
Message.success('JSON 格式化成功');
} catch (error) {
Message.error('JSON 格式错误,无法格式化');
}
} else if (bodyConfig.bodyType === 'xml' && bodyConfig.body) {
try {
// 简单的 XML 格式化(使用 DOMParser)
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(bodyConfig.body, 'text/xml');
if (xmlDoc.documentElement.nodeName === 'parsererror') {
Message.error('XML 格式错误,无法格式化');
return;
}
const serializer = new XMLSerializer();
const formatted = serializer.serializeToString(xmlDoc);
// 使用简单的缩进格式化
bodyConfig.body = formatXml(formatted);
Message.success('XML 格式化成功');
} catch (error) {
Message.error('XML 格式错误,无法格式化');
}
} else {
Message.warning('当前内容类型不支持格式化');
}
};
// 复制请求体内容(json/xml)
const copyRequestBodyContent = async () => {
const content = bodyConfig.body || '';
if (!content) {
Message.warning('暂无内容可复制');
return;
}
try {
await navigator.clipboard.writeText(content);
Message.success('已复制到剪贴板');
} catch (error) {
// 降级方案:使用传统方法
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
Message.success('已复制到剪贴板');
} catch (err) {
Message.error('复制失败');
}
document.body.removeChild(textArea);
}
};
// XML 格式化辅助函数
function formatXml(xml: string): string {
let formatted = '';
let indent = '';
const tab = ' ';
xml.split(/>\s*</).forEach((node) => {
if (node.match(/^\/\w/)) indent = indent.substring(tab.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^\/]$/) && !node.startsWith('<?xml')) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
}
defineExpose({
importCurlFromString: (curl: string) => {
if (curl && curl.trim()) parseCurlCommand(curl.trim());
},
});
</script>
<template>
<div class="api-test-container">
<!-- 顶部控制栏 -->
<div class="api-test-header">
<a-select
v-model="selectedMethod"
:class="['method-select', `method-${String(selectedMethod || '').toLowerCase()}`]"
:style="{ width: '120px' }"
placeholder="请求方法"
>
<a-option
v-for="method in httpMethods"
:key="method"
:value="method"
:class="`method-${method.toLowerCase()}`"
>
{{ method }}
</a-option>
</a-select>
<a-input
v-model="apiUrl"
class="url-input"
placeholder="接口路径"
/>
<div class="header-actions">
<a-button size="small" @click="importCurl">
导入 cURL
</a-button>
<a-button
size="small"
type="primary"
:loading="isLoading"
:disabled="isLoading"
@click="sendRequest"
>
发送
</a-button>
</div>
</div>
<!-- 主体内容区域 -->
<div class="api-test-body" :class="`layout-${panelLayout}`">
<!-- 左侧:请求体 -->
<div class="api-test-request">
<!-- Request 容器(使用 InterfaceDetail 的结构,包含 Query、Request、Header) -->
<div class="detail-section section-request">
<div class="section-header">
<a-tabs
v-model:active-key="requestTab"
type="line"
size="mini"
direction="horizontal"
class="request-header-tabs"
>
<a-tab-pane key="query">
<template #title>Query ({{ queryTabCount }})</template>
</a-tab-pane>
<a-tab-pane key="request">
<template #title>Body ({{ bodyTabCount }})</template>
</a-tab-pane>
<a-tab-pane key="header">
<template #title>Header ({{ headerTabCount }})</template>
</a-tab-pane>
</a-tabs>
<div class="section-header-right">
<div class="section-actions">
<a-button
v-if="requestTab === 'query'"
size="small"
@click="addNewQueryParam"
>
新增
</a-button>
<a-button
v-if="requestTab === 'request' && bodyConfig.bodyType === 'x-www-form-urlencoded'"
size="small"
@click="addUrlEncodedField"
>
新增
</a-button>
<a-button
v-if="requestTab === 'request' && bodyConfig.bodyType === 'form-data'"
size="small"
@click="addFormField"
>
新增
</a-button>
<a-button
v-if="requestTab === 'header'"
size="small"
@click="addNewHeaderParam"
>
新增
</a-button>
</div>
<div class="header-switches">
<a-switch
class="layout-switch"
:model-value="panelLayout === 'vertical'"
checked-text="上下"
unchecked-text="左右"
title="切换请求 / 响应布局"
@change="onPanelLayoutSwitch"
/>
</div>
</div>
</div>
<div class="section-content">
<div
v-if="requestTab === 'request' || requestEmptyTip"
class="body-type-selector"
>
<a-radio-group
v-if="requestTab === 'request'"
v-model="bodyConfig.bodyType"
type="button"
size="small"
class="body-type-radio"
>
<a-radio value="none">none</a-radio>
<a-radio value="json">json</a-radio>
<a-radio value="xml">xml</a-radio>
<a-radio value="x-www-form-urlencoded">x-www-form-urlencoded</a-radio>
<a-radio value="form-data">form-data</a-radio>
</a-radio-group>
<div
v-if="requestEmptyTip"
class="content-type-empty-tip"
:title="requestEmptyTip"
>
{{ requestEmptyTip }}
</div>
<div
v-if="requestTab === 'request' && (bodyConfig.bodyType === 'json' || bodyConfig.bodyType === 'xml')"
class="body-actions"
>
<a-button size="small" @click="copyRequestBodyContent">复制</a-button>
<a-button size="small" @click="formatBody">格式化</a-button>
</div>
</div>
<!-- Query tab 内容 -->
<div v-if="requestTab === 'query'" class="table-content">
<TablePro
ref="queryTableRef"
v-bind="queryTableProps"
:row-config="{ isHover: true }"
:mouse-config="{
area: false,
}"
class="query-params-table"
>
<template #name="{ row }">
<template v-if="isQueryRowEditing(row)">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<span v-else>{{ row.name || '-' }}</span>
</template>
<template #name_edit="{ row }">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<template #type="{ row }">
<template v-if="isQueryRowEditing(row)">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<span v-else>{{ getParameterTypeName(row.type) || '-' }}</span>
</template>
<template #type_edit="{ row }">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<template #value="{ row }">
<template v-if="isQueryRowEditing(row)">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<span v-else>{{ row.value || '-' }}</span>
</template>
<template #value_edit="{ row }">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<template #description="{ row }">
<template v-if="isQueryRowEditing(row)">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<span v-else>{{ row.description || '-' }}</span>
</template>
<template #description_edit="{ row }">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<template #action="{ row }">
<a-space>
<a-button type="text" class="row-action-btn" title="删除" @click="deleteQueryRow(row)">
<IconDelete />
</a-button>
</a-space>
</template>
</TablePro>
</div>
<!-- Request tab 内容(复制自上游接口的 body tab) -->
<div v-if="requestTab === 'request'" class="config-content">
<div v-if="bodyConfig.bodyType === 'json'" class="body-editor">
<CodeMirror
v-model="bodyConfig.body"
language="json"
:style="{ height: '100%', width: '100%' }"
/>
</div>
<div v-else-if="bodyConfig.bodyType === 'xml'" class="body-editor">
<CodeMirror
v-model="bodyConfig.body"
language="xml"
:style="{ height: '100%', width: '100%' }"
/>
</div>
<div v-else-if="bodyConfig.bodyType === 'x-www-form-urlencoded'" class="table-content">
<TablePro
ref="urlEncodedTableRef"
v-bind="urlEncodedTableProps"
:row-config="{ isHover: true }"
:mouse-config="{
area: false,
}"
class="query-params-table"
>
<template #name="{ row }">
<template v-if="isUrlEncodedRowEditing(row)">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<span v-else>{{ row.name || '-' }}</span>
</template>
<template #name_edit="{ row }">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<template #type="{ row }">
<template v-if="isUrlEncodedRowEditing(row)">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<span v-else>{{ getParameterTypeName(row.type) || '-' }}</span>
</template>
<template #type_edit="{ row }">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<template #value="{ row }">
<template v-if="isUrlEncodedRowEditing(row)">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<span v-else>{{ row.value || '-' }}</span>
</template>
<template #value_edit="{ row }">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<template #description="{ row }">
<template v-if="isUrlEncodedRowEditing(row)">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<span v-else>{{ row.description || '-' }}</span>
</template>
<template #description_edit="{ row }">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<template #action="{ row }">
<a-space>
<a-button type="text" class="row-action-btn" title="删除" @click="deleteUrlEncodedRow(row)">
<IconDelete />
</a-button>
</a-space>
</template>
</TablePro>
</div>
<div v-else-if="bodyConfig.bodyType === 'form-data'" class="table-content">
<TablePro
ref="formDataTableRef"
v-bind="formDataTableProps"
:row-config="{ isHover: true }"
:mouse-config="{
area: false,
}"
class="query-params-table"
>
<template #name="{ row }">
<template v-if="isFormDataRowEditing(row)">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<span v-else>{{ row.name || '-' }}</span>
</template>
<template #name_edit="{ row }">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<template #type="{ row }">
<template v-if="isFormDataRowEditing(row)">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<span v-else>{{ getParameterTypeName(row.type) || '-' }}</span>
</template>
<template #type_edit="{ row }">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<template #value="{ row }">
<template v-if="isFormDataRowEditing(row)">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<span v-else>{{ row.value || '-' }}</span>
</template>
<template #value_edit="{ row }">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<template #description="{ row }">
<template v-if="isFormDataRowEditing(row)">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<span v-else>{{ row.description || '-' }}</span>
</template>
<template #description_edit="{ row }">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<template #action="{ row }">
<a-space>
<a-button type="text" class="row-action-btn" title="删除" @click="deleteFormDataRow(row)">
<IconDelete />
</a-button>
</a-space>
</template>
</TablePro>
</div>
<div v-else-if="bodyConfig.bodyType === 'raw'" class="raw-editor">
<a-textarea
v-model="bodyConfig.rawBody"
placeholder="请输入JSON或其他格式的请求体"
:rows="10"
class="raw-textarea"
/>
</div>
</div>
<!-- Header tab 内容(使用 Query 的样式) -->
<div v-if="requestTab === 'header'" class="table-content">
<TablePro
ref="headerTableRef"
v-bind="headerTableProps"
:row-config="{ isHover: true }"
:mouse-config="{
area: false,
}"
class="query-params-table"
>
<template #name="{ row }">
<template v-if="isHeaderRowEditing(row)">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<span v-else>{{ row.name || '-' }}</span>
</template>
<template #name_edit="{ row }">
<a-input
v-model="row.name"
allow-clear
placeholder="请输入参数名"
/>
</template>
<template #type="{ row }">
<template v-if="isHeaderRowEditing(row)">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<span v-else>{{ getParameterTypeName(row.type) || '-' }}</span>
</template>
<template #type_edit="{ row }">
<a-select
v-model="row.type"
placeholder="请选择类型"
>
<a-option
v-for="option in ParameterTypeList"
:key="option.key"
:value="option.key"
>
{{ option.value }}
</a-option>
</a-select>
</template>
<template #value="{ row }">
<template v-if="isHeaderRowEditing(row)">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<span v-else>{{ row.value || '-' }}</span>
</template>
<template #value_edit="{ row }">
<a-input
v-model="row.value"
allow-clear
placeholder="请输入参数值"
/>
</template>
<template #description="{ row }">
<template v-if="isHeaderRowEditing(row)">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<span v-else>{{ row.description || '-' }}</span>
</template>
<template #description_edit="{ row }">
<a-input
v-model="row.description"
allow-clear
placeholder="请输入描述"
/>
</template>
<template #action="{ row }">
<a-space>
<a-button type="text" class="row-action-btn" title="删除" @click="deleteHeaderRow(row)">
<IconDelete />
</a-button>
</a-space>
</template>
</TablePro>
</div>
</div>
</div>
</div>
<!-- 右侧:返回体(使用请求体的样式) -->
<div class="detail-section section-response">
<div class="section-header">
<a-tabs
v-model:active-key="responseTab"
type="line"
size="mini"
direction="horizontal"
class="request-header-tabs"
>
<a-tab-pane key="response" title="Response" />
<a-tab-pane key="curl" title="cURL" />
</a-tabs>
<div class="response-status" v-if="responseStatusText">
<span :class="response?.status >= 200 && response?.status < 300 ? 'status-success' : 'status-error'">
{{ responseStatusText }}
</span>
</div>
<div class="section-actions">
<a-button
size="small"
@click="copyResponseContent"
>
复制
</a-button>
<a-button
size="small"
:disabled="!responseData"
@click="openRequestLogByRqId"
>
查看报文
</a-button>
</div>
</div>
<div class="section-content">
<!-- Response tab 内容 -->
<div v-if="responseTab === 'response'" class="response-container">
<CodeMirror
v-model="responseData"
language="json"
:read-only="true"
:style="{ height: '100%', width: '100%' }"
/>
</div>
<!-- cURL tab 内容 -->
<div v-if="responseTab === 'curl'" class="response-container">
<CodeMirror
v-model="curlCommand"
language="shell"
:read-only="true"
:style="{ height: '100%', width: '100%' }"
/>
</div>
</div>
</div>
</div>
</div>
<a-modal
v-model:visible="requestLogVisible"
title="查看报文"
:width="'92vw'"
:footer="false"
unmount-on-close
class="api-test-request-log-modal"
:body-style="{ padding: '12px 16px 16px' }"
>
<div class="request-log-modal-tip">
已用响应中的 <code>rqId</code> 预填请求日志并查询:{{ viewPacketRqId }}
</div>
<RequestLog
v-if="requestLogVisible"
:key="viewPacketRqId"
:initial-request-id="viewPacketRqId"
embedded
/>
</a-modal>
</template>
<style lang="less" scoped>
.api-test-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
background: var(--app-card-bg);
overflow: hidden;
box-sizing: border-box;
:deep(.arco-btn) {
border-radius: 4px;
}
:deep(.arco-btn-primary) {
background-color: var(--app-primary-color);
border-color: var(--app-primary-color);
&:hover,
&:focus {
background-color: var(--app-primary-color);
border-color: var(--app-primary-color);
opacity: 0.9;
}
}
:deep(.arco-input-wrapper:focus-within),
:deep(.arco-select-view-focus),
:deep(.arco-textarea-focus) {
border-color: var(--app-primary-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-primary-color) 14%, transparent);
}
:deep(.arco-input-wrapper .arco-input),
:deep(.arco-input-wrapper .arco-input:focus) {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
}
.api-test-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--app-card-bg, #fff);
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
flex-shrink: 0;
box-sizing: border-box;
min-height: 56px;
:deep(.arco-btn) {
height: 32px;
}
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single) {
height: 32px;
border-radius: 4px;
}
:deep(.arco-input) {
height: 30px;
line-height: 30px;
}
:deep(.arco-select-view-value),
:deep(.arco-select-view-input) {
line-height: 30px;
}
}
.url-input {
flex: 1;
min-width: 0;
height: 32px;
}
.header-actions {
display: flex;
align-items: center;
gap: var(--app-button-gap, 8px);
flex-shrink: 0;
}
.api-test-body {
display: flex;
flex: 1;
gap: 16px;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
&.layout-horizontal {
flex-direction: row;
}
&.layout-vertical {
flex-direction: column;
.api-test-request,
.section-response {
flex: 1;
min-height: 0;
height: auto;
}
}
}
.api-test-request {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.detail-section {
display: flex;
flex-direction: column;
background-color: var(--app-card-bg, #fff);
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
border-radius: 8px;
overflow: hidden;
min-height: 0;
}
.section-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border-2) 35%, transparent);
background: var(--app-card-bg, #fff);
flex-shrink: 0;
min-height: 40px;
box-sizing: border-box;
}
.section-header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.header-switches {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
}
.layout-switch {
flex-shrink: 0;
min-width: 58px !important;
width: auto;
height: 28px !important;
line-height: 28px !important;
border-radius: 14px !important;
padding: 0;
:deep(.arco-switch-handle) {
top: 4px;
left: 4px;
width: 20px;
height: 20px;
}
:deep(.arco-switch-text-holder) {
margin: 0 10px 0 30px;
font-size: 12px;
font-weight: 500;
}
:deep(.arco-switch-text) {
left: 30px;
font-size: 12px;
font-weight: 500;
color: var(--color-white);
}
:deep(&.arco-switch-checked) {
background-color: var(--app-primary-color);
}
:deep(&.arco-switch-checked .arco-switch-handle) {
left: calc(100% - 24px);
}
:deep(&.arco-switch-checked .arco-switch-text-holder) {
margin: 0 30px 0 10px;
}
:deep(&.arco-switch-checked .arco-switch-text) {
left: 10px;
color: var(--color-white);
}
}
.section-title {
font-weight: 600;
font-size: 14px;
color: var(--color-text-1);
line-height: 24px;
}
.section-actions {
display: flex;
gap: var(--app-button-gap, 8px);
align-items: center;
min-height: 24px;
}
.request-log-modal-tip {
margin-bottom: 8px;
font-size: 12px;
color: var(--color-text-2);
code {
padding: 0 4px;
color: var(--app-primary-color);
}
}
.response-status {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin: 0 12px;
font-size: 12px;
.status-success {
color: var(--color-success-6, #00b42a);
font-weight: 500;
}
.status-error {
color: var(--color-danger-6, #f53f3f);
font-weight: 500;
}
}
.section-content {
padding: 16px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.config-content {
padding: 0;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.table-content {
flex: 1;
min-height: 0;
overflow: auto;
}
.body-type-selector {
position: relative;
display: flex;
align-items: center;
gap: var(--app-button-gap, 8px);
padding: 0 0 10px var(--vxe-table-cell-padding-left, 10px);
margin-bottom: 0;
min-height: 28px;
box-sizing: border-box;
background: transparent;
flex-shrink: 0;
border: none;
.content-type-empty-tip {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -55%);
z-index: 1;
max-width: min(46%, 360px);
padding: 0 8px;
font-size: 12px;
line-height: 20px;
color: var(--color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
text-align: center;
}
.body-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.body-type-radio {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
:deep(.arco-radio-button) {
margin: 0 !important;
padding: 4px 10px !important;
height: auto !important;
min-height: 28px;
line-height: 20px !important;
font-size: 13px;
letter-spacing: 0.01em;
text-align: center;
color: var(--color-text-2);
background: transparent !important;
border: none !important;
border-radius: 4px !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
&:hover {
color: var(--color-text-1);
background: color-mix(in srgb, var(--color-fill-3) 55%, transparent) !important;
}
}
:deep(.arco-radio-button-content) {
padding: 0 !important;
}
:deep(.arco-radio-checked.arco-radio-button),
:deep(.arco-radio-button.arco-radio-checked) {
color: var(--app-primary-color) !important;
font-weight: 600;
background: color-mix(in srgb, var(--app-primary-color) 12%, transparent) !important;
border: none !important;
box-shadow: none !important;
}
}
}
.body-editor {
flex: 1;
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 280px;
}
.raw-editor {
.raw-textarea {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.5;
resize: both;
height: 400px;
}
}
.query-params-table {
height: 100%;
min-height: 0;
:deep(.vxe-table) {
height: 100%;
--vxe-table-border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent);
border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent) !important;
}
:deep(.vxe-table--body-wrapper) {
max-height: 100%;
overflow-y: auto;
}
}
// 列表:表头白底 + 边框弱化(仅本页)
:deep(.vxe-table) {
--vxe-table-border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent);
border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent) !important;
}
:deep(.vxe-table--header-wrapper),
:deep(.vxe-table--header),
:deep(.vxe-header--row),
:deep(.vxe-header--column) {
background-color: var(--app-card-bg, #fff) !important;
}
:deep(.vxe-header--column),
:deep(.vxe-body--column),
:deep(.vxe-table--border-line) {
border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent) !important;
}
:deep(.vxe-table--header-border-line) {
border-color: color-mix(in srgb, var(--color-border-2) 42%, transparent) !important;
}
.section-request {
flex: 1;
min-height: 0;
height: 100%;
}
.request-header-tabs {
margin-left: 0;
height: 24px;
:deep(.arco-tabs-nav) {
margin-bottom: 0;
min-height: auto;
margin-left: 0;
height: 24px;
}
:deep(.arco-tabs-nav-list) {
display: flex;
flex-direction: row !important;
align-items: center;
}
:deep(.arco-tabs-tab) {
padding: 0 8px;
height: 24px;
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-2);
}
:deep(.arco-tabs-tab-active),
:deep(.arco-tabs-tab-active:hover) {
color: var(--app-primary-color);
}
:deep(.arco-tabs-nav-ink) {
background-color: var(--app-primary-color);
}
:deep(.arco-tabs-nav::before) {
height: 0;
}
}
.section-response {
flex: 1;
min-height: 0;
height: 100%;
}
.table-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
max-height: 100%;
padding: 12px;
overflow: hidden;
box-sizing: border-box;
}
.table-toolbar {
margin-bottom: 12px;
flex-shrink: 0;
}
.response-container {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.method-select {
flex-shrink: 0;
:deep(.arco-select-view) {
font-weight: 600;
}
&.method-get {
:deep(.arco-select-view),
:deep(.arco-select-view-single),
:deep(.arco-select-view-value),
:deep(.arco-select-view-single .arco-select-view-value) {
color: #10b981 !important;
}
}
&.method-post {
:deep(.arco-select-view),
:deep(.arco-select-view-single),
:deep(.arco-select-view-value),
:deep(.arco-select-view-single .arco-select-view-value) {
color: #f59e0b !important;
}
}
&.method-delete {
:deep(.arco-select-view),
:deep(.arco-select-view-single),
:deep(.arco-select-view-value),
:deep(.arco-select-view-single .arco-select-view-value) {
color: #ef4444 !important;
}
}
&.method-put,
&.method-patch,
&.method-head,
&.method-options {
:deep(.arco-select-view),
:deep(.arco-select-view-single),
:deep(.arco-select-view-value),
:deep(.arco-select-view-single .arco-select-view-value) {
color: #6b7280 !important;
}
}
}
.row-action-btn {
color: var(--color-text-3) !important;
&:hover,
&:focus {
color: var(--color-text-1) !important;
background-color: var(--color-fill-2) !important;
}
}
</style>
<style lang="less">
/* 下拉挂到 body,需全局样式恢复请求方式颜色 */
.method-select.method-get {
.arco-select-view,
.arco-select-view-single,
.arco-select-view-value {
color: #10b981 !important;
font-weight: 700 !important;
}
}
.method-select.method-post {
.arco-select-view,
.arco-select-view-single,
.arco-select-view-value {
color: #f59e0b !important;
font-weight: 700 !important;
}
}
.method-select.method-delete {
.arco-select-view,
.arco-select-view-single,
.arco-select-view-value {
color: #ef4444 !important;
font-weight: 700 !important;
}
}
.method-select.method-put,
.method-select.method-patch,
.method-select.method-head,
.method-select.method-options {
.arco-select-view,
.arco-select-view-single,
.arco-select-view-value {
color: #6b7280 !important;
font-weight: 700 !important;
}
}
.arco-select-dropdown {
.arco-select-option {
&.method-get {
color: #10b981 !important;
font-weight: 600;
&.arco-select-option-selected {
font-weight: 700;
}
}
&.method-post {
color: #f59e0b !important;
font-weight: 600;
&.arco-select-option-selected {
font-weight: 700;
}
}
&.method-delete {
color: #ef4444 !important;
font-weight: 600;
&.arco-select-option-selected {
font-weight: 700;
}
}
&.method-put,
&.method-patch,
&.method-head,
&.method-options {
color: #6b7280 !important;
&.arco-select-option-selected {
font-weight: 700;
}
}
}
}
</style>