whistle.mock-plugins 1.2.2-beta.8 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COLUMN_RESIZE_IMPLEMENTATION.md +162 -0
- package/TEST_GUIDE.md +156 -0
- package/VERSION.md +57 -3
- package/app/cgi-bin/proxy-fetch.js +117 -0
- package/app/cgi-bin/settings.js +37 -0
- package/app/cgi-bin/stats.js +149 -0
- package/app/public/index.html +1 -1
- package/app/public/js/index.js +1 -1
- package/app/public/js/index.js.LICENSE.txt +25 -2
- package/lib/models.js +9 -0
- package/lib/routes/interfaces.js +6 -3
- package/lib/routes/settings.js +51 -0
- package/lib/ruleManager.js +122 -63
- package/lib/server.js +4 -0
- package/lib/ui-server.js +20 -4
- package/package.json +2 -1
- package/ui/src/App.css +1 -1
- package/ui/src/components/AppLayout.js +317 -80
- package/ui/src/components/FeatureCard.js +7 -2
- package/ui/src/components/KeyboardShortcutsHelp.js +58 -0
- package/ui/src/components/VersionModal.js +4 -41
- package/ui/src/components/interface-management/DataManagementDrawer.js +115 -0
- package/ui/src/components/interface-management/PreviewModal.js +4 -6
- package/ui/src/components/interface-management/RealResponseRecorder.js +165 -0
- package/ui/src/components/interface-management/ResponseContentEditor.js +124 -165
- package/ui/src/components/interface-management/index.js +1 -0
- package/ui/src/hooks/useKeyboardShortcuts.js +99 -0
- package/ui/src/index.html +5 -2
- package/ui/src/index.js +12 -2
- package/ui/src/pages/InterfaceManagementV2.js +565 -141
- package/ui/src/pages/MockDataV2.js +21 -13
- package/ui/src/pages/Settings.js +275 -8
- package/ui/src/pages/Sniffer.js +521 -0
- package/ui/src/pages/Stats.js +288 -0
- package/ui/src/styles/cyber-theme.css +1234 -0
- package/ui/src/styles/earth-theme.css +618 -0
- package/ui/src/styles/interface-management.css +37 -1
- package/ui/src/styles/settings.css +45 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import AppLayout from '../components/AppLayout';
|
|
3
|
+
import {
|
|
4
|
+
Input, Select, Button, Tabs, Tag, Space, Divider, message,
|
|
5
|
+
Table, Popconfirm, Drawer, Form, Row, Col, Card, Tooltip, Empty
|
|
6
|
+
} from 'antd';
|
|
7
|
+
import {
|
|
8
|
+
SendOutlined, SaveOutlined, HistoryOutlined, DeleteOutlined,
|
|
9
|
+
PlusOutlined, CopyOutlined, ApiOutlined, ClearOutlined,
|
|
10
|
+
ThunderboltOutlined, CodeOutlined
|
|
11
|
+
} from '@ant-design/icons';
|
|
12
|
+
import axios from 'axios';
|
|
13
|
+
|
|
14
|
+
const { Option } = Select;
|
|
15
|
+
const { TextArea } = Input;
|
|
16
|
+
|
|
17
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
18
|
+
const BODY_TYPES = [
|
|
19
|
+
{ key: 'none', label: 'None' },
|
|
20
|
+
{ key: 'json', label: 'JSON' },
|
|
21
|
+
{ key: 'form', label: 'x-www-form-urlencoded' },
|
|
22
|
+
{ key: 'text', label: 'Raw Text' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const INITIAL_HISTORY = () => {
|
|
26
|
+
try {
|
|
27
|
+
const raw = localStorage.getItem('sniffer-history');
|
|
28
|
+
return raw ? JSON.parse(raw) : [];
|
|
29
|
+
} catch { return []; }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const Sniffer = () => {
|
|
33
|
+
const [url, setUrl] = useState('');
|
|
34
|
+
const [method, setMethod] = useState('GET');
|
|
35
|
+
const [bodyType, setBodyType] = useState('none');
|
|
36
|
+
const [bodyContent, setBodyContent] = useState('');
|
|
37
|
+
const [queryParams, setQueryParams] = useState([{ key: '', value: '', id: Date.now() }]);
|
|
38
|
+
const [headers, setHeaders] = useState([{ key: '', value: '', id: Date.now() + 1 }]);
|
|
39
|
+
const [loading, setLoading] = useState(false);
|
|
40
|
+
const [response, setResponse] = useState(null);
|
|
41
|
+
const [history, setHistory] = useState(INITIAL_HISTORY);
|
|
42
|
+
const [historyVisible, setHistoryVisible] = useState(false);
|
|
43
|
+
const [saveDrawerVisible, setSaveDrawerVisible] = useState(false);
|
|
44
|
+
const [saveForm] = Form.useForm();
|
|
45
|
+
const [features, setFeatures] = useState([]);
|
|
46
|
+
|
|
47
|
+
// 加载功能模块列表(用于保存时选择归属)
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
axios.get('/cgi-bin/features').then(res => {
|
|
50
|
+
if (res.data.code === 0) {
|
|
51
|
+
setFeatures(res.data.data || []);
|
|
52
|
+
}
|
|
53
|
+
}).catch(() => {});
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
// 持久化历史记录
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
localStorage.setItem('sniffer-history', JSON.stringify(history.slice(0, 100)));
|
|
59
|
+
}, [history]);
|
|
60
|
+
|
|
61
|
+
const addParam = (list, setList) => {
|
|
62
|
+
setList([...list, { key: '', value: '', id: Date.now() + Math.random() }]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const removeParam = (list, setList, idx) => {
|
|
66
|
+
const next = [...list];
|
|
67
|
+
next.splice(idx, 1);
|
|
68
|
+
if (next.length === 0) next.push({ key: '', value: '', id: Date.now() });
|
|
69
|
+
setList(next);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const updateParam = (list, setList, idx, field, value) => {
|
|
73
|
+
const next = [...list];
|
|
74
|
+
next[idx][field] = value;
|
|
75
|
+
setList(next);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const buildParamsObject = (list) => {
|
|
79
|
+
const obj = {};
|
|
80
|
+
list.forEach(item => {
|
|
81
|
+
if (item.key && item.value !== undefined) {
|
|
82
|
+
obj[item.key] = item.value;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return obj;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleSend = async () => {
|
|
89
|
+
if (!url.trim()) {
|
|
90
|
+
message.warning('请输入请求 URL');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setLoading(true);
|
|
94
|
+
setResponse(null);
|
|
95
|
+
try {
|
|
96
|
+
const payload = {
|
|
97
|
+
url: url.trim(),
|
|
98
|
+
method,
|
|
99
|
+
headers: buildParamsObject(headers),
|
|
100
|
+
queryParams: buildParamsObject(queryParams),
|
|
101
|
+
body: bodyType === 'none' ? undefined : bodyContent,
|
|
102
|
+
bodyType
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const res = await axios.post('/cgi-bin/proxy-fetch', payload);
|
|
106
|
+
if (res.data.code === 0) {
|
|
107
|
+
const resp = res.data.data;
|
|
108
|
+
setResponse(resp);
|
|
109
|
+
|
|
110
|
+
// 添加到历史记录
|
|
111
|
+
const record = {
|
|
112
|
+
id: Date.now().toString(),
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
url: payload.url,
|
|
115
|
+
method: payload.method,
|
|
116
|
+
status: resp.status,
|
|
117
|
+
statusText: resp.statusText,
|
|
118
|
+
preview: resp.body?.slice(0, 200) || '',
|
|
119
|
+
request: payload
|
|
120
|
+
};
|
|
121
|
+
setHistory(prev => [record, ...prev].slice(0, 100));
|
|
122
|
+
|
|
123
|
+
message.success(`请求成功 ${resp.status}`);
|
|
124
|
+
} else {
|
|
125
|
+
throw new Error(res.data.message);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
message.error(err.response?.data?.message || err.message || '请求失败');
|
|
129
|
+
} finally {
|
|
130
|
+
setLoading(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleCopyResponse = () => {
|
|
135
|
+
if (response?.body) {
|
|
136
|
+
navigator.clipboard.writeText(response.body);
|
|
137
|
+
message.success('响应内容已复制');
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const loadFromHistory = (record) => {
|
|
142
|
+
const req = record.request || {};
|
|
143
|
+
setUrl(req.url || record.url || '');
|
|
144
|
+
setMethod(req.method || 'GET');
|
|
145
|
+
setBodyType(req.bodyType || 'none');
|
|
146
|
+
setBodyContent(req.body || '');
|
|
147
|
+
|
|
148
|
+
const qs = Object.entries(req.queryParams || {}).map(([k, v], i) => ({
|
|
149
|
+
key: k, value: v, id: Date.now() + i
|
|
150
|
+
}));
|
|
151
|
+
setQueryParams(qs.length ? qs : [{ key: '', value: '', id: Date.now() }]);
|
|
152
|
+
|
|
153
|
+
const hs = Object.entries(req.headers || {}).map(([k, v], i) => ({
|
|
154
|
+
key: k, value: v, id: Date.now() + i + 1000
|
|
155
|
+
}));
|
|
156
|
+
setHeaders(hs.length ? hs : [{ key: '', value: '', id: Date.now() + 1000 }]);
|
|
157
|
+
|
|
158
|
+
setResponse({
|
|
159
|
+
status: record.status,
|
|
160
|
+
statusText: record.statusText,
|
|
161
|
+
body: record.preview,
|
|
162
|
+
headers: {}
|
|
163
|
+
});
|
|
164
|
+
setHistoryVisible(false);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const clearHistory = () => {
|
|
168
|
+
setHistory([]);
|
|
169
|
+
message.success('历史记录已清空');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleSaveAsMock = async () => {
|
|
173
|
+
try {
|
|
174
|
+
const values = await saveForm.validateFields();
|
|
175
|
+
const mockContent = response?.body || '';
|
|
176
|
+
|
|
177
|
+
// 构造接口数据
|
|
178
|
+
const interfaceData = {
|
|
179
|
+
name: values.name,
|
|
180
|
+
urlPattern: values.urlPattern || url,
|
|
181
|
+
proxyType: 'response',
|
|
182
|
+
httpMethod: method,
|
|
183
|
+
httpStatus: response?.status || 200,
|
|
184
|
+
contentType: 'application/json; charset=utf-8',
|
|
185
|
+
responseContent: mockContent,
|
|
186
|
+
responses: [
|
|
187
|
+
{
|
|
188
|
+
id: Date.now().toString(),
|
|
189
|
+
name: '默认响应',
|
|
190
|
+
description: '从接口嗅探导入',
|
|
191
|
+
content: mockContent,
|
|
192
|
+
paramMatchers: []
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
activeResponseId: Date.now().toString(),
|
|
196
|
+
active: true
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const res = await axios.post('/cgi-bin/interfaces', interfaceData);
|
|
200
|
+
if (res.data.code === 0) {
|
|
201
|
+
message.success('Mock 接口创建成功');
|
|
202
|
+
setSaveDrawerVisible(false);
|
|
203
|
+
saveForm.resetFields();
|
|
204
|
+
} else {
|
|
205
|
+
throw new Error(res.data.message);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err.errorFields) return;
|
|
209
|
+
message.error('创建失败: ' + (err.response?.data?.message || err.message));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const historyColumns = [
|
|
214
|
+
{
|
|
215
|
+
title: 'Method',
|
|
216
|
+
dataIndex: 'method',
|
|
217
|
+
key: 'method',
|
|
218
|
+
width: 80,
|
|
219
|
+
render: (m) => <Tag color={m === 'GET' ? 'blue' : m === 'POST' ? 'green' : 'default'}>{m}</Tag>
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
title: 'URL',
|
|
223
|
+
dataIndex: 'url',
|
|
224
|
+
key: 'url',
|
|
225
|
+
ellipsis: true,
|
|
226
|
+
render: (u) => <span style={{ fontSize: 12 }}>{u}</span>
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
title: 'Status',
|
|
230
|
+
dataIndex: 'status',
|
|
231
|
+
key: 'status',
|
|
232
|
+
width: 80,
|
|
233
|
+
render: (s) => <Tag color={s >= 200 && s < 300 ? 'success' : s >= 400 ? 'error' : 'default'}>{s}</Tag>
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
title: '操作',
|
|
237
|
+
key: 'action',
|
|
238
|
+
width: 120,
|
|
239
|
+
render: (_, record) => (
|
|
240
|
+
<Space>
|
|
241
|
+
<Button size="small" onClick={() => loadFromHistory(record)}>加载</Button>
|
|
242
|
+
<Popconfirm title="确定删除?" onConfirm={() => setHistory(prev => prev.filter(h => h.id !== record.id))}>
|
|
243
|
+
<Button size="small" danger icon={<DeleteOutlined />} />
|
|
244
|
+
</Popconfirm>
|
|
245
|
+
</Space>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const formatBody = (body) => {
|
|
251
|
+
try {
|
|
252
|
+
const json = JSON.parse(body);
|
|
253
|
+
return JSON.stringify(json, null, 2);
|
|
254
|
+
} catch {
|
|
255
|
+
return body;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<AppLayout>
|
|
261
|
+
<div className="page-container">
|
|
262
|
+
<div className="page-title-bar">
|
|
263
|
+
<div>
|
|
264
|
+
<h1 className="page-title"><ApiOutlined /> 接口嗅探</h1>
|
|
265
|
+
<div className="page-description">
|
|
266
|
+
像 Postman 一样发送真实请求,抓取响应后一键保存为 Mock 接口
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
<Space>
|
|
270
|
+
<Button icon={<HistoryOutlined />} onClick={() => setHistoryVisible(true)}>
|
|
271
|
+
历史记录 ({history.length})
|
|
272
|
+
</Button>
|
|
273
|
+
</Space>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* 请求构建器 */}
|
|
277
|
+
<Card style={{ marginBottom: 24 }}>
|
|
278
|
+
{/* URL + Method + Send */}
|
|
279
|
+
<Row gutter={8} style={{ marginBottom: 16 }}>
|
|
280
|
+
<Col flex="120px">
|
|
281
|
+
<Select value={method} onChange={setMethod} style={{ width: '100%' }}>
|
|
282
|
+
{HTTP_METHODS.map(m => <Option key={m} value={m}>{m}</Option>)}
|
|
283
|
+
</Select>
|
|
284
|
+
</Col>
|
|
285
|
+
<Col flex="auto">
|
|
286
|
+
<Input
|
|
287
|
+
placeholder="https://api.example.com/users"
|
|
288
|
+
value={url}
|
|
289
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
290
|
+
onPressEnter={handleSend}
|
|
291
|
+
style={{ fontFamily: 'monospace' }}
|
|
292
|
+
prefix={<ThunderboltOutlined />}
|
|
293
|
+
/>
|
|
294
|
+
</Col>
|
|
295
|
+
<Col flex="120px">
|
|
296
|
+
<Button
|
|
297
|
+
type="primary"
|
|
298
|
+
icon={<SendOutlined />}
|
|
299
|
+
loading={loading}
|
|
300
|
+
onClick={handleSend}
|
|
301
|
+
block
|
|
302
|
+
>
|
|
303
|
+
发送
|
|
304
|
+
</Button>
|
|
305
|
+
</Col>
|
|
306
|
+
</Row>
|
|
307
|
+
|
|
308
|
+
{/* Tabs: Params / Headers / Body */}
|
|
309
|
+
<Tabs defaultActiveKey="params">
|
|
310
|
+
<Tabs.TabPane tab={<span><CodeOutlined /> Query 参数</span>} key="params">
|
|
311
|
+
<Space direction="vertical" style={{ width: '100%' }}>
|
|
312
|
+
{queryParams.map((item, idx) => (
|
|
313
|
+
<Row gutter={8} key={item.id}>
|
|
314
|
+
<Col span={8}>
|
|
315
|
+
<Input
|
|
316
|
+
placeholder="参数名"
|
|
317
|
+
value={item.key}
|
|
318
|
+
onChange={(e) => updateParam(queryParams, setQueryParams, idx, 'key', e.target.value)}
|
|
319
|
+
/>
|
|
320
|
+
</Col>
|
|
321
|
+
<Col span={14}>
|
|
322
|
+
<Input
|
|
323
|
+
placeholder="参数值"
|
|
324
|
+
value={item.value}
|
|
325
|
+
onChange={(e) => updateParam(queryParams, setQueryParams, idx, 'value', e.target.value)}
|
|
326
|
+
/>
|
|
327
|
+
</Col>
|
|
328
|
+
<Col span={2}>
|
|
329
|
+
<Button
|
|
330
|
+
icon={<DeleteOutlined />}
|
|
331
|
+
onClick={() => removeParam(queryParams, setQueryParams, idx)}
|
|
332
|
+
disabled={queryParams.length === 1 && !item.key}
|
|
333
|
+
/>
|
|
334
|
+
</Col>
|
|
335
|
+
</Row>
|
|
336
|
+
))}
|
|
337
|
+
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addParam(queryParams, setQueryParams)}>
|
|
338
|
+
添加参数
|
|
339
|
+
</Button>
|
|
340
|
+
</Space>
|
|
341
|
+
</Tabs.TabPane>
|
|
342
|
+
|
|
343
|
+
<Tabs.TabPane tab={<span><CodeOutlined /> Headers</span>} key="headers">
|
|
344
|
+
<Space direction="vertical" style={{ width: '100%' }}>
|
|
345
|
+
{headers.map((item, idx) => (
|
|
346
|
+
<Row gutter={8} key={item.id}>
|
|
347
|
+
<Col span={8}>
|
|
348
|
+
<Input
|
|
349
|
+
placeholder="Header 名"
|
|
350
|
+
value={item.key}
|
|
351
|
+
onChange={(e) => updateParam(headers, setHeaders, idx, 'key', e.target.value)}
|
|
352
|
+
/>
|
|
353
|
+
</Col>
|
|
354
|
+
<Col span={14}>
|
|
355
|
+
<Input
|
|
356
|
+
placeholder="Header 值"
|
|
357
|
+
value={item.value}
|
|
358
|
+
onChange={(e) => updateParam(headers, setHeaders, idx, 'value', e.target.value)}
|
|
359
|
+
/>
|
|
360
|
+
</Col>
|
|
361
|
+
<Col span={2}>
|
|
362
|
+
<Button
|
|
363
|
+
icon={<DeleteOutlined />}
|
|
364
|
+
onClick={() => removeParam(headers, setHeaders, idx)}
|
|
365
|
+
disabled={headers.length === 1 && !item.key}
|
|
366
|
+
/>
|
|
367
|
+
</Col>
|
|
368
|
+
</Row>
|
|
369
|
+
))}
|
|
370
|
+
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addParam(headers, setHeaders)}>
|
|
371
|
+
添加 Header
|
|
372
|
+
</Button>
|
|
373
|
+
</Space>
|
|
374
|
+
</Tabs.TabPane>
|
|
375
|
+
|
|
376
|
+
<Tabs.TabPane tab={<span><CodeOutlined /> Body</span>} key="body">
|
|
377
|
+
<Space direction="vertical" style={{ width: '100%' }}>
|
|
378
|
+
<Select value={bodyType} onChange={setBodyType} style={{ width: 200 }}>
|
|
379
|
+
{BODY_TYPES.map(t => <Option key={t.key} value={t.key}>{t.label}</Option>)}
|
|
380
|
+
</Select>
|
|
381
|
+
{bodyType !== 'none' && (
|
|
382
|
+
<TextArea
|
|
383
|
+
rows={8}
|
|
384
|
+
placeholder={bodyType === 'json' ? '{\n "name": "test"\n}' : bodyType === 'form' ? 'name=test&age=18' : 'raw text...'}
|
|
385
|
+
value={bodyContent}
|
|
386
|
+
onChange={(e) => setBodyContent(e.target.value)}
|
|
387
|
+
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
|
388
|
+
/>
|
|
389
|
+
)}
|
|
390
|
+
</Space>
|
|
391
|
+
</Tabs.TabPane>
|
|
392
|
+
</Tabs>
|
|
393
|
+
</Card>
|
|
394
|
+
|
|
395
|
+
{/* 响应区域 */}
|
|
396
|
+
{response && (
|
|
397
|
+
<Card
|
|
398
|
+
title={
|
|
399
|
+
<Space>
|
|
400
|
+
<span>响应结果</span>
|
|
401
|
+
<Tag color={response.status >= 200 && response.status < 300 ? 'success' : response.status >= 400 ? 'error' : 'default'}>
|
|
402
|
+
{response.status} {response.statusText}
|
|
403
|
+
</Tag>
|
|
404
|
+
</Space>
|
|
405
|
+
}
|
|
406
|
+
extra={
|
|
407
|
+
<Space>
|
|
408
|
+
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyResponse}>复制响应</Button>
|
|
409
|
+
<Button size="small" icon={<SaveOutlined />} type="primary" onClick={() => {
|
|
410
|
+
saveForm.setFieldsValue({ name: '', urlPattern: url });
|
|
411
|
+
setSaveDrawerVisible(true);
|
|
412
|
+
}}>
|
|
413
|
+
保存为 Mock 接口
|
|
414
|
+
</Button>
|
|
415
|
+
</Space>
|
|
416
|
+
}
|
|
417
|
+
>
|
|
418
|
+
<Tabs defaultActiveKey="body">
|
|
419
|
+
<Tabs.TabPane tab="Body" key="body">
|
|
420
|
+
<TextArea
|
|
421
|
+
value={formatBody(response.body)}
|
|
422
|
+
readOnly
|
|
423
|
+
rows={16}
|
|
424
|
+
style={{ fontFamily: 'monospace', fontSize: 12, background: '#fafafa' }}
|
|
425
|
+
/>
|
|
426
|
+
</Tabs.TabPane>
|
|
427
|
+
<Tabs.TabPane tab="Headers" key="headers">
|
|
428
|
+
<pre style={{ background: '#fafafa', padding: 16, borderRadius: 4, fontSize: 12 }}>
|
|
429
|
+
{JSON.stringify(response.headers, null, 2)}
|
|
430
|
+
</pre>
|
|
431
|
+
</Tabs.TabPane>
|
|
432
|
+
</Tabs>
|
|
433
|
+
</Card>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{!response && !loading && (
|
|
437
|
+
<Empty description="输入 URL 并点击「发送」开始嗅探真实接口" />
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
{/* 历史记录 Drawer */}
|
|
442
|
+
<Drawer
|
|
443
|
+
title="历史记录"
|
|
444
|
+
open={historyVisible}
|
|
445
|
+
onClose={() => setHistoryVisible(false)}
|
|
446
|
+
width={600}
|
|
447
|
+
extra={
|
|
448
|
+
<Popconfirm title="确定清空全部历史?" onConfirm={clearHistory}>
|
|
449
|
+
<Button icon={<ClearOutlined />} danger>清空</Button>
|
|
450
|
+
</Popconfirm>
|
|
451
|
+
}
|
|
452
|
+
>
|
|
453
|
+
<Table
|
|
454
|
+
dataSource={history}
|
|
455
|
+
columns={historyColumns}
|
|
456
|
+
rowKey="id"
|
|
457
|
+
pagination={{ pageSize: 10 }}
|
|
458
|
+
size="small"
|
|
459
|
+
/>
|
|
460
|
+
</Drawer>
|
|
461
|
+
|
|
462
|
+
{/* 保存为 Mock Drawer */}
|
|
463
|
+
<Drawer
|
|
464
|
+
title="保存为 Mock 接口"
|
|
465
|
+
open={saveDrawerVisible}
|
|
466
|
+
onClose={() => setSaveDrawerVisible(false)}
|
|
467
|
+
width={480}
|
|
468
|
+
footer={
|
|
469
|
+
<Space style={{ float: 'right' }}>
|
|
470
|
+
<Button onClick={() => setSaveDrawerVisible(false)}>取消</Button>
|
|
471
|
+
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveAsMock}>创建接口</Button>
|
|
472
|
+
</Space>
|
|
473
|
+
}
|
|
474
|
+
>
|
|
475
|
+
<Form form={saveForm} layout="vertical">
|
|
476
|
+
<Form.Item
|
|
477
|
+
name="name"
|
|
478
|
+
label="接口名称"
|
|
479
|
+
rules={[{ required: true, message: '请输入接口名称' }]}
|
|
480
|
+
>
|
|
481
|
+
<Input placeholder="例如:获取用户列表" />
|
|
482
|
+
</Form.Item>
|
|
483
|
+
<Form.Item
|
|
484
|
+
name="featureId"
|
|
485
|
+
label="所属功能模块"
|
|
486
|
+
rules={[{ required: true, message: '请选择功能模块' }]}
|
|
487
|
+
>
|
|
488
|
+
<Select placeholder="选择功能模块">
|
|
489
|
+
{features.map(f => (
|
|
490
|
+
<Option key={f.id} value={f.id}>{f.name}</Option>
|
|
491
|
+
))}
|
|
492
|
+
</Select>
|
|
493
|
+
</Form.Item>
|
|
494
|
+
<Form.Item
|
|
495
|
+
name="urlPattern"
|
|
496
|
+
label="URL 匹配规则"
|
|
497
|
+
rules={[{ required: true, message: '请输入匹配规则' }]}
|
|
498
|
+
>
|
|
499
|
+
<Input placeholder="例如:/api/users 或 /api/users/*" />
|
|
500
|
+
</Form.Item>
|
|
501
|
+
<Form.Item label="请求方法">
|
|
502
|
+
<Tag>{method}</Tag>
|
|
503
|
+
</Form.Item>
|
|
504
|
+
<Form.Item label="响应状态码">
|
|
505
|
+
<Tag>{response?.status}</Tag>
|
|
506
|
+
</Form.Item>
|
|
507
|
+
<Form.Item label="响应预览">
|
|
508
|
+
<TextArea
|
|
509
|
+
value={response?.body?.slice(0, 500)}
|
|
510
|
+
readOnly
|
|
511
|
+
rows={6}
|
|
512
|
+
style={{ fontFamily: 'monospace', fontSize: 11, background: '#fafafa' }}
|
|
513
|
+
/>
|
|
514
|
+
</Form.Item>
|
|
515
|
+
</Form>
|
|
516
|
+
</Drawer>
|
|
517
|
+
</AppLayout>
|
|
518
|
+
);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
export default Sniffer;
|