json-api-mocker 2.3.1 → 3.1.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/README.ch.md +21 -1
- package/README.md +24 -4
- package/dist/index.js +2 -2
- package/dist/public/admin.html +908 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +55 -0
- package/package.json +3 -3
package/README.ch.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
- 🚀 通过 JSON 配置快速搭建
|
|
14
14
|
- 🔄 支持 GET、POST、PUT、DELETE 方法
|
|
15
|
+
- 🖥️ 可视化管理后台,无需手写 JSON
|
|
15
16
|
- 📝 自动数据持久化
|
|
16
17
|
- 🔍 内置分页支持
|
|
17
18
|
- 🛠 可自定义响应结构
|
|
@@ -121,7 +122,8 @@ npm run mock
|
|
|
121
122
|
你会看到类似这样的输出:
|
|
122
123
|
```bash
|
|
123
124
|
Mock 服务器已启动:
|
|
124
|
-
- 地址:http://localhost:8080
|
|
125
|
+
- HTTP 地址:http://localhost:8080
|
|
126
|
+
- 管理后台:http://localhost:8080/admin
|
|
125
127
|
- 基础路径:/api
|
|
126
128
|
可用的接口:
|
|
127
129
|
GET http://localhost:8080/api/users
|
|
@@ -129,6 +131,24 @@ Mock 服务器已启动:
|
|
|
129
131
|
POST http://localhost:8080/api/upload/avatar
|
|
130
132
|
```
|
|
131
133
|
|
|
134
|
+
## 🖥️ 可视化管理后台
|
|
135
|
+
|
|
136
|
+
不想手写 JSON 配置文件?启动服务后,打开浏览器访问管理后台:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
http://localhost:8080/admin
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
管理后台提供以下功能:
|
|
143
|
+
|
|
144
|
+
- 📝 **可视化编辑**:通过表单配置路由、接口方法、Mock 字段,无需了解 JSON 格式
|
|
145
|
+
- 🎨 **字段构建器**:通过下拉菜单选择字段类型(随机姓名、年龄范围、邮箱、城市等),自动生成 Mock.js 模板
|
|
146
|
+
- 💻 **JSON 源码编辑**:切换到源码模式直接编辑 JSON,支持格式化和语法检查
|
|
147
|
+
- 👀 **接口预览**:查看所有可用接口,GET 接口可直接点击测试
|
|
148
|
+
- 💾 **一键保存**:修改后点击保存,配置自动写回 `data.json` 文件
|
|
149
|
+
|
|
150
|
+
> 💡 保存配置后,静态数据修改会立即生效;新增路由需要重启服务才能完全生效。
|
|
151
|
+
|
|
132
152
|
## 📖 配置指南
|
|
133
153
|
|
|
134
154
|
详细配置请参考 [CONFIG.ch.md](./CONFIG.ch.md)。
|
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ A lightweight and flexible mock server that uses JSON configuration to quickly c
|
|
|
12
12
|
|
|
13
13
|
- 🚀 Quick setup with JSON configuration
|
|
14
14
|
- 🔄 Support for GET, POST, PUT, DELETE methods
|
|
15
|
+
- 🖥️ Visual admin console, no JSON editing required
|
|
15
16
|
- 📝 Automatic data persistence
|
|
16
17
|
- 🔍 Built-in pagination support
|
|
17
18
|
- 🛠 Customizable response schemas
|
|
@@ -120,15 +121,34 @@ Now your mock server is running at `http://localhost:8080`!
|
|
|
120
121
|
|
|
121
122
|
You'll see output like this:
|
|
122
123
|
```bash
|
|
123
|
-
Mock
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
|
|
124
|
+
Mock 服务器已启动:
|
|
125
|
+
- HTTP 地址: http://localhost:8080
|
|
126
|
+
- 管理后台: http://localhost:8080/admin
|
|
127
|
+
- 基础路径: /api
|
|
128
|
+
可用的接口:
|
|
127
129
|
GET http://localhost:8080/api/users
|
|
128
130
|
POST http://localhost:8080/api/users
|
|
129
131
|
POST http://localhost:8080/api/upload/avatar
|
|
130
132
|
```
|
|
131
133
|
|
|
134
|
+
## 🖥️ Visual Admin Console
|
|
135
|
+
|
|
136
|
+
Don't want to write JSON config by hand? After starting the server, open the admin console in your browser:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
http://localhost:8080/admin
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The admin console provides the following features:
|
|
143
|
+
|
|
144
|
+
- 📝 **Visual Editor**: Configure routes, methods, and Mock fields via forms — no JSON knowledge required
|
|
145
|
+
- 🎨 **Field Builder**: Select field types (random name, age range, email, city, etc.) from a dropdown to auto-generate Mock.js templates
|
|
146
|
+
- 💻 **JSON Source Editor**: Switch to source mode to edit JSON directly, with formatting and syntax checking
|
|
147
|
+
- 👀 **API Preview**: View all available APIs; GET endpoints can be tested with one click
|
|
148
|
+
- 💾 **One-click Save**: Save changes and they're automatically written back to `data.json`
|
|
149
|
+
|
|
150
|
+
> 💡 After saving, static data changes take effect immediately; newly added routes require a server restart.
|
|
151
|
+
|
|
132
152
|
## 📖 Configuration Guide
|
|
133
153
|
|
|
134
154
|
For detailed configuration options, please refer to [CONFIG.md](./CONFIG.md).
|
package/dist/index.js
CHANGED
|
@@ -17,8 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
18
|
};
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.MockServer = void 0;
|
|
21
|
-
exports.startServer = startServer;
|
|
20
|
+
exports.MockServer = exports.startServer = void 0;
|
|
22
21
|
const fs_1 = __importDefault(require("fs"));
|
|
23
22
|
const path_1 = __importDefault(require("path"));
|
|
24
23
|
const server_1 = require("./server");
|
|
@@ -35,6 +34,7 @@ function startServer(configPath = 'data.json') {
|
|
|
35
34
|
process.exit(1);
|
|
36
35
|
}
|
|
37
36
|
}
|
|
37
|
+
exports.startServer = startServer;
|
|
38
38
|
if (require.main === module) {
|
|
39
39
|
startServer();
|
|
40
40
|
}
|
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>JSON API Mocker 控制台</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
11
|
+
background: #f0f2f5;
|
|
12
|
+
color: #1f2937;
|
|
13
|
+
font-size: 14px;
|
|
14
|
+
}
|
|
15
|
+
/* 顶部栏 */
|
|
16
|
+
.topbar {
|
|
17
|
+
background: #fff;
|
|
18
|
+
border-bottom: 1px solid #e5e7eb;
|
|
19
|
+
padding: 0 24px;
|
|
20
|
+
height: 60px;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: space-between;
|
|
24
|
+
position: sticky;
|
|
25
|
+
top: 0;
|
|
26
|
+
z-index: 100;
|
|
27
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
28
|
+
}
|
|
29
|
+
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
|
30
|
+
.logo { font-size: 18px; font-weight: 700; color: #2563eb; }
|
|
31
|
+
.logo-sub { color: #9ca3af; font-size: 13px; }
|
|
32
|
+
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
|
33
|
+
.status-badge {
|
|
34
|
+
padding: 4px 10px;
|
|
35
|
+
border-radius: 12px;
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
font-weight: 500;
|
|
38
|
+
}
|
|
39
|
+
.status-ok { background: #dcfce7; color: #16a34a; }
|
|
40
|
+
.status-saving { background: #fef3c7; color: #d97706; }
|
|
41
|
+
.status-error { background: #fee2e2; color: #dc2626; }
|
|
42
|
+
/* 按钮 */
|
|
43
|
+
.btn {
|
|
44
|
+
padding: 7px 16px;
|
|
45
|
+
border-radius: 6px;
|
|
46
|
+
border: none;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
font-size: 13px;
|
|
49
|
+
font-weight: 500;
|
|
50
|
+
transition: all 0.15s;
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: 6px;
|
|
54
|
+
}
|
|
55
|
+
.btn-primary { background: #2563eb; color: #fff; }
|
|
56
|
+
.btn-primary:hover { background: #1d4ed8; }
|
|
57
|
+
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
|
58
|
+
.btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
|
|
59
|
+
.btn-secondary:hover { background: #e5e7eb; }
|
|
60
|
+
.btn-danger { background: #fee2e2; color: #dc2626; }
|
|
61
|
+
.btn-danger:hover { background: #fecaca; }
|
|
62
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
63
|
+
.btn-ghost { background: transparent; color: #6b7280; }
|
|
64
|
+
.btn-ghost:hover { background: #f3f4f6; }
|
|
65
|
+
/* Tab */
|
|
66
|
+
.tabs {
|
|
67
|
+
display: flex;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
padding: 12px 24px 0;
|
|
70
|
+
background: #fff;
|
|
71
|
+
border-bottom: 1px solid #e5e7eb;
|
|
72
|
+
}
|
|
73
|
+
.tab {
|
|
74
|
+
padding: 8px 16px;
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
border-bottom: 2px solid transparent;
|
|
77
|
+
color: #6b7280;
|
|
78
|
+
font-weight: 500;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
}
|
|
81
|
+
.tab.active { color: #2563eb; border-bottom-color: #2563eb; }
|
|
82
|
+
/* 容器 */
|
|
83
|
+
.container { max-width: 1000px; margin: 0 auto; padding: 24px; }
|
|
84
|
+
/* 卡片 */
|
|
85
|
+
.card {
|
|
86
|
+
background: #fff;
|
|
87
|
+
border-radius: 10px;
|
|
88
|
+
padding: 20px;
|
|
89
|
+
margin-bottom: 16px;
|
|
90
|
+
border: 1px solid #e5e7eb;
|
|
91
|
+
}
|
|
92
|
+
.card-header {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: space-between;
|
|
96
|
+
margin-bottom: 16px;
|
|
97
|
+
}
|
|
98
|
+
.card-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
99
|
+
.card-icon { width: 8px; height: 8px; border-radius: 50%; background: #2563eb; }
|
|
100
|
+
/* 表单 */
|
|
101
|
+
.form-row { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
102
|
+
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
|
103
|
+
.form-group.flex1 { flex: 1; min-width: 160px; }
|
|
104
|
+
label { font-size: 12px; color: #6b7280; font-weight: 500; }
|
|
105
|
+
input, select, textarea {
|
|
106
|
+
border: 1px solid #d1d5db;
|
|
107
|
+
border-radius: 6px;
|
|
108
|
+
padding: 7px 10px;
|
|
109
|
+
font-size: 13px;
|
|
110
|
+
font-family: inherit;
|
|
111
|
+
outline: none;
|
|
112
|
+
transition: border-color 0.15s;
|
|
113
|
+
}
|
|
114
|
+
input:focus, select:focus, textarea:focus { border-color: #2563eb; }
|
|
115
|
+
input[type="number"] { width: 90px; }
|
|
116
|
+
textarea { resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; }
|
|
117
|
+
/* 开关 */
|
|
118
|
+
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
|
|
119
|
+
.switch input { opacity: 0; width: 0; height: 0; }
|
|
120
|
+
.slider {
|
|
121
|
+
position: absolute; cursor: pointer; inset: 0;
|
|
122
|
+
background: #d1d5db; border-radius: 22px; transition: 0.2s;
|
|
123
|
+
}
|
|
124
|
+
.slider:before {
|
|
125
|
+
content: ""; position: absolute; height: 16px; width: 16px;
|
|
126
|
+
left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: 0.2s;
|
|
127
|
+
}
|
|
128
|
+
.switch input:checked + .slider { background: #2563eb; }
|
|
129
|
+
.switch input:checked + .slider:before { transform: translateX(18px); }
|
|
130
|
+
.switch-label { display: flex; align-items: center; gap: 8px; }
|
|
131
|
+
/* 路由卡片 */
|
|
132
|
+
.route-card { border-left: 3px solid #2563eb; }
|
|
133
|
+
.route-header {
|
|
134
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
.route-path {
|
|
138
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
139
|
+
font-size: 14px; font-weight: 600; color: #1f2937;
|
|
140
|
+
}
|
|
141
|
+
.method-tags { display: flex; gap: 4px; margin-top: 4px; }
|
|
142
|
+
.method-tag {
|
|
143
|
+
padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
|
144
|
+
font-weight: 600; font-family: monospace;
|
|
145
|
+
}
|
|
146
|
+
.method-get { background: #dcfce7; color: #16a34a; }
|
|
147
|
+
.method-post { background: #dbeafe; color: #2563eb; }
|
|
148
|
+
.method-put { background: #fef3c7; color: #d97706; }
|
|
149
|
+
.method-delete { background: #fee2e2; color: #dc2626; }
|
|
150
|
+
.route-body { margin-top: 16px; }
|
|
151
|
+
.route-body.collapsed { display: none; }
|
|
152
|
+
/* 方法配置 */
|
|
153
|
+
.method-config {
|
|
154
|
+
background: #f9fafb; border-radius: 8px; padding: 16px;
|
|
155
|
+
margin-bottom: 12px; border: 1px solid #e5e7eb;
|
|
156
|
+
}
|
|
157
|
+
.method-config-header {
|
|
158
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
159
|
+
margin-bottom: 12px;
|
|
160
|
+
}
|
|
161
|
+
.method-name {
|
|
162
|
+
font-family: monospace; font-weight: 700; font-size: 13px;
|
|
163
|
+
padding: 3px 8px; border-radius: 4px;
|
|
164
|
+
}
|
|
165
|
+
/* 字段构建器 */
|
|
166
|
+
.field-row {
|
|
167
|
+
display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
|
|
168
|
+
padding: 8px; background: #fff; border-radius: 6px; border: 1px solid #e5e7eb;
|
|
169
|
+
}
|
|
170
|
+
.field-name { width: 120px; }
|
|
171
|
+
.field-type { width: 150px; }
|
|
172
|
+
.field-value { flex: 1; }
|
|
173
|
+
.field-remove {
|
|
174
|
+
width: 28px; height: 28px; border: none; background: transparent;
|
|
175
|
+
color: #9ca3af; cursor: pointer; border-radius: 4px; font-size: 16px;
|
|
176
|
+
}
|
|
177
|
+
.field-remove:hover { background: #fee2e2; color: #dc2626; }
|
|
178
|
+
.field-add {
|
|
179
|
+
padding: 6px 12px; border: 1px dashed #d1d5db; background: transparent;
|
|
180
|
+
border-radius: 6px; cursor: pointer; color: #6b7280; font-size: 12px;
|
|
181
|
+
}
|
|
182
|
+
.field-add:hover { border-color: #2563eb; color: #2563eb; }
|
|
183
|
+
/* JSON 编辑 */
|
|
184
|
+
.json-editor {
|
|
185
|
+
width: 100%; min-height: 500px; font-family: "SFMono-Regular", Consolas, monospace;
|
|
186
|
+
font-size: 13px; padding: 16px; border-radius: 8px; border: 1px solid #d1d5db;
|
|
187
|
+
line-height: 1.6; tab-size: 2;
|
|
188
|
+
}
|
|
189
|
+
.json-editor.error { border-color: #dc2626; }
|
|
190
|
+
.json-error { color: #dc2626; font-size: 12px; margin-top: 6px; }
|
|
191
|
+
/* 预览 */
|
|
192
|
+
.preview-list { display: flex; flex-direction: column; gap: 6px; }
|
|
193
|
+
.preview-item {
|
|
194
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
|
195
|
+
background: #f9fafb; border-radius: 6px; font-family: monospace; font-size: 12px;
|
|
196
|
+
}
|
|
197
|
+
/* 提示 */
|
|
198
|
+
.empty-hint {
|
|
199
|
+
text-align: center; padding: 40px; color: #9ca3af; font-size: 14px;
|
|
200
|
+
}
|
|
201
|
+
.hint-text { color: #9ca3af; font-size: 12px; margin-top: 4px; }
|
|
202
|
+
.section-title {
|
|
203
|
+
font-size: 12px; font-weight: 600; color: #6b7280;
|
|
204
|
+
text-transform: uppercase; letter-spacing: 0.5px; margin: 12px 0 8px;
|
|
205
|
+
}
|
|
206
|
+
/* Toast */
|
|
207
|
+
.toast {
|
|
208
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
209
|
+
padding: 10px 20px; border-radius: 8px; color: #fff; font-size: 13px;
|
|
210
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 200;
|
|
211
|
+
animation: fadeIn 0.2s;
|
|
212
|
+
}
|
|
213
|
+
.toast-success { background: #16a34a; }
|
|
214
|
+
.toast-error { background: #dc2626; }
|
|
215
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; } }
|
|
216
|
+
.collapse-arrow { transition: transform 0.2s; color: #9ca3af; }
|
|
217
|
+
.collapse-arrow.collapsed { transform: rotate(-90deg); }
|
|
218
|
+
.ws-event-card {
|
|
219
|
+
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;
|
|
220
|
+
padding: 12px; margin-bottom: 8px;
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div id="app">
|
|
226
|
+
<!-- 顶部栏 -->
|
|
227
|
+
<div class="topbar">
|
|
228
|
+
<div class="topbar-left">
|
|
229
|
+
<span class="logo">Mock Server</span>
|
|
230
|
+
<span class="logo-sub">控制台</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="topbar-right">
|
|
233
|
+
<span class="status-badge" :class="statusClass">{{ statusText }}</span>
|
|
234
|
+
<button class="btn btn-secondary" @click="loadConfig" :disabled="saving">重新加载</button>
|
|
235
|
+
<button class="btn btn-primary" @click="saveConfig" :disabled="saving">保存配置</button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<!-- Tab 切换 -->
|
|
240
|
+
<div class="tabs">
|
|
241
|
+
<div class="tab" :class="{active: tab==='visual'}" @click="tab='visual'">可视化编辑</div>
|
|
242
|
+
<div class="tab" :class="{active: tab==='json'}" @click="tab='json'">JSON 源码</div>
|
|
243
|
+
<div class="tab" :class="{active: tab==='preview'}" @click="tab='preview'">接口预览</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- 可视化编辑 -->
|
|
247
|
+
<div class="container" v-show="tab==='visual'">
|
|
248
|
+
<!-- 服务器配置 -->
|
|
249
|
+
<div class="card">
|
|
250
|
+
<div class="card-header">
|
|
251
|
+
<div class="card-title"><span class="card-icon"></span>服务器配置</div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="form-row">
|
|
254
|
+
<div class="form-group">
|
|
255
|
+
<label>端口号</label>
|
|
256
|
+
<input type="number" v-model.number="config.server.port">
|
|
257
|
+
</div>
|
|
258
|
+
<div class="form-group flex1">
|
|
259
|
+
<label>基础路径 (baseProxy)</label>
|
|
260
|
+
<input v-model="config.server.baseProxy" placeholder="/api">
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<!-- 路由配置 -->
|
|
266
|
+
<div class="card" v-for="(route, ri) in config.routes" :key="ri">
|
|
267
|
+
<div class="card-header route-header" @click="toggleRoute(ri)">
|
|
268
|
+
<div>
|
|
269
|
+
<div class="route-path">{{ route.path }}</div>
|
|
270
|
+
<div class="method-tags">
|
|
271
|
+
<span v-for="m in Object.keys(route.methods)" :key="m"
|
|
272
|
+
class="method-tag" :class="'method-'+m">{{ m.toUpperCase() }}</span>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
276
|
+
<span class="collapse-arrow" :class="{collapsed: !expandedRoutes[ri]}">▼</span>
|
|
277
|
+
<button class="btn btn-danger btn-sm" @click.stop="removeRoute(ri)">删除路由</button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div class="route-body" :class="{collapsed: !expandedRoutes[ri]}">
|
|
282
|
+
<div class="form-group" style="margin-bottom:16px">
|
|
283
|
+
<label>路由路径</label>
|
|
284
|
+
<input v-model="route.path" placeholder="/users" style="max-width:300px">
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<!-- 方法配置 -->
|
|
288
|
+
<div class="method-config" v-for="(mc, method) in route.methods" :key="method">
|
|
289
|
+
<div class="method-config-header">
|
|
290
|
+
<span class="method-name" :class="'method-'+method">{{ method.toUpperCase() }}</span>
|
|
291
|
+
<button class="btn btn-danger btn-sm" @click="removeMethod(route, method)">移除方法</button>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div class="form-row">
|
|
295
|
+
<div class="form-group">
|
|
296
|
+
<label>响应类型</label>
|
|
297
|
+
<select v-model="mc.type">
|
|
298
|
+
<option value="array">array (数组)</option>
|
|
299
|
+
<option value="object">object (对象)</option>
|
|
300
|
+
</select>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<!-- Mock 配置 -->
|
|
305
|
+
<div class="section-title">Mock 数据生成</div>
|
|
306
|
+
<div class="switch-label" style="margin-bottom:8px">
|
|
307
|
+
<label class="switch">
|
|
308
|
+
<input type="checkbox" v-model="mc.mock.enabled">
|
|
309
|
+
<span class="slider"></span>
|
|
310
|
+
</label>
|
|
311
|
+
<span style="font-size:13px">启用 Mock.js 随机数据</span>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div v-if="mc.mock.enabled">
|
|
315
|
+
<div class="form-row" v-if="mc.type==='array'">
|
|
316
|
+
<div class="form-group">
|
|
317
|
+
<label>生成条数</label>
|
|
318
|
+
<input type="number" v-model.number="mc.mock.total" min="1">
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<!-- 字段构建器 -->
|
|
323
|
+
<div class="section-title">返回字段 <span class="hint-text">(选择字段类型,自动生成随机数据)</span></div>
|
|
324
|
+
<div v-for="(field, fi) in getFields(mc.mock.template)" :key="fi" class="field-row">
|
|
325
|
+
<input class="field-name" v-model="field.name" placeholder="字段名">
|
|
326
|
+
<select class="field-type" v-model="field.type" @change="onFieldTypeChange(field)">
|
|
327
|
+
<option value="increment">自增 ID</option>
|
|
328
|
+
<option value="name">随机姓名(英文)</option>
|
|
329
|
+
<option value="cname">随机姓名(中文)</option>
|
|
330
|
+
<option value="integer">随机整数(范围)</option>
|
|
331
|
+
<option value="float">随机小数(范围)</option>
|
|
332
|
+
<option value="email">随机邮箱</option>
|
|
333
|
+
<option value="city">随机城市(英文)</option>
|
|
334
|
+
<option value="county">随机城市(中文)</option>
|
|
335
|
+
<option value="datetime">随机日期时间</option>
|
|
336
|
+
<option value="date">随机日期</option>
|
|
337
|
+
<option value="image">随机图片</option>
|
|
338
|
+
<option value="url">随机 URL</option>
|
|
339
|
+
<option value="paragraph">随机段落</option>
|
|
340
|
+
<option value="sentence">随机句子</option>
|
|
341
|
+
<option value="title">随机标题</option>
|
|
342
|
+
<option value="boolean">随机布尔值</option>
|
|
343
|
+
<option value="pick">随机选择(多项)</option>
|
|
344
|
+
</div>
|
|
345
|
+
<input class="field-value" v-model="field.value" :placeholder="fieldPlaceholder(field)">
|
|
346
|
+
<button class="field-remove" @click="removeField(mc.mock.template, fi)">×</button>
|
|
347
|
+
</div>
|
|
348
|
+
<button class="field-add" @click="addField(mc.mock.template)">+ 添加字段</button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- 静态响应 (未启用 mock 时) -->
|
|
352
|
+
<div v-if="!mc.mock.enabled">
|
|
353
|
+
<div class="section-title">静态响应数据 (JSON)</div>
|
|
354
|
+
<textarea v-model="mc._responseText" rows="5"
|
|
355
|
+
placeholder='{"success": true}' style="width:100%;font-family:monospace"></textarea>
|
|
356
|
+
<div class="json-error" v-if="mc._responseError">{{ mc._responseError }}</div>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<!-- 分页配置 -->
|
|
360
|
+
<div class="section-title" v-if="mc.type==='array'">分页设置</div>
|
|
361
|
+
<div v-if="mc.type==='array'">
|
|
362
|
+
<div class="switch-label" style="margin-bottom:8px">
|
|
363
|
+
<label class="switch">
|
|
364
|
+
<input type="checkbox" v-model="mc.pagination.enabled">
|
|
365
|
+
<span class="slider"></span>
|
|
366
|
+
</label>
|
|
367
|
+
<span style="font-size:13px">启用分页</span>
|
|
368
|
+
</div>
|
|
369
|
+
<div class="form-row" v-if="mc.pagination.enabled">
|
|
370
|
+
<div class="form-group">
|
|
371
|
+
<label>每页条数</label>
|
|
372
|
+
<input type="number" v-model.number="mc.pagination.pageSize">
|
|
373
|
+
</div>
|
|
374
|
+
<div class="form-group">
|
|
375
|
+
<label>数据总数</label>
|
|
376
|
+
<input type="number" v-model.number="mc.pagination.totalCount">
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<!-- 添加方法 -->
|
|
383
|
+
<div style="margin-top:12px">
|
|
384
|
+
<select v-model="newMethod[ri]" style="width:auto">
|
|
385
|
+
<option value="">+ 添加方法...</option>
|
|
386
|
+
<option value="get" v-if="!route.methods.get">GET</option>
|
|
387
|
+
<option value="post" v-if="!route.methods.post">POST</option>
|
|
388
|
+
<option value="put" v-if="!route.methods.put">PUT</option>
|
|
389
|
+
<option value="delete" v-if="!route.methods.delete">DELETE</option>
|
|
390
|
+
</select>
|
|
391
|
+
<button class="btn btn-secondary btn-sm" v-if="newMethod[ri]" @click="addMethod(route, ri)">添加</button>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<button class="btn btn-secondary" @click="addRoute" style="width:100%;padding:10px">+ 添加路由</button>
|
|
397
|
+
|
|
398
|
+
<!-- WebSocket 配置 -->
|
|
399
|
+
<div class="card" style="margin-top:16px">
|
|
400
|
+
<div class="card-header">
|
|
401
|
+
<div class="card-title"><span class="card-icon"></span>WebSocket 实时通信</div>
|
|
402
|
+
<div class="switch-label">
|
|
403
|
+
<label class="switch">
|
|
404
|
+
<input type="checkbox" v-model="config.websocket.enabled">
|
|
405
|
+
<span class="slider"></span>
|
|
406
|
+
</label>
|
|
407
|
+
<span style="font-size:13px">启用</span>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<div v-if="config.websocket.enabled">
|
|
411
|
+
<div class="form-row">
|
|
412
|
+
<div class="form-group flex1">
|
|
413
|
+
<label>WebSocket 路径</label>
|
|
414
|
+
<input v-model="config.websocket.path" placeholder="/ws">
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="section-title">事件列表</div>
|
|
418
|
+
<div class="ws-event-card" v-for="(ev, ename) in config.websocket.events" :key="ename">
|
|
419
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
420
|
+
<input v-model="ev._eventName" placeholder="事件名" style="font-weight:600;width:200px" @change="renameEvent(config.websocket.events, ename, ev._eventName)">
|
|
421
|
+
<button class="btn btn-danger btn-sm" @click="removeEvent(config.websocket.events, ename)">删除</button>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="switch-label" style="margin-bottom:8px">
|
|
424
|
+
<label class="switch">
|
|
425
|
+
<input type="checkbox" v-model="ev.mock.enabled">
|
|
426
|
+
<span class="slider"></span>
|
|
427
|
+
</label>
|
|
428
|
+
<span style="font-size:13px">启用 Mock</span>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="form-row" v-if="ev.mock.enabled">
|
|
431
|
+
<div class="form-group">
|
|
432
|
+
<label>自动推送间隔 (ms, 0=不自动推送)</label>
|
|
433
|
+
<input type="number" v-model.number="ev.mock.interval">
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
<div v-if="ev.mock.enabled">
|
|
437
|
+
<div class="section-title">返回字段</div>
|
|
438
|
+
<div v-for="(field, fi) in getFields(ev.mock.template)" :key="fi" class="field-row">
|
|
439
|
+
<input class="field-name" v-model="field.name" placeholder="字段名">
|
|
440
|
+
<select class="field-type" v-model="field.type" @change="onFieldTypeChange(field)">
|
|
441
|
+
<option value="increment">自增 ID</option>
|
|
442
|
+
<option value="name">随机姓名(英文)</option>
|
|
443
|
+
<option value="cname">随机姓名(中文)</option>
|
|
444
|
+
<option value="integer">随机整数(范围)</option>
|
|
445
|
+
<option value="float">随机小数(范围)</option>
|
|
446
|
+
<option value="email">随机邮箱</option>
|
|
447
|
+
<option value="city">随机城市(英文)</option>
|
|
448
|
+
<option value="county">随机城市(中文)</option>
|
|
449
|
+
<option value="datetime">随机日期时间</option>
|
|
450
|
+
<option value="date">随机日期</option>
|
|
451
|
+
<option value="image">随机图片</option>
|
|
452
|
+
<option value="paragraph">随机段落</option>
|
|
453
|
+
<option value="sentence">随机句子</option>
|
|
454
|
+
<option value="boolean">随机布尔值</option>
|
|
455
|
+
<option value="pick">随机选择(多项)</option>
|
|
456
|
+
</select>
|
|
457
|
+
<input class="field-value" v-model="field.value" :placeholder="fieldPlaceholder(field)">
|
|
458
|
+
<button class="field-remove" @click="removeField(ev.mock.template, fi)">×</button>
|
|
459
|
+
</div>
|
|
460
|
+
<button class="field-add" @click="addField(ev.mock.template)">+ 添加字段</button>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
<button class="btn btn-secondary btn-sm" @click="addEvent(config.websocket.events)">+ 添加事件</button>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<!-- JSON 源码 -->
|
|
469
|
+
<div class="container" v-show="tab==='json'">
|
|
470
|
+
<div class="card">
|
|
471
|
+
<div class="card-header">
|
|
472
|
+
<div class="card-title"><span class="card-icon"></span>直接编辑 JSON 源码</div>
|
|
473
|
+
<div style="display:flex;gap:8px">
|
|
474
|
+
<button class="btn btn-secondary btn-sm" @click="formatJson">格式化</button>
|
|
475
|
+
<button class="btn btn-secondary btn-sm" @click="syncToJson">从可视化同步</button>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
<textarea class="json-editor" :class="{error: jsonError}" v-model="jsonText" @input="syncFromJson"></textarea>
|
|
479
|
+
<div class="json-error" v-if="jsonError">{{ jsonError }}</div>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<!-- 接口预览 -->
|
|
484
|
+
<div class="container" v-show="tab==='preview'">
|
|
485
|
+
<div class="card">
|
|
486
|
+
<div class="card-header">
|
|
487
|
+
<div class="card-title"><span class="card-icon"></span>可用接口列表</div>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="preview-list" v-if="config.routes.length">
|
|
490
|
+
<div class="preview-item" v-for="r in previewRoutes" :key="r.method+r.path">
|
|
491
|
+
<span class="method-tag" :class="'method-'+r.method">{{ r.method.toUpperCase() }}</span>
|
|
492
|
+
<span>{{ config.server.baseProxy }}{{ r.path }}</span>
|
|
493
|
+
<a :href="'http://'+host+config.server.baseProxy+r.path" target="_blank"
|
|
494
|
+
v-if="r.method==='get'" style="margin-left:auto;color:#2563eb;text-decoration:none;font-size:12px">测试 →</a>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="empty-hint" v-else>暂无接口,请在"可视化编辑"中添加路由</div>
|
|
498
|
+
</div>
|
|
499
|
+
<div class="card" v-if="config.websocket && config.websocket.enabled">
|
|
500
|
+
<div class="card-header">
|
|
501
|
+
<div class="card-title"><span class="card-icon"></span>WebSocket 接口</div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="preview-item">
|
|
504
|
+
<span class="method-tag" style="background:#ede9fe;color:#7c3aed">WS</span>
|
|
505
|
+
<span>ws://{{ host }}{{ config.websocket.path }}</span>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
<!-- Toast -->
|
|
511
|
+
<div class="toast" :class="'toast-'+toast.type" v-if="toast.show">{{ toast.msg }}</div>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
515
|
+
<script>
|
|
516
|
+
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
|
517
|
+
|
|
518
|
+
// 字段类型 -> Mock.js 表达式生成器
|
|
519
|
+
const FIELD_SPECS = {
|
|
520
|
+
increment: { placeholder: '起始值,如 1', needValue: true },
|
|
521
|
+
name: { placeholder: '无需填写', needValue: false },
|
|
522
|
+
cname: { placeholder: '无需填写', needValue: false },
|
|
523
|
+
integer: { placeholder: '范围,如 18-60', needValue: true },
|
|
524
|
+
float: { placeholder: '范围,如 1-100 | 1-2', needValue: true },
|
|
525
|
+
email: { placeholder: '无需填写', needValue: false },
|
|
526
|
+
city: { placeholder: '无需填写', needValue: false },
|
|
527
|
+
county: { placeholder: '无需填写', needValue: false },
|
|
528
|
+
datetime: { placeholder: '格式,如 yyyy-MM-dd (可选)', needValue: false },
|
|
529
|
+
date: { placeholder: '无需填写', needValue: false },
|
|
530
|
+
image: { placeholder: '尺寸,如 200x200', needValue: true },
|
|
531
|
+
url: { placeholder: '无需填写', needValue: false },
|
|
532
|
+
paragraph: { placeholder: '无需填写', needValue: false },
|
|
533
|
+
sentence: { placeholder: '无需填写', needValue: false },
|
|
534
|
+
title: { placeholder: '无需填写', needValue: false },
|
|
535
|
+
boolean: { placeholder: '无需填写', needValue: false },
|
|
536
|
+
pick: { placeholder: '选项,用逗号分隔,如 男,女', needValue: true },
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
createApp({
|
|
540
|
+
setup() {
|
|
541
|
+
const config = reactive({
|
|
542
|
+
server: { port: 8080, baseProxy: '/api' },
|
|
543
|
+
routes: [],
|
|
544
|
+
websocket: { enabled: false, path: '/ws', events: {} }
|
|
545
|
+
});
|
|
546
|
+
const tab = ref('visual');
|
|
547
|
+
const saving = ref(false);
|
|
548
|
+
const status = ref('ok'); // ok | saving | error
|
|
549
|
+
const expandedRoutes = reactive({});
|
|
550
|
+
const newMethod = reactive({});
|
|
551
|
+
const jsonText = ref('');
|
|
552
|
+
const jsonError = ref('');
|
|
553
|
+
const toast = reactive({ show: false, msg: '', type: 'success' });
|
|
554
|
+
const host = ref(location.host);
|
|
555
|
+
|
|
556
|
+
const statusClass = computed(() => ({
|
|
557
|
+
ok: 'status-ok', saving: 'status-saving', error: 'status-error'
|
|
558
|
+
}[status.value]));
|
|
559
|
+
const statusText = computed(() => ({
|
|
560
|
+
ok: '已加载', saving: '保存中...', error: '保存失败'
|
|
561
|
+
}[status.value]));
|
|
562
|
+
|
|
563
|
+
const previewRoutes = computed(() => {
|
|
564
|
+
const list = [];
|
|
565
|
+
config.routes.forEach(r => {
|
|
566
|
+
Object.keys(r.methods || {}).forEach(m => list.push({ method: m, path: r.path }));
|
|
567
|
+
});
|
|
568
|
+
return list;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
function showToast(msg, type = 'success') {
|
|
572
|
+
toast.msg = msg; toast.type = type; toast.show = true;
|
|
573
|
+
setTimeout(() => toast.show = false, 2500);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 加载配置
|
|
577
|
+
async function loadConfig() {
|
|
578
|
+
try {
|
|
579
|
+
const res = await fetch('/admin/api/config');
|
|
580
|
+
const data = await res.json();
|
|
581
|
+
Object.assign(config, normalizeConfig(data));
|
|
582
|
+
syncToJson();
|
|
583
|
+
status.value = 'ok';
|
|
584
|
+
showToast('配置已加载');
|
|
585
|
+
} catch (e) {
|
|
586
|
+
status.value = 'error';
|
|
587
|
+
showToast('加载失败: ' + e.message, 'error');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 规范化配置,补充默认值和辅助字段
|
|
592
|
+
function normalizeConfig(data) {
|
|
593
|
+
if (!data.server) data.server = { port: 8080, baseProxy: '/api' };
|
|
594
|
+
if (!data.routes) data.routes = [];
|
|
595
|
+
if (!data.websocket) data.websocket = { enabled: false, path: '/ws', events: {} };
|
|
596
|
+
if (!data.websocket.events) data.websocket.events = {};
|
|
597
|
+
if (!data.websocket.path) data.websocket.path = '/ws';
|
|
598
|
+
|
|
599
|
+
data.routes.forEach((route, ri) => {
|
|
600
|
+
expandedRoutes[ri] = expandedRoutes[ri] !== undefined ? expandedRoutes[ri] : true;
|
|
601
|
+
if (!route.methods) route.methods = {};
|
|
602
|
+
Object.entries(route.methods).forEach(([_, mc]) => {
|
|
603
|
+
if (!mc.type) mc.type = 'array';
|
|
604
|
+
if (!mc.mock) mc.mock = { enabled: false, template: {} };
|
|
605
|
+
if (!mc.mock.template) mc.mock.template = {};
|
|
606
|
+
if (mc.mock.total === undefined) mc.mock.total = 10;
|
|
607
|
+
if (!mc.pagination) mc.pagination = { enabled: false, pageSize: 10, totalCount: 100 };
|
|
608
|
+
// response 文本
|
|
609
|
+
mc._responseText = mc.response !== undefined ? JSON.stringify(mc.response, null, 2) : '';
|
|
610
|
+
mc._responseError = '';
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// WebSocket events 补充 _eventName
|
|
615
|
+
Object.entries(data.websocket.events || {}).forEach(([ename, ev]) => {
|
|
616
|
+
ev._eventName = ename;
|
|
617
|
+
if (!ev.mock) ev.mock = { enabled: false, template: {} };
|
|
618
|
+
if (!ev.mock.template) ev.mock.template = {};
|
|
619
|
+
});
|
|
620
|
+
return data;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 保存配置
|
|
624
|
+
async function saveConfig() {
|
|
625
|
+
// 先同步可视化 -> JSON
|
|
626
|
+
syncToJson();
|
|
627
|
+
if (jsonError.value) {
|
|
628
|
+
showToast('JSON 格式错误,请检查', 'error');
|
|
629
|
+
tab.value = 'json';
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
saving.value = true;
|
|
633
|
+
status.value = 'saving';
|
|
634
|
+
try {
|
|
635
|
+
const res = await fetch('/admin/api/config', {
|
|
636
|
+
method: 'PUT',
|
|
637
|
+
headers: { 'Content-Type': 'application/json' },
|
|
638
|
+
body: jsonText.value
|
|
639
|
+
});
|
|
640
|
+
const result = await res.json();
|
|
641
|
+
if (result.success) {
|
|
642
|
+
status.value = 'ok';
|
|
643
|
+
showToast('保存成功!部分改动(新增路由)需重启服务生效');
|
|
644
|
+
} else {
|
|
645
|
+
throw new Error(result.error || '保存失败');
|
|
646
|
+
}
|
|
647
|
+
} catch (e) {
|
|
648
|
+
status.value = 'error';
|
|
649
|
+
showToast('保存失败: ' + e.message, 'error');
|
|
650
|
+
} finally {
|
|
651
|
+
saving.value = false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ===== 路由操作 =====
|
|
656
|
+
function addRoute() {
|
|
657
|
+
const newRoute = {
|
|
658
|
+
path: '/new-route',
|
|
659
|
+
methods: {
|
|
660
|
+
get: {
|
|
661
|
+
type: 'array',
|
|
662
|
+
mock: { enabled: true, total: 10, template: {} },
|
|
663
|
+
pagination: { enabled: false, pageSize: 10, totalCount: 100 },
|
|
664
|
+
_responseText: '', _responseError: ''
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
config.routes.push(newRoute);
|
|
669
|
+
expandedRoutes[config.routes.length - 1] = true;
|
|
670
|
+
}
|
|
671
|
+
function removeRoute(ri) {
|
|
672
|
+
if (!confirm('确定删除此路由?')) return;
|
|
673
|
+
config.routes.splice(ri, 1);
|
|
674
|
+
}
|
|
675
|
+
function toggleRoute(ri) {
|
|
676
|
+
expandedRoutes[ri] = !expandedRoutes[ri];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ===== 方法操作 =====
|
|
680
|
+
function addMethod(route, ri) {
|
|
681
|
+
const m = newMethod[ri];
|
|
682
|
+
if (!m || route.methods[m]) return;
|
|
683
|
+
route.methods[m] = {
|
|
684
|
+
type: m === 'get' ? 'array' : 'object',
|
|
685
|
+
mock: { enabled: false, total: 10, template: {} },
|
|
686
|
+
pagination: { enabled: false, pageSize: 10, totalCount: 100 },
|
|
687
|
+
_responseText: '', _responseError: ''
|
|
688
|
+
};
|
|
689
|
+
newMethod[ri] = '';
|
|
690
|
+
}
|
|
691
|
+
function removeMethod(route, method) {
|
|
692
|
+
if (!confirm('确定移除 ' + method.toUpperCase() + ' 方法?')) return;
|
|
693
|
+
delete route.methods[method];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ===== 字段构建器 =====
|
|
697
|
+
// 把 Mock.js template 转成可编辑的字段数组(挂在 __fields 上,缓存)
|
|
698
|
+
function getFields(template) {
|
|
699
|
+
if (!template.__fields) {
|
|
700
|
+
template.__fields = Object.entries(template)
|
|
701
|
+
.filter(([k]) => k !== '__fields')
|
|
702
|
+
.map(([key, val]) => parseMockField(key, val));
|
|
703
|
+
}
|
|
704
|
+
return template.__fields;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function parseMockField(key, val) {
|
|
708
|
+
// 解析 Mock.js 的 key|rule: value 格式
|
|
709
|
+
const pipeIdx = key.indexOf('|');
|
|
710
|
+
let name = key, rule = '', type = 'custom', value = '';
|
|
711
|
+
if (pipeIdx >= 0) {
|
|
712
|
+
name = key.substring(0, pipeIdx);
|
|
713
|
+
rule = key.substring(pipeIdx + 1);
|
|
714
|
+
}
|
|
715
|
+
if (rule === '+1') { type = 'increment'; value = String(val); }
|
|
716
|
+
else if (rule.match(/^\d+-\d+$/)) {
|
|
717
|
+
type = typeof val === 'number' && !Number.isInteger(val) ? 'float' : 'integer';
|
|
718
|
+
value = rule;
|
|
719
|
+
}
|
|
720
|
+
else if (typeof val === 'string' && val.startsWith('@')) {
|
|
721
|
+
const fn = val.substring(1).split('(')[0];
|
|
722
|
+
const argMatch = val.match(/\((.*)\)/);
|
|
723
|
+
const arg = argMatch ? argMatch[1].replace(/['"]/g, '') : '';
|
|
724
|
+
const map = { name:'name', cname:'cname', email:'email', city:'city',
|
|
725
|
+
county:'county', datetime:'datetime', date:'date', image:'image',
|
|
726
|
+
url:'url', paragraph:'paragraph', sentence:'sentence', title:'title' };
|
|
727
|
+
type = map[fn] || 'custom';
|
|
728
|
+
value = arg;
|
|
729
|
+
if (type === 'custom') value = val;
|
|
730
|
+
}
|
|
731
|
+
else if (typeof val === 'boolean') { type = 'boolean'; value = ''; }
|
|
732
|
+
else if (Array.isArray(val) && rule === '1') { type = 'pick'; value = val.join(','); }
|
|
733
|
+
else { type = 'custom'; value = typeof val === 'object' ? JSON.stringify(val) : String(val); }
|
|
734
|
+
return { name, type, value, _key: key };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function buildMockKey(field) {
|
|
738
|
+
const { name, type, value } = field;
|
|
739
|
+
switch (type) {
|
|
740
|
+
case 'increment': return { key: name + '|+1', val: Number(value) || 1 };
|
|
741
|
+
case 'integer': {
|
|
742
|
+
const v = value || '1-100';
|
|
743
|
+
return { key: name + '|' + v, val: 1 };
|
|
744
|
+
}
|
|
745
|
+
case 'float': {
|
|
746
|
+
const v = value || '1-100';
|
|
747
|
+
return { key: name + '|' + v, val: 1 };
|
|
748
|
+
}
|
|
749
|
+
case 'name': return { key: name, val: '@name' };
|
|
750
|
+
case 'cname': return { key: name, val: '@cname' };
|
|
751
|
+
case 'email': return { key: name, val: '@email' };
|
|
752
|
+
case 'city': return { key: name, val: '@city' };
|
|
753
|
+
case 'county': return { key: name, val: '@county(true)' };
|
|
754
|
+
case 'datetime': return { key: name, val: value ? '@datetime("' + value + '")' : '@datetime' };
|
|
755
|
+
case 'date': return { key: name, val: '@date' };
|
|
756
|
+
case 'image': return { key: name, val: "@image('" + (value || '200x200') + "')" };
|
|
757
|
+
case 'url': return { key: name, val: '@url' };
|
|
758
|
+
case 'paragraph': return { key: name, val: '@paragraph' };
|
|
759
|
+
case 'sentence': return { key: name, val: '@sentence' };
|
|
760
|
+
case 'title': return { key: name, val: '@title' };
|
|
761
|
+
case 'boolean': return { key: name + '|1', val: true };
|
|
762
|
+
case 'pick': {
|
|
763
|
+
const items = (value || 'a,b').split(',').map(s => s.trim()).filter(Boolean);
|
|
764
|
+
return { key: name + '|1', val: items };
|
|
765
|
+
}
|
|
766
|
+
default: return { key: name, val: value };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function onFieldTypeChange(field) {
|
|
771
|
+
// 类型改变时,重置 value
|
|
772
|
+
const spec = FIELD_SPECS[field.type];
|
|
773
|
+
if (!spec.needValue) field.value = '';
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function fieldPlaceholder(field) {
|
|
777
|
+
return (FIELD_SPECS[field.type] || { placeholder: '' }).placeholder;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function addField(template) {
|
|
781
|
+
if (!template.__fields) template.__fields = [];
|
|
782
|
+
template.__fields.push({ name: 'field' + (template.__fields.length + 1), type: 'name', value: '' });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function removeField(template, fi) {
|
|
786
|
+
template.__fields.splice(fi, 1);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ===== WebSocket 事件操作 =====
|
|
790
|
+
function addEvent(events) {
|
|
791
|
+
let name = 'event-' + (Object.keys(events).length + 1);
|
|
792
|
+
while (events[name]) name += '-1';
|
|
793
|
+
events[name] = { _eventName: name, mock: { enabled: true, template: {}, interval: 0 } };
|
|
794
|
+
}
|
|
795
|
+
function removeEvent(events, ename) {
|
|
796
|
+
if (!confirm('确定删除事件 ' + ename + '?')) return;
|
|
797
|
+
delete events[ename];
|
|
798
|
+
}
|
|
799
|
+
function renameEvent(events, oldName, newName) {
|
|
800
|
+
if (!newName || oldName === newName) return;
|
|
801
|
+
if (events[newName]) { showToast('事件名已存在', 'error'); return; }
|
|
802
|
+
const ev = events[oldName];
|
|
803
|
+
delete events[oldName];
|
|
804
|
+
events[newName] = ev;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ===== JSON 同步 =====
|
|
808
|
+
function buildCleanConfig() {
|
|
809
|
+
const out = {
|
|
810
|
+
server: { port: config.server.port, baseProxy: config.server.baseProxy },
|
|
811
|
+
routes: config.routes.map(route => {
|
|
812
|
+
const methods = {};
|
|
813
|
+
Object.entries(route.methods).forEach(([m, mc]) => {
|
|
814
|
+
// 解析 response 文本
|
|
815
|
+
let response = mc.response;
|
|
816
|
+
if (mc._responseText !== undefined && mc._responseText !== '') {
|
|
817
|
+
try { response = JSON.parse(mc._responseText); } catch (e) { /* 忽略,前端已提示 */ }
|
|
818
|
+
}
|
|
819
|
+
const methodCfg = { type: mc.type };
|
|
820
|
+
if (mc.mock && mc.mock.enabled) {
|
|
821
|
+
// 从字段构建器生成 template
|
|
822
|
+
const template = {};
|
|
823
|
+
(mc.mock.template.__fields || []).forEach(f => {
|
|
824
|
+
const r = buildMockKey(f);
|
|
825
|
+
template[r.key] = r.val;
|
|
826
|
+
});
|
|
827
|
+
methodCfg.mock = { enabled: true, template };
|
|
828
|
+
if (mc.type === 'array') methodCfg.mock.total = mc.mock.total;
|
|
829
|
+
} else {
|
|
830
|
+
methodCfg.response = response !== undefined ? response : null;
|
|
831
|
+
}
|
|
832
|
+
if (mc.type === 'array' && mc.pagination && mc.pagination.enabled) {
|
|
833
|
+
methodCfg.pagination = {
|
|
834
|
+
enabled: true,
|
|
835
|
+
pageSize: mc.pagination.pageSize,
|
|
836
|
+
totalCount: mc.pagination.totalCount
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
methods[m] = methodCfg;
|
|
840
|
+
});
|
|
841
|
+
return { path: route.path, methods };
|
|
842
|
+
})
|
|
843
|
+
};
|
|
844
|
+
if (config.websocket && config.websocket.enabled) {
|
|
845
|
+
const events = {};
|
|
846
|
+
Object.entries(config.websocket.events || {}).forEach(([_, ev]) => {
|
|
847
|
+
const template = {};
|
|
848
|
+
(ev.mock.template.__fields || []).forEach(f => {
|
|
849
|
+
const r = buildMockKey(f);
|
|
850
|
+
template[r.key] = r.val;
|
|
851
|
+
});
|
|
852
|
+
events[ev._eventName] = {
|
|
853
|
+
mock: { enabled: ev.mock.enabled, template, interval: ev.mock.interval || undefined }
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
out.websocket = { enabled: true, path: config.websocket.path, events };
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function syncToJson() {
|
|
862
|
+
try {
|
|
863
|
+
jsonText.value = JSON.stringify(buildCleanConfig(), null, 2);
|
|
864
|
+
jsonError.value = '';
|
|
865
|
+
} catch (e) {
|
|
866
|
+
jsonError.value = e.message;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function syncFromJson() {
|
|
871
|
+
try {
|
|
872
|
+
const parsed = JSON.parse(jsonText.value);
|
|
873
|
+
jsonError.value = '';
|
|
874
|
+
// 不立即覆盖可视化(避免输入中途破坏),用户点"重新加载"可应用
|
|
875
|
+
} catch (e) {
|
|
876
|
+
jsonError.value = 'JSON 语法错误: ' + e.message;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function formatJson() {
|
|
881
|
+
try {
|
|
882
|
+
jsonText.value = JSON.stringify(JSON.parse(jsonText.value), null, 2);
|
|
883
|
+
jsonError.value = '';
|
|
884
|
+
showToast('已格式化');
|
|
885
|
+
} catch (e) {
|
|
886
|
+
jsonError.value = 'JSON 语法错误: ' + e.message;
|
|
887
|
+
showToast('格式化失败', 'error');
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
onMounted(() => { loadConfig(); });
|
|
892
|
+
|
|
893
|
+
return {
|
|
894
|
+
config, tab, saving, status, statusClass, statusText,
|
|
895
|
+
expandedRoutes, newMethod, jsonText, jsonError, toast, host,
|
|
896
|
+
previewRoutes,
|
|
897
|
+
loadConfig, saveConfig,
|
|
898
|
+
addRoute, removeRoute, toggleRoute,
|
|
899
|
+
addMethod, removeMethod,
|
|
900
|
+
getFields, addField, removeField, onFieldTypeChange, fieldPlaceholder,
|
|
901
|
+
addEvent, removeEvent, renameEvent,
|
|
902
|
+
syncToJson, syncFromJson, formatJson,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}).mount('#app');
|
|
906
|
+
</script>
|
|
907
|
+
</body>
|
|
908
|
+
</html>
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.MockServer = void 0;
|
|
|
7
7
|
const mockjs_1 = __importDefault(require("mockjs"));
|
|
8
8
|
const express_1 = __importDefault(require("express"));
|
|
9
9
|
const cors_1 = __importDefault(require("cors"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
12
|
const ws_1 = require("ws");
|
|
11
13
|
const http_1 = __importDefault(require("http"));
|
|
12
14
|
class MockServer {
|
|
@@ -34,6 +36,7 @@ class MockServer {
|
|
|
34
36
|
this.configPath = configPath;
|
|
35
37
|
this.server = http_1.default.createServer(this.app);
|
|
36
38
|
this.setupMiddleware();
|
|
39
|
+
this.setupAdminRoutes();
|
|
37
40
|
this.setupRoutes();
|
|
38
41
|
if (config.websocket?.enabled) {
|
|
39
42
|
this.setupWebSocket();
|
|
@@ -84,6 +87,57 @@ class MockServer {
|
|
|
84
87
|
}
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
setupAdminRoutes() {
|
|
91
|
+
// 管理后台页面
|
|
92
|
+
this.app.get('/admin', (req, res) => {
|
|
93
|
+
const html = this.getAdminHtml();
|
|
94
|
+
if (html) {
|
|
95
|
+
res.type('html').send(html);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
res.status(404).send('Admin page not found');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// 获取当前配置
|
|
102
|
+
this.app.get('/admin/api/config', (req, res) => {
|
|
103
|
+
res.json(this.config);
|
|
104
|
+
});
|
|
105
|
+
// 保存配置
|
|
106
|
+
this.app.put('/admin/api/config', (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const newConfig = req.body;
|
|
109
|
+
// 简单校验
|
|
110
|
+
if (!newConfig.server || !newConfig.routes) {
|
|
111
|
+
res.status(400).json({ success: false, error: '配置格式不正确:缺少 server 或 routes' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// 写入文件
|
|
115
|
+
const fullPath = path_1.default.resolve(process.cwd(), this.configPath);
|
|
116
|
+
fs_1.default.writeFileSync(fullPath, JSON.stringify(newConfig, null, 2), 'utf-8');
|
|
117
|
+
// 更新内存中的配置
|
|
118
|
+
this.config = newConfig;
|
|
119
|
+
console.log(`配置已保存到: ${fullPath}`);
|
|
120
|
+
res.json({ success: true, message: '配置已保存,部分改动(如新增路由)需重启服务生效' });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error('保存配置失败:', error);
|
|
124
|
+
res.status(500).json({ success: false, error: error.message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
getAdminHtml() {
|
|
129
|
+
const candidates = [
|
|
130
|
+
path_1.default.join(__dirname, 'public', 'admin.html'),
|
|
131
|
+
path_1.default.join(__dirname, '..', 'public', 'admin.html'),
|
|
132
|
+
path_1.default.join(process.cwd(), 'public', 'admin.html'),
|
|
133
|
+
];
|
|
134
|
+
for (const p of candidates) {
|
|
135
|
+
if (fs_1.default.existsSync(p)) {
|
|
136
|
+
return fs_1.default.readFileSync(p, 'utf-8');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
87
141
|
setupRoutes() {
|
|
88
142
|
this.config.routes.forEach((route) => {
|
|
89
143
|
Object.entries(route.methods).forEach(([method, methodConfig]) => {
|
|
@@ -178,6 +232,7 @@ class MockServer {
|
|
|
178
232
|
this.server.listen(this.config.server.port, () => {
|
|
179
233
|
console.log(`Mock 服务器已启动:`);
|
|
180
234
|
console.log(`- HTTP 地址: http://localhost:${this.config.server.port}`);
|
|
235
|
+
console.log(`- 管理后台: http://localhost:${this.config.server.port}/admin`);
|
|
181
236
|
if (this.config.websocket?.enabled) {
|
|
182
237
|
console.log(`- WebSocket 地址: ws://localhost:${this.config.server.port}${this.config.websocket.path}`);
|
|
183
238
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "json-api-mocker",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A mock server based on JSON configuration",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"json-api-mocker": "
|
|
8
|
+
"json-api-mocker": "dist/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"dev": "nodemon",
|
|
19
|
-
"build": "tsc",
|
|
19
|
+
"build": "tsc && node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.cpSync('public','dist/public',{recursive:true})\"",
|
|
20
20
|
"start": "node dist/index.js",
|
|
21
21
|
"test": "jest",
|
|
22
22
|
"test:watch": "jest --watch",
|