whistle.mock-plugins
Version:
Whistle 插件,用于快速创建 API 模拟数据
522 lines (486 loc) • 17.7 kB
JavaScript
import React, { useState, useEffect } from 'react';
import AppLayout from '../components/AppLayout';
import {
Input, Select, Button, Tabs, Tag, Space, Divider, message,
Table, Popconfirm, Drawer, Form, Row, Col, Card, Tooltip, Empty
} from 'antd';
import {
SendOutlined, SaveOutlined, HistoryOutlined, DeleteOutlined,
PlusOutlined, CopyOutlined, ApiOutlined, ClearOutlined,
ThunderboltOutlined, CodeOutlined
} from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
const { TextArea } = Input;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
const BODY_TYPES = [
{ key: 'none', label: 'None' },
{ key: 'json', label: 'JSON' },
{ key: 'form', label: 'x-www-form-urlencoded' },
{ key: 'text', label: 'Raw Text' },
];
const INITIAL_HISTORY = () => {
try {
const raw = localStorage.getItem('sniffer-history');
return raw ? JSON.parse(raw) : [];
} catch { return []; }
};
const Sniffer = () => {
const [url, setUrl] = useState('');
const [method, setMethod] = useState('GET');
const [bodyType, setBodyType] = useState('none');
const [bodyContent, setBodyContent] = useState('');
const [queryParams, setQueryParams] = useState([{ key: '', value: '', id: Date.now() }]);
const [headers, setHeaders] = useState([{ key: '', value: '', id: Date.now() + 1 }]);
const [loading, setLoading] = useState(false);
const [response, setResponse] = useState(null);
const [history, setHistory] = useState(INITIAL_HISTORY);
const [historyVisible, setHistoryVisible] = useState(false);
const [saveDrawerVisible, setSaveDrawerVisible] = useState(false);
const [saveForm] = Form.useForm();
const [features, setFeatures] = useState([]);
// 加载功能模块列表(用于保存时选择归属)
useEffect(() => {
axios.get('/cgi-bin/features').then(res => {
if (res.data.code === 0) {
setFeatures(res.data.data || []);
}
}).catch(() => {});
}, []);
// 持久化历史记录
useEffect(() => {
localStorage.setItem('sniffer-history', JSON.stringify(history.slice(0, 100)));
}, [history]);
const addParam = (list, setList) => {
setList([...list, { key: '', value: '', id: Date.now() + Math.random() }]);
};
const removeParam = (list, setList, idx) => {
const next = [...list];
next.splice(idx, 1);
if (next.length === 0) next.push({ key: '', value: '', id: Date.now() });
setList(next);
};
const updateParam = (list, setList, idx, field, value) => {
const next = [...list];
next[idx][field] = value;
setList(next);
};
const buildParamsObject = (list) => {
const obj = {};
list.forEach(item => {
if (item.key && item.value !== undefined) {
obj[item.key] = item.value;
}
});
return obj;
};
const handleSend = async () => {
if (!url.trim()) {
message.warning('请输入请求 URL');
return;
}
setLoading(true);
setResponse(null);
try {
const payload = {
url: url.trim(),
method,
headers: buildParamsObject(headers),
queryParams: buildParamsObject(queryParams),
body: bodyType === 'none' ? undefined : bodyContent,
bodyType
};
const res = await axios.post('/cgi-bin/proxy-fetch', payload);
if (res.data.code === 0) {
const resp = res.data.data;
setResponse(resp);
// 添加到历史记录
const record = {
id: Date.now().toString(),
timestamp: new Date().toISOString(),
url: payload.url,
method: payload.method,
status: resp.status,
statusText: resp.statusText,
preview: resp.body?.slice(0, 200) || '',
request: payload
};
setHistory(prev => [record, ...prev].slice(0, 100));
message.success(`请求成功 ${resp.status}`);
} else {
throw new Error(res.data.message);
}
} catch (err) {
message.error(err.response?.data?.message || err.message || '请求失败');
} finally {
setLoading(false);
}
};
const handleCopyResponse = () => {
if (response?.body) {
navigator.clipboard.writeText(response.body);
message.success('响应内容已复制');
}
};
const loadFromHistory = (record) => {
const req = record.request || {};
setUrl(req.url || record.url || '');
setMethod(req.method || 'GET');
setBodyType(req.bodyType || 'none');
setBodyContent(req.body || '');
const qs = Object.entries(req.queryParams || {}).map(([k, v], i) => ({
key: k, value: v, id: Date.now() + i
}));
setQueryParams(qs.length ? qs : [{ key: '', value: '', id: Date.now() }]);
const hs = Object.entries(req.headers || {}).map(([k, v], i) => ({
key: k, value: v, id: Date.now() + i + 1000
}));
setHeaders(hs.length ? hs : [{ key: '', value: '', id: Date.now() + 1000 }]);
setResponse({
status: record.status,
statusText: record.statusText,
body: record.preview,
headers: {}
});
setHistoryVisible(false);
};
const clearHistory = () => {
setHistory([]);
message.success('历史记录已清空');
};
const handleSaveAsMock = async () => {
try {
const values = await saveForm.validateFields();
const mockContent = response?.body || '';
// 构造接口数据
const interfaceData = {
name: values.name,
urlPattern: values.urlPattern || url,
proxyType: 'response',
httpMethod: method,
httpStatus: response?.status || 200,
contentType: 'application/json; charset=utf-8',
responseContent: mockContent,
responses: [
{
id: Date.now().toString(),
name: '默认响应',
description: '从接口嗅探导入',
content: mockContent,
paramMatchers: []
}
],
activeResponseId: Date.now().toString(),
active: true
};
const res = await axios.post('/cgi-bin/interfaces', interfaceData);
if (res.data.code === 0) {
message.success('Mock 接口创建成功');
setSaveDrawerVisible(false);
saveForm.resetFields();
} else {
throw new Error(res.data.message);
}
} catch (err) {
if (err.errorFields) return;
message.error('创建失败: ' + (err.response?.data?.message || err.message));
}
};
const historyColumns = [
{
title: 'Method',
dataIndex: 'method',
key: 'method',
width: 80,
render: (m) => <Tag color={m === 'GET' ? 'blue' : m === 'POST' ? 'green' : 'default'}>{m}</Tag>
},
{
title: 'URL',
dataIndex: 'url',
key: 'url',
ellipsis: true,
render: (u) => <span style={{ fontSize: 12 }}>{u}</span>
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 80,
render: (s) => <Tag color={s >= 200 && s < 300 ? 'success' : s >= 400 ? 'error' : 'default'}>{s}</Tag>
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => loadFromHistory(record)}>加载</Button>
<Popconfirm title="确定删除?" onConfirm={() => setHistory(prev => prev.filter(h => h.id !== record.id))}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
];
const formatBody = (body) => {
try {
const json = JSON.parse(body);
return JSON.stringify(json, null, 2);
} catch {
return body;
}
};
return (
<AppLayout>
<div className="page-container">
<div className="page-title-bar">
<div>
<h1 className="page-title"><ApiOutlined /> 接口嗅探</h1>
<div className="page-description">
像 Postman 一样发送真实请求,抓取响应后一键保存为 Mock 接口
</div>
</div>
<Space>
<Button icon={<HistoryOutlined />} onClick={() => setHistoryVisible(true)}>
历史记录 ({history.length})
</Button>
</Space>
</div>
{/* 请求构建器 */}
<Card style={{ marginBottom: 24 }}>
{/* URL + Method + Send */}
<Row gutter={8} style={{ marginBottom: 16 }}>
<Col flex="120px">
<Select value={method} onChange={setMethod} style={{ width: '100%' }}>
{HTTP_METHODS.map(m => <Option key={m} value={m}>{m}</Option>)}
</Select>
</Col>
<Col flex="auto">
<Input
placeholder="https://api.example.com/users"
value={url}
onChange={(e) => setUrl(e.target.value)}
onPressEnter={handleSend}
style={{ fontFamily: 'monospace' }}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col flex="120px">
<Button
type="primary"
icon={<SendOutlined />}
loading={loading}
onClick={handleSend}
block
>
发送
</Button>
</Col>
</Row>
{/* Tabs: Params / Headers / Body */}
<Tabs defaultActiveKey="params">
<Tabs.TabPane tab={<span><CodeOutlined /> Query 参数</span>} key="params">
<Space direction="vertical" style={{ width: '100%' }}>
{queryParams.map((item, idx) => (
<Row gutter={8} key={item.id}>
<Col span={8}>
<Input
placeholder="参数名"
value={item.key}
onChange={(e) => updateParam(queryParams, setQueryParams, idx, 'key', e.target.value)}
/>
</Col>
<Col span={14}>
<Input
placeholder="参数值"
value={item.value}
onChange={(e) => updateParam(queryParams, setQueryParams, idx, 'value', e.target.value)}
/>
</Col>
<Col span={2}>
<Button
icon={<DeleteOutlined />}
onClick={() => removeParam(queryParams, setQueryParams, idx)}
disabled={queryParams.length === 1 && !item.key}
/>
</Col>
</Row>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addParam(queryParams, setQueryParams)}>
添加参数
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane tab={<span><CodeOutlined /> Headers</span>} key="headers">
<Space direction="vertical" style={{ width: '100%' }}>
{headers.map((item, idx) => (
<Row gutter={8} key={item.id}>
<Col span={8}>
<Input
placeholder="Header 名"
value={item.key}
onChange={(e) => updateParam(headers, setHeaders, idx, 'key', e.target.value)}
/>
</Col>
<Col span={14}>
<Input
placeholder="Header 值"
value={item.value}
onChange={(e) => updateParam(headers, setHeaders, idx, 'value', e.target.value)}
/>
</Col>
<Col span={2}>
<Button
icon={<DeleteOutlined />}
onClick={() => removeParam(headers, setHeaders, idx)}
disabled={headers.length === 1 && !item.key}
/>
</Col>
</Row>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addParam(headers, setHeaders)}>
添加 Header
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane tab={<span><CodeOutlined /> Body</span>} key="body">
<Space direction="vertical" style={{ width: '100%' }}>
<Select value={bodyType} onChange={setBodyType} style={{ width: 200 }}>
{BODY_TYPES.map(t => <Option key={t.key} value={t.key}>{t.label}</Option>)}
</Select>
{bodyType !== 'none' && (
<TextArea
rows={8}
placeholder={bodyType === 'json' ? '{\n "name": "test"\n}' : bodyType === 'form' ? 'name=test&age=18' : 'raw text...'}
value={bodyContent}
onChange={(e) => setBodyContent(e.target.value)}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
)}
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
{/* 响应区域 */}
{response && (
<Card
title={
<Space>
<span>响应结果</span>
<Tag color={response.status >= 200 && response.status < 300 ? 'success' : response.status >= 400 ? 'error' : 'default'}>
{response.status} {response.statusText}
</Tag>
</Space>
}
extra={
<Space>
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyResponse}>复制响应</Button>
<Button size="small" icon={<SaveOutlined />} type="primary" onClick={() => {
saveForm.setFieldsValue({ name: '', urlPattern: url });
setSaveDrawerVisible(true);
}}>
保存为 Mock 接口
</Button>
</Space>
}
>
<Tabs defaultActiveKey="body">
<Tabs.TabPane tab="Body" key="body">
<TextArea
value={formatBody(response.body)}
readOnly
rows={16}
style={{ fontFamily: 'monospace', fontSize: 12, background: '#fafafa' }}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="Headers" key="headers">
<pre style={{ background: '#fafafa', padding: 16, borderRadius: 4, fontSize: 12 }}>
{JSON.stringify(response.headers, null, 2)}
</pre>
</Tabs.TabPane>
</Tabs>
</Card>
)}
{!response && !loading && (
<Empty description="输入 URL 并点击「发送」开始嗅探真实接口" />
)}
</div>
{/* 历史记录 Drawer */}
<Drawer
title="历史记录"
open={historyVisible}
onClose={() => setHistoryVisible(false)}
width={600}
extra={
<Popconfirm title="确定清空全部历史?" onConfirm={clearHistory}>
<Button icon={<ClearOutlined />} danger>清空</Button>
</Popconfirm>
}
>
<Table
dataSource={history}
columns={historyColumns}
rowKey="id"
pagination={{ pageSize: 10 }}
size="small"
/>
</Drawer>
{/* 保存为 Mock Drawer */}
<Drawer
title="保存为 Mock 接口"
open={saveDrawerVisible}
onClose={() => setSaveDrawerVisible(false)}
width={480}
footer={
<Space style={{ float: 'right' }}>
<Button onClick={() => setSaveDrawerVisible(false)}>取消</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveAsMock}>创建接口</Button>
</Space>
}
>
<Form form={saveForm} layout="vertical">
<Form.Item
name="name"
label="接口名称"
rules={[{ required: true, message: '请输入接口名称' }]}
>
<Input placeholder="例如:获取用户列表" />
</Form.Item>
<Form.Item
name="featureId"
label="所属功能模块"
rules={[{ required: true, message: '请选择功能模块' }]}
>
<Select placeholder="选择功能模块">
{features.map(f => (
<Option key={f.id} value={f.id}>{f.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="urlPattern"
label="URL 匹配规则"
rules={[{ required: true, message: '请输入匹配规则' }]}
>
<Input placeholder="例如:/api/users 或 /api/users/*" />
</Form.Item>
<Form.Item label="请求方法">
<Tag>{method}</Tag>
</Form.Item>
<Form.Item label="响应状态码">
<Tag>{response?.status}</Tag>
</Form.Item>
<Form.Item label="响应预览">
<TextArea
value={response?.body?.slice(0, 500)}
readOnly
rows={6}
style={{ fontFamily: 'monospace', fontSize: 11, background: '#fafafa' }}
/>
</Form.Item>
</Form>
</Drawer>
</AppLayout>
);
};
export default Sniffer;