web-access-cli 0.5.0 → 0.7.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.
Files changed (50) hide show
  1. package/README.md +104 -207
  2. package/README_CN.md +106 -209
  3. package/THIRD_PARTY_NOTICES.md +0 -13
  4. package/dist/config/config.js +10 -2
  5. package/dist/config/config.js.map +1 -1
  6. package/dist/core/router.js +4 -2
  7. package/dist/core/router.js.map +1 -1
  8. package/dist/core/schema.js +1 -1
  9. package/dist/core/types.js +1 -0
  10. package/dist/core/types.js.map +1 -1
  11. package/dist/gateway/admin.js +670 -0
  12. package/dist/gateway/admin.js.map +1 -0
  13. package/dist/gateway/panel.js +2405 -0
  14. package/dist/gateway/panel.js.map +1 -0
  15. package/dist/gateway/records.js +464 -0
  16. package/dist/gateway/records.js.map +1 -0
  17. package/dist/gateway/schema.js +115 -0
  18. package/dist/gateway/schema.js.map +1 -0
  19. package/dist/gateway/server.js +663 -0
  20. package/dist/gateway/server.js.map +1 -0
  21. package/dist/gateway/service.js +607 -0
  22. package/dist/gateway/service.js.map +1 -0
  23. package/dist/providers/anysearch.js +5 -3
  24. package/dist/providers/anysearch.js.map +1 -1
  25. package/dist/providers/deepseek.js +173 -106
  26. package/dist/providers/deepseek.js.map +1 -1
  27. package/dist/providers/registry.js +2 -0
  28. package/dist/providers/registry.js.map +1 -1
  29. package/dist/providers/search.js +4 -2
  30. package/dist/providers/search.js.map +1 -1
  31. package/dist/providers/xai.js +16 -8
  32. package/dist/providers/xai.js.map +1 -1
  33. package/dist/providers/xcrawl.js +1 -1
  34. package/dist/providers/xcrawl.js.map +1 -1
  35. package/dist/providers/yuanbao.js +214 -0
  36. package/dist/providers/yuanbao.js.map +1 -0
  37. package/dist/server.js +127 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/transport/http.js +8 -0
  40. package/dist/transport/http.js.map +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +4 -2
  43. package/schemas/config.schema.json +4 -0
  44. package/schemas/gatewayError.schema.json +33 -0
  45. package/schemas/gatewayFetchRequest.schema.json +13 -0
  46. package/schemas/gatewayFetchResponse.schema.json +16 -0
  47. package/schemas/gatewaySearchRequest.schema.json +37 -0
  48. package/schemas/gatewaySearchResponse.schema.json +81 -0
  49. package/schemas/output.schema.json +52 -0
  50. package/schemas/searchRequest.schema.json +1 -1
