This commit is contained in:
biybuy 2026-03-17 11:32:53 +08:00
parent 1eaec68f92
commit 897079144c
7 changed files with 551 additions and 299 deletions

View File

@ -742,7 +742,7 @@
console.log('ApiTest: 当前映射关系: 1=xml, 2=form-urlencoded, 3=json, 4=form-data');
console.log('ApiTest: 如果不对,请告诉我接口明细显示什么和应该映射到什么!');
} else {
// 如果没有contentType,默认设置为json,因为我们有inParams数据
// 如果没有contentType,默认设置为json
newBodyType = 'json';
console.log('ApiTest: no contentType found, defaulting bodyType to json');
}
@ -766,93 +766,7 @@
}, 100);
});
// 4. 设置请求容器的Demo数据(从接口对象的 inParams 中读取)
console.log('ApiTest: checking for inParams in interface...');
console.log('ApiTest: newInterface has inParams:', !!newInterface.inParams);
console.log('ApiTest: newInterface.inParams type:', typeof newInterface.inParams);
console.log('ApiTest: current bodyType:', bodyConfig.bodyType);
if (newInterface.inParams) {
console.log('ApiTest: found inParams in interface, processing...');
console.log('ApiTest: inParams length:', newInterface.inParams.length);
console.log('ApiTest: inParams preview:', newInterface.inParams.substring(0, 200));
// 始终将处理后的inParams数据显示在CodeMirror中(用于显示demo数据)
const processedInParams = processInParamsData(newInterface.inParams);
bodyConfig.body = processedInParams;
console.log('ApiTest: always set bodyConfig.body with processed inParams for display, length:', processedInParams.length);
// 根据不同的bodyType设置相应的容器数据(用于请求发送)
if (bodyConfig.bodyType === 'x-www-form-urlencoded') {
// 对于x-www-form-urlencoded,将JSON数据转换为表格数据
console.log('ApiTest: processing for x-www-form-urlencoded bodyType');
try {
const rawData = newInterface.inParams.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\\\/g, '\\').replace(/\\"/g, '"');
console.log('ApiTest: processed raw data length:', rawData.length);
const parsedData = JSON5.parse(rawData);
console.log('ApiTest: parsed JSON successfully');
const flattenedData = flattenJsonToKeyValuePairs(parsedData);
console.log('ApiTest: flattened to', flattenedData.length, 'key-value pairs');
const tableData = flattenedData.map(item => ({
id: random(0, 2099999999),
name: item.key,
type: 1, // string类型
value: String(item.value),
description: item.description || ''
}));
urlEncodedTableProps.data = tableData;
console.log('ApiTest: set urlEncoded table data with', urlEncodedTableProps.data.length, 'items');
console.log('ApiTest: first 3 items:', urlEncodedTableProps.data.slice(0, 3));
console.log('ApiTest: urlEncodedTableProps.data is reactive:', isReactive(urlEncodedTableProps.data));
console.log('ApiTest: current urlEncodedTableProps.data:', urlEncodedTableProps.data);
// 强制更新UI
nextTick(() => {
console.log('ApiTest: nextTick - table data should be updated');
});
} catch (error) {
console.warn('ApiTest: failed to parse inParams for urlEncoded:', error);
console.warn('ApiTest: error details:', error instanceof Error ? error.message : String(error));
}
} else if (bodyConfig.bodyType === 'form-data') {
// 对于form-data,将JSON数据转换为表格数据
console.log('ApiTest: processing for form-data bodyType');
try {
const rawData = newInterface.inParams.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\\\/g, '\\').replace(/\\"/g, '"');
const parsedData = JSON5.parse(rawData);
const flattenedData = flattenJsonToKeyValuePairs(parsedData);
formDataTableProps.data = flattenedData.map(item => ({
id: random(0, 2099999999),
name: item.key,
type: 1, // string类型
value: String(item.value),
description: item.description || ''
}));
console.log('ApiTest: set formData table data with', formDataTableProps.data.length, 'items');
} catch (error) {
console.warn('ApiTest: failed to parse inParams for formData:', error);
}
} else if (bodyConfig.bodyType === 'raw') {
// 对于raw类型,直接显示处理后的数据
console.log('ApiTest: processing for raw bodyType');
bodyConfig.rawBody = processedInParams;
console.log('ApiTest: set bodyConfig.rawBody with processed inParams');
} else {
console.log('ApiTest: bodyType is json/xml/raw, using CodeMirror display');
}
} else {
console.log('ApiTest: no inParams found in interface');
console.log('ApiTest: available fields in newInterface:', Object.keys(newInterface));
}
// 4. 不再从接口明细的 inParams/curl 示例加载 body 数据,body 仅通过「导入cURL」或「调试」按钮导入的 cURL 的 --data-raw 显示
// 5. 设置 Header 和 Query 的默认值(只在启用默认值且为空时设置)
console.log('ApiTest: in watch, enableDefaults:', props.enableDefaults);
@ -958,22 +872,17 @@
}
// 导入 cURL
const importCurl = () => {
// 使用Modal显示输入框让用户输入cURL命令
const curlInput = ref('');
const modalVisible = ref(true);
const parseCurl = (curlCommand: string) => {
// 解析并导入 cURL 命令(可被外部调用)
function parseCurlCommand(curlCommand: string) {
try {
console.log('Parsing cURL command:', curlCommand);
// 清理命令,但保留数据部分的多行结构
let cleanCurl = curlCommand.trim();
// 先处理多行命令的连接(反斜杠+换行符)
// 注意:要先处理 \r\n,再处理 \n;这里统一用 \r?\n
// 先处理多行命令的连接:Linux/Mac 用 \,Windows 用 ^
cleanCurl = cleanCurl.replace(/\\\s*\r?\n\s*/g, ' ');
cleanCurl = cleanCurl.replace(/\^\s*\r?\n\s*/g, ' ');
// 移除多余的空格,但保留数据部分
cleanCurl = cleanCurl.replace(/\s+/g, ' ');
@ -1086,39 +995,52 @@
}
}
// 查找--data-raw
const dataRawIndex = cleanCurl.indexOf('--data-raw');
if (dataRawIndex !== -1) {
// 找到--data-raw的位置
const dataPart = cleanCurl.substring(dataRawIndex + '--data-raw'.length).trim();
console.log('Found --data-raw part:', dataPart.substring(0, 200) + '...');
// 检查是否有引号包围
if (dataPart.startsWith("'") || dataPart.startsWith('"')) {
const quote = dataPart.charAt(0);
const endQuoteIndex = dataPart.indexOf(quote, 1);
if (endQuoteIndex !== -1) {
bodyData = dataPart.substring(1, endQuoteIndex);
console.log('Extracted quoted data-raw:', bodyData.substring(0, 100) + '...');
// 查找--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];
}
} else {
// 没有引号,找到下一个--或行尾
const nextFlagIndex = dataPart.search(/\s--/);
if (nextFlagIndex !== -1) {
bodyData = dataPart.substring(0, nextFlagIndex).trim();
} else {
bodyData = dataPart.trim();
}
console.log('Extracted unquoted data-raw:', bodyData.substring(0, 100) + '...');
return null;
}
} else {
// 检查是否有--data
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;
}
const extracted = extractDataRawValue(cleanCurl);
if (extracted !== null) {
bodyData = extracted.replace(/\\"/g, '"');
console.log('Extracted data-raw:', bodyData.substring(0, 100) + '...');
}
// 若仍未解析到 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();
console.log('Found --data part:', dataPart.substring(0, 50) + '...');
if (dataPart.startsWith("'") || dataPart.startsWith('"')) {
const quote = dataPart.charAt(0);
const endQuoteIndex = dataPart.indexOf(quote, 1);
@ -1126,7 +1048,6 @@
bodyData = dataPart.substring(1, endQuoteIndex);
}
}
console.log('Extracted data:', bodyData.substring(0, 100) + '...');
}
}
@ -1175,15 +1096,48 @@
urlEncodedTableProps.data = formData;
console.log('Set as form-urlencoded from --data-urlencode with', formData.length, 'params');
} else if (bodyData) {
// 根据Content-Type设置bodyType
// 根据 Content-Type 或内容推断 bodyType
let inferredType: 'json' | 'xml' | 'x-www-form-urlencoded' | 'form-data' | 'raw' = 'raw';
if (contentType.includes('application/json')) {
bodyConfig.bodyType = 'json';
// 对JSON数据进行格式化
bodyConfig.body = processInParamsData(bodyData);
console.log('Set as formatted JSON body');
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);
console.log('Set as JSON body (from Content-Type or inferred)');
} else if (inferredType === 'xml') {
bodyConfig.bodyType = 'xml';
bodyConfig.body = bodyData;
console.log('Set as XML body (from Content-Type or inferred)');
} else if (inferredType === 'x-www-form-urlencoded') {
bodyConfig.bodyType = 'x-www-form-urlencoded';
// 解析form data
try {
const formData: any[] = [];
const params = new URLSearchParams(bodyData);
@ -1203,7 +1157,7 @@
bodyConfig.bodyType = 'raw';
bodyConfig.rawBody = bodyData;
}
} else if (contentType.includes('multipart/form-data')) {
} else if (inferredType === 'form-data') {
bodyConfig.bodyType = 'form-data';
bodyConfig.body = bodyData;
console.log('Set as form-data');
@ -1236,10 +1190,12 @@
console.error('cURL parsing error:', error);
Message.error(`cURL命令解析失败: ${error instanceof Error ? error.message : String(error)}`);
}
};
}
// 显示Modal
const modal = Modal.confirm({
// 导入 cURL(打开 Modal 让用户粘贴)
const importCurl = () => {
const curlInput = ref('');
Modal.confirm({
title: '导入 cURL 命令',
content: () => h('div', [
h('p', { style: 'margin-bottom: 16px' }, '请粘贴完整的 cURL 命令:'),
@ -1269,7 +1225,7 @@
Message.warning('请输入 cURL 命令');
return false;
}
parseCurl(curlInput.value.trim());
parseCurlCommand(curlInput.value.trim());
return true;
},
onCancel: () => {
@ -1633,6 +1589,12 @@
});
return formatted.substring(1, formatted.length - 3);
}
defineExpose({
importCurlFromString: (curl: string) => {
if (curl && curl.trim()) parseCurlCommand(curl.trim());
},
});
</script>
<template>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, reactive, watch, nextTick, onMounted, onUnmounted, computed } from 'vue';
import { IconDragArrow, IconPlus, IconDelete } from '@arco-design/web-vue/es/icon';
import { IconDragArrow, IconPlus, IconDelete, IconDown } from '@arco-design/web-vue/es/icon';
import FormPro from '@/components/FormPro/index.vue';
import TablePro from '@/components/TablePro/index.vue';
import CodeMirror from '@/components/CodeMirror/index.vue';
@ -18,6 +18,8 @@
import { postParameterParse, postParameterValidateParameter } from '@/api/ParameterServices/parameter';
import { $baseMessage } from '@/utils';
import JSON5 from 'json5';
import SelectPro from '@/components/SelectPro/index.vue';
import { getMethodText } from '@/views/api/upstreamInterface/utils';
const { t } = useI18n();
@ -103,11 +105,8 @@
let requestSortable: any = null;
let responseSortable: any = null;
// Query tab 切换
const queryTab = ref<'body' | 'fixed'>('body');
// Request tab 切换
const requestTab = ref<'body' | 'demo'>('body');
// Request tab 切换(含 Query、固定配置、Body、cURL)
const requestTab = ref<'query' | 'fixed' | 'body' | 'demo'>('body');
const requestExample = ref<string>(''); // Demo内容
const requestExampleEditorRef = ref<any>(); // Demo编辑器引用
@ -146,18 +145,51 @@
const contentTypeOptions = ref<any[]>([]);
const responseContentTypeOptions = ref<any[]>([]);
const mappedParamTypeOptions = ref<any[]>([]);
const requestMethodOptions = ref<any[]>([]);
// 过滤后的基本表单配置(排除 contentType、responseContentType、mappedParamType,当 showContentTypeSelectors 时由上方栏渲染)
// 只读模式下显示的请求方式文本
const displayRequestMethod = computed(() => {
const method = (props.basicParams as any)?.requestMethod;
return method !== undefined && method !== null ? getMethodText(method) : '';
});
// 过滤后的基本表单配置(排除 requestMethod、requestAddress 由上方 header 渲染;排除 contentType、responseContentType、mappedParamType,当 showContentTypeSelectors 时由上方栏渲染)
const filteredBasicFormItems = computed(() => {
if (!props.showContentTypeSelectors) return props.basicFormItems;
return (props.basicFormItems || []).filter(
(item: any) =>
item?.field !== 'contentType' &&
item?.field !== 'responseContentType' &&
item?.field !== 'mappedParamType'
item?.field !== 'requestMethod' &&
item?.field !== 'requestAddress' &&
(item?.field !== 'contentType' || !props.showContentTypeSelectors) &&
(item?.field !== 'responseContentType' || !props.showContentTypeSelectors) &&
(item?.field !== 'mappedParamType' || !props.showContentTypeSelectors)
);
});
// 基本参数表单项(强制 span=4,使每行放 6 个输入框)
const basicFormItemsForLayout = computed(() => {
return filteredBasicFormItems.value.map((item: any) => ({
...item,
span: 4,
}));
});
// 只读模式下基本参数显示(排除 requestMethod、requestAddress,由上方 header 渲染)
const basicParamsForDisplay = computed(() => {
const params = props.basicParams || {};
const { requestMethod, requestAddress, ...rest } = params as Record<string, any>;
return rest;
});
// 加载请求方式选项(用于 header 中的请求方式下拉)
async function loadRequestMethodOptions() {
try {
const res = await getDictionaryGetList({ type: 'RequestMethod' });
requestMethodOptions.value = Array.isArray(res) ? res : ((res as any)?.data || []);
} catch (e) {
console.error('加载请求方式选项失败:', e);
}
}
// 加载请求类型、返回类型、根节点选项
async function loadContentTypeOptions() {
if (!props.showContentTypeSelectors) return;
@ -193,12 +225,32 @@
return typeTagColors[index % typeTagColors.length];
}
// 请求/返回类型标签样式类(每个类型独立颜色)
const contentTypeColorClasses = ['content-type-json', 'content-type-xml', 'content-type-form', 'content-type-raw', 'content-type-purple', 'content-type-cyan', 'content-type-pink'];
function getContentTypeTagClass(value: any, index: number = 0) {
const v = (value || '').toString().toLowerCase();
if (v.includes('json')) return 'content-type-json';
if (v.includes('xml')) return 'content-type-xml';
if (v.includes('form') || v.includes('urlencoded')) return 'content-type-form';
if (v.includes('raw')) return 'content-type-raw';
return contentTypeColorClasses[4 + (index % 3)]; // purple, cyan, pink 循环
}
// 根据参数类型 key 获取颜色(用于参数类型列)
function getParameterTypeColor(typeKey: any) {
const idx = ParameterTypeList.value.findIndex((item: any) => item.key === typeKey);
return idx >= 0 ? getTypeTagColor(idx) : 'gray';
}
// 根节点选中项的显示信息(用于下拉框选中后显示带颜色的标签)
const selectedMappedParamOption = computed(() => {
const key = props.basicFormData?.mappedParamType;
if (key === undefined || key === null) return null;
const idx = mappedParamTypeOptions.value.findIndex((item: any) => item.key === key);
const opt = mappedParamTypeOptions.value[idx];
return opt ? { label: opt.value, color: getTypeTagColor(idx) } : null;
});
// Query 表格配置(使用下游接口的配置方式)
const queryTableProps = reactive<any>({
columns: [
@ -255,7 +307,7 @@
},
});
// Query 固定配置表格
// Query 固定配置表格(不传 height,随内容自然增高,与 Query 列表的固定高度区分)
const queryFixedTableProps = computed(() => ({
columns: [
{
@ -289,7 +341,7 @@
showToolbar: false,
keepSource: true,
tableConfig: {
height: '100%',
// 不传 height,vxe-table 的 height:'auto' 是铺满父容器,会固定高度;不传则随内容增高
data: fixedParams.value,
editConfig: {
trigger: 'click',
@ -301,9 +353,7 @@
showIndex: false,
useForm: false,
size: 'small',
minHeight: 150,
autoHeight: false,
height: '100%',
editRules: {},
}));
@ -482,7 +532,7 @@
}
// 监听Query tab切换,检查是否有未保存的更改
watch(queryTab, async (newTab, oldTab) => {
watch(requestTab, async (newTab, oldTab) => {
// 如果从固定配置tab切换到其他tab,检查是否有未保存的更改
if (oldTab === 'fixed' && newTab !== 'fixed') {
if (checkFixedConfigChanges()) {
@ -509,7 +559,7 @@
if (!result) {
// 如果保存失败,回到原来的tab
queryTab.value = oldTab;
requestTab.value = oldTab;
return;
}
}
@ -521,6 +571,13 @@
}
});
// 当 showQuery 为 false 时,若当前在 query/fixed tab,切回 body
watch(() => props.showQuery, (val) => {
if (!val && (requestTab.value === 'query' || requestTab.value === 'fixed')) {
requestTab.value = 'body';
}
});
// 监听 requestParams prop 变化,同步到表格数据
watch(
() => props.requestParams,
@ -753,8 +810,8 @@
// 固定配置:新增
async function addFixedParam() {
if (queryTab.value !== 'fixed') {
queryTab.value = 'fixed';
if (requestTab.value !== 'fixed') {
requestTab.value = 'fixed';
await nextTick();
}
const record = {
@ -1092,6 +1149,12 @@
return String(inParams);
}
const trimmed = inParams.trim();
// 非 JSON 内容(如 cURL、XML 等)直接返回,不尝试解析
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return inParams;
}
try {
// 步骤1: 处理转义字符 - 将字面量的转义序列转换为实际字符
let processed = inParams;
@ -1154,6 +1217,7 @@
if (props.showContentTypeSelectors) {
loadContentTypeOptions();
}
loadRequestMethodOptions();
});
onUnmounted(() => {
@ -1720,9 +1784,45 @@
<template>
<div class="interface-detail">
<!-- 请求方式 + 请求地址(独立容器,参考 ApiTest api-test-header) -->
<div class="api-test-header">
<a-select
v-if="useFormEdit"
:model-value="basicFormData?.requestMethod"
:class="['method-select', `method-${(getMethodText(basicFormData?.requestMethod) || 'get').toLowerCase()}`]"
placeholder="请求方法"
:style="{ width: '120px' }"
allow-clear
@update:model-value="emit('update:basicFormData', { ...basicFormData, requestMethod: $event })"
>
<a-option
v-for="opt in requestMethodOptions"
:key="opt.key"
:value="opt.key"
:class="`method-${(opt.value || '').toLowerCase()}`"
>
{{ opt.value }}
</a-option>
</a-select>
<span v-else class="method-badge" :class="`method-${(displayRequestMethod || 'get').toLowerCase()}`">
{{ displayRequestMethod || '-' }}
</span>
<a-input
v-if="useFormEdit"
:model-value="basicFormData?.requestAddress"
placeholder="接口路径"
class="url-input"
@update:model-value="emit('update:basicFormData', { ...basicFormData, requestAddress: $event })"
/>
<span v-else class="url-text">{{ (basicParams as any)?.requestAddress || '-' }}</span>
<div v-if="$slots.headerActions" class="header-actions">
<slot name="headerActions" />
</div>
</div>
<div class="detail-grid">
<!-- 左上:基本参数 -->
<div class="detail-section section-basic">
<!-- 顶部:基本参数(一行) -->
<div class="detail-section section-basic section-basic-fullwidth">
<div class="section-header">
<span class="section-title">基本参数</span>
<div class="section-actions"></div>
@ -1738,7 +1838,7 @@
:default-span="8"
:row-gutter="16"
:form-layout="'vertical'"
:item-list="filteredBasicFormItems"
:item-list="basicFormItemsForLayout"
:required-position="'none'"
:rules="basicFormRules"
:hide-colon="false"
@ -1747,11 +1847,11 @@
/>
<!-- 只读显示模式 -->
<div v-else class="basic-form">
<div class="form-item" v-for="(value, key) in basicParams" :key="key">
<div class="form-item" v-for="(value, key) in basicParamsForDisplay" :key="key">
<div class="form-label">{{ key }}</div>
<div class="form-value">{{ value || '单行输入' }}</div>
</div>
<div v-if="Object.keys(basicParams).length === 0" class="empty-placeholder">
<div v-if="Object.keys(basicParamsForDisplay).length === 0" class="empty-placeholder">
<div class="form-item" v-for="i in 6" :key="i">
<div class="form-label">字段{{ i }}</div>
<div class="form-value">单行输入</div>
@ -1761,20 +1861,22 @@
</div>
</div>
<!-- 右上:Query -->
<div class="detail-section section-query" :class="{ 'section-query-hidden': !showQuery }">
<!-- 左下:Request(含 Query、固定配置、Body、cURL) -->
<div class="detail-section section-request" :class="{ 'section-request-fixed-tab': requestTab === 'fixed' }">
<div class="section-header">
<a-tabs v-model:active-key="queryTab" type="line" size="mini" direction="horizontal" class="request-header-tabs">
<a-tab-pane key="body" title="Query" />
<a-tab-pane key="fixed" title="固定配置" />
<a-tabs v-model:active-key="requestTab" type="line" size="mini" direction="horizontal" class="request-header-tabs">
<a-tab-pane v-if="showQuery" key="query" title="Query" />
<a-tab-pane v-if="showQuery" key="fixed" title="固定配置" />
<a-tab-pane key="body" title="Body" />
<a-tab-pane key="demo" title="cURL" />
</a-tabs>
<div class="section-actions">
<template v-if="queryTab === 'body'">
<template v-if="requestTab === 'query'">
<a-button size="small" status="success" @click="addNewQueryParam">
新增
</a-button>
</template>
<template v-else>
<template v-else-if="requestTab === 'fixed'">
<a-button size="small" status="success" @click="addFixedParam">
新增
</a-button>
@ -1782,10 +1884,57 @@
保存
</a-button>
</template>
<template v-else-if="requestTab === 'body'">
<a-button size="small" status="success" @click="addNewRequestParam">
新增
</a-button>
<a-button size="small" status="warning" @click="drawerClick">
一键导入
</a-button>
</template>
</div>
</div>
<div class="section-content">
<div v-if="queryTab === 'body'" class="table-content">
<div v-if="showContentTypeSelectors && requestTab === 'body'" class="content-type-bar">
<div class="content-type-tags">
<span
v-for="(opt, idx) in contentTypeOptions"
:key="opt.key"
class="content-type-tag"
:class="[getContentTypeTagClass(opt.value, idx), { active: basicFormData?.contentType === opt.key }]"
@click="updateContentType(opt.key)"
>
{{ opt.value }}
</span>
</div>
<div class="mapped-param-select-wrap">
<span class="mapped-param-label">根节点</span>
<a-dropdown trigger="click">
<div class="mapped-param-trigger" :style="{ width: '100px' }">
<a-tag
v-if="selectedMappedParamOption"
:color="selectedMappedParamOption.color"
size="small"
>
{{ selectedMappedParamOption.label }}
</a-tag>
<span v-else class="mapped-param-placeholder">请选择</span>
<IconDown class="mapped-param-arrow" />
</div>
<template #content>
<a-doption
v-for="(opt, optIdx) in mappedParamTypeOptions"
:key="opt.key"
@click="updateMappedParamType(opt.key)"
>
<a-tag :color="getTypeTagColor(optIdx)" size="small">{{ opt.value }}</a-tag>
</a-doption>
</template>
</a-dropdown>
</div>
</div>
<div class="section-content" :class="{ 'section-content-fixed-tab': requestTab === 'fixed' }">
<!-- Query tab 内容 -->
<div v-if="requestTab === 'query'" class="table-content">
<TablePro
ref="queryTableRef"
v-bind="queryTableProps"
@ -1879,7 +2028,9 @@
</template>
</TablePro>
</div>
<div v-else class="table-content query-fixed-content">
<!-- 固定配置 tab 内容 -->
<div v-else-if="requestTab === 'fixed'" class="table-content query-fixed-content">
<a-spin :loading="fixedParamsLoading" class="query-fixed-spin">
<TablePro
ref="queryFixedTableRef"
@ -1929,62 +2080,9 @@
</TablePro>
</a-spin>
</div>
</div>
</div>
<!-- 左下:Request -->
<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="body" title="Body" />
<a-tab-pane key="demo" title="Demo" />
</a-tabs>
<div class="section-actions" v-if="requestTab === 'body'">
<a-button size="small" status="success" @click="addNewRequestParam">
新增
</a-button>
<a-button size="small" status="warning" @click="drawerClick">
一键导入
</a-button>
</div>
</div>
<div v-if="showContentTypeSelectors" class="content-type-bar">
<a-radio-group
:model-value="basicFormData?.contentType"
size="small"
type="button"
@update:model-value="updateContentType"
>
<a-radio
v-for="opt in contentTypeOptions"
:key="opt.key"
:value="opt.key"
>
{{ opt.value }}
</a-radio>
</a-radio-group>
<div class="mapped-param-select-wrap">
<a-select
:model-value="basicFormData?.mappedParamType"
size="small"
placeholder="请选择根节点"
allow-clear
:style="{ width: '140px' }"
@update:model-value="updateMappedParamType"
>
<a-option
v-for="opt in mappedParamTypeOptions"
:key="opt.key"
:value="opt.key"
>
{{ opt.value }}
</a-option>
</a-select>
</div>
</div>
<div class="section-content">
<!-- 请求体 tab 内容 -->
<div v-if="requestTab === 'body'" class="table-content">
<div v-else-if="requestTab === 'body'" class="table-content">
<TablePro
ref="requestTableRef"
v-bind="requestTableProps"
@ -2101,7 +2199,7 @@
</div>
<!-- Demo tab 内容 -->
<div v-if="requestTab === 'demo'" class="request-example-content">
<div v-else-if="requestTab === 'demo'" class="request-example-content">
<div class="request-example-container">
<CodeMirror
ref="requestExampleEditorRef"
@ -2128,7 +2226,7 @@
<a-tabs v-model:active-key="responseTab" type="line" size="mini" direction="horizontal" class="response-header-tabs">
<a-tab-pane key="body" title="Response" />
<a-tab-pane v-if="showReturnConfig" key="returnConfig" title="返回配置" :disabled="!hasResponseParams" />
<a-tab-pane key="demo" title="Demo" />
<a-tab-pane key="demo" title="示例" />
</a-tabs>
<div class="section-actions">
<!-- Response tab的操作按钮 -->
@ -2151,21 +2249,18 @@
</template>
</div>
</div>
<div v-if="showContentTypeSelectors" class="content-type-bar">
<a-radio-group
:model-value="basicFormData?.responseContentType"
size="small"
type="button"
@update:model-value="updateResponseContentType"
>
<a-radio
v-for="opt in responseContentTypeOptions"
<div v-if="showContentTypeSelectors && responseTab === 'body'" class="content-type-bar">
<div class="content-type-tags">
<span
v-for="(opt, idx) in responseContentTypeOptions"
:key="opt.key"
:value="opt.key"
class="content-type-tag"
:class="[getContentTypeTagClass(opt.value, idx), { active: basicFormData?.responseContentType === opt.key }]"
@click="updateResponseContentType(opt.key)"
>
{{ opt.value }}
</a-radio>
</a-radio-group>
</span>
</div>
</div>
<div class="section-content">
<!-- Response body tab 内容 -->
@ -2466,17 +2561,74 @@
background-color: var(--color-bg-1);
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
// 请求方式 + 请求地址(参考 ApiTest api-test-header)
.api-test-header {
display: flex;
align-items: center;
margin-bottom: 16px;
padding: 12px;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
flex-shrink: 0;
box-sizing: border-box;
min-height: 56px;
.method-select,
.method-badge {
font-weight: 600;
}
.method-badge {
min-width: 80px;
padding: 0 12px;
font-size: 14px;
}
.method-get { color: #10b981 !important; }
.method-post { color: #f59e0b !important; }
.method-delete { color: #ef4444 !important; }
.method-put, .method-patch, .method-head, .method-options { color: #6b7280 !important; }
.url-input {
flex: 0.5;
margin-left: 12px;
margin-right: 12px;
}
.url-text {
flex: 0.5;
margin-left: 12px;
padding: 8px 12px;
background: var(--color-fill-2);
border-radius: 4px;
font-size: 13px;
color: var(--color-text-2);
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 12px;
}
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 0.67fr 1.33fr;
grid-template-rows: auto 1fr;
gap: 16px;
height: 100%;
flex: 1;
min-height: 0;
}
.section-basic-fullwidth {
grid-column: 1 / -1;
}
.detail-section {
display: flex;
flex-direction: column;
@ -2496,19 +2648,79 @@
.content-type-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 8px 30px;
gap: 12px;
padding: 10px 30px;
min-height: 52px;
box-sizing: border-box;
border-bottom: 1px solid var(--color-border-2);
background-color: var(--color-bg-1);
flex-shrink: 0;
.arco-radio-group {
.content-type-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.content-type-tag {
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: none;
background: var(--color-fill-2);
color: var(--color-text-3);
&:hover {
opacity: 0.9;
}
&.active {
color: #fff !important;
}
&.content-type-json.active { background: #165dff; }
&.content-type-xml.active { background: #00b42a; }
&.content-type-form.active { background: #f59e0b; }
&.content-type-raw.active,
&.content-type-purple.active { background: #8b5cf6; }
&.content-type-cyan.active { background: #06b6d4; }
&.content-type-pink.active { background: #ec4899; }
}
.mapped-param-select-wrap {
margin-left: auto;
margin-right: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.mapped-param-trigger {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 2px 8px;
min-height: 24px;
}
.mapped-param-placeholder {
font-size: 13px;
color: var(--color-text-3);
}
.mapped-param-arrow {
margin-left: auto;
font-size: 12px;
color: var(--color-text-3);
}
.mapped-param-label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-2);
}
}
@ -2618,7 +2830,8 @@
.form-item {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
flex-shrink: 0;
}
.form-label {
@ -2662,46 +2875,48 @@
}
}
/* 固定配置:弹窗内防止列表挤在一起 */
.query-fixed-content {
min-height: 180px;
/* 固定配置 tab 时:section-request 允许内容溢出,section-content 不限制高度,列表自然展开 */
.section-request.section-request-fixed-tab {
overflow: visible;
}
.section-content.section-content-fixed-tab {
overflow: visible;
min-height: auto;
flex: none;
}
/* 固定配置:无滚动面板,列表自然展开 */
.query-fixed-content.table-content {
overflow: visible;
flex: none;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-fixed-spin {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-fixed-spin :deep(.arco-spin) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-fixed-spin :deep(.arco-spin-children) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-fixed-params-table {
flex: 1;
min-height: 0;
height: 100%;
:deep(.vxe-table) {
height: 100%;
height: auto !important;
}
:deep(.vxe-table--body-wrapper) {
max-height: 100%;
overflow-y: auto;
overflow: visible !important;
}
/* 防止弹窗内行被压缩挤在一起 */
@ -3028,9 +3243,4 @@
justify-content: center !important;
}
// 隐藏 Query 容器但保留空间
.section-query-hidden {
visibility: hidden;
pointer-events: none;
}
</style>

View File

@ -768,6 +768,7 @@
init,
saveInterface,
closeModal,
getRequestExample: () => interfaceDetailRef.value?.getRequestExample?.() || '',
});
</script>
@ -789,7 +790,11 @@
:interface-id="tableForm.id"
:service-provider-id="tableForm.serviceProviderId"
@update:basic-form-data="(val) => Object.assign(tableForm, val)"
/>
>
<template v-if="$slots.headerActions" #headerActions>
<slot name="headerActions" />
</template>
</InterfaceDetail>
</div>
</template>

View File

@ -1452,6 +1452,25 @@
}
}
// 点击调试 / 切换 API 调试 tab:获取请求容器的 cURL 并导入到 API 调试
const curlToImport = ref('');
function importCurlToDebugger() {
const curl = interfaceDetailModalRef.value?.getRequestExample?.() || '';
if (curl && curl.trim()) {
curlToImport.value = curl;
nextTick(() => {
setTimeout(() => { curlToImport.value = ''; }, 500);
});
}
}
function handleDebugClick() {
interfaceDetailTab.value = 'debug';
}
// 切换至 API 调试 tab 时也解析 cURL
watch(interfaceDetailTab, (tab) => {
if (tab === 'debug') importCurlToDebugger();
});
onMounted(() => {
getAppList();
loadServiceProviderList();
@ -1617,11 +1636,7 @@
{{ t('system.serviceProviders.detail.tab.debug') }}
</div>
</div>
<div class="tabs-extra">
<a-button size="small" type="primary" @click="handleInterfaceDetailSave">
{{ t('system.serviceProviders.detail.actions.save') }}
</a-button>
</div>
<div class="tabs-extra" v-if="interfaceDetailTab !== 'detail'"></div>
</div>
</div>
@ -1634,7 +1649,16 @@
:key="`interface-detail-${selectedInterface?.id}-${detailRefreshKey}`"
ref="interfaceDetailModalRef"
@saved="handleInterfaceSaved"
/>
>
<template #headerActions>
<a-button size="small" type="primary" @click="handleInterfaceDetailSave">
{{ t('system.serviceProviders.detail.actions.save') }}
</a-button>
<a-button size="small" @click="handleDebugClick">
调试
</a-button>
</template>
</ServiceProviderInterfaceModal>
</a-spin>
</div>
@ -1644,6 +1668,7 @@
:selected-interface="selectedInterface"
:selected-service-provider="selectedProvider"
:enable-defaults="false"
:curl-to-import="curlToImport"
/>
</div>
</div>

View File

@ -1,6 +1,7 @@
<template>
<div class="api-debugger-container">
<ApiTest
ref="apiTestRef"
:selected-interface="selectedInterface"
:selected-service-provider="selectedServiceProvider"
:enable-defaults="enableDefaults"
@ -11,6 +12,7 @@
<script lang="ts" setup>
import ApiTest from '@/components/ApiTest/index.vue';
import { getInterfaceGetInterfaceById } from '@/api/InterfaceServices/interface';
import { ref, computed, watch, nextTick } from 'vue';
defineOptions({
name: 'ApiDebugger',
@ -21,8 +23,11 @@
selectedInterface?: any;
selectedServiceProvider?: any;
enableDefaults?: boolean; // 是否启用默认值
curlToImport?: string; // 从接口明细请求容器导入的 cURL 内容
}>();
const apiTestRef = ref<InstanceType<typeof ApiTest> | null>(null);
// 为enableDefaults设置默认值
const enableDefaults = computed(() => props.enableDefaults ?? true);
@ -62,9 +67,6 @@
if (interfaceData) {
console.log('ApiDebugger: interfaceData:', interfaceData);
console.log('ApiDebugger: interfaceData keys:', Object.keys(interfaceData || {}));
console.log('ApiDebugger: has inParams:', !!(interfaceData as any)?.inParams);
console.log('ApiDebugger: has requestExample:', !!(interfaceData as any)?.requestExample);
console.log('ApiDebugger: has demoData:', !!(interfaceData as any)?.demoData);
console.log('ApiDebugger: contentType value:', (interfaceData as any)?.contentType);
console.log('ApiDebugger: contentType type:', typeof (interfaceData as any)?.contentType);
console.log('ApiDebugger: requestMethod:', (interfaceData as any)?.requestMethod);
@ -90,18 +92,16 @@
console.log('ApiDebugger: selectedInterface changed:', newVal);
if (newVal && newVal.id) {
console.log('ApiDebugger: selectedInterface has id:', newVal.id);
console.log('ApiDebugger: selectedInterface has inParams:', !!newVal.inParams);
console.log('ApiDebugger: selectedInterface contentType:', newVal.contentType);
// 如果传递的接口对象不完整(没有关键字段),则获取完整详情
// 如果传递的接口对象不完整(没有关键字段),则获取完整详情;body 不再从接口明细加载,仅从导入的 cURL 的 --data-raw 显示
const interfaceObj = newVal as any;
const hasDemoData = !!(interfaceObj?.inParams || interfaceObj?.requestExample || interfaceObj?.demoData);
const hasContentType = !!interfaceObj?.contentType;
const hasRequestMethod = !!interfaceObj?.requestMethod;
console.log('ApiDebugger: hasDemoData:', hasDemoData, 'hasContentType:', hasContentType, 'hasRequestMethod:', hasRequestMethod);
console.log('ApiDebugger: hasContentType:', hasContentType, 'hasRequestMethod:', hasRequestMethod);
if (!hasDemoData || !hasContentType || !hasRequestMethod) {
if (!hasContentType || !hasRequestMethod) {
console.log('ApiDebugger: interface data incomplete, fetching full detail...');
await fetchInterfaceDetail(newVal.id);
} else {
@ -117,6 +117,16 @@
watch(() => props.selectedServiceProvider, (newVal) => {
console.log('ApiDebugger: selectedServiceProvider changed:', newVal);
}, { immediate: true });
// 当收到 curlToImport 时,导入到 ApiTest(immediate 确保切换 tab 时若已有值也能触发)
watch(() => props.curlToImport, (curl) => {
if (curl && curl.trim()) {
const toImport = curl.trim();
const doImport = () => apiTestRef.value?.importCurlFromString?.(toImport);
nextTick(doImport);
setTimeout(doImport, 150);
}
}, { immediate: true });
</script>
<style scoped lang="less">

View File

@ -609,6 +609,7 @@
init,
submit: saveInterface,
saveInterface,
getRequestExample: () => interfaceDetailRef.value?.getRequestExample?.() || '',
});
</script>
@ -629,7 +630,11 @@
:show-query="false"
:show-return-config="false"
@update:basic-form-data="(val) => Object.assign(tableForm, val)"
/>
>
<template v-if="$slots.headerActions" #headerActions>
<slot name="headerActions" />
</template>
</InterfaceDetail>
</div>
</template>

View File

@ -884,6 +884,24 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
}
}
// 点击调试 / 切换 API 调试 tab:获取请求容器的 cURL 并导入到 API 调试
const curlToImport = ref('');
function importCurlToDebugger() {
const curl = interfaceDetailModalRef.value?.getRequestExample?.() || '';
if (curl && curl.trim()) {
curlToImport.value = curl;
nextTick(() => {
setTimeout(() => { curlToImport.value = ''; }, 500);
});
}
}
function handleDetailDebugClick() {
handleTabChange('debugger');
if (!MapServiceProviderROW.value) return;
importCurlToDebugger();
activeContentTab.value = 'debugger';
}
// 统一的保存函数,根据当前tab调用不同的接口
async function handleTabSave() {
// 使用独立的currentActiveTab变量,在handleTabChange中实时更新
@ -1048,22 +1066,19 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
setTimeout(checkAndInit, 200);
}
// 监听 interfaceDetailModalRef 变化,当组件挂载后自动初始化
// 监听 interfaceDetailModalRef 变化,当组件挂载后自动初始化(detail 用 v-show 保持挂载,需在有 selectedInterface 时初始化以便切换调试 tab 时能获取 cURL)
watch(interfaceDetailModalRef, async (newRef, oldRef) => {
// 只有当 ref 从 undefined/null 变为有值时,才初始化(避免重复初始化)
if (newRef && !oldRef && selectedInterface.value && activeContentTab.value === 'detail') {
if (newRef && !oldRef && selectedInterface.value && newRef.init) {
await nextTick();
await nextTick();
setTimeout(() => {
// 再次检查条件,可能在等待过程中状态已改变
if (activeContentTab.value === 'detail' && selectedInterface.value && newRef && newRef.init) {
if (selectedInterface.value && newRef && newRef.init) {
try {
newRef.init({
id: selectedInterface.value.id,
interfaceId: selectedInterface.value.id,
});
} catch (error) {
// 初始化失败,静默处理
console.error('初始化接口明细组件失败:', error);
}
}
@ -1074,7 +1089,9 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
// 监听 activeContentTab 变化,同步更新 currentActiveTab
watch(activeContentTab, async (newTab) => {
currentActiveTab.value = newTab;
if (newTab === 'detail' && selectedInterface.value) {
if (newTab === 'debugger') {
importCurlToDebugger();
} else if (newTab === 'detail' && selectedInterface.value) {
// 等待 DOM 更新完成后再初始化
await nextTick();
await nextTick();
@ -1096,15 +1113,23 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
}
});
// 监听 selectedInterface 变化,初始化组件
// 监听 selectedInterface 变化,初始化组件(detail 用 v-show 保持挂载,需在切换接口时初始化以便切换调试 tab 时能获取 cURL)
watch(selectedInterface, async (newInterface) => {
if (newInterface && activeContentTab.value === 'detail') {
// 等待 DOM 更新完成后再初始化
if (newInterface) {
await nextTick();
await nextTick();
setTimeout(() => {
initInterfaceConfig();
}, 100);
if (interfaceDetailModalRef.value?.init && selectedInterface.value) {
try {
interfaceDetailModalRef.value.init({
id: selectedInterface.value.id,
interfaceId: selectedInterface.value.id,
});
} catch (error) {
console.error('初始化接口明细组件失败:', error);
}
}
}, activeContentTab.value === 'detail' ? 100 : 300);
}
}, { immediate: false });
@ -1422,7 +1447,7 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
</div>
<div class="tabs-extra">
<a-button
v-if="activeContentTab === 'mapping' || activeContentTab === 'detail'"
v-if="activeContentTab === 'mapping'"
size="small"
type="primary"
@click="handleTabSave"
@ -1455,16 +1480,26 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
:selected-interface="selectedInterface"
:selected-service-provider="MapServiceProviderROW"
:enable-defaults="true"
:curl-to-import="curlToImport"
/>
</div>
<!-- 接口明细内容 -->
<div v-if="activeContentTab === 'detail' && selectedInterface" class="detail-content">
<!-- 接口明细内容(v-show 保持挂载,便于切换至调试 tab 时获取 cURL) -->
<div v-show="activeContentTab === 'detail' && selectedInterface" class="detail-content">
<UpstreamInterfaceModal
:key="`${selectedInterface?.id}-${detailRefreshKey}`"
ref="interfaceDetailModalRef"
@saved="() => { /* 保留在当前tab,按需刷新左侧树 */ }"
/>
>
<template #headerActions>
<a-button size="small" type="primary" @click="handleTabSave">
{{ $t('system.interface.actions.save') }}
</a-button>
<a-button size="small" @click="handleDetailDebugClick">
调试
</a-button>
</template>
</UpstreamInterfaceModal>
</div>
</div>
</div>