whistle.mock-plugins
Version:
Whistle 插件,用于快速创建 API 模拟数据
1,312 lines (1,220 loc) • 54.1 kB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import AppLayout from '../components/AppLayout';
import { Table, Button, Modal, Form, Input, Select, message, Switch, Popconfirm, Alert, Space, Tabs, Card, Divider, Badge, Tooltip, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined, PlayCircleOutlined, FormatPainterOutlined, EyeOutlined, CopyOutlined, CodeOutlined, CheckCircleOutlined, CloseCircleOutlined, MenuOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import '../styles/interface-management.css';
import axios from 'axios';
const { Option } = Select;
const { TextArea } = Input;
const { TabPane } = Tabs;
const InterfaceManagement = () => {
const { featureId } = useParams();
const history = useHistory();
const [features, setFeatures] = useState([]);
const [interfaces, setInterfaces] = useState([]);
const [loading, setLoading] = useState(false);
const [featuresLoading, setFeaturesLoading] = useState(false);
const [interfacesLoading, setInterfacesLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingInterface, setEditingInterface] = useState(null);
const [testModalVisible, setTestModalVisible] = useState(false);
const [testUrl, setTestUrl] = useState('');
const [testResult, setTestResult] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [selectedFeatureId, setSelectedFeatureId] = useState(null);
const [form] = Form.useForm();
const [testForm] = Form.useForm();
const [previewContent, setPreviewContent] = useState(null);
const [previewVisible, setPreviewVisible] = useState(false);
const contentTypes = [
{ value: 'application/json; charset=utf-8', label: 'JSON' },
{ value: 'text/plain; charset=utf-8', label: '纯文本' },
{ value: 'text/html; charset=utf-8', label: 'HTML' },
{ value: 'application/xml; charset=utf-8', label: 'XML' },
{ value: 'application/javascript; charset=utf-8', label: 'JavaScript' },
];
const proxyTypes = [
{ value: 'response', label: '模拟响应' },
{ value: 'redirect', label: '重定向' },
{ value: 'url_redirect', label: 'URL重定向' },
];
const statusCodes = [
{ value: '200', label: '200 OK' },
{ value: '201', label: '201 Created' },
{ value: '204', label: '204 No Content' },
{ value: '400', label: '400 Bad Request' },
{ value: '401', label: '401 Unauthorized' },
{ value: '403', label: '403 Forbidden' },
{ value: '404', label: '404 Not Found' },
{ value: '500', label: '500 Internal Server Error' },
];
const httpMethods = [
{ value: 'ALL', label: '所有方法' },
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
{ value: 'HEAD', label: 'HEAD' },
{ value: 'OPTIONS', label: 'OPTIONS' },
];
useEffect(() => {
fetchFeatures();
}, []);
useEffect(() => {
if (selectedFeatureId) {
fetchInterfaces();
}
}, [selectedFeatureId]);
// 获取当前选中的功能模块
const selectedFeature = features.find(f => f.id === selectedFeatureId);
const fetchFeatures = async () => {
try {
setFeaturesLoading(true);
const response = await axios.get('/cgi-bin/features');
if (response.data && response.data.code === 0 && Array.isArray(response.data.data)) {
setFeatures(response.data.data);
// 如果URL中有featureId参数,使用它
const initialFeatureId = featureId || (response.data.data[0]?.id);
if (initialFeatureId) {
setSelectedFeatureId(initialFeatureId);
}
} else {
setFeatures([]);
message.warning('获取功能模块数据格式不正确');
}
} catch (error) {
console.error('获取功能模块失败:', error);
message.error(error.response?.data?.message || '获取功能模块失败');
setFeatures([]);
} finally {
setFeaturesLoading(false);
}
};
const fetchInterfaces = async () => {
if (!selectedFeatureId) {
return;
}
try {
setInterfacesLoading(true);
const response = await axios.get(`/cgi-bin/interfaces?featureId=${selectedFeatureId}`);
if (response.data && response.data.code === 0 && Array.isArray(response.data.data)) {
setInterfaces(response.data.data);
} else {
setInterfaces([]);
message.warning('获取接口配置数据格式不正确');
}
} catch (error) {
console.error('获取接口配置失败:', error);
message.error(error.response?.data?.message || '获取接口配置失败');
setInterfaces([]);
} finally {
setInterfacesLoading(false);
}
};
const handleAddInterface = () => {
if (!selectedFeatureId) {
message.warning('请先选择一个功能模块');
return;
}
form.resetFields();
setEditingInterface(null);
setModalVisible(true);
};
const handleEditInterface = (record) => {
setEditingInterface(record);
// 将自定义请求头转换为数组格式,用于动态表单项
let headersArray = [];
if (record.customHeaders && typeof record.customHeaders === 'object') {
headersArray = Object.entries(record.customHeaders).map(([key, value]) => ({
headerName: key,
headerValue: value
}));
}
form.setFieldsValue({
name: record.name,
pattern: record.urlPattern,
proxyType: record.proxyType || 'response',
statusCode: record.httpStatus?.toString() || '200',
contentType: record.contentType || 'application/json; charset=utf-8',
responseBody: record.responseContent || '',
httpMethod: record.httpMethod || 'ALL',
targetUrl: record.targetUrl || '',
headerItems: headersArray // 使用数组存储表单项
});
setModalVisible(true);
};
const handleDeleteInterface = async (id) => {
try {
const response = await axios.delete(`/cgi-bin/interfaces?id=${id}`);
if (response.data && response.data.code === 0) {
message.success('接口删除成功');
fetchInterfaces();
} else {
throw new Error(response.data?.message || '接口删除失败');
}
} catch (error) {
console.error('接口删除失败:', error);
message.error(error.response?.data?.message || error.message || '接口删除失败');
}
};
const handleToggleActive = async (id, currentActive) => {
try {
const response = await axios.patch(`/cgi-bin/interfaces?id=${id}`, {
active: !currentActive
});
if (response.data && response.data.code === 0) {
message.success(`接口${!currentActive ? '启用' : '禁用'}成功`);
fetchInterfaces();
} else {
throw new Error(response.data?.message || `接口${!currentActive ? '启用' : '禁用'}失败`);
}
} catch (error) {
console.error(`接口${!currentActive ? '启用' : '禁用'}失败:`, error);
message.error(error.response?.data?.message || error.message || `接口${!currentActive ? '启用' : '禁用'}失败`);
}
};
const cleanJsonResponse = (jsonStr) => {
try {
const parsed = JSON.parse(jsonStr);
return JSON.stringify(parsed);
} catch (e) {
return jsonStr;
}
};
// 验证URL是否合法
const isValidUrl = (url) => {
if (!url) return false;
try {
new URL(url);
return true;
} catch (e) {
return false;
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// 检查responseBody是否是有效的JSON
if (values.proxyType === 'response' && values.contentType.includes('application/json')) {
try {
JSON.parse(values.responseBody);
} catch (e) {
message.error('响应体不是有效的JSON格式');
return;
}
}
// 处理自定义请求头,将表单项数组转换为对象格式
let customHeaders = {};
if (values.proxyType === 'redirect' || values.proxyType === 'url_redirect') {
if (values.headerItems && values.headerItems.length > 0) {
values.headerItems.forEach(item => {
if (item && item.headerName && item.headerName.trim()) {
customHeaders[item.headerName.trim()] = item.headerValue || '';
}
});
}
}
const interfaceData = {
name: values.name,
featureId: selectedFeatureId,
urlPattern: values.pattern,
proxyType: values.proxyType,
responseContent: values.proxyType === 'response' ? values.responseBody : '',
targetUrl: (values.proxyType === 'redirect' || values.proxyType === 'url_redirect') ? values.targetUrl : '',
customHeaders: (values.proxyType === 'redirect' || values.proxyType === 'url_redirect') ? customHeaders : {},
httpStatus: parseInt(values.statusCode, 10), // 转换为数字
contentType: values.contentType,
responseDelay: 0,
httpMethod: values.httpMethod,
active: true
};
if (editingInterface) {
const response = await axios.put(`/cgi-bin/interfaces?id=${editingInterface.id}`, interfaceData);
if (response.data && response.data.code === 0) {
message.success('接口更新成功');
} else {
throw new Error(response.data?.message || '接口更新失败');
}
} else {
const response = await axios.post('/cgi-bin/interfaces', interfaceData);
if (response.data && response.data.code === 0) {
message.success('接口添加成功');
} else {
throw new Error(response.data?.message || '接口添加失败');
}
}
setModalVisible(false);
fetchInterfaces();
} catch (error) {
if (error.errorFields) {
return; // 表单验证错误
}
console.error('操作失败:', error);
message.error(error.response?.data?.message || error.message || '操作失败,请检查数据格式是否正确');
}
};
const handleCancel = () => {
setModalVisible(false);
};
const handleTestInterface = (record) => {
setEditingInterface(record);
setTestUrl('');
setTestResult(null);
setTestModalVisible(true);
testForm.resetFields();
};
// 检查 URL 是否匹配模式
const isUrlMatchPattern = (url, pattern, proxyType) => {
if (!url || !pattern) return false;
try {
// 对于url_redirect类型,需要完全匹配
if (proxyType === 'url_redirect') {
return url === pattern;
}
// 对于redirect类型,只要url以pattern开头即可命中(前缀匹配)
if (proxyType === 'redirect') {
return url.indexOf(pattern) === 0;
}
// 以下是默认的匹配逻辑(用于response类型等)
// 如果是正则表达式
if (pattern.startsWith('/') && pattern.endsWith('/')) {
const regex = new RegExp(pattern.slice(1, -1));
return regex.test(url);
}
// 如果是通配符模式
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(url);
}
// 精确匹配
return url === pattern;
} catch (e) {
console.error('URL匹配检查失败:', e);
return false;
}
};
const handleTestSubmit = async () => {
try {
const values = await testForm.validateFields();
setTestLoading(true);
try {
// 验证测试 URL 是否匹配当前接口的匹配规则
if (!isUrlMatchPattern(values.testUrl, editingInterface.urlPattern, editingInterface.proxyType)) {
let errorMessage = '测试URL与接口匹配规则不匹配';
// 根据不同的代理类型提供更具体的错误信息
if (editingInterface.proxyType === 'url_redirect') {
errorMessage = `测试URL必须完全匹配 ${editingInterface.urlPattern}`;
} else if (editingInterface.proxyType === 'redirect') {
errorMessage = `测试URL必须以 ${editingInterface.urlPattern} 开头`;
}
setTestResult({
success: false,
error: errorMessage
});
message.error(errorMessage);
return;
}
// 调用实际测试接口
const response = await axios.post(`/cgi-bin/test-interface`, {
url: values.testUrl,
interfaceId: editingInterface.id
});
if (response.data && response.data.code === 0 && response.data.data) {
setTestResult({
success: true,
statusCode: response.data.data.statusCode,
contentType: response.data.data.contentType,
responseBody: response.data.data.responseBody,
matchedRule: editingInterface.urlPattern,
httpMethod: editingInterface.httpMethod,
requestUrl: values.testUrl,
mockInfo: response.data.data.mockInfo
});
message.success('测试成功');
} else {
throw new Error(response.data?.message || '测试响应格式不正确');
}
} catch (error) {
setTestResult({
success: false,
error: error.response?.data?.message || error.message || '测试失败'
});
message.error(error.response?.data?.message || error.message || '测试失败');
}
setTestLoading(false);
} catch (error) {
if (error.errorFields) {
return; // 表单验证错误
}
setTestLoading(false);
}
};
const handleSelectFeature = (featureId) => {
setSelectedFeatureId(featureId);
};
const formatResponseContent = (content, contentType) => {
if (contentType && contentType.includes('json')) {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (e) {
return content;
}
}
return content;
};
const formatJsonContent = () => {
const proxyType = form.getFieldValue('proxyType');
if (proxyType !== 'response') {
return;
}
const responseBody = form.getFieldValue('responseBody');
if (responseBody) {
try {
const formattedJson = JSON.stringify(JSON.parse(responseBody), null, 2);
form.setFieldsValue({ responseBody: formattedJson });
message.success('JSON格式化成功');
} catch (error) {
message.error('JSON格式不正确,无法格式化');
}
}
};
const handlePreview = () => {
const proxyType = form.getFieldValue('proxyType');
if (proxyType !== 'response') {
return;
}
const responseBody = form.getFieldValue('responseBody');
if (responseBody) {
try {
// 如果是JSON,格式化显示
const contentType = form.getFieldValue('contentType');
if (contentType && contentType.includes('json')) {
setPreviewContent(JSON.stringify(JSON.parse(responseBody), null, 2));
} else {
setPreviewContent(responseBody);
}
setPreviewVisible(true);
} catch (error) {
message.error('内容格式不正确,无法预览');
}
} else {
message.warning('响应内容为空,无法预览');
}
};
const filteredInterfaces = interfaces.filter(item =>
!selectedFeatureId || item.featureId === selectedFeatureId
);
const columns = [
{
title: '状态',
dataIndex: 'active',
key: 'active',
width: 80,
render: (active, record) => (
<Switch
checked={active}
onChange={() => handleToggleActive(record.id, active)}
/>
),
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
ellipsis: true,
},
{
title: 'URL匹配规则',
dataIndex: 'urlPattern',
key: 'urlPattern',
ellipsis: true,
},
{
title: '处理方式',
dataIndex: 'proxyType',
key: 'proxyType',
width: 120,
render: (text) => {
const found = proxyTypes.find(item => item.value === text);
return found ? found.label : text || '模拟响应';
}
},
{
title: '状态码',
dataIndex: 'httpStatus',
key: 'httpStatus',
width: 100,
render: (text, record) => {
return record.proxyType === 'response' ? text : '-';
}
},
{
title: '内容类型',
dataIndex: 'contentType',
key: 'contentType',
width: 120,
render: (text, record) => {
if (record.proxyType !== 'response') {
return '-';
}
const found = contentTypes.find(item => item.value === text);
return found ? found.label : text;
}
},
{
title: '目标URL',
dataIndex: 'targetUrl',
key: 'targetUrl',
ellipsis: true,
render: (text, record) => {
return (record.proxyType === 'redirect' || record.proxyType === 'url_redirect') ? text : '-';
}
},
{
title: '自定义头',
dataIndex: 'customHeaders',
key: 'customHeaders',
width: 100,
render: (_, record) => {
if (record.proxyType !== 'redirect' && record.proxyType !== 'url_redirect') {
return '-';
}
const headers = record.customHeaders || {};
const count = Object.keys(headers).length;
if (count === 0) {
return '-';
}
// 检查是否包含随机值
const hasRandomValue = Object.values(headers).some(v => v && v.startsWith('@'));
return (
<Tooltip title={
<div>
{Object.entries(headers).map(([key, value]) => (
<div key={key}>
{key}: {value}
{value && value.startsWith('@') && (
<span style={{ color: '#52c41a' }}> (随机)</span>
)}
</div>
))}
</div>
}>
<span style={{ color: '#1890ff' }}>
{count}个
{hasRandomValue && <span style={{ marginLeft: 4 }}>🎲</span>}
</span>
</Tooltip>
);
}
},
{
title: '请求方法',
dataIndex: 'httpMethod',
key: 'httpMethod',
width: 120,
render: (text) => {
const found = httpMethods.find(item => item.value === text);
return found ? found.label : text;
}
},
{
title: '操作',
key: 'action',
width: 180,
render: (_, record) => (
<div className="action-buttons">
<Button
type="text"
icon={<PlayCircleOutlined />}
onClick={() => handleTestInterface(record)}
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEditInterface(record)}
/>
<Popconfirm
title="确定要删除此接口吗?"
onConfirm={() => handleDeleteInterface(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
),
},
];
return (
<AppLayout>
<div className="interface-management-container">
<div className="interface-management-header">
<div className="feature-selector">
<span>功能模块:</span>
<Select
value={selectedFeatureId}
onChange={handleSelectFeature}
style={{ width: 200 }}
placeholder="选择功能模块"
loading={featuresLoading}
>
{(features || []).map(feature => (
<Option key={feature.id} value={feature.id}>
{feature.name}
{feature.active === false &&
<span style={{ color: '#ff4d4f', marginLeft: 8 }}>(已禁用)</span>
}
</Option>
))}
</Select>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddInterface}
disabled={!selectedFeatureId || selectedFeature?.active === false}
>
添加接口
</Button>
</div>
{!features.length && (
<Alert
message="未找到功能模块"
description="请先在模拟数据页面创建功能模块,然后再添加接口"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{selectedFeature?.active === false && (
<Alert
message="功能模块已禁用"
description="当前功能模块已被禁用,所有关联接口不会生效。您可以在模拟数据页面启用此功能模块。"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<div className="interface-list-container">
<Table
columns={columns}
dataSource={filteredInterfaces}
rowKey="id"
loading={interfacesLoading}
pagination={{ pageSize: 10 }}
locale={{ emptyText: '暂无接口配置' }}
/>
</div>
<Modal
title={editingInterface ? '编辑接口' : '添加接口'}
open={modalVisible}
onOk={handleSubmit}
onCancel={handleCancel}
width={800}
destroyOnClose
okText={editingInterface ? '保存' : '创建'}
cancelText="取消"
bodyStyle={{ maxHeight: '70vh', overflow: 'auto', padding: '24px' }}
>
<Form
form={form}
layout="vertical"
initialValues={{
name: '',
pattern: '',
proxyType: 'response',
statusCode: '200',
contentType: 'application/json; charset=utf-8',
responseBody: '{\n "code": 0,\n "message": "success",\n "data": {}\n}',
httpMethod: 'ALL',
targetUrl: '',
headerItems: []
}}
>
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px' }}>
<div style={{ flex: 1 }}>
<Form.Item
name="name"
label="接口名称"
rules={[{ required: true, message: '请输入接口名称' }]}
>
<Input placeholder="请输入接口名称" />
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name="pattern"
label="URL匹配规则"
rules={[
{ required: true, message: '请输入URL匹配规则' },
({ getFieldValue }) => ({
validator(_, value) {
const proxyType = getFieldValue('proxyType');
if (!value) return Promise.resolve();
// URL重定向模式下,必须是完整URL
if (proxyType === 'url_redirect') {
try {
new URL(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('URL重定向模式下,URL匹配规则必须是完整的URL(包括http://或https://)'));
}
}
// 重定向模式下,必须以http://或https://开头
if (proxyType === 'redirect') {
if (value.startsWith('http://') || value.startsWith('https://')) {
return Promise.resolve();
}
return Promise.reject(new Error('重定向模式下,URL匹配规则必须以http://或https://开头'));
}
// 响应模式下的验证
if (proxyType === 'response') {
// 正则表达式验证
if (value.startsWith('/') && value.endsWith('/')) {
try {
new RegExp(value.slice(1, -1));
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('无效的正则表达式格式'));
}
}
// 通配符验证
if (value.includes('*')) {
if (/^[a-zA-Z0-9\-_/.*]+$/.test(value)) {
return Promise.resolve();
}
return Promise.reject(new Error('通配符URL格式不正确'));
}
// 普通路径验证
if (/^[a-zA-Z0-9\-_/]+$/.test(value)) {
return Promise.resolve();
}
return Promise.reject(new Error('URL路径格式不正确'));
}
return Promise.resolve();
},
}),
]}
tooltip={{
title: (
<>
<div>不同处理方式下URL匹配规则要求:</div>
<ul style={{margin: '5px 0 0 15px', padding: 0}}>
<li><b>模拟响应:</b> 支持路径格式如 /api/users,通配符如 /api/*,正则如 /\/api\/\d+/</li>
<li><b>重定向:</b> 必须以http://或https://开头,例如:https://example.com/api</li>
<li><b>URL重定向:</b> 必须是完整URL,包括http://或https://,例如:https://example.com/api/users</li>
</ul>
</>
),
overlayStyle: { maxWidth: '450px' }
}}
>
<Input
placeholder="根据选择的处理方式输入相应格式的URL"
/>
</Form.Item>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px' }}>
<div style={{ flex: 1 }}>
<Form.Item
name="proxyType"
label="处理方式"
rules={[{ required: true, message: '请选择处理方式' }]}
tooltip={{
title: (
<>
<div>不同处理方式的规则说明:</div>
<ul style={{margin: '5px 0 0 15px', padding: 0}}>
<li><b>模拟响应:</b> 返回您定义的响应内容</li>
<li><b>重定向:</b> 将请求重定向到其他URL,URL匹配规则必须以http://或https://开头</li>
<li><b>URL重定向:</b> 完全匹配URL时重定向,URL匹配规则必须是完整URL</li>
</ul>
</>
),
overlayStyle: { maxWidth: '450px' }
}}
>
<Select onChange={(value) => {
// 当切换代理类型时,清空pattern字段,并提供不同的placeholder
form.setFieldsValue({ pattern: '' });
// 为不同的代理类型提供不同的pattern占位符
const patternInput = document.querySelector('input[placeholder="根据选择的处理方式输入相应格式的URL"]');
if (patternInput) {
if (value === 'response') {
patternInput.placeholder = "例如:/api/users,/api/users/*,/api\/users\/\d+/";
} else if (value === 'redirect') {
patternInput.placeholder = "例如:https://example.com/api";
} else if (value === 'url_redirect') {
patternInput.placeholder = "例如:https://example.com/api/users";
}
}
}}>
{proxyTypes.map(item => (
<Option key={item.value} value={item.value}>{item.label}</Option>
))}
</Select>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name="httpMethod"
label="请求方法"
rules={[{ required: true, message: '请选择请求方法' }]}
>
<Select>
{httpMethods.map(item => (
<Option key={item.value} value={item.value}>{item.label}</Option>
))}
</Select>
</Form.Item>
</div>
</div>
{/* 根据proxyType显示不同的表单项 */}
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.proxyType !== currentValues.proxyType}>
{({ getFieldValue }) => {
const proxyType = getFieldValue('proxyType');
if (proxyType === 'response') {
return (
<>
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px' }}>
<div style={{ flex: 1 }}>
<Form.Item
name="statusCode"
label="状态码"
rules={[{ required: true, message: '请选择状态码' }]}
>
<Select>
{statusCodes.map(item => (
<Option key={item.value} value={item.value}>{item.label}</Option>
))}
</Select>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name="contentType"
label="内容类型"
rules={[{ required: true, message: '请选择内容类型' }]}
>
<Select>
{contentTypes.map(item => (
<Option key={item.value} value={item.value}>{item.label}</Option>
))}
</Select>
</Form.Item>
</div>
</div>
<Form.Item
name="responseBody"
labelCol={{
span: 8, /* 宽度比例 */
}}
label={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<span>响应内容</span>
<Space>
<Button
type="link"
icon={<EyeOutlined />}
onClick={handlePreview}
style={{ padding: 0 }}
>
预览
</Button>
<Button
type="link"
icon={<FormatPainterOutlined />}
onClick={formatJsonContent}
style={{ padding: 0 }}
>
格式化JSON
</Button>
</Space>
</div>
}
rules={[{ required: true, message: '请输入响应内容' }]}
>
<TextArea rows={12} placeholder="请输入响应内容" style={{ fontFamily: 'monospace' }} />
</Form.Item>
</>
);
}
if (proxyType === 'redirect' || proxyType === 'url_redirect') {
return (
<>
<Form.Item
name="targetUrl"
label="重定向目标URL"
rules={[
{ required: true, message: '请输入重定向目标URL' },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try {
new URL(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('请输入有效的URL,必须包含http://或https://'));
}
}
}
]}
tooltip={proxyType === 'redirect' ?
"重定向模式:输入完整的目标URL。匹配时使用前缀匹配,只要请求URL以匹配规则开头即命中。例如:https://example.com/api" :
"URL重定向模式:输入完整的目标URL。匹配时要求完全匹配URL,必须与匹配规则完全一致才命中。例如:https://example.com/api/users"
}
>
<Input
placeholder={proxyType === 'redirect' ?
"例如:https://example.com/api" :
"例如:https://example.com/api/users"
}
addonBefore={
<Select
defaultValue="https://"
className="select-before"
style={{ width: 100 }}
onChange={(value) => {
const currentUrl = form.getFieldValue('targetUrl') || '';
const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, '');
form.setFieldsValue({ targetUrl: value + urlWithoutProtocol });
}}
>
<Option value="http://">http://</Option>
<Option value="https://">https://</Option>
</Select>
}
/>
</Form.Item>
<Form.Item
label="自定义请求头"
tooltip="为请求添加或替换HTTP请求头"
className="custom-headers-container"
>
<Form.List name="headerItems">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Row key={key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={10}>
<Form.Item
{...restField}
name={[name, 'headerName']}
noStyle
rules={[
{
required: true,
message: '请输入请求头名称'
},
{
pattern: /^[^:]+$/,
message: '请求头名称不能包含冒号'
}
]}
>
<Input placeholder="Header-Name" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
{...restField}
name={[name, 'headerValue']}
noStyle
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
// 检查是否是随机数格式 @xxxx-xxx
if (value.startsWith('@')) {
const randomPattern = value.substring(1);
// 验证格式: 只能包含x和-,且首尾不能是-
if (!/^[x][-x]*[x]$/.test(randomPattern) ||
randomPattern.startsWith('-') ||
randomPattern.endsWith('-')) {
return Promise.reject(
new Error('随机数格式错误,应为@xxxx-xxx格式,只能包含x和-,且首尾不能是-')
);
}
}
return Promise.resolve();
}
}
]}
>
<Input placeholder="Header-Value" />
</Form.Item>
</Col>
<Col span={2}>
<Button
type="text"
icon={<MinusCircleOutlined />}
onClick={() => remove(name)}
style={{ color: '#ff4d4f' }}
/>
</Col>
</Row>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
添加请求头
</Button>
</Form.Item>
<div style={{ marginTop: 8 }}>
<Button
type="link"
onClick={() => {
add({ headerName: 'Content-Type', headerValue: 'application/json' });
}}
>
添加 Content-Type
</Button>
<Button
type="link"
onClick={() => {
add({ headerName: 'Authorization', headerValue: 'Bearer ' });
}}
>
添加 Authorization
</Button>
<Button
type="link"
onClick={() => {
add({ headerName: 'User-Agent', headerValue: 'Mozilla/5.0' });
}}
>
添加 User-Agent
</Button>
<Button
type="link"
onClick={() => {
add({ headerName: 'X-Random-ID', headerValue: '@xxxx-xxxx' });
}}
>
添加随机ID
</Button>
</div>
<div style={{ marginTop: 8, background: '#f5f5f5', padding: '8px', borderRadius: '4px' }}>
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>随机数格式说明:</div>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
<li>使用 <code>@</code> 开头表示这是一个随机数值</li>
<li>格式示例:<code>@xxxx-xxxx</code> 将生成如 <code>a1b2-c3d4</code> 的随机值</li>
<li>每个 <code>x</code> 将替换为随机字母或数字</li>
<li><code>-</code> 将保留在输出中作为分隔符</li>
</ul>
</div>
</>
)}
</Form.List>
</Form.Item>
</>
);
}
return null;
}}
</Form.Item>
</Form>
</Modal>
<Modal
title="测试接口"
open={testModalVisible}
onCancel={() => setTestModalVisible(false)}
footer={[
<Button key="back" onClick={() => setTestModalVisible(false)}>
关闭
</Button>,
<Button key="submit" type="primary" loading={testLoading} onClick={handleTestSubmit}>
测试
</Button>
]}
width={800}
>
{editingInterface && (
<div className="test-interface-container">
<Alert
message="URL匹配规则说明"
description={
<div>
<p>当前接口匹配规则:<code>{editingInterface.urlPattern}</code></p>
<p>代理类型:{
proxyTypes.find(item => item.value === editingInterface.proxyType)?.label ||
editingInterface.proxyType || '模拟响应'
}</p>
{editingInterface.proxyType === 'url_redirect' && (
<div>
<p><b>URL重定向模式规则:</b></p>
<ul>
<li>URL匹配规则必须是完整URL(包括http://或https://)</li>
<li>需要完全匹配URL路径,测试URL必须与匹配规则<b>完全一致</b></li>
<li>命中后将直接跳转到目标URL: {editingInterface.targetUrl}</li>
</ul>
</div>
)}
{editingInterface.proxyType === 'redirect' && (
<div>
<p><b>重定向模式规则:</b></p>
<ul>
<li>URL匹配规则必须以http://或https://开头</li>
<li>测试URL必须以匹配规则开头(<b>前缀匹配</b>)</li>
<li>命中后将直接跳转到目标URL: {editingInterface.targetUrl}</li>
</ul>
</div>
)}
{editingInterface.proxyType === 'response' && (
<div>
<p>在Whistle规则中,您需要配置完整URL或域名指向whistle.mock-plugins://,然后插件会根据接口的匹配规则处理请求。</p>
<p>插件支持以下匹配方式:</p>
<ul>
<li>精确匹配:完全匹配URL路径部分</li>
<li>通配符匹配:使用*代表任意字符(例如:/api/users/*)</li>
<li>正则表达式:使用/pattern/格式(例如:/\/api\/users\/\d+/)</li>
</ul>
</div>
)}
<p>请在下方输入完整测试URL进行测试</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form
form={testForm}
layout="vertical"
>
<Form.Item
name="testUrl"
label="测试URL"
rules={[{ required: true, message: '请输入测试URL' }]}
>
<Input
placeholder="输入完整URL,例如:https://example.com/api/users"
onChange={(e) => setTestUrl(e.target.value)}
/>
</Form.Item>
</Form>
{testResult && (
<div className="test-result">
<div className="result-header">
<h3>测试结果</h3>
{testResult.success ? (
<span className="status success">成功</span>
) : (
<span className="status error">失败</span>
)}
</div>
{testResult.success ? (
<div className="result-content">
<div className="result-item">
<span className="label">请求URL:</span>
<span className="value">{testResult.requestUrl}</span>
</div>
<div className="result-item">
<span className="label">匹配规则:</span>
<span className="value">{testResult.matchedRule}</span>
</div>
<div className="result-item">
<span className="label">请求方法:</span>
<span className="value">{testResult.httpMethod || 'ALL'}</span>
</div>
<div className="result-item">
<span className="label">处理方式:</span>
<span className="value">
{(() => {
const found = proxyTypes.find(item => item.value === editingInterface.proxyType);
return found ? found.label : editingInterface.proxyType || '模拟响应';
})()}
</span>
</div>
{(editingInterface.proxyType === 'redirect' || editingInterface.proxyType === 'url_redirect') && (
<div className="result-item">
<span className="label">重定向目标:</span>
<span className="value">{testResult.targetUrl || editingInterface.targetUrl}</span>
</div>
)}
{(editingInterface.proxyType === 'redirect' || editingInterface.proxyType === 'url_redirect') &&
testResult.formattedHeaders && (
<div className="result-item">
<span className="label">自定义请求头:</span>
<div className="value" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
{
editingInterface.customHeaders &&
Object.entries(editingInterface.customHeaders)
.map(([key, value]) => {
const generatedValue = testResult.customHeaders[key];
return (
<div key={key}>
{key}: {value.startsWith('@') ? (
<span>
<span style={{ color: '#d9d9d9' }}>{value}</span>
{' '} → {' '}
<span style={{ color: '#52c41a', fontWeight: 'bold' }}>{generatedValue}</span>
<span style={{ color: '#8c8c8c', fontSize: '12px' }}> (随机生成)</span>
</span>
) : generatedValue}
</div>
);
})
}
</div>
</div>
)}
{editingInterface.proxyType === 'response' && (
<>
<div className="result-item">
<span className="label">状态码:</span>
<span className="value">{testResult.statusCode}</span>
</div>
<div className="result-item">
<span className="label">内容类型:</span>
<span className="value">{testResult.contentType}</span>
</div>
</>
)}
{testResult.mockInfo && (
<>
<div className="result-item">
<span className="label">模拟延迟:</span>
<span className="value">{testResult.mockInfo.delay}ms</span>
</div>
<div className="result-item">
<span className="label">响应时间:</span>
<span className="value">{new Date(testResult.mockInfo.timestamp).toLocaleString()}</span>
</div>
</>
)}
{editingInterface.proxyType === 'response' && (
<div className="result-body">
<div className="label">响应内容:</div>
<pre>{formatResponseContent(testResult.responseBody, testResult.contentType)}</pre>
</div>
)}
</div>
) : (
<div className="result-error">
<ExclamationCircleOutlined /> {testResult.error}
</div>
)}
</div>
)}
</div>
)}
</Modal>
{/* 预览内容弹窗 */}
<Modal
title="预览响应内容"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewVisible(false)}>