web-access-cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -10
- package/README_CN.md +67 -10
- package/THIRD_PARTY_NOTICES.md +0 -13
- package/dist/config/config.js +10 -2
- package/dist/config/config.js.map +1 -1
- package/dist/core/router.js +4 -2
- package/dist/core/router.js.map +1 -1
- package/dist/core/schema.js +1 -1
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -1
- package/dist/gateway/panel.js +791 -0
- package/dist/gateway/panel.js.map +1 -0
- package/dist/gateway/records.js +87 -0
- package/dist/gateway/records.js.map +1 -0
- package/dist/gateway/schema.js +115 -0
- package/dist/gateway/schema.js.map +1 -0
- package/dist/gateway/server.js +497 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/gateway/service.js +417 -0
- package/dist/gateway/service.js.map +1 -0
- package/dist/providers/anysearch.js +5 -3
- package/dist/providers/anysearch.js.map +1 -1
- package/dist/providers/deepseek.js +173 -106
- package/dist/providers/deepseek.js.map +1 -1
- package/dist/providers/registry.js +2 -0
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/search.js +4 -2
- package/dist/providers/search.js.map +1 -1
- package/dist/providers/xcrawl.js +1 -1
- package/dist/providers/xcrawl.js.map +1 -1
- package/dist/providers/yuanbao.js +214 -0
- package/dist/providers/yuanbao.js.map +1 -0
- package/dist/server.js +127 -0
- package/dist/server.js.map +1 -0
- package/dist/transport/http.js +8 -0
- package/dist/transport/http.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
- package/schemas/config.schema.json +4 -0
- package/schemas/gatewayError.schema.json +33 -0
- package/schemas/gatewayFetchRequest.schema.json +13 -0
- package/schemas/gatewayFetchResponse.schema.json +16 -0
- package/schemas/gatewaySearchRequest.schema.json +37 -0
- package/schemas/gatewaySearchResponse.schema.json +81 -0
- package/schemas/output.schema.json +52 -0
- package/schemas/searchRequest.schema.json +1 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
export const PANEL_PATH = "/panel";
|
|
2
|
+
export const RECORDS_PATH = "/admin/api/records";
|
|
3
|
+
/**
|
|
4
|
+
* The panel is a self-contained document served from memory. It has no build
|
|
5
|
+
* assets, no third-party scripts and no secrets; the records API is protected by
|
|
6
|
+
* the same Bearer key as the tool endpoints.
|
|
7
|
+
*/
|
|
8
|
+
const PANEL_TEMPLATE = `<!doctype html>
|
|
9
|
+
<html lang="zh-CN">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13
|
+
<meta name="robots" content="noindex, nofollow">
|
|
14
|
+
<meta name="color-scheme" content="light dark">
|
|
15
|
+
<title>Web Access 网关 · 请求记录</title>
|
|
16
|
+
<style nonce="__NONCE__">
|
|
17
|
+
:root {
|
|
18
|
+
color-scheme: light dark;
|
|
19
|
+
--bg: #f4f2ec;
|
|
20
|
+
--surface: #fbfaf6;
|
|
21
|
+
--surface-strong: #eeece4;
|
|
22
|
+
--border: #d8d4c8;
|
|
23
|
+
--text: #201f1b;
|
|
24
|
+
--muted: #6d675a;
|
|
25
|
+
--accent: #0f766e;
|
|
26
|
+
--accent-soft: #d9ece7;
|
|
27
|
+
--ok: #166534;
|
|
28
|
+
--ok-soft: #dcfce7;
|
|
29
|
+
--warn: #92400e;
|
|
30
|
+
--warn-soft: #fef3c7;
|
|
31
|
+
--err: #991b1b;
|
|
32
|
+
--err-soft: #fee2e2;
|
|
33
|
+
--sans: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
34
|
+
--mono: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
35
|
+
}
|
|
36
|
+
@media (prefers-color-scheme: dark) {
|
|
37
|
+
:root {
|
|
38
|
+
--bg: #131311;
|
|
39
|
+
--surface: #1b1b18;
|
|
40
|
+
--surface-strong: #252521;
|
|
41
|
+
--border: #3a3a33;
|
|
42
|
+
--text: #ece9df;
|
|
43
|
+
--muted: #a7a093;
|
|
44
|
+
--accent: #5eead4;
|
|
45
|
+
--accent-soft: rgba(94, 234, 212, 0.13);
|
|
46
|
+
--ok: #86efac;
|
|
47
|
+
--ok-soft: rgba(134, 239, 172, 0.12);
|
|
48
|
+
--warn: #fcd34d;
|
|
49
|
+
--warn-soft: rgba(252, 211, 77, 0.12);
|
|
50
|
+
--err: #fca5a5;
|
|
51
|
+
--err-soft: rgba(252, 165, 165, 0.12);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
* { box-sizing: border-box; }
|
|
55
|
+
html { background: var(--bg); }
|
|
56
|
+
body {
|
|
57
|
+
margin: 0;
|
|
58
|
+
background: var(--bg);
|
|
59
|
+
color: var(--text);
|
|
60
|
+
font: 14px/1.55 var(--sans);
|
|
61
|
+
-webkit-font-smoothing: antialiased;
|
|
62
|
+
}
|
|
63
|
+
button, input { font: inherit; color: inherit; }
|
|
64
|
+
a { color: var(--accent); text-underline-offset: 2px; }
|
|
65
|
+
h1, h2, p { margin-top: 0; }
|
|
66
|
+
[hidden] { display: none !important; }
|
|
67
|
+
.topbar {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-wrap: wrap;
|
|
70
|
+
gap: 14px 24px;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
padding: 16px clamp(16px, 4vw, 40px);
|
|
74
|
+
border-bottom: 1px solid var(--border);
|
|
75
|
+
background: var(--surface);
|
|
76
|
+
}
|
|
77
|
+
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
78
|
+
.brand-mark {
|
|
79
|
+
display: grid;
|
|
80
|
+
place-items: center;
|
|
81
|
+
width: 38px;
|
|
82
|
+
height: 38px;
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
border-radius: 7px;
|
|
85
|
+
background: var(--surface-strong);
|
|
86
|
+
color: var(--accent);
|
|
87
|
+
font: 600 12px var(--mono);
|
|
88
|
+
letter-spacing: 0.05em;
|
|
89
|
+
}
|
|
90
|
+
h1 { margin: 0; font-size: 16px; letter-spacing: 0.01em; }
|
|
91
|
+
.brand p { margin: 1px 0 0; color: var(--muted); font-size: 12px; }
|
|
92
|
+
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
|
93
|
+
.status-pill {
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 7px;
|
|
97
|
+
min-height: 30px;
|
|
98
|
+
padding: 0 10px;
|
|
99
|
+
border: 1px solid var(--border);
|
|
100
|
+
border-radius: 999px;
|
|
101
|
+
background: var(--surface-strong);
|
|
102
|
+
color: var(--muted);
|
|
103
|
+
font-size: 12px;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
.status-pill .dot {
|
|
107
|
+
width: 7px;
|
|
108
|
+
height: 7px;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
background: var(--muted);
|
|
111
|
+
}
|
|
112
|
+
.status-pill[data-state="ready"] .dot { background: var(--ok); }
|
|
113
|
+
.status-pill[data-state="not-ready"] .dot { background: var(--warn); }
|
|
114
|
+
.status-pill[data-state="offline"] .dot { background: var(--err); }
|
|
115
|
+
main {
|
|
116
|
+
max-width: 1180px;
|
|
117
|
+
margin: 0 auto;
|
|
118
|
+
padding: 24px clamp(16px, 4vw, 40px) 64px;
|
|
119
|
+
}
|
|
120
|
+
.btn {
|
|
121
|
+
min-height: 40px;
|
|
122
|
+
padding: 0 14px;
|
|
123
|
+
border: 1px solid var(--border);
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
background: var(--surface);
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
white-space: nowrap;
|
|
128
|
+
}
|
|
129
|
+
.btn:hover { background: var(--surface-strong); }
|
|
130
|
+
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
131
|
+
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
132
|
+
.btn.primary { border-color: var(--accent); background: var(--accent); color: var(--bg); font-weight: 600; }
|
|
133
|
+
.btn.ghost { background: transparent; }
|
|
134
|
+
.login { max-width: 540px; margin: 7vh auto 0; padding: clamp(20px, 4vw, 32px); border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
|
|
135
|
+
.login h2 { margin-bottom: 8px; font-size: 19px; }
|
|
136
|
+
.login > p { color: var(--muted); margin-bottom: 20px; }
|
|
137
|
+
.login label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; }
|
|
138
|
+
.key-row { display: flex; gap: 8px; }
|
|
139
|
+
.key-row input {
|
|
140
|
+
flex: 1;
|
|
141
|
+
min-width: 0;
|
|
142
|
+
min-height: 40px;
|
|
143
|
+
padding: 0 12px;
|
|
144
|
+
border: 1px solid var(--border);
|
|
145
|
+
border-radius: 6px;
|
|
146
|
+
background: var(--bg);
|
|
147
|
+
}
|
|
148
|
+
.key-row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
149
|
+
.hint { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
|
|
150
|
+
.form-error { margin: 12px 0 0; color: var(--err); font-size: 13px; }
|
|
151
|
+
.stats {
|
|
152
|
+
display: grid;
|
|
153
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
154
|
+
overflow: hidden;
|
|
155
|
+
border: 1px solid var(--border);
|
|
156
|
+
border-radius: 8px;
|
|
157
|
+
background: var(--surface);
|
|
158
|
+
}
|
|
159
|
+
.stat { padding: 13px 16px; border-left: 1px solid var(--border); min-width: 0; }
|
|
160
|
+
.stat:first-child { border-left: 0; }
|
|
161
|
+
.stat-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.05em; }
|
|
162
|
+
.stat strong { display: block; margin-top: 3px; font: 600 20px/1.25 var(--mono); }
|
|
163
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin: 18px 0 12px; }
|
|
164
|
+
.segmented { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); }
|
|
165
|
+
.segmented button {
|
|
166
|
+
min-height: 38px;
|
|
167
|
+
padding: 0 12px;
|
|
168
|
+
border: 0;
|
|
169
|
+
border-left: 1px solid var(--border);
|
|
170
|
+
background: transparent;
|
|
171
|
+
color: var(--muted);
|
|
172
|
+
font-size: 12px;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
}
|
|
175
|
+
.segmented button:first-child { border-left: 0; }
|
|
176
|
+
.segmented button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
|
177
|
+
.segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
178
|
+
#filter-text {
|
|
179
|
+
flex: 1 1 220px;
|
|
180
|
+
min-width: 160px;
|
|
181
|
+
min-height: 40px;
|
|
182
|
+
padding: 0 12px;
|
|
183
|
+
border: 1px solid var(--border);
|
|
184
|
+
border-radius: 6px;
|
|
185
|
+
background: var(--surface);
|
|
186
|
+
}
|
|
187
|
+
#filter-text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
188
|
+
.switch { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 4px; color: var(--muted); font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
189
|
+
.switch input { accent-color: var(--accent); width: 15px; height: 15px; }
|
|
190
|
+
.list-head { display: flex; justify-content: space-between; gap: 12px; margin: 4px 0 8px; color: var(--muted); font-size: 12px; }
|
|
191
|
+
.banner { margin: 0 0 12px; padding: 9px 12px; border: 1px solid var(--err); border-left-width: 3px; border-radius: 6px; background: var(--err-soft); color: var(--err); font-size: 13px; }
|
|
192
|
+
.records { margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); list-style: none; }
|
|
193
|
+
.record + .record { border-top: 1px solid var(--border); }
|
|
194
|
+
.record-head {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-wrap: wrap;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: 5px 10px;
|
|
199
|
+
width: 100%;
|
|
200
|
+
min-height: 48px;
|
|
201
|
+
padding: 9px 14px;
|
|
202
|
+
border: 0;
|
|
203
|
+
background: transparent;
|
|
204
|
+
text-align: left;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
.record-head:hover { background: var(--surface-strong); }
|
|
208
|
+
.record-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
209
|
+
.record-time { flex: 0 0 auto; color: var(--muted); font: 12px var(--mono); white-space: nowrap; }
|
|
210
|
+
.badge {
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
flex: 0 0 auto;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
padding: 1px 6px;
|
|
215
|
+
border: 1px solid transparent;
|
|
216
|
+
border-radius: 4px;
|
|
217
|
+
font: 600 11px/1.7 var(--mono);
|
|
218
|
+
white-space: nowrap;
|
|
219
|
+
}
|
|
220
|
+
.route-search { background: var(--accent-soft); color: var(--accent); }
|
|
221
|
+
.route-fetch { border-color: var(--border); background: var(--surface-strong); color: var(--muted); }
|
|
222
|
+
.status-ok { background: var(--ok-soft); color: var(--ok); }
|
|
223
|
+
.status-warn { background: var(--warn-soft); color: var(--warn); }
|
|
224
|
+
.status-error { background: var(--err-soft); color: var(--err); }
|
|
225
|
+
.record-summary { flex: 1 1 260px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
226
|
+
.record-provider { flex: 0 1 auto; max-width: 110px; overflow: hidden; color: var(--muted); font: 12px var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
|
227
|
+
.record-duration, .record-results { flex: 0 0 auto; color: var(--muted); font: 12px var(--mono); text-align: right; white-space: nowrap; }
|
|
228
|
+
.record-duration { min-width: 62px; }
|
|
229
|
+
.record-results { min-width: 48px; }
|
|
230
|
+
.chevron { flex: 0 0 auto; margin-left: auto; color: var(--muted); transition: transform 0.15s ease; }
|
|
231
|
+
.record.expanded .chevron { transform: rotate(90deg); }
|
|
232
|
+
.record-detail { padding: 13px 16px 16px; border-top: 1px dashed var(--border); background: var(--bg); }
|
|
233
|
+
.record-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; margin: 0; }
|
|
234
|
+
.record-meta dt { color: var(--muted); font-size: 12px; }
|
|
235
|
+
.record-meta dd { margin: 0; overflow-wrap: anywhere; font: 12px/1.5 var(--mono); }
|
|
236
|
+
.detail-label { margin: 16px 0 6px; color: var(--muted); font-size: 11px; letter-spacing: 0.05em; }
|
|
237
|
+
.result-list { display: grid; gap: 7px; margin: 0; padding-left: 20px; }
|
|
238
|
+
.result-title { display: block; overflow-wrap: anywhere; }
|
|
239
|
+
.result-url { display: block; color: var(--muted); font: 11px/1.45 var(--mono); overflow-wrap: anywhere; }
|
|
240
|
+
.empty { margin-top: 0; padding: 42px 20px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface); color: var(--muted); text-align: center; }
|
|
241
|
+
.empty strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 14px; }
|
|
242
|
+
.footnote { margin: 14px 0 0; color: var(--muted); font-size: 12px; }
|
|
243
|
+
@media (max-width: 680px) {
|
|
244
|
+
.stats { grid-template-columns: 1fr 1fr; }
|
|
245
|
+
.stat { border-top: 1px solid var(--border); }
|
|
246
|
+
.stat:nth-child(-n+2) { border-top: 0; }
|
|
247
|
+
.stat:nth-child(odd) { border-left: 0; }
|
|
248
|
+
.stat:nth-child(even) { border-left: 1px solid var(--border); }
|
|
249
|
+
.record-head { row-gap: 3px; }
|
|
250
|
+
.record-summary { order: 1; flex-basis: 100%; white-space: normal; }
|
|
251
|
+
.record-provider { order: 2; max-width: none; }
|
|
252
|
+
.record-duration { order: 3; min-width: 0; text-align: left; }
|
|
253
|
+
.record-results { order: 4; min-width: 0; text-align: left; }
|
|
254
|
+
.btn, .segmented button, #filter-text, .key-row input { min-height: 44px; }
|
|
255
|
+
.segmented button { padding: 0 13px; }
|
|
256
|
+
.key-row { flex-wrap: wrap; }
|
|
257
|
+
.key-row .btn { width: 100%; }
|
|
258
|
+
.login { margin-top: 3vh; }
|
|
259
|
+
}
|
|
260
|
+
@media (prefers-reduced-motion: reduce) {
|
|
261
|
+
* { transition: none !important; animation: none !important; }
|
|
262
|
+
}
|
|
263
|
+
</style>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<header class="topbar">
|
|
267
|
+
<div class="brand">
|
|
268
|
+
<span class="brand-mark" aria-hidden="true">WA</span>
|
|
269
|
+
<div>
|
|
270
|
+
<h1>Web Access 网关</h1>
|
|
271
|
+
<p>请求记录 · 最近 __RETENTION__ 条 · 重启后清空</p>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="topbar-actions">
|
|
275
|
+
<span class="status-pill" id="ready-pill" data-state="checking" role="status">
|
|
276
|
+
<span class="dot" aria-hidden="true"></span>
|
|
277
|
+
<span id="ready-text">检查中</span>
|
|
278
|
+
</span>
|
|
279
|
+
<button type="button" class="btn ghost" id="logout" hidden>退出</button>
|
|
280
|
+
</div>
|
|
281
|
+
</header>
|
|
282
|
+
<main>
|
|
283
|
+
<section class="login" id="login-view" hidden aria-labelledby="login-title">
|
|
284
|
+
<h2 id="login-title">查看请求记录</h2>
|
|
285
|
+
<p>这个面板只读取本机内存中的最近请求。请输入网关 API Key 继续。</p>
|
|
286
|
+
<form id="login-form">
|
|
287
|
+
<label for="key-input">网关 API Key</label>
|
|
288
|
+
<div class="key-row">
|
|
289
|
+
<input id="key-input" name="key" type="password" autocomplete="off" spellcheck="false" required minlength="__MIN_KEY_LENGTH__" placeholder="至少 __MIN_KEY_LENGTH__ 个字符">
|
|
290
|
+
<button type="submit" class="btn primary" id="login-submit">进入面板</button>
|
|
291
|
+
</div>
|
|
292
|
+
<p class="hint">Key 只保存在当前标签页的 sessionStorage,不写入 URL;关闭标签页后清除。也可用 <code>#apikey=…</code> 链接打开,脚本会立即从地址栏移除它。</p>
|
|
293
|
+
<p class="form-error" id="login-error" role="alert" hidden></p>
|
|
294
|
+
</form>
|
|
295
|
+
</section>
|
|
296
|
+
<section id="panel-view" hidden aria-labelledby="panel-title">
|
|
297
|
+
<h2 id="panel-title" hidden>请求记录</h2>
|
|
298
|
+
<div class="stats" aria-label="概览">
|
|
299
|
+
<div class="stat"><span class="stat-label">已加载请求</span><strong id="stat-total">0</strong></div>
|
|
300
|
+
<div class="stat"><span class="stat-label">成功</span><strong id="stat-ok">0</strong></div>
|
|
301
|
+
<div class="stat"><span class="stat-label">失败</span><strong id="stat-bad">0</strong></div>
|
|
302
|
+
<div class="stat"><span class="stat-label">平均耗时</span><strong id="stat-avg">—</strong></div>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="toolbar">
|
|
305
|
+
<div class="segmented" role="group" aria-label="按请求类型筛选">
|
|
306
|
+
<button type="button" data-route="all" aria-pressed="true">全部</button>
|
|
307
|
+
<button type="button" data-route="search" aria-pressed="false">搜索</button>
|
|
308
|
+
<button type="button" data-route="fetch" aria-pressed="false">抓取</button>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="segmented" role="group" aria-label="按结果状态筛选">
|
|
311
|
+
<button type="button" data-status="all" aria-pressed="true">全部状态</button>
|
|
312
|
+
<button type="button" data-status="ok" aria-pressed="false">成功</button>
|
|
313
|
+
<button type="button" data-status="error" aria-pressed="false">失败</button>
|
|
314
|
+
</div>
|
|
315
|
+
<input id="filter-text" type="search" placeholder="筛选 query、URL、provider、错误码" aria-label="文本筛选">
|
|
316
|
+
<label class="switch"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
|
|
317
|
+
<button type="button" class="btn" id="refresh">刷新</button>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="list-head">
|
|
320
|
+
<span id="record-count">已加载 0 条</span>
|
|
321
|
+
<span>/v1/tools/search · /v1/tools/fetch</span>
|
|
322
|
+
</div>
|
|
323
|
+
<p class="banner" id="banner" role="alert" hidden></p>
|
|
324
|
+
<div class="empty" id="empty" hidden>
|
|
325
|
+
<strong id="empty-title">还没有请求记录</strong>
|
|
326
|
+
<span id="empty-text">调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示时间和结果。</span>
|
|
327
|
+
</div>
|
|
328
|
+
<ul class="records" id="records" aria-label="请求记录"></ul>
|
|
329
|
+
<p class="footnote">记录只保存在容器内存中,不写入磁盘,也不记录响应正文;重启网关后清空。面板版本 __VERSION__。</p>
|
|
330
|
+
</section>
|
|
331
|
+
<noscript><p class="banner">面板需要 JavaScript 才能读取请求记录。</p></noscript>
|
|
332
|
+
</main>
|
|
333
|
+
<script nonce="__NONCE__">
|
|
334
|
+
(() => {
|
|
335
|
+
"use strict";
|
|
336
|
+
const STORAGE_KEY = "web-access-gateway-key";
|
|
337
|
+
const RETENTION = __RETENTION__;
|
|
338
|
+
const MIN_KEY_LENGTH = __MIN_KEY_LENGTH__;
|
|
339
|
+
const REFRESH_MS = 3000;
|
|
340
|
+
const byId = (id) => document.getElementById(id);
|
|
341
|
+
const state = {
|
|
342
|
+
key: "",
|
|
343
|
+
records: [],
|
|
344
|
+
route: "all",
|
|
345
|
+
status: "all",
|
|
346
|
+
text: "",
|
|
347
|
+
loading: false,
|
|
348
|
+
timer: 0,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function readStoredKey() {
|
|
352
|
+
try {
|
|
353
|
+
return sessionStorage.getItem(STORAGE_KEY) || "";
|
|
354
|
+
} catch {
|
|
355
|
+
return "";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function storeKey(key) {
|
|
360
|
+
try {
|
|
361
|
+
sessionStorage.setItem(STORAGE_KEY, key);
|
|
362
|
+
} catch {
|
|
363
|
+
// Storage can be unavailable in hardened browser contexts.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function clearStoredKey() {
|
|
368
|
+
try {
|
|
369
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
370
|
+
} catch {
|
|
371
|
+
// Ignore storage failures.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Optional "#apikey=..." bootstrap for bookmarks. The fragment never reaches
|
|
376
|
+
// the server or Referer, and it is stripped from the address bar and history
|
|
377
|
+
// before any request is made, so a copied URL cannot leak the key later.
|
|
378
|
+
function takeKeyFromHash() {
|
|
379
|
+
const raw = location.hash.startsWith("#") ? location.hash.slice(1) : "";
|
|
380
|
+
if (!raw) return "";
|
|
381
|
+
let key = "";
|
|
382
|
+
let remaining = location.hash;
|
|
383
|
+
try {
|
|
384
|
+
const params = new URLSearchParams(raw);
|
|
385
|
+
if (params.has("apikey")) {
|
|
386
|
+
key = (params.get("apikey") || "").trim();
|
|
387
|
+
params.delete("apikey");
|
|
388
|
+
const rest = params.toString();
|
|
389
|
+
remaining = rest ? "#" + rest : "";
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
return "";
|
|
393
|
+
}
|
|
394
|
+
if (remaining === location.hash) return key;
|
|
395
|
+
history.replaceState(null, "", location.pathname + location.search + remaining);
|
|
396
|
+
return key;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function element(tag, className, content) {
|
|
400
|
+
const node = document.createElement(tag);
|
|
401
|
+
if (className) node.className = className;
|
|
402
|
+
if (content !== undefined && content !== null) node.textContent = String(content);
|
|
403
|
+
return node;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setReady(kind, label) {
|
|
407
|
+
const pill = byId("ready-pill");
|
|
408
|
+
if (!pill) return;
|
|
409
|
+
pill.dataset.state = kind;
|
|
410
|
+
const text = byId("ready-text");
|
|
411
|
+
if (text.textContent !== label) text.textContent = label;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function refreshReady() {
|
|
415
|
+
try {
|
|
416
|
+
const response = await fetch("/readyz", { cache: "no-store" });
|
|
417
|
+
if (response.ok) setReady("ready", "网关就绪");
|
|
418
|
+
else setReady("not-ready", "网关未就绪");
|
|
419
|
+
} catch {
|
|
420
|
+
setReady("offline", "连接失败");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function formatTime(value) {
|
|
425
|
+
const date = new Date(value);
|
|
426
|
+
if (Number.isNaN(date.getTime())) return "—";
|
|
427
|
+
return date.toLocaleString("zh-CN", {
|
|
428
|
+
month: "2-digit",
|
|
429
|
+
day: "2-digit",
|
|
430
|
+
hour: "2-digit",
|
|
431
|
+
minute: "2-digit",
|
|
432
|
+
second: "2-digit",
|
|
433
|
+
hour12: false,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function formatDuration(value) {
|
|
438
|
+
if (!Number.isFinite(value)) return "—";
|
|
439
|
+
if (value < 1000) return Math.round(value) + " ms";
|
|
440
|
+
return (value / 1000).toFixed(1) + " s";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function statusKind(status) {
|
|
444
|
+
if (status >= 200 && status < 300) return "ok";
|
|
445
|
+
if (status >= 500) return "error";
|
|
446
|
+
return "warn";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function routeLabel(route) {
|
|
450
|
+
if (route === "search") return "搜索";
|
|
451
|
+
if (route === "fetch") return "抓取";
|
|
452
|
+
return route;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function safeHref(value) {
|
|
456
|
+
try {
|
|
457
|
+
const url = new URL(value);
|
|
458
|
+
if (url.protocol === "http:" || url.protocol === "https:") return url.href;
|
|
459
|
+
} catch {
|
|
460
|
+
// Not an absolute URL; render it as text.
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function appendMeta(list, label, value) {
|
|
466
|
+
list.append(element("dt", null, label), element("dd", null, value));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function renderRecord(record) {
|
|
470
|
+
const kind = statusKind(record.status);
|
|
471
|
+
const item = element("li", "record");
|
|
472
|
+
item.dataset.status = kind;
|
|
473
|
+
|
|
474
|
+
const summaryText =
|
|
475
|
+
record.query ||
|
|
476
|
+
record.url ||
|
|
477
|
+
record.title ||
|
|
478
|
+
(record.code === "invalid_request_error"
|
|
479
|
+
? "(请求体未通过校验)"
|
|
480
|
+
: "(请求体未解析)");
|
|
481
|
+
const summary = element("span", "record-summary", summaryText);
|
|
482
|
+
summary.title = summaryText;
|
|
483
|
+
|
|
484
|
+
const head = element("button", "record-head");
|
|
485
|
+
head.type = "button";
|
|
486
|
+
head.setAttribute("aria-expanded", "false");
|
|
487
|
+
head.append(
|
|
488
|
+
element("span", "record-time", formatTime(record.at)),
|
|
489
|
+
element("span", "badge route-" + record.route, routeLabel(record.route)),
|
|
490
|
+
element("span", "badge status-" + kind, String(record.status)),
|
|
491
|
+
summary,
|
|
492
|
+
element("span", "record-provider", record.provider || "—"),
|
|
493
|
+
element("span", "record-duration", formatDuration(record.durationMs)),
|
|
494
|
+
);
|
|
495
|
+
if (record.resultCount !== undefined) {
|
|
496
|
+
head.append(
|
|
497
|
+
element("span", "record-results", record.resultCount + " 条"),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const chevron = element("span", "chevron", "›");
|
|
501
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
502
|
+
head.append(chevron);
|
|
503
|
+
|
|
504
|
+
const detail = element("div", "record-detail");
|
|
505
|
+
detail.hidden = true;
|
|
506
|
+
const meta = element("dl", "record-meta");
|
|
507
|
+
appendMeta(meta, "请求 ID", record.id || "—");
|
|
508
|
+
appendMeta(meta, "时间", formatTime(record.at));
|
|
509
|
+
appendMeta(meta, "耗时", formatDuration(record.durationMs));
|
|
510
|
+
appendMeta(meta, "Provider", record.provider || "—");
|
|
511
|
+
if (record.code) appendMeta(meta, "错误码", record.code);
|
|
512
|
+
if (record.route === "search" && record.needContent !== undefined) {
|
|
513
|
+
appendMeta(meta, "count", record.count === undefined ? "10(默认)" : String(record.count));
|
|
514
|
+
appendMeta(meta, "need_content", record.needContent ? "true" : "false");
|
|
515
|
+
}
|
|
516
|
+
if (record.title) appendMeta(meta, "页面标题", record.title);
|
|
517
|
+
detail.append(meta);
|
|
518
|
+
|
|
519
|
+
const results = Array.isArray(record.results) ? record.results : [];
|
|
520
|
+
if (results.length > 0) {
|
|
521
|
+
detail.append(element("p", "detail-label", "结果标题(前 " + results.length + " 条)"));
|
|
522
|
+
const list = element("ol", "result-list");
|
|
523
|
+
for (const result of results) {
|
|
524
|
+
const row = element("li", "result-item");
|
|
525
|
+
const href = safeHref(result.url);
|
|
526
|
+
if (href) {
|
|
527
|
+
const link = element("a", "result-title", result.title || result.url);
|
|
528
|
+
link.href = href;
|
|
529
|
+
link.target = "_blank";
|
|
530
|
+
link.rel = "noreferrer noopener";
|
|
531
|
+
row.append(link);
|
|
532
|
+
} else {
|
|
533
|
+
row.append(element("span", "result-title", result.title || "(无标题)"));
|
|
534
|
+
}
|
|
535
|
+
row.append(element("span", "result-url", result.url || ""));
|
|
536
|
+
list.append(row);
|
|
537
|
+
}
|
|
538
|
+
detail.append(list);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
head.addEventListener("click", () => {
|
|
542
|
+
const expanded = head.getAttribute("aria-expanded") === "true";
|
|
543
|
+
head.setAttribute("aria-expanded", String(!expanded));
|
|
544
|
+
detail.hidden = expanded;
|
|
545
|
+
item.classList.toggle("expanded", !expanded);
|
|
546
|
+
});
|
|
547
|
+
item.append(head, detail);
|
|
548
|
+
return item;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function matches(record) {
|
|
552
|
+
if (state.route !== "all" && record.route !== state.route) return false;
|
|
553
|
+
const ok = record.status >= 200 && record.status < 300;
|
|
554
|
+
if (state.status === "ok" && !ok) return false;
|
|
555
|
+
if (state.status === "error" && ok) return false;
|
|
556
|
+
const needle = state.text.trim().toLowerCase();
|
|
557
|
+
if (!needle) return true;
|
|
558
|
+
const parts = [record.query, record.url, record.provider, record.code, record.title];
|
|
559
|
+
for (const result of record.results || []) parts.push(result.title, result.url);
|
|
560
|
+
return parts.filter(Boolean).join(" ").toLowerCase().includes(needle);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function renderStats() {
|
|
564
|
+
let ok = 0;
|
|
565
|
+
let bad = 0;
|
|
566
|
+
let totalDuration = 0;
|
|
567
|
+
let counted = 0;
|
|
568
|
+
for (const record of state.records) {
|
|
569
|
+
if (record.status >= 200 && record.status < 300) ok += 1;
|
|
570
|
+
else bad += 1;
|
|
571
|
+
if (Number.isFinite(record.durationMs)) {
|
|
572
|
+
totalDuration += record.durationMs;
|
|
573
|
+
counted += 1;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
byId("stat-total").textContent = String(state.records.length);
|
|
577
|
+
byId("stat-ok").textContent = String(ok);
|
|
578
|
+
byId("stat-bad").textContent = String(bad);
|
|
579
|
+
byId("stat-avg").textContent = counted > 0 ? formatDuration(totalDuration / counted) : "—";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function render() {
|
|
583
|
+
renderStats();
|
|
584
|
+
const list = byId("records");
|
|
585
|
+
list.replaceChildren();
|
|
586
|
+
const visible = state.records.filter(matches);
|
|
587
|
+
for (const record of visible) list.append(renderRecord(record));
|
|
588
|
+
byId("empty").hidden = visible.length > 0;
|
|
589
|
+
if (visible.length === 0) {
|
|
590
|
+
byId("empty-title").textContent = state.records.length > 0 ? "没有匹配的记录" : "还没有请求记录";
|
|
591
|
+
byId("empty-text").textContent = state.records.length > 0
|
|
592
|
+
? "调整类型、状态或文本筛选后再试。"
|
|
593
|
+
: "调用一次 /v1/tools/search 或 /v1/tools/fetch 后,这里会显示时间和结果。";
|
|
594
|
+
}
|
|
595
|
+
byId("record-count").textContent = visible.length === state.records.length
|
|
596
|
+
? "已加载 " + state.records.length + " 条"
|
|
597
|
+
: "已加载 " + state.records.length + " 条,筛选后 " + visible.length + " 条";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function showBanner(message) {
|
|
601
|
+
const banner = byId("banner");
|
|
602
|
+
if (banner.textContent !== message) banner.textContent = message;
|
|
603
|
+
banner.hidden = false;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function hideBanner() {
|
|
607
|
+
byId("banner").hidden = true;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function schedule() {
|
|
611
|
+
if (state.timer) {
|
|
612
|
+
clearInterval(state.timer);
|
|
613
|
+
state.timer = 0;
|
|
614
|
+
}
|
|
615
|
+
if (byId("auto-refresh").checked) {
|
|
616
|
+
state.timer = setInterval(() => {
|
|
617
|
+
if (!document.hidden && state.key) void load();
|
|
618
|
+
}, REFRESH_MS);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function load() {
|
|
623
|
+
if (!state.key || state.loading) return;
|
|
624
|
+
state.loading = true;
|
|
625
|
+
const button = byId("refresh");
|
|
626
|
+
button.disabled = true;
|
|
627
|
+
try {
|
|
628
|
+
const response = await fetch("/admin/api/records?limit=" + RETENTION, {
|
|
629
|
+
headers: { Authorization: "Bearer " + state.key },
|
|
630
|
+
cache: "no-store",
|
|
631
|
+
});
|
|
632
|
+
if (response.status === 401) {
|
|
633
|
+
handleUnauthorized();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
637
|
+
const payload = await response.json();
|
|
638
|
+
state.records = Array.isArray(payload.records) ? payload.records : [];
|
|
639
|
+
hideBanner();
|
|
640
|
+
render();
|
|
641
|
+
void refreshReady();
|
|
642
|
+
} catch (error) {
|
|
643
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
644
|
+
showBanner("读取请求记录失败:" + detail + "。面板会继续自动重试。");
|
|
645
|
+
} finally {
|
|
646
|
+
state.loading = false;
|
|
647
|
+
button.disabled = false;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function showLogin(message) {
|
|
652
|
+
byId("panel-view").hidden = true;
|
|
653
|
+
byId("login-view").hidden = false;
|
|
654
|
+
byId("logout").hidden = true;
|
|
655
|
+
byId("key-input").value = "";
|
|
656
|
+
byId("login-error").hidden = !message;
|
|
657
|
+
if (message) byId("login-error").textContent = message;
|
|
658
|
+
byId("key-input").focus();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function showPanel() {
|
|
662
|
+
byId("login-view").hidden = true;
|
|
663
|
+
byId("panel-view").hidden = false;
|
|
664
|
+
byId("logout").hidden = false;
|
|
665
|
+
byId("login-error").hidden = true;
|
|
666
|
+
schedule();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function handleUnauthorized() {
|
|
670
|
+
clearStoredKey();
|
|
671
|
+
state.key = "";
|
|
672
|
+
state.records = [];
|
|
673
|
+
render();
|
|
674
|
+
showLogin("Key 已失效,请重新输入。");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function onLogin(event) {
|
|
678
|
+
event.preventDefault();
|
|
679
|
+
const input = byId("key-input");
|
|
680
|
+
const key = input.value.trim();
|
|
681
|
+
if (key.length < MIN_KEY_LENGTH) {
|
|
682
|
+
byId("login-error").textContent = "Key 至少需要 " + MIN_KEY_LENGTH + " 个字符。";
|
|
683
|
+
byId("login-error").hidden = false;
|
|
684
|
+
input.focus();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const button = byId("login-submit");
|
|
688
|
+
button.disabled = true;
|
|
689
|
+
button.textContent = "验证中…";
|
|
690
|
+
try {
|
|
691
|
+
const response = await fetch("/admin/api/records?limit=1", {
|
|
692
|
+
headers: { Authorization: "Bearer " + key },
|
|
693
|
+
cache: "no-store",
|
|
694
|
+
});
|
|
695
|
+
if (response.status === 401) {
|
|
696
|
+
byId("login-error").textContent = "Key 无效,或与当前网关不匹配。";
|
|
697
|
+
byId("login-error").hidden = false;
|
|
698
|
+
input.select();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (!response.ok) {
|
|
702
|
+
byId("login-error").textContent = "网关返回 HTTP " + response.status + ",暂时无法读取记录。";
|
|
703
|
+
byId("login-error").hidden = false;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
state.key = key;
|
|
707
|
+
storeKey(key);
|
|
708
|
+
showPanel();
|
|
709
|
+
await load();
|
|
710
|
+
} catch {
|
|
711
|
+
byId("login-error").textContent = "无法连接网关,请检查网络后重试。";
|
|
712
|
+
byId("login-error").hidden = false;
|
|
713
|
+
} finally {
|
|
714
|
+
button.disabled = false;
|
|
715
|
+
button.textContent = "进入面板";
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function logout() {
|
|
720
|
+
clearStoredKey();
|
|
721
|
+
state.key = "";
|
|
722
|
+
state.records = [];
|
|
723
|
+
state.text = "";
|
|
724
|
+
byId("filter-text").value = "";
|
|
725
|
+
render();
|
|
726
|
+
showLogin("");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function bind() {
|
|
730
|
+
byId("login-form").addEventListener("submit", (event) => void onLogin(event));
|
|
731
|
+
byId("logout").addEventListener("click", logout);
|
|
732
|
+
byId("refresh").addEventListener("click", () => void load());
|
|
733
|
+
byId("auto-refresh").addEventListener("change", schedule);
|
|
734
|
+
byId("filter-text").addEventListener("input", () => {
|
|
735
|
+
state.text = byId("filter-text").value;
|
|
736
|
+
render();
|
|
737
|
+
});
|
|
738
|
+
document.querySelectorAll("[data-route]").forEach((button) => {
|
|
739
|
+
button.addEventListener("click", () => {
|
|
740
|
+
state.route = button.dataset.route;
|
|
741
|
+
document.querySelectorAll("[data-route]").forEach((peer) => {
|
|
742
|
+
peer.setAttribute("aria-pressed", String(peer === button));
|
|
743
|
+
});
|
|
744
|
+
render();
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
document.querySelectorAll("[data-status]").forEach((button) => {
|
|
748
|
+
button.addEventListener("click", () => {
|
|
749
|
+
state.status = button.dataset.status;
|
|
750
|
+
document.querySelectorAll("[data-status]").forEach((peer) => {
|
|
751
|
+
peer.setAttribute("aria-pressed", String(peer === button));
|
|
752
|
+
});
|
|
753
|
+
render();
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
document.addEventListener("visibilitychange", () => {
|
|
757
|
+
if (!document.hidden && state.key) void load();
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function boot() {
|
|
762
|
+
bind();
|
|
763
|
+
const hashKey = takeKeyFromHash();
|
|
764
|
+
state.key = hashKey || readStoredKey();
|
|
765
|
+
void refreshReady();
|
|
766
|
+
if (!state.key) {
|
|
767
|
+
showLogin("");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (state.key.length < MIN_KEY_LENGTH) {
|
|
771
|
+
state.key = "";
|
|
772
|
+
showLogin("Key 至少需要 " + MIN_KEY_LENGTH + " 个字符。");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (hashKey) storeKey(state.key);
|
|
776
|
+
showPanel();
|
|
777
|
+
void load();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
boot();
|
|
781
|
+
})();
|
|
782
|
+
</script>
|
|
783
|
+
</body>
|
|
784
|
+
</html>`;
|
|
785
|
+
export function renderPanelHtml(options) {
|
|
786
|
+
return PANEL_TEMPLATE.replaceAll("__NONCE__", options.nonce)
|
|
787
|
+
.replaceAll("__RETENTION__", String(options.retention))
|
|
788
|
+
.replaceAll("__MIN_KEY_LENGTH__", String(options.minKeyLength))
|
|
789
|
+
.replaceAll("__VERSION__", options.version);
|
|
790
|
+
}
|
|
791
|
+
//# sourceMappingURL=panel.js.map
|