@@ -0,0 +1,2405 @@
1
+ export const PANEL_PATH = "/panel";
2
+ export const RECORDS_PATH = "/admin/api/records";
3
+ export const OVERVIEW_PATH = "/admin/api/overview";
4
+ export const STATS_RESET_PATH = "/admin/api/stats/reset";
5
+ export const CONFIG_PATH = "/admin/api/config";
6
+ export const CONFIG_RELOAD_PATH = "/admin/api/config/reload";
7
+ /**
8
+ * Self-contained gateway administration console served from memory.
9
+ * Zero external assets, protected by per-response CSP nonce and Bearer auth.
10
+ */
11
+ const PANEL_TEMPLATE = `<!doctype html>
12
+ <html lang="zh-CN">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <meta name="robots" content="noindex, nofollow">
17
+ <meta name="color-scheme" content="light dark">
18
+ <title>Web Access 网关 · 管理后台</title>
19
+ <style nonce="__NONCE__">
20
+ :root {
21
+ color-scheme: light dark;
22
+ --bg: #f4f2ec;
23
+ --surface: #fbfaf6;
24
+ --surface-strong: #eeece4;
25
+ --border: #d8d4c8;
26
+ --text: #201f1b;
27
+ --muted: #6d675a;
28
+ --accent: #0f766e;
29
+ --accent-soft: #d9ece7;
30
+ --ok: #166534;
31
+ --ok-soft: #dcfce7;
32
+ --warn: #92400e;
33
+ --warn-soft: #fef3c7;
34
+ --err: #991b1b;
35
+ --err-soft: #fee2e2;
36
+ --sans: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
37
+ --mono: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
38
+ }
39
+ @media (prefers-color-scheme: dark) {
40
+ :root {
41
+ --bg: #131311;
42
+ --surface: #1b1b18;
43
+ --surface-strong: #252521;
44
+ --border: #3a3a33;
45
+ --text: #ece9df;
46
+ --muted: #a7a093;
47
+ --accent: #5eead4;
48
+ --accent-soft: rgba(94, 234, 212, 0.13);
49
+ --ok: #86efac;
50
+ --ok-soft: rgba(134, 239, 172, 0.12);
51
+ --warn: #fcd34d;
52
+ --warn-soft: rgba(252, 211, 77, 0.12);
53
+ --err: #fca5a5;
54
+ --err-soft: rgba(252, 165, 165, 0.12);
55
+ }
56
+ }
57
+ * { box-sizing: border-box; }
58
+ html { background: var(--bg); }
59
+ body {
60
+ margin: 0;
61
+ background: var(--bg);
62
+ color: var(--text);
63
+ font: 14px/1.55 var(--sans);
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ button, input, select, textarea { font: inherit; color: inherit; }
67
+ a { color: var(--accent); text-underline-offset: 2px; }
68
+ h1, h2, h3, p { margin-top: 0; }
69
+ [hidden] { display: none !important; }
70
+ .topbar {
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ gap: 12px 20px;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ padding: 14px clamp(16px, 3.5vw, 36px);
77
+ border-bottom: 1px solid var(--border);
78
+ background: var(--surface);
79
+ }
80
+ .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
81
+ .brand-mark {
82
+ display: grid;
83
+ place-items: center;
84
+ width: 36px;
85
+ height: 36px;
86
+ border: 1px solid var(--border);
87
+ border-radius: 7px;
88
+ background: var(--surface-strong);
89
+ color: var(--accent);
90
+ font: 600 12px var(--mono);
91
+ letter-spacing: 0.05em;
92
+ }
93
+ h1 { margin: 0; font-size: 16px; letter-spacing: 0.01em; }
94
+ .brand p { margin: 1px 0 0; color: var(--muted); font-size: 12px; }
95
+ .nav-tabs {
96
+ display: flex;
97
+ gap: 4px;
98
+ padding: 3px;
99
+ border: 1px solid var(--border);
100
+ border-radius: 8px;
101
+ background: var(--bg);
102
+ }
103
+ .nav-tab {
104
+ min-height: 34px;
105
+ padding: 0 14px;
106
+ border: 0;
107
+ border-radius: 6px;
108
+ background: transparent;
109
+ color: var(--muted);
110
+ font-size: 13px;
111
+ font-weight: 500;
112
+ cursor: pointer;
113
+ white-space: nowrap;
114
+ }
115
+ .nav-tab:hover { color: var(--text); }
116
+ .nav-tab[aria-selected="true"] {
117
+ background: var(--surface);
118
+ color: var(--accent);
119
+ font-weight: 600;
120
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
121
+ }
122
+ .nav-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
123
+ .topbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
124
+ .status-pill {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 7px;
128
+ min-height: 30px;
129
+ padding: 0 10px;
130
+ border: 1px solid var(--border);
131
+ border-radius: 999px;
132
+ background: var(--surface-strong);
133
+ color: var(--muted);
134
+ font-size: 12px;
135
+ white-space: nowrap;
136
+ }
137
+ .status-pill .dot {
138
+ width: 7px;
139
+ height: 7px;
140
+ border-radius: 50%;
141
+ background: var(--muted);
142
+ }
143
+ .status-pill[data-state="ready"] .dot { background: var(--ok); }
144
+ .status-pill[data-state="not-ready"] .dot { background: var(--warn); }
145
+ .status-pill[data-state="offline"] .dot { background: var(--err); }
146
+ main {
147
+ max-width: 1240px;
148
+ margin: 0 auto;
149
+ padding: 22px clamp(16px, 3.5vw, 36px) 64px;
150
+ }
151
+ .btn {
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ gap: 6px;
156
+ min-height: 38px;
157
+ padding: 0 13px;
158
+ border: 1px solid var(--border);
159
+ border-radius: 6px;
160
+ background: var(--surface);
161
+ font-size: 13px;
162
+ cursor: pointer;
163
+ white-space: nowrap;
164
+ }
165
+ .btn.sm { min-height: 30px; padding: 0 9px; font-size: 12px; }
166
+ .btn:hover:not(:disabled) { background: var(--surface-strong); }
167
+ .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
168
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
169
+ .btn.primary { border-color: var(--accent); background: var(--accent); color: var(--bg); font-weight: 600; }
170
+ .btn.primary:hover:not(:disabled) { opacity: 0.92; background: var(--accent); }
171
+ .btn.danger { border-color: var(--err); color: var(--err); }
172
+ .btn.danger:hover:not(:disabled) { background: var(--err-soft); }
173
+ .btn.ghost { background: transparent; }
174
+ .login {
175
+ max-width: 540px;
176
+ margin: 7vh auto 0;
177
+ padding: clamp(20px, 4vw, 32px);
178
+ border: 1px solid var(--border);
179
+ border-radius: 9px;
180
+ background: var(--surface);
181
+ }
182
+ .login h2 { margin-bottom: 8px; font-size: 19px; }
183
+ .login > p { color: var(--muted); margin-bottom: 20px; }
184
+ .login label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; }
185
+ .key-row { display: flex; gap: 8px; }
186
+ .key-row input, .input, .select {
187
+ min-width: 0;
188
+ min-height: 38px;
189
+ padding: 0 11px;
190
+ border: 1px solid var(--border);
191
+ border-radius: 6px;
192
+ background: var(--surface);
193
+ }
194
+ .key-row input { flex: 1; background: var(--bg); }
195
+ .key-row input:focus-visible, .input:focus-visible, .select:focus-visible {
196
+ outline: 2px solid var(--accent);
197
+ outline-offset: 1px;
198
+ }
199
+ .hint { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
200
+ .form-error { margin: 12px 0 0; color: var(--err); font-size: 13px; }
201
+ .stats {
202
+ display: grid;
203
+ grid-template-columns: repeat(4, minmax(0, 1fr));
204
+ overflow: hidden;
205
+ border: 1px solid var(--border);
206
+ border-radius: 8px;
207
+ background: var(--surface);
208
+ }
209
+ .stat { padding: 13px 16px; border-left: 1px solid var(--border); min-width: 0; }
210
+ .stat:first-child { border-left: 0; }
211
+ .stat-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.04em; }
212
+ .stat strong { display: block; margin-top: 3px; font: 600 20px/1.25 var(--mono); }
213
+ .stat-sub { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; font-family: var(--mono); }
214
+ .toolbar {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ align-items: center;
218
+ gap: 8px 10px;
219
+ margin: 16px 0 12px;
220
+ }
221
+ .segmented {
222
+ display: inline-flex;
223
+ overflow: hidden;
224
+ border: 1px solid var(--border);
225
+ border-radius: 6px;
226
+ background: var(--surface);
227
+ }
228
+ .segmented button {
229
+ min-height: 36px;
230
+ padding: 0 12px;
231
+ border: 0;
232
+ border-left: 1px solid var(--border);
233
+ background: transparent;
234
+ color: var(--muted);
235
+ font-size: 12px;
236
+ cursor: pointer;
237
+ }
238
+ .segmented button:first-child { border-left: 0; }
239
+ .segmented button[aria-pressed="true"] {
240
+ background: var(--accent-soft);
241
+ color: var(--accent);
242
+ font-weight: 600;
243
+ }
244
+ .segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
245
+ #filter-text {
246
+ flex: 1 1 200px;
247
+ min-width: 150px;
248
+ }
249
+ .switch {
250
+ display: inline-flex;
251
+ align-items: center;
252
+ gap: 7px;
253
+ min-height: 38px;
254
+ padding: 0 4px;
255
+ color: var(--muted);
256
+ font-size: 12px;
257
+ cursor: pointer;
258
+ white-space: nowrap;
259
+ }
260
+ .switch input { accent-color: var(--accent); width: 15px; height: 15px; }
261
+ .list-head {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ flex-wrap: wrap;
266
+ gap: 8px 12px;
267
+ margin: 4px 0 8px;
268
+ color: var(--muted);
269
+ font-size: 12px;
270
+ }
271
+ .banner {
272
+ margin: 0 0 12px;
273
+ padding: 9px 12px;
274
+ border: 1px solid var(--err);
275
+ border-left-width: 3px;
276
+ border-radius: 6px;
277
+ background: var(--err-soft);
278
+ color: var(--err);
279
+ font-size: 13px;
280
+ }
281
+ .banner.ok { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
282
+ .banner.warn { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
283
+ .records {
284
+ margin: 0;
285
+ padding: 0;
286
+ overflow: hidden;
287
+ border: 1px solid var(--border);
288
+ border-radius: 8px;
289
+ background: var(--surface);
290
+ list-style: none;
291
+ }
292
+ .record + .record { border-top: 1px solid var(--border); }
293
+ .record-head {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ align-items: center;
297
+ gap: 5px 10px;
298
+ width: 100%;
299
+ min-height: 48px;
300
+ padding: 9px 14px;
301
+ border: 0;
302
+ background: transparent;
303
+ text-align: left;
304
+ cursor: pointer;
305
+ }
306
+ .record-head:hover { background: var(--surface-strong); }
307
+ .record-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
308
+ .record-time { flex: 0 0 auto; color: var(--muted); font: 12px var(--mono); white-space: nowrap; }
309
+ .badge {
310
+ display: inline-flex;
311
+ flex: 0 0 auto;
312
+ align-items: center;
313
+ justify-content: center;
314
+ padding: 1px 6px;
315
+ border: 1px solid transparent;
316
+ border-radius: 4px;
317
+ font: 600 11px/1.65 var(--mono);
318
+ white-space: nowrap;
319
+ }
320
+ .route-search { background: var(--accent-soft); color: var(--accent); }
321
+ .route-fetch { border-color: var(--border); background: var(--surface-strong); color: var(--muted); }
322
+ .status-ok { background: var(--ok-soft); color: var(--ok); }
323
+ .status-warn { background: var(--warn-soft); color: var(--warn); }
324
+ .status-error { background: var(--err-soft); color: var(--err); }
325
+ .badge-muted { border-color: var(--border); background: var(--bg); color: var(--muted); font-weight: 500; }
326
+ .record-summary {
327
+ flex: 1 1 240px;
328
+ min-width: 0;
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+ .record-provider {
334
+ flex: 0 1 auto;
335
+ max-width: 130px;
336
+ overflow: hidden;
337
+ color: var(--muted);
338
+ font: 12px var(--mono);
339
+ text-overflow: ellipsis;
340
+ white-space: nowrap;
341
+ }
342
+ .record-duration, .record-results {
343
+ flex: 0 0 auto;
344
+ color: var(--muted);
345
+ font: 12px var(--mono);
346
+ text-align: right;
347
+ white-space: nowrap;
348
+ }
349
+ .record-duration { min-width: 62px; }
350
+ .record-results { min-width: 48px; }
351
+ .chevron { flex: 0 0 auto; margin-left: auto; color: var(--muted); transition: transform 0.15s ease; }
352
+ .record.expanded .chevron { transform: rotate(90deg); }
353
+ .record-detail {
354
+ padding: 14px 16px 18px;
355
+ border-top: 1px dashed var(--border);
356
+ background: var(--bg);
357
+ }
358
+ .record-meta {
359
+ display: grid;
360
+ grid-template-columns: max-content minmax(0, 1fr);
361
+ gap: 5px 16px;
362
+ margin: 0;
363
+ }
364
+ .record-meta dt { color: var(--muted); font-size: 12px; }
365
+ .record-meta dd { margin: 0; overflow-wrap: anywhere; font: 12px/1.5 var(--mono); }
366
+ .detail-label {
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: space-between;
370
+ gap: 10px;
371
+ margin: 16px 0 8px;
372
+ color: var(--muted);
373
+ font-size: 11px;
374
+ font-weight: 600;
375
+ letter-spacing: 0.04em;
376
+ }
377
+ .attempts-list {
378
+ display: grid;
379
+ gap: 6px;
380
+ margin: 0;
381
+ padding: 0;
382
+ list-style: none;
383
+ }
384
+ .attempt-row {
385
+ display: flex;
386
+ flex-wrap: wrap;
387
+ align-items: center;
388
+ gap: 6px 10px;
389
+ padding: 6px 10px;
390
+ border: 1px solid var(--border);
391
+ border-radius: 6px;
392
+ background: var(--surface);
393
+ font-size: 12px;
394
+ }
395
+ .attempt-provider { font-family: var(--mono); font-weight: 600; }
396
+ .attempt-duration { color: var(--muted); font-family: var(--mono); }
397
+ .attempt-err { color: var(--err); font-family: var(--mono); overflow-wrap: anywhere; }
398
+ .content-preview-box {
399
+ margin-top: 6px;
400
+ border: 1px solid var(--border);
401
+ border-radius: 6px;
402
+ background: var(--surface);
403
+ overflow: hidden;
404
+ }
405
+ .content-preview-pre {
406
+ margin: 0;
407
+ padding: 10px 12px;
408
+ max-height: 280px;
409
+ overflow: auto;
410
+ font: 12px/1.55 var(--mono);
411
+ white-space: pre-wrap;
412
+ overflow-wrap: anywhere;
413
+ }
414
+ .result-list { display: grid; gap: 10px; margin: 0; padding-left: 20px; }
415
+ .result-item { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); }
416
+ .result-title { display: block; font-weight: 600; overflow-wrap: anywhere; }
417
+ .result-url { display: block; margin-top: 2px; color: var(--muted); font: 11px/1.45 var(--mono); overflow-wrap: anywhere; }
418
+ .result-snippet { margin: 5px 0 0; color: var(--text); font-size: 12.5px; overflow-wrap: anywhere; }
419
+ .result-preview-details { margin-top: 6px; font-size: 12px; }
420
+ .result-preview-details summary { cursor: pointer; color: var(--accent); font-family: var(--mono); }
421
+ .empty {
422
+ margin-top: 0;
423
+ padding: 42px 20px;
424
+ border: 1px dashed var(--border);
425
+ border-radius: 8px;
426
+ background: var(--surface);
427
+ color: var(--muted);
428
+ text-align: center;
429
+ }
430
+ .empty strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 14px; }
431
+ .footnote { margin: 14px 0 0; color: var(--muted); font-size: 12px; }
432
+ .grid-2 {
433
+ display: grid;
434
+ grid-template-columns: repeat(2, minmax(0, 1fr));
435
+ gap: 16px;
436
+ margin-top: 16px;
437
+ }
438
+ .card {
439
+ padding: 16px;
440
+ border: 1px solid var(--border);
441
+ border-radius: 8px;
442
+ background: var(--surface);
443
+ min-width: 0;
444
+ }
445
+ .card-head {
446
+ display: flex;
447
+ align-items: center;
448
+ justify-content: space-between;
449
+ gap: 10px;
450
+ margin-bottom: 12px;
451
+ }
452
+ .card-head h3 { margin: 0; font-size: 15px; }
453
+ .route-chain {
454
+ display: flex;
455
+ flex-wrap: wrap;
456
+ gap: 6px;
457
+ align-items: center;
458
+ margin: 8px 0;
459
+ }
460
+ .route-node {
461
+ display: inline-flex;
462
+ align-items: center;
463
+ gap: 6px;
464
+ padding: 4px 9px;
465
+ border: 1px solid var(--border);
466
+ border-radius: 6px;
467
+ background: var(--bg);
468
+ font: 12px var(--mono);
469
+ }
470
+ .route-node.ready { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
471
+ .route-node.unready { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
472
+ .table-wrap {
473
+ margin-top: 16px;
474
+ overflow-x: auto;
475
+ border: 1px solid var(--border);
476
+ border-radius: 8px;
477
+ background: var(--surface);
478
+ }
479
+ .data-table {
480
+ width: 100%;
481
+ border-collapse: collapse;
482
+ font-size: 13px;
483
+ text-align: left;
484
+ }
485
+ .data-table th, .data-table td {
486
+ padding: 10px 12px;
487
+ border-bottom: 1px solid var(--border);
488
+ vertical-align: middle;
489
+ }
490
+ .data-table th {
491
+ background: var(--surface-strong);
492
+ color: var(--muted);
493
+ font-size: 11px;
494
+ font-weight: 600;
495
+ letter-spacing: 0.04em;
496
+ white-space: nowrap;
497
+ }
498
+ .data-table tr:last-child td { border-bottom: 0; }
499
+ .mono { font-family: var(--mono); }
500
+ .field-grid {
501
+ display: grid;
502
+ grid-template-columns: repeat(2, minmax(0, 1fr));
503
+ gap: 10px 12px;
504
+ margin-bottom: 14px;
505
+ }
506
+ .field label {
507
+ display: block;
508
+ margin-bottom: 4px;
509
+ color: var(--muted);
510
+ font-size: 11.5px;
511
+ font-weight: 600;
512
+ }
513
+ .field input, .field select { width: 100%; }
514
+ .bucket-title {
515
+ margin: 12px 0 6px;
516
+ color: var(--muted);
517
+ font-size: 11.5px;
518
+ font-weight: 600;
519
+ }
520
+ .route-editor-list {
521
+ display: grid;
522
+ gap: 6px;
523
+ margin: 0;
524
+ padding: 0;
525
+ list-style: none;
526
+ }
527
+ .route-editor-item {
528
+ display: flex;
529
+ align-items: center;
530
+ justify-content: space-between;
531
+ gap: 8px;
532
+ padding: 6px 10px;
533
+ border: 1px solid var(--border);
534
+ border-radius: 6px;
535
+ background: var(--bg);
536
+ font-size: 12.5px;
537
+ }
538
+ .route-editor-actions { display: flex; gap: 4px; flex-wrap: wrap; }
539
+ .editor-drawer {
540
+ margin: 14px 0;
541
+ padding: 16px;
542
+ border: 2px solid var(--accent);
543
+ border-radius: 8px;
544
+ background: var(--surface);
545
+ }
546
+ @media (max-width: 860px) {
547
+ .grid-2 { grid-template-columns: 1fr; }
548
+ }
549
+ @media (max-width: 680px) {
550
+ .stats { grid-template-columns: 1fr 1fr; }
551
+ .stat { border-top: 1px solid var(--border); }
552
+ .stat:nth-child(-n+2) { border-top: 0; }
553
+ .stat:nth-child(odd) { border-left: 0; }
554
+ .stat:nth-child(even) { border-left: 1px solid var(--border); }
555
+ .field-grid { grid-template-columns: 1fr; }
556
+ .record-head { row-gap: 3px; }
557
+ .record-summary { order: 1; flex-basis: 100%; white-space: normal; }
558
+ .record-provider { order: 2; max-width: none; }
559
+ .record-duration { order: 3; min-width: 0; text-align: left; }
560
+ .record-results { order: 4; min-width: 0; text-align: left; }
561
+ .btn, .segmented button, #filter-text, .key-row input { min-height: 44px; }
562
+ .segmented button { padding: 0 13px; }
563
+ .key-row { flex-wrap: wrap; }
564
+ .key-row .btn { width: 100%; }
565
+ .login { margin-top: 3vh; }
566
+ }
567
+ @media (prefers-reduced-motion: reduce) {
568
+ * { transition: none !important; animation: none !important; }
569
+ }
570
+ </style>
571
+ </head>
572
+ <body>
573
+ <header class="topbar">
574
+ <div class="brand">
575
+ <span class="brand-mark" aria-hidden="true">WA</span>
576
+ <div>
577
+ <h1>Web Access 网关</h1>
578
+ <p>管理后台 · 最近 __RETENTION__ 条记录 · 版本 __VERSION__</p>
579
+ </div>
580
+ </div>
581
+ <div class="nav-tabs" id="nav-tabs" role="tablist" aria-label="后台功能模块" hidden>
582
+ <button type="button" class="nav-tab" role="tab" data-tab="records" aria-selected="true">请求记录</button>
583
+ <button type="button" class="nav-tab" role="tab" data-tab="overview" aria-selected="false">路由与统计</button>
584
+ <button type="button" class="nav-tab" role="tab" data-tab="config" aria-selected="false">配置管理</button>
585
+ </div>
586
+ <div class="topbar-actions">
587
+ <span class="status-pill" id="persist-pill" data-state="not-ready" hidden>
588
+ <span class="dot" aria-hidden="true"></span>
589
+ <span id="persist-text">仅内存生效</span>
590
+ </span>
591
+ <span class="status-pill" id="ready-pill" data-state="checking" role="status">
592
+ <span class="dot" aria-hidden="true"></span>
593
+ <span id="ready-text">检查中</span>
594
+ </span>
595
+ <button type="button" class="btn ghost" id="logout" hidden>退出</button>
596
+ </div>
597
+ </header>
598
+ <main>
599
+ <section class="login" id="login-view" hidden aria-labelledby="login-title">
600
+ <h2 id="login-title">登录网关管理后台</h2>
601
+ <p>查看请求追踪链路、Provider 诊断统计与在线编排配置。请输入网关 API Key 继续。</p>
602
+ <form id="login-form">
603
+ <label for="key-input">网关 API Key</label>
604
+ <div class="key-row">
605
+ <input id="key-input" name="key" type="password" autocomplete="off" spellcheck="false" required minlength="__MIN_KEY_LENGTH__" placeholder="至少 __MIN_KEY_LENGTH__ 个字符">
606
+ <button type="submit" class="btn primary" id="login-submit">进入后台</button>
607
+ </div>
608
+ <p class="hint">Key 仅保存在当前标签页的 sessionStorage;也支持用 <code>#apikey=…</code> 书签打开,脚本会立即从地址栏剥离。</p>
609
+ <p class="form-error" id="login-error" role="alert" hidden></p>
610
+ </form>
611
+ </section>
612
+
613
+ <div id="panel-view" hidden>
614
+ <p class="banner" id="banner" role="alert" hidden></p>
615
+
616
+ <!-- Tab 1: 请求记录 -->
617
+ <section id="tab-records" role="tabpanel" aria-labelledby="panel-title">
618
+ <h2 id="panel-title" hidden>请求记录</h2>
619
+ <div class="stats" aria-label="概览">
620
+ <div class="stat"><span class="stat-label">已加载请求</span><strong id="stat-total">0</strong></div>
621
+ <div class="stat"><span class="stat-label">成功</span><strong id="stat-ok">0</strong></div>
622
+ <div class="stat"><span class="stat-label">失败</span><strong id="stat-bad">0</strong></div>
623
+ <div class="stat"><span class="stat-label">平均耗时</span><strong id="stat-avg">—</strong></div>
624
+ </div>
625
+ <div class="toolbar">
626
+ <div class="segmented" role="group" aria-label="按请求类型筛选">
627
+ <button type="button" data-route="all" aria-pressed="true">全部</button>
628
+ <button type="button" data-route="search" aria-pressed="false">搜索</button>
629
+ <button type="button" data-route="fetch" aria-pressed="false">抓取</button>
630
+ </div>
631
+ <div class="segmented" role="group" aria-label="按结果状态筛选">
632
+ <button type="button" data-status="all" aria-pressed="true">全部状态</button>
633
+ <button type="button" data-status="ok" aria-pressed="false">成功</button>
634
+ <button type="button" data-status="error" aria-pressed="false">失败</button>
635
+ </div>
636
+ <select id="filter-provider" class="select" aria-label="按 Provider 筛选">
637
+ <option value="">全部 Provider</option>
638
+ </select>
639
+ <input id="filter-text" class="input" type="search" placeholder="筛选 query、URL、provider、错误码、摘要" aria-label="文本筛选">
640
+ <label class="switch"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
641
+ <button type="button" class="btn" id="refresh">刷新</button>
642
+ <button type="button" class="btn" id="export-records">导出 JSON</button>
643
+ <button type="button" class="btn danger" id="clear-records">清空记录</button>
644
+ </div>
645
+ <div class="list-head">
646
+ <span id="record-count">已加载 0 条</span>
647
+ <span>/v1/tools/search · /v1/tools/fetch</span>
648
+ </div>
649
+ <div class="empty" id="empty" hidden>
650
+ <strong id="empty-title">还没有请求记录</strong>
651
+ <span id="empty-text">调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示完整链路、耗时与结果预览。</span>
652
+ </div>
653
+ <ul class="records" id="records" aria-label="请求记录"></ul>
654
+ <p class="footnote">记录保存在网关进程内存中(上限 __RETENTION__ 条),含尝试链路、搜索摘要与截断正文预览;重启网关或点击清空后释放。</p>
655
+ </section>
656
+
657
+ <!-- Tab 2: 路由与统计看板 -->
658
+ <section id="tab-overview" role="tabpanel" hidden>
659
+ <div class="stats" aria-label="运行时累计指标">
660
+ <div class="stat">
661
+ <span class="stat-label">网关累计请求</span>
662
+ <strong id="ov-total-req">0</strong>
663
+ <span class="stat-sub" id="ov-uptime">运行时间 —</span>
664
+ </div>
665
+ <div class="stat">
666
+ <span class="stat-label">Search 成功 / 总数</span>
667
+ <strong id="ov-search-stat">0 / 0</strong>
668
+ <span class="stat-sub" id="ov-doctor-search">路由检查 —</span>
669
+ </div>
670
+ <div class="stat">
671
+ <span class="stat-label">Fetch 成功 / 总数</span>
672
+ <strong id="ov-fetch-stat">0 / 0</strong>
673
+ <span class="stat-sub" id="ov-doctor-extract">路由检查 —</span>
674
+ </div>
675
+ <div class="stat">
676
+ <span class="stat-label">正文补抓成功 / 尝试</span>
677
+ <strong id="ov-backfill-stat">0 / 0</strong>
678
+ <span class="stat-sub" id="ov-config-mode">配置文件 —</span>
679
+ </div>
680
+ </div>
681
+
682
+ <div class="toolbar">
683
+ <div class="segmented" role="group" aria-label="筛选 Provider 列表">
684
+ <button type="button" data-pfilter="all" aria-pressed="true">全部实例</button>
685
+ <button type="button" data-pfilter="routed" aria-pressed="false">仅路由启用</button>
686
+ <button type="button" data-pfilter="ready" aria-pressed="false">仅已就绪</button>
687
+ </div>
688
+ <div style="margin-left:auto;display:flex;gap:8px;flex-wrap:wrap">
689
+ <button type="button" class="btn" id="refresh-overview">刷新状态</button>
690
+ <button type="button" class="btn" id="reset-stats">重置累计统计</button>
691
+ <button type="button" class="btn primary" id="jump-config">编排路由与配置</button>
692
+ </div>
693
+ </div>
694
+
695
+ <div class="grid-2">
696
+ <div class="card">
697
+ <div class="card-head">
698
+ <h3>Search 路由链</h3>
699
+ <span class="badge badge-muted" id="ov-search-params">—</span>
700
+ </div>
701
+ <div class="bucket-title">主路由 (Primary)</div>
702
+ <div class="route-chain" id="ov-search-primary"></div>
703
+ <div class="bucket-title">兜底路由 (Fallback)</div>
704
+ <div class="route-chain" id="ov-search-fallback"></div>
705
+ </div>
706
+ <div class="card">
707
+ <div class="card-head">
708
+ <h3>Extract 路由链</h3>
709
+ <span class="badge badge-muted" id="ov-extract-params">—</span>
710
+ </div>
711
+ <div class="bucket-title">主路由 (Primary)</div>
712
+ <div class="route-chain" id="ov-extract-primary"></div>
713
+ <div class="bucket-title">兜底路由 (Fallback)</div>
714
+ <div class="route-chain" id="ov-extract-fallback"></div>
715
+ </div>
716
+ </div>
717
+
718
+ <div class="table-wrap">
719
+ <table class="data-table" aria-label="Provider 诊断与调用统计">
720
+ <thead>
721
+ <tr>
722
+ <th>实例 ID / 类型</th>
723
+ <th>路由角色</th>
724
+ <th>就绪状态</th>
725
+ <th>凭据与端点来源</th>
726
+ <th>Search (成功/尝试 · 均耗时)</th>
727
+ <th>Extract (成功/尝试 · 均耗时)</th>
728
+ <th>最近状态 / 错误码</th>
729
+ <th>操作</th>
730
+ </tr>
731
+ </thead>
732
+ <tbody id="provider-stats-tbody"></tbody>
733
+ </table>
734
+ </div>
735
+ </section>
736
+
737
+ <!-- Tab 3: 配置管理 -->
738
+ <section id="tab-config" role="tabpanel" hidden>
739
+ <div class="card">
740
+ <div class="card-head" style="margin-bottom:6px">
741
+ <div>
742
+ <h3>配置与路由在线管理</h3>
743
+ <p class="hint" id="cfg-file-label" style="margin-top:2px">配置文件:—</p>
744
+ </div>
745
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
746
+ <span class="badge status-warn" id="cfg-dirty-badge" hidden>有未保存修改</span>
747
+ <button type="button" class="btn" id="reload-config">从磁盘重载</button>
748
+ <button type="button" class="btn" id="reset-config-draft">重置草稿</button>
749
+ <button type="button" class="btn primary" id="save-config">保存并应用配置</button>
750
+ </div>
751
+ </div>
752
+ <p class="banner" id="cfg-feedback" role="status" hidden style="margin-top:10px;margin-bottom:0"></p>
753
+ </div>
754
+
755
+ <div class="grid-2">
756
+ <div class="card">
757
+ <div class="card-head">
758
+ <h3>Search 路由与预算参数</h3>
759
+ </div>
760
+ <div class="field-grid">
761
+ <div class="field">
762
+ <label for="cfg-s-limit">默认返回条数 (limit, 1-20)</label>
763
+ <input id="cfg-s-limit" class="input" type="number" min="1" max="20">
764
+ </div>
765
+ <div class="field">
766
+ <label for="cfg-s-timeout">总超时 (timeoutMs)</label>
767
+ <input id="cfg-s-timeout" class="input" type="number" min="100" step="500">
768
+ </div>
769
+ <div class="field">
770
+ <label for="cfg-s-attempt">单 Provider 超时 (attemptTimeoutMs)</label>
771
+ <input id="cfg-s-attempt" class="input" type="number" min="100" step="500">
772
+ </div>
773
+ <div class="field">
774
+ <label for="cfg-s-bytes">最大响应字节 (maxResponseBytes)</label>
775
+ <input id="cfg-s-bytes" class="input" type="number" min="1024" step="1024">
776
+ </div>
777
+ </div>
778
+ <div class="bucket-title">主路由 (按顺序回退尝试)</div>
779
+ <ul class="route-editor-list" id="cfg-s-primary"></ul>
780
+ <div class="bucket-title">兜底路由 (主路由全部失败后尝试)</div>
781
+ <ul class="route-editor-list" id="cfg-s-fallback"></ul>
782
+ <div class="bucket-title">未启用 Search 实例</div>
783
+ <ul class="route-editor-list" id="cfg-s-available"></ul>
784
+ </div>
785
+
786
+ <div class="card">
787
+ <div class="card-head">
788
+ <h3>Extract 路由与预算参数</h3>
789
+ </div>
790
+ <div class="field-grid">
791
+ <div class="field">
792
+ <label for="cfg-e-minchars">最小正文字符 (minContentCharacters)</label>
793
+ <input id="cfg-e-minchars" class="input" type="number" min="1" step="10">
794
+ </div>
795
+ <div class="field">
796
+ <label for="cfg-e-timeout">总超时 (timeoutMs)</label>
797
+ <input id="cfg-e-timeout" class="input" type="number" min="100" step="500">
798
+ </div>
799
+ <div class="field">
800
+ <label for="cfg-e-attempt">单 Provider 超时 (attemptTimeoutMs)</label>
801
+ <input id="cfg-e-attempt" class="input" type="number" min="100" step="500">
802
+ </div>
803
+ <div class="field">
804
+ <label for="cfg-e-bytes">最大响应字节 (maxResponseBytes)</label>
805
+ <input id="cfg-e-bytes" class="input" type="number" min="1024" step="1024">
806
+ </div>
807
+ </div>
808
+ <div class="bucket-title">主路由 (按顺序回退尝试)</div>
809
+ <ul class="route-editor-list" id="cfg-e-primary"></ul>
810
+ <div class="bucket-title">兜底路由 (主路由全部失败后尝试)</div>
811
+ <ul class="route-editor-list" id="cfg-e-fallback"></ul>
812
+ <div class="bucket-title">未启用 Extract 实例</div>
813
+ <ul class="route-editor-list" id="cfg-e-available"></ul>
814
+ </div>
815
+ </div>
816
+
817
+ <div class="card" style="margin-top:16px">
818
+ <div class="card-head">
819
+ <div>
820
+ <h3>Provider 实例与凭据配置</h3>
821
+ <p class="hint" style="margin-top:2px">明文 API Key 永不在接口响应中回显;修改后请点击顶部「保存并应用配置」写回。</p>
822
+ </div>
823
+ <button type="button" class="btn" id="add-provider-btn">+ 新增自定义实例</button>
824
+ </div>
825
+
826
+ <div class="editor-drawer" id="provider-editor" hidden>
827
+ <div class="card-head">
828
+ <h3 id="pe-title">编辑 Provider 实例</h3>
829
+ <button type="button" class="btn sm ghost" id="pe-cancel">关闭</button>
830
+ </div>
831
+ <div class="field-grid">
832
+ <div class="field">
833
+ <label for="pe-id">实例 ID (小写唯一标识)</label>
834
+ <input id="pe-id" class="input mono" type="text" placeholder="例如 tavily-backup">
835
+ </div>
836
+ <div class="field">
837
+ <label for="pe-type">Provider 协议类型</label>
838
+ <select id="pe-type" class="select mono"></select>
839
+ </div>
840
+ <div class="field">
841
+ <label for="pe-key-action">配置文件 API Key 操作</label>
842
+ <select id="pe-key-action" class="select">
843
+ <option value="keep">保持现有配置不变</option>
844
+ <option value="set">写入新 API Key</option>
845
+ <option value="clear">清除配置文件中的 API Key</option>
846
+ </select>
847
+ </div>
848
+ <div class="field" id="pe-key-field" hidden>
849
+ <label for="pe-key">新 API Key (仅单向写入,不回显)</label>
850
+ <input id="pe-key" class="input mono" type="password" autocomplete="off" spellcheck="false" placeholder="输入新的 API Key">
851
+ </div>
852
+ <div class="field">
853
+ <label for="pe-key-env">API Key 环境变量名 (apiKeyEnv)</label>
854
+ <input id="pe-key-env" class="input mono" type="text" placeholder="可选,如 CUSTOM_TAVILY_KEY">
855
+ </div>
856
+ <div class="field">
857
+ <label for="pe-base-url">自定义 Base URL (baseUrl)</label>
858
+ <input id="pe-base-url" class="input mono" type="url" placeholder="留空使用协议默认端点">
859
+ </div>
860
+ <div class="field">
861
+ <label for="pe-base-url-env">Base URL 环境变量名 (baseUrlEnv)</label>
862
+ <input id="pe-base-url-env" class="input mono" type="text" placeholder="可选,如 CUSTOM_BASE_URL">
863
+ </div>
864
+ <div class="field" id="pe-filter-wrap" hidden>
865
+ <label for="pe-filter-mode">域名过滤模式 (searchFilterMode)</label>
866
+ <select id="pe-filter-mode" class="select">
867
+ <option value="strict">strict (严格拒绝不支持过滤参数)</option>
868
+ <option value="best_effort">best_effort (本地后置过滤)</option>
869
+ </select>
870
+ </div>
871
+ <div class="field" id="pe-authjson-wrap" hidden>
872
+ <label for="pe-auth-json">xAI 认证文件路径 (authJson)</label>
873
+ <input id="pe-auth-json" class="input mono" type="text" placeholder="可选外部 OAuth JSON 路径">
874
+ </div>
875
+ <div class="field" id="pe-authjson-env-wrap" hidden>
876
+ <label for="pe-auth-json-env">xAI 认证文件环境变量 (authJsonEnv)</label>
877
+ <input id="pe-auth-json-env" class="input mono" type="text" placeholder="如 XAI_AUTH_JSON">
878
+ </div>
879
+ <div class="field" id="pe-model-wrap" hidden>
880
+ <label for="pe-model">模型覆盖 (model)</label>
881
+ <input id="pe-model" class="input mono" type="text" placeholder="如 grok-4.6">
882
+ </div>
883
+ <div class="field" id="pe-model-env-wrap" hidden>
884
+ <label for="pe-model-env">模型环境变量 (modelEnv)</label>
885
+ <input id="pe-model-env" class="input mono" type="text" placeholder="可选模型环境变量">
886
+ </div>
887
+ </div>
888
+ <p class="form-error" id="pe-error" hidden></p>
889
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
890
+ <button type="button" class="btn danger sm" id="pe-delete" hidden>移除自定义实例</button>
891
+ <button type="button" class="btn sm" id="pe-apply">暂存到配置草稿</button>
892
+ </div>
893
+ </div>
894
+
895
+ <div class="table-wrap" style="margin-top:8px">
896
+ <table class="data-table" aria-label="Provider 配置清单">
897
+ <thead>
898
+ <tr>
899
+ <th>实例 ID</th>
900
+ <th>协议类型</th>
901
+ <th>支持能力</th>
902
+ <th>当前凭据来源</th>
903
+ <th>生效端点 / 附加项</th>
904
+ <th>草稿状态</th>
905
+ <th>操作</th>
906
+ </tr>
907
+ </thead>
908
+ <tbody id="cfg-providers-tbody"></tbody>
909
+ </table>
910
+ </div>
911
+ </div>
912
+ </section>
913
+ </div>
914
+ <noscript><p class="banner">面板需要 JavaScript 才能管理网关。</p></noscript>
915
+ </main>
916
+ <script nonce="__NONCE__">
917
+ (() => {
918
+ "use strict";
919
+ const STORAGE_KEY = "web-access-gateway-key";
920
+ const RETENTION = __RETENTION__;
921
+ const MIN_KEY_LENGTH = __MIN_KEY_LENGTH__;
922
+ const REFRESH_MS = 3000;
923
+ const byId = (id) => document.getElementById(id);
924
+ const state = {
925
+ key: "",
926
+ tab: "records",
927
+ records: [],
928
+ overview: null,
929
+ route: "all",
930
+ status: "all",
931
+ providerFilter: "",
932
+ text: "",
933
+ pfilter: "all",
934
+ loading: false,
935
+ timer: 0,
936
+ draft: null,
937
+ dirty: false,
938
+ editingProviderId: null,
939
+ editingIsNew: false,
940
+ expandedIds: new Set(),
941
+ expandedPreviews: new Set(),
942
+ };
943
+
944
+ function readStoredKey() {
945
+ try {
946
+ return sessionStorage.getItem(STORAGE_KEY) || "";
947
+ } catch {
948
+ return "";
949
+ }
950
+ }
951
+
952
+ function storeKey(key) {
953
+ try {
954
+ sessionStorage.setItem(STORAGE_KEY, key);
955
+ } catch {
956
+ // Ignore storage errors in restricted contexts.
957
+ }
958
+ }
959
+
960
+ function clearStoredKey() {
961
+ try {
962
+ sessionStorage.removeItem(STORAGE_KEY);
963
+ } catch {
964
+ // Ignore storage errors.
965
+ }
966
+ }
967
+
968
+ function takeKeyFromHash() {
969
+ const raw = location.hash.startsWith("#") ? location.hash.slice(1) : "";
970
+ if (!raw) return "";
971
+ let key = "";
972
+ let remaining = location.hash;
973
+ try {
974
+ const params = new URLSearchParams(raw);
975
+ if (params.has("apikey")) {
976
+ key = (params.get("apikey") || "").trim();
977
+ params.delete("apikey");
978
+ const rest = params.toString();
979
+ remaining = rest ? "#" + rest : "";
980
+ }
981
+ } catch {
982
+ return "";
983
+ }
984
+ if (remaining === location.hash) return key;
985
+ history.replaceState(null, "", location.pathname + location.search + remaining);
986
+ return key;
987
+ }
988
+
989
+ function element(tag, className, content) {
990
+ const node = document.createElement(tag);
991
+ if (className) node.className = className;
992
+ if (content !== undefined && content !== null) node.textContent = String(content);
993
+ return node;
994
+ }
995
+
996
+ function setReady(kind, label) {
997
+ const pill = byId("ready-pill");
998
+ if (!pill) return;
999
+ pill.dataset.state = kind;
1000
+ const text = byId("ready-text");
1001
+ if (text.textContent !== label) text.textContent = label;
1002
+ }
1003
+
1004
+ async function refreshReady() {
1005
+ try {
1006
+ const response = await fetch("/readyz", { cache: "no-store" });
1007
+ if (response.ok) setReady("ready", "网关就绪");
1008
+ else setReady("not-ready", "网关未就绪");
1009
+ } catch {
1010
+ setReady("offline", "连接失败");
1011
+ }
1012
+ }
1013
+
1014
+ function formatTime(value) {
1015
+ if (!value) return "—";
1016
+ const date = new Date(value);
1017
+ if (Number.isNaN(date.getTime())) return "—";
1018
+ return date.toLocaleString("zh-CN", {
1019
+ month: "2-digit",
1020
+ day: "2-digit",
1021
+ hour: "2-digit",
1022
+ minute: "2-digit",
1023
+ second: "2-digit",
1024
+ hour12: false,
1025
+ });
1026
+ }
1027
+
1028
+ function formatDuration(value) {
1029
+ if (!Number.isFinite(value)) return "—";
1030
+ if (value < 1000) return Math.round(value) + " ms";
1031
+ return (value / 1000).toFixed(1) + " s";
1032
+ }
1033
+
1034
+ function formatUptime(ms) {
1035
+ if (!Number.isFinite(ms) || ms < 0) return "—";
1036
+ const sec = Math.floor(ms / 1000);
1037
+ if (sec < 60) return sec + " 秒";
1038
+ const min = Math.floor(sec / 60);
1039
+ if (min < 60) return min + " 分钟";
1040
+ const hr = Math.floor(min / 60);
1041
+ const remMin = min % 60;
1042
+ return hr + " 小时 " + remMin + " 分";
1043
+ }
1044
+
1045
+ function statusKind(status) {
1046
+ if (status >= 200 && status < 300) return "ok";
1047
+ if (status >= 500) return "error";
1048
+ return "warn";
1049
+ }
1050
+
1051
+ function routeLabel(route) {
1052
+ if (route === "search") return "搜索";
1053
+ if (route === "fetch") return "抓取";
1054
+ return route;
1055
+ }
1056
+
1057
+ function credSourceLabel(source) {
1058
+ if (source === "env") return "环境变量";
1059
+ if (source === "config") return "配置文件";
1060
+ if (source === "none") return "免凭据";
1061
+ return "未配置";
1062
+ }
1063
+
1064
+ function safeHref(value) {
1065
+ try {
1066
+ const url = new URL(value);
1067
+ if (url.protocol === "http:" || url.protocol === "https:") return url.href;
1068
+ } catch {
1069
+ // Render non-HTTP URLs as plain text.
1070
+ }
1071
+ return null;
1072
+ }
1073
+
1074
+ function appendMeta(list, label, value) {
1075
+ list.append(element("dt", null, label), element("dd", null, value));
1076
+ }
1077
+
1078
+ function renderAttemptRow(attempt, idx) {
1079
+ const row = element("li", "attempt-row");
1080
+ const badgeKind = attempt.status === "ok" ? "status-ok" : attempt.status === "skipped" ? "status-warn" : "status-error";
1081
+ const badgeLabel = attempt.status === "ok" ? "成功" : attempt.status === "skipped" ? "跳过" : "失败";
1082
+ row.append(
1083
+ element("span", "badge " + badgeKind, "#" + (idx + 1) + " " + badgeLabel),
1084
+ element("span", "attempt-provider", attempt.provider + (attempt.providerType && attempt.providerType !== attempt.provider ? " (" + attempt.providerType + ")" : "")),
1085
+ element("span", "attempt-duration", formatDuration(attempt.durationMs)),
1086
+ );
1087
+ if (attempt.errorCode || attempt.httpStatus) {
1088
+ const codeStr = [attempt.errorCode, attempt.httpStatus ? "HTTP " + attempt.httpStatus : ""].filter(Boolean).join(" · ");
1089
+ row.append(element("span", "badge badge-muted", codeStr));
1090
+ }
1091
+ if (attempt.errorMessage) {
1092
+ row.append(element("span", "attempt-err", attempt.errorMessage));
1093
+ }
1094
+ return row;
1095
+ }
1096
+
1097
+ function recordKey(record, fallbackIdx) {
1098
+ if (record && record.id) return String(record.id);
1099
+ return (
1100
+ String(record.at || "") +
1101
+ ":" +
1102
+ String(record.route || "") +
1103
+ ":" +
1104
+ String(record.query || record.url || record.title || fallbackIdx || "")
1105
+ );
1106
+ }
1107
+
1108
+ function renderRecord(record, recKey) {
1109
+ const kind = statusKind(record.status);
1110
+ const isExpanded = state.expandedIds.has(recKey);
1111
+ const item = element("li", "record" + (isExpanded ? " expanded" : ""));
1112
+ item.dataset.status = kind;
1113
+ item.dataset.recordKey = recKey;
1114
+
1115
+ const summaryText =
1116
+ record.query ||
1117
+ record.url ||
1118
+ record.title ||
1119
+ (record.code === "invalid_request_error"
1120
+ ? "(请求体未通过校验)"
1121
+ : "(请求体未解析)");
1122
+ const summary = element("span", "record-summary", summaryText);
1123
+ summary.title = summaryText;
1124
+
1125
+ const head = element("button", "record-head");
1126
+ head.type = "button";
1127
+ head.setAttribute("aria-expanded", String(isExpanded));
1128
+ head.append(
1129
+ element("span", "record-time", formatTime(record.at)),
1130
+ element("span", "badge route-" + record.route, routeLabel(record.route)),
1131
+ element("span", "badge status-" + kind, String(record.status)),
1132
+ );
1133
+ if (record.needContent) {
1134
+ head.append(element("span", "badge badge-muted", "含补抓"));
1135
+ }
1136
+ if (Array.isArray(record.attempts) && record.attempts.length > 1) {
1137
+ head.append(element("span", "badge status-warn", record.attempts.length + " 次尝试"));
1138
+ }
1139
+ head.append(
1140
+ summary,
1141
+ element("span", "record-provider", record.provider || "—"),
1142
+ element("span", "record-duration", formatDuration(record.durationMs)),
1143
+ );
1144
+ if (record.resultCount !== undefined) {
1145
+ head.append(
1146
+ element("span", "record-results", record.resultCount + " 条"),
1147
+ );
1148
+ }
1149
+ const chevron = element("span", "chevron", "›");
1150
+ chevron.setAttribute("aria-hidden", "true");
1151
+ head.append(chevron);
1152
+
1153
+ const detail = element("div", "record-detail");
1154
+ detail.hidden = !isExpanded;
1155
+ const meta = element("dl", "record-meta");
1156
+ appendMeta(meta, "请求 ID", record.id || "—");
1157
+ appendMeta(meta, "时间", formatTime(record.at));
1158
+ appendMeta(meta, "耗时", formatDuration(record.durationMs));
1159
+ appendMeta(meta, "最终 Provider", record.provider || "—");
1160
+ if (record.code) appendMeta(meta, "错误码", record.code);
1161
+ if (record.route === "search" && record.needContent !== undefined) {
1162
+ appendMeta(meta, "count", record.count === undefined ? "10(默认)" : String(record.count));
1163
+ appendMeta(meta, "need_content", record.needContent ? "true" : "false");
1164
+ }
1165
+ if (record.title) appendMeta(meta, "页面标题", record.title);
1166
+ detail.append(meta);
1167
+
1168
+ const attempts = Array.isArray(record.attempts) ? record.attempts : [];
1169
+ if (attempts.length > 0) {
1170
+ detail.append(element("div", "detail-label", "Provider 尝试链路(共 " + attempts.length + " 步)"));
1171
+ const attList = element("ol", "attempts-list");
1172
+ attempts.forEach((att, idx) => attList.append(renderAttemptRow(att, idx)));
1173
+ detail.append(attList);
1174
+ }
1175
+
1176
+ if (record.contentBackfill) {
1177
+ const bf = record.contentBackfill;
1178
+ detail.append(
1179
+ element(
1180
+ "div",
1181
+ "detail-label",
1182
+ "正文补抓统计(请求 " + bf.requestedCount + " · 尝试 " + bf.attemptedCount + " · 成功 " + bf.succeededCount + " · 耗时 " + formatDuration(bf.durationMs) + ")",
1183
+ ),
1184
+ );
1185
+ if (Array.isArray(bf.items) && bf.items.length > 0) {
1186
+ const bfList = element("ol", "attempts-list");
1187
+ bf.items.forEach((bfItem, idx) => {
1188
+ const row = element("li", "attempt-row");
1189
+ const bKind = bfItem.status === "ok" ? "status-ok" : bfItem.status === "skipped" ? "status-warn" : "status-error";
1190
+ const bLabel = bfItem.status === "ok" ? "补抓成功" : bfItem.status === "skipped" ? "跳过" : bfItem.status === "aborted" ? "中断" : "补抓失败";
1191
+ row.append(
1192
+ element("span", "badge " + bKind, "#" + (idx + 1) + " " + bLabel),
1193
+ element("span", "attempt-provider", bfItem.provider || "—"),
1194
+ element("span", "attempt-duration", formatDuration(bfItem.durationMs)),
1195
+ );
1196
+ if (bfItem.contentChars !== undefined) {
1197
+ row.append(element("span", "badge badge-muted", bfItem.contentChars + " 字符"));
1198
+ }
1199
+ row.append(element("span", "result-url", bfItem.url));
1200
+ bfList.append(row);
1201
+ });
1202
+ detail.append(bfList);
1203
+ }
1204
+ }
1205
+
1206
+ if (record.contentPreview) {
1207
+ const labelRow = element("div", "detail-label");
1208
+ const charText = record.contentChars
1209
+ ? "抓取正文预览(" + record.contentChars + " 字符" + (record.contentTruncated ? ",已截断预览" : "") + ")"
1210
+ : "抓取正文预览";
1211
+ labelRow.append(element("span", null, charText));
1212
+ const copyBtn = element("button", "btn sm", "复制预览");
1213
+ copyBtn.type = "button";
1214
+ copyBtn.addEventListener("click", () => {
1215
+ if (navigator.clipboard) {
1216
+ void navigator.clipboard.writeText(record.contentPreview).then(() => {
1217
+ copyBtn.textContent = "已复制";
1218
+ setTimeout(() => { copyBtn.textContent = "复制预览"; }, 1200);
1219
+ });
1220
+ }
1221
+ });
1222
+ labelRow.append(copyBtn);
1223
+ detail.append(labelRow);
1224
+ const box = element("div", "content-preview-box");
1225
+ box.append(element("pre", "content-preview-pre", record.contentPreview));
1226
+ detail.append(box);
1227
+ }
1228
+
1229
+ const results = Array.isArray(record.results) ? record.results : [];
1230
+ if (results.length > 0) {
1231
+ detail.append(element("div", "detail-label", "搜索结果列表(共 " + results.length + " 条)"));
1232
+ const list = element("ol", "result-list");
1233
+ results.forEach((result, rIdx) => {
1234
+ const row = element("li", "result-item");
1235
+ const href = safeHref(result.url);
1236
+ if (href) {
1237
+ const link = element("a", "result-title", result.title || result.url);
1238
+ link.href = href;
1239
+ link.target = "_blank";
1240
+ link.rel = "noreferrer noopener";
1241
+ row.append(link);
1242
+ } else {
1243
+ row.append(element("span", "result-title", result.title || "(无标题)"));
1244
+ }
1245
+ row.append(element("span", "result-url", result.url || ""));
1246
+ if (result.snippet) {
1247
+ row.append(element("p", "result-snippet", result.snippet));
1248
+ }
1249
+ if (result.contentPreview) {
1250
+ const previewKey = recKey + "#r" + rIdx;
1251
+ const detailsNode = element("details", "result-preview-details");
1252
+ if (state.expandedPreviews.has(previewKey)) {
1253
+ detailsNode.open = true;
1254
+ }
1255
+ detailsNode.addEventListener("toggle", () => {
1256
+ if (detailsNode.open) state.expandedPreviews.add(previewKey);
1257
+ else state.expandedPreviews.delete(previewKey);
1258
+ });
1259
+ const sumText = "查看补抓正文预览" + (result.contentChars ? " (" + result.contentChars + " 字符" + (result.contentTruncated ? ",已截断" : "") + ")" : "");
1260
+ detailsNode.append(element("summary", null, sumText));
1261
+ const box = element("div", "content-preview-box");
1262
+ box.append(element("pre", "content-preview-pre", result.contentPreview));
1263
+ detailsNode.append(box);
1264
+ row.append(detailsNode);
1265
+ }
1266
+ list.append(row);
1267
+ });
1268
+ detail.append(list);
1269
+ }
1270
+
1271
+ head.addEventListener("click", () => {
1272
+ const expanded = head.getAttribute("aria-expanded") === "true";
1273
+ const next = !expanded;
1274
+ head.setAttribute("aria-expanded", String(next));
1275
+ detail.hidden = !next;
1276
+ item.classList.toggle("expanded", next);
1277
+ if (next) state.expandedIds.add(recKey);
1278
+ else state.expandedIds.delete(recKey);
1279
+ });
1280
+ item.append(head, detail);
1281
+ return item;
1282
+ }
1283
+
1284
+ function matches(record) {
1285
+ if (state.route !== "all" && record.route !== state.route) return false;
1286
+ const ok = record.status >= 200 && record.status < 300;
1287
+ if (state.status === "ok" && !ok) return false;
1288
+ if (state.status === "error" && ok) return false;
1289
+ if (state.providerFilter) {
1290
+ const matchedMain = record.provider === state.providerFilter;
1291
+ const matchedAttempt = Array.isArray(record.attempts) && record.attempts.some((a) => a.provider === state.providerFilter);
1292
+ if (!matchedMain && !matchedAttempt) return false;
1293
+ }
1294
+ const needle = state.text.trim().toLowerCase();
1295
+ if (!needle) return true;
1296
+ const parts = [record.query, record.url, record.provider, record.code, record.title];
1297
+ for (const result of record.results || []) parts.push(result.title, result.url, result.snippet);
1298
+ for (const att of record.attempts || []) parts.push(att.provider, att.errorCode, att.errorMessage);
1299
+ return parts.filter(Boolean).join(" ").toLowerCase().includes(needle);
1300
+ }
1301
+
1302
+ function updateProviderFilterDropdown() {
1303
+ const select = byId("filter-provider");
1304
+ const prev = state.providerFilter;
1305
+ const names = new Set();
1306
+ for (const rec of state.records) {
1307
+ if (rec.provider) names.add(rec.provider);
1308
+ for (const att of rec.attempts || []) {
1309
+ if (att.provider) names.add(att.provider);
1310
+ }
1311
+ }
1312
+ if (state.overview && Array.isArray(state.overview.providers)) {
1313
+ for (const p of state.overview.providers) {
1314
+ if (p.routeRole.search !== "disabled" || p.routeRole.extract !== "disabled") {
1315
+ names.add(p.id);
1316
+ }
1317
+ }
1318
+ }
1319
+ const sorted = Array.from(names).sort();
1320
+ const currentValues = Array.from(select.options).map((o) => o.value);
1321
+ const nextValues = ["", ...sorted];
1322
+ const sameOptions =
1323
+ currentValues.length === nextValues.length &&
1324
+ currentValues.every((val, i) => val === nextValues[i]);
1325
+ if (sameOptions) {
1326
+ if (select.value !== prev) select.value = prev;
1327
+ return;
1328
+ }
1329
+ select.replaceChildren(element("option", null, "全部 Provider"));
1330
+ select.firstChild.value = "";
1331
+ for (const id of sorted) {
1332
+ const opt = element("option", null, id);
1333
+ opt.value = id;
1334
+ if (id === prev) opt.selected = true;
1335
+ select.append(opt);
1336
+ }
1337
+ }
1338
+
1339
+ function renderStats() {
1340
+ let ok = 0;
1341
+ let bad = 0;
1342
+ let totalDuration = 0;
1343
+ let counted = 0;
1344
+ for (const record of state.records) {
1345
+ if (record.status >= 200 && record.status < 300) ok += 1;
1346
+ else bad += 1;
1347
+ if (Number.isFinite(record.durationMs)) {
1348
+ totalDuration += record.durationMs;
1349
+ counted += 1;
1350
+ }
1351
+ }
1352
+ byId("stat-total").textContent = String(state.records.length);
1353
+ byId("stat-ok").textContent = String(ok);
1354
+ byId("stat-bad").textContent = String(bad);
1355
+ byId("stat-avg").textContent = counted > 0 ? formatDuration(totalDuration / counted) : "—";
1356
+ }
1357
+
1358
+ function renderRecordsTab() {
1359
+ renderStats();
1360
+ updateProviderFilterDropdown();
1361
+ const list = byId("records");
1362
+ const visible = state.records.filter(matches);
1363
+ const existingNodes = new Map();
1364
+ for (const child of Array.from(list.children)) {
1365
+ const k = child.dataset && child.dataset.recordKey;
1366
+ if (k) existingNodes.set(k, child);
1367
+ }
1368
+ const keepKeys = new Set();
1369
+ const nextNodes = visible.map((record, idx) => {
1370
+ const k = recordKey(record, idx);
1371
+ keepKeys.add(k);
1372
+ const existing = existingNodes.get(k);
1373
+ if (existing) {
1374
+ const isExp = state.expandedIds.has(k);
1375
+ existing.classList.toggle("expanded", isExp);
1376
+ const h = existing.querySelector(".record-head");
1377
+ const d = existing.querySelector(".record-detail");
1378
+ if (h) h.setAttribute("aria-expanded", String(isExp));
1379
+ if (d) d.hidden = !isExp;
1380
+ return existing;
1381
+ }
1382
+ return renderRecord(record, k);
1383
+ });
1384
+ for (const child of Array.from(list.children)) {
1385
+ const k = child.dataset && child.dataset.recordKey;
1386
+ if (!k || !keepKeys.has(k)) {
1387
+ child.remove();
1388
+ }
1389
+ }
1390
+ for (let i = 0; i < nextNodes.length; i += 1) {
1391
+ const node = nextNodes[i];
1392
+ const currentAtIdx = list.children[i];
1393
+ if (currentAtIdx !== node) {
1394
+ list.insertBefore(node, currentAtIdx || null);
1395
+ }
1396
+ }
1397
+ byId("empty").hidden = visible.length > 0;
1398
+ if (visible.length === 0) {
1399
+ byId("empty-title").textContent = state.records.length > 0 ? "没有匹配的记录" : "还没有请求记录";
1400
+ byId("empty-text").textContent = state.records.length > 0
1401
+ ? "调整类型、状态、Provider 或文本筛选后再试。"
1402
+ : "调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示完整链路、耗时与结果预览。";
1403
+ }
1404
+ byId("record-count").textContent = visible.length === state.records.length
1405
+ ? "已加载 " + state.records.length + " 条"
1406
+ : "已加载 " + state.records.length + " 条,筛选后 " + visible.length + " 条";
1407
+ }
1408
+
1409
+ function renderRouteChainNodes(container, ids, providerMap, capability) {
1410
+ container.replaceChildren();
1411
+ if (!ids || ids.length === 0) {
1412
+ container.append(element("span", "hint", "(未配置)"));
1413
+ return;
1414
+ }
1415
+ ids.forEach((id, idx) => {
1416
+ if (idx > 0) container.append(element("span", "hint", "→"));
1417
+ const p = providerMap.get(id);
1418
+ const ready = p ? (capability === "search" ? p.configured.searchReady : p.configured.extractReady) : false;
1419
+ const node = element("span", "route-node " + (ready ? "ready" : "unready"));
1420
+ node.append(
1421
+ element("strong", null, "#" + (idx + 1) + " " + id),
1422
+ element("span", null, ready ? "就绪" : "未就绪"),
1423
+ );
1424
+ container.append(node);
1425
+ });
1426
+ }
1427
+
1428
+ function renderOverviewTab() {
1429
+ const ov = state.overview;
1430
+ if (!ov) return;
1431
+
1432
+ const persistPill = byId("persist-pill");
1433
+ if (!ov.gateway.configPersisted) {
1434
+ persistPill.hidden = false;
1435
+ byId("persist-text").textContent = "只读配置·仅内存生效";
1436
+ } else {
1437
+ persistPill.hidden = true;
1438
+ }
1439
+
1440
+ const st = ov.stats;
1441
+ byId("ov-total-req").textContent = String(st.totalRequests);
1442
+ byId("ov-uptime").textContent = "运行时间 " + formatUptime(ov.gateway.uptimeMs);
1443
+ byId("ov-search-stat").textContent = st.searchSuccesses + " / " + st.searchRequests;
1444
+ byId("ov-doctor-search").textContent = ov.doctor.routes.search ? "Search 路由就绪" : "Search 路由缺可用实例";
1445
+ byId("ov-fetch-stat").textContent = st.fetchSuccesses + " / " + st.fetchRequests;
1446
+ byId("ov-doctor-extract").textContent = ov.doctor.routes.extract ? "Extract 路由就绪" : "Extract 路由缺可用实例";
1447
+ byId("ov-backfill-stat").textContent = st.backfillItemsSucceeded + " / " + (st.backfillItemsSucceeded + st.backfillItemsFailed);
1448
+ byId("ov-config-mode").textContent = ov.gateway.configPersisted ? "已同步配置文件" : "仅内存生效 (只读挂载)";
1449
+
1450
+ byId("ov-search-params").textContent =
1451
+ "limit=" + ov.routes.search.limit + " · timeout=" + formatDuration(ov.routes.search.timeoutMs) + " · attempt=" + formatDuration(ov.routes.search.attemptTimeoutMs);
1452
+ byId("ov-extract-params").textContent =
1453
+ "minChars=" + ov.routes.extract.minContentCharacters + " · timeout=" + formatDuration(ov.routes.extract.timeoutMs) + " · attempt=" + formatDuration(ov.routes.extract.attemptTimeoutMs);
1454
+
1455
+ const providerMap = new Map(ov.providers.map((p) => [p.id, p]));
1456
+ renderRouteChainNodes(byId("ov-search-primary"), ov.routes.search.providers, providerMap, "search");
1457
+ renderRouteChainNodes(byId("ov-search-fallback"), ov.routes.search.providers_fallback, providerMap, "search");
1458
+ renderRouteChainNodes(byId("ov-extract-primary"), ov.routes.extract.providers, providerMap, "extract");
1459
+ renderRouteChainNodes(byId("ov-extract-fallback"), ov.routes.extract.providers_fallback, providerMap, "extract");
1460
+
1461
+ const tbody = byId("provider-stats-tbody");
1462
+ tbody.replaceChildren();
1463
+ const filtered = ov.providers.filter((p) => {
1464
+ if (state.pfilter === "routed") {
1465
+ return p.routeRole.search !== "disabled" || p.routeRole.extract !== "disabled";
1466
+ }
1467
+ if (state.pfilter === "ready") {
1468
+ return p.configured.searchReady || p.configured.extractReady;
1469
+ }
1470
+ return true;
1471
+ });
1472
+
1473
+ for (const p of filtered) {
1474
+ const tr = element("tr");
1475
+ const tdId = element("td");
1476
+ tdId.append(
1477
+ element("strong", "mono", p.id),
1478
+ element("span", "badge badge-muted", p.type),
1479
+ );
1480
+ if (!p.isBuiltin) {
1481
+ tdId.append(element("span", "badge route-search", "自定义"));
1482
+ }
1483
+
1484
+ const tdRole = element("td");
1485
+ const roles = [];
1486
+ if (p.routeRole.search !== "disabled") {
1487
+ roles.push("S:" + (p.routeRole.search === "primary" ? "主#" : "备#") + (p.routeRole.searchIndex + 1));
1488
+ }
1489
+ if (p.routeRole.extract !== "disabled") {
1490
+ roles.push("E:" + (p.routeRole.extract === "primary" ? "主#" : "备#") + (p.routeRole.extractIndex + 1));
1491
+ }
1492
+ tdRole.textContent = roles.length > 0 ? roles.join(" · ") : "未启用";
1493
+ tdRole.className = "mono";
1494
+
1495
+ const tdReady = element("td");
1496
+ if (p.capabilities.includes("search")) {
1497
+ tdReady.append(
1498
+ element("span", "badge " + (p.configured.searchReady ? "status-ok" : "status-warn"), p.configured.searchReady ? "Search就绪" : "Search未配"),
1499
+ );
1500
+ }
1501
+ if (p.capabilities.includes("extract")) {
1502
+ tdReady.append(
1503
+ element("span", "badge " + (p.configured.extractReady ? "status-ok" : "status-warn"), p.configured.extractReady ? "Extract就绪" : "Extract未配"),
1504
+ );
1505
+ }
1506
+
1507
+ const tdCred = element("td");
1508
+ tdCred.append(
1509
+ element("span", "badge badge-muted", credSourceLabel(p.credentialSource)),
1510
+ );
1511
+ if (p.config.effectiveBaseUrl) {
1512
+ const urlSpan = element("div", "result-url", p.config.effectiveBaseUrl);
1513
+ tdCred.append(urlSpan);
1514
+ }
1515
+
1516
+ const sStat = p.stats.search;
1517
+ const tdSearch = element("td", "mono");
1518
+ tdSearch.textContent = p.capabilities.includes("search")
1519
+ ? sStat.successes + "/" + sStat.attempts + " · " + (sStat.attempts > 0 ? formatDuration(sStat.avgDurationMs) : "—")
1520
+ : "—";
1521
+
1522
+ const eStat = p.stats.extract;
1523
+ const tdExtract = element("td", "mono");
1524
+ tdExtract.textContent = p.capabilities.includes("extract")
1525
+ ? eStat.successes + "/" + eStat.attempts + " · " + (eStat.attempts > 0 ? formatDuration(eStat.avgDurationMs) : "—")
1526
+ : "—";
1527
+
1528
+ const tdLast = element("td", "mono");
1529
+ const errEntries = Object.entries({ ...sStat.errorCodes, ...eStat.errorCodes });
1530
+ if (errEntries.length > 0) {
1531
+ tdLast.textContent = errEntries.map(([k, v]) => k + ":" + v).join(", ");
1532
+ } else if (sStat.lastUsedAt || eStat.lastUsedAt) {
1533
+ tdLast.textContent = "正常 · " + formatTime(Math.max(sStat.lastUsedAt || 0, eStat.lastUsedAt || 0));
1534
+ } else {
1535
+ tdLast.textContent = "暂无调用";
1536
+ }
1537
+
1538
+ const tdAct = element("td");
1539
+ const editBtn = element("button", "btn sm", "配置");
1540
+ editBtn.type = "button";
1541
+ editBtn.addEventListener("click", () => {
1542
+ switchTab("config");
1543
+ openProviderEditor(p.id, false);
1544
+ });
1545
+ tdAct.append(editBtn);
1546
+
1547
+ tr.append(tdId, tdRole, tdReady, tdCred, tdSearch, tdExtract, tdLast, tdAct);
1548
+ tbody.append(tr);
1549
+ }
1550
+ }
1551
+
1552
+ function cloneOverviewToDraft(ov) {
1553
+ return {
1554
+ search: {
1555
+ providers: [...ov.routes.search.providers],
1556
+ providers_fallback: [...ov.routes.search.providers_fallback],
1557
+ limit: ov.routes.search.limit,
1558
+ timeoutMs: ov.routes.search.timeoutMs,
1559
+ attemptTimeoutMs: ov.routes.search.attemptTimeoutMs,
1560
+ maxResponseBytes: ov.routes.search.maxResponseBytes,
1561
+ },
1562
+ extract: {
1563
+ providers: [...ov.routes.extract.providers],
1564
+ providers_fallback: [...ov.routes.extract.providers_fallback],
1565
+ timeoutMs: ov.routes.extract.timeoutMs,
1566
+ attemptTimeoutMs: ov.routes.extract.attemptTimeoutMs,
1567
+ minContentCharacters: ov.routes.extract.minContentCharacters,
1568
+ maxResponseBytes: ov.routes.extract.maxResponseBytes,
1569
+ },
1570
+ providerPatches: {},
1571
+ removedProviders: [],
1572
+ };
1573
+ }
1574
+
1575
+ function setDirty(dirty) {
1576
+ state.dirty = dirty;
1577
+ byId("cfg-dirty-badge").hidden = !dirty;
1578
+ }
1579
+
1580
+ function getEffectiveProvidersList() {
1581
+ const ov = state.overview;
1582
+ if (!ov || !state.draft) return [];
1583
+ const removed = new Set(state.draft.removedProviders);
1584
+ const map = new Map();
1585
+ for (const p of ov.providers) {
1586
+ if (!removed.has(p.id)) {
1587
+ map.set(p.id, { ...p, draftPatch: state.draft.providerPatches[p.id] || null });
1588
+ }
1589
+ }
1590
+ for (const [id, patch] of Object.entries(state.draft.providerPatches)) {
1591
+ if (!map.has(id) && !removed.has(id)) {
1592
+ const typeInfo = ov.providerTypes.find((t) => t.type === patch.type);
1593
+ map.set(id, {
1594
+ id,
1595
+ type: patch.type,
1596
+ isBuiltin: false,
1597
+ capabilities: typeInfo ? typeInfo.capabilities : ["search"],
1598
+ configured: {
1599
+ credential: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1600
+ baseUrl: true,
1601
+ adapter: true,
1602
+ searchReady: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1603
+ extractReady: patch.apiKeyAction === "set" || Boolean(patch.apiKeyEnv),
1604
+ },
1605
+ credentialSource: patch.apiKeyAction === "set" ? "config" : patch.apiKeyEnv ? "env" : "missing",
1606
+ config: {
1607
+ hasConfigApiKey: patch.apiKeyAction === "set",
1608
+ apiKeyEnv: patch.apiKeyEnv || undefined,
1609
+ configBaseUrl: patch.baseUrl || undefined,
1610
+ effectiveBaseUrl: patch.baseUrl || null,
1611
+ baseUrlEnv: patch.baseUrlEnv || undefined,
1612
+ authJson: patch.authJson || undefined,
1613
+ authJsonEnv: patch.authJsonEnv || undefined,
1614
+ model: patch.model || undefined,
1615
+ modelEnv: patch.modelEnv || undefined,
1616
+ searchFilterMode: patch.searchFilterMode || null,
1617
+ headerNames: [],
1618
+ },
1619
+ draftPatch: patch,
1620
+ });
1621
+ }
1622
+ }
1623
+ return Array.from(map.values());
1624
+ }
1625
+
1626
+ function moveItem(arr, fromIdx, toIdx) {
1627
+ if (toIdx < 0 || toIdx >= arr.length) return;
1628
+ const [moved] = arr.splice(fromIdx, 1);
1629
+ arr.splice(toIdx, 0, moved);
1630
+ setDirty(true);
1631
+ renderConfigTab();
1632
+ }
1633
+
1634
+ function renderRouteBucket(container, list, otherList, capability, kind, providerMap) {
1635
+ container.replaceChildren();
1636
+ if (list.length === 0) {
1637
+ const emptyLi = element("li", "route-editor-item");
1638
+ emptyLi.append(element("span", "hint", "(空)"));
1639
+ container.append(emptyLi);
1640
+ return;
1641
+ }
1642
+ list.forEach((id, idx) => {
1643
+ const p = providerMap.get(id);
1644
+ const ready = p ? (capability === "search" ? p.configured.searchReady : p.configured.extractReady) : false;
1645
+ const li = element("li", "route-editor-item");
1646
+ const left = element("div", null);
1647
+ left.style.display = "flex";
1648
+ left.style.alignItems = "center";
1649
+ left.style.gap = "8px";
1650
+ left.append(
1651
+ element("strong", "mono", "#" + (idx + 1) + " " + id),
1652
+ element("span", "badge " + (ready ? "status-ok" : "status-warn"), ready ? "就绪" : "缺凭据"),
1653
+ );
1654
+ const actions = element("div", "route-editor-actions");
1655
+ const upBtn = element("button", "btn sm", "↑");
1656
+ upBtn.type = "button";
1657
+ upBtn.disabled = idx === 0;
1658
+ upBtn.addEventListener("click", () => moveItem(list, idx, idx - 1));
1659
+
1660
+ const downBtn = element("button", "btn sm", "↓");
1661
+ downBtn.type = "button";
1662
+ downBtn.disabled = idx === list.length - 1;
1663
+ downBtn.addEventListener("click", () => moveItem(list, idx, idx + 1));
1664
+
1665
+ const swapBtn = element("button", "btn sm", kind === "primary" ? "转兜底" : "转主路由");
1666
+ swapBtn.type = "button";
1667
+ swapBtn.addEventListener("click", () => {
1668
+ list.splice(idx, 1);
1669
+ if (!otherList.includes(id)) otherList.push(id);
1670
+ setDirty(true);
1671
+ renderConfigTab();
1672
+ });
1673
+
1674
+ const delBtn = element("button", "btn sm danger", "移除");
1675
+ delBtn.type = "button";
1676
+ delBtn.addEventListener("click", () => {
1677
+ list.splice(idx, 1);
1678
+ setDirty(true);
1679
+ renderConfigTab();
1680
+ });
1681
+
1682
+ actions.append(upBtn, downBtn, swapBtn, delBtn);
1683
+ li.append(left, actions);
1684
+ container.append(li);
1685
+ });
1686
+ }
1687
+
1688
+ function renderAvailableBucket(container, primaryList, fallbackList, capability, allProviders) {
1689
+ container.replaceChildren();
1690
+ const used = new Set([...primaryList, ...fallbackList]);
1691
+ const candidates = allProviders.filter((p) => p.capabilities.includes(capability) && !used.has(p.id));
1692
+ if (candidates.length === 0) {
1693
+ const emptyLi = element("li", "route-editor-item");
1694
+ emptyLi.append(element("span", "hint", "所有支持该能力的实例均已编入路由"));
1695
+ container.append(emptyLi);
1696
+ return;
1697
+ }
1698
+ for (const p of candidates) {
1699
+ const ready = capability === "search" ? p.configured.searchReady : p.configured.extractReady;
1700
+ const li = element("li", "route-editor-item");
1701
+ const left = element("div", null);
1702
+ left.style.display = "flex";
1703
+ left.style.alignItems = "center";
1704
+ left.style.gap = "8px";
1705
+ left.append(
1706
+ element("span", "mono", p.id),
1707
+ element("span", "badge " + (ready ? "status-ok" : "status-warn"), ready ? "就绪" : "缺凭据"),
1708
+ );
1709
+ const actions = element("div", "route-editor-actions");
1710
+ const addPrimary = element("button", "btn sm", "+ 主路由");
1711
+ addPrimary.type = "button";
1712
+ addPrimary.addEventListener("click", () => {
1713
+ primaryList.push(p.id);
1714
+ setDirty(true);
1715
+ renderConfigTab();
1716
+ });
1717
+ const addFallback = element("button", "btn sm", "+ 兜底");
1718
+ addFallback.type = "button";
1719
+ addFallback.addEventListener("click", () => {
1720
+ fallbackList.push(p.id);
1721
+ setDirty(true);
1722
+ renderConfigTab();
1723
+ });
1724
+ actions.append(addPrimary, addFallback);
1725
+ li.append(left, actions);
1726
+ container.append(li);
1727
+ }
1728
+ }
1729
+
1730
+ function renderConfigTab() {
1731
+ const ov = state.overview;
1732
+ if (!ov) return;
1733
+ if (!state.draft) {
1734
+ state.draft = cloneOverviewToDraft(ov);
1735
+ setDirty(false);
1736
+ }
1737
+
1738
+ const modeDesc = ov.gateway.configPersisted
1739
+ ? "可写并同步磁盘"
1740
+ : "只读挂载(修改在内存立即生效)";
1741
+ byId("cfg-file-label").textContent = "配置文件:" + ov.gateway.configPath + " · " + modeDesc;
1742
+
1743
+ const d = state.draft;
1744
+ byId("cfg-s-limit").value = String(d.search.limit);
1745
+ byId("cfg-s-timeout").value = String(d.search.timeoutMs);
1746
+ byId("cfg-s-attempt").value = String(d.search.attemptTimeoutMs);
1747
+ byId("cfg-s-bytes").value = String(d.search.maxResponseBytes);
1748
+
1749
+ byId("cfg-e-minchars").value = String(d.extract.minContentCharacters);
1750
+ byId("cfg-e-timeout").value = String(d.extract.timeoutMs);
1751
+ byId("cfg-e-attempt").value = String(d.extract.attemptTimeoutMs);
1752
+ byId("cfg-e-bytes").value = String(d.extract.maxResponseBytes);
1753
+
1754
+ const allProviders = getEffectiveProvidersList();
1755
+ const providerMap = new Map(allProviders.map((p) => [p.id, p]));
1756
+
1757
+ renderRouteBucket(byId("cfg-s-primary"), d.search.providers, d.search.providers_fallback, "search", "primary", providerMap);
1758
+ renderRouteBucket(byId("cfg-s-fallback"), d.search.providers_fallback, d.search.providers, "search", "fallback", providerMap);
1759
+ renderAvailableBucket(byId("cfg-s-available"), d.search.providers, d.search.providers_fallback, "search", allProviders);
1760
+
1761
+ renderRouteBucket(byId("cfg-e-primary"), d.extract.providers, d.extract.providers_fallback, "extract", "primary", providerMap);
1762
+ renderRouteBucket(byId("cfg-e-fallback"), d.extract.providers_fallback, d.extract.providers, "extract", "fallback", providerMap);
1763
+ renderAvailableBucket(byId("cfg-e-available"), d.extract.providers, d.extract.providers_fallback, "extract", allProviders);
1764
+
1765
+ const tbody = byId("cfg-providers-tbody");
1766
+ tbody.replaceChildren();
1767
+ for (const p of allProviders) {
1768
+ const tr = element("tr");
1769
+ const tdId = element("td", "mono", p.id);
1770
+ const tdType = element("td", "mono", p.type);
1771
+ const tdCaps = element("td", "mono", p.capabilities.join(", "));
1772
+ const tdCred = element("td");
1773
+ tdCred.append(
1774
+ element("span", "badge " + (p.credentialSource !== "missing" ? "status-ok" : "status-warn"), credSourceLabel(p.credentialSource)),
1775
+ );
1776
+ if (p.config.apiKeyEnv) {
1777
+ tdCred.append(element("span", "badge badge-muted", "env:" + p.config.apiKeyEnv));
1778
+ }
1779
+
1780
+ const tdEnd = element("td", "mono");
1781
+ tdEnd.textContent = p.config.effectiveBaseUrl || "—";
1782
+ if (p.config.searchFilterMode) {
1783
+ tdEnd.append(element("span", "badge badge-muted", "filter:" + p.config.searchFilterMode));
1784
+ }
1785
+
1786
+ const tdDraft = element("td");
1787
+ if (p.draftPatch) {
1788
+ tdDraft.append(element("span", "badge status-warn", "待保存修改"));
1789
+ } else {
1790
+ tdDraft.append(element("span", "hint", "已同步"));
1791
+ }
1792
+
1793
+ const tdAct = element("td");
1794
+ const editBtn = element("button", "btn sm", "编辑实例");
1795
+ editBtn.type = "button";
1796
+ editBtn.addEventListener("click", () => openProviderEditor(p.id, false));
1797
+ tdAct.append(editBtn);
1798
+
1799
+ tr.append(tdId, tdType, tdCaps, tdCred, tdEnd, tdDraft, tdAct);
1800
+ tbody.append(tr);
1801
+ }
1802
+ }
1803
+
1804
+ function updateProviderEditorTypeFields(type) {
1805
+ const ov = state.overview;
1806
+ const typeInfo = ov && ov.providerTypes ? ov.providerTypes.find((t) => t.type === type) : null;
1807
+ const supportsFilter = typeInfo ? typeInfo.supportsFilterMode : (type === "anysearch" || type === "xcrawl");
1808
+ const isXai = typeInfo ? typeInfo.isXai : (type === "xai_x_search" || type === "xai_web_search");
1809
+ byId("pe-filter-wrap").hidden = !supportsFilter;
1810
+ byId("pe-authjson-wrap").hidden = !isXai;
1811
+ byId("pe-authjson-env-wrap").hidden = !isXai;
1812
+ byId("pe-model-wrap").hidden = !isXai;
1813
+ byId("pe-model-env-wrap").hidden = !isXai;
1814
+ }
1815
+
1816
+ function openProviderEditor(id, isNew) {
1817
+ const ov = state.overview;
1818
+ if (!ov || !state.draft) return;
1819
+ state.editingProviderId = id;
1820
+ state.editingIsNew = isNew;
1821
+ byId("pe-error").hidden = true;
1822
+
1823
+ const typeSelect = byId("pe-type");
1824
+ typeSelect.replaceChildren();
1825
+ for (const pt of ov.providerTypes) {
1826
+ const opt = element("option", null, pt.type + " (" + pt.capabilities.join("/") + ")");
1827
+ opt.value = pt.type;
1828
+ typeSelect.append(opt);
1829
+ }
1830
+
1831
+ const allProviders = getEffectiveProvidersList();
1832
+ const existing = !isNew ? allProviders.find((p) => p.id === id) : null;
1833
+ const patch = (!isNew && state.draft.providerPatches[id]) || null;
1834
+
1835
+ byId("pe-title").textContent = isNew ? "新增自定义 Provider 实例" : "编辑实例:" + id;
1836
+ const idInput = byId("pe-id");
1837
+ idInput.value = isNew ? "" : id;
1838
+ idInput.readOnly = !isNew;
1839
+
1840
+ const selectedType = patch ? patch.type : existing ? existing.type : ov.providerTypes[0].type;
1841
+ typeSelect.value = selectedType;
1842
+ typeSelect.disabled = Boolean(existing && existing.isBuiltin);
1843
+
1844
+ const keyAction = patch ? (patch.apiKeyAction || "keep") : "keep";
1845
+ byId("pe-key-action").value = keyAction;
1846
+ byId("pe-key-field").hidden = keyAction !== "set";
1847
+ byId("pe-key").value = patch && patch.apiKey ? patch.apiKey : "";
1848
+
1849
+ byId("pe-key-env").value = patch && patch.apiKeyEnv !== undefined
1850
+ ? (patch.apiKeyEnv || "")
1851
+ : (existing && existing.config.apiKeyEnv) || "";
1852
+ byId("pe-base-url").value = patch && patch.baseUrl !== undefined
1853
+ ? (patch.baseUrl || "")
1854
+ : (existing && existing.config.configBaseUrl) || "";
1855
+ byId("pe-base-url-env").value = patch && patch.baseUrlEnv !== undefined
1856
+ ? (patch.baseUrlEnv || "")
1857
+ : (existing && existing.config.baseUrlEnv) || "";
1858
+ byId("pe-filter-mode").value = patch && patch.searchFilterMode
1859
+ ? patch.searchFilterMode
1860
+ : (existing && existing.config.searchFilterMode) || "strict";
1861
+ byId("pe-auth-json").value = patch && patch.authJson !== undefined
1862
+ ? (patch.authJson || "")
1863
+ : (existing && existing.config.authJson) || "";
1864
+ byId("pe-auth-json-env").value = patch && patch.authJsonEnv !== undefined
1865
+ ? (patch.authJsonEnv || "")
1866
+ : (existing && existing.config.authJsonEnv) || "";
1867
+ byId("pe-model").value = patch && patch.model !== undefined
1868
+ ? (patch.model || "")
1869
+ : (existing && existing.config.model) || "";
1870
+ byId("pe-model-env").value = patch && patch.modelEnv !== undefined
1871
+ ? (patch.modelEnv || "")
1872
+ : (existing && existing.config.modelEnv) || "";
1873
+
1874
+ updateProviderEditorTypeFields(selectedType);
1875
+ byId("pe-delete").hidden = isNew || Boolean(existing && existing.isBuiltin);
1876
+ byId("provider-editor").hidden = false;
1877
+ idInput.focus();
1878
+ }
1879
+
1880
+ function applyProviderEditorToDraft() {
1881
+ const rawId = byId("pe-id").value.trim().toLowerCase();
1882
+ const errNode = byId("pe-error");
1883
+ if (!rawId || rawId === "auto" || /\\s/.test(rawId)) {
1884
+ errNode.textContent = "实例 ID 不能为空、不能包含空白且不能为 auto。";
1885
+ errNode.hidden = false;
1886
+ return;
1887
+ }
1888
+ const type = byId("pe-type").value;
1889
+ const keyAction = byId("pe-key-action").value;
1890
+ const keyVal = byId("pe-key").value.trim();
1891
+ if (keyAction === "set" && !keyVal) {
1892
+ errNode.textContent = "选择写入新 API Key 时,必须填写非空 Key。";
1893
+ errNode.hidden = false;
1894
+ return;
1895
+ }
1896
+ const patch = {
1897
+ id: rawId,
1898
+ type,
1899
+ apiKeyAction: keyAction,
1900
+ ...(keyAction === "set" ? { apiKey: keyVal } : {}),
1901
+ apiKeyEnv: byId("pe-key-env").value.trim() || null,
1902
+ baseUrl: byId("pe-base-url").value.trim() || null,
1903
+ baseUrlEnv: byId("pe-base-url-env").value.trim() || null,
1904
+ };
1905
+ if (!byId("pe-filter-wrap").hidden) {
1906
+ patch.searchFilterMode = byId("pe-filter-mode").value;
1907
+ }
1908
+ if (!byId("pe-authjson-wrap").hidden) {
1909
+ patch.authJson = byId("pe-auth-json").value.trim() || null;
1910
+ patch.authJsonEnv = byId("pe-auth-json-env").value.trim() || null;
1911
+ patch.model = byId("pe-model").value.trim() || null;
1912
+ patch.modelEnv = byId("pe-model-env").value.trim() || null;
1913
+ }
1914
+ state.draft.providerPatches[rawId] = patch;
1915
+ state.draft.removedProviders = state.draft.removedProviders.filter((item) => item !== rawId);
1916
+ byId("provider-editor").hidden = true;
1917
+ setDirty(true);
1918
+ renderConfigTab();
1919
+ showConfigFeedback("ok", "实例 " + rawId + " 已暂存到配置草稿,点击顶部「保存并应用配置」正式生效。");
1920
+ }
1921
+
1922
+ function deleteCustomProviderFromDraft() {
1923
+ const id = state.editingProviderId;
1924
+ if (!id || !state.draft) return;
1925
+ delete state.draft.providerPatches[id];
1926
+ if (!state.draft.removedProviders.includes(id)) {
1927
+ state.draft.removedProviders.push(id);
1928
+ }
1929
+ state.draft.search.providers = state.draft.search.providers.filter((x) => x !== id);
1930
+ state.draft.search.providers_fallback = state.draft.search.providers_fallback.filter((x) => x !== id);
1931
+ state.draft.extract.providers = state.draft.extract.providers.filter((x) => x !== id);
1932
+ state.draft.extract.providers_fallback = state.draft.extract.providers_fallback.filter((x) => x !== id);
1933
+ byId("provider-editor").hidden = true;
1934
+ setDirty(true);
1935
+ renderConfigTab();
1936
+ showConfigFeedback("warn", "已从草稿移除自定义实例 " + id + ",请点击「保存并应用配置」完成提交。");
1937
+ }
1938
+
1939
+ function showConfigFeedback(kind, message) {
1940
+ const node = byId("cfg-feedback");
1941
+ node.className = "banner" + (kind === "ok" ? " ok" : kind === "warn" ? " warn" : "");
1942
+ node.textContent = message;
1943
+ node.hidden = false;
1944
+ }
1945
+
1946
+ function readNumberInputsToDraft() {
1947
+ if (!state.draft) return;
1948
+ const sLimit = Number(byId("cfg-s-limit").value);
1949
+ const sTimeout = Number(byId("cfg-s-timeout").value);
1950
+ const sAttempt = Number(byId("cfg-s-attempt").value);
1951
+ const sBytes = Number(byId("cfg-s-bytes").value);
1952
+ if (Number.isInteger(sLimit) && sLimit >= 1) state.draft.search.limit = sLimit;
1953
+ if (Number.isInteger(sTimeout) && sTimeout >= 1) state.draft.search.timeoutMs = sTimeout;
1954
+ if (Number.isInteger(sAttempt) && sAttempt >= 1) state.draft.search.attemptTimeoutMs = sAttempt;
1955
+ if (Number.isInteger(sBytes) && sBytes >= 1024) state.draft.search.maxResponseBytes = sBytes;
1956
+
1957
+ const eMinChars = Number(byId("cfg-e-minchars").value);
1958
+ const eTimeout = Number(byId("cfg-e-timeout").value);
1959
+ const eAttempt = Number(byId("cfg-e-attempt").value);
1960
+ const eBytes = Number(byId("cfg-e-bytes").value);
1961
+ if (Number.isInteger(eMinChars) && eMinChars >= 1) state.draft.extract.minContentCharacters = eMinChars;
1962
+ if (Number.isInteger(eTimeout) && eTimeout >= 1) state.draft.extract.timeoutMs = eTimeout;
1963
+ if (Number.isInteger(eAttempt) && eAttempt >= 1) state.draft.extract.attemptTimeoutMs = eAttempt;
1964
+ if (Number.isInteger(eBytes) && eBytes >= 1024) state.draft.extract.maxResponseBytes = eBytes;
1965
+ }
1966
+
1967
+ async function saveConfig() {
1968
+ if (!state.key || !state.draft) return;
1969
+ readNumberInputsToDraft();
1970
+ const btn = byId("save-config");
1971
+ btn.disabled = true;
1972
+ btn.textContent = "保存中…";
1973
+ try {
1974
+ const payload = {
1975
+ search: state.draft.search,
1976
+ extract: state.draft.extract,
1977
+ providers: Object.values(state.draft.providerPatches),
1978
+ removeProviders: state.draft.removedProviders,
1979
+ };
1980
+ const response = await fetch("/admin/api/config", {
1981
+ method: "PUT",
1982
+ headers: {
1983
+ Authorization: "Bearer " + state.key,
1984
+ "Content-Type": "application/json",
1985
+ },
1986
+ body: JSON.stringify(payload),
1987
+ });
1988
+ if (response.status === 401) {
1989
+ handleUnauthorized();
1990
+ return;
1991
+ }
1992
+ const data = await response.json();
1993
+ if (!response.ok) {
1994
+ const msg = data && data.error && data.error.message ? data.error.message : "保存失败 (HTTP " + response.status + ")";
1995
+ showConfigFeedback("error", msg);
1996
+ return;
1997
+ }
1998
+ state.overview = data.overview;
1999
+ state.draft = cloneOverviewToDraft(state.overview);
2000
+ setDirty(false);
2001
+ renderOverviewTab();
2002
+ renderConfigTab();
2003
+ void refreshReady();
2004
+ if (data.persisted) {
2005
+ showConfigFeedback("ok", "配置已更新并持久化写回配置文件。");
2006
+ } else {
2007
+ showConfigFeedback("warn", data.persistWarning || "配置已在内存生效(只读挂载未写入磁盘)。");
2008
+ }
2009
+ } catch (error) {
2010
+ const detail = error instanceof Error ? error.message : String(error);
2011
+ showConfigFeedback("error", "保存请求异常:" + detail);
2012
+ } finally {
2013
+ btn.disabled = false;
2014
+ btn.textContent = "保存并应用配置";
2015
+ }
2016
+ }
2017
+
2018
+ async function reloadConfigFromDisk() {
2019
+ if (!state.key) return;
2020
+ const btn = byId("reload-config");
2021
+ btn.disabled = true;
2022
+ try {
2023
+ const response = await fetch("/admin/api/config/reload", {
2024
+ method: "POST",
2025
+ headers: { Authorization: "Bearer " + state.key },
2026
+ });
2027
+ if (response.status === 401) {
2028
+ handleUnauthorized();
2029
+ return;
2030
+ }
2031
+ const data = await response.json();
2032
+ if (!response.ok) {
2033
+ const msg = data && data.error && data.error.message ? data.error.message : "重载失败";
2034
+ showConfigFeedback("error", msg);
2035
+ return;
2036
+ }
2037
+ state.overview = data.overview;
2038
+ state.draft = cloneOverviewToDraft(state.overview);
2039
+ setDirty(false);
2040
+ renderOverviewTab();
2041
+ renderConfigTab();
2042
+ void refreshReady();
2043
+ showConfigFeedback("ok", "已从磁盘重新加载最新配置。");
2044
+ } catch (error) {
2045
+ const detail = error instanceof Error ? error.message : String(error);
2046
+ showConfigFeedback("error", "重载失败:" + detail);
2047
+ } finally {
2048
+ btn.disabled = false;
2049
+ }
2050
+ }
2051
+
2052
+ async function clearRecords() {
2053
+ if (!state.key) return;
2054
+ const btn = byId("clear-records");
2055
+ btn.disabled = true;
2056
+ try {
2057
+ const response = await fetch("/admin/api/records", {
2058
+ method: "DELETE",
2059
+ headers: { Authorization: "Bearer " + state.key },
2060
+ });
2061
+ if (response.status === 401) {
2062
+ handleUnauthorized();
2063
+ return;
2064
+ }
2065
+ if (response.ok) {
2066
+ state.records = [];
2067
+ state.expandedIds.clear();
2068
+ state.expandedPreviews.clear();
2069
+ renderRecordsTab();
2070
+ }
2071
+ } finally {
2072
+ btn.disabled = false;
2073
+ }
2074
+ }
2075
+
2076
+ async function resetStats() {
2077
+ if (!state.key) return;
2078
+ const btn = byId("reset-stats");
2079
+ btn.disabled = true;
2080
+ try {
2081
+ const response = await fetch("/admin/api/stats/reset", {
2082
+ method: "POST",
2083
+ headers: { Authorization: "Bearer " + state.key },
2084
+ });
2085
+ if (response.status === 401) {
2086
+ handleUnauthorized();
2087
+ return;
2088
+ }
2089
+ if (response.ok) {
2090
+ await loadOverview();
2091
+ }
2092
+ } finally {
2093
+ btn.disabled = false;
2094
+ }
2095
+ }
2096
+
2097
+ function exportRecordsJson() {
2098
+ const visible = state.records.filter(matches);
2099
+ const blob = new Blob([JSON.stringify(visible, null, 2)], { type: "application/json" });
2100
+ const url = URL.createObjectURL(blob);
2101
+ const link = document.createElement("a");
2102
+ link.href = url;
2103
+ link.download = "gateway-records-" + new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-") + ".json";
2104
+ document.body.append(link);
2105
+ link.click();
2106
+ link.remove();
2107
+ URL.revokeObjectURL(url);
2108
+ }
2109
+
2110
+ function showBanner(message) {
2111
+ const banner = byId("banner");
2112
+ if (banner.textContent !== message) banner.textContent = message;
2113
+ banner.hidden = false;
2114
+ }
2115
+
2116
+ function hideBanner() {
2117
+ byId("banner").hidden = true;
2118
+ }
2119
+
2120
+ function switchTab(tab) {
2121
+ state.tab = tab;
2122
+ document.querySelectorAll(".nav-tab").forEach((btn) => {
2123
+ btn.setAttribute("aria-selected", String(btn.dataset.tab === tab));
2124
+ });
2125
+ byId("tab-records").hidden = tab !== "records";
2126
+ byId("tab-overview").hidden = tab !== "overview";
2127
+ byId("tab-config").hidden = tab !== "config";
2128
+ if (tab === "overview" || tab === "config") {
2129
+ void loadOverview();
2130
+ }
2131
+ }
2132
+
2133
+ function schedule() {
2134
+ if (state.timer) {
2135
+ clearInterval(state.timer);
2136
+ state.timer = 0;
2137
+ }
2138
+ if (byId("auto-refresh").checked) {
2139
+ state.timer = setInterval(() => {
2140
+ if (!document.hidden && state.key) {
2141
+ if (state.tab === "records") void load();
2142
+ else if (state.tab === "overview") void loadOverview();
2143
+ }
2144
+ }, REFRESH_MS);
2145
+ }
2146
+ }
2147
+
2148
+ async function loadOverview() {
2149
+ if (!state.key) return;
2150
+ try {
2151
+ const response = await fetch("/admin/api/overview", {
2152
+ headers: { Authorization: "Bearer " + state.key },
2153
+ cache: "no-store",
2154
+ });
2155
+ if (response.status === 401) {
2156
+ handleUnauthorized();
2157
+ return;
2158
+ }
2159
+ if (!response.ok) return;
2160
+ state.overview = await response.json();
2161
+ renderOverviewTab();
2162
+ if (!state.dirty) {
2163
+ state.draft = cloneOverviewToDraft(state.overview);
2164
+ renderConfigTab();
2165
+ }
2166
+ } catch {
2167
+ // Ignore transient overview errors during background refresh.
2168
+ }
2169
+ }
2170
+
2171
+ async function load(manual = false) {
2172
+ if (!state.key || state.loading) return;
2173
+ state.loading = true;
2174
+ const button = byId("refresh");
2175
+ if (manual) button.disabled = true;
2176
+ try {
2177
+ const response = await fetch("/admin/api/records?limit=" + RETENTION, {
2178
+ headers: { Authorization: "Bearer " + state.key },
2179
+ cache: "no-store",
2180
+ });
2181
+ if (response.status === 401) {
2182
+ handleUnauthorized();
2183
+ return;
2184
+ }
2185
+ if (!response.ok) throw new Error("HTTP " + response.status);
2186
+ const payload = await response.json();
2187
+ state.records = Array.isArray(payload.records) ? payload.records : [];
2188
+ hideBanner();
2189
+ renderRecordsTab();
2190
+ void refreshReady();
2191
+ if (!state.overview) void loadOverview();
2192
+ } catch (error) {
2193
+ const detail = error instanceof Error ? error.message : String(error);
2194
+ showBanner("读取请求记录失败:" + detail + "。面板会继续自动重试。");
2195
+ } finally {
2196
+ state.loading = false;
2197
+ if (manual) button.disabled = false;
2198
+ }
2199
+ }
2200
+
2201
+ function showLogin(message) {
2202
+ byId("panel-view").hidden = true;
2203
+ byId("nav-tabs").hidden = true;
2204
+ byId("login-view").hidden = false;
2205
+ byId("logout").hidden = true;
2206
+ byId("persist-pill").hidden = true;
2207
+ byId("key-input").value = "";
2208
+ byId("login-error").hidden = !message;
2209
+ if (message) byId("login-error").textContent = message;
2210
+ byId("key-input").focus();
2211
+ }
2212
+
2213
+ function showPanel() {
2214
+ byId("login-view").hidden = true;
2215
+ byId("panel-view").hidden = false;
2216
+ byId("nav-tabs").hidden = false;
2217
+ byId("logout").hidden = false;
2218
+ byId("login-error").hidden = true;
2219
+ schedule();
2220
+ }
2221
+
2222
+ function handleUnauthorized() {
2223
+ clearStoredKey();
2224
+ state.key = "";
2225
+ state.records = [];
2226
+ state.expandedIds.clear();
2227
+ state.expandedPreviews.clear();
2228
+ state.overview = null;
2229
+ state.draft = null;
2230
+ renderRecordsTab();
2231
+ showLogin("Key 已失效,请重新输入。");
2232
+ }
2233
+
2234
+ async function onLogin(event) {
2235
+ event.preventDefault();
2236
+ const input = byId("key-input");
2237
+ const key = input.value.trim();
2238
+ if (key.length < MIN_KEY_LENGTH) {
2239
+ byId("login-error").textContent = "Key 至少需要 " + MIN_KEY_LENGTH + " 个字符。";
2240
+ byId("login-error").hidden = false;
2241
+ input.focus();
2242
+ return;
2243
+ }
2244
+ const button = byId("login-submit");
2245
+ button.disabled = true;
2246
+ button.textContent = "验证中…";
2247
+ try {
2248
+ const response = await fetch("/admin/api/records?limit=1", {
2249
+ headers: { Authorization: "Bearer " + key },
2250
+ cache: "no-store",
2251
+ });
2252
+ if (response.status === 401) {
2253
+ byId("login-error").textContent = "Key 无效,或与当前网关不匹配。";
2254
+ byId("login-error").hidden = false;
2255
+ input.select();
2256
+ return;
2257
+ }
2258
+ if (!response.ok) {
2259
+ byId("login-error").textContent = "网关返回 HTTP " + response.status + ",暂时无法读取记录。";
2260
+ byId("login-error").hidden = false;
2261
+ return;
2262
+ }
2263
+ state.key = key;
2264
+ storeKey(key);
2265
+ showPanel();
2266
+ await Promise.all([load(), loadOverview()]);
2267
+ } catch {
2268
+ byId("login-error").textContent = "无法连接网关,请检查网络后重试。";
2269
+ byId("login-error").hidden = false;
2270
+ } finally {
2271
+ button.disabled = false;
2272
+ button.textContent = "进入后台";
2273
+ }
2274
+ }
2275
+
2276
+ function logout() {
2277
+ clearStoredKey();
2278
+ state.key = "";
2279
+ state.records = [];
2280
+ state.expandedIds.clear();
2281
+ state.expandedPreviews.clear();
2282
+ state.overview = null;
2283
+ state.draft = null;
2284
+ state.text = "";
2285
+ byId("filter-text").value = "";
2286
+ renderRecordsTab();
2287
+ showLogin("");
2288
+ }
2289
+
2290
+ function bind() {
2291
+ byId("login-form").addEventListener("submit", (event) => void onLogin(event));
2292
+ byId("logout").addEventListener("click", logout);
2293
+ byId("refresh").addEventListener("click", () => void load(true));
2294
+ byId("export-records").addEventListener("click", exportRecordsJson);
2295
+ byId("clear-records").addEventListener("click", () => void clearRecords());
2296
+ byId("auto-refresh").addEventListener("change", schedule);
2297
+ byId("filter-text").addEventListener("input", () => {
2298
+ state.text = byId("filter-text").value;
2299
+ renderRecordsTab();
2300
+ });
2301
+ byId("filter-provider").addEventListener("change", () => {
2302
+ state.providerFilter = byId("filter-provider").value;
2303
+ renderRecordsTab();
2304
+ });
2305
+ document.querySelectorAll(".nav-tab").forEach((btn) => {
2306
+ btn.addEventListener("click", () => switchTab(btn.dataset.tab));
2307
+ });
2308
+ document.querySelectorAll("[data-route]").forEach((button) => {
2309
+ button.addEventListener("click", () => {
2310
+ state.route = button.dataset.route;
2311
+ document.querySelectorAll("[data-route]").forEach((peer) => {
2312
+ peer.setAttribute("aria-pressed", String(peer === button));
2313
+ });
2314
+ renderRecordsTab();
2315
+ });
2316
+ });
2317
+ document.querySelectorAll("[data-status]").forEach((button) => {
2318
+ button.addEventListener("click", () => {
2319
+ state.status = button.dataset.status;
2320
+ document.querySelectorAll("[data-status]").forEach((peer) => {
2321
+ peer.setAttribute("aria-pressed", String(peer === button));
2322
+ });
2323
+ renderRecordsTab();
2324
+ });
2325
+ });
2326
+ document.querySelectorAll("[data-pfilter]").forEach((button) => {
2327
+ button.addEventListener("click", () => {
2328
+ state.pfilter = button.dataset.pfilter;
2329
+ document.querySelectorAll("[data-pfilter]").forEach((peer) => {
2330
+ peer.setAttribute("aria-pressed", String(peer === button));
2331
+ });
2332
+ renderOverviewTab();
2333
+ });
2334
+ });
2335
+ byId("refresh-overview").addEventListener("click", () => void loadOverview());
2336
+ byId("reset-stats").addEventListener("click", () => void resetStats());
2337
+ byId("jump-config").addEventListener("click", () => switchTab("config"));
2338
+
2339
+ ["cfg-s-limit", "cfg-s-timeout", "cfg-s-attempt", "cfg-s-bytes", "cfg-e-minchars", "cfg-e-timeout", "cfg-e-attempt", "cfg-e-bytes"].forEach((id) => {
2340
+ byId(id).addEventListener("input", () => {
2341
+ readNumberInputsToDraft();
2342
+ setDirty(true);
2343
+ });
2344
+ });
2345
+
2346
+ byId("save-config").addEventListener("click", () => void saveConfig());
2347
+ byId("reload-config").addEventListener("click", () => void reloadConfigFromDisk());
2348
+ byId("reset-config-draft").addEventListener("click", () => {
2349
+ if (state.overview) {
2350
+ state.draft = cloneOverviewToDraft(state.overview);
2351
+ setDirty(false);
2352
+ byId("provider-editor").hidden = true;
2353
+ byId("cfg-feedback").hidden = true;
2354
+ renderConfigTab();
2355
+ }
2356
+ });
2357
+ byId("add-provider-btn").addEventListener("click", () => openProviderEditor("", true));
2358
+ byId("pe-cancel").addEventListener("click", () => { byId("provider-editor").hidden = true; });
2359
+ byId("pe-type").addEventListener("change", () => updateProviderEditorTypeFields(byId("pe-type").value));
2360
+ byId("pe-key-action").addEventListener("change", () => {
2361
+ byId("pe-key-field").hidden = byId("pe-key-action").value !== "set";
2362
+ });
2363
+ byId("pe-apply").addEventListener("click", applyProviderEditorToDraft);
2364
+ byId("pe-delete").addEventListener("click", deleteCustomProviderFromDraft);
2365
+
2366
+ document.addEventListener("visibilitychange", () => {
2367
+ if (!document.hidden && state.key) {
2368
+ if (state.tab === "records") void load();
2369
+ else if (state.tab === "overview") void loadOverview();
2370
+ }
2371
+ });
2372
+ }
2373
+
2374
+ function boot() {
2375
+ bind();
2376
+ const hashKey = takeKeyFromHash();
2377
+ state.key = hashKey || readStoredKey();
2378
+ void refreshReady();
2379
+ if (!state.key) {
2380
+ showLogin("");
2381
+ return;
2382
+ }
2383
+ if (state.key.length < MIN_KEY_LENGTH) {
2384
+ state.key = "";
2385
+ showLogin("Key 至少需要 " + MIN_KEY_LENGTH + " 个字符。");
2386
+ return;
2387
+ }
2388
+ if (hashKey) storeKey(state.key);
2389
+ showPanel();
2390
+ void load();
2391
+ void loadOverview();
2392
+ }
2393
+
2394
+ boot();
2395
+ })();
2396
+ </script>
2397
+ </body>
2398
+ </html>`;
2399
+ export function renderPanelHtml(options) {
2400
+ return PANEL_TEMPLATE.replaceAll("__NONCE__", options.nonce)
2401
+ .replaceAll("__RETENTION__", String(options.retention))
2402
+ .replaceAll("__MIN_KEY_LENGTH__", String(options.minKeyLength))
2403
+ .replaceAll("__VERSION__", options.version);
2404
+ }
2405
+ //# sourceMappingURL=panel.js.map