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.
- package/VERSION.md +36 -0
- package/app/cgi-bin/proxy-fetch.js +117 -0
- package/app/cgi-bin/stats.js +149 -0
- package/app/public/js/index.js +1 -1
- package/app/public/js/index.js.LICENSE.txt +18 -0
- package/lib/ruleManager.js +27 -0
- package/lib/ui-server.js +20 -4
- package/package.json +1 -1
- package/ui/src/components/AppLayout.js +40 -1
- package/ui/src/components/KeyboardShortcutsHelp.js +58 -0
- package/ui/src/components/interface-management/InterfaceTestModal.js +1 -1
- package/ui/src/components/interface-management/RealResponseRecorder.js +165 -0
- package/ui/src/components/interface-management/ResponseContentEditor.js +3 -3
- package/ui/src/hooks/useKeyboardShortcuts.js +99 -0
- package/ui/src/index.js +4 -0
- package/ui/src/pages/InterfaceManagementV2.js +30 -5
- package/ui/src/pages/MockDataV2.js +21 -13
- package/ui/src/pages/Settings.js +1 -5
- package/ui/src/pages/Sniffer.js +521 -0
- package/ui/src/pages/Stats.js +288 -0
package/VERSION.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# 🔄 版本更新记录
|
|
2
2
|
|
|
3
|
+
## v1.2.2-beta.28
|
|
4
|
+
|
|
5
|
+
### ✨ Feature
|
|
6
|
+
- **请求统计-最近命中记录增强**:增加"模块"列展示命中接口所属功能模块;去掉冗余的"响应"列
|
|
7
|
+
- **Top 10 功能模块命中**:原"Top 10 未命中 URL"(实际无数据)改为"Top 10 功能模块命中排行",按模块聚合命中次数
|
|
8
|
+
- **最近命中记录筛选**:去掉分页,固定展示 20 条;新增日期选择器和 URL 关键字输入框,支持按日期精确筛选和 URL 模糊匹配
|
|
9
|
+
|
|
10
|
+
### 🎨 UI
|
|
11
|
+
- **亮色主题颜色优化**:将组件中硬编码的刺眼霓虹绿 `#00ff41` 替换为标准柔和绿 `#52c41a`,在亮色主题下视觉更舒适(赛博朋克主题仍通过 CSS 覆盖保持霓虹效果)
|
|
12
|
+
- **去掉 V2 标签**:功能模块管理页面右上角 "V2" 标签移除
|
|
13
|
+
|
|
14
|
+
### 🔧 Technical
|
|
15
|
+
- **后端日志字段扩展**:`ruleManager.js` 记录 `mock_hit` 日志时新增 `featureName`(功能模块名称)和 `responseName`(命中响应名称)字段
|
|
16
|
+
- **stats API 增强**:`cgi-bin/stats?type=recent` 支持 `date`(YYYY-MM-DD)和 `url`(模糊匹配)查询参数过滤
|
|
17
|
+
|
|
18
|
+
## v1.2.2-beta.26
|
|
19
|
+
|
|
20
|
+
### ✨ Feature
|
|
21
|
+
- **主题切换免刷新**:关闭 Settings 中的强制刷新逻辑,主题切换通过 `storage` 事件 + `body.cyber-theme` class 动态切换即时生效
|
|
22
|
+
- **请求统计面板**:新增「请求统计」页面(Ctrl+4),展示近7天命中趋势、Top 10 命中接口、Top 10 未命中 URL、最近命中记录
|
|
23
|
+
- **从真实接口导入**:接口编辑表单新增「从真实接口导入」区域,输入真实 API URL 后一键抓取响应,自动生成 Mock.js 模板并应用到当前响应
|
|
24
|
+
- **全局键盘快捷键**:
|
|
25
|
+
- `Ctrl+1/2/3/4` — 快速切换功能模块/接口管理/系统设置/请求统计
|
|
26
|
+
- `Ctrl+S` — 保存当前表单(Modal/Drawer 打开时)
|
|
27
|
+
- `Ctrl+Shift+N` — 新建接口/功能模块
|
|
28
|
+
- `Ctrl+K` — 聚焦搜索框
|
|
29
|
+
- `Shift+?` — 显示快捷键帮助
|
|
30
|
+
|
|
31
|
+
### 🔧 Technical
|
|
32
|
+
- **新增 `app/cgi-bin/stats.js`**:后端统计 API,支持 overview/top/timeline/recent 四种查询维度
|
|
33
|
+
- **新增 `app/cgi-bin/proxy-fetch.js`**:后端代理抓取真实接口响应
|
|
34
|
+
- **新增 `ui/src/pages/Stats.js`**:请求统计前端页面
|
|
35
|
+
- **新增 `ui/src/hooks/useKeyboardShortcuts.js`**:全局键盘快捷键 Hook
|
|
36
|
+
- **新增 `ui/src/components/KeyboardShortcutsHelp.js`**:快捷键帮助弹窗
|
|
37
|
+
- **新增 `ui/src/components/interface-management/RealResponseRecorder.js`**:真实响应抓取与 Mock 模板生成组件
|
|
38
|
+
|
|
3
39
|
## v1.2.2-beta.25
|
|
4
40
|
|
|
5
41
|
### 🐛 Fix
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const { URL } = require('url');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 代理抓取真实接口响应
|
|
6
|
+
* POST /cgi-bin/proxy-fetch
|
|
7
|
+
* body: {
|
|
8
|
+
* url: string,
|
|
9
|
+
* method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS',
|
|
10
|
+
* headers?: Record<string, string>,
|
|
11
|
+
* queryParams?: Record<string, string>,
|
|
12
|
+
* body?: any,
|
|
13
|
+
* bodyType?: 'json' | 'form' | 'text' | 'none'
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* 注意:ui-server.js 已用 body-parser 解析 JSON,直接取 req.body
|
|
17
|
+
*/
|
|
18
|
+
module.exports = async (req, res) => {
|
|
19
|
+
if (req.method !== 'POST') {
|
|
20
|
+
res.statusCode = 405;
|
|
21
|
+
res.setHeader('Content-Type', 'application/json');
|
|
22
|
+
res.end(JSON.stringify({ code: 405, message: 'Method Not Allowed' }));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const params = req.body || {};
|
|
28
|
+
const {
|
|
29
|
+
url: targetUrl,
|
|
30
|
+
method = 'GET',
|
|
31
|
+
headers = {},
|
|
32
|
+
queryParams = {},
|
|
33
|
+
body: requestBody,
|
|
34
|
+
bodyType = 'none'
|
|
35
|
+
} = params;
|
|
36
|
+
|
|
37
|
+
if (!targetUrl) {
|
|
38
|
+
res.setHeader('Content-Type', 'application/json');
|
|
39
|
+
res.end(JSON.stringify({ code: 400, message: '缺少 URL 参数' }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 构建带 query 参数的 URL
|
|
44
|
+
const parsed = new URL(targetUrl);
|
|
45
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
46
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
47
|
+
parsed.searchParams.set(key, String(value));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const finalUrl = parsed.toString();
|
|
51
|
+
|
|
52
|
+
// 构造 axios 请求配置
|
|
53
|
+
const axiosConfig = {
|
|
54
|
+
url: finalUrl,
|
|
55
|
+
method: method.toUpperCase(),
|
|
56
|
+
headers: {
|
|
57
|
+
'Accept': 'application/json, text/plain, */*',
|
|
58
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
59
|
+
...headers
|
|
60
|
+
},
|
|
61
|
+
timeout: 15000,
|
|
62
|
+
maxRedirects: 5,
|
|
63
|
+
responseType: 'text',
|
|
64
|
+
decompress: true,
|
|
65
|
+
validateStatus: () => true,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 移除禁止客户端发送的请求头(不区分大小写)
|
|
69
|
+
['host', 'content-length', 'transfer-encoding'].forEach(h => {
|
|
70
|
+
const key = Object.keys(axiosConfig.headers).find(k => k.toLowerCase() === h);
|
|
71
|
+
if (key) delete axiosConfig.headers[key];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 处理请求体
|
|
75
|
+
if (requestBody && bodyType !== 'none' && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
|
76
|
+
if (bodyType === 'json') {
|
|
77
|
+
axiosConfig.headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
78
|
+
axiosConfig.data = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody);
|
|
79
|
+
} else if (bodyType === 'form') {
|
|
80
|
+
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8';
|
|
81
|
+
if (typeof requestBody === 'object' && requestBody !== null) {
|
|
82
|
+
const qs = new URLSearchParams();
|
|
83
|
+
Object.entries(requestBody).forEach(([k, v]) => qs.append(k, v));
|
|
84
|
+
axiosConfig.data = qs.toString();
|
|
85
|
+
} else {
|
|
86
|
+
axiosConfig.data = String(requestBody);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
axiosConfig.headers['Content-Type'] = 'text/plain; charset=utf-8';
|
|
90
|
+
axiosConfig.data = String(requestBody);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('[proxy-fetch] 发起请求:', axiosConfig.method, finalUrl);
|
|
95
|
+
const proxyRes = await axios(axiosConfig);
|
|
96
|
+
console.log('[proxy-fetch] 请求完成:', proxyRes.status, finalUrl);
|
|
97
|
+
|
|
98
|
+
res.setHeader('Content-Type', 'application/json');
|
|
99
|
+
res.end(JSON.stringify({
|
|
100
|
+
code: 0,
|
|
101
|
+
data: {
|
|
102
|
+
status: proxyRes.status,
|
|
103
|
+
statusText: proxyRes.statusText,
|
|
104
|
+
headers: proxyRes.headers,
|
|
105
|
+
body: typeof proxyRes.data === 'string' ? proxyRes.data : JSON.stringify(proxyRes.data),
|
|
106
|
+
finalUrl: proxyRes.request?.res?.responseUrl || finalUrl
|
|
107
|
+
}
|
|
108
|
+
}));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('[proxy-fetch] 请求失败:', err.message);
|
|
111
|
+
res.setHeader('Content-Type', 'application/json');
|
|
112
|
+
res.end(JSON.stringify({
|
|
113
|
+
code: 500,
|
|
114
|
+
message: '请求失败: ' + (err.response?.statusText || err.message)
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const storage = require('../../lib/storage');
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = storage.DATA_DIR;
|
|
6
|
+
const LOGS_FILE = path.join(DATA_DIR, 'logs.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 统计接口
|
|
10
|
+
* GET /cgi-bin/stats?type=overview|hits|misses|top
|
|
11
|
+
*/
|
|
12
|
+
module.exports = (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const type = req.query.type || 'overview';
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(LOGS_FILE)) {
|
|
17
|
+
return res.json({ code: 0, data: { logs: [], stats: {} } });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const logsData = fs.readJsonSync(LOGS_FILE);
|
|
21
|
+
const logs = logsData.logs || [];
|
|
22
|
+
|
|
23
|
+
// 只取最近 7 天的日志做统计(避免数据量过大)
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
26
|
+
const recentLogs = logs.filter(log => {
|
|
27
|
+
const t = new Date(log.timestamp);
|
|
28
|
+
return t >= sevenDaysAgo;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 分离命中和未命中
|
|
32
|
+
const hitLogs = recentLogs.filter(log => log.eventType === 'mock_hit');
|
|
33
|
+
const missLogs = recentLogs.filter(log => log.eventType === 'mock_miss' || log.eventType === 'proxy_pass');
|
|
34
|
+
|
|
35
|
+
let data = {};
|
|
36
|
+
|
|
37
|
+
switch (type) {
|
|
38
|
+
case 'overview': {
|
|
39
|
+
// 接口总数
|
|
40
|
+
const interfacesFile = path.join(DATA_DIR, 'interfaces.json');
|
|
41
|
+
let totalInterfaces = 0;
|
|
42
|
+
let activeInterfaces = 0;
|
|
43
|
+
if (fs.existsSync(interfacesFile)) {
|
|
44
|
+
const intfData = fs.readJsonSync(interfacesFile);
|
|
45
|
+
const list = intfData.data || intfData.interfaces || intfData || [];
|
|
46
|
+
totalInterfaces = Array.isArray(list) ? list.length : 0;
|
|
47
|
+
activeInterfaces = Array.isArray(list) ? list.filter(i => i.active).length : 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
data = {
|
|
51
|
+
totalRequests: recentLogs.length,
|
|
52
|
+
totalHits: hitLogs.length,
|
|
53
|
+
totalMisses: missLogs.length,
|
|
54
|
+
totalInterfaces,
|
|
55
|
+
activeInterfaces,
|
|
56
|
+
hitRate: recentLogs.length > 0 ? ((hitLogs.length / recentLogs.length) * 100).toFixed(1) : 0,
|
|
57
|
+
todayHits: hitLogs.filter(log => {
|
|
58
|
+
const t = new Date(log.timestamp);
|
|
59
|
+
return t.toDateString() === now.toDateString();
|
|
60
|
+
}).length,
|
|
61
|
+
};
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case 'top': {
|
|
66
|
+
// Top 10 命中接口
|
|
67
|
+
const interfaceHitMap = {};
|
|
68
|
+
hitLogs.forEach(log => {
|
|
69
|
+
const id = log.interfaceId || log.interfaceName || 'unknown';
|
|
70
|
+
const name = log.interfaceName || id;
|
|
71
|
+
if (!interfaceHitMap[id]) {
|
|
72
|
+
interfaceHitMap[id] = { id, name, count: 0 };
|
|
73
|
+
}
|
|
74
|
+
interfaceHitMap[id].count++;
|
|
75
|
+
});
|
|
76
|
+
const topInterfaces = Object.values(interfaceHitMap)
|
|
77
|
+
.sort((a, b) => b.count - a.count)
|
|
78
|
+
.slice(0, 10);
|
|
79
|
+
|
|
80
|
+
// Top 10 命中功能模块
|
|
81
|
+
const featureHitMap = {};
|
|
82
|
+
hitLogs.forEach(log => {
|
|
83
|
+
const id = log.featureName || log.featureId || 'unknown';
|
|
84
|
+
const name = log.featureName || '未分类';
|
|
85
|
+
if (!featureHitMap[id]) {
|
|
86
|
+
featureHitMap[id] = { id, name, count: 0 };
|
|
87
|
+
}
|
|
88
|
+
featureHitMap[id].count++;
|
|
89
|
+
});
|
|
90
|
+
const topFeatures = Object.values(featureHitMap)
|
|
91
|
+
.sort((a, b) => b.count - a.count)
|
|
92
|
+
.slice(0, 10);
|
|
93
|
+
|
|
94
|
+
data = { topInterfaces, topFeatures };
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'timeline': {
|
|
99
|
+
// 最近 7 天每天的命中数
|
|
100
|
+
const timeline = {};
|
|
101
|
+
for (let i = 6; i >= 0; i--) {
|
|
102
|
+
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
|
103
|
+
const key = d.toISOString().slice(0, 10);
|
|
104
|
+
timeline[key] = 0;
|
|
105
|
+
}
|
|
106
|
+
hitLogs.forEach(log => {
|
|
107
|
+
const key = new Date(log.timestamp).toISOString().slice(0, 10);
|
|
108
|
+
if (timeline[key] !== undefined) {
|
|
109
|
+
timeline[key]++;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
data = { timeline };
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'recent': {
|
|
117
|
+
let filteredLogs = hitLogs;
|
|
118
|
+
const filterDate = req.query.date;
|
|
119
|
+
const filterUrl = req.query.url;
|
|
120
|
+
|
|
121
|
+
if (filterDate) {
|
|
122
|
+
filteredLogs = filteredLogs.filter(log => {
|
|
123
|
+
const logDate = new Date(log.timestamp).toISOString().slice(0, 10);
|
|
124
|
+
return logDate === filterDate;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (filterUrl) {
|
|
129
|
+
const lowerUrl = filterUrl.toLowerCase();
|
|
130
|
+
filteredLogs = filteredLogs.filter(log => {
|
|
131
|
+
const logUrl = (log.url || '').toLowerCase();
|
|
132
|
+
return logUrl.includes(lowerUrl);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
data = { logs: filteredLogs.slice(0, 20) };
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
default:
|
|
141
|
+
data = {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
res.json({ code: 0, data });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error('Stats error:', err);
|
|
147
|
+
res.status(500).json({ code: 500, message: err.message });
|
|
148
|
+
}
|
|
149
|
+
};
|