whistle.mock-plugins 1.2.2-beta.25 → 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.
@@ -2,7 +2,7 @@
2
2
  * InterfaceManagementV2.js - 接口管理页面
3
3
  */
4
4
 
5
- import React, { useState, useEffect } from 'react';
5
+ import React, { useState, useEffect, useRef } from 'react';
6
6
  import { useParams, useHistory } from 'react-router-dom';
7
7
  import AppLayout from '../components/AppLayout';
8
8
  import {
@@ -87,6 +87,7 @@ const InterfaceManagement = () => {
87
87
  const [currentResponseId, setCurrentResponseId] = useState(null);
88
88
 
89
89
  // 搜索状态
90
+ const searchRef = useRef(null);
90
91
  const [searchValue, setSearchValue] = useState('');
91
92
  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
92
93
  const [drawerVisible, setDrawerVisible] = useState(false);
@@ -136,6 +137,29 @@ const InterfaceManagement = () => {
136
137
  }
137
138
  }, [selectedFeatureId]);
138
139
 
140
+ // 键盘快捷键监听
141
+ useEffect(() => {
142
+ const handleSave = () => {
143
+ if (modalVisible) {
144
+ handleSubmit();
145
+ }
146
+ };
147
+ const handleNew = () => {
148
+ handleAddInterface();
149
+ };
150
+ const handleFocusSearch = () => {
151
+ searchRef.current?.focus();
152
+ };
153
+ window.addEventListener('shortcut-save', handleSave);
154
+ window.addEventListener('shortcut-new-interface', handleNew);
155
+ window.addEventListener('shortcut-focus-search', handleFocusSearch);
156
+ return () => {
157
+ window.removeEventListener('shortcut-save', handleSave);
158
+ window.removeEventListener('shortcut-new-interface', handleNew);
159
+ window.removeEventListener('shortcut-focus-search', handleFocusSearch);
160
+ };
161
+ }, [modalVisible]);
162
+
139
163
  // 监听数据管理显示模式变化
140
164
  useEffect(() => {
141
165
  const savedMode = localStorage.getItem('dataManagementDisplayMode');
@@ -1006,7 +1030,7 @@ const InterfaceManagement = () => {
1006
1030
  <div key={key}>
1007
1031
  {key}: {value}
1008
1032
  {value && value.startsWith('@') && (
1009
- <span style={{ color: '#00ff41' }}> (随机)</span>
1033
+ <span style={{ color: '#52c41a' }}> (随机)</span>
1010
1034
  )}
1011
1035
  </div>
1012
1036
  ))}
