json-api-mocker 3.0.0 → 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/CONFIG.md +38 -0
- package/README.ch.md +328 -95
- package/README.md +331 -98
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +6 -41
- package/dist/index.d.ts +3 -7
- package/dist/index.js +43 -48
- package/dist/public/admin.html +908 -0
- package/dist/server.d.ts +24 -21
- package/dist/server.js +260 -328
- package/dist/types.d.ts +45 -36
- package/dist/types.js +2 -2
- package/package.json +12 -20
- package/DESIGN.md +0 -227
- package/web/assets/index-C4eZNDku.js +0 -25
- package/web/assets/index-CGhPNH5w.css +0 -1
- package/web/index.html +0 -15
- package/web/monaco-editor-worker-loader.js +0 -8
- package/web/vite.svg +0 -1
|
@@ -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>
|