- 奶油纸背景、薄荷主色、pill 输入/按钮、3D 按压阴影 - 焦点黄高亮、Title 飘带、圆角卡片表 - 仍为单文件静态 HTML,无 React/npm,适合 512MB
650 lines
25 KiB
HTML
650 lines
25 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>数据出口代理 · 小岛控制台</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||
<style>
|
||
/* animal-island-ui design tokens (from DESIGN_PROMPT / PROMPT.md) */
|
||
:root {
|
||
--primary: #19c8b9;
|
||
--primary-hover: #3dd4c6;
|
||
--primary-active: #11a89b;
|
||
--primary-bg: #e6f9f6;
|
||
--text: #794f27;
|
||
--text-body: #725d42;
|
||
--text-secondary: #9f927d;
|
||
--text-muted: #8a7b66;
|
||
--text-disabled: #c4b89e;
|
||
--bg: #f8f8f0;
|
||
--bg-content: rgb(247, 243, 223);
|
||
--bg-disabled: #f0ece2;
|
||
--border: #c4b89e;
|
||
--border-hover: #a89878;
|
||
--border-strong: #9f927d;
|
||
--success: #6fba2c;
|
||
--success-active: #5a9e1e;
|
||
--warning: #f5c31c;
|
||
--error: #e05a5a;
|
||
--error-active: #c94444;
|
||
--focus-yellow: #ffcc00;
|
||
--focus-yellow-d: #e0b800;
|
||
--shadow-btn: #bdaea0;
|
||
--shadow-soft: rgba(61, 52, 40, 0.06);
|
||
--r-sm: 12px;
|
||
--r-base: 18px;
|
||
--r-lg: 24px;
|
||
--r-pill: 50px;
|
||
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||
--d-base: 0.25s;
|
||
--app-teal: #82d5bb;
|
||
--app-blue: #889df0;
|
||
--app-yellow: #f7cd67;
|
||
--app-green: #8ac68a;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; min-height: 100%; }
|
||
body {
|
||
font-family: Nunito, "Noto Sans SC", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
font-weight: 500;
|
||
letter-spacing: 0.01em;
|
||
color: var(--text-body);
|
||
background:
|
||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(130, 213, 187, 0.35), transparent 55%),
|
||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(247, 205, 103, 0.28), transparent 50%),
|
||
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(136, 157, 240, 0.18), transparent 55%),
|
||
var(--bg);
|
||
}
|
||
|
||
/* decorative dots (card pattern vibe) */
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
opacity: 0.35;
|
||
background-image: radial-gradient(circle at 20px 20px, rgba(159, 146, 125, 0.18) 2px, transparent 2.5px);
|
||
background-size: 28px 28px;
|
||
z-index: 0;
|
||
}
|
||
|
||
.page { position: relative; z-index: 1; max-width: 980px; margin: 0 auto; padding: 20px 16px 48px; }
|
||
|
||
/* Title ribbon (simplified swallowtail) */
|
||
.title-ribbon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 36px 10px 22px;
|
||
background: linear-gradient(180deg, #fff9e8 0%, var(--app-yellow) 100%);
|
||
color: var(--text);
|
||
font-weight: 900;
|
||
font-size: 1.05rem;
|
||
letter-spacing: 0.04em;
|
||
border: 2.5px solid #e0b85a;
|
||
border-radius: 8px 4px 4px 8px;
|
||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 50%, calc(100% - 18px) 100%, 0 100%);
|
||
box-shadow: 3px 4px 0 0 rgba(189, 174, 160, 0.55);
|
||
margin: 0;
|
||
}
|
||
.title-ribbon .leaf {
|
||
width: 18px; height: 18px;
|
||
background: var(--success);
|
||
border-radius: 60% 0 60% 0;
|
||
transform: rotate(-25deg);
|
||
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.08);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
header.top {
|
||
display: flex; flex-wrap: wrap; gap: 14px;
|
||
align-items: center; justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
}
|
||
.sub { color: var(--text-muted); font-size: 0.9rem; margin-top: 8px; font-weight: 600; }
|
||
.header-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||
|
||
.pill {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 6px 14px; border-radius: var(--r-pill);
|
||
font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em;
|
||
border: 2px solid var(--border-strong);
|
||
background: #fff;
|
||
color: var(--text-body);
|
||
}
|
||
.pill.ok { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
|
||
.pill.bad { background: #fdecec; border-color: var(--error); color: var(--error-active); }
|
||
.pill.warn { background: #fff8df; border-color: var(--warning); color: #a07a08; }
|
||
|
||
/* Card */
|
||
.card {
|
||
background: var(--bg-content);
|
||
border: 2px solid var(--border-strong);
|
||
border-radius: 20px;
|
||
padding: 16px 18px;
|
||
margin-bottom: 14px;
|
||
transition: transform var(--d-base) var(--ease);
|
||
}
|
||
.card:hover { transform: translateY(-2px); }
|
||
.card h2 {
|
||
margin: 0 0 12px; font-size: 1rem; font-weight: 800;
|
||
color: var(--text); letter-spacing: 0.02em;
|
||
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
|
||
}
|
||
.card h2 .hint { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
|
||
|
||
.banner {
|
||
background: linear-gradient(135deg, var(--primary-bg), #fff8df 70%);
|
||
border: 2px solid rgba(25, 200, 185, 0.45);
|
||
border-radius: 20px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 14px;
|
||
color: var(--text-body);
|
||
line-height: 1.55;
|
||
font-weight: 600;
|
||
}
|
||
.banner strong { color: var(--text); }
|
||
|
||
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 150px; flex: 1 1 160px; }
|
||
.field label {
|
||
font-size: 0.8rem; font-weight: 700; color: var(--text-muted);
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
/* Input — pill, yellow focus */
|
||
input[type="text"], input[type="password"], input:not([type]) {
|
||
height: 40px;
|
||
padding: 0 18px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
color: var(--text-body);
|
||
background: var(--bg-content);
|
||
border: 2.5px solid var(--border);
|
||
border-radius: var(--r-pill);
|
||
outline: none;
|
||
transition: border-color var(--d-base) var(--ease), box-shadow var(--d-base) var(--ease);
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
input:hover { border-color: var(--border-hover); }
|
||
input:focus {
|
||
border-color: var(--focus-yellow);
|
||
box-shadow: 0 0 0 3px rgba(255, 204, 0, 0.18);
|
||
}
|
||
input::placeholder { color: var(--text-disabled); font-weight: 400; }
|
||
|
||
/* Buttons */
|
||
button {
|
||
font-family: inherit;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: all var(--d-base) var(--ease);
|
||
}
|
||
.btn {
|
||
height: 40px;
|
||
padding: 0 18px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-primary {
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
border-color: var(--bg);
|
||
box-shadow: 0 5px 0 0 var(--shadow-btn);
|
||
}
|
||
.btn-primary:hover {
|
||
box-shadow: 0 6px 0 0 var(--shadow-btn);
|
||
transform: translateY(-1px);
|
||
}
|
||
.btn-primary:active {
|
||
box-shadow: 0 1px 0 0 var(--shadow-btn);
|
||
transform: translateY(2px);
|
||
}
|
||
.btn-primary:focus-visible {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 2px;
|
||
}
|
||
.btn-default {
|
||
color: var(--text-body);
|
||
background: var(--bg);
|
||
border: 2px solid var(--border-strong);
|
||
box-shadow: 0 2px 4px 0 var(--shadow-soft);
|
||
}
|
||
.btn-default:hover {
|
||
color: var(--primary-active);
|
||
border-color: var(--primary);
|
||
box-shadow: 0 3px 10px 0 rgba(61, 52, 40, 0.10);
|
||
transform: translateY(-1px);
|
||
}
|
||
.btn-default:active { transform: translateY(0); }
|
||
.btn-danger {
|
||
color: #fff;
|
||
background: var(--error);
|
||
border-color: var(--error);
|
||
box-shadow: 0 5px 0 0 var(--error-active);
|
||
}
|
||
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 6px 0 0 var(--error-active); }
|
||
.btn-danger:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--error-active); }
|
||
.btn-sm { height: 32px; padding: 0 14px; font-size: 12px; border-radius: 16px; }
|
||
.btn-mint {
|
||
color: #fff;
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
box-shadow: 0 4px 0 0 var(--primary-active);
|
||
}
|
||
.btn-mint:hover { background: var(--primary-hover); transform: translateY(-1px); box-shadow: 0 5px 0 0 var(--primary-active); }
|
||
.btn-mint:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--primary-active); }
|
||
|
||
.muted { color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
|
||
.msg { margin-top: 10px; font-size: 0.85rem; font-weight: 700; min-height: 1.2em; }
|
||
.msg.ok { color: var(--success-active); }
|
||
.msg.bad { color: var(--error-active); }
|
||
|
||
/* Table */
|
||
.table-wrap { overflow: auto; border-radius: 16px; border: 2px solid var(--border); background: #fffef8; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1.5px solid rgba(196, 184, 158, 0.55); }
|
||
th {
|
||
color: var(--text-muted); font-weight: 700; font-size: 0.78rem;
|
||
letter-spacing: 0.04em; text-transform: uppercase;
|
||
background: rgba(247, 243, 223, 0.9);
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
tbody tr:hover td { background: rgba(230, 249, 246, 0.55); }
|
||
|
||
pre {
|
||
margin: 0; white-space: pre-wrap; word-break: break-all;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 0.8rem; font-weight: 500;
|
||
color: #f0e8d8;
|
||
background: #2b2118;
|
||
border: 2px solid #3d3028;
|
||
border-radius: 16px;
|
||
padding: 12px 14px;
|
||
max-height: 240px; overflow: auto;
|
||
}
|
||
|
||
.howto pre {
|
||
color: var(--text-body);
|
||
background: #fffef8;
|
||
border: 2px solid var(--border);
|
||
}
|
||
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.stat {
|
||
background: #fffef8;
|
||
border: 2px solid var(--border);
|
||
border-radius: 16px;
|
||
padding: 12px 14px;
|
||
}
|
||
.stat .k { font-size: 0.75rem; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; }
|
||
.stat .v { margin-top: 4px; font-size: 1.05rem; font-weight: 800; color: var(--text); word-break: break-all; }
|
||
|
||
.tag {
|
||
display: inline-block;
|
||
padding: 3px 10px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
border: 1.5px solid var(--border-strong);
|
||
background: #fff;
|
||
color: var(--text-body);
|
||
}
|
||
.tag.teal { background: var(--primary-bg); border-color: var(--primary); color: var(--primary-active); }
|
||
.tag.green { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
|
||
|
||
footer.foot {
|
||
margin-top: 22px;
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
.wave {
|
||
height: 28px;
|
||
margin: 18px 0 8px;
|
||
background:
|
||
radial-gradient(circle at 10px 18px, var(--app-teal) 8px, transparent 9px) 0 0 / 28px 28px,
|
||
radial-gradient(circle at 24px 18px, var(--app-blue) 6px, transparent 7px) 0 0 / 28px 28px;
|
||
opacity: 0.55;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
.btn { width: 100%; }
|
||
.header-actions { width: 100%; }
|
||
.title-ribbon { font-size: 0.95rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header class="top">
|
||
<div>
|
||
<h1 class="title-ribbon"><span class="leaf" aria-hidden="true"></span>数据出口代理</h1>
|
||
<div class="sub">走代理 = 走运营商流量 · animal-island 风格控制台</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<span id="connState" class="pill warn">未连接</span>
|
||
<button type="button" class="btn btn-default" onclick="refreshAll()">刷新</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="banner">
|
||
<strong>行为说明:</strong>系统默认仍可走 WiFi。
|
||
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
|
||
无需配置域名分流。面板视觉参考
|
||
<a href="https://github.com/guokaigdg/animal-island-ui" target="_blank" rel="noopener" style="color:var(--primary-active);font-weight:800">animal-island-ui</a>
|
||
设计 token(纯静态实现,无 React 依赖)。
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>连接设置 <span class="hint">密钥仅存本浏览器 localStorage</span></h2>
|
||
<div class="row">
|
||
<div class="field">
|
||
<label>Clash API(9090)</label>
|
||
<input id="apiBase" type="text" placeholder="http://127.0.0.1:9090" />
|
||
</div>
|
||
<div class="field">
|
||
<label>管理 API(9091)</label>
|
||
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" />
|
||
</div>
|
||
<div class="field">
|
||
<label>面板密钥 secret</label>
|
||
<input id="secret" type="password" placeholder="PANEL_SECRET" autocomplete="current-password" />
|
||
</div>
|
||
<div style="padding-bottom:1px">
|
||
<button type="button" class="btn btn-primary" onclick="saveAuth(); refreshAll()">保存并连接</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>
|
||
代理账号密码(7890)
|
||
<span id="proxyAuthState" class="pill warn">未知</span>
|
||
</h2>
|
||
<div class="row">
|
||
<div class="field">
|
||
<label>用户名</label>
|
||
<input id="proxyUser" type="text" placeholder="proxy" autocomplete="username" />
|
||
</div>
|
||
<div class="field">
|
||
<label>密码</label>
|
||
<input id="proxyPass" type="password" placeholder="新密码(不回显已有密码)" autocomplete="new-password" />
|
||
</div>
|
||
<div class="row" style="flex:0 0 auto;padding-bottom:1px">
|
||
<button type="button" class="btn btn-mint" onclick="saveProxyAuth()">保存并生效</button>
|
||
<button type="button" class="btn btn-default" onclick="clearProxyAuth()">清除鉴权</button>
|
||
<button type="button" class="btn btn-default" onclick="loadProxyAuth()">刷新状态</button>
|
||
</div>
|
||
</div>
|
||
<div class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
|
||
<div id="authMsg" class="msg"></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>状态</h2>
|
||
<div class="stat-grid">
|
||
<div class="stat"><div class="k">VERSION</div><div class="v" id="verInfo">—</div></div>
|
||
<div class="stat"><div class="k">PROXY</div><div class="v">7890 · HTTP/SOCKS</div></div>
|
||
<div class="stat"><div class="k">OUTBOUND</div><div class="v"><span class="tag teal">cellular</span></div></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>出站 <span class="hint">应只有 cellular(数据网)</span></h2>
|
||
<div id="proxies" class="muted">加载中…</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>
|
||
当前连接
|
||
<button type="button" class="btn btn-default btn-sm" onclick="loadConnections()">刷新</button>
|
||
</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="connBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card howto">
|
||
<h2>怎么用</h2>
|
||
<pre>1. 代理端口默认 7890(HTTP + SOCKS5 同一端口)
|
||
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
|
||
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p
|
||
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
|
||
5. 改网卡:sudo cpxy rebind
|
||
6. 低内存:MEMORY_MAX_MB=64~96,LOG_LEVEL=warn</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>API 调试</h2>
|
||
<pre id="raw">—</pre>
|
||
</section>
|
||
|
||
<div class="wave" aria-hidden="true"></div>
|
||
<footer class="foot">cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量</footer>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
|
||
function defaultAdminBase() {
|
||
try {
|
||
const u = new URL(defaultBase());
|
||
u.port = "9091";
|
||
return u.origin;
|
||
} catch {
|
||
return "http://127.0.0.1:9091";
|
||
}
|
||
}
|
||
function loadAuth() {
|
||
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
|
||
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
|
||
$("secret").value = localStorage.getItem("cpxy_secret") || "";
|
||
}
|
||
function saveAuth() {
|
||
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
|
||
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
|
||
localStorage.setItem("cpxy_secret", $("secret").value);
|
||
}
|
||
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
|
||
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
|
||
function headers() {
|
||
const h = { "Content-Type": "application/json" };
|
||
const s = $("secret").value;
|
||
if (s) h["Authorization"] = "Bearer " + s;
|
||
return h;
|
||
}
|
||
async function api(path, opt = {}) {
|
||
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
|
||
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
|
||
const t = await r.text();
|
||
try { return t ? JSON.parse(t) : {}; } catch { return t; }
|
||
}
|
||
async function adminApi(path, opt = {}) {
|
||
const r = await fetch(adminBase() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
|
||
const t = await r.text();
|
||
let data = {};
|
||
try { data = t ? JSON.parse(t) : {}; } catch { data = { raw: t }; }
|
||
if (!r.ok) throw new Error((data.error || data.message || t || r.status) + "");
|
||
return data;
|
||
}
|
||
function fmt(n) {
|
||
if (n == null) return "—";
|
||
const u = ["B", "KB", "MB", "GB"];
|
||
let i = 0, x = Number(n);
|
||
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||
return x.toFixed(i ? 1 : 0) + " " + u[i];
|
||
}
|
||
function setAuthMsg(text, ok) {
|
||
const el = $("authMsg");
|
||
el.textContent = text || "";
|
||
el.className = "msg" + (ok === true ? " ok" : (ok === false ? " bad" : ""));
|
||
}
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """).replace(/'/g, "'");
|
||
}
|
||
async function loadProxyAuth() {
|
||
try {
|
||
const data = await adminApi("/proxy-auth");
|
||
$("proxyUser").value = data.proxy_user || "";
|
||
if (data.auth_enabled) {
|
||
$("proxyAuthState").textContent = "已启用 · " + (data.proxy_user || "");
|
||
$("proxyAuthState").className = "pill ok";
|
||
setAuthMsg("当前已设置鉴权" + (data.proxy_pass_masked ? "(密码 " + data.proxy_pass_masked + ")" : ""), true);
|
||
} else {
|
||
$("proxyAuthState").textContent = "无鉴权";
|
||
$("proxyAuthState").className = "pill warn";
|
||
setAuthMsg("当前 7890 无账号密码", null);
|
||
}
|
||
$("proxyPass").value = "";
|
||
} catch (e) {
|
||
$("proxyAuthState").textContent = "管理 API 不可用";
|
||
$("proxyAuthState").className = "pill bad";
|
||
setAuthMsg("无法连接管理 API(9091)。确认 cellular-proxy-admin 已启动,或: sudo cpxy auth --user u --pass p。错误: " + e.message, false);
|
||
}
|
||
}
|
||
async function saveProxyAuth() {
|
||
saveAuth();
|
||
const user = $("proxyUser").value.trim();
|
||
const pass = $("proxyPass").value;
|
||
if (!user) { setAuthMsg("请填写用户名", false); return; }
|
||
if (!pass) { setAuthMsg("请填写密码(不会回显已有密码)", false); return; }
|
||
setAuthMsg("正在应用…", null);
|
||
try {
|
||
const data = await adminApi("/proxy-auth", {
|
||
method: "POST",
|
||
body: JSON.stringify({ user, pass })
|
||
});
|
||
setAuthMsg("已生效:用户 " + (data.proxy_user || user), true);
|
||
await loadProxyAuth();
|
||
} catch (e) {
|
||
setAuthMsg("保存失败: " + e.message, false);
|
||
}
|
||
}
|
||
async function clearProxyAuth() {
|
||
if (!confirm("确定清除代理账号密码?清除后 7890 无需鉴权。")) return;
|
||
saveAuth();
|
||
setAuthMsg("正在清除…", null);
|
||
try {
|
||
await adminApi("/proxy-auth", {
|
||
method: "POST",
|
||
body: JSON.stringify({ clear: true })
|
||
});
|
||
$("proxyUser").value = "";
|
||
$("proxyPass").value = "";
|
||
setAuthMsg("已清除鉴权", true);
|
||
await loadProxyAuth();
|
||
} catch (e) {
|
||
setAuthMsg("清除失败: " + e.message, false);
|
||
}
|
||
}
|
||
async function refreshAll() {
|
||
try {
|
||
const ver = await api("/version");
|
||
$("verInfo").textContent = ver.version || JSON.stringify(ver);
|
||
$("connState").textContent = "已连接";
|
||
$("connState").className = "pill ok";
|
||
$("raw").textContent = JSON.stringify(ver, null, 2);
|
||
await loadProxies();
|
||
await loadConnections();
|
||
await loadProxyAuth();
|
||
} catch (e) {
|
||
$("connState").textContent = "失败";
|
||
$("connState").className = "pill bad";
|
||
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>";
|
||
await loadProxyAuth();
|
||
}
|
||
}
|
||
async function loadProxies() {
|
||
const data = await api("/proxies");
|
||
const proxies = data.proxies || {};
|
||
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
|
||
const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
|
||
let html = '<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>';
|
||
for (const name of keys) {
|
||
const p = proxies[name] || {};
|
||
const safe = esc(name);
|
||
html += `<tr>
|
||
<td><span class="tag ${name === "cellular" ? "teal" : ""}">${safe}</span></td>
|
||
<td>${esc(p.type || "")}</td>
|
||
<td><button type="button" class="btn btn-default btn-sm" data-delay="${safe}">测速</button> <span id="d-${safe}"></span></td>
|
||
</tr>`;
|
||
}
|
||
html += "</tbody></table></div>";
|
||
$("proxies").innerHTML = html;
|
||
$("proxies").querySelectorAll("[data-delay]").forEach(btn => {
|
||
btn.addEventListener("click", () => delay(btn.getAttribute("data-delay")));
|
||
});
|
||
}
|
||
async function delay(name) {
|
||
try {
|
||
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
|
||
encodeURIComponent("https://www.gstatic.com/generate_204"));
|
||
const el = document.getElementById("d-" + name);
|
||
if (el) el.innerHTML = r.delay != null
|
||
? `<span class="tag green">${r.delay}ms</span>`
|
||
: esc(JSON.stringify(r));
|
||
} catch (e) {
|
||
const el = document.getElementById("d-" + name);
|
||
if (el) el.textContent = e.message;
|
||
}
|
||
}
|
||
async function loadConnections() {
|
||
try {
|
||
const data = await api("/connections");
|
||
const conns = data.connections || [];
|
||
const body = $("connBody");
|
||
body.innerHTML = "";
|
||
conns.slice(0, 50).forEach(c => {
|
||
const tr = document.createElement("tr");
|
||
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
|
||
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
|
||
tr.innerHTML = `<td>${esc(host)}${esc(port)}</td>
|
||
<td>${esc(c.chains ? c.chains.join("→") : "—")}</td>
|
||
<td>${esc(fmt(c.upload))}</td><td>${esc(fmt(c.download))}</td>
|
||
<td><button type="button" class="btn btn-danger btn-sm" data-close="${esc(c.id)}">断开</button></td>`;
|
||
body.appendChild(tr);
|
||
});
|
||
body.querySelectorAll("[data-close]").forEach(btn => {
|
||
btn.addEventListener("click", () => closeConn(btn.getAttribute("data-close")));
|
||
});
|
||
if (!conns.length) body.innerHTML = '<tr><td colspan="5" class="muted">暂无连接</td></tr>';
|
||
} catch (e) {
|
||
$("connBody").innerHTML = '<tr><td colspan="5">' + esc(e.message) + "</td></tr>";
|
||
}
|
||
}
|
||
async function closeConn(id) {
|
||
await api("/connections/" + id, { method: "DELETE" });
|
||
loadConnections();
|
||
}
|
||
loadAuth();
|
||
refreshAll();
|
||
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|