@@ -1058,7 +1082,7 @@ const InterfaceManagement = () => {
1058
1082
  {interfaceMatchers.filter(m => m.paramPath).map((m, i) => (
1059
1083
  <div key={i} style={{ fontSize: '12px' }}>
1060
1084
  {m.paramPath}: {m.paramValue}
1061
- <span style={{ color: '#00ff41', marginLeft: 4 }}>
1085
+ <span style={{ color: '#52c41a', marginLeft: 4 }}>
1062
1086
  ({m.matchType === 'exact' ? '精确' : m.matchType === 'contains' ? '包含' : '正则'})
1063
1087
  </span>
1064
1088
  </div>
@@ -1074,7 +1098,7 @@ const InterfaceManagement = () => {
1074
1098
  {resp.paramMatchers.filter(m => m.paramPath).map((m, mi) => (
1075
1099
  <div key={mi} style={{ fontSize: '12px', paddingLeft: 8 }}>
1076
1100
  {m.paramPath}: {m.paramValue}
1077
- <span style={{ color: '#00ff41', marginLeft: 4 }}>
1101
+ <span style={{ color: '#52c41a', marginLeft: 4 }}>
1078
1102
  ({m.matchType === 'exact' ? '精确' : m.matchType === 'contains' ? '包含' : '正则'})
1079
1103
  </span>
1080
1104
  </div>
@@ -1231,6 +1255,7 @@ const InterfaceManagement = () => {
1231
1255
  </div>
1232
1256
 
1233
1257
  <Search
1258
+ ref={searchRef}
1234
1259
  placeholder="搜索接口名称或URL"
1235
1260
  onSearch={handleSearch}
1236
1261
  value={searchValue}
@@ -1312,7 +1337,7 @@ const InterfaceManagement = () => {
1312
1337
  ) : (
1313
1338
  <Badge
1314
1339
  count={`共 ${filteredInterfaces.length} 个接口`}
1315
- style={{ backgroundColor: '#00ff41' }}
1340
+ style={{ backgroundColor: '#52c41a' }}
1316
1341
  />
1317
1342
  )}
1318
1343
  </span>
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import React, { useState, useEffect } from 'react';
6
+ import { useRef } from 'react';
6
7
  import { useHistory } from 'react-router-dom';
7
8
  import AppLayout from '../components/AppLayout';
8
9
  import { Card, Row, Col, Button, Modal, Form, Input, message, Switch, Empty, Spin, Typography, Tooltip, Badge, Select, Pagination, Table, Space, Tag } from 'antd';
@@ -57,6 +58,24 @@ const MockData = () => {
57
58
  fetchFeatures();
58
59
  }, []);
59
60
 
61
+ // 键盘快捷键监听
62
+ useEffect(() => {
63
+ const handleSave = () => {
64
+ if (showModal) {
65
+ handleSubmit();
66
+ }
67
+ };
68
+ const handleNew = () => {
69
+ openModal();
70
+ };
71
+ window.addEventListener('shortcut-save', handleSave);
72
+ window.addEventListener('shortcut-new-interface', handleNew);
73
+ return () => {
74
+ window.removeEventListener('shortcut-save', handleSave);
75
+ window.removeEventListener('shortcut-new-interface', handleNew);
76
+ };
77
+ }, [showModal]);
78
+
60
79
  // 获取所有功能模块
61
80
  const fetchFeatures = async () => {
62
81
  try {
@@ -454,7 +473,7 @@ const MockData = () => {
454
473
  <Col xs={24} sm={12} md={8} lg={6} key={feature.id} style={{ marginBottom: 16 }}>
455
474
  <Badge.Ribbon
456
475
  text={feature.active ? '已启用' : '已禁用'}
457
- color={feature.active ? '#00ff41' : '#f5222d'}
476
+ color={feature.active ? '#52c41a' : '#f5222d'}
458
477
  style={{ display: 'block' }}
459
478
  >
460
479
  <Card
@@ -661,17 +680,6 @@ const MockData = () => {
661
680
  <div className="title-section">
662
681
  <div className="title-with-badge">
663
682
  <h1 className="page-title">功能模块管理</h1>
664
- <Badge
665
- count="V2"
666
- style={{
667
- backgroundColor: '#00ff41',
668
- fontSize: '12px',
669
- height: '20px',
670
- lineHeight: '20px',
671
- borderRadius: '10px',
672
- marginLeft: '12px'
673
- }}
674
- />
675
683
  </div>
676
684
  <div className="page-description">
677
685
  创建和管理功能模块,为每个功能配置独立的接口
@@ -689,7 +697,7 @@ const MockData = () => {
689
697
  type="primary"
690
698
  icon={<ExportOutlined />}
691
699
  onClick={importFeatureConfig}
692
- style={{ backgroundColor: '#00ff41', borderColor: '#00ff41' }}
700
+ style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
693
701
  >
694
702
  导入功能
695
703
  </Button>
@@ -78,11 +78,7 @@ const Settings = () => {
78
78
  setCyberThemeEnabled(checked);
79
79
  localStorage.setItem('cyberTheme', String(checked));
80
80
  window.dispatchEvent(new Event('storage'));
81
- message.success(checked ? '赛博朋克主题已开启,刷新页面后生效' : '经典主题已开启,刷新页面后生效');
82
- // 1.5秒后自动刷新
83
- setTimeout(() => {
84
- window.location.reload();
85
- }, 1500);
81
+ message.success(checked ? '赛博朋克主题已开启' : '经典主题已开启');
86
82
  };
87
83
 
88
84
  const showMatcherRuleModal = (type) => {
@@ -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;