pac-proxy-cli 0.1.7 → 1.0.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/.env +1 -3
- package/README.md +71 -63
- package/bin/cli.js +3 -0
- package/dist/assets/index-BT0RwRSQ.css +1 -0
- package/dist/assets/index-BeZfrxlH.js +27 -0
- package/dist/index.html +16 -0
- package/lib/capture-log.js +138 -0
- package/lib/index.js +22 -0
- package/lib/local-store.js +122 -0
- package/lib/mitm-proxy-server.js +143 -0
- package/lib/pac-file.js +49 -0
- package/lib/pac-match.js +44 -0
- package/lib/proxy-server.js +247 -0
- package/lib/server.js +239 -0
- package/lib/socks-http.js +47 -0
- package/lib/system-proxy.js +264 -0
- package/lib/traffic-log.js +14 -0
- package/package.json +37 -15
- package/bin/proxy.js +0 -30
- package/public/index.html +0 -732
- package/src/capture/store.js +0 -40
- package/src/cli/panel.js +0 -23
- package/src/cli/start.js +0 -62
- package/src/pac/store.js +0 -186
- package/src/panel/server.js +0 -145
- package/src/proxy/server.js +0 -262
- package/src/sysproxy/index.js +0 -270
package/public/index.html
DELETED
|
@@ -1,732 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>代理管理面板</title>
|
|
7
|
-
<style>
|
|
8
|
-
* { box-sizing: border-box; }
|
|
9
|
-
body { font-family: system-ui, sans-serif; margin: 0; padding: 0; background: #0f0f12; color: #e4e4e7; }
|
|
10
|
-
nav { display: flex; gap: 8px; padding: 12px 24px; background: #18181b; border-bottom: 1px solid #27272a; }
|
|
11
|
-
nav a { color: #a1a1aa; text-decoration: none; padding: 8px 12px; border-radius: 6px; }
|
|
12
|
-
nav a:hover, nav a.active { color: #fff; background: #27272a; }
|
|
13
|
-
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
14
|
-
h1 { font-size: 1.5rem; margin-bottom: 24px; }
|
|
15
|
-
.card { background: #18181b; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
|
16
|
-
label { display: block; margin-bottom: 4px; color: #a1a1aa; font-size: 0.875rem; }
|
|
17
|
-
/* 统一控件高度与样式 */
|
|
18
|
-
button, input, select, textarea {
|
|
19
|
-
font-size: 14px;
|
|
20
|
-
line-height: 1.25;
|
|
21
|
-
border-radius: 6px;
|
|
22
|
-
font-family: inherit;
|
|
23
|
-
}
|
|
24
|
-
button {
|
|
25
|
-
min-height: 36px;
|
|
26
|
-
padding: 8px 16px;
|
|
27
|
-
background: #3b82f6;
|
|
28
|
-
color: #fff;
|
|
29
|
-
border: none;
|
|
30
|
-
cursor: pointer;
|
|
31
|
-
margin-right: 8px;
|
|
32
|
-
}
|
|
33
|
-
button:hover { background: #2563eb; }
|
|
34
|
-
button.secondary { background: #27272a; }
|
|
35
|
-
button.secondary:hover { background: #3f3f46; }
|
|
36
|
-
button.danger { background: #dc2626; }
|
|
37
|
-
input:not([type="radio"]):not([type="checkbox"]), select {
|
|
38
|
-
min-height: 36px;
|
|
39
|
-
height: 36px;
|
|
40
|
-
padding: 8px 12px;
|
|
41
|
-
background: #27272a;
|
|
42
|
-
color: #e4e4e7;
|
|
43
|
-
border: 1px solid #3f3f46;
|
|
44
|
-
outline: none;
|
|
45
|
-
}
|
|
46
|
-
input:not([type="radio"]):not([type="checkbox"]):focus, select:focus {
|
|
47
|
-
border-color: #3b82f6;
|
|
48
|
-
}
|
|
49
|
-
input[type="number"] { width: 80px; }
|
|
50
|
-
.input-w-200 { width: 200px; }
|
|
51
|
-
.input-max-w-200 { width: 100%; max-width: 200px; }
|
|
52
|
-
.input-max-w-320 { width: 100%; max-width: 320px; }
|
|
53
|
-
input[type="radio"], input[type="checkbox"] { margin-right: 6px; vertical-align: middle; }
|
|
54
|
-
textarea {
|
|
55
|
-
width: 100%;
|
|
56
|
-
min-height: 200px;
|
|
57
|
-
padding: 12px;
|
|
58
|
-
background: #27272a;
|
|
59
|
-
color: #e4e4e7;
|
|
60
|
-
border: 1px solid #3f3f46;
|
|
61
|
-
font-family: monospace;
|
|
62
|
-
font-size: 13px;
|
|
63
|
-
}
|
|
64
|
-
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
|
65
|
-
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #27272a; }
|
|
66
|
-
th { color: #a1a1aa; font-weight: 500; }
|
|
67
|
-
tr:hover { background: #27272a; }
|
|
68
|
-
.code { font-family: monospace; font-size: 12px; background: #27272a; padding: 12px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
|
69
|
-
.status-2xx { color: #22c55e; }
|
|
70
|
-
.status-4xx { color: #eab308; }
|
|
71
|
-
.status-5xx { color: #ef4444; }
|
|
72
|
-
.page { display: none; }
|
|
73
|
-
.page.active { display: block; }
|
|
74
|
-
#wrap-mode-select { text-align: center; padding: 48px 24px; max-width: 560px; margin: 0 auto; }
|
|
75
|
-
#wrap-mode-select h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
|
76
|
-
#wrap-mode-select p { color: #a1a1aa; font-size: 0.875rem; margin-bottom: 32px; }
|
|
77
|
-
.mode-cards { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; align-items: stretch; }
|
|
78
|
-
.mode-card {
|
|
79
|
-
background: #18181b;
|
|
80
|
-
border: 2px solid #27272a;
|
|
81
|
-
border-radius: 12px;
|
|
82
|
-
padding: 24px 32px;
|
|
83
|
-
width: 200px;
|
|
84
|
-
min-height: 120px;
|
|
85
|
-
cursor: pointer;
|
|
86
|
-
transition: border-color .2s, background .2s;
|
|
87
|
-
display: flex;
|
|
88
|
-
flex-direction: column;
|
|
89
|
-
justify-content: center;
|
|
90
|
-
}
|
|
91
|
-
.mode-card:hover { border-color: #3b82f6; background: #1e293b; }
|
|
92
|
-
.mode-card h2 { font-size: 1.1rem; margin: 0 0 8px 0; }
|
|
93
|
-
.mode-card .desc { color: #a1a1aa; font-size: 0.8rem; margin: 0; line-height: 1.4; }
|
|
94
|
-
body.mode-select nav, body.mode-select main { display: none !important; }
|
|
95
|
-
</style>
|
|
96
|
-
</head>
|
|
97
|
-
<body>
|
|
98
|
-
<div id="wrap-mode-select" style="display:none;">
|
|
99
|
-
<h1>代理管理面板</h1>
|
|
100
|
-
<p>请选择使用方式</p>
|
|
101
|
-
<div class="mode-cards">
|
|
102
|
-
<div class="mode-card" data-mode="local">
|
|
103
|
-
<h2>本地模式</h2>
|
|
104
|
-
<p class="desc">配置与 PAC 规则由本机 node-cli 保存,无需登录</p>
|
|
105
|
-
</div>
|
|
106
|
-
<div class="mode-card" data-mode="cloud">
|
|
107
|
-
<h2>云端模式</h2>
|
|
108
|
-
<p class="desc">登录后可将 PAC 规则同步到远程服务器</p>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
<nav>
|
|
113
|
-
<a href="#" class="nav-link active" data-page="home">首页</a>
|
|
114
|
-
<a href="#" class="nav-link" data-page="pac">PAC 规则</a>
|
|
115
|
-
<a href="#" class="nav-link" data-page="traffic">流量</a>
|
|
116
|
-
<span style="margin-left:auto;display:flex;align-items:center;gap:12px;">
|
|
117
|
-
<span id="nav-user" style="color:#a1a1aa;">未登录</span>
|
|
118
|
-
<a href="#" id="nav-switch-mode" class="nav-link" style="color:#a1a1aa;padding:8px 12px;">切换模式</a>
|
|
119
|
-
</span>
|
|
120
|
-
</nav>
|
|
121
|
-
<main>
|
|
122
|
-
<div id="page-home" class="page active">
|
|
123
|
-
<h1>代理管理面板</h1>
|
|
124
|
-
<div class="card" id="card-login">
|
|
125
|
-
<div id="login-form-wrap">
|
|
126
|
-
<h2 style="margin-top:0;">登录(同步 PAC 到远程服务器)</h2>
|
|
127
|
-
<p style="color:#a1a1aa;font-size:0.875rem;margin-top:0;">服务器地址已在 .env 中配置(PROXY_SERVER_URL)</p>
|
|
128
|
-
<label>用户名</label>
|
|
129
|
-
<input type="text" id="login-username" class="input-w-200" style="margin-bottom:8px;" />
|
|
130
|
-
<label>密码</label>
|
|
131
|
-
<input type="password" id="login-password" class="input-w-200" style="margin-bottom:8px;" />
|
|
132
|
-
<p>
|
|
133
|
-
<button type="button" id="btn-login">登录</button>
|
|
134
|
-
<button type="button" id="btn-register" class="secondary">注册</button>
|
|
135
|
-
<span id="login-status" style="margin-left:8px;"></span>
|
|
136
|
-
</p>
|
|
137
|
-
</div>
|
|
138
|
-
<div id="login-logged-wrap" style="display:none;">
|
|
139
|
-
<p style="margin:0;">已登录 <button type="button" id="btn-logout" class="secondary">退出</button></p>
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
142
|
-
<div class="card">
|
|
143
|
-
<h2 style="margin-top:0;">系统代理</h2>
|
|
144
|
-
<p style="color:#a1a1aa;margin-top:0;">选择一种方式后点击「应用」写入系统代理设置。<strong style="color:#eab308;">若已设 PAC 规则(如某域名直连),必须选「PAC 代理」并重新应用,否则选成「全局代理」会忽略规则、全部走代理。</strong></p>
|
|
145
|
-
<p>
|
|
146
|
-
<label style="display:inline-flex;align-items:center;margin-right:16px;cursor:pointer;">
|
|
147
|
-
<input type="radio" name="sysproxy-mode" value="global" />
|
|
148
|
-
全局代理:全部流量走本地代理(127.0.0.1:代理端口)
|
|
149
|
-
</label>
|
|
150
|
-
<label style="display:inline-flex;align-items:center;cursor:pointer;">
|
|
151
|
-
<input type="radio" name="sysproxy-mode" value="pac" checked />
|
|
152
|
-
PAC 代理:按「PAC 规则」页的规则决定走代理或直连
|
|
153
|
-
</label>
|
|
154
|
-
</p>
|
|
155
|
-
<p style="color:#a1a1aa;font-size:0.875rem;">PAC 地址:<code id="sysproxy-pac-url">http://127.0.0.1:3892/pac.js</code> <a href="/pac.js" target="_blank" rel="noopener" style="color:#3b82f6;">预览 PAC</a></p>
|
|
156
|
-
<p style="margin-bottom:12px;"><strong>当前系统配置:</strong><span id="sysproxy-current">加载中…</span> <span style="color:#a1a1aa;font-size:0.875rem;">(此处为「PAC 代理」时,规则里的直连才会生效)</span></p>
|
|
157
|
-
<p>
|
|
158
|
-
<button type="button" id="btn-apply-proxy">应用系统代理</button>
|
|
159
|
-
<button type="button" id="btn-clear-proxy" class="secondary">清除系统代理</button>
|
|
160
|
-
</p>
|
|
161
|
-
</div>
|
|
162
|
-
<div class="card">
|
|
163
|
-
<p id="status">就绪</p>
|
|
164
|
-
</div>
|
|
165
|
-
</div>
|
|
166
|
-
|
|
167
|
-
<div id="page-pac" class="page">
|
|
168
|
-
<h1>PAC 规则</h1>
|
|
169
|
-
<div class="card">
|
|
170
|
-
<p style="margin-top:0;color:#a1a1aa;">按顺序匹配,命中则使用对应代理;类型为「默认」时匹配所有。登录后可同步到服务器。<strong>复制</strong> / <strong>导出</strong>:仅导出「走代理」且启用的规则(每行一个匹配模式),可粘贴至 Shadowsocks 等客户端的自定义规则中使用。</p>
|
|
171
|
-
<p style="color:#eab308;font-size:0.8rem;margin-top:4px;"><strong>云端模式:</strong>系统 PAC 使用<strong>本机</strong>规则。新增/编辑/删除规则后会自动同步到本机;若未生效请先点「从服务器拉取」再在首页「应用系统代理」。</p>
|
|
172
|
-
<p style="color:#a1a1aa;font-size:0.8rem;margin-top:4px;"><strong>访问不了时请检查:</strong>① 首页是否已点击「应用系统代理」并选择「PAC 代理」;② 按 Host 时访问 www.google.com 则 host 为 www.google.com,匹配模式需填 <code>*.google.com</code>(不能只填 google.com);③ 该规则需选「走代理」;④ 确保 node-proxy 上游(如 -s 127.0.0.1:1080)已启动且能访问外网。</p>
|
|
173
|
-
<p>
|
|
174
|
-
<button type="button" id="btn-pac-add">新增规则</button>
|
|
175
|
-
<button type="button" id="btn-pac-pull" class="secondary">从服务器拉取</button>
|
|
176
|
-
<button type="button" id="btn-pac-push" class="secondary">保存到服务器</button>
|
|
177
|
-
<button type="button" id="btn-pac-copy" class="secondary">复制</button>
|
|
178
|
-
<button type="button" id="btn-pac-export" class="secondary">导出</button>
|
|
179
|
-
<span id="pac-status" style="margin-left:8px;color:#22c55e;"></span>
|
|
180
|
-
</p>
|
|
181
|
-
<table style="margin-top:12px;">
|
|
182
|
-
<thead>
|
|
183
|
-
<tr><th>类型</th><th>匹配模式</th><th>代理</th><th>启用</th><th>顺序</th><th>操作</th></tr>
|
|
184
|
-
</thead>
|
|
185
|
-
<tbody id="pac-tbody"></tbody>
|
|
186
|
-
</table>
|
|
187
|
-
</div>
|
|
188
|
-
<div id="pac-modal" class="card" style="display:none;max-width:400px;margin-top:16px;">
|
|
189
|
-
<h3 style="margin-top:0;">编辑规则</h3>
|
|
190
|
-
<input type="hidden" id="pac-edit-id" />
|
|
191
|
-
<label>类型</label>
|
|
192
|
-
<select id="pac-edit-type" class="input-max-w-200" style="margin-bottom:8px;">
|
|
193
|
-
<option value="host">按 Host</option>
|
|
194
|
-
<option value="url">按 URL</option>
|
|
195
|
-
<option value="default">默认</option>
|
|
196
|
-
</select>
|
|
197
|
-
<label>匹配模式(Host 可用 * 通配,如 *.google.com)</label>
|
|
198
|
-
<input type="text" id="pac-edit-pattern" class="input-max-w-320" placeholder="*.example.com 或 https://api.*/path" style="margin-bottom:8px;" />
|
|
199
|
-
<label>代理(由 node-proxy CLI 端口决定)</label>
|
|
200
|
-
<select id="pac-edit-proxy" class="input-max-w-200" style="margin-bottom:8px;">
|
|
201
|
-
<option value="proxy">代理</option>
|
|
202
|
-
<option value="direct">直连</option>
|
|
203
|
-
</select>
|
|
204
|
-
<label>顺序(数字越小越先匹配)</label>
|
|
205
|
-
<input type="number" id="pac-edit-order" min="0" value="0" style="margin-bottom:8px;" />
|
|
206
|
-
<label><input type="checkbox" id="pac-edit-enabled" checked /> 启用</label>
|
|
207
|
-
<p style="margin-top:12px;">
|
|
208
|
-
<button type="button" id="pac-modal-save">保存</button>
|
|
209
|
-
<button type="button" id="pac-modal-cancel" class="secondary">取消</button>
|
|
210
|
-
</p>
|
|
211
|
-
</div>
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
<div id="page-traffic" class="page">
|
|
215
|
-
<h1>流量记录</h1>
|
|
216
|
-
<div class="card">
|
|
217
|
-
<p style="margin-top:0;">经本地代理的请求(仅元数据,不含 body)</p>
|
|
218
|
-
<p style="color:#a1a1aa;font-size:0.8rem;margin-top:4px;">HTTPS 按 <strong>CONNECT 隧道</strong>记录:每个「host:端口」一条,隧道内的多条请求(如页面里的 100 个资源)不会逐条列出,故条数会少于浏览器 Network 的请求数;直连的请求不会经过代理,也不会出现在本列表。</p>
|
|
219
|
-
<button id="btn-refresh-traffic">刷新</button>
|
|
220
|
-
<button id="btn-clear-traffic" class="secondary">清空</button>
|
|
221
|
-
<table style="margin-top:12px;">
|
|
222
|
-
<thead>
|
|
223
|
-
<tr><th>时间</th><th>方法</th><th>URL / Host</th><th>状态</th><th>代理/直连</th><th>大小</th><th></th></tr>
|
|
224
|
-
</thead>
|
|
225
|
-
<tbody id="traffic-tbody"></tbody>
|
|
226
|
-
</table>
|
|
227
|
-
</div>
|
|
228
|
-
<div id="traffic-detail" class="card" style="display:none;">
|
|
229
|
-
<h3>请求详情 <button id="btn-close-detail" class="secondary">关闭</button></h3>
|
|
230
|
-
<div id="traffic-detail-body"></div>
|
|
231
|
-
</div>
|
|
232
|
-
</div>
|
|
233
|
-
</main>
|
|
234
|
-
<script>
|
|
235
|
-
const MODE_KEY = 'proxy-panel-mode';
|
|
236
|
-
function getPanelMode() { return localStorage.getItem(MODE_KEY); }
|
|
237
|
-
function setPanelMode(mode) {
|
|
238
|
-
if (mode) localStorage.setItem(MODE_KEY, mode); else localStorage.removeItem(MODE_KEY);
|
|
239
|
-
applyModeUI();
|
|
240
|
-
}
|
|
241
|
-
function clearPanelModeAndShowSelect() {
|
|
242
|
-
localStorage.removeItem(MODE_KEY);
|
|
243
|
-
applyModeUI();
|
|
244
|
-
}
|
|
245
|
-
function applyModeUI() {
|
|
246
|
-
const mode = getPanelMode();
|
|
247
|
-
const wrapSelect = document.getElementById('wrap-mode-select');
|
|
248
|
-
const navEl = document.querySelector('nav');
|
|
249
|
-
const mainEl = document.querySelector('main');
|
|
250
|
-
const cardLogin = document.getElementById('card-login');
|
|
251
|
-
const navUser = document.getElementById('nav-user');
|
|
252
|
-
const btnPull = document.getElementById('btn-pac-pull');
|
|
253
|
-
const btnPush = document.getElementById('btn-pac-push');
|
|
254
|
-
if (wrapSelect) wrapSelect.style.display = mode ? 'none' : 'block';
|
|
255
|
-
if (navEl) navEl.style.display = mode ? '' : 'none';
|
|
256
|
-
if (mainEl) mainEl.style.display = mode ? '' : 'none';
|
|
257
|
-
document.body.classList.toggle('mode-select', !mode);
|
|
258
|
-
if (mode === 'local') {
|
|
259
|
-
if (cardLogin) cardLogin.style.display = 'none';
|
|
260
|
-
if (navUser) navUser.textContent = '本地模式';
|
|
261
|
-
if (btnPull) btnPull.style.display = 'none';
|
|
262
|
-
if (btnPush) btnPush.style.display = 'none';
|
|
263
|
-
} else if (mode === 'cloud') {
|
|
264
|
-
if (cardLogin) cardLogin.style.display = '';
|
|
265
|
-
if (btnPull) btnPull.style.display = '';
|
|
266
|
-
if (btnPush) btnPush.style.display = '';
|
|
267
|
-
syncCloudLoginUI();
|
|
268
|
-
}
|
|
269
|
-
if (mode && document.querySelector('.nav-link.active')?.dataset.page === 'pac') loadPac();
|
|
270
|
-
syncProxyToken();
|
|
271
|
-
}
|
|
272
|
-
/** 云端模式:根据是否登录显示登录/注册 或 已登录状态 */
|
|
273
|
-
function syncCloudLoginUI() {
|
|
274
|
-
if (getPanelMode() !== 'cloud') return;
|
|
275
|
-
const formWrap = document.getElementById('login-form-wrap');
|
|
276
|
-
const loggedWrap = document.getElementById('login-logged-wrap');
|
|
277
|
-
const navUser = document.getElementById('nav-user');
|
|
278
|
-
const token = getToken();
|
|
279
|
-
const isLoggedIn = !!(token && token.split('.').length === 3);
|
|
280
|
-
if (formWrap) formWrap.style.display = isLoggedIn ? 'none' : '';
|
|
281
|
-
if (loggedWrap) loggedWrap.style.display = isLoggedIn ? '' : 'none';
|
|
282
|
-
if (navUser) navUser.textContent = isLoggedIn ? (parseTokenUsername(token) || '已登录') : '未登录';
|
|
283
|
-
syncProxyToken();
|
|
284
|
-
}
|
|
285
|
-
function parseTokenUsername(token) {
|
|
286
|
-
try {
|
|
287
|
-
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
288
|
-
return (payload.username || '').trim().toLowerCase();
|
|
289
|
-
} catch (_) { return ''; }
|
|
290
|
-
}
|
|
291
|
-
/** 使用 PROXY_SERVER_URL 时:将登录 token 同步给本地面板,由代理转发到私有 server 做校验 */
|
|
292
|
-
function syncProxyToken() {
|
|
293
|
-
const token = getPanelMode() === 'cloud' ? (getToken() || '') : '';
|
|
294
|
-
fetch('/api/upstream/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => {});
|
|
295
|
-
}
|
|
296
|
-
document.querySelectorAll('.mode-card').forEach((el) => {
|
|
297
|
-
el.addEventListener('click', () => {
|
|
298
|
-
const mode = el.getAttribute('data-mode');
|
|
299
|
-
if (mode) setPanelMode(mode);
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
document.getElementById('nav-switch-mode').addEventListener('click', (e) => {
|
|
303
|
-
e.preventDefault();
|
|
304
|
-
clearPanelModeAndShowSelect();
|
|
305
|
-
});
|
|
306
|
-
const TOKEN_KEY = 'proxy_token';
|
|
307
|
-
let cachedServerUrl = '';
|
|
308
|
-
async function getServerUrl() {
|
|
309
|
-
if (cachedServerUrl) return cachedServerUrl;
|
|
310
|
-
const r = await fetch('/api/config');
|
|
311
|
-
const j = await r.json().catch(() => ({}));
|
|
312
|
-
cachedServerUrl = (j.serverUrl || '').trim();
|
|
313
|
-
cachedProxyAddress = (j.proxyAddress || '127.0.0.1:3893').trim();
|
|
314
|
-
return cachedServerUrl;
|
|
315
|
-
}
|
|
316
|
-
async function ensureConfig() {
|
|
317
|
-
if (!cachedProxyAddress) await getServerUrl();
|
|
318
|
-
}
|
|
319
|
-
function getToken() { return localStorage.getItem(TOKEN_KEY); }
|
|
320
|
-
function setToken(v) { if (v) localStorage.setItem(TOKEN_KEY, v); else localStorage.removeItem(TOKEN_KEY); updateUserUI(); }
|
|
321
|
-
function updateUserUI() {
|
|
322
|
-
try {
|
|
323
|
-
if (getPanelMode() === 'local') {
|
|
324
|
-
const el = document.getElementById('nav-user');
|
|
325
|
-
if (el) el.textContent = '本地模式';
|
|
326
|
-
syncProxyToken();
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
if (getPanelMode() === 'cloud') {
|
|
330
|
-
const t = getToken();
|
|
331
|
-
if (t && t.split('.').length !== 3) localStorage.removeItem(TOKEN_KEY);
|
|
332
|
-
syncCloudLoginUI();
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
} catch (_) {
|
|
336
|
-
if (typeof localStorage !== 'undefined') localStorage.removeItem(TOKEN_KEY);
|
|
337
|
-
}
|
|
338
|
-
syncProxyToken();
|
|
339
|
-
}
|
|
340
|
-
if (!getPanelMode()) {
|
|
341
|
-
document.getElementById('wrap-mode-select').style.display = 'block';
|
|
342
|
-
document.body.classList.add('mode-select');
|
|
343
|
-
} else {
|
|
344
|
-
applyModeUI();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const statusEl = document.getElementById('status');
|
|
348
|
-
async function refreshSysproxyStatus() {
|
|
349
|
-
const el = document.getElementById('sysproxy-current');
|
|
350
|
-
if (!el) return;
|
|
351
|
-
try {
|
|
352
|
-
const r = await fetch('/api/sysproxy/status');
|
|
353
|
-
const j = await r.ok ? r.json() : {};
|
|
354
|
-
if (j.mode === 'global' && j.proxyServer) el.textContent = '全局代理 ' + j.proxyServer;
|
|
355
|
-
else if (j.mode === 'pac' && j.pacUrl) el.textContent = 'PAC 代理 ' + j.pacUrl;
|
|
356
|
-
else if (j.mode === 'none') el.textContent = '未设置';
|
|
357
|
-
else el.textContent = j.error ? '读取失败: ' + j.error : '未知';
|
|
358
|
-
} catch (e) { el.textContent = '读取失败: ' + (e.message || '网络错误'); }
|
|
359
|
-
}
|
|
360
|
-
document.getElementById('btn-apply-proxy').addEventListener('click', async () => {
|
|
361
|
-
const mode = document.querySelector('input[name="sysproxy-mode"]:checked')?.value || 'pac';
|
|
362
|
-
statusEl.textContent = '请求中…';
|
|
363
|
-
try {
|
|
364
|
-
const r = await fetch('/api/sysproxy/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) });
|
|
365
|
-
const j = await r.json();
|
|
366
|
-
statusEl.textContent = j.ok ? (mode === 'global' ? '已应用全局代理' : '已应用 PAC 代理') : (j.error || '失败');
|
|
367
|
-
if (j.ok) refreshSysproxyStatus();
|
|
368
|
-
} catch (e) { statusEl.textContent = '错误: ' + e.message; }
|
|
369
|
-
});
|
|
370
|
-
document.getElementById('btn-clear-proxy').addEventListener('click', async () => {
|
|
371
|
-
statusEl.textContent = '请求中…';
|
|
372
|
-
try {
|
|
373
|
-
const r = await fetch('/api/sysproxy/clear', { method: 'POST' });
|
|
374
|
-
const j = await r.json();
|
|
375
|
-
statusEl.textContent = j.ok ? '已清除系统代理' : (j.error || '失败');
|
|
376
|
-
if (j.ok) refreshSysproxyStatus();
|
|
377
|
-
} catch (e) { statusEl.textContent = '错误: ' + e.message; }
|
|
378
|
-
});
|
|
379
|
-
fetch('/api/config').then((r) => r.json()).then((j) => {
|
|
380
|
-
const el = document.getElementById('sysproxy-pac-url');
|
|
381
|
-
if (el && j.pacUrl) el.textContent = j.pacUrl;
|
|
382
|
-
}).catch(() => {});
|
|
383
|
-
refreshSysproxyStatus();
|
|
384
|
-
|
|
385
|
-
document.querySelectorAll('.nav-link').forEach(a => {
|
|
386
|
-
a.addEventListener('click', (e) => {
|
|
387
|
-
e.preventDefault();
|
|
388
|
-
document.querySelectorAll('.nav-link').forEach(x => x.classList.remove('active'));
|
|
389
|
-
document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
|
|
390
|
-
a.classList.add('active');
|
|
391
|
-
const page = document.getElementById('page-' + a.dataset.page);
|
|
392
|
-
if (page) page.classList.add('active');
|
|
393
|
-
if (a.dataset.page === 'pac') loadPac();
|
|
394
|
-
if (a.dataset.page === 'traffic') loadTraffic();
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
let pacRulesCache = [];
|
|
399
|
-
let cachedProxyAddress = '';
|
|
400
|
-
async function pacFetch(path, opts = {}) {
|
|
401
|
-
const headers = { ...(opts.headers || {}) };
|
|
402
|
-
if (opts.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
403
|
-
const optsWithHeaders = { ...opts, headers };
|
|
404
|
-
if (getPanelMode() !== 'cloud') return fetch(path.startsWith('/') ? path : '/' + path, optsWithHeaders);
|
|
405
|
-
const base = (await getServerUrl()).replace(/\/$/, '');
|
|
406
|
-
const token = getToken();
|
|
407
|
-
if (base && token) {
|
|
408
|
-
const url = base + path;
|
|
409
|
-
headers['Authorization'] = 'Bearer ' + token;
|
|
410
|
-
return fetch(url, { ...opts, headers });
|
|
411
|
-
}
|
|
412
|
-
return fetch(path.startsWith('/') ? path : '/' + path, optsWithHeaders);
|
|
413
|
-
}
|
|
414
|
-
async function loadPac() {
|
|
415
|
-
try {
|
|
416
|
-
const r = await pacFetch('/api/pac/rules');
|
|
417
|
-
const j = await r.json();
|
|
418
|
-
if (!r.ok) { document.getElementById('pac-status').textContent = j.error || '加载失败'; return; }
|
|
419
|
-
const rules = j.rules || [];
|
|
420
|
-
pacRulesCache = rules;
|
|
421
|
-
const tbody = document.getElementById('pac-tbody');
|
|
422
|
-
const typeNames = { host: '按 Host', url: '按 URL', default: '默认' };
|
|
423
|
-
tbody.innerHTML = rules.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((rule) => {
|
|
424
|
-
const patternText = (rule.pattern != null && rule.pattern !== '') ? rule.pattern : (rule.host != null && rule.host !== '') ? rule.host : '-';
|
|
425
|
-
return `
|
|
426
|
-
<tr data-id="${rule.id}">
|
|
427
|
-
<td>${typeNames[rule.type] || rule.type}</td>
|
|
428
|
-
<td><span class="code">${escapeHtml(patternText)}</span></td>
|
|
429
|
-
<td>${String(rule.proxy || '').trim().toUpperCase() === 'DIRECT' ? '直连' : '代理'}</td>
|
|
430
|
-
<td>${rule.enabled !== false ? '是' : '否'}</td>
|
|
431
|
-
<td>${rule.order ?? 0}</td>
|
|
432
|
-
<td>
|
|
433
|
-
<button type="button" class="secondary pac-btn-edit" data-id="${rule.id}">编辑</button>
|
|
434
|
-
<button type="button" class="secondary pac-btn-del" data-id="${rule.id}">删除</button>
|
|
435
|
-
</td>
|
|
436
|
-
</tr>
|
|
437
|
-
`;
|
|
438
|
-
}).join('');
|
|
439
|
-
tbody.querySelectorAll('.pac-btn-edit').forEach((btn) => btn.addEventListener('click', () => openPacModal(btn.dataset.id).catch(() => {})));
|
|
440
|
-
tbody.querySelectorAll('.pac-btn-del').forEach((btn) => btn.addEventListener('click', () => deletePacRule(btn.dataset.id)));
|
|
441
|
-
document.getElementById('pac-status').textContent = '';
|
|
442
|
-
} catch (e) {
|
|
443
|
-
document.getElementById('pac-status').textContent = '加载失败';
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
/** 生成可供 Shadowsocks 等使用的规则文本:仅「走代理」且启用的规则,每行一个匹配模式 */
|
|
447
|
-
function getPacRulesExportText() {
|
|
448
|
-
const rules = (pacRulesCache || []).filter((r) => r.enabled !== false && String(r.proxy || '').trim().toUpperCase() !== 'DIRECT');
|
|
449
|
-
rules.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
450
|
-
return rules.map((r) => (r.pattern != null && r.pattern !== '' ? r.pattern : r.host || '')).filter(Boolean).join('\n');
|
|
451
|
-
}
|
|
452
|
-
function escapeHtml(s) {
|
|
453
|
-
const div = document.createElement('div');
|
|
454
|
-
div.textContent = s;
|
|
455
|
-
return div.innerHTML;
|
|
456
|
-
}
|
|
457
|
-
async function openPacModal(id) {
|
|
458
|
-
await ensureConfig();
|
|
459
|
-
const rule = id ? pacRulesCache.find((r) => r.id === id) : null;
|
|
460
|
-
const proxyVal = rule ? rule.proxy : '';
|
|
461
|
-
const isDirect = typeof proxyVal === 'string' && proxyVal.toUpperCase() === 'DIRECT';
|
|
462
|
-
document.getElementById('pac-edit-id').value = id || '';
|
|
463
|
-
document.getElementById('pac-edit-type').value = rule ? rule.type : 'host';
|
|
464
|
-
document.getElementById('pac-edit-pattern').value = rule ? (rule.pattern != null && rule.pattern !== '' ? rule.pattern : rule.host || '') : '';
|
|
465
|
-
document.getElementById('pac-edit-proxy').value = isDirect ? 'direct' : 'proxy';
|
|
466
|
-
document.getElementById('pac-edit-order').value = rule ? (rule.order ?? 0) : pacRulesCache.length;
|
|
467
|
-
document.getElementById('pac-edit-enabled').checked = rule ? rule.enabled !== false : true;
|
|
468
|
-
document.getElementById('pac-modal').style.display = 'block';
|
|
469
|
-
}
|
|
470
|
-
function closePacModal() {
|
|
471
|
-
document.getElementById('pac-modal').style.display = 'none';
|
|
472
|
-
}
|
|
473
|
-
document.getElementById('btn-pac-add').addEventListener('click', () => openPacModal(null).catch(() => {}));
|
|
474
|
-
document.getElementById('btn-pac-copy').addEventListener('click', async () => {
|
|
475
|
-
const text = getPacRulesExportText();
|
|
476
|
-
const pacStatus = document.getElementById('pac-status');
|
|
477
|
-
try {
|
|
478
|
-
await navigator.clipboard.writeText(text);
|
|
479
|
-
pacStatus.textContent = text ? '已复制到剪贴板,可粘贴到 Shadowsocks 自定义规则' : '当前无走代理的规则可复制';
|
|
480
|
-
} catch (e) {
|
|
481
|
-
pacStatus.textContent = '复制失败: ' + (e.message || '请手动选择表格内容复制');
|
|
482
|
-
}
|
|
483
|
-
});
|
|
484
|
-
document.getElementById('btn-pac-export').addEventListener('click', () => {
|
|
485
|
-
const text = getPacRulesExportText();
|
|
486
|
-
const pacStatus = document.getElementById('pac-status');
|
|
487
|
-
if (!text) { pacStatus.textContent = '当前无走代理的规则可导出'; return; }
|
|
488
|
-
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
489
|
-
const url = URL.createObjectURL(blob);
|
|
490
|
-
const a = document.createElement('a');
|
|
491
|
-
a.href = url;
|
|
492
|
-
a.download = 'pac-rules-' + new Date().toISOString().slice(0, 10) + '.txt';
|
|
493
|
-
a.click();
|
|
494
|
-
URL.revokeObjectURL(url);
|
|
495
|
-
pacStatus.textContent = '已导出,可导入 Shadowsocks 自定义规则';
|
|
496
|
-
});
|
|
497
|
-
document.getElementById('pac-modal-cancel').addEventListener('click', closePacModal);
|
|
498
|
-
document.getElementById('pac-modal-save').addEventListener('click', async () => {
|
|
499
|
-
await ensureConfig();
|
|
500
|
-
const id = document.getElementById('pac-edit-id').value;
|
|
501
|
-
const proxySel = document.getElementById('pac-edit-proxy').value;
|
|
502
|
-
const proxyStr = proxySel === 'direct' ? 'DIRECT' : ('PROXY ' + (cachedProxyAddress || '127.0.0.1:3893'));
|
|
503
|
-
const body = {
|
|
504
|
-
type: document.getElementById('pac-edit-type').value,
|
|
505
|
-
pattern: document.getElementById('pac-edit-pattern').value.trim(),
|
|
506
|
-
proxy: proxyStr,
|
|
507
|
-
enabled: document.getElementById('pac-edit-enabled').checked,
|
|
508
|
-
order: parseInt(document.getElementById('pac-edit-order').value, 10) || 0,
|
|
509
|
-
};
|
|
510
|
-
try {
|
|
511
|
-
if (id) {
|
|
512
|
-
const r = await pacFetch('/api/pac/rules/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
513
|
-
if (!r.ok) { document.getElementById('pac-status').textContent = '更新失败'; return; }
|
|
514
|
-
document.getElementById('pac-status').textContent = '已更新';
|
|
515
|
-
} else {
|
|
516
|
-
const r = await pacFetch('/api/pac/rules', { method: 'POST', body: JSON.stringify(body) });
|
|
517
|
-
const j = await r.json().catch(() => ({}));
|
|
518
|
-
if (!r.ok) {
|
|
519
|
-
document.getElementById('pac-status').textContent = '添加失败: ' + (j.error || r.statusText || r.status);
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
document.getElementById('pac-status').textContent = '已添加';
|
|
523
|
-
}
|
|
524
|
-
closePacModal();
|
|
525
|
-
await loadPac();
|
|
526
|
-
if (getPanelMode() === 'cloud') await syncCloudRulesToLocal();
|
|
527
|
-
} catch (e) {
|
|
528
|
-
document.getElementById('pac-status').textContent = '保存失败: ' + (e.message || '请检查网络与服务器');
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
|
-
async function deletePacRule(id) {
|
|
532
|
-
if (!confirm('确定删除该规则?')) return;
|
|
533
|
-
try {
|
|
534
|
-
const r = await pacFetch('/api/pac/rules/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
535
|
-
if (!r.ok) { document.getElementById('pac-status').textContent = '删除失败'; return; }
|
|
536
|
-
document.getElementById('pac-status').textContent = '已删除';
|
|
537
|
-
await loadPac();
|
|
538
|
-
if (getPanelMode() === 'cloud') await syncCloudRulesToLocal();
|
|
539
|
-
} catch (e) {
|
|
540
|
-
document.getElementById('pac-status').textContent = '删除失败';
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
/** 云端模式:把当前规则列表同步到本机,使本机 PAC(/pac.js)使用与云端一致的规则 */
|
|
544
|
-
async function syncCloudRulesToLocal() {
|
|
545
|
-
const rules = pacRulesCache && pacRulesCache.length >= 0 ? pacRulesCache : [];
|
|
546
|
-
try {
|
|
547
|
-
const r = await fetch('/api/pac', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules }) });
|
|
548
|
-
if (r.ok) document.getElementById('pac-status').textContent = (document.getElementById('pac-status').textContent || '') + ' 已同步到本机';
|
|
549
|
-
} catch (_) {}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function formatTime(ts) {
|
|
553
|
-
const d = new Date(ts);
|
|
554
|
-
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + (d.getMilliseconds() + '').padStart(3, '0');
|
|
555
|
-
}
|
|
556
|
-
function statusClass(s) {
|
|
557
|
-
if (!s) return '';
|
|
558
|
-
if (s >= 200 && s < 300) return 'status-2xx';
|
|
559
|
-
if (s >= 400 && s < 500) return 'status-4xx';
|
|
560
|
-
if (s >= 500) return 'status-5xx';
|
|
561
|
-
return '';
|
|
562
|
-
}
|
|
563
|
-
function sizeStr(n) {
|
|
564
|
-
if (n == null) return '-';
|
|
565
|
-
if (n < 1024) return n + ' B';
|
|
566
|
-
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
567
|
-
return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function formatTrafficUrl(item) {
|
|
571
|
-
const raw = item.url || item.host || '';
|
|
572
|
-
if (!raw) return '-';
|
|
573
|
-
if (item.method === 'CONNECT' && /^[^\/]+:\d+$/.test(raw)) {
|
|
574
|
-
const [h, p] = raw.split(':');
|
|
575
|
-
const port = parseInt(p, 10);
|
|
576
|
-
return port === 443 ? `https://${h}` : `https://${h}:${port}`;
|
|
577
|
-
}
|
|
578
|
-
const s = String(raw).slice(0, 60);
|
|
579
|
-
return s + (raw.length > 60 ? '…' : '');
|
|
580
|
-
}
|
|
581
|
-
async function loadTraffic() {
|
|
582
|
-
try {
|
|
583
|
-
const r = await fetch('/api/capture');
|
|
584
|
-
const j = await r.json();
|
|
585
|
-
const tbody = document.getElementById('traffic-tbody');
|
|
586
|
-
tbody.innerHTML = (j.list || []).map(item => `
|
|
587
|
-
<tr>
|
|
588
|
-
<td>${formatTime(item.time)}</td>
|
|
589
|
-
<td>${item.method}</td>
|
|
590
|
-
<td title="${(item.url || item.host || '').slice(0, 200)}">${formatTrafficUrl(item)}</td>
|
|
591
|
-
<td class="${statusClass(item.statusCode)}">${item.statusCode ?? '-'}</td>
|
|
592
|
-
<td>${item.proxyDecision ?? '代理'}</td>
|
|
593
|
-
<td>${sizeStr(item.responseBodySize)}</td>
|
|
594
|
-
<td><button class="secondary" data-id="${item.id}">详情</button></td>
|
|
595
|
-
</tr>
|
|
596
|
-
`).join('');
|
|
597
|
-
tbody.querySelectorAll('button[data-id]').forEach(btn => {
|
|
598
|
-
btn.addEventListener('click', () => showDetail(btn.dataset.id));
|
|
599
|
-
});
|
|
600
|
-
} catch (e) {
|
|
601
|
-
document.getElementById('traffic-tbody').innerHTML = '<tr><td colspan="7">加载失败</td></tr>';
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
document.getElementById('btn-refresh-traffic').addEventListener('click', loadTraffic);
|
|
605
|
-
document.getElementById('btn-clear-traffic').addEventListener('click', async () => {
|
|
606
|
-
await fetch('/api/capture', { method: 'DELETE' });
|
|
607
|
-
loadTraffic();
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
async function showDetail(id) {
|
|
611
|
-
const r = await fetch('/api/capture/' + id);
|
|
612
|
-
const item = await r.json();
|
|
613
|
-
const body = document.getElementById('traffic-detail-body');
|
|
614
|
-
body.innerHTML = `
|
|
615
|
-
<p><strong>时间</strong> ${new Date(item.time).toLocaleString()}</p>
|
|
616
|
-
<p><strong>方法</strong> ${item.method}</p>
|
|
617
|
-
<p><strong>URL</strong> <span class="code">${(item.url || '-')}</span></p>
|
|
618
|
-
<p><strong>状态</strong> ${item.statusCode ?? '-'}</p>
|
|
619
|
-
<p><strong>代理/直连</strong> ${item.proxyDecision ?? '代理'}</p>
|
|
620
|
-
<p><strong>请求体大小</strong> ${sizeStr(item.requestBodySize)} <strong>响应体大小</strong> ${sizeStr(item.responseBodySize)}</p>
|
|
621
|
-
<p><strong>请求头</strong></p>
|
|
622
|
-
<pre class="code">${JSON.stringify(item.requestHeaders || {}, null, 2)}</pre>
|
|
623
|
-
<p><strong>响应头</strong></p>
|
|
624
|
-
<pre class="code">${JSON.stringify(item.responseHeaders || {}, null, 2)}</pre>
|
|
625
|
-
`;
|
|
626
|
-
document.getElementById('traffic-detail').style.display = 'block';
|
|
627
|
-
}
|
|
628
|
-
document.getElementById('btn-close-detail').addEventListener('click', () => {
|
|
629
|
-
document.getElementById('traffic-detail').style.display = 'none';
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
document.getElementById('btn-login').addEventListener('click', async (e) => {
|
|
633
|
-
e.preventDefault();
|
|
634
|
-
const base = (await getServerUrl()).replace(/\/$/, '');
|
|
635
|
-
const statusEl = document.getElementById('login-status');
|
|
636
|
-
if (!base) { statusEl.textContent = '请在项目 .env 中配置 PROXY_SERVER_URL'; return; }
|
|
637
|
-
const username = document.getElementById('login-username').value.trim();
|
|
638
|
-
const password = document.getElementById('login-password').value;
|
|
639
|
-
statusEl.textContent = '请求中…';
|
|
640
|
-
try {
|
|
641
|
-
const r = await fetch(base + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
|
642
|
-
const j = await r.json().catch(() => ({}));
|
|
643
|
-
if (j.token) { setToken(j.token); statusEl.textContent = '登录成功'; loadPac(); }
|
|
644
|
-
else statusEl.textContent = j.error || '登录失败';
|
|
645
|
-
} catch (e) { statusEl.textContent = '请求失败: ' + (e.message || '请确认服务器已启动且地址正确'); }
|
|
646
|
-
});
|
|
647
|
-
document.getElementById('btn-register').addEventListener('click', async (e) => {
|
|
648
|
-
e.preventDefault();
|
|
649
|
-
const base = (await getServerUrl()).replace(/\/$/, '');
|
|
650
|
-
const statusEl = document.getElementById('login-status');
|
|
651
|
-
if (!base) { statusEl.textContent = '请在项目 .env 中配置 PROXY_SERVER_URL'; return; }
|
|
652
|
-
const username = document.getElementById('login-username').value.trim();
|
|
653
|
-
const password = document.getElementById('login-password').value;
|
|
654
|
-
statusEl.textContent = '请求中…';
|
|
655
|
-
try {
|
|
656
|
-
const r = await fetch(base + '/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
|
657
|
-
const j = await r.json().catch(() => ({}));
|
|
658
|
-
if (j.token) { setToken(j.token); statusEl.textContent = '注册成功'; loadPac(); }
|
|
659
|
-
else statusEl.textContent = j.error || '注册失败';
|
|
660
|
-
} catch (e) { statusEl.textContent = '请求失败: ' + (e.message || '请确认服务器已启动且地址正确'); }
|
|
661
|
-
});
|
|
662
|
-
document.getElementById('btn-logout').addEventListener('click', () => { setToken(null); document.getElementById('login-status').textContent = ''; });
|
|
663
|
-
updateUserUI();
|
|
664
|
-
|
|
665
|
-
document.getElementById('btn-pac-pull').addEventListener('click', async () => {
|
|
666
|
-
const base = (await getServerUrl()).replace(/\/$/, '');
|
|
667
|
-
const pacStatus = document.getElementById('pac-status');
|
|
668
|
-
if (!base) { pacStatus.textContent = '请在 .env 中配置 PROXY_SERVER_URL'; return; }
|
|
669
|
-
const token = getToken();
|
|
670
|
-
pacStatus.textContent = '…';
|
|
671
|
-
if (!token) { pacStatus.textContent = '请先登录'; return; }
|
|
672
|
-
try {
|
|
673
|
-
const r = await fetch(base + '/api/pac', { headers: { 'Authorization': 'Bearer ' + token } });
|
|
674
|
-
let j = {};
|
|
675
|
-
try { j = await r.json(); } catch (_) { pacStatus.textContent = '响应格式错误'; return; }
|
|
676
|
-
if (!r.ok) {
|
|
677
|
-
pacStatus.textContent = (r.status === 401 ? '登录已过期,请重新登录' : '') || (j && j.error) || ('失败 ' + r.status);
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const rules = j && Array.isArray(j.rules) ? j.rules : null;
|
|
681
|
-
if (rules) {
|
|
682
|
-
const putR = await fetch('/api/pac', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules }) });
|
|
683
|
-
if (!putR.ok) { pacStatus.textContent = '写入本地失败'; return; }
|
|
684
|
-
pacRulesCache = rules;
|
|
685
|
-
const tbody = document.getElementById('pac-tbody');
|
|
686
|
-
const typeNames = { host: '按 Host', url: '按 URL', default: '默认' };
|
|
687
|
-
tbody.innerHTML = rules.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((rule) => {
|
|
688
|
-
const patternText = (rule.pattern != null && rule.pattern !== '') ? rule.pattern : (rule.host != null && rule.host !== '') ? rule.host : '-';
|
|
689
|
-
return `
|
|
690
|
-
<tr data-id="${rule.id}">
|
|
691
|
-
<td>${typeNames[rule.type] || rule.type}</td>
|
|
692
|
-
<td><span class="code">${escapeHtml(patternText)}</span></td>
|
|
693
|
-
<td>${String(rule.proxy || '').trim().toUpperCase() === 'DIRECT' ? '直连' : '代理'}</td>
|
|
694
|
-
<td>${rule.enabled !== false ? '是' : '否'}</td>
|
|
695
|
-
<td>${rule.order ?? 0}</td>
|
|
696
|
-
<td>
|
|
697
|
-
<button type="button" class="secondary pac-btn-edit" data-id="${rule.id}">编辑</button>
|
|
698
|
-
<button type="button" class="secondary pac-btn-del" data-id="${rule.id}">删除</button>
|
|
699
|
-
</td>
|
|
700
|
-
</tr>
|
|
701
|
-
`;
|
|
702
|
-
}).join('');
|
|
703
|
-
tbody.querySelectorAll('.pac-btn-edit').forEach((btn) => btn.addEventListener('click', () => openPacModal(btn.dataset.id).catch(() => {})));
|
|
704
|
-
tbody.querySelectorAll('.pac-btn-del').forEach((btn) => btn.addEventListener('click', () => deletePacRule(btn.dataset.id)));
|
|
705
|
-
pacStatus.textContent = '已从服务器拉取并写入本机,PAC 已使用新规则;若未生效请到首页重新「应用系统代理」';
|
|
706
|
-
} else {
|
|
707
|
-
pacStatus.textContent = '服务器存的是旧格式,无法转为规则表';
|
|
708
|
-
}
|
|
709
|
-
} catch (e) {
|
|
710
|
-
pacStatus.textContent = '请求失败: ' + (e.message || '请检查网络与服务器');
|
|
711
|
-
}
|
|
712
|
-
});
|
|
713
|
-
document.getElementById('btn-pac-push').addEventListener('click', async () => {
|
|
714
|
-
const base = (await getServerUrl()).replace(/\/$/, '');
|
|
715
|
-
const pacStatus = document.getElementById('pac-status');
|
|
716
|
-
if (!base) { pacStatus.textContent = '请在 .env 中配置 PROXY_SERVER_URL'; return; }
|
|
717
|
-
const token = getToken();
|
|
718
|
-
pacStatus.textContent = '…';
|
|
719
|
-
if (!token) { pacStatus.textContent = '请先登录'; return; }
|
|
720
|
-
try {
|
|
721
|
-
const r = await fetch('/api/pac/rules');
|
|
722
|
-
const j = await r.json();
|
|
723
|
-
const rules = j.rules || [];
|
|
724
|
-
const sr = await fetch(base + '/api/pac', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ rules }) });
|
|
725
|
-
const sj = await sr.json();
|
|
726
|
-
if (sr.ok) pacStatus.textContent = '已保存到服务器';
|
|
727
|
-
else pacStatus.textContent = sj.error || '失败';
|
|
728
|
-
} catch (e) { pacStatus.textContent = '请求失败'; }
|
|
729
|
-
});
|
|
730
|
-
</script>
|
|
731
|
-
</body>
|
|
732
|
-
</html>
|