@ghfight/dsh-llm-proxy 0.0.0-stage → 0.4.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/lib/ui.html ADDED
@@ -0,0 +1,654 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>DSH LLM Proxy · 控制面板</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --accent: #4f8cff;
11
+ --accent-2: #7c5cff;
12
+ --text: #111827;
13
+ --muted: #667085;
14
+ --line: rgba(17, 24, 39, 0.12);
15
+ --glass: rgba(255, 255, 255, 0.58);
16
+ --glass-strong: rgba(255, 255, 255, 0.80);
17
+ --glass-border: rgba(255, 255, 255, 0.74);
18
+ --field: rgba(255, 255, 255, 0.52);
19
+ --shadow: 0 24px 70px rgba(31, 56, 102, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.92);
20
+ --ok: #12a36b;
21
+ --ok-soft: rgba(18, 163, 107, 0.12);
22
+ --warn: #c47b13;
23
+ --warn-soft: rgba(196, 123, 19, 0.12);
24
+ --bad: #d14343;
25
+ --bad-soft: rgba(209, 67, 67, 0.12);
26
+ }
27
+ @media (prefers-color-scheme: dark) {
28
+ :root {
29
+ --text: #eef3ff;
30
+ --muted: #9aa8c2;
31
+ --line: rgba(255, 255, 255, 0.13);
32
+ --glass: rgba(13, 21, 36, 0.62);
33
+ --glass-strong: rgba(13, 21, 36, 0.84);
34
+ --glass-border: rgba(255, 255, 255, 0.12);
35
+ --field: rgba(7, 12, 22, 0.44);
36
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.07);
37
+ --ok: #4ade9d;
38
+ --ok-soft: rgba(74, 222, 157, 0.13);
39
+ --warn: #f0b86e;
40
+ --warn-soft: rgba(240, 184, 110, 0.13);
41
+ --bad: #ff8c82;
42
+ --bad-soft: rgba(255, 140, 130, 0.13);
43
+ }
44
+ }
45
+ * { box-sizing: border-box; }
46
+ html, body { min-height: 100%; margin: 0; }
47
+ body {
48
+ position: relative;
49
+ overflow-x: hidden;
50
+ background:
51
+ radial-gradient(900px 600px at 10% -10%, rgba(79, 140, 255, 0.30), transparent 60%),
52
+ radial-gradient(760px 560px at 95% 0%, rgba(124, 92, 255, 0.20), transparent 58%),
53
+ radial-gradient(900px 700px at 50% 115%, rgba(18, 163, 107, 0.13), transparent 62%),
54
+ #eef3fb;
55
+ color: var(--text);
56
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
57
+ -webkit-font-smoothing: antialiased;
58
+ }
59
+ @media (prefers-color-scheme: dark) {
60
+ body {
61
+ background:
62
+ radial-gradient(900px 600px at 10% -10%, rgba(43, 91, 190, 0.34), transparent 60%),
63
+ radial-gradient(760px 560px at 95% 0%, rgba(96, 64, 190, 0.26), transparent 58%),
64
+ radial-gradient(900px 700px at 50% 115%, rgba(12, 110, 83, 0.20), transparent 62%),
65
+ #070c16;
66
+ }
67
+ }
68
+ body::before, body::after {
69
+ content: "";
70
+ position: fixed;
71
+ z-index: 0;
72
+ border-radius: 50%;
73
+ filter: blur(60px);
74
+ pointer-events: none;
75
+ opacity: 0.55;
76
+ }
77
+ body::before { width: 340px; height: 340px; left: -110px; top: 24%; background: rgba(79, 140, 255, 0.32); }
78
+ body::after { width: 300px; height: 300px; right: -80px; bottom: 6%; background: rgba(124, 92, 255, 0.26); }
79
+ .app { position: relative; z-index: 1; width: min(1180px, calc(100% - 28px)); margin: 0 auto; padding: 22px 0 34px; }
80
+ body.embed .app { width: calc(100% - 20px); padding: 8px 0 14px; }
81
+ body.embed .hero { padding: 12px 14px; }
82
+ body.embed .hero-sub, body.embed .logo { display: none; }
83
+
84
+ .glass {
85
+ background: var(--glass);
86
+ border: 1px solid var(--glass-border);
87
+ box-shadow: var(--shadow);
88
+ -webkit-backdrop-filter: blur(26px) saturate(165%);
89
+ backdrop-filter: blur(26px) saturate(165%);
90
+ }
91
+ .hero {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 16px;
96
+ padding: 16px 18px;
97
+ border-radius: 18px;
98
+ margin-bottom: 14px;
99
+ }
100
+ .hero-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
101
+ .logo {
102
+ width: 42px; height: 42px; flex: none; border-radius: 13px;
103
+ display: grid; place-items: center;
104
+ color: #fff; font-weight: 800; font-size: 18px;
105
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
106
+ box-shadow: 0 10px 26px rgba(79, 140, 255, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.36);
107
+ }
108
+ h1 { margin: 0; font-size: 18px; line-height: 1.25; letter-spacing: 0.2px; }
109
+ .hero-sub { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
110
+ .hero-right { display: flex; align-items: center; gap: 8px; flex: none; }
111
+ .pill {
112
+ display: inline-flex; align-items: center; gap: 6px;
113
+ height: 30px; padding: 0 11px; border-radius: 999px;
114
+ border: 1px solid var(--line); background: var(--field);
115
+ font-size: 12px; font-weight: 650; white-space: nowrap; color: var(--muted);
116
+ }
117
+ .pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
118
+ .pill.ok { color: var(--ok); background: var(--ok-soft); border-color: transparent; }
119
+ .pill.warn { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
120
+ .pill.bad { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
121
+
122
+ .tabs {
123
+ display: inline-flex; gap: 3px; padding: 4px;
124
+ border-radius: 12px; margin-bottom: 14px;
125
+ background: var(--glass); border: 1px solid var(--glass-border);
126
+ -webkit-backdrop-filter: blur(20px) saturate(150%);
127
+ backdrop-filter: blur(20px) saturate(150%);
128
+ box-shadow: 0 10px 30px rgba(31, 56, 102, 0.10);
129
+ }
130
+ .tab-btn {
131
+ appearance: none; border: 0; background: transparent; color: var(--muted);
132
+ height: 30px; padding: 0 14px; border-radius: 8px;
133
+ font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer;
134
+ transition: background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
135
+ }
136
+ .tab-btn:hover { color: var(--text); }
137
+ .tab-btn.active {
138
+ color: var(--text);
139
+ background: var(--glass-strong);
140
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.55) inset, 0 6px 16px rgba(31, 56, 102, 0.10);
141
+ }
142
+ .panel { display: none; }
143
+ .panel.active { display: block; animation: fade 0.18s ease; }
144
+ @keyframes fade { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
145
+
146
+ .grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 14px; }
147
+ .card { border-radius: 16px; padding: 16px; }
148
+ .card + .card { margin-top: 14px; }
149
+ .card h2 { margin: 0 0 12px; font-size: 13px; letter-spacing: 0.2px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
150
+ .card h2 span { color: var(--muted); font-weight: 500; font-size: 11.5px; }
151
+ .field { display: grid; gap: 6px; margin-bottom: 10px; }
152
+ .field label { color: var(--muted); font-size: 11.5px; font-weight: 600; }
153
+ input[type="text"], textarea {
154
+ width: 100%; color: var(--text);
155
+ background: var(--field); border: 1px solid var(--line); border-radius: 8px;
156
+ padding: 0 10px; height: 32px; font: inherit; font-size: 12.5px;
157
+ outline: none; transition: border-color 0.16s ease, box-shadow 0.16s ease;
158
+ }
159
+ textarea { height: auto; min-height: 148px; padding: 9px 10px; line-height: 1.6; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
160
+ input[type="text"]:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.16); }
161
+ input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.7; }
162
+ .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
163
+ .row.end { justify-content: flex-end; }
164
+ .btn {
165
+ appearance: none; height: 30px; padding: 0 12px; border-radius: 8px;
166
+ border: 1px solid var(--line); background: var(--field); color: var(--text);
167
+ font: inherit; font-size: 12px; font-weight: 650; cursor: pointer;
168
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
169
+ transition: transform 0.12s ease, background 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
170
+ text-decoration: none;
171
+ }
172
+ .btn:hover { background: var(--glass-strong); border-color: rgba(79, 140, 255, 0.34); }
173
+ .btn:active { transform: translateY(1px); }
174
+ .btn.primary {
175
+ border-color: transparent; color: #fff;
176
+ background: linear-gradient(135deg, var(--accent), #6f7cff);
177
+ box-shadow: 0 8px 20px rgba(79, 140, 255, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.28);
178
+ }
179
+ .btn.primary:hover { filter: brightness(1.04); }
180
+ .btn.danger { color: var(--bad); }
181
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
182
+ .btn.small { height: 26px; padding: 0 9px; font-size: 11.5px; }
183
+
184
+ .stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
185
+ .stat { background: var(--field); border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; min-width: 0; }
186
+ .stat .k { color: var(--muted); font-size: 10.5px; font-weight: 600; letter-spacing: 0.2px; }
187
+ .stat .v { margin-top: 2px; font-size: 12.5px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
188
+ .stat .v.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11.5px; }
189
+ .subtle { color: var(--muted); font-size: 11.5px; }
190
+
191
+ .nodes-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
192
+ .nodes-head .search { flex: 1 1 150px; min-width: 120px; }
193
+ .node-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; max-height: 360px; overflow: auto; padding-right: 2px; }
194
+ .node {
195
+ appearance: none; text-align: left; width: 100%; border: 1px solid var(--line);
196
+ background: var(--field); color: inherit; border-radius: 10px; padding: 8px 10px;
197
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
198
+ font: inherit; transition: border-color 0.16s ease, background 0.16s ease, transform 0.12s ease;
199
+ }
200
+ .node:hover { border-color: rgba(79, 140, 255, 0.40); background: var(--glass-strong); }
201
+ .node:active { transform: translateY(1px); }
202
+ .node.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.15); }
203
+ .node .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--muted); opacity: 0.55; }
204
+ .node.active .dot { background: var(--accent); opacity: 1; box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.16); }
205
+ .node .nm { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
206
+ .node .tag { color: var(--muted); font-size: 10px; flex: none; }
207
+ .badge { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 999px; font-size: 10.5px; font-weight: 650; background: var(--field); border: 1px solid var(--line); color: var(--muted); flex: none; }
208
+ .badge.ok { color: var(--ok); background: var(--ok-soft); border-color: transparent; }
209
+ .badge.warn { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
210
+ .badge.bad { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
211
+ .empty { padding: 18px 10px; text-align: center; color: var(--muted); font-size: 12px; }
212
+
213
+ .scope-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
214
+ .scope {
215
+ position: relative; display: flex; gap: 9px; align-items: flex-start;
216
+ padding: 11px 12px; border-radius: 11px; border: 1px solid var(--line);
217
+ background: var(--field); cursor: pointer;
218
+ }
219
+ .scope:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.14); background: var(--glass-strong); }
220
+ .scope input { margin: 3px 0 0; accent-color: var(--accent); flex: none; }
221
+ .scope b { display: block; font-size: 12.5px; }
222
+ .scope small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; line-height: 1.45; }
223
+ .note { color: var(--muted); font-size: 11.5px; line-height: 1.7; }
224
+ .note code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; background: var(--field); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; }
225
+ .about-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
226
+ .about-list li { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--line); font-size: 12px; }
227
+ .about-list li:last-child { border-bottom: 0; padding-bottom: 0; }
228
+ .about-list span:first-child { color: var(--muted); }
229
+ .about-list span:last-child { text-align: right; overflow-wrap: anywhere; }
230
+ .error-banner { margin-bottom: 12px; border-radius: 10px; padding: 9px 11px; font-size: 12px; color: var(--bad); background: var(--bad-soft); border: 1px solid rgba(209, 67, 67, 0.18); }
231
+
232
+ .toast {
233
+ position: fixed; z-index: 20; left: 50%; bottom: 22px; transform: translate(-50%, 12px);
234
+ max-width: min(560px, calc(100vw - 32px));
235
+ padding: 10px 14px; border-radius: 10px; font-size: 12px;
236
+ color: var(--text); background: var(--glass-strong); border: 1px solid var(--glass-border);
237
+ box-shadow: 0 16px 40px rgba(20, 34, 64, 0.20);
238
+ -webkit-backdrop-filter: blur(24px) saturate(160%); backdrop-filter: blur(24px) saturate(160%);
239
+ opacity: 0; pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease;
240
+ }
241
+ .toast.show { opacity: 1; transform: translate(-50%, 0); }
242
+ .spinner { width: 12px; height: 12px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; animation: spin 0.7s linear infinite; }
243
+ @keyframes spin { to { transform: rotate(360deg); } }
244
+
245
+ @media (max-width: 760px) {
246
+ .hero { align-items: flex-start; flex-direction: column; }
247
+ .hero-right { width: 100%; justify-content: space-between; }
248
+ .grid { grid-template-columns: 1fr; }
249
+ .node-list { grid-template-columns: 1fr; }
250
+ .scope-grid { grid-template-columns: 1fr; }
251
+ .tabs { width: 100%; }
252
+ .tab-btn { flex: 1; }
253
+ }
254
+ </style>
255
+ </head>
256
+ <body>
257
+ <div class="app">
258
+ <header class="hero glass">
259
+ <div class="hero-left">
260
+ <div class="logo">C</div>
261
+ <div>
262
+ <h1>DSH LLM Proxy</h1>
263
+ <p class="hero-sub">DSH 专属代理 · 订阅、节点与绕行范围</p>
264
+ </div>
265
+ </div>
266
+ <div class="hero-right">
267
+ <span id="statusPill" class="pill">读取中</span>
268
+ <button id="fullBtn" class="btn" type="button" title="在系统默认浏览器中打开当前页面">用系统浏览器打开</button>
269
+ </div>
270
+ </header>
271
+
272
+ <div class="tabs" role="tablist">
273
+ <button class="tab-btn active" data-tab="proxy" role="tab">代理</button>
274
+ <button class="tab-btn" data-tab="bypass" role="tab">绕过与范围</button>
275
+ <button class="tab-btn" data-tab="about" role="tab">关于</button>
276
+ </div>
277
+
278
+ <main>
279
+ <section id="tab-proxy" class="panel active">
280
+ <div id="errorBanner" class="error-banner" hidden></div>
281
+ <div class="grid">
282
+ <div class="card glass">
283
+ <h2>订阅 <span id="subMeta">未导入</span></h2>
284
+ <div class="field">
285
+ <label for="subUrl">订阅链接</label>
286
+ <input id="subUrl" type="text" autocomplete="off" placeholder="https://example.com/api/v1/clash?token=..." />
287
+ </div>
288
+ <div class="field">
289
+ <label for="subName">名称(可选)</label>
290
+ <input id="subName" type="text" autocomplete="off" placeholder="例如:机场 A" />
291
+ </div>
292
+ <div class="row">
293
+ <button id="importBtn" class="btn primary">导入订阅</button>
294
+ <button id="updateBtn" class="btn">更新订阅</button>
295
+ <button id="coreBtn" class="btn">安装/修复内核</button>
296
+ </div>
297
+ <p class="note" style="margin:10px 0 0">导入后会生成 <code>DSH-AUTO</code> 自动组,并默认走延迟最低的节点。订阅只在本地 Mihomo 中运行,不修改系统代理。</p>
298
+ </div>
299
+
300
+ <div class="card glass">
301
+ <h2>运行状态 <span id="modeText">—</span></h2>
302
+ <div class="stats">
303
+ <div class="stat"><div class="k">当前节点</div><div id="stNode" class="v">—</div></div>
304
+ <div class="stat"><div class="k">节点数</div><div id="stCount" class="v">—</div></div>
305
+ <div class="stat"><div class="k">混合端口</div><div id="stMixed" class="v mono">—</div></div>
306
+ <div class="stat"><div class="k">控制端口</div><div id="stController" class="v mono">—</div></div>
307
+ <div class="stat"><div class="k">内核</div><div id="stCore" class="v mono">—</div></div>
308
+ <div class="stat"><div class="k">进程</div><div id="stPid" class="v mono">—</div></div>
309
+ </div>
310
+ <div class="row" style="margin-top:12px">
311
+ <button id="startBtn" class="btn primary">启动</button>
312
+ <button id="stopBtn" class="btn danger">停止</button>
313
+ <button id="testBtn" class="btn">测速</button>
314
+ </div>
315
+ <p class="subtle" style="margin:10px 0 0">启动后仅影响 DSH 进程内的网络请求,其他程序保持直连。</p>
316
+ </div>
317
+ </div>
318
+
319
+ <div class="card glass">
320
+ <div class="nodes-head">
321
+ <h2 style="margin:0;flex:1">节点 <span id="nodeMeta"></span></h2>
322
+ <input id="search" class="search" type="text" placeholder="搜索节点…" />
323
+ <button id="autoBtn" class="btn small">自动选择</button>
324
+ </div>
325
+ <div id="nodeList" class="node-list"></div>
326
+ </div>
327
+ </section>
328
+
329
+ <section id="tab-bypass" class="panel">
330
+ <div class="card glass">
331
+ <h2>代理范围 <span>保存后立即生效</span></h2>
332
+ <div class="scope-grid">
333
+ <label class="scope">
334
+ <input type="radio" name="scope" value="all" />
335
+ <span><b>全部流量</b><small>除本机/内网和下方绕行列表外,DSH 的所有外部请求都走代理。</small></span>
336
+ </label>
337
+ <label class="scope">
338
+ <input type="radio" name="scope" value="models" />
339
+ <span><b>仅模型接口</b><small>只代理大模型 API,其余请求(含 DSH Remote、插件服务)保持直连。</small></span>
340
+ </label>
341
+ </div>
342
+ <div class="field">
343
+ <label for="bypass">绕行列表(每行一个,支持 * 与 *.example.com)</label>
344
+ <textarea id="bypass" spellcheck="false" placeholder="dsh.r2049.cn&#10;*.r2049.cn"></textarea>
345
+ </div>
346
+ <div class="row end">
347
+ <button id="defaultsBtn" class="btn">填入默认值</button>
348
+ <button id="savePolicyBtn" class="btn primary">保存设置</button>
349
+ </div>
350
+ <p class="note" style="margin:12px 0 0">
351
+ 默认绕行:<span id="defaultHosts" class="subtle"></span><br />
352
+ DSH Remote 使用 <code>https://dsh.r2049.cn</code> 作为信令服务器;如果 Remote 无法连接,请确认它在绕行列表中,或切换到“仅模型接口”。
353
+ </p>
354
+ </div>
355
+ <div class="card glass">
356
+ <h2>模型接口白名单 <span>“仅模型接口”模式使用</span></h2>
357
+ <p class="note" id="modelHosts" style="margin:0"></p>
358
+ </div>
359
+ </section>
360
+
361
+ <section id="tab-about" class="panel">
362
+ <div class="card glass">
363
+ <h2>关于 DSH LLM Proxy <span id="versionText">v0.4.0</span></h2>
364
+ <ul class="about-list">
365
+ <li><span>插件</span><span>dsh-llm-proxy</span></li>
366
+ <li><span>代理内核</span><span id="aboutCore">Mihomo</span></li>
367
+ <li><span>数据目录</span><span id="aboutData">—</span></li>
368
+ <li><span>订阅地址</span><span id="aboutSub">—</span></li>
369
+ <li><span>自动组</span><span>DSH-AUTO(url-test)</span></li>
370
+ <li><span>系统代理</span><span>不修改</span></li>
371
+ </ul>
372
+ <p class="note" style="margin:14px 0 0">面板只控制 DSH 进程内的请求。关闭 DSH 或停止代理后,其他软件不受影响。</p>
373
+ </div>
374
+ </section>
375
+ </main>
376
+ </div>
377
+ <div id="toast" class="toast" role="status"></div>
378
+
379
+ <script>
380
+ (function () {
381
+ var PAGE = location.pathname;
382
+ while (PAGE.length > 1 && PAGE.charAt(PAGE.length - 1) === '/') PAGE = PAGE.slice(0, -1);
383
+ var API = PAGE + '/api/';
384
+ var state = null;
385
+ var busy = false;
386
+ var toastTimer = 0;
387
+ var lastPolicyText = '';
388
+ var policyDirty = false;
389
+
390
+ function $(id) { return document.getElementById(id); }
391
+
392
+ function toast(message, bad) {
393
+ var el = $('toast');
394
+ el.textContent = String(message || '');
395
+ el.style.color = bad ? 'var(--bad)' : 'var(--text)';
396
+ el.classList.add('show');
397
+ clearTimeout(toastTimer);
398
+ toastTimer = setTimeout(function () { el.classList.remove('show'); }, 3200);
399
+ }
400
+
401
+ async function request(path, body) {
402
+ var options = { method: body === undefined ? 'GET' : 'POST', headers: {} };
403
+ if (body !== undefined) {
404
+ options.headers['content-type'] = 'application/json';
405
+ options.body = JSON.stringify(body);
406
+ }
407
+ var response = await fetch(API + path, options);
408
+ var data = null;
409
+ try { data = await response.json(); } catch (error) { data = null; }
410
+ if (!response.ok || !data || data.ok === false) {
411
+ var message = data && data.error && data.error.message ? data.error.message : ('请求失败 HTTP ' + response.status);
412
+ throw new Error(message);
413
+ }
414
+ return data;
415
+ }
416
+
417
+ function fullPageUrl() {
418
+ return location.origin + PAGE;
419
+ }
420
+
421
+ async function openFullPage() {
422
+ try {
423
+ await request('open', { url: fullPageUrl() });
424
+ toast('已在系统浏览器中打开');
425
+ return;
426
+ } catch (error) {
427
+ try {
428
+ var win = window.open(fullPageUrl(), '_blank', 'noopener,noreferrer');
429
+ if (win) { toast('已请求系统浏览器打开'); return; }
430
+ } catch (fallbackError) {}
431
+ toast('打开失败:' + (error && error.message ? error.message : error), true);
432
+ }
433
+ }
434
+
435
+ function activateTab(name) {
436
+ var buttons = document.querySelectorAll('.tab-btn');
437
+ for (var i = 0; i < buttons.length; i += 1) {
438
+ buttons[i].classList.toggle('active', buttons[i].getAttribute('data-tab') === name);
439
+ }
440
+ var panels = document.querySelectorAll('.panel');
441
+ for (var j = 0; j < panels.length; j += 1) {
442
+ panels[j].classList.toggle('active', panels[j].id === 'tab-' + name);
443
+ }
444
+ if (name === 'bypass' && !policyDirty) fillPolicy();
445
+ try { history.replaceState(null, '', '#' + name); } catch (error) {}
446
+ }
447
+
448
+ function setText(id, value) {
449
+ var el = $(id);
450
+ if (el) el.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
451
+ }
452
+
453
+ function statusClass(status) {
454
+ if (!status.configured) return 'warn';
455
+ if (status.running) return 'ok';
456
+ return '';
457
+ }
458
+
459
+ function shortNode(name) {
460
+ var text = String(name || '—');
461
+ return text.length > 22 ? text.slice(0, 21) + '…' : text;
462
+ }
463
+
464
+ function delayBadge(delay) {
465
+ var n = Number(delay) || 0;
466
+ if (!n) return '<span class="badge">未测</span>';
467
+ var cls = n < 160 ? 'ok' : (n < 420 ? 'warn' : 'bad');
468
+ return '<span class="badge ' + cls + '">' + n + ' ms</span>';
469
+ }
470
+
471
+ function isInfoNode(name) {
472
+ return /(剩余流量|套餐到期|距离下次重置|过期时间|官网|订阅地址)/.test(String(name || ''));
473
+ }
474
+
475
+ function escapeHtml(value) {
476
+ return String(value === undefined || value === null ? '' : value)
477
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
478
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
479
+ }
480
+
481
+ function renderNodes(nodes, status) {
482
+ var list = $('nodeList');
483
+ var keyword = String($('search').value || '').trim().toLowerCase();
484
+ var selected = status && status.activeNode ? String(status.activeNode) : '';
485
+ var shown = 0;
486
+ var html = '';
487
+ for (var i = 0; i < (nodes || []).length; i += 1) {
488
+ var node = nodes[i] || {};
489
+ if (isInfoNode(node.name)) continue;
490
+ if (keyword && String(node.name || '').toLowerCase().indexOf(keyword) === -1) continue;
491
+ shown += 1;
492
+ var active = node.name === selected;
493
+ html += '<button type="button" class="node' + (active ? ' active' : '') + '" data-node="' + escapeHtml(node.name) + '">' +
494
+ '<span class="dot"></span><span class="nm">' + escapeHtml(node.name) + '</span>' +
495
+ '<span class="tag">' + escapeHtml(node.type || '') + '</span>' + delayBadge(node.delay) + '</button>';
496
+ }
497
+ list.innerHTML = shown ? html : '<div class="empty">没有匹配的节点</div>';
498
+ setText('nodeMeta', shown + ' / ' + ((nodes || []).length) + ' 个');
499
+ }
500
+
501
+ function render(data) {
502
+ if (!data) return;
503
+ state = data;
504
+ var status = data.status || {};
505
+ var policy = data.policy || {};
506
+ var pill = $('statusPill');
507
+ pill.className = 'pill ' + statusClass(status);
508
+ pill.textContent = !status.configured ? '未配置' : (status.running ? '运行中' : '已停止');
509
+ setText('modeText', status.running ? (status.mode === 'auto' ? '自动选择' : '手动选择') : '—');
510
+ setText('stNode', shortNode(status.activeNode || status.selected));
511
+ setText('stCount', status.nodeCount || 0);
512
+ setText('stMixed', status.mixedPort || '—');
513
+ setText('stController', status.controllerPort || '—');
514
+ setText('stCore', status.coreVersion || '—');
515
+ setText('stPid', status.pid || '—');
516
+ setText('subMeta', status.configured ? (status.subscription || '已导入') + (status.updatedAt ? ' · ' + status.updatedAt : '') : '未导入');
517
+ setText('aboutCore', status.coreVersion || 'Mihomo');
518
+ setText('versionText', 'v0.4.0');
519
+ $('startBtn').disabled = busy || status.running;
520
+ $('stopBtn').disabled = busy || !status.running;
521
+ $('testBtn').disabled = busy || !status.running;
522
+ $('autoBtn').disabled = busy || !status.running;
523
+ $('updateBtn').disabled = busy || !status.configured;
524
+ var banner = $('errorBanner');
525
+ if (status.lastError) { banner.hidden = false; banner.textContent = '最近错误:' + status.lastError; }
526
+ else { banner.hidden = true; banner.textContent = ''; }
527
+ renderNodes(data.nodes || [], status);
528
+ if (policy && policy.bypassHosts) {
529
+ var text = policy.bypassHosts.join('\n');
530
+ if (!policyDirty && text !== lastPolicyText) {
531
+ lastPolicyText = text;
532
+ $('bypass').value = text;
533
+ }
534
+ setText('defaultHosts', (policy.defaultBypassHosts || []).join('、'));
535
+ setText('modelHosts', (policy.modelHosts || []).join('、'));
536
+ var scope = policy.proxyScope === 'models' ? 'models' : 'all';
537
+ var radios = document.querySelectorAll('input[name="scope"]');
538
+ if (!policyDirty) {
539
+ for (var i = 0; i < radios.length; i += 1) {
540
+ if (radios[i].value === scope && document.activeElement !== radios[i]) radios[i].checked = true;
541
+ }
542
+ }
543
+ setText('aboutData', status.dataDir || '—');
544
+ setText('aboutSub', status.subscriptionHost || '—');
545
+ }
546
+ }
547
+
548
+ async function refresh(silent) {
549
+ try {
550
+ var data = await request('state');
551
+ render(data.state);
552
+ if (!silent && data.state && data.state.status && data.state.status.lastError) toast(data.state.status.lastError, true);
553
+ } catch (error) {
554
+ if (!silent) toast(error.message || String(error), true);
555
+ }
556
+ }
557
+
558
+ async function action(name, body, success) {
559
+ if (busy) return false;
560
+ busy = true;
561
+ try {
562
+ var data = await request(name, body === undefined ? {} : body);
563
+ if (data.state) render(data.state);
564
+ else await refresh(true);
565
+ toast((data && data.message) || success || '操作完成');
566
+ return true;
567
+ } catch (error) {
568
+ toast(error.message || String(error), true);
569
+ return false;
570
+ } finally {
571
+ busy = false;
572
+ refresh(true);
573
+ }
574
+ }
575
+
576
+ function fillPolicy() {
577
+ if (!state || !state.policy) return;
578
+ var hosts = state.policy.bypassHosts || [];
579
+ $('bypass').value = hosts.join('\n');
580
+ lastPolicyText = hosts.join('\n');
581
+ policyDirty = false;
582
+ var scope = state.policy.proxyScope === 'models' ? 'models' : 'all';
583
+ var radios = document.querySelectorAll('input[name="scope"]');
584
+ for (var i = 0; i < radios.length; i += 1) radios[i].checked = radios[i].value === scope;
585
+ setText('defaultHosts', (state.policy.defaultBypassHosts || []).join('、'));
586
+ setText('modelHosts', (state.policy.modelHosts || []).join('、'));
587
+ }
588
+
589
+ function bind() {
590
+ var tabs = document.querySelectorAll('.tab-btn');
591
+ for (var i = 0; i < tabs.length; i += 1) {
592
+ tabs[i].addEventListener('click', function () { activateTab(this.getAttribute('data-tab')); });
593
+ }
594
+ $('startBtn').addEventListener('click', function () { action('start', {}, '代理已启动'); });
595
+ $('stopBtn').addEventListener('click', function () { action('stop', {}, '代理已停止'); });
596
+ $('testBtn').addEventListener('click', function () { action('test', {}, '测速完成'); });
597
+ $('autoBtn').addEventListener('click', function () { action('auto', {}, '已切换到自动选择'); });
598
+ $('coreBtn').addEventListener('click', function () { action('installCore', {}, '内核已安装'); });
599
+ $('fullBtn').addEventListener('click', function () { openFullPage(); });
600
+ $('updateBtn').addEventListener('click', function () {
601
+ var url = $('subUrl').value.trim();
602
+ if (url) action('subscribe', { url: url, name: $('subName').value.trim() || undefined }, '订阅已更新');
603
+ else action('update', {}, '订阅已更新');
604
+ });
605
+ $('importBtn').addEventListener('click', function () {
606
+ var url = $('subUrl').value.trim();
607
+ if (!url) { toast('请先填写订阅链接', true); $('subUrl').focus(); return; }
608
+ action('subscribe', { url: url, name: $('subName').value.trim() || undefined }, '订阅已导入');
609
+ });
610
+ $('search').addEventListener('input', function () { if (state) renderNodes(state.nodes || [], state.status || {}); });
611
+ $('nodeList').addEventListener('click', function (event) {
612
+ var target = event.target;
613
+ while (target && target !== this && !target.getAttribute('data-node')) target = target.parentElement;
614
+ if (!target || target === this) return;
615
+ var name = target.getAttribute('data-node');
616
+ if (!name) return;
617
+ action('select', { name: name }, '已切换到 ' + name);
618
+ });
619
+ $('bypass').addEventListener('input', function () { policyDirty = true; });
620
+ var scopeInputs = document.querySelectorAll('input[name="scope"]');
621
+ for (var s = 0; s < scopeInputs.length; s += 1) scopeInputs[s].addEventListener('change', function () { policyDirty = true; });
622
+ $('savePolicyBtn').addEventListener('click', function () {
623
+ var checked = document.querySelector('input[name="scope"]:checked');
624
+ action('settings', {
625
+ proxyScope: checked ? checked.value : 'all',
626
+ bypassHosts: $('bypass').value
627
+ }, '设置已保存').then(function (ok) { if (ok) policyDirty = false; });
628
+ });
629
+ $('defaultsBtn').addEventListener('click', function () {
630
+ var defaults = state && state.policy && state.policy.defaultBypassHosts ? state.policy.defaultBypassHosts : [];
631
+ $('bypass').value = defaults.join('\n');
632
+ lastPolicyText = $('bypass').value;
633
+ policyDirty = true;
634
+ toast('已填入默认绕行列表,点击“保存设置”生效');
635
+ });
636
+ window.addEventListener('keydown', function (event) {
637
+ if (event.key === 'Escape') { var el = document.activeElement; if (el && el.blur) el.blur(); }
638
+ });
639
+ }
640
+
641
+ async function init() {
642
+ if (location.search.indexOf('embed=1') !== -1) document.body.classList.add('embed');
643
+ bind();
644
+ var hash = String(location.hash || '').replace('#', '');
645
+ activateTab(hash === 'bypass' || hash === 'about' ? hash : 'proxy');
646
+ await refresh(true);
647
+ setInterval(function () { if (!busy && document.visibilityState !== 'hidden') refresh(true); }, 5000);
648
+ }
649
+
650
+ init();
651
+ })();
652
+ </script>
653
+ </body>
654
+ </html>