devconfig-gen 1.0.0__py3-none-any.whl
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.
- devconfig_gen/__init__.py +90 -0
- devconfig_gen/cli.py +213 -0
- devconfig_gen/engine.py +217 -0
- devconfig_gen/formats.py +708 -0
- devconfig_gen/interactive.py +377 -0
- devconfig_gen/models.py +135 -0
- devconfig_gen/providers/__init__.py +7 -0
- devconfig_gen/providers/custom.py +92 -0
- devconfig_gen/providers/env_provider.py +184 -0
- devconfig_gen/providers/json_provider.py +84 -0
- devconfig_gen/py.typed +0 -0
- devconfig_gen/registry.py +38 -0
- devconfig_gen/validation.py +148 -0
- devconfig_gen/web_ui.py +2143 -0
- devconfig_gen-1.0.0.dist-info/METADATA +456 -0
- devconfig_gen-1.0.0.dist-info/RECORD +20 -0
- devconfig_gen-1.0.0.dist-info/WHEEL +5 -0
- devconfig_gen-1.0.0.dist-info/entry_points.txt +2 -0
- devconfig_gen-1.0.0.dist-info/licenses/LICENSE +201 -0
- devconfig_gen-1.0.0.dist-info/top_level.txt +1 -0
devconfig_gen/web_ui.py
ADDED
|
@@ -0,0 +1,2143 @@
|
|
|
1
|
+
"""Embedded local WebUI for DevConfig-Gen.
|
|
2
|
+
|
|
3
|
+
Provides a zero-build, Mac-first, self-contained web wizard with live preview,
|
|
4
|
+
split-view editing, template pre-filling, and config export. Zero npm/node_modules
|
|
5
|
+
required — runs out-of-the-box using the Python standard library http.server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import webbrowser
|
|
13
|
+
from http import HTTPStatus
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from . import formats
|
|
19
|
+
from .engine import describe_provider, diagnose_request, generate
|
|
20
|
+
from .models import GenerationRequest
|
|
21
|
+
from .registry import ProviderRegistry, default_registry
|
|
22
|
+
|
|
23
|
+
_HTML_PAGE = """<!DOCTYPE html>
|
|
24
|
+
<html lang="zh">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="UTF-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
28
|
+
<title>DevConfig-Gen — 配置工作台</title>
|
|
29
|
+
<style>
|
|
30
|
+
:root {
|
|
31
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
32
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
33
|
+
|
|
34
|
+
--bg: #f5f5f7;
|
|
35
|
+
--card-bg: #ffffff;
|
|
36
|
+
--card-border: #e5e5ea;
|
|
37
|
+
--text: #1d1d1f;
|
|
38
|
+
--text-muted: #86868b;
|
|
39
|
+
--primary: #0071e3;
|
|
40
|
+
--primary-hover: #0077ed;
|
|
41
|
+
--accent: #34c759;
|
|
42
|
+
--danger: #ff3b30;
|
|
43
|
+
--code-bg: #1e1e24;
|
|
44
|
+
--code-text: #e1e1e6;
|
|
45
|
+
--input-bg: #ffffff;
|
|
46
|
+
--input-border: #d2d2d7;
|
|
47
|
+
--input-focus: #0071e3;
|
|
48
|
+
--nav-bg: rgba(255, 255, 255, 0.85);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (prefers-color-scheme: dark) {
|
|
52
|
+
:root {
|
|
53
|
+
--bg: #000000;
|
|
54
|
+
--card-bg: #1c1c1e;
|
|
55
|
+
--card-border: #2c2c2e;
|
|
56
|
+
--text: #f5f5f7;
|
|
57
|
+
--text-muted: #86868b;
|
|
58
|
+
--primary: #2997ff;
|
|
59
|
+
--primary-hover: #40a9ff;
|
|
60
|
+
--accent: #30d158;
|
|
61
|
+
--danger: #ff453a;
|
|
62
|
+
--code-bg: #121214;
|
|
63
|
+
--code-text: #e1e1e6;
|
|
64
|
+
--input-bg: #2c2c2e;
|
|
65
|
+
--input-border: #3a3a3c;
|
|
66
|
+
--input-focus: #2997ff;
|
|
67
|
+
--nav-bg: rgba(28, 28, 30, 0.85);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
72
|
+
body {
|
|
73
|
+
font-family: var(--font-sans);
|
|
74
|
+
background-color: var(--bg);
|
|
75
|
+
color: var(--text);
|
|
76
|
+
line-height: 1.5;
|
|
77
|
+
-webkit-font-smoothing: antialiased;
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
min-height: 100vh;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
header {
|
|
84
|
+
position: sticky;
|
|
85
|
+
top: 0;
|
|
86
|
+
z-index: 100;
|
|
87
|
+
background: var(--nav-bg);
|
|
88
|
+
backdrop-filter: saturate(180%) blur(20px);
|
|
89
|
+
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
|
90
|
+
border-bottom: 1px solid var(--card-border);
|
|
91
|
+
padding: 0.75rem 1.5rem;
|
|
92
|
+
display: flex;
|
|
93
|
+
justify-content: space-between;
|
|
94
|
+
align-items: center;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.brand {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 0.5rem;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
font-size: 1.05rem;
|
|
103
|
+
letter-spacing: -0.01em;
|
|
104
|
+
}
|
|
105
|
+
.brand-tag {
|
|
106
|
+
font-size: 0.75rem;
|
|
107
|
+
padding: 0.15rem 0.4rem;
|
|
108
|
+
background: var(--primary);
|
|
109
|
+
color: #fff;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
font-weight: 500;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.header-controls {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 0.75rem;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.container {
|
|
121
|
+
display: flex;
|
|
122
|
+
flex: 1;
|
|
123
|
+
max-width: 1600px;
|
|
124
|
+
width: 100%;
|
|
125
|
+
margin: 0 auto;
|
|
126
|
+
padding: 1.5rem;
|
|
127
|
+
gap: 1.5rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.wizard-panel {
|
|
131
|
+
flex: 1.1;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: 1.25rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.preview-panel {
|
|
138
|
+
flex: 0.9;
|
|
139
|
+
display: flex;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
background: var(--card-bg);
|
|
142
|
+
border: 1px solid var(--card-border);
|
|
143
|
+
border-radius: 12px;
|
|
144
|
+
overflow: hidden;
|
|
145
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Steps Bar */
|
|
149
|
+
.stepper-nav {
|
|
150
|
+
display: flex;
|
|
151
|
+
gap: 0.5rem;
|
|
152
|
+
background: var(--card-bg);
|
|
153
|
+
border: 1px solid var(--card-border);
|
|
154
|
+
border-radius: 10px;
|
|
155
|
+
padding: 0.5rem;
|
|
156
|
+
}
|
|
157
|
+
.step-btn {
|
|
158
|
+
flex: 1;
|
|
159
|
+
padding: 0.6rem 0.8rem;
|
|
160
|
+
background: transparent;
|
|
161
|
+
border: none;
|
|
162
|
+
border-radius: 8px;
|
|
163
|
+
font-family: inherit;
|
|
164
|
+
font-size: 0.85rem;
|
|
165
|
+
font-weight: 500;
|
|
166
|
+
color: var(--text-muted);
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
justify-content: center;
|
|
171
|
+
gap: 0.4rem;
|
|
172
|
+
transition: all 0.15s ease;
|
|
173
|
+
}
|
|
174
|
+
.step-btn.active {
|
|
175
|
+
background: var(--primary);
|
|
176
|
+
color: #fff;
|
|
177
|
+
box-shadow: 0 2px 8px rgba(0, 113, 227, 0.25);
|
|
178
|
+
}
|
|
179
|
+
.step-btn.completed {
|
|
180
|
+
color: var(--text);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Card */
|
|
184
|
+
.card {
|
|
185
|
+
background: var(--card-bg);
|
|
186
|
+
border: 1px solid var(--card-border);
|
|
187
|
+
border-radius: 12px;
|
|
188
|
+
padding: 1.5rem;
|
|
189
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.card-title {
|
|
193
|
+
font-size: 1.25rem;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
margin-bottom: 0.25rem;
|
|
196
|
+
letter-spacing: -0.01em;
|
|
197
|
+
}
|
|
198
|
+
.card-desc {
|
|
199
|
+
color: var(--text-muted);
|
|
200
|
+
font-size: 0.9rem;
|
|
201
|
+
margin-bottom: 1.5rem;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Form Fields */
|
|
205
|
+
.form-group {
|
|
206
|
+
margin-bottom: 1.25rem;
|
|
207
|
+
}
|
|
208
|
+
.form-label {
|
|
209
|
+
display: flex;
|
|
210
|
+
justify-content: space-between;
|
|
211
|
+
margin-bottom: 0.4rem;
|
|
212
|
+
font-size: 0.85rem;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
}
|
|
215
|
+
.form-hint {
|
|
216
|
+
color: var(--text-muted);
|
|
217
|
+
font-size: 0.8rem;
|
|
218
|
+
font-weight: normal;
|
|
219
|
+
margin-top: 0.25rem;
|
|
220
|
+
}
|
|
221
|
+
.form-error {
|
|
222
|
+
color: var(--danger);
|
|
223
|
+
font-size: 0.8rem;
|
|
224
|
+
margin-top: 0.25rem;
|
|
225
|
+
display: none;
|
|
226
|
+
}
|
|
227
|
+
.has-error .form-error { display: block; }
|
|
228
|
+
.has-error input, .has-error select, .has-error textarea {
|
|
229
|
+
border-color: var(--danger) !important;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
input[type="text"], input[type="number"], select, textarea {
|
|
233
|
+
width: 100%;
|
|
234
|
+
padding: 0.65rem 0.85rem;
|
|
235
|
+
font-family: inherit;
|
|
236
|
+
font-size: 0.9rem;
|
|
237
|
+
background: var(--input-bg);
|
|
238
|
+
border: 1px solid var(--input-border);
|
|
239
|
+
border-radius: 8px;
|
|
240
|
+
color: var(--text);
|
|
241
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
242
|
+
outline: none;
|
|
243
|
+
}
|
|
244
|
+
input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus {
|
|
245
|
+
border-color: var(--input-focus);
|
|
246
|
+
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.15);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.checkbox-label {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
gap: 0.5rem;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
font-size: 0.9rem;
|
|
255
|
+
font-weight: 500;
|
|
256
|
+
}
|
|
257
|
+
.checkbox-label input {
|
|
258
|
+
width: 1.1rem;
|
|
259
|
+
height: 1.1rem;
|
|
260
|
+
accent-color: var(--primary);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Buttons */
|
|
264
|
+
.btn {
|
|
265
|
+
padding: 0.6rem 1rem;
|
|
266
|
+
border-radius: 8px;
|
|
267
|
+
font-family: inherit;
|
|
268
|
+
font-size: 0.85rem;
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
border: 1px solid transparent;
|
|
271
|
+
cursor: pointer;
|
|
272
|
+
display: inline-flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 0.4rem;
|
|
275
|
+
transition: all 0.15s ease;
|
|
276
|
+
}
|
|
277
|
+
.btn-primary {
|
|
278
|
+
background: var(--primary);
|
|
279
|
+
color: #fff;
|
|
280
|
+
}
|
|
281
|
+
.btn-primary:hover { background: var(--primary-hover); }
|
|
282
|
+
.btn-secondary {
|
|
283
|
+
background: var(--card-bg);
|
|
284
|
+
border-color: var(--card-border);
|
|
285
|
+
color: var(--text);
|
|
286
|
+
}
|
|
287
|
+
.btn-secondary:hover {
|
|
288
|
+
background: rgba(128, 128, 128, 0.08);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.wizard-actions {
|
|
292
|
+
display: flex;
|
|
293
|
+
justify-content: space-between;
|
|
294
|
+
margin-top: 1.5rem;
|
|
295
|
+
padding-top: 1.25rem;
|
|
296
|
+
border-top: 1px solid var(--card-border);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Preview Panel */
|
|
300
|
+
.preview-header {
|
|
301
|
+
padding: 0.75rem 1.25rem;
|
|
302
|
+
background: var(--card-bg);
|
|
303
|
+
border-bottom: 1px solid var(--card-border);
|
|
304
|
+
display: flex;
|
|
305
|
+
justify-content: space-between;
|
|
306
|
+
align-items: center;
|
|
307
|
+
}
|
|
308
|
+
.preview-title {
|
|
309
|
+
font-size: 0.9rem;
|
|
310
|
+
font-weight: 600;
|
|
311
|
+
}
|
|
312
|
+
.preview-toolbar {
|
|
313
|
+
display: flex;
|
|
314
|
+
gap: 0.5rem;
|
|
315
|
+
align-items: center;
|
|
316
|
+
}
|
|
317
|
+
.format-toggle {
|
|
318
|
+
display: flex;
|
|
319
|
+
background: var(--input-bg);
|
|
320
|
+
border: 1px solid var(--card-border);
|
|
321
|
+
border-radius: 6px;
|
|
322
|
+
padding: 2px;
|
|
323
|
+
}
|
|
324
|
+
.format-btn {
|
|
325
|
+
padding: 0.25rem 0.6rem;
|
|
326
|
+
border: none;
|
|
327
|
+
background: transparent;
|
|
328
|
+
color: var(--text-muted);
|
|
329
|
+
font-size: 0.75rem;
|
|
330
|
+
font-weight: 600;
|
|
331
|
+
border-radius: 4px;
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
display: inline-flex;
|
|
334
|
+
align-items: center;
|
|
335
|
+
gap: 0.25rem;
|
|
336
|
+
}
|
|
337
|
+
.format-btn.active {
|
|
338
|
+
background: var(--primary);
|
|
339
|
+
color: #fff;
|
|
340
|
+
}
|
|
341
|
+
.format-btn.reserved {
|
|
342
|
+
opacity: 0.7;
|
|
343
|
+
}
|
|
344
|
+
.format-btn.reserved:hover {
|
|
345
|
+
opacity: 0.95;
|
|
346
|
+
}
|
|
347
|
+
.format-badge {
|
|
348
|
+
font-size: 0.6rem;
|
|
349
|
+
padding: 1px 4px;
|
|
350
|
+
border-radius: 3px;
|
|
351
|
+
background: rgba(128, 128, 128, 0.2);
|
|
352
|
+
color: var(--text-muted);
|
|
353
|
+
font-weight: 500;
|
|
354
|
+
text-transform: uppercase;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Document Upload & Raw Editor */
|
|
358
|
+
.doc-editor-card {
|
|
359
|
+
border: 1px solid var(--card-border);
|
|
360
|
+
border-radius: 8px;
|
|
361
|
+
overflow: hidden;
|
|
362
|
+
margin-top: 0.5rem;
|
|
363
|
+
background: var(--card-bg);
|
|
364
|
+
}
|
|
365
|
+
.doc-dropzone {
|
|
366
|
+
border: 2px dashed var(--card-border);
|
|
367
|
+
border-radius: 8px;
|
|
368
|
+
padding: 1.25rem 1rem;
|
|
369
|
+
text-align: center;
|
|
370
|
+
cursor: pointer;
|
|
371
|
+
background: rgba(128, 128, 128, 0.03);
|
|
372
|
+
transition: all 0.2s ease;
|
|
373
|
+
margin-bottom: 0.75rem;
|
|
374
|
+
}
|
|
375
|
+
.doc-dropzone:hover, .doc-dropzone.drag-over {
|
|
376
|
+
border-color: var(--primary);
|
|
377
|
+
background: rgba(0, 113, 227, 0.06);
|
|
378
|
+
}
|
|
379
|
+
.doc-dropzone-icon {
|
|
380
|
+
font-size: 1.5rem;
|
|
381
|
+
margin-bottom: 0.25rem;
|
|
382
|
+
}
|
|
383
|
+
.doc-dropzone-text {
|
|
384
|
+
font-size: 0.85rem;
|
|
385
|
+
font-weight: 500;
|
|
386
|
+
color: var(--text);
|
|
387
|
+
}
|
|
388
|
+
.doc-dropzone-sub {
|
|
389
|
+
font-size: 0.75rem;
|
|
390
|
+
color: var(--text-muted);
|
|
391
|
+
margin-top: 0.2rem;
|
|
392
|
+
}
|
|
393
|
+
.doc-editor-toolbar {
|
|
394
|
+
display: flex;
|
|
395
|
+
justify-content: space-between;
|
|
396
|
+
align-items: center;
|
|
397
|
+
padding: 0.4rem 0.75rem;
|
|
398
|
+
background: var(--input-bg);
|
|
399
|
+
border-bottom: 1px solid var(--card-border);
|
|
400
|
+
font-size: 0.75rem;
|
|
401
|
+
}
|
|
402
|
+
.doc-editor-toolbar-actions {
|
|
403
|
+
display: flex;
|
|
404
|
+
gap: 0.4rem;
|
|
405
|
+
}
|
|
406
|
+
.doc-editor-textarea {
|
|
407
|
+
width: 100%;
|
|
408
|
+
min-height: 220px;
|
|
409
|
+
padding: 0.75rem;
|
|
410
|
+
font-family: var(--font-mono);
|
|
411
|
+
font-size: 0.82rem;
|
|
412
|
+
line-height: 1.5;
|
|
413
|
+
background: var(--code-bg);
|
|
414
|
+
color: var(--code-text);
|
|
415
|
+
border: none;
|
|
416
|
+
resize: vertical;
|
|
417
|
+
outline: none;
|
|
418
|
+
box-sizing: border-box;
|
|
419
|
+
display: block;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* Recursive Tree Editor */
|
|
423
|
+
.tree-root {
|
|
424
|
+
border: 1px solid var(--card-border);
|
|
425
|
+
border-radius: 8px;
|
|
426
|
+
padding: 0.6rem 0.75rem;
|
|
427
|
+
background: var(--card-bg);
|
|
428
|
+
overflow-x: auto;
|
|
429
|
+
}
|
|
430
|
+
.tree-children {
|
|
431
|
+
border-left: 2px solid var(--card-border);
|
|
432
|
+
margin-left: 0.35rem;
|
|
433
|
+
padding-left: 0.5rem;
|
|
434
|
+
margin-top: 0.3rem;
|
|
435
|
+
}
|
|
436
|
+
.tree-row {
|
|
437
|
+
display: flex;
|
|
438
|
+
align-items: flex-start;
|
|
439
|
+
gap: 0.4rem;
|
|
440
|
+
margin-bottom: 0.35rem;
|
|
441
|
+
}
|
|
442
|
+
.tree-row > .tree-node {
|
|
443
|
+
flex: 1 1 auto;
|
|
444
|
+
min-width: 0;
|
|
445
|
+
}
|
|
446
|
+
.tree-row input.tree-key {
|
|
447
|
+
flex: 0 0 8rem;
|
|
448
|
+
width: 8rem;
|
|
449
|
+
min-width: 6rem;
|
|
450
|
+
padding: 0.35rem 0.5rem;
|
|
451
|
+
font-size: 0.8rem;
|
|
452
|
+
}
|
|
453
|
+
.tree-type {
|
|
454
|
+
flex: 0 0 auto;
|
|
455
|
+
width: auto;
|
|
456
|
+
padding: 0.3rem 0.4rem;
|
|
457
|
+
font-size: 0.75rem;
|
|
458
|
+
}
|
|
459
|
+
.tree-row input.tree-value {
|
|
460
|
+
flex: 1 1 6rem;
|
|
461
|
+
min-width: 5rem;
|
|
462
|
+
width: auto;
|
|
463
|
+
padding: 0.35rem 0.5rem;
|
|
464
|
+
font-size: 0.8rem;
|
|
465
|
+
}
|
|
466
|
+
.tree-btn {
|
|
467
|
+
padding: 0.25rem 0.5rem;
|
|
468
|
+
font-size: 0.72rem;
|
|
469
|
+
border-radius: 6px;
|
|
470
|
+
border: 1px solid var(--card-border);
|
|
471
|
+
background: var(--input-bg);
|
|
472
|
+
color: var(--text);
|
|
473
|
+
cursor: pointer;
|
|
474
|
+
white-space: nowrap;
|
|
475
|
+
}
|
|
476
|
+
.tree-btn.remove {
|
|
477
|
+
color: var(--danger);
|
|
478
|
+
}
|
|
479
|
+
.tree-btn.add {
|
|
480
|
+
color: var(--primary);
|
|
481
|
+
}
|
|
482
|
+
.tree-btn.nest {
|
|
483
|
+
color: var(--text);
|
|
484
|
+
font-weight: 600;
|
|
485
|
+
}
|
|
486
|
+
.tree-bulk {
|
|
487
|
+
display: flex;
|
|
488
|
+
gap: 0.35rem;
|
|
489
|
+
margin-top: 0.3rem;
|
|
490
|
+
align-items: center;
|
|
491
|
+
}
|
|
492
|
+
.tree-bulk input {
|
|
493
|
+
flex: 1 1 auto;
|
|
494
|
+
min-width: 8rem;
|
|
495
|
+
padding: 0.35rem 0.5rem;
|
|
496
|
+
font-size: 0.78rem;
|
|
497
|
+
}
|
|
498
|
+
.tree-bulk .tree-btn {
|
|
499
|
+
flex: 0 0 auto;
|
|
500
|
+
}
|
|
501
|
+
.tree-null {
|
|
502
|
+
font-size: 0.8rem;
|
|
503
|
+
color: var(--text-muted);
|
|
504
|
+
padding: 0.35rem 0;
|
|
505
|
+
}
|
|
506
|
+
.tree-toolbar {
|
|
507
|
+
display: flex;
|
|
508
|
+
justify-content: space-between;
|
|
509
|
+
align-items: center;
|
|
510
|
+
margin-bottom: 0.4rem;
|
|
511
|
+
}
|
|
512
|
+
.tree-toolbar-title {
|
|
513
|
+
font-size: 0.78rem;
|
|
514
|
+
font-weight: 600;
|
|
515
|
+
color: var(--text);
|
|
516
|
+
}
|
|
517
|
+
.editor-mode-toggle {
|
|
518
|
+
display: inline-flex;
|
|
519
|
+
gap: 2px;
|
|
520
|
+
padding: 2px;
|
|
521
|
+
background: var(--input-bg);
|
|
522
|
+
border: 1px solid var(--card-border);
|
|
523
|
+
border-radius: 8px;
|
|
524
|
+
margin-bottom: 0.6rem;
|
|
525
|
+
}
|
|
526
|
+
.mode-btn {
|
|
527
|
+
padding: 0.25rem 0.8rem;
|
|
528
|
+
font-size: 0.75rem;
|
|
529
|
+
font-weight: 600;
|
|
530
|
+
border: none;
|
|
531
|
+
background: transparent;
|
|
532
|
+
color: var(--text-muted);
|
|
533
|
+
border-radius: 6px;
|
|
534
|
+
cursor: pointer;
|
|
535
|
+
}
|
|
536
|
+
.mode-btn.active {
|
|
537
|
+
background: var(--primary);
|
|
538
|
+
color: #fff;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.preview-body {
|
|
542
|
+
flex: 1;
|
|
543
|
+
padding: 1.25rem;
|
|
544
|
+
background: var(--code-bg);
|
|
545
|
+
color: var(--code-text);
|
|
546
|
+
font-family: var(--font-mono);
|
|
547
|
+
font-size: 0.85rem;
|
|
548
|
+
line-height: 1.6;
|
|
549
|
+
overflow: auto;
|
|
550
|
+
white-space: pre;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.preview-status {
|
|
554
|
+
padding: 0.5rem 1.25rem;
|
|
555
|
+
font-size: 0.8rem;
|
|
556
|
+
border-top: 1px solid var(--card-border);
|
|
557
|
+
display: flex;
|
|
558
|
+
align-items: center;
|
|
559
|
+
gap: 0.4rem;
|
|
560
|
+
}
|
|
561
|
+
.status-ok { color: var(--accent); }
|
|
562
|
+
.status-err { color: var(--danger); }
|
|
563
|
+
|
|
564
|
+
/* Key-Value Mapping Table */
|
|
565
|
+
.mapping-table {
|
|
566
|
+
width: 100%;
|
|
567
|
+
margin-bottom: 0.5rem;
|
|
568
|
+
}
|
|
569
|
+
.mapping-row {
|
|
570
|
+
display: flex;
|
|
571
|
+
gap: 0.5rem;
|
|
572
|
+
margin-bottom: 0.4rem;
|
|
573
|
+
}
|
|
574
|
+
.mapping-row input { flex: 1; }
|
|
575
|
+
.mapping-remove {
|
|
576
|
+
padding: 0.4rem 0.6rem;
|
|
577
|
+
background: transparent;
|
|
578
|
+
border: 1px solid var(--card-border);
|
|
579
|
+
border-radius: 6px;
|
|
580
|
+
color: var(--danger);
|
|
581
|
+
cursor: pointer;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/* Modal / Toast */
|
|
585
|
+
.toast {
|
|
586
|
+
position: fixed;
|
|
587
|
+
bottom: 2rem;
|
|
588
|
+
right: 2rem;
|
|
589
|
+
background: #333;
|
|
590
|
+
color: #fff;
|
|
591
|
+
padding: 0.75rem 1.25rem;
|
|
592
|
+
border-radius: 8px;
|
|
593
|
+
font-size: 0.85rem;
|
|
594
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
|
595
|
+
display: none;
|
|
596
|
+
z-index: 1000;
|
|
597
|
+
}
|
|
598
|
+
</style>
|
|
599
|
+
</head>
|
|
600
|
+
<body>
|
|
601
|
+
<header>
|
|
602
|
+
<div class="brand">
|
|
603
|
+
<span>⚙️ DevConfig-Gen</span>
|
|
604
|
+
<span class="brand-tag" data-i18n="studio">工作台</span>
|
|
605
|
+
</div>
|
|
606
|
+
<div class="header-controls">
|
|
607
|
+
<select id="providerSelect" style="width: auto; padding: 0.4rem 0.8rem;">
|
|
608
|
+
<option value="custom">Provider: custom</option>
|
|
609
|
+
</select>
|
|
610
|
+
<button class="btn btn-secondary" id="btnClearAll" data-i18n="clearAll">🧹 全部清空</button>
|
|
611
|
+
<button class="btn btn-secondary" id="btnUploadConfig" data-i18n="importFile">⬆️ 导入文件</button>
|
|
612
|
+
<input type="file" id="fileInput" style="display: none;" accept=".json,.yaml,.yml">
|
|
613
|
+
<button class="btn btn-secondary" id="btnPreset" data-i18n="loadPreset">✨ 加载预设</button>
|
|
614
|
+
<button class="btn btn-primary" id="btnExport" data-i18n="saveToDisk">💾 保存到磁盘</button>
|
|
615
|
+
<button class="btn btn-secondary" id="btnLang" style="padding: 0.4rem 0.7rem; font-size: 0.8rem;">中 / EN</button>
|
|
616
|
+
</div>
|
|
617
|
+
</header>
|
|
618
|
+
|
|
619
|
+
<div class="container">
|
|
620
|
+
<div class="wizard-panel">
|
|
621
|
+
<div class="stepper-nav" id="stepperNav"></div>
|
|
622
|
+
|
|
623
|
+
<div class="card" id="stepCard">
|
|
624
|
+
<h2 class="card-title" id="stepTitle">步骤标题</h2>
|
|
625
|
+
<p class="card-desc" id="stepDesc">步骤描述</p>
|
|
626
|
+
|
|
627
|
+
<div id="stepFields"></div>
|
|
628
|
+
|
|
629
|
+
<div class="wizard-actions">
|
|
630
|
+
<button class="btn btn-secondary" id="btnPrev" disabled data-i18n="prev">← 上一步</button>
|
|
631
|
+
<div style="font-size: 0.8rem; color: var(--text-muted); align-self: center;">
|
|
632
|
+
<span data-i18n="shortcut">快捷键</span>: <kbd>⌘</kbd> + <kbd>Enter</kbd>
|
|
633
|
+
</div>
|
|
634
|
+
<button class="btn btn-primary" id="btnNext" data-i18n="next">下一步 →</button>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<div class="preview-panel">
|
|
640
|
+
<div class="preview-header">
|
|
641
|
+
<span class="preview-title" data-i18n="livePreview">实时配置预览</span>
|
|
642
|
+
<div class="preview-toolbar">
|
|
643
|
+
<div class="format-toggle">
|
|
644
|
+
<button class="format-btn active" id="fmtYaml" onclick="setFormat('yaml')">YAML</button>
|
|
645
|
+
<button class="format-btn" id="fmtJson" onclick="setFormat('json')">JSON</button>
|
|
646
|
+
<button class="format-btn reserved" id="fmtXml" onclick="setFormat('xml')" title="XML 格式开关预留(即将支持)/ Reserved XML switch">XML <span class="format-badge">soon</span></button>
|
|
647
|
+
</div>
|
|
648
|
+
<button class="btn btn-secondary" id="btnCopy" style="padding: 0.25rem 0.6rem; font-size: 0.75rem;" data-i18n="copy">📋 复制</button>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
<div class="preview-body" id="previewCode"># 生成预览中...</div>
|
|
652
|
+
<div class="preview-status" id="previewStatus">
|
|
653
|
+
<span class="status-ok">●</span> <span data-i18n="validConfig">配置有效</span>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
<div class="toast" id="toast">通知消息</div>
|
|
659
|
+
|
|
660
|
+
<script>
|
|
661
|
+
// ── i18n ──────────────────────────────────────────────
|
|
662
|
+
const i18n = {
|
|
663
|
+
zh: {
|
|
664
|
+
studio: "工作台", importFile: "⬆️ 导入文件", loadPreset: "✨ 加载预设",
|
|
665
|
+
saveToDisk: "💾 保存到磁盘", prev: "← 上一步", next: "下一步 →",
|
|
666
|
+
finish: "完成 ✓", shortcut: "快捷键", livePreview: "实时配置预览",
|
|
667
|
+
copy: "📋 复制", validConfig: "配置有效", providerLabel: "Provider: ",
|
|
668
|
+
issues: (n) => `${n} 个校验问题`,
|
|
669
|
+
allDone: "✓ 全部步骤完成!可以保存或复制。",
|
|
670
|
+
copied: "📋 已复制配置到剪贴板!",
|
|
671
|
+
presetLoaded: "✨ 已加载预设!",
|
|
672
|
+
saved: (f) => `💾 已保存: ${f}`,
|
|
673
|
+
saveFail: (e) => `保存失败: ${e}`,
|
|
674
|
+
exportErr: (e) => `导出错误: ${e}`,
|
|
675
|
+
enterDir: "请输入保存配置的目标目录:",
|
|
676
|
+
parseFail: (e) => `解析失败: ${e}`,
|
|
677
|
+
uploadOk: (n) => `⬆️ 已从 ${n} 反向解析并填充!`,
|
|
678
|
+
uploadErr: (e) => `上传失败: ${e}`,
|
|
679
|
+
noEntries: "暂无条目",
|
|
680
|
+
keyValuePairs: "键值对",
|
|
681
|
+
add: "+ 添加",
|
|
682
|
+
key: "键", value: "值",
|
|
683
|
+
generating: "# 生成预览中...",
|
|
684
|
+
validationError: "# 校验错误:\\n# ",
|
|
685
|
+
xmlSoon: "XML 格式开关已预留,后续版本即将接入!",
|
|
686
|
+
uploadOrDrop: "点击或拖拽 JSON / YAML 文件到此处上传",
|
|
687
|
+
dropHint: "支持 .json, .yaml, .yml 格式文档,自动反向解析填充",
|
|
688
|
+
docEditor: "文档编辑器 (JSON / YAML)",
|
|
689
|
+
sampleDoc: "✨ 载入示例",
|
|
690
|
+
formatDoc: "格式化",
|
|
691
|
+
clearDoc: "清空",
|
|
692
|
+
clearAll: "🧹 全部清空",
|
|
693
|
+
clearAllDone: "🧹 已清空全部内容。",
|
|
694
|
+
treeEmpty: "暂无字段,点击下方按钮添加。",
|
|
695
|
+
treeAddField: "+ 添加字段",
|
|
696
|
+
treeAddItem: "+ 添加项",
|
|
697
|
+
treeRemove: "删除",
|
|
698
|
+
treeNest: "往里",
|
|
699
|
+
treeNestHint: "用 { } 包起来,里面可以继续嵌套",
|
|
700
|
+
treeBulkAdd: "批量添加",
|
|
701
|
+
treeBulkObj: "批量:a, b, c 或 a=1, b=2, …(逗号分隔,一次可加很多)",
|
|
702
|
+
treeBulkArr: "批量:1, 2, 3, …(逗号分隔,一次可加很多)",
|
|
703
|
+
treeRoot: "根节点",
|
|
704
|
+
treeKey: "键名",
|
|
705
|
+
treeType: "类型",
|
|
706
|
+
modeTree: "结构模式",
|
|
707
|
+
modeText: "文本模式",
|
|
708
|
+
textHint: '直接输入 JSON 或 YAML:列表 [1,2,3,4,5]、嵌套 {"1":{"2":{}}}、混合都行。',
|
|
709
|
+
textParseError: "解析失败",
|
|
710
|
+
},
|
|
711
|
+
en: {
|
|
712
|
+
studio: "Studio", importFile: "⬆️ Import File", loadPreset: "✨ Load Preset",
|
|
713
|
+
saveToDisk: "💾 Save to Disk", prev: "← Previous", next: "Next →",
|
|
714
|
+
finish: "Finish ✓", shortcut: "Shortcut", livePreview: "Live Generated Configuration",
|
|
715
|
+
copy: "📋 Copy", validConfig: "Valid configuration", providerLabel: "Provider: ",
|
|
716
|
+
issues: (n) => `${n} validation ${n === 1 ? 'issue' : 'issues'}`,
|
|
717
|
+
allDone: "✓ All steps completed! Ready to save or copy.",
|
|
718
|
+
copied: "📋 Copied configuration to clipboard!",
|
|
719
|
+
presetLoaded: "✨ Loaded preset!",
|
|
720
|
+
saved: (f) => `💾 Saved: ${f}`,
|
|
721
|
+
saveFail: (e) => `Save failed: ${e}`,
|
|
722
|
+
exportErr: (e) => `Export error: ${e}`,
|
|
723
|
+
enterDir: "Enter target directory to save configuration:",
|
|
724
|
+
parseFail: (e) => `Parse failed: ${e}`,
|
|
725
|
+
uploadOk: (n) => `⬆️ Successfully backfilled from ${n}!`,
|
|
726
|
+
uploadErr: (e) => `Upload failed: ${e}`,
|
|
727
|
+
noEntries: "No entries defined",
|
|
728
|
+
keyValuePairs: "Key-Value pairs",
|
|
729
|
+
add: "+ Add",
|
|
730
|
+
key: "Key", value: "Value",
|
|
731
|
+
generating: "# Generating preview...",
|
|
732
|
+
validationError: "# Validation Error:\\n# ",
|
|
733
|
+
xmlSoon: "XML format switch is reserved and will be supported in an upcoming release!",
|
|
734
|
+
uploadOrDrop: "Click or drag & drop a JSON / YAML file here to upload",
|
|
735
|
+
dropHint: "Supports .json, .yaml, .yml documents with auto-parsing",
|
|
736
|
+
docEditor: "Document Editor (JSON / YAML)",
|
|
737
|
+
sampleDoc: "✨ Sample",
|
|
738
|
+
formatDoc: "Format",
|
|
739
|
+
clearDoc: "Clear",
|
|
740
|
+
clearAll: "🧹 Clear All",
|
|
741
|
+
clearAllDone: "🧹 Cleared all content.",
|
|
742
|
+
treeEmpty: "No fields yet. Use the button below to add one.",
|
|
743
|
+
treeAddField: "+ Add field",
|
|
744
|
+
treeAddItem: "+ Add item",
|
|
745
|
+
treeRemove: "Remove",
|
|
746
|
+
treeNest: "Nest",
|
|
747
|
+
treeNestHint: "Wrap in { }; you can keep nesting inside",
|
|
748
|
+
treeBulkAdd: "Add all",
|
|
749
|
+
treeBulkObj: "Bulk: a, b, c or a=1, b=2, … (comma-separated, many at once)",
|
|
750
|
+
treeBulkArr: "Bulk: 1, 2, 3, … (comma-separated, many at once)",
|
|
751
|
+
treeRoot: "Root",
|
|
752
|
+
treeKey: "Key",
|
|
753
|
+
treeType: "Type",
|
|
754
|
+
modeTree: "Tree mode",
|
|
755
|
+
modeText: "Text mode",
|
|
756
|
+
textHint: 'Type JSON or YAML directly: lists [1,2,3,4,5], nesting {"1":{"2":{}}}, or anything mixed.',
|
|
757
|
+
textParseError: "Parse failed",
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
let currentLang = localStorage.getItem("dcg_lang") || "zh";
|
|
761
|
+
|
|
762
|
+
function t(key, arg) {
|
|
763
|
+
const val = i18n[currentLang][key];
|
|
764
|
+
return typeof val === "function" ? val(arg) : val;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Localize a schema object (step or field) using its i18n map.
|
|
768
|
+
function loc(item, key) {
|
|
769
|
+
if (item && item.i18n && item.i18n[currentLang] && item.i18n[currentLang][key]) {
|
|
770
|
+
return item.i18n[currentLang][key];
|
|
771
|
+
}
|
|
772
|
+
return item ? item[key] : undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function applyLang() {
|
|
776
|
+
document.querySelectorAll("[data-i18n]").forEach(el => {
|
|
777
|
+
const key = el.getAttribute("data-i18n");
|
|
778
|
+
const v = t(key);
|
|
779
|
+
if (v !== undefined) el.textContent = v;
|
|
780
|
+
});
|
|
781
|
+
document.getElementById("previewCode").textContent =
|
|
782
|
+
(currentLang === "zh") ? "# 生成预览中..." : "# Generating preview...";
|
|
783
|
+
document.getElementById("toast").textContent =
|
|
784
|
+
(currentLang === "zh") ? "通知消息" : "Notice message";
|
|
785
|
+
const btn = document.getElementById("btnLang");
|
|
786
|
+
btn.textContent = (currentLang === "zh") ? "EN" : "中";
|
|
787
|
+
document.documentElement.lang = (currentLang === "zh") ? "zh" : "en";
|
|
788
|
+
document.title = (currentLang === "zh")
|
|
789
|
+
? "DevConfig-Gen — 配置工作台"
|
|
790
|
+
: "DevConfig-Gen — Configuration Studio";
|
|
791
|
+
// Refresh provider option labels
|
|
792
|
+
document.querySelectorAll("#providerSelect option").forEach(opt => {
|
|
793
|
+
opt.textContent = `${t("providerLabel")}${opt.value}`;
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Core State ────────────────────────────────────────
|
|
798
|
+
let currentProvider = "custom";
|
|
799
|
+
let schemaSteps = [];
|
|
800
|
+
let activeStepIdx = 0;
|
|
801
|
+
let formData = {};
|
|
802
|
+
let currentFormat = "yaml";
|
|
803
|
+
let debounceTimer = null;
|
|
804
|
+
|
|
805
|
+
// Escape untrusted strings before HTML/attribute interpolation
|
|
806
|
+
function esc(value) {
|
|
807
|
+
return String(value === null || value === undefined ? "" : value)
|
|
808
|
+
.replace(/&/g, "&")
|
|
809
|
+
.replace(/</g, "<")
|
|
810
|
+
.replace(/>/g, ">")
|
|
811
|
+
.replace(/"/g, """)
|
|
812
|
+
.replace(/'/g, "'");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Toast
|
|
816
|
+
function showToast(msg, duration = 2500) {
|
|
817
|
+
const toast = document.getElementById("toast");
|
|
818
|
+
toast.textContent = msg;
|
|
819
|
+
toast.style.display = "block";
|
|
820
|
+
setTimeout(() => { toast.style.display = "none"; }, duration);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Deep setter for dotted path
|
|
824
|
+
function setDotted(obj, path, val) {
|
|
825
|
+
const parts = path.split(".");
|
|
826
|
+
let cur = obj;
|
|
827
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
828
|
+
if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
|
|
829
|
+
cur = cur[parts[i]];
|
|
830
|
+
}
|
|
831
|
+
cur[parts[parts.length - 1]] = val;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Deep getter for dotted path
|
|
835
|
+
function getDotted(obj, path, def = null) {
|
|
836
|
+
if (!obj) return def;
|
|
837
|
+
const parts = path.split(".");
|
|
838
|
+
let cur = obj;
|
|
839
|
+
for (const p of parts) {
|
|
840
|
+
if (!cur || typeof cur !== "object" || !(p in cur)) return def;
|
|
841
|
+
cur = cur[p];
|
|
842
|
+
}
|
|
843
|
+
return cur !== undefined ? cur : def;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── Recursive tree editor ─────────────────────────────
|
|
847
|
+
const TREE_TYPES = ["string", "number", "boolean", "object", "array", "null"];
|
|
848
|
+
|
|
849
|
+
function valueType(v) {
|
|
850
|
+
if (v === null) return "null";
|
|
851
|
+
if (Array.isArray(v)) return "array";
|
|
852
|
+
if (typeof v === "object") return "object";
|
|
853
|
+
if (typeof v === "boolean") return "boolean";
|
|
854
|
+
if (typeof v === "number") return "number";
|
|
855
|
+
return "string";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function defaultForType(type) {
|
|
859
|
+
if (type === "number") return 0;
|
|
860
|
+
if (type === "boolean") return false;
|
|
861
|
+
if (type === "null") return null;
|
|
862
|
+
if (type === "object") return {};
|
|
863
|
+
if (type === "array") return [];
|
|
864
|
+
return "";
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Interpret a typed token as number/boolean/null when it clearly is one.
|
|
868
|
+
function coerceToken(text) {
|
|
869
|
+
const s = String(text).trim();
|
|
870
|
+
if (s === "true") return true;
|
|
871
|
+
if (s === "false") return false;
|
|
872
|
+
if (s === "null") return null;
|
|
873
|
+
if (s !== "" && !isNaN(Number(s))) return Number(s);
|
|
874
|
+
return s;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Build an editor for one value. onChange(newValue) is called on every edit.
|
|
878
|
+
function buildValueEditor(value, onChange, depth) {
|
|
879
|
+
const wrap = document.createElement("div");
|
|
880
|
+
wrap.className = "tree-node";
|
|
881
|
+
const type = valueType(value);
|
|
882
|
+
|
|
883
|
+
const head = document.createElement("div");
|
|
884
|
+
head.className = "tree-row";
|
|
885
|
+
|
|
886
|
+
const typeSel = document.createElement("select");
|
|
887
|
+
typeSel.className = "tree-type";
|
|
888
|
+
TREE_TYPES.forEach(tt => {
|
|
889
|
+
const o = document.createElement("option");
|
|
890
|
+
o.value = tt;
|
|
891
|
+
o.textContent = tt;
|
|
892
|
+
if (tt === type) o.selected = true;
|
|
893
|
+
typeSel.appendChild(o);
|
|
894
|
+
});
|
|
895
|
+
typeSel.onchange = () => onChange(defaultForType(typeSel.value), true);
|
|
896
|
+
head.appendChild(typeSel);
|
|
897
|
+
|
|
898
|
+
if (type === "string" || type === "number") {
|
|
899
|
+
const inp = document.createElement("input");
|
|
900
|
+
inp.type = type === "number" ? "number" : "text";
|
|
901
|
+
inp.className = "tree-value";
|
|
902
|
+
inp.value = value === null || value === undefined ? "" : value;
|
|
903
|
+
inp.oninput = () => {
|
|
904
|
+
if (type === "number") {
|
|
905
|
+
onChange(inp.value === "" ? 0 : Number(inp.value), false);
|
|
906
|
+
} else {
|
|
907
|
+
onChange(inp.value, false);
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
head.appendChild(inp);
|
|
911
|
+
} else if (type === "boolean") {
|
|
912
|
+
const inp = document.createElement("input");
|
|
913
|
+
inp.type = "checkbox";
|
|
914
|
+
inp.checked = !!value;
|
|
915
|
+
inp.onchange = () => onChange(inp.checked, false);
|
|
916
|
+
head.appendChild(inp);
|
|
917
|
+
} else if (type === "null") {
|
|
918
|
+
const span = document.createElement("span");
|
|
919
|
+
span.className = "tree-null";
|
|
920
|
+
span.textContent = "null";
|
|
921
|
+
head.appendChild(span);
|
|
922
|
+
}
|
|
923
|
+
wrap.appendChild(head);
|
|
924
|
+
|
|
925
|
+
if (type === "object") {
|
|
926
|
+
wrap.appendChild(buildContainerEditor(value, onChange, depth, false));
|
|
927
|
+
} else if (type === "array") {
|
|
928
|
+
wrap.appendChild(buildContainerEditor(value, onChange, depth, true));
|
|
929
|
+
}
|
|
930
|
+
return wrap;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function buildContainerEditor(obj, onChange, depth, isArray) {
|
|
934
|
+
const box = document.createElement("div");
|
|
935
|
+
box.className = "tree-children";
|
|
936
|
+
|
|
937
|
+
const keys = isArray ? obj.map((_, i) => String(i)) : Object.keys(obj);
|
|
938
|
+
if (keys.length === 0) {
|
|
939
|
+
const empty = document.createElement("div");
|
|
940
|
+
empty.className = "tree-null";
|
|
941
|
+
empty.textContent = t("treeEmpty");
|
|
942
|
+
box.appendChild(empty);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
keys.forEach(k => {
|
|
946
|
+
const row = document.createElement("div");
|
|
947
|
+
row.className = "tree-row";
|
|
948
|
+
|
|
949
|
+
if (!isArray) {
|
|
950
|
+
const keyInp = document.createElement("input");
|
|
951
|
+
keyInp.type = "text";
|
|
952
|
+
keyInp.className = "tree-key";
|
|
953
|
+
keyInp.placeholder = t("treeKey");
|
|
954
|
+
keyInp.value = k;
|
|
955
|
+
keyInp.onchange = () => {
|
|
956
|
+
const newKey = keyInp.value.trim();
|
|
957
|
+
if (!newKey || newKey === k) { keyInp.value = k; return; }
|
|
958
|
+
const next = {};
|
|
959
|
+
Object.keys(obj).forEach(kk => {
|
|
960
|
+
if (kk === k) next[newKey] = obj[k];
|
|
961
|
+
else next[kk] = obj[kk];
|
|
962
|
+
});
|
|
963
|
+
onChange(next, true);
|
|
964
|
+
};
|
|
965
|
+
row.appendChild(keyInp);
|
|
966
|
+
} else {
|
|
967
|
+
const idx = document.createElement("span");
|
|
968
|
+
idx.className = "tree-null";
|
|
969
|
+
idx.style.flex = "0 0 auto";
|
|
970
|
+
idx.textContent = `[${k}]`;
|
|
971
|
+
row.appendChild(idx);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const childEditor = buildValueEditor(
|
|
975
|
+
isArray ? obj[Number(k)] : obj[k],
|
|
976
|
+
(nv, structural) => {
|
|
977
|
+
if (structural) {
|
|
978
|
+
if (isArray) {
|
|
979
|
+
const arr = obj.slice();
|
|
980
|
+
arr[Number(k)] = nv;
|
|
981
|
+
onChange(arr, true);
|
|
982
|
+
} else {
|
|
983
|
+
const next = Object.assign({}, obj);
|
|
984
|
+
next[k] = nv;
|
|
985
|
+
onChange(next, true);
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
if (isArray) obj[Number(k)] = nv;
|
|
989
|
+
else obj[k] = nv;
|
|
990
|
+
onChange(obj, false);
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
depth + 1
|
|
994
|
+
);
|
|
995
|
+
row.appendChild(childEditor);
|
|
996
|
+
|
|
997
|
+
const nest = document.createElement("button");
|
|
998
|
+
nest.type = "button";
|
|
999
|
+
nest.className = "tree-btn nest";
|
|
1000
|
+
nest.textContent = t("treeNest");
|
|
1001
|
+
nest.title = t("treeNestHint");
|
|
1002
|
+
nest.onclick = () => {
|
|
1003
|
+
const cur = isArray ? obj[Number(k)] : obj[k];
|
|
1004
|
+
const wrapped = (cur === undefined || cur === null || cur === "")
|
|
1005
|
+
? {}
|
|
1006
|
+
: (typeof cur === "object" ? cur : { value: cur });
|
|
1007
|
+
if (isArray) {
|
|
1008
|
+
const arr = obj.slice();
|
|
1009
|
+
arr[Number(k)] = wrapped;
|
|
1010
|
+
onChange(arr, true);
|
|
1011
|
+
} else {
|
|
1012
|
+
const next = Object.assign({}, obj);
|
|
1013
|
+
next[k] = wrapped;
|
|
1014
|
+
onChange(next, true);
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
row.appendChild(nest);
|
|
1018
|
+
|
|
1019
|
+
const rm = document.createElement("button");
|
|
1020
|
+
rm.type = "button";
|
|
1021
|
+
rm.className = "tree-btn remove";
|
|
1022
|
+
rm.textContent = "✕";
|
|
1023
|
+
rm.title = t("treeRemove");
|
|
1024
|
+
rm.onclick = () => {
|
|
1025
|
+
if (isArray) {
|
|
1026
|
+
const arr = obj.slice();
|
|
1027
|
+
arr.splice(Number(k), 1);
|
|
1028
|
+
onChange(arr, true);
|
|
1029
|
+
} else {
|
|
1030
|
+
const next = Object.assign({}, obj);
|
|
1031
|
+
delete next[k];
|
|
1032
|
+
onChange(next, true);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
row.appendChild(rm);
|
|
1036
|
+
box.appendChild(row);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
const add = document.createElement("button");
|
|
1040
|
+
add.type = "button";
|
|
1041
|
+
add.className = "tree-btn add";
|
|
1042
|
+
add.textContent = isArray ? t("treeAddItem") : t("treeAddField");
|
|
1043
|
+
add.onclick = () => {
|
|
1044
|
+
if (isArray) {
|
|
1045
|
+
onChange(obj.concat([""]), true);
|
|
1046
|
+
} else {
|
|
1047
|
+
let name = "field";
|
|
1048
|
+
let i = 1;
|
|
1049
|
+
while (name in obj) { name = `field_${i++}`; }
|
|
1050
|
+
const next = Object.assign({}, obj);
|
|
1051
|
+
next[name] = "";
|
|
1052
|
+
onChange(next, true);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
box.appendChild(add);
|
|
1056
|
+
|
|
1057
|
+
// Bulk add: many comma-separated elements in one go.
|
|
1058
|
+
const bulk = document.createElement("div");
|
|
1059
|
+
bulk.className = "tree-bulk";
|
|
1060
|
+
const bulkInput = document.createElement("input");
|
|
1061
|
+
bulkInput.type = "text";
|
|
1062
|
+
bulkInput.placeholder = isArray ? t("treeBulkArr") : t("treeBulkObj");
|
|
1063
|
+
const bulkBtn = document.createElement("button");
|
|
1064
|
+
bulkBtn.type = "button";
|
|
1065
|
+
bulkBtn.className = "tree-btn add";
|
|
1066
|
+
bulkBtn.textContent = t("treeBulkAdd");
|
|
1067
|
+
function commitBulk() {
|
|
1068
|
+
const tokens = bulkInput.value
|
|
1069
|
+
.split(/[,,]/)
|
|
1070
|
+
.map(s => s.trim())
|
|
1071
|
+
.filter(s => s !== "");
|
|
1072
|
+
if (tokens.length === 0) return;
|
|
1073
|
+
if (isArray) {
|
|
1074
|
+
onChange(obj.concat(tokens.map(coerceToken)), true);
|
|
1075
|
+
} else {
|
|
1076
|
+
const next = Object.assign({}, obj);
|
|
1077
|
+
tokens.forEach(tok => {
|
|
1078
|
+
const eq = tok.indexOf("=");
|
|
1079
|
+
if (eq >= 0) {
|
|
1080
|
+
const key = tok.slice(0, eq).trim();
|
|
1081
|
+
if (key) next[key] = coerceToken(tok.slice(eq + 1));
|
|
1082
|
+
} else {
|
|
1083
|
+
next[tok] = "";
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
onChange(next, true);
|
|
1087
|
+
}
|
|
1088
|
+
bulkInput.value = "";
|
|
1089
|
+
}
|
|
1090
|
+
bulkBtn.onclick = commitBulk;
|
|
1091
|
+
bulkInput.onkeydown = (e) => {
|
|
1092
|
+
if (e.key === "Enter") { e.preventDefault(); commitBulk(); }
|
|
1093
|
+
};
|
|
1094
|
+
bulk.appendChild(bulkInput);
|
|
1095
|
+
bulk.appendChild(bulkBtn);
|
|
1096
|
+
box.appendChild(bulk);
|
|
1097
|
+
return box;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Mount the tree editor into `host`; persist(newRoot) stores each change.
|
|
1101
|
+
function renderTreeEditor(host, root, persist) {
|
|
1102
|
+
let current = (root && typeof root === "object" && !Array.isArray(root)) ? root : {};
|
|
1103
|
+
|
|
1104
|
+
function apply(next, structural) {
|
|
1105
|
+
current = next;
|
|
1106
|
+
persist(next);
|
|
1107
|
+
if (structural) paint();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function paint() {
|
|
1111
|
+
host.innerHTML = "";
|
|
1112
|
+
const box = document.createElement("div");
|
|
1113
|
+
box.className = "tree-root";
|
|
1114
|
+
|
|
1115
|
+
const toolbar = document.createElement("div");
|
|
1116
|
+
toolbar.className = "tree-toolbar";
|
|
1117
|
+
const title = document.createElement("span");
|
|
1118
|
+
title.className = "tree-toolbar-title";
|
|
1119
|
+
title.textContent = t("treeRoot") + " (object)";
|
|
1120
|
+
toolbar.appendChild(title);
|
|
1121
|
+
const clearBtn = document.createElement("button");
|
|
1122
|
+
clearBtn.type = "button";
|
|
1123
|
+
clearBtn.className = "tree-btn remove";
|
|
1124
|
+
clearBtn.textContent = t("clearDoc");
|
|
1125
|
+
clearBtn.onclick = () => apply({}, true);
|
|
1126
|
+
toolbar.appendChild(clearBtn);
|
|
1127
|
+
box.appendChild(toolbar);
|
|
1128
|
+
|
|
1129
|
+
box.appendChild(buildContainerEditor(current, apply, 0, false));
|
|
1130
|
+
host.appendChild(box);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
paint();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Free-form text editor for arbitrary JSON/YAML. persist(parsed) stores it.
|
|
1137
|
+
function mountTextEditor(host, value, persist) {
|
|
1138
|
+
host.innerHTML = `
|
|
1139
|
+
<div class="doc-editor-card">
|
|
1140
|
+
<div class="doc-editor-toolbar">
|
|
1141
|
+
<span style="font-weight:600; color:var(--text);">${t("docEditor")}</span>
|
|
1142
|
+
<div class="doc-editor-toolbar-actions">
|
|
1143
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" data-act="format">${t("formatDoc")}</button>
|
|
1144
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" data-act="clear">${t("clearDoc")}</button>
|
|
1145
|
+
</div>
|
|
1146
|
+
</div>
|
|
1147
|
+
<textarea class="doc-editor-textarea" spellcheck="false"></textarea>
|
|
1148
|
+
</div>
|
|
1149
|
+
<div class="form-hint">${t("textHint")}</div>
|
|
1150
|
+
<div class="form-error"></div>
|
|
1151
|
+
`;
|
|
1152
|
+
const ta = host.querySelector("textarea");
|
|
1153
|
+
const err = host.querySelector(".form-error");
|
|
1154
|
+
try {
|
|
1155
|
+
ta.value = JSON.stringify(value || {}, null, 2);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
ta.value = "{}";
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let timer = null;
|
|
1161
|
+
ta.oninput = () => {
|
|
1162
|
+
clearTimeout(timer);
|
|
1163
|
+
timer = setTimeout(async () => {
|
|
1164
|
+
const text = ta.value.trim();
|
|
1165
|
+
if (!text) {
|
|
1166
|
+
err.textContent = "";
|
|
1167
|
+
persist({});
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1171
|
+
const resp = await fetch("/api/parse", {
|
|
1172
|
+
method: "POST",
|
|
1173
|
+
headers: { "Content-Type": "application/json" },
|
|
1174
|
+
body: JSON.stringify({ content: text })
|
|
1175
|
+
});
|
|
1176
|
+
const res = await resp.json();
|
|
1177
|
+
if (res.context) {
|
|
1178
|
+
err.textContent = "";
|
|
1179
|
+
persist(res.context);
|
|
1180
|
+
} else {
|
|
1181
|
+
err.textContent = res.error || t("textParseError");
|
|
1182
|
+
}
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
err.textContent = String(e);
|
|
1185
|
+
}
|
|
1186
|
+
}, 200);
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
host.querySelector('[data-act="format"]').onclick = () => {
|
|
1190
|
+
try {
|
|
1191
|
+
ta.value = JSON.stringify(JSON.parse(ta.value), null, 2);
|
|
1192
|
+
err.textContent = "";
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
err.textContent = t("textParseError");
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
host.querySelector('[data-act="clear"]').onclick = () => {
|
|
1198
|
+
ta.value = "{}";
|
|
1199
|
+
persist({});
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function starterData(provider) {
|
|
1204
|
+
if (provider === "custom") {
|
|
1205
|
+
return {
|
|
1206
|
+
document: {
|
|
1207
|
+
app: {
|
|
1208
|
+
name: "my-app",
|
|
1209
|
+
enabled: true,
|
|
1210
|
+
port: 8080,
|
|
1211
|
+
tags: ["web", "api"],
|
|
1212
|
+
database: { host: "localhost", port: 5432, ssl: false },
|
|
1213
|
+
notes: "comma, 逗号, both are kept"
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
if (provider === "json") {
|
|
1219
|
+
return { document: {} };
|
|
1220
|
+
}
|
|
1221
|
+
return {};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function initApp() {
|
|
1225
|
+
try {
|
|
1226
|
+
const resp = await fetch("/api/providers");
|
|
1227
|
+
const data = await resp.json();
|
|
1228
|
+
const select = document.getElementById("providerSelect");
|
|
1229
|
+
select.innerHTML = "";
|
|
1230
|
+
data.providers.forEach(p => {
|
|
1231
|
+
const opt = document.createElement("option");
|
|
1232
|
+
opt.value = p;
|
|
1233
|
+
opt.textContent = `${t("providerLabel")}${p}`;
|
|
1234
|
+
select.appendChild(opt);
|
|
1235
|
+
});
|
|
1236
|
+
select.value = currentProvider;
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
console.error("Failed to load providers:", err);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
await loadSchema(currentProvider);
|
|
1242
|
+
restoreDraft();
|
|
1243
|
+
if (!formData || Object.keys(formData).length === 0) {
|
|
1244
|
+
formData = starterData(currentProvider);
|
|
1245
|
+
}
|
|
1246
|
+
renderStep();
|
|
1247
|
+
triggerLivePreview();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function loadSchema(provider) {
|
|
1251
|
+
const resp = await fetch(`/api/schema?provider=${provider}`);
|
|
1252
|
+
schemaSteps = await resp.json();
|
|
1253
|
+
activeStepIdx = 0;
|
|
1254
|
+
renderStepper();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function renderStepper() {
|
|
1258
|
+
const nav = document.getElementById("stepperNav");
|
|
1259
|
+
nav.innerHTML = "";
|
|
1260
|
+
schemaSteps.forEach((step, idx) => {
|
|
1261
|
+
const btn = document.createElement("button");
|
|
1262
|
+
btn.className = `step-btn ${idx === activeStepIdx ? "active" : ""}`;
|
|
1263
|
+
btn.innerHTML = `<span>${idx + 1}.</span> <span>${esc(loc(step, "title"))}</span>`;
|
|
1264
|
+
btn.onclick = () => { activeStepIdx = idx; renderStep(); };
|
|
1265
|
+
nav.appendChild(btn);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function renderStep() {
|
|
1270
|
+
renderStepper();
|
|
1271
|
+
const step = schemaSteps[activeStepIdx];
|
|
1272
|
+
if (!step) return;
|
|
1273
|
+
|
|
1274
|
+
document.getElementById("stepTitle").textContent = loc(step, "title");
|
|
1275
|
+
document.getElementById("stepDesc").textContent = loc(step, "description") || "";
|
|
1276
|
+
document.getElementById("btnPrev").disabled = activeStepIdx === 0;
|
|
1277
|
+
document.getElementById("btnNext").textContent = activeStepIdx === schemaSteps.length - 1 ? t("finish") : t("next");
|
|
1278
|
+
|
|
1279
|
+
const fieldsContainer = document.getElementById("stepFields");
|
|
1280
|
+
fieldsContainer.innerHTML = "";
|
|
1281
|
+
|
|
1282
|
+
step.fields.forEach(field => {
|
|
1283
|
+
const grp = document.createElement("div");
|
|
1284
|
+
grp.className = "form-group";
|
|
1285
|
+
grp.id = `grp_${field.name.replace(/\\./g, "_")}`;
|
|
1286
|
+
|
|
1287
|
+
const existing = getDotted(formData, field.name, field.default);
|
|
1288
|
+
const fid = field.name.replace(/\\./g, "_");
|
|
1289
|
+
|
|
1290
|
+
if (field.type === "boolean") {
|
|
1291
|
+
grp.innerHTML = `
|
|
1292
|
+
<label class="checkbox-label">
|
|
1293
|
+
<input type="checkbox" id="field_${fid}" ${existing ? "checked" : ""}>
|
|
1294
|
+
<span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
|
|
1295
|
+
</label>
|
|
1296
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1297
|
+
`;
|
|
1298
|
+
const input = grp.querySelector("input");
|
|
1299
|
+
input.onchange = () => {
|
|
1300
|
+
setDotted(formData, field.name, input.checked);
|
|
1301
|
+
saveDraft();
|
|
1302
|
+
triggerLivePreview();
|
|
1303
|
+
};
|
|
1304
|
+
} else if (field.choices && field.choices.length > 0) {
|
|
1305
|
+
grp.innerHTML = `
|
|
1306
|
+
<label class="form-label">
|
|
1307
|
+
<span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
|
|
1308
|
+
</label>
|
|
1309
|
+
<select id="field_${fid}">
|
|
1310
|
+
${field.choices.map(c => `<option value="${esc(c)}" ${c === existing ? "selected" : ""}>${esc(c)}</option>`).join("")}
|
|
1311
|
+
</select>
|
|
1312
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1313
|
+
<div class="form-error"></div>
|
|
1314
|
+
`;
|
|
1315
|
+
const sel = grp.querySelector("select");
|
|
1316
|
+
sel.onchange = () => {
|
|
1317
|
+
setDotted(formData, field.name, sel.value);
|
|
1318
|
+
saveDraft();
|
|
1319
|
+
triggerLivePreview();
|
|
1320
|
+
};
|
|
1321
|
+
} else if (field.type === "tree") {
|
|
1322
|
+
grp.innerHTML = `
|
|
1323
|
+
<label class="form-label">
|
|
1324
|
+
<span>${esc(loc(field, "title"))}</span>
|
|
1325
|
+
</label>
|
|
1326
|
+
<div class="editor-mode-toggle">
|
|
1327
|
+
<button type="button" class="mode-btn active" id="modeTree_${fid}">${t("modeTree")}</button>
|
|
1328
|
+
<button type="button" class="mode-btn" id="modeText_${fid}">${t("modeText")}</button>
|
|
1329
|
+
</div>
|
|
1330
|
+
<div id="treeHost_${fid}"></div>
|
|
1331
|
+
<div id="textHost_${fid}" style="display:none;"></div>
|
|
1332
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1333
|
+
<div class="form-error"></div>
|
|
1334
|
+
`;
|
|
1335
|
+
const treeHost = grp.querySelector(`#treeHost_${fid}`);
|
|
1336
|
+
const textHost = grp.querySelector(`#textHost_${fid}`);
|
|
1337
|
+
const btnTree = grp.querySelector(`#modeTree_${fid}`);
|
|
1338
|
+
const btnText = grp.querySelector(`#modeText_${fid}`);
|
|
1339
|
+
let treeData = existing;
|
|
1340
|
+
if (!treeData || typeof treeData !== "object") {
|
|
1341
|
+
treeData = {};
|
|
1342
|
+
}
|
|
1343
|
+
const persistTree = (next) => {
|
|
1344
|
+
treeData = next;
|
|
1345
|
+
setDotted(formData, field.name, next);
|
|
1346
|
+
saveDraft();
|
|
1347
|
+
triggerLivePreview();
|
|
1348
|
+
};
|
|
1349
|
+
function showTreeMode() {
|
|
1350
|
+
btnTree.className = "mode-btn active";
|
|
1351
|
+
btnText.className = "mode-btn";
|
|
1352
|
+
treeHost.style.display = "";
|
|
1353
|
+
textHost.style.display = "none";
|
|
1354
|
+
renderTreeEditor(treeHost, treeData, persistTree);
|
|
1355
|
+
}
|
|
1356
|
+
function showTextMode() {
|
|
1357
|
+
btnTree.className = "mode-btn";
|
|
1358
|
+
btnText.className = "mode-btn active";
|
|
1359
|
+
treeHost.style.display = "none";
|
|
1360
|
+
textHost.style.display = "";
|
|
1361
|
+
mountTextEditor(textHost, treeData, persistTree);
|
|
1362
|
+
}
|
|
1363
|
+
btnTree.onclick = showTreeMode;
|
|
1364
|
+
btnText.onclick = showTextMode;
|
|
1365
|
+
showTreeMode();
|
|
1366
|
+
} else if (field.type === "document" || (currentProvider === "json" && field.name === "document")) {
|
|
1367
|
+
let docData = existing;
|
|
1368
|
+
if ((!docData || (typeof docData === "object" && Object.keys(docData).length === 0)) && currentProvider === "json") {
|
|
1369
|
+
const keys = Object.keys(formData).filter(k => k !== "document");
|
|
1370
|
+
if (keys.length > 0) docData = formData;
|
|
1371
|
+
}
|
|
1372
|
+
if (!docData) {
|
|
1373
|
+
docData = {
|
|
1374
|
+
app: {
|
|
1375
|
+
name: "example-app",
|
|
1376
|
+
port: 8080,
|
|
1377
|
+
environment: "development"
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
if (currentProvider === "json") {
|
|
1381
|
+
formData = JSON.parse(JSON.stringify(docData));
|
|
1382
|
+
formData.document = JSON.parse(JSON.stringify(docData));
|
|
1383
|
+
} else {
|
|
1384
|
+
setDotted(formData, field.name, docData);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
let initialText = "";
|
|
1388
|
+
try {
|
|
1389
|
+
initialText = typeof docData === "string" ? docData : JSON.stringify(docData, null, 2);
|
|
1390
|
+
} catch (e) {
|
|
1391
|
+
initialText = "{}";
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
grp.innerHTML = `
|
|
1395
|
+
<label class="form-label">
|
|
1396
|
+
<span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
|
|
1397
|
+
</label>
|
|
1398
|
+
<div class="doc-dropzone" id="dropzone_${fid}">
|
|
1399
|
+
<div class="doc-dropzone-icon">📄</div>
|
|
1400
|
+
<div class="doc-dropzone-text">${t("uploadOrDrop")}</div>
|
|
1401
|
+
<div class="doc-dropzone-sub">${t("dropHint")}</div>
|
|
1402
|
+
<input type="file" id="dropInput_${fid}" style="display:none;" accept=".json,.yaml,.yml">
|
|
1403
|
+
</div>
|
|
1404
|
+
<div class="doc-editor-card">
|
|
1405
|
+
<div class="doc-editor-toolbar">
|
|
1406
|
+
<span style="font-weight:600; color:var(--text);">${t("docEditor")}</span>
|
|
1407
|
+
<div class="doc-editor-toolbar-actions">
|
|
1408
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocSample_${fid}">${t("sampleDoc")}</button>
|
|
1409
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocFormat_${fid}">${t("formatDoc")}</button>
|
|
1410
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocClear_${fid}">${t("clearDoc")}</button>
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
<textarea class="doc-editor-textarea" id="docText_${fid}" spellcheck="false"></textarea>
|
|
1414
|
+
</div>
|
|
1415
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1416
|
+
<div class="form-error" id="docErr_${fid}"></div>
|
|
1417
|
+
`;
|
|
1418
|
+
|
|
1419
|
+
const dropzone = grp.querySelector(`#dropzone_${fid}`);
|
|
1420
|
+
const dropInput = grp.querySelector(`#dropInput_${fid}`);
|
|
1421
|
+
const textarea = grp.querySelector(`#docText_${fid}`);
|
|
1422
|
+
const docErr = grp.querySelector(`#docErr_${fid}`);
|
|
1423
|
+
const btnSample = grp.querySelector(`#btnDocSample_${fid}`);
|
|
1424
|
+
const btnFormat = grp.querySelector(`#btnDocFormat_${fid}`);
|
|
1425
|
+
const btnClear = grp.querySelector(`#btnDocClear_${fid}`);
|
|
1426
|
+
|
|
1427
|
+
textarea.value = initialText;
|
|
1428
|
+
|
|
1429
|
+
function updateDocContext(parsed) {
|
|
1430
|
+
if (currentProvider === "json") {
|
|
1431
|
+
formData = parsed;
|
|
1432
|
+
formData.document = parsed;
|
|
1433
|
+
} else {
|
|
1434
|
+
setDotted(formData, field.name, parsed);
|
|
1435
|
+
}
|
|
1436
|
+
saveDraft();
|
|
1437
|
+
triggerLivePreview();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
let docTimer = null;
|
|
1441
|
+
function parseAndApply(text) {
|
|
1442
|
+
clearTimeout(docTimer);
|
|
1443
|
+
docTimer = setTimeout(async () => {
|
|
1444
|
+
const trimmed = text.trim();
|
|
1445
|
+
if (!trimmed) {
|
|
1446
|
+
docErr.textContent = "";
|
|
1447
|
+
updateDocContext({});
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
try {
|
|
1451
|
+
const resp = await fetch("/api/parse", {
|
|
1452
|
+
method: "POST",
|
|
1453
|
+
headers: { "Content-Type": "application/json" },
|
|
1454
|
+
body: JSON.stringify({ content: trimmed })
|
|
1455
|
+
});
|
|
1456
|
+
const res = await resp.json();
|
|
1457
|
+
if (res.context) {
|
|
1458
|
+
docErr.textContent = "";
|
|
1459
|
+
updateDocContext(res.context);
|
|
1460
|
+
} else {
|
|
1461
|
+
docErr.textContent = res.error || "Format parse error";
|
|
1462
|
+
}
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
docErr.textContent = String(err);
|
|
1465
|
+
}
|
|
1466
|
+
}, 150);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
textarea.oninput = () => {
|
|
1470
|
+
parseAndApply(textarea.value);
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
async function handleDocFile(file) {
|
|
1474
|
+
if (!file) return;
|
|
1475
|
+
try {
|
|
1476
|
+
const text = await file.text();
|
|
1477
|
+
textarea.value = text;
|
|
1478
|
+
const resp = await fetch("/api/parse", {
|
|
1479
|
+
method: "POST",
|
|
1480
|
+
headers: { "Content-Type": "application/json" },
|
|
1481
|
+
body: JSON.stringify({ content: text })
|
|
1482
|
+
});
|
|
1483
|
+
const res = await resp.json();
|
|
1484
|
+
if (res.context) {
|
|
1485
|
+
docErr.textContent = "";
|
|
1486
|
+
updateDocContext(res.context);
|
|
1487
|
+
showToast(t("uploadOk")(file.name));
|
|
1488
|
+
} else {
|
|
1489
|
+
docErr.textContent = res.error || "Format parse error";
|
|
1490
|
+
}
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
docErr.textContent = String(e);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
dropzone.onclick = () => dropInput.click();
|
|
1497
|
+
dropInput.onchange = () => {
|
|
1498
|
+
if (dropInput.files[0]) handleDocFile(dropInput.files[0]);
|
|
1499
|
+
};
|
|
1500
|
+
dropzone.ondragover = (e) => {
|
|
1501
|
+
e.preventDefault();
|
|
1502
|
+
dropzone.classList.add("drag-over");
|
|
1503
|
+
};
|
|
1504
|
+
dropzone.ondragleave = () => {
|
|
1505
|
+
dropzone.classList.remove("drag-over");
|
|
1506
|
+
};
|
|
1507
|
+
dropzone.ondrop = (e) => {
|
|
1508
|
+
e.preventDefault();
|
|
1509
|
+
dropzone.classList.remove("drag-over");
|
|
1510
|
+
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
1511
|
+
handleDocFile(e.dataTransfer.files[0]);
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
btnSample.onclick = () => {
|
|
1516
|
+
const sample = {
|
|
1517
|
+
app: {
|
|
1518
|
+
name: "checkout-api",
|
|
1519
|
+
version: "1.0.0",
|
|
1520
|
+
port: 8080,
|
|
1521
|
+
environment: "production",
|
|
1522
|
+
labels: { tier: "backend", team: "core" }
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
textarea.value = JSON.stringify(sample, null, 2);
|
|
1526
|
+
parseAndApply(textarea.value);
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
btnFormat.onclick = () => {
|
|
1530
|
+
try {
|
|
1531
|
+
const val = JSON.parse(textarea.value);
|
|
1532
|
+
textarea.value = JSON.stringify(val, null, 2);
|
|
1533
|
+
docErr.textContent = "";
|
|
1534
|
+
} catch (e) {
|
|
1535
|
+
showToast("JSON 格式化需要标准 JSON 语法 / Standard JSON required");
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
btnClear.onclick = () => {
|
|
1540
|
+
textarea.value = "{\\n}";
|
|
1541
|
+
parseAndApply(textarea.value);
|
|
1542
|
+
};
|
|
1543
|
+
} else if (field.type === "mapping") {
|
|
1544
|
+
const mapData = existing || {};
|
|
1545
|
+
grp.innerHTML = `
|
|
1546
|
+
<label class="form-label">
|
|
1547
|
+
<span>${esc(loc(field, "title"))} (${t("keyValuePairs")})</span>
|
|
1548
|
+
<button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnAddMap_${fid}">${t("add")}</button>
|
|
1549
|
+
</label>
|
|
1550
|
+
<div class="mapping-table" id="mapTable_${fid}"></div>
|
|
1551
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1552
|
+
`;
|
|
1553
|
+
const table = grp.querySelector(`#mapTable_${fid}`);
|
|
1554
|
+
const addBtn = grp.querySelector(`#btnAddMap_${fid}`);
|
|
1555
|
+
|
|
1556
|
+
function renderMapRows() {
|
|
1557
|
+
table.innerHTML = "";
|
|
1558
|
+
const keys = Object.keys(mapData);
|
|
1559
|
+
if (keys.length === 0) {
|
|
1560
|
+
table.innerHTML = `<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.4rem;">${t("noEntries")}</div>`;
|
|
1561
|
+
}
|
|
1562
|
+
keys.forEach(k => {
|
|
1563
|
+
const row = document.createElement("div");
|
|
1564
|
+
row.className = "mapping-row";
|
|
1565
|
+
row.innerHTML = `
|
|
1566
|
+
<input type="text" placeholder="${t("key")}" value="${esc(k)}" class="map-k">
|
|
1567
|
+
<input type="text" placeholder="${t("value")}" value="${esc(mapData[k])}" class="map-v">
|
|
1568
|
+
<button type="button" class="mapping-remove">✕</button>
|
|
1569
|
+
`;
|
|
1570
|
+
const kInput = row.querySelector(".map-k");
|
|
1571
|
+
const vInput = row.querySelector(".map-v");
|
|
1572
|
+
row.querySelector(".mapping-remove").onclick = () => {
|
|
1573
|
+
delete mapData[k];
|
|
1574
|
+
setDotted(formData, field.name, mapData);
|
|
1575
|
+
renderMapRows();
|
|
1576
|
+
triggerLivePreview();
|
|
1577
|
+
};
|
|
1578
|
+
kInput.onchange = () => {
|
|
1579
|
+
const newK = kInput.value.trim();
|
|
1580
|
+
const oldV = mapData[k];
|
|
1581
|
+
delete mapData[k];
|
|
1582
|
+
if (newK) mapData[newK] = oldV;
|
|
1583
|
+
setDotted(formData, field.name, mapData);
|
|
1584
|
+
renderMapRows();
|
|
1585
|
+
triggerLivePreview();
|
|
1586
|
+
};
|
|
1587
|
+
vInput.oninput = () => {
|
|
1588
|
+
mapData[k] = vInput.value.trim();
|
|
1589
|
+
setDotted(formData, field.name, mapData);
|
|
1590
|
+
triggerLivePreview();
|
|
1591
|
+
};
|
|
1592
|
+
table.appendChild(row);
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
addBtn.onclick = () => {
|
|
1596
|
+
const nextKey = `key_${Object.keys(mapData).length + 1}`;
|
|
1597
|
+
mapData[nextKey] = "value";
|
|
1598
|
+
setDotted(formData, field.name, mapData);
|
|
1599
|
+
renderMapRows();
|
|
1600
|
+
triggerLivePreview();
|
|
1601
|
+
};
|
|
1602
|
+
renderMapRows();
|
|
1603
|
+
} else {
|
|
1604
|
+
// string or integer
|
|
1605
|
+
const isNum = field.type === "integer";
|
|
1606
|
+
grp.innerHTML = `
|
|
1607
|
+
<label class="form-label">
|
|
1608
|
+
<span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
|
|
1609
|
+
${field.minimum !== undefined && field.maximum !== undefined ? `<span class="form-hint">[${field.minimum} - ${field.maximum}]</span>` : ""}
|
|
1610
|
+
</label>
|
|
1611
|
+
<input type="${isNum ? 'number' : 'text'}" id="field_${fid}"
|
|
1612
|
+
value="${esc(existing !== null && existing !== undefined ? existing : '')}"
|
|
1613
|
+
placeholder="${esc(field.default !== null && field.default !== undefined ? field.default : '')}">
|
|
1614
|
+
<div class="form-hint">${esc(loc(field, "description") || "")}</div>
|
|
1615
|
+
<div class="form-error"></div>
|
|
1616
|
+
`;
|
|
1617
|
+
const input = grp.querySelector("input");
|
|
1618
|
+
input.oninput = () => {
|
|
1619
|
+
let val = input.value;
|
|
1620
|
+
if (isNum && val !== "") val = parseInt(val, 10);
|
|
1621
|
+
setDotted(formData, field.name, val === "" ? null : val);
|
|
1622
|
+
saveDraft();
|
|
1623
|
+
triggerLivePreview();
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
fieldsContainer.appendChild(grp);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Trigger Live Preview & Diagnostics
|
|
1631
|
+
function triggerLivePreview() {
|
|
1632
|
+
clearTimeout(debounceTimer);
|
|
1633
|
+
debounceTimer = setTimeout(runPreviewAndValidate, 150);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
async function runPreviewAndValidate() {
|
|
1637
|
+
try {
|
|
1638
|
+
// Validate
|
|
1639
|
+
const valResp = await fetch("/api/validate", {
|
|
1640
|
+
method: "POST",
|
|
1641
|
+
headers: { "Content-Type": "application/json" },
|
|
1642
|
+
body: JSON.stringify({ provider: currentProvider, context: formData })
|
|
1643
|
+
});
|
|
1644
|
+
const valData = await valResp.json();
|
|
1645
|
+
|
|
1646
|
+
// Clear all field errors
|
|
1647
|
+
document.querySelectorAll(".form-group").forEach(el => {
|
|
1648
|
+
el.classList.remove("has-error");
|
|
1649
|
+
const errEl = el.querySelector(".form-error");
|
|
1650
|
+
if (errEl) errEl.textContent = "";
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
const statusEl = document.getElementById("previewStatus");
|
|
1654
|
+
if (valData.valid) {
|
|
1655
|
+
statusEl.innerHTML = `<span class="status-ok">●</span> ${t("validConfig")}`;
|
|
1656
|
+
} else {
|
|
1657
|
+
const count = valData.diagnostics.length;
|
|
1658
|
+
statusEl.innerHTML = `<span class="status-err">●</span> ${t("issues")(count)}`;
|
|
1659
|
+
valData.diagnostics.forEach(d => {
|
|
1660
|
+
const grpId = `grp_${d.field.replace(/\\./g, "_")}`;
|
|
1661
|
+
const grp = document.getElementById(grpId);
|
|
1662
|
+
if (grp) {
|
|
1663
|
+
grp.classList.add("has-error");
|
|
1664
|
+
const errEl = grp.querySelector(".form-error");
|
|
1665
|
+
if (errEl) errEl.textContent = d.message;
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Generate Preview
|
|
1671
|
+
const genResp = await fetch("/api/generate", {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
headers: { "Content-Type": "application/json" },
|
|
1674
|
+
body: JSON.stringify({
|
|
1675
|
+
provider: currentProvider,
|
|
1676
|
+
context: formData,
|
|
1677
|
+
format: currentFormat
|
|
1678
|
+
})
|
|
1679
|
+
});
|
|
1680
|
+
const genData = await genResp.json();
|
|
1681
|
+
const codeEl = document.getElementById("previewCode");
|
|
1682
|
+
if (genData.artifacts && genData.artifacts.length > 0) {
|
|
1683
|
+
codeEl.textContent = genData.artifacts[0].content;
|
|
1684
|
+
} else if (genData.error) {
|
|
1685
|
+
codeEl.textContent = t("validationError") + genData.error;
|
|
1686
|
+
}
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
console.error("Preview failed:", err);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function setFormat(fmt) {
|
|
1693
|
+
if (fmt === "xml") {
|
|
1694
|
+
showToast(t("xmlSoon"));
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
currentFormat = fmt;
|
|
1698
|
+
document.getElementById("fmtYaml").className = `format-btn ${fmt === "yaml" ? "active" : ""}`;
|
|
1699
|
+
document.getElementById("fmtJson").className = `format-btn ${fmt === "json" ? "active" : ""}`;
|
|
1700
|
+
triggerLivePreview();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Navigation
|
|
1704
|
+
document.getElementById("btnPrev").onclick = () => {
|
|
1705
|
+
if (activeStepIdx > 0) {
|
|
1706
|
+
activeStepIdx--;
|
|
1707
|
+
renderStep();
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
document.getElementById("btnNext").onclick = () => {
|
|
1711
|
+
if (activeStepIdx < schemaSteps.length - 1) {
|
|
1712
|
+
activeStepIdx++;
|
|
1713
|
+
renderStep();
|
|
1714
|
+
} else {
|
|
1715
|
+
showToast(t("allDone"));
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
// Keyboard Shortcut (⌘ + Enter)
|
|
1720
|
+
window.addEventListener("keydown", (e) => {
|
|
1721
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
1722
|
+
e.preventDefault();
|
|
1723
|
+
document.getElementById("btnNext").click();
|
|
1724
|
+
}
|
|
1725
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
1726
|
+
e.preventDefault();
|
|
1727
|
+
document.getElementById("btnExport").click();
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
// Copy to clipboard
|
|
1732
|
+
document.getElementById("btnCopy").onclick = () => {
|
|
1733
|
+
const code = document.getElementById("previewCode").textContent;
|
|
1734
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
1735
|
+
showToast(t("copied"));
|
|
1736
|
+
});
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
// Clear All
|
|
1740
|
+
document.getElementById("btnClearAll").onclick = () => {
|
|
1741
|
+
formData = {};
|
|
1742
|
+
if (currentProvider === "custom" || currentProvider === "json") {
|
|
1743
|
+
formData = { document: {} };
|
|
1744
|
+
}
|
|
1745
|
+
saveDraft();
|
|
1746
|
+
renderStep();
|
|
1747
|
+
triggerLivePreview();
|
|
1748
|
+
showToast(t("clearAllDone"));
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
// Preset
|
|
1752
|
+
document.getElementById("btnPreset").onclick = () => {
|
|
1753
|
+
if (currentProvider === "json") {
|
|
1754
|
+
formData = {
|
|
1755
|
+
app: {
|
|
1756
|
+
name: "payments-core",
|
|
1757
|
+
version: "1.0.0",
|
|
1758
|
+
port: 8080,
|
|
1759
|
+
environment: "production",
|
|
1760
|
+
replicas: 3,
|
|
1761
|
+
labels: { team: "payments", tier: "backend" }
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
formData.document = JSON.parse(JSON.stringify(formData));
|
|
1765
|
+
} else if (currentProvider === "custom") {
|
|
1766
|
+
formData = {
|
|
1767
|
+
document: {
|
|
1768
|
+
app: {
|
|
1769
|
+
name: "my-app",
|
|
1770
|
+
enabled: true,
|
|
1771
|
+
port: 8080,
|
|
1772
|
+
tags: ["web", "api"],
|
|
1773
|
+
database: { host: "localhost", port: 5432, ssl: false },
|
|
1774
|
+
limits: { cpu: "500m", memory: "512Mi" }
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
} else if (currentProvider === "env") {
|
|
1779
|
+
formData = {
|
|
1780
|
+
variables: {
|
|
1781
|
+
database: { host: "localhost", port: 5432, name: "app_db" },
|
|
1782
|
+
debug: true,
|
|
1783
|
+
log_level: "info"
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
} else {
|
|
1787
|
+
formData = {
|
|
1788
|
+
app: {
|
|
1789
|
+
name: "payments-core",
|
|
1790
|
+
version: "1.0.0",
|
|
1791
|
+
port: 8080,
|
|
1792
|
+
environment: "production",
|
|
1793
|
+
replicas: 3,
|
|
1794
|
+
labels: { team: "payments", tier: "backend" },
|
|
1795
|
+
health_check: { path: "/healthz", interval_seconds: 15, timeout_seconds: 5 }
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
saveDraft();
|
|
1800
|
+
renderStep();
|
|
1801
|
+
triggerLivePreview();
|
|
1802
|
+
showToast(t("presetLoaded"));
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
// Export to Disk
|
|
1806
|
+
document.getElementById("btnExport").onclick = async () => {
|
|
1807
|
+
const targetDir = prompt(t("enterDir"), ".");
|
|
1808
|
+
if (targetDir === null) return;
|
|
1809
|
+
try {
|
|
1810
|
+
const resp = await fetch("/api/export", {
|
|
1811
|
+
method: "POST",
|
|
1812
|
+
headers: { "Content-Type": "application/json" },
|
|
1813
|
+
body: JSON.stringify({
|
|
1814
|
+
provider: currentProvider,
|
|
1815
|
+
context: formData,
|
|
1816
|
+
format: currentFormat,
|
|
1817
|
+
output_dir: targetDir
|
|
1818
|
+
})
|
|
1819
|
+
});
|
|
1820
|
+
const res = await resp.json();
|
|
1821
|
+
if (res.success) {
|
|
1822
|
+
showToast(t("saved")(res.saved.join(", ")));
|
|
1823
|
+
} else {
|
|
1824
|
+
alert(t("saveFail")(res.error));
|
|
1825
|
+
}
|
|
1826
|
+
} catch (err) {
|
|
1827
|
+
alert(t("exportErr")(err));
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
// Upload & Reverse Parse
|
|
1832
|
+
const fileInput = document.getElementById("fileInput");
|
|
1833
|
+
document.getElementById("btnUploadConfig").onclick = () => fileInput.click();
|
|
1834
|
+
fileInput.onchange = async () => {
|
|
1835
|
+
const file = fileInput.files[0];
|
|
1836
|
+
if (!file) return;
|
|
1837
|
+
const text = await file.text();
|
|
1838
|
+
try {
|
|
1839
|
+
const resp = await fetch("/api/parse", {
|
|
1840
|
+
method: "POST",
|
|
1841
|
+
headers: { "Content-Type": "application/json" },
|
|
1842
|
+
body: JSON.stringify({ content: text })
|
|
1843
|
+
});
|
|
1844
|
+
const res = await resp.json();
|
|
1845
|
+
if (res.context) {
|
|
1846
|
+
formData = res.context;
|
|
1847
|
+
saveDraft();
|
|
1848
|
+
renderStep();
|
|
1849
|
+
triggerLivePreview();
|
|
1850
|
+
showToast(t("uploadOk")(file.name));
|
|
1851
|
+
} else {
|
|
1852
|
+
alert(t("parseFail")(res.error));
|
|
1853
|
+
}
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
alert(t("uploadErr")(err));
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
// LocalStorage Draft
|
|
1860
|
+
function saveDraft() {
|
|
1861
|
+
try {
|
|
1862
|
+
localStorage.setItem(`devconfig_draft_${currentProvider}`, JSON.stringify(formData));
|
|
1863
|
+
} catch (e) {}
|
|
1864
|
+
}
|
|
1865
|
+
function restoreDraft() {
|
|
1866
|
+
try {
|
|
1867
|
+
const saved = localStorage.getItem(`devconfig_draft_${currentProvider}`);
|
|
1868
|
+
if (saved) formData = JSON.parse(saved);
|
|
1869
|
+
} catch (e) {}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Provider Selector Change
|
|
1873
|
+
document.getElementById("providerSelect").onchange = async (e) => {
|
|
1874
|
+
currentProvider = e.target.value;
|
|
1875
|
+
await loadSchema(currentProvider);
|
|
1876
|
+
formData = {};
|
|
1877
|
+
restoreDraft();
|
|
1878
|
+
if (!formData || Object.keys(formData).length === 0) {
|
|
1879
|
+
formData = starterData(currentProvider);
|
|
1880
|
+
}
|
|
1881
|
+
renderStep();
|
|
1882
|
+
triggerLivePreview();
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
// ── Language Toggle ───────────────────────────────────
|
|
1886
|
+
document.getElementById("btnLang").onclick = () => {
|
|
1887
|
+
currentLang = currentLang === "zh" ? "en" : "zh";
|
|
1888
|
+
localStorage.setItem("dcg_lang", currentLang);
|
|
1889
|
+
applyLang();
|
|
1890
|
+
renderStep();
|
|
1891
|
+
triggerLivePreview();
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
// ── Start App ────────────────────────────────────────
|
|
1895
|
+
window.onload = () => { applyLang(); initApp(); };
|
|
1896
|
+
</script>
|
|
1897
|
+
</body>
|
|
1898
|
+
</html>
|
|
1899
|
+
"""
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
class WebUIRequestHandler(BaseHTTPRequestHandler):
|
|
1903
|
+
"""Serve the single-page studio and a small JSON API over the engine.
|
|
1904
|
+
|
|
1905
|
+
The server is intended for local use only. ``workspace_root`` bounds where
|
|
1906
|
+
``/api/export`` may write, and requests with a non-local ``Host`` header are
|
|
1907
|
+
rejected to mitigate DNS-rebinding attacks against the loopback listener.
|
|
1908
|
+
"""
|
|
1909
|
+
|
|
1910
|
+
registry: ProviderRegistry = default_registry
|
|
1911
|
+
workspace_root: Optional[Path] = None
|
|
1912
|
+
|
|
1913
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
1914
|
+
# Keep terminal log clean and minimal
|
|
1915
|
+
pass
|
|
1916
|
+
|
|
1917
|
+
def _allowed_host(self) -> bool:
|
|
1918
|
+
host = (self.headers.get("Host") or "").strip().lower()
|
|
1919
|
+
if host.startswith("["): # IPv6 literal, e.g. [::1]:8848
|
|
1920
|
+
host = host[1:].split("]", 1)[0]
|
|
1921
|
+
else:
|
|
1922
|
+
host = host.split(":", 1)[0]
|
|
1923
|
+
return host in ("", "127.0.0.1", "localhost", "::1")
|
|
1924
|
+
|
|
1925
|
+
def _workspace(self) -> Path:
|
|
1926
|
+
if self.workspace_root is not None:
|
|
1927
|
+
return Path(self.workspace_root).resolve()
|
|
1928
|
+
return Path.cwd().resolve()
|
|
1929
|
+
|
|
1930
|
+
def _safe_output_dir(self, requested: str) -> Path:
|
|
1931
|
+
root = self._workspace()
|
|
1932
|
+
candidate = Path(requested or ".")
|
|
1933
|
+
if not candidate.is_absolute():
|
|
1934
|
+
candidate = root / candidate
|
|
1935
|
+
target = candidate.resolve()
|
|
1936
|
+
if target != root and root not in target.parents:
|
|
1937
|
+
raise ValueError(f"output directory must stay inside the workspace root: {root}")
|
|
1938
|
+
return target
|
|
1939
|
+
|
|
1940
|
+
def _read_json_body(self) -> dict:
|
|
1941
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
1942
|
+
raw = self.rfile.read(length) if length > 0 else b""
|
|
1943
|
+
if not raw:
|
|
1944
|
+
return {}
|
|
1945
|
+
try:
|
|
1946
|
+
text = raw.decode("utf-8")
|
|
1947
|
+
except UnicodeDecodeError as exc:
|
|
1948
|
+
raise ValueError(f"request body must be UTF-8: {exc}") from exc
|
|
1949
|
+
try:
|
|
1950
|
+
payload = json.loads(text)
|
|
1951
|
+
except json.JSONDecodeError as exc:
|
|
1952
|
+
raise ValueError(f"invalid JSON request body: {exc}") from exc
|
|
1953
|
+
if not isinstance(payload, dict):
|
|
1954
|
+
raise ValueError("request body must be a JSON object")
|
|
1955
|
+
return payload
|
|
1956
|
+
|
|
1957
|
+
def _send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
1958
|
+
encoded = json.dumps(data, indent=2).encode("utf-8")
|
|
1959
|
+
self.send_response(status)
|
|
1960
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1961
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
1962
|
+
self.send_header("Cache-Control", "no-store")
|
|
1963
|
+
self.end_headers()
|
|
1964
|
+
self.wfile.write(encoded)
|
|
1965
|
+
|
|
1966
|
+
def _send_html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
1967
|
+
encoded = html.encode("utf-8")
|
|
1968
|
+
self.send_response(status)
|
|
1969
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1970
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
1971
|
+
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
1972
|
+
self.send_header("Pragma", "no-cache")
|
|
1973
|
+
self.send_header("Expires", "0")
|
|
1974
|
+
self.end_headers()
|
|
1975
|
+
self.wfile.write(encoded)
|
|
1976
|
+
|
|
1977
|
+
def do_GET(self) -> None:
|
|
1978
|
+
if not self._allowed_host():
|
|
1979
|
+
self._send_json({"error": "forbidden host"}, status=HTTPStatus.FORBIDDEN)
|
|
1980
|
+
return
|
|
1981
|
+
|
|
1982
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
1983
|
+
path = parsed.path
|
|
1984
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1985
|
+
|
|
1986
|
+
try:
|
|
1987
|
+
if path in ("/", "/index.html"):
|
|
1988
|
+
self._send_html(_HTML_PAGE)
|
|
1989
|
+
return
|
|
1990
|
+
|
|
1991
|
+
if path == "/api/providers":
|
|
1992
|
+
self._send_json({"providers": list(self.registry.names())})
|
|
1993
|
+
return
|
|
1994
|
+
|
|
1995
|
+
if path == "/api/schema":
|
|
1996
|
+
provider_name = query.get("provider", ["custom"])[0]
|
|
1997
|
+
steps = describe_provider(provider_name, registry=self.registry)
|
|
1998
|
+
self._send_json([s.as_dict() for s in steps])
|
|
1999
|
+
return
|
|
2000
|
+
except ValueError as exc:
|
|
2001
|
+
self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
|
2002
|
+
return
|
|
2003
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
2004
|
+
self._send_json(
|
|
2005
|
+
{"error": f"internal error: {exc}"}, status=HTTPStatus.INTERNAL_SERVER_ERROR
|
|
2006
|
+
)
|
|
2007
|
+
return
|
|
2008
|
+
|
|
2009
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
2010
|
+
|
|
2011
|
+
def do_POST(self) -> None:
|
|
2012
|
+
if not self._allowed_host():
|
|
2013
|
+
self._send_json({"error": "forbidden host"}, status=HTTPStatus.FORBIDDEN)
|
|
2014
|
+
return
|
|
2015
|
+
|
|
2016
|
+
path = urllib.parse.urlparse(self.path).path
|
|
2017
|
+
try:
|
|
2018
|
+
payload = self._read_json_body()
|
|
2019
|
+
except ValueError as exc:
|
|
2020
|
+
self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
|
2021
|
+
return
|
|
2022
|
+
|
|
2023
|
+
try:
|
|
2024
|
+
if path == "/api/validate":
|
|
2025
|
+
provider_name = payload.get("provider", "custom")
|
|
2026
|
+
context = payload.get("context", {})
|
|
2027
|
+
diagnostics = diagnose_request(
|
|
2028
|
+
provider_name, context=context, registry=self.registry
|
|
2029
|
+
)
|
|
2030
|
+
self._send_json(
|
|
2031
|
+
{
|
|
2032
|
+
"valid": not any(d.severity == "error" for d in diagnostics),
|
|
2033
|
+
"diagnostics": [d.as_dict() for d in diagnostics],
|
|
2034
|
+
}
|
|
2035
|
+
)
|
|
2036
|
+
return
|
|
2037
|
+
|
|
2038
|
+
if path == "/api/generate":
|
|
2039
|
+
provider_name = payload.get("provider", "custom")
|
|
2040
|
+
context = payload.get("context", {})
|
|
2041
|
+
fmt = payload.get("format", "yaml")
|
|
2042
|
+
result = generate(
|
|
2043
|
+
provider_name,
|
|
2044
|
+
GenerationRequest(context=context, options={"format": fmt}),
|
|
2045
|
+
registry=self.registry,
|
|
2046
|
+
)
|
|
2047
|
+
artifacts_data = []
|
|
2048
|
+
for artifact in result.artifacts:
|
|
2049
|
+
content_str = (
|
|
2050
|
+
artifact.content
|
|
2051
|
+
if isinstance(artifact.content, str)
|
|
2052
|
+
else formats.dumps(
|
|
2053
|
+
artifact.content,
|
|
2054
|
+
formats.format_from_media_type(artifact.media_type),
|
|
2055
|
+
)
|
|
2056
|
+
)
|
|
2057
|
+
artifacts_data.append(
|
|
2058
|
+
{
|
|
2059
|
+
"name": artifact.name,
|
|
2060
|
+
"content": content_str,
|
|
2061
|
+
"media_type": artifact.media_type,
|
|
2062
|
+
}
|
|
2063
|
+
)
|
|
2064
|
+
self._send_json({"artifacts": artifacts_data})
|
|
2065
|
+
return
|
|
2066
|
+
|
|
2067
|
+
if path == "/api/export":
|
|
2068
|
+
provider_name = payload.get("provider", "custom")
|
|
2069
|
+
context = payload.get("context", {})
|
|
2070
|
+
fmt = payload.get("format", "yaml")
|
|
2071
|
+
output_dir = self._safe_output_dir(str(payload.get("output_dir", ".")))
|
|
2072
|
+
result = generate(
|
|
2073
|
+
provider_name,
|
|
2074
|
+
GenerationRequest(context=context, options={"format": fmt}),
|
|
2075
|
+
registry=self.registry,
|
|
2076
|
+
output_dir=str(output_dir),
|
|
2077
|
+
)
|
|
2078
|
+
saved = [str(output_dir / artifact.name) for artifact in result.artifacts]
|
|
2079
|
+
self._send_json({"success": True, "saved": saved})
|
|
2080
|
+
return
|
|
2081
|
+
|
|
2082
|
+
if path == "/api/parse":
|
|
2083
|
+
content = payload.get("content", "")
|
|
2084
|
+
if not isinstance(content, str):
|
|
2085
|
+
raise ValueError("content must be a string")
|
|
2086
|
+
parsed_ctx = formats.loads(content)
|
|
2087
|
+
self._send_json({"context": parsed_ctx})
|
|
2088
|
+
return
|
|
2089
|
+
except ValueError as exc:
|
|
2090
|
+
self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
|
2091
|
+
return
|
|
2092
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
2093
|
+
self._send_json(
|
|
2094
|
+
{"error": f"internal error: {exc}"}, status=HTTPStatus.INTERNAL_SERVER_ERROR
|
|
2095
|
+
)
|
|
2096
|
+
return
|
|
2097
|
+
|
|
2098
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
2099
|
+
|
|
2100
|
+
def run_web_ui(
|
|
2101
|
+
host: str = "127.0.0.1",
|
|
2102
|
+
port: int = 8848,
|
|
2103
|
+
open_browser: bool = True,
|
|
2104
|
+
registry: Optional[ProviderRegistry] = None,
|
|
2105
|
+
workspace_root: Optional[str] = None,
|
|
2106
|
+
) -> None:
|
|
2107
|
+
"""Start the local WebUI HTTP server and open a browser.
|
|
2108
|
+
|
|
2109
|
+
``workspace_root`` bounds the directories ``/api/export`` may write to; it
|
|
2110
|
+
defaults to the current working directory.
|
|
2111
|
+
"""
|
|
2112
|
+
|
|
2113
|
+
reg = registry or default_registry
|
|
2114
|
+
root = Path(workspace_root).resolve() if workspace_root else Path.cwd().resolve()
|
|
2115
|
+
|
|
2116
|
+
class CustomHandler(WebUIRequestHandler):
|
|
2117
|
+
pass
|
|
2118
|
+
|
|
2119
|
+
CustomHandler.registry = reg
|
|
2120
|
+
CustomHandler.workspace_root = root
|
|
2121
|
+
|
|
2122
|
+
server = ThreadingHTTPServer((host, port), CustomHandler)
|
|
2123
|
+
url = f"http://{host}:{port}"
|
|
2124
|
+
|
|
2125
|
+
print("=================================================================")
|
|
2126
|
+
print(" DevConfig-Gen Studio (本地配置工作台) ")
|
|
2127
|
+
print(f" 本地运行地址: {url}")
|
|
2128
|
+
print(f" 导出工作空间: {root}")
|
|
2129
|
+
print(" 按 Ctrl+C 停止服务")
|
|
2130
|
+
print("=================================================================")
|
|
2131
|
+
|
|
2132
|
+
if open_browser:
|
|
2133
|
+
try:
|
|
2134
|
+
webbrowser.open(url)
|
|
2135
|
+
except Exception:
|
|
2136
|
+
pass
|
|
2137
|
+
|
|
2138
|
+
try:
|
|
2139
|
+
server.serve_forever()
|
|
2140
|
+
except KeyboardInterrupt:
|
|
2141
|
+
print("\nStudio server stopped. / 服务已停止。")
|
|
2142
|
+
finally:
|
|
2143
|
+
server.server_close()
|