whistle.mock-plugins 1.2.2-beta.23 → 1.2.2-beta.28

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.
@@ -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;