ai-model-studio / static /admin.html
slk1st's picture
Deploy AI Model Studio
8a03d2c verified
Raw History Blame Contribute Delete
80.8 kB
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vertex Proxy · Control</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg:#0a0c0f; --bg2:#0f1215; --bg3:#141820;
--bd:#1e2530; --bd2:#252d3a;
--tx:#c8d4e8; --tx2:#6b7f9a; --tx3:#3d4f63;
--ac:#00e5a0; --ac2:#00b87d; --ac3:rgba(0,229,160,.08);
--red:#ff4d6a; --red2:rgba(255,77,106,.1);
--yel:#f0c040; --blue:#4d9fff;
--glow:0 0 20px rgba(0,229,160,.15);
--shadow:0 4px 24px rgba(0,0,0,.4);
--mono:'JetBrains Mono',ui-monospace,monospace;
--sans:'Syne',system-ui,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:var(--mono);background:var(--bg);color:var(--tx);font-size:13px;line-height:1.6;-webkit-font-smoothing:antialiased}
body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(0,229,160,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(0,229,160,.022) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;z-index:0}
::-webkit-scrollbar{width:4px;height:4px} ::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
a{color:var(--ac);text-decoration:none}
button{font-family:var(--mono);cursor:pointer}
code{color:var(--ac);font-size:12px;background:var(--ac3);padding:1px 5px;border-radius:3px}
input,select,textarea{font-family:var(--mono);background:var(--bg);color:var(--tx);border:1px solid var(--bd2);border-radius:7px;padding:9px 12px;font-size:13px;outline:none;width:100%;transition:border-color .15s,box-shadow .15s}
input:-webkit-autofill,input:-webkit-autofill:hover,input:-webkit-autofill:focus,input:-webkit-autofill:active{-webkit-text-fill-color:var(--tx)!important;caret-color:var(--tx);box-shadow:0 0 0 1000px var(--bg) inset!important;border-color:var(--bd2);transition:background-color 9999s ease-out 0s}
textarea{min-height:126px;resize:vertical;line-height:1.55;background:linear-gradient(180deg,rgba(0,229,160,.025),transparent),var(--bg)}
input:focus,select:focus,textarea:focus{border-color:rgba(0,229,160,.4);box-shadow:0 0 0 3px rgba(0,229,160,.06)}
select option{background:var(--bg3)}
label{display:block;font-size:11px;color:var(--tx2);text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px}
input[type="file"].hidden-file{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
/* btns */
.btn{display:inline-flex;align-items:center;gap:6px;background:transparent;color:var(--tx2);border:1px solid var(--bd2);border-radius:7px;padding:8px 16px;font-size:12px;font-family:var(--mono);transition:all .15s}
.btn:hover{border-color:var(--tx3);color:var(--tx)}
.btn-primary{background:var(--ac);color:#000;border-color:var(--ac);font-weight:600}
.btn-primary:hover{background:var(--ac2);border-color:var(--ac2);color:#000}
.btn-danger{color:var(--tx3)}
.btn-danger:hover{border-color:var(--red);color:var(--red);background:var(--red2)}
.btn-sm{padding:5px 10px;font-size:11px;border-radius:5px}
.btn:disabled{opacity:.35;cursor:not-allowed}
.file-picker{display:flex;align-items:center;gap:10px;width:100%;max-width:360px;background:rgba(255,255,255,.02);border:1px dashed rgba(0,229,160,.26);border-radius:10px;padding:8px 10px;transition:all .15s}
.file-picker:hover{border-color:rgba(0,229,160,.55);background:rgba(0,229,160,.045)}
.file-name{font-size:11px;color:var(--tx2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px}
.soft-card{border:1px solid rgba(0,229,160,.12);background:linear-gradient(135deg,rgba(0,229,160,.06),rgba(77,159,255,.025));border-radius:12px;padding:14px;margin-top:14px}
.subtabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding:6px;background:rgba(255,255,255,.015);border:1px solid var(--bd);border-radius:10px}
.subtab{border:1px solid transparent;background:transparent;color:var(--tx2);border-radius:7px;padding:8px 12px;font-size:12px;transition:all .15s}
.subtab:hover{color:var(--tx);border-color:var(--bd2)}
.subtab.active{color:#001b13;background:var(--ac);border-color:var(--ac);font-weight:600}
.import-pane{display:none}
.import-pane.active{display:block;animation:fi .18s ease}
.collection-grid{display:grid;gap:12px;margin-top:14px}
.collection-card{border:1px solid var(--bd);background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);border-radius:10px;padding:14px;min-width:0}
.collection-card.accent{border-color:rgba(0,229,160,.18);background:linear-gradient(135deg,rgba(0,229,160,.05),rgba(77,159,255,.02)),var(--bg)}
.collection-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.collection-title .s-title{font-size:13px}
.filter-row{display:grid;grid-template-columns:minmax(220px,1fr) 170px 170px;gap:10px}
@media(max-width:760px){.filter-row{grid-template-columns:1fr}.collection-title{align-items:flex-start;flex-direction:column}}
/* toggle */
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:var(--bg);border:1px solid var(--bd2);border-radius:8px}
.toggle-info .t-title{font-size:13px;color:var(--tx)}
.toggle-info .t-sub{font-size:11px;color:var(--tx3);margin-top:2px}
.tog{position:relative;width:40px;height:22px;flex-shrink:0;margin-left:14px}
.tog input{display:none}
.tog-track{position:absolute;inset:0;background:var(--bd2);border-radius:11px;cursor:pointer;transition:background .2s}
.tog-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;background:var(--tx3);border-radius:50%;transition:all .2s;pointer-events:none}
.tog input:checked~.tog-track{background:rgba(0,229,160,.25)}
.tog input:checked~.tog-track .tog-thumb{left:21px;background:var(--ac);box-shadow:0 0 8px rgba(0,229,160,.5)}
/* login */
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;position:relative;z-index:1}
.login-card{width:100%;max-width:400px;background:var(--bg2);border:1px solid var(--bd);border-radius:14px;padding:44px 38px 36px;position:relative;box-shadow:var(--shadow)}
.login-card::before{content:'';position:absolute;top:-1px;left:20px;right:20px;height:2px;background:var(--ac);border-radius:0 0 2px 2px;box-shadow:var(--glow)}
.login-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px}
.login-logo .ico{width:42px;height:42px;background:var(--ac3);border:1px solid rgba(0,229,160,.3);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--glow)}
.login-logo h1{font-family:var(--sans);font-size:20px;font-weight:800;color:#fff}
.login-logo p{font-size:11px;color:var(--tx2);margin-top:1px}
.login-card form{display:flex;flex-direction:column;gap:14px}
.err-msg{color:var(--red);font-size:11px}
.hint{margin-top:20px;padding-top:16px;border-top:1px dashed var(--bd2);color:var(--tx3);font-size:11px;line-height:1.7}
/* app */
.app{display:none;position:relative;z-index:1}
.topbar{border-bottom:1px solid var(--bd);padding:13px 24px;display:flex;justify-content:space-between;align-items:center;background:var(--bg2);position:sticky;top:0;z-index:10;flex-wrap:wrap;gap:12px}
.brand{display:flex;align-items:center;gap:11px}
.brand-ico{width:34px;height:34px;background:var(--ac3);border:1px solid rgba(0,229,160,.25);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px}
.brand-txt{font-family:var(--sans);font-size:16px;font-weight:800;color:#fff}
.brand-txt span{color:var(--tx3);font-size:11px;font-family:var(--mono);font-weight:400;margin-left:8px}
.topbar-r{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.port-badge{font-size:11px;color:var(--tx2);padding:4px 10px;background:var(--bg3);border:1px solid var(--bd2);border-radius:6px;display:flex;align-items:center;gap:6px}
.port-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ac);box-shadow:0 0 6px var(--ac);animation:blink 2.5s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.top-status{display:flex;align-items:center;gap:10px;min-width:0;max-width:58vw;background:rgba(0,229,160,.045);border:1px solid rgba(0,229,160,.14);border-radius:8px;padding:5px 8px;color:var(--tx2);font-size:11px;flex-wrap:wrap}
.top-status.off{background:rgba(255,255,255,.015);border-color:var(--bd2)}
.top-status .ab-l{gap:7px;flex-wrap:wrap}
.top-status .ab-lbl{font-size:9px}
.top-status .ab-name{max-width:260px}
.top-status .btn{flex-shrink:0}
.top-status.off .ab-dot{background:var(--tx3);box-shadow:none}
@media(max-width:820px){
.topbar-r{flex:1 1 100%;justify-content:flex-end}
.top-status{max-width:100%;flex:1 1 100%;order:3;margin-top:4px;justify-content:space-between}
.port-badge{order:1}
.topbar-r > .btn{order:2}
}
@media(max-width:500px){
.topbar{padding:12px 16px}
.main{padding:20px 16px 80px}
.tabs{padding:0 16px}
}
/* active bar */
.active-bar{padding:9px 24px;background:rgba(0,229,160,.05);border-bottom:1px solid rgba(0,229,160,.15);display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;transition:all .3s;flex-wrap:wrap}
.active-bar.off{background:transparent;border-bottom-color:var(--bd)}
.ab-l{display:flex;align-items:center;gap:8px;min-width:0;flex:1;flex-wrap:wrap}
.ab-dot{width:7px;height:7px;border-radius:50%;background:var(--ac);box-shadow:0 0 6px var(--ac);flex-shrink:0}
.active-bar.off .ab-dot{background:var(--tx3);box-shadow:none}
.ab-lbl{color:var(--tx3);text-transform:uppercase;letter-spacing:.1em;font-size:10px;flex-shrink:0}
.active-bar:not(.off) .ab-lbl{color:var(--ac)}
.ab-name{color:var(--tx);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ab-via{color:var(--tx3);font-size:11px;flex-shrink:0}
.ab-pool-badge{font-size:10px;padding:2px 8px;background:rgba(77,159,255,.12);color:var(--blue);border:1px solid rgba(77,159,255,.2);border-radius:4px;flex-shrink:0}
/* tabs */
.tabs{display:flex;border-bottom:1px solid var(--bd);padding:0 24px;background:var(--bg2);gap:2px;flex-wrap:wrap}
.tab{background:transparent;border:none;border-bottom:2px solid transparent;padding:12px 16px;margin-bottom:-1px;color:var(--tx2);font-size:11px;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;font-weight:500;transition:all .15s;cursor:pointer}
.tab:hover{color:var(--tx)}
.tab.active{color:var(--ac);border-bottom-color:var(--ac)}
/* main */
.main{padding:28px 24px 80px;max-width:1180px}
.panel{display:none}
.panel.active{display:block;animation:fi .2s ease}
@keyframes fi{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
/* section card */
.section{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;margin-bottom:18px;overflow:hidden}
.s-hd{display:flex;align-items:flex-start;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--bd);background:var(--bg3)}
.s-title{font-family:var(--sans);font-size:14px;font-weight:700;color:#fff;display:flex;align-items:center;gap:8px}
.s-sub{font-size:11px;color:var(--tx3);margin-top:3px}
.s-body{padding:18px}
/* grid */
.grid{display:grid;gap:14px;grid-template-columns:1fr 1fr}
.grid .full{grid-column:1/-1}
.grid3{grid-template-columns:1fr 1fr 1fr}
@media(max-width:680px){.grid,.grid3{grid-template-columns:1fr}}
/* fields */
.field{display:flex;flex-direction:column;gap:6px}
.fhint{color:var(--tx3);font-size:11px;margin-top:4px}
.fwarn{color:var(--yel);font-size:11px;margin-top:4px}
.muted{color:var(--tx3);font-size:11px;line-height:1.7;word-break:break-all}
.actions{margin-top:18px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
/* table */
.table{width:100%;border-collapse:collapse;font-size:12px}
.table th{text-align:left;padding:8px 12px;color:var(--tx3);font-size:10px;font-weight:500;letter-spacing:.15em;text-transform:uppercase;border-bottom:1px solid var(--bd)}
.table td{padding:10px 12px;border-bottom:1px solid var(--bd)}
.table tr:last-child td{border-bottom:none}
.table tr:hover td{background:rgba(255,255,255,.012)}
.mono-cell{color:var(--tx)}
.masked{color:var(--tx2);cursor:pointer;letter-spacing:.04em}
.empty{padding:24px;text-align:center;color:var(--tx3);font-size:12px}
/* nodes */
.node-list{display:grid;gap:7px;margin-top:14px;max-height:480px;overflow-y:auto;padding-right:4px}
.node{border:1px solid var(--bd);border-radius:8px;padding:10px 14px;display:flex;align-items:center;gap:12px;transition:border-color .15s;background:var(--bg);min-width:0}
.node:hover{border-color:var(--bd2)}
.node.active{border-color:rgba(0,229,160,.45);background:rgba(0,229,160,.03)}
.node.selected{border-color:rgba(77,159,255,.65);box-shadow:0 0 0 1px rgba(77,159,255,.16) inset}
.node.in-pool{border-color:rgba(77,159,255,.35);background:rgba(77,159,255,.03)}
.node.active.in-pool{border-color:rgba(0,229,160,.6)}
.node-cb{width:15px;height:15px;accent-color:var(--blue);cursor:pointer;flex-shrink:0;margin:0}
.node-info{flex:1;min-width:0}
.node-name{font-size:13px;color:var(--tx);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all}
.node-meta{font-size:11px;color:var(--tx2);display:flex;gap:10px;flex-wrap:wrap}
.node-meta span { min-width:0; word-break:break-all }
.node-type{text-transform:uppercase;letter-spacing:.08em;color:var(--ac)}
.node-badges{display:flex;gap:5px;flex-shrink:0}
.node-actions{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end}
.nbadge{font-size:9px;padding:2px 6px;border-radius:3px;letter-spacing:.08em}
.nbadge-active{background:var(--ac3);color:var(--ac);border:1px solid rgba(0,229,160,.25)}
.nbadge-pool{background:rgba(77,159,255,.1);color:var(--blue);border:1px solid rgba(77,159,255,.2)}
.nbadge-idx{background:rgba(77,159,255,.07);color:var(--tx2);border:1px solid var(--bd2)}
.nbadge-off{background:rgba(255,255,255,.025);color:var(--tx3);border:1px solid var(--bd2)}
/* subscriptions */
.sub-list{display:grid;gap:8px;margin-top:14px}
.sub-item{border:1px solid var(--bd);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px;background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);min-width:0}
.sub-item:hover{border-color:var(--bd2)}
.sub-item.disabled{opacity:.58}
.sub-cb{width:15px;height:15px;accent-color:var(--blue);cursor:pointer;flex-shrink:0;margin:0}
@media(max-width:600px){
.node, .sub-item { flex-wrap: wrap; align-items: flex-start; }
.node-cb, .sub-cb { margin-top: 2px; }
.node-info, .sub-main { flex: 1 1 calc(100% - 30px); }
.node-badges { flex: 1 1 100%; margin-top: 6px; padding-left: 27px; }
.node-actions, .sub-actions { flex: 1 1 100%; margin-top: 6px; padding-left: 27px; justify-content: flex-start; gap: 8px; }
.node-actions .btn, .sub-actions .btn { flex: 1; justify-content: center; }
}
.sub-main{flex:1;min-width:0}
.sub-name{font-size:13px;color:var(--tx);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all}
.sub-url{font-size:11px;color:var(--tx3);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all}
.sub-meta{font-size:10px;color:var(--tx2);margin-top:2px;display:flex;gap:8px;flex-wrap:wrap}
.sub-actions{display:flex;align-items:center;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end}
.mini-toggle{display:flex;align-items:center;gap:6px;color:var(--tx2);font-size:11px;white-space:nowrap}
.sub-item.selected{border-color:rgba(77,159,255,.62);box-shadow:0 0 0 1px rgba(77,159,255,.14) inset;background:linear-gradient(135deg,rgba(77,159,255,.06),rgba(0,229,160,.025)),var(--bg)}
.import-stack{display:grid;gap:14px}
.import-block{border:1px solid var(--bd);background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);border-radius:10px;padding:14px;min-width:0}
.import-block .actions{margin-top:0}
.single-node-card{border:1px solid rgba(0,229,160,.16);background:linear-gradient(135deg,rgba(0,229,160,.055),rgba(77,159,255,.025)),var(--bg);border-radius:12px;padding:16px}
/* selection controls */
.select-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(0,229,160,.04);border:1px solid rgba(0,229,160,.12);border-radius:8px;margin-top:12px}
.select-bar-label{font-size:11px;color:var(--ac);text-transform:uppercase;letter-spacing:.1em}
.node-manager{display:grid;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr);gap:14px;margin-top:14px}
@media(max-width:860px){.node-manager{grid-template-columns:1fr}}
/* pool controls */
.pool-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(77,159,255,.05);border:1px solid rgba(77,159,255,.15);border-radius:8px;margin-top:10px}
.pool-bar-label{font-size:11px;color:var(--blue);text-transform:uppercase;letter-spacing:.1em}
/* core badge */
.pill-warn{display:inline-block;padding:2px 9px;font-size:10px;letter-spacing:.08em;text-transform:uppercase;border:1px solid rgba(240,192,64,.35);color:var(--yel);border-radius:4px;background:rgba(240,192,64,.08)}
/* toast */
.toast-wrap{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--bg3);border:1px solid var(--bd2);border-left:2px solid var(--ac);border-radius:9px;padding:11px 15px;min-width:220px;max-width:360px;font-size:12px;box-shadow:var(--shadow);animation:tin .2s cubic-bezier(.34,1.56,.64,1)}
.toast.ok{border-left-color:var(--ac)}
.toast.err{border-left-color:var(--red)}
@keyframes tin{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="loginView" class="login-wrap">
<div class="login-card">
<div class="login-logo">
<div class="ico">⬡</div>
<div><h1>Vertex Proxy</h1><p>Control Panel</p></div>
</div>
<form id="loginForm">
<div class="field">
<label for="pw">Admin password</label>
<input type="password" id="pw" autocomplete="current-password" autofocus required placeholder="••••••••">
</div>
<div class="err-msg" id="loginErr" style="display:none"></div>
<button class="btn btn-primary" type="submit" style="width:100%;justify-content:center;padding:11px;">Enter →</button>
</form>
<div class="hint">
首次部署?启动日志里会打印随机生成的管理员密码,搜索 <code>密码:</code>。<br>
也可以设置环境变量 <code>ADMIN_PASSWORD</code> 固定密码。
</div>
</div>
</div>
<!-- APP -->
<div class="app" id="appView">
<div class="topbar">
<div class="brand">
<div class="brand-ico">⬡</div>
<div class="brand-txt">Vertex Proxy <span>// control</span></div>
</div>
<div class="topbar-r">
<div class="top-status off" id="activeBar">
<div class="ab-l">
<span class="ab-dot"></span>
<span class="ab-lbl" id="activeLabel">未启用节点</span>
<span class="ab-name" id="activeName"></span>
<span class="ab-via" id="activeVia"></span>
<span class="ab-pool-badge" id="poolBadge" style="display:none"></span>
</div>
<button class="btn btn-sm btn-danger" id="stopBtn" onclick="stopProxy()" style="display:none;">停止并直连</button>
</div>
<div class="port-badge" id="portBadge">—</div>
<button class="btn btn-sm" onclick="logout()">退出登录</button>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="server">⚙ 系统</button>
<button class="tab" data-tab="keys">🔑 API Keys</button>
<button class="tab" data-tab="models">🤖 Models</button>
<button class="tab" data-tab="proxy">📡 节点与订阅</button>
</div>
<div class="main">
<!-- ═══ SERVER ═══ -->
<div class="panel active" id="panel-server">
<div class="section">
<div class="s-hd"><div><div class="s-title">🚀 Server</div><div class="s-sub">基本运行参数。端口与 debug 变更需重启容器生效。</div></div></div>
<div class="s-body">
<div class="grid">
<div class="field">
<label>API 端口</label>
<input type="number" id="port_api" min="1" max="65535">
<div class="fhint">容器暴露端口 (1–65535)</div>
</div>
<div class="field">
<label>Admin password</label>
<input type="password" id="newPw" autocomplete="new-password" data-lpignore="true" data-form-type="other" placeholder="至少 6 位">
<div class="fhint">修改管理面板登录密码。</div>
</div>
<div class="full">
<div class="toggle-row">
<div class="toggle-info"><div class="t-title">Debug 日志模式</div><div class="t-sub">输出 DEBUG 级别详细日志,正常运行建议关闭</div></div>
<label class="tog"><input type="checkbox" id="debug"><span class="tog-track"><span class="tog-thumb"></span></span></label>
</div>
</div>
</div>
<div id="pwLockedWarn" style="display:none;margin-top:12px;color:var(--yel);font-size:11px">
当前由环境变量 <code>ADMIN_PASSWORD</code> 锁定,不能在面板修改。
</div>
<div class="actions">
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
<span id="saveTip" class="fwarn"></span>
</div>
</div>
</div>
<div class="section">
<div class="s-hd"><div><div class="s-title">💬 会话</div><div class="s-sub">每个业务会话创建请求池,并行尝试候选节点;流式 winner 直接返回,非流式 winner 必须完整成功。</div></div></div>
<div class="s-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="grid">
<div class="field">
<label>并行池大小</label>
<input type="number" id="parallel_pool_size" min="1" max="64" placeholder="10" title="每个请求池同时运行的上游节点尝试数量;谁先返回有效首包就成为 winner。">
</div>
<div class="field">
<label>安全上限</label>
<input type="number" id="parallel_pool_max_size" min="1" max="64" placeholder="20" title="允许配置的最大并行池大小,用于防止误配置导致资源耗尽。">
</div>
<div class="field">
<label>候选队列长度</label>
<input type="number" id="candidate_queue_length" min="1" max="100000" placeholder="100" title="每轮请求池生成的候选节点数量;候选会按健康评分、冷却状态和加权随机排序。">
</div>
<div class="field">
<label>候选获取轮次</label>
<input type="number" id="candidate_queue_rounds" min="0" max="10000" placeholder="0" title="当前请求池候选队列耗尽后,最多重新获取候选队列的轮次;0 表示不限轮次。">
</div>
<div class="field">
<label>单节点内部重试</label>
<input type="number" id="node_retry_count" min="0" max="100" placeholder="1" title="单个节点协程内部重试次数;获取 token 或上游请求任一步失败都会消耗这个预算,耗尽后交给请求池换下一个候选节点。">
</div>
<div class="field">
<label>请求池首包超时</label>
<input type="number" id="request_pool_deadline_seconds" min="0" max="3600" step="1" placeholder="0" title="请求池从启动到收到上游响应首包并选出 winner 的总超时;0 表示使用底层网络超时。winner 选出后后续响应不受此配置影响。">
</div>
<div class="field">
<label>winner 停顿超时秒</label>
<input type="number" id="stream_winner_stall_timeout_seconds" min="0" max="3600" step="1" placeholder="0" title="winner 首包后如果超过该秒数没有新的上游 raw chunk 且响应未结束,则直接返回 504 上游响应超时并清理资源;0 表示关闭。">
</div>
<div class="field">
<label>Worker 起始端口</label>
<input type="number" id="parallel_worker_base_port" min="1" max="65535" placeholder="12080" title="临时代理 worker 申请端口的起始端口,用于 vmess/vless/trojan 等需要本地 worker 的节点。">
</div>
<div class="field">
<label>Worker 端口范围</label>
<input type="number" id="parallel_worker_port_span" min="1" max="20000" placeholder="2000" title="临时 worker 可申请端口数量,实际范围为起始端口到起始端口+范围-1。">
</div>
<div class="field">
<label>活动业务会话上限</label>
<input type="number" id="business_session_concurrency_limit" min="0" max="10000" placeholder="10" title="同时处于 active 状态的下游业务会话上限;0 表示不限。">
</div>
</div>
<div class="muted">说明:无启用节点=直连,启用 1 个节点=固定代理,启用多个节点=动态代理。并行池大小控制每轮请求池内同时运行的上游尝试数;活动业务会话上限控制同时 active 的下游会话数,0 表示不限,超过限制的新请求会等待;请求池首包超时只限制请求池选出 winner 前的等待时间,填 0 表示交给底层网络超时;winner 停顿超时用于首包后 raw chunk 长时间不再到达时直接判定 504,不重选其它候选;单节点内部重试填 0 表示 0 次重试。</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="saveParallelPool()">Save</button>
</div>
</div>
</div>
<div class="section">
<div class="s-hd"><div><div class="s-title">🛡️ 请求防护</div><div class="s-sub">Anti-tracking 与 token 控制。立即生效,无需重启。</div></div></div>
<div class="s-body">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="toggle-row">
<div class="toggle-info">
<div class="t-title">🛡️ 防标记注入 (anti_tracking)</div>
<div class="t-sub">在系统指令顶部注入随机字符,防止 Google 对会话进行行为标记</div>
</div>
<label class="tog"><input type="checkbox" id="anti_tracking"><span class="tog-track"><span class="tog-thumb"></span></span></label>
</div>
<div class="toggle-row">
<div class="toggle-info">
<div class="t-title">🚫 丢弃 max_tokens (drop_max_tokens)</div>
<div class="t-sub">转发请求时移除 max_tokens 字段,避免 Vertex 响应被意外截断</div>
</div>
<label class="tog"><input type="checkbox" id="drop_max_tokens"><span class="tog-track"><span class="tog-thumb"></span></span></label>
</div>
<div class="toggle-row">
<div class="toggle-info">
<div class="t-title">🔢 Anti-429 随机数注入</div>
<div class="t-sub">每次请求自动插入 100 位随机数,降低被缓存命中/限流的概率</div>
</div>
<label class="tog"><input type="checkbox" id="anti429_enabled"><span class="tog-track"><span class="tog-thumb"></span></span></label>
</div>
<div class="field" style="padding:0 2px">
<label>Anti-429 插入位置</label>
<select id="anti429_target">
<option value="system">System(系统消息最前面)</option>
<option value="user">User(第一条用户消息最前面)</option>
</select>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="saveProtection()">Save</button>
</div>
</div>
</div>
</div><!-- /server -->
<!-- ═══ API KEYS ═══ -->
<div class="panel" id="panel-keys">
<div class="section">
<div class="s-hd"><div><div class="s-title">🔑 API Keys</div><div class="s-sub">调用 /v1/chat/completions 的访问密钥。修改立即生效。</div></div></div>
<div class="s-body">
<div class="grid">
<div class="field"><label>名称</label><input type="text" id="key_name" placeholder="e.g. my-client"></div>
<div class="field"><label>密钥</label><input type="text" id="key_value" placeholder="sk-123456"></div>
<div class="field full"><label>备注 (可选)</label><input type="text" id="key_desc"></div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="addKey()">+ Add key</button>
<span class="fhint">同名会覆盖</span>
</div>
<div class="sub-list" id="keysList" style="margin-top:18px"></div>
</div>
</div>
</div>
<!-- ═══ MODELS ═══ -->
<div class="panel" id="panel-models">
<div class="section">
<div class="s-hd"><div><div class="s-title">🤖 Models</div><div class="s-sub">管理 models.json 中的可用模型列表。修改立即生效。</div></div></div>
<div class="s-body">
<div class="grid">
<div class="field"><label>添加模型 ID</label><input type="text" id="new_model_id" autocomplete="off" data-lpignore="true" data-form-type="other" placeholder="gemini-2.5-pro"></div>
<div style="display:flex;align-items:flex-end"><button class="btn btn-primary" onclick="addModel()">+ Add</button></div>
</div>
<table class="table" style="margin-top:18px">
<thead><tr><th>Model ID</th><th style="width:80px"></th></tr></thead>
<tbody id="modelsTbody"></tbody>
</table>
</div>
</div>
<div class="section">
<div class="s-hd"><div><div class="s-title">🔀 Alias map</div><div class="s-sub">别名 → 实际模型 ID 的映射。</div></div></div>
<div class="s-body">
<div class="grid">
<div class="field"><label>别名(客户端传入)</label><input type="text" id="alias_from" placeholder="gpt-4o"></div>
<div class="field"><label>映射到的实际模型 ID</label><input type="text" id="alias_to" placeholder="gemini-2.5-pro"></div>
</div>
<div class="actions"><button class="btn btn-primary" onclick="addAlias()">+ Add alias</button></div>
<table class="table" style="margin-top:18px">
<thead><tr><th>别名</th><th></th><th>实际模型</th><th style="width:80px"></th></tr></thead>
<tbody id="aliasTbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SUBSCRIPTION ═══ -->
<div class="panel" id="panel-proxy">
<div class="section">
<div class="s-hd">
<div><div class="s-title">📥 节点导入</div><div class="s-sub">订阅导入、剪切板导入、文件导入与手动单节点导入统一在此切换显示。</div></div>
<span id="coreBadge" class="pill-warn" style="display:none">core not ready</span>
</div>
<div class="s-body">
<div class="subtabs">
<button class="subtab active" data-import-tab="subscription">订阅导入</button>
<button class="subtab" data-import-tab="clipboard">剪切板导入</button>
<button class="subtab" data-import-tab="file">文件导入</button>
<button class="subtab" data-import-tab="manual">添加单个节点</button>
</div>
<div class="import-pane active" id="import-subscription">
<div class="import-stack">
<div class="import-block">
<div class="field">
<label>订阅链接输入</label>
<textarea id="sub_urls" placeholder="每行一个,或直接粘贴多个订阅链接。系统会自动从链接中提取名称;无法提取时使用序号。"></textarea>
<div class="fhint">支持一次粘贴多个 http(s) 订阅链接;名称可为空,会自动用域名、路径、token/id 等生成。</div>
</div>
</div>
<div class="import-block">
<div class="collection-title"><div class="s-title">操作按钮</div><div id="subSelectCount" class="fhint">选择订阅后拉取节点</div></div>
<div class="actions">
<button class="btn btn-primary" onclick="addSubscriptionsBatch()">+ 添加订阅</button>
<button class="btn" onclick="document.getElementById('sub_urls').value=''">清空输入</button>
<button class="btn" onclick="selectAllSubscriptions(true)">全选</button>
<button class="btn" onclick="selectAllSubscriptions(false)">取消全选</button>
<button class="btn btn-danger" onclick="deleteSelectedSubscriptions()">删除选中订阅</button>
<button class="btn btn-primary" id="fetchBtn" onclick="fetchSubscriptions('selected')" disabled>拉取选中订阅 (0)</button>
</div>
<div id="subSummary" class="fhint" style="margin-top:10px"></div>
</div>
<div class="import-block">
<div class="collection-title"><div class="s-title">订阅链接列表</div><div class="fhint">支持 Ctrl+点击跳跃多选、Shift+点击连续多选</div></div>
<div class="sub-list" id="subList"></div>
<div id="subSelectBar" style="display:none"></div>
</div>
</div>
</div>
<div class="import-pane" id="import-clipboard">
<div class="soft-card" style="margin-top:0">
<div class="s-title" style="font-size:13px">📋 剪切板粘贴导入</div>
<div class="s-sub">支持 URI 列表、base64 订阅内容、Clash/Mihomo YAML、JSON/日志中混杂的协议链接。</div>
<textarea id="nodePasteText" placeholder="粘贴 vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / socks5:// / http:// 或 Clash YAML..."></textarea>
<div class="toolbar">
<button class="btn btn-sm" onclick="pasteFromClipboard()">读取剪切板</button>
<button class="btn btn-sm btn-primary" onclick="importNodeText()">导入粘贴节点</button>
<button class="btn btn-sm" onclick="document.getElementById('nodePasteText').value=''">清空</button>
</div>
</div>
</div>
<div class="import-pane" id="import-file">
<div class="soft-card" style="margin-top:0">
<div class="s-title" style="font-size:13px">📁 文件导入</div>
<div class="s-sub">统一支持 .txt / .yaml / .yml / .json / .conf / .list / 任意文本文件中的各种协议节点。</div>
<div class="file-picker" style="margin-top:12px">
<input class="hidden-file" type="file" id="nodeImportFile" accept=".txt,.yaml,.yml,.json,.conf,.list,.csv,.log,*/*" onchange="updateFileName()">
<button class="btn btn-sm" onclick="document.getElementById('nodeImportFile').click()">选择文件</button>
<span class="file-name" id="nodeFileName">未选择文件</span>
</div>
<div class="toolbar">
<button class="btn btn-sm btn-primary" onclick="importNodeFile()">导入文件</button>
<span class="fhint">导入会合并到统一节点集合,不再覆盖已有节点。</span>
</div>
</div>
</div>
<div class="import-pane" id="import-manual">
<div class="single-node-card">
<div class="collection-title">
<div><div class="s-title" style="font-size:13px">➕ 添加单个节点</div><div class="s-sub">粘贴一个节点连接,支持所有已解析协议,添加后进入统一节点集合。</div></div>
</div>
<div class="grid" style="margin-top:12px">
<div class="field"><label>节点名称</label><input type="text" id="manualNodeName" placeholder="可选;留空使用节点内名称"></div>
<div class="field"><label>节点连接</label><input type="text" id="manualNodeUri" placeholder="vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / socks5:// / http:// ..."></div>
</div>
<div class="toolbar">
<button class="btn btn-sm btn-primary" onclick="addManualNode()">添加到统一节点集合</button>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="s-hd"><div><div class="s-title">🧩 统一节点集合</div><div class="s-sub">所有导入来源会合并为统一候选集合,默认未启用。</div></div></div>
<div class="s-body">
<div class="collection-grid">
<div class="collection-card accent">
<div class="collection-title">
<div><div class="s-title">集合信息</div><div class="s-sub">简要状态与当前筛选结果。</div></div>
<span id="nodeModeBadge" class="pill-warn">直连</span>
</div>
<div id="poolCount" class="muted">订阅/文件/剪切板导入后自动保存为候选节点</div>
</div>
<div class="collection-card">
<div class="collection-title"><div class="s-title">筛选搜索</div></div>
<div class="filter-row">
<input type="text" id="nodeSearch" placeholder="按名称 / 地址 / 来源搜索" oninput="renderFilteredNodes()">
<select id="nodeSourceFilter" onchange="renderFilteredNodes()"><option value="">全部来源</option></select>
<select id="nodeTypeFilter" onchange="renderFilteredNodes()"><option value="">全部协议</option></select>
</div>
</div>
<div class="collection-card" id="poolBar">
<div class="collection-title"><div class="s-title">批量操作</div><div class="fhint">针对当前筛选 / 搜索结果生效</div></div>
<div class="toolbar" style="margin-top:0">
<button class="btn btn-sm" onclick="selectAllUnifiedNodes(true)">全选</button>
<button class="btn btn-sm" onclick="selectAllUnifiedNodes(false)">取消全选</button>
<button class="btn btn-sm btn-danger" onclick="deleteSelectedNodes()">删除选中</button>
<button class="btn btn-sm btn-primary" onclick="setSelectedNodesEnabled(true)">启用选中</button>
<button class="btn btn-sm" onclick="setSelectedNodesEnabled(false)">禁用选中</button>
</div>
</div>
<div class="collection-card">
<div class="collection-title"><div class="s-title">节点列表</div><div class="fhint">支持 Ctrl+点击跳跃多选、Shift+点击连续多选</div></div>
<div class="node-list" id="nodeList"></div>
</div>
</div>
</div>
</div>
</div><!-- /proxy -->
</div><!-- /main -->
</div><!-- /app -->
<div class="toast-wrap" id="toastWrap"></div>
<script>
// ── Core ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY = 'vp_admin_token';
let token = localStorage.getItem(TOKEN_KEY) || '';
let _allNodes = []; // last fetched nodes array
let _filteredNodes = [];
let _selectedNodeUris = new Set();
let _subscriptions = []; // configured subscriptions
let _selectedSubIds = new Set();
let _lastNodeClickIndex = null;
let _lastSubClickIndex = null;
function esc(s) {
return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
function showToast(msg, type='info') {
const wrap = document.getElementById('toastWrap');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
wrap.appendChild(el);
setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; }, 2800);
setTimeout(()=>el.remove(), 3100);
}
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(apiPath(path), opts);
if (res.status === 401) { token=''; localStorage.removeItem(TOKEN_KEY); showLogin(); throw new Error('Unauthorized'); }
const data = await res.json().catch(()=>({}));
if (!res.ok) throw new Error(data.detail || data.message || ('HTTP ' + res.status));
return data;
}
function apiBase() {
const p = window.location.pathname;
const marker = '/admin';
const idx = p.lastIndexOf(marker);
return idx >= 0 ? p.slice(0, idx) : '';
}
function apiPath(path) {
if (/^https?:\/\//i.test(path)) return path;
return apiBase() + (path.startsWith('/') ? path : '/' + path);
}
function showLogin() {
document.getElementById('loginView').style.display = 'flex';
document.getElementById('appView').style.display = 'none';
}
function showApp() {
document.getElementById('loginView').style.display = 'none';
document.getElementById('appView').style.display = 'block';
loadAll();
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const pwInput = document.getElementById('pw');
const pw = pwInput.value.trim();
const errEl = document.getElementById('loginErr');
errEl.style.display = 'none';
localStorage.removeItem(TOKEN_KEY);
token = '';
try {
const r = await fetch(apiPath('/api/admin/login'), { method:'POST', headers:{'Content-Type':'application/json'}, cache:'no-store', body: JSON.stringify({password:pw}) });
const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.detail||'Login failed');
token = j.token; localStorage.setItem(TOKEN_KEY, token);
pwInput.value = '';
showApp();
} catch(err) { errEl.textContent=err.message; errEl.style.display='block'; }
});
async function logout() {
try { await api('POST','/api/admin/logout'); } catch(e){}
token=''; localStorage.removeItem(TOKEN_KEY); showLogin();
}
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
document.getElementById('panel-'+t.dataset.tab).classList.add('active');
});
});
document.querySelectorAll('.subtab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.subtab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.import-pane').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
document.getElementById('import-'+t.dataset.importTab).classList.add('active');
});
});
async function loadAll() {
try {
await loadSettings();
await loadKeys();
await loadModels();
await loadSubscriptions();
await loadUnifiedNodes();
await loadProxyStatus();
} catch(e) {}
}
// ── Settings ──────────────────────────────────────────────────────────────────
async function loadSettings() {
const s = await api('GET','/api/admin/settings');
document.getElementById('port_api').value = s.port_api;
document.getElementById('debug').checked = s.debug;
document.getElementById('portBadge').textContent = 'Port ' + s.port_api;
document.getElementById('anti429_enabled').checked = !!s.anti429_enabled;
document.getElementById('anti429_target').value = s.anti429_target || 'system';
document.getElementById('parallel_pool_size').value = s.parallel_pool_size || 10;
document.getElementById('parallel_pool_max_size').value = s.parallel_pool_max_size || 20;
document.getElementById('candidate_queue_length').value = s.candidate_queue_length || 100;
document.getElementById('candidate_queue_rounds').value = s.candidate_queue_rounds || 0;
document.getElementById('node_retry_count').value = s.node_retry_count ?? 1;
document.getElementById('request_pool_deadline_seconds').value = s.request_pool_deadline_seconds || 0;
document.getElementById('stream_winner_stall_timeout_seconds').value = s.stream_winner_stall_timeout_seconds || 0;
document.getElementById('parallel_worker_base_port').value = s.parallel_worker_base_port || 12080;
document.getElementById('parallel_worker_port_span').value = s.parallel_worker_port_span || 2000;
document.getElementById('business_session_concurrency_limit').value = s.business_session_concurrency_limit || 10;
document.getElementById('anti_tracking').checked = !!s.anti_tracking;
document.getElementById('drop_max_tokens').checked = !!s.drop_max_tokens;
document.getElementById('pwLockedWarn').style.display = s.admin_password_env_locked ? 'block' : 'none';
document.getElementById('newPw').disabled = !!s.admin_password_env_locked;
}
async function saveSettings() {
const body = {
port_api: parseInt(document.getElementById('port_api').value,10),
debug: document.getElementById('debug').checked,
};
try {
const r = await api('PUT','/api/admin/settings',body);
const pwEl = document.getElementById('newPw');
const pw = pwEl.value.trim();
if (pw && !pwEl.disabled) {
if (pw.length < 6) { showToast('密码至少 6 位','err'); return; }
await api('PUT','/api/admin/settings',{ admin_password: pw });
pwEl.value = '';
}
showToast('已保存','ok');
document.getElementById('saveTip').textContent = (r.notes&&r.notes.length) ? r.notes.join(' · ') : '';
document.getElementById('portBadge').textContent = 'Port ' + body.port_api;
} catch(e) { showToast(e.message,'err'); }
}
async function saveProtection() {
try {
await api('PUT','/api/admin/settings', {
anti_tracking: document.getElementById('anti_tracking').checked,
drop_max_tokens: document.getElementById('drop_max_tokens').checked,
anti429_enabled: document.getElementById('anti429_enabled').checked,
anti429_target: document.getElementById('anti429_target').value,
});
showToast('已保存','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function saveParallelPool() {
try {
await api('PUT','/api/admin/settings', {
parallel_pool_size: parseInt(document.getElementById('parallel_pool_size').value,10),
parallel_pool_max_size: parseInt(document.getElementById('parallel_pool_max_size').value,10),
candidate_queue_length: parseInt(document.getElementById('candidate_queue_length').value,10),
candidate_queue_rounds: parseInt(document.getElementById('candidate_queue_rounds').value,10),
node_retry_count: parseInt(document.getElementById('node_retry_count').value,10),
request_pool_deadline_seconds: parseFloat(document.getElementById('request_pool_deadline_seconds').value),
stream_winner_stall_timeout_seconds: parseFloat(document.getElementById('stream_winner_stall_timeout_seconds').value),
parallel_worker_base_port: parseInt(document.getElementById('parallel_worker_base_port').value,10),
parallel_worker_port_span: parseInt(document.getElementById('parallel_worker_port_span').value,10),
business_session_concurrency_limit: parseInt(document.getElementById('business_session_concurrency_limit').value,10),
});
showToast('已保存','ok');
} catch(e) { showToast(e.message,'err'); }
}
// ── API Keys ──────────────────────────────────────────────────────────────────
function maskKey(k) { return (!k||k.length<8)?k:(k.slice(0,6)+'…'+k.slice(-4)); }
async function copyKey(key) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(key);
} else {
let textArea = document.createElement("textarea");
textArea.value = key;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
textArea.remove();
}
showToast('已复制到剪贴板','ok');
} catch(e) {
showToast('复制失败','err');
}
}
async function loadKeys() {
const data = await api('GET','/api/admin/keys');
const container = document.getElementById('keysList');
container.innerHTML = '';
if (!data.keys.length) {
container.innerHTML = '<div class="empty">还没有密钥。</div>';
return;
}
data.keys.forEach(k => {
const el = document.createElement('div');
el.className = 'sub-item';
el.innerHTML = `
<div class="sub-main">
<div class="sub-name mono-cell">${esc(k.name)}</div>
<div class="sub-url masked mono-cell" title="点击显示" data-full="${esc(k.key)}" onclick="toggleReveal(this)">${esc(maskKey(k.key))}</div>
<div class="sub-meta">${esc(k.description||'无备注')}</div>
</div>
<div class="sub-actions">
<button class="btn btn-sm" data-key="${esc(k.key)}" onclick="copyKey(this.dataset.key)">复制</button>
<button class="btn btn-sm btn-danger" data-name="${esc(k.name)}" onclick="deleteKey(this.dataset.name)">删除</button>
</div>`;
container.appendChild(el);
});
}
function toggleReveal(el) {
const f = el.dataset.full;
if (el.dataset.shown==='1') { el.textContent=maskKey(f); el.dataset.shown='0'; }
else { el.textContent=f; el.dataset.shown='1'; }
}
async function addKey() {
const name = document.getElementById('key_name').value.trim();
const key = document.getElementById('key_value').value.trim();
const description = document.getElementById('key_desc').value.trim();
if (!name||!key) { showToast('name / key 不能为空','err'); return; }
try {
await api('POST','/api/admin/keys',{name,key,description});
document.getElementById('key_name').value='';
document.getElementById('key_value').value='';
document.getElementById('key_desc').value='';
await loadKeys(); showToast('已添加','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function deleteKey(name) {
if (!confirm('删除密钥 '+name+' ?')) return;
try {
await api('DELETE','/api/admin/keys/'+encodeURIComponent(name));
await loadKeys(); showToast('已删除','ok');
} catch(e) { showToast(e.message,'err'); }
}
// ── Models ────────────────────────────────────────────────────────────────────
let _modelsCache = {models:[],alias_map:{}};
async function loadModels() {
const data = await api('GET','/api/admin/models');
_modelsCache = data;
renderModels(data.models, data.alias_map);
}
function renderModels(models, aliasMap) {
const tbody = document.getElementById('modelsTbody');
tbody.innerHTML = '';
if (!models.length) { tbody.innerHTML='<tr><td colspan="2" class="empty">还没有模型</td></tr>'; }
else models.forEach(m => {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="mono-cell" style="color:var(--ac)">${esc(m)}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteModel('${esc(m)}')">删除</button></td>`;
tbody.appendChild(tr);
});
const atbody = document.getElementById('aliasTbody');
atbody.innerHTML = '';
const entries = Object.entries(aliasMap||{});
if (!entries.length) { atbody.innerHTML='<tr><td colspan="4" class="empty">还没有别名映射</td></tr>'; }
else entries.forEach(([from,to]) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="mono-cell" style="color:var(--ac)">${esc(from)}</td>
<td style="color:var(--tx3);font-size:16px">→</td>
<td class="mono-cell">${esc(to)}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteAlias('${esc(from)}')">删除</button></td>`;
atbody.appendChild(tr);
});
}
async function addModel() {
const id = document.getElementById('new_model_id').value.trim();
if (!id) { showToast('请输入模型 ID','err'); return; }
const models = [...new Set([..._modelsCache.models, id])];
try { await api('PUT','/api/admin/models',{models}); document.getElementById('new_model_id').value=''; await loadModels(); showToast('已添加模型','ok'); }
catch(e) { showToast(e.message,'err'); }
}
async function deleteModel(id) {
if (!confirm('删除模型 '+id+' ?')) return;
const models = _modelsCache.models.filter(m=>m!==id);
try { await api('PUT','/api/admin/models',{models}); await loadModels(); showToast('已删除','ok'); }
catch(e) { showToast(e.message,'err'); }
}
async function addAlias() {
const from = document.getElementById('alias_from').value.trim();
const to = document.getElementById('alias_to').value.trim();
if (!from||!to) { showToast('别名和目标均不能为空','err'); return; }
const alias_map = {..._modelsCache.alias_map,[from]:to};
try {
await api('PUT','/api/admin/models',{alias_map});
document.getElementById('alias_from').value=''; document.getElementById('alias_to').value='';
await loadModels(); showToast('别名已添加','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function deleteAlias(from) {
if (!confirm('删除别名 '+from+' ?')) return;
const alias_map = {..._modelsCache.alias_map};
delete alias_map[from];
try { await api('PUT','/api/admin/models',{alias_map}); await loadModels(); showToast('已删除','ok'); }
catch(e) { showToast(e.message,'err'); }
}
// ── Unified Nodes ─────────────────────────────────────────────────────────────
async function loadNodePool() {
return loadUnifiedNodes();
}
async function loadUnifiedNodes() {
try {
const r = await api('GET','/api/admin/nodes');
_allNodes = r.nodes || _allNodes || [];
const existingUris = new Set(_allNodes.map(n => n.raw_uri));
_selectedNodeUris = new Set([..._selectedNodeUris].filter(uri => existingUris.has(uri)));
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
} catch(e) {}
}
function updatePoolUI() {
const badge = document.getElementById('poolBadge');
if (_allNodes.length > 0) {
badge.textContent = `nodes ${_allNodes.length}`;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
refreshPoolCount();
syncRenderedNodeSelectionState();
}
function syncRenderedNodeSelectionState() {
const listEl = document.getElementById('nodeList');
if (!listEl) return;
listEl.querySelectorAll('.node').forEach(div => {
const uri = div.dataset.uri;
const selected = _selectedNodeUris.has(uri);
div.classList.toggle('selected', selected);
const cb = div.querySelector('.node-cb');
if (cb) cb.checked = selected;
});
}
function syncRenderedNodeEnabledState(targetUris, enabled) {
const listEl = document.getElementById('nodeList');
if (!listEl) return;
listEl.querySelectorAll('.node').forEach(div => {
if (!targetUris.has(div.dataset.uri)) return;
const badges = div.querySelector('.node-badges');
if (badges) badges.innerHTML = enabled ? '<span class="nbadge nbadge-pool">已启用</span>' : '<span class="nbadge nbadge-off">未启用</span>';
});
}
function refreshPoolCount() {
const countEl = document.getElementById('poolCount');
const modeEl = document.getElementById('nodeModeBadge');
const count = _allNodes.length;
const enabled = _allNodes.filter(n => !!n.enabled).length;
const shown = _filteredNodes.length || (!_allNodes.length ? 0 : getFilteredNodes().length);
let mode = '直连';
if (enabled === 1) mode = '固定代理';
else if (enabled > 1) mode = '动态代理';
if (modeEl) modeEl.textContent = mode;
if (countEl) countEl.textContent = count ? `统一节点集合:${count} 个,启用 ${enabled} 个(${mode}),当前显示 ${shown} 个,已选择 ${_selectedNodeUris.size} 个` : '订阅/文件/剪切板导入后保存为节点配置,默认不启用';
}
function nodeSourceLabel(n) {
return n.subscription_name || n.source || '未知来源';
}
function refreshNodeFilterOptions() {
const sourceSel = document.getElementById('nodeSourceFilter');
const typeSel = document.getElementById('nodeTypeFilter');
if (!sourceSel || !typeSel) return;
const currentSource = sourceSel.value;
const currentType = typeSel.value;
const sources = [...new Set(_allNodes.map(nodeSourceLabel).filter(Boolean))].sort();
const types = [...new Set(_allNodes.map(n => _tmap[n.type] || n.type || '').filter(Boolean))].sort();
sourceSel.innerHTML = '<option value="">全部来源</option>' + sources.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
typeSel.innerHTML = '<option value="">全部协议</option>' + types.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
sourceSel.value = sources.includes(currentSource) ? currentSource : '';
typeSel.value = types.includes(currentType) ? currentType : '';
}
function getFilteredNodes() {
const q = (document.getElementById('nodeSearch')?.value || '').trim().toLowerCase();
const source = document.getElementById('nodeSourceFilter')?.value || '';
const type = document.getElementById('nodeTypeFilter')?.value || '';
return _allNodes.filter(n => {
const displayType = _tmap[n.type] || n.type || '';
const src = nodeSourceLabel(n);
const hay = `${n.name||''} ${n.server||''} ${n.port||''} ${src} ${displayType} ${n.source||''}`.toLowerCase();
return (!q || hay.includes(q)) && (!source || src === source) && (!type || displayType === type);
});
}
function renderFilteredNodes() {
_filteredNodes = getFilteredNodes();
renderNodes(_filteredNodes);
refreshPoolCount();
}
// ── Subscription ──────────────────────────────────────────────────────────────
async function loadSubscriptions() {
try {
const r = await api('GET','/api/admin/subscriptions');
_subscriptions = r.subscriptions || [];
_selectedSubIds = new Set([..._selectedSubIds].filter(id => _subscriptions.some(s => s.id === id)));
renderSubscriptions();
} catch(e) {}
}
async function loadSubscriptionsOnly() {
const r = await api('GET','/api/admin/subscriptions');
_subscriptions = r.subscriptions || [];
_selectedSubIds = new Set([..._selectedSubIds].filter(id => _subscriptions.some(s => s.id === id)));
renderSubscriptions();
}
function renderSubscriptions() {
const listEl = document.getElementById('subList');
const bar = document.getElementById('subSelectBar');
listEl.innerHTML = '';
if (!_subscriptions.length) {
listEl.innerHTML = '<div class="empty">还没有订阅。添加订阅后可单选、多选或全选拉取节点。</div>';
bar.style.display = 'none';
refreshSubscriptionSelection();
return;
}
bar.style.display = 'flex';
_subscriptions.forEach(s => {
const idx = _subscriptions.indexOf(s);
const checked = _selectedSubIds.has(s.id);
const updated = s.updated_at ? new Date(s.updated_at * 1000).toLocaleString() : '未拉取';
const div = document.createElement('div');
div.className = 'sub-item' + (checked ? ' selected' : '');
div.innerHTML = `
<input type="checkbox" class="sub-cb" data-index="${idx}" data-id="${esc(s.id)}" ${checked?'checked':''} onclick="handleSubCheckboxClick(event, this)" title="选择此订阅参与本次拉取">
<div class="sub-main">
<div class="sub-name">${esc(s.name || '订阅')}</div>
<div class="sub-url">${esc(s.url)}</div>
<div class="sub-meta">nodes ${esc(s.node_count || 0)} · ${esc(updated)}</div>
</div>
<div class="sub-actions">
<button class="btn btn-sm" onclick="editSubscription('${esc(s.id)}')">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteSubscription('${esc(s.id)}')">删除</button>
</div>`;
listEl.appendChild(div);
});
refreshSubscriptionSelection();
}
function refreshSubscriptionSelection() {
const count = _selectedSubIds.size;
const btn = document.getElementById('fetchBtn');
const countEl = document.getElementById('subSelectCount');
if (btn) {
btn.textContent = `拉取选中订阅 (${count})`;
btn.disabled = count < 1;
}
if (countEl) countEl.textContent = count ? `已选择 ${count} 个订阅` : '选择订阅后拉取节点';
}
function handleSubCheckboxClick(evt, cb) {
const idx = parseInt(cb.dataset.index, 10);
const targetChecked = cb.checked;
if (evt.shiftKey && _lastSubClickIndex !== null) {
const start = Math.min(_lastSubClickIndex, idx);
const end = Math.max(_lastSubClickIndex, idx);
for (let i = start; i <= end; i++) {
const sub = _subscriptions[i];
if (sub && sub.id) {
if (targetChecked) _selectedSubIds.add(sub.id);
else _selectedSubIds.delete(sub.id);
}
}
_lastSubClickIndex = idx;
renderSubscriptions();
return;
}
toggleSubSelect(cb, false);
_lastSubClickIndex = idx;
renderSubscriptions();
}
function toggleSubSelect(cb, refresh=true) {
const id = cb.dataset.id;
if (cb.checked) _selectedSubIds.add(id);
else _selectedSubIds.delete(id);
if (refresh) refreshSubscriptionSelection();
}
function selectAllSubscriptions(checked) {
_selectedSubIds.clear();
if (checked) _subscriptions.forEach(s=>_selectedSubIds.add(s.id));
_lastSubClickIndex = null;
renderSubscriptions();
}
async function addSubscription() {
const url = document.getElementById('sub_urls').value.trim();
if (!url) { showToast('请输入订阅地址','err'); return; }
try {
const r = await api('POST','/api/admin/subscriptions',{name:'',url,enabled:true});
_selectedSubIds.add(r.subscription.id);
document.getElementById('sub_urls').value = '';
await loadSubscriptionsOnly();
showToast('订阅已添加','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function addSubscriptionsBatch() {
const text = document.getElementById('sub_urls').value.trim();
if (!text) { showToast('请粘贴至少一个订阅链接','err'); return; }
try {
const r = await api('POST','/api/admin/subscriptions/batch',{text});
_subscriptions = r.subscriptions || [];
_selectedSubIds = new Set((r.added_items || []).map(s => s.id));
document.getElementById('sub_urls').value = '';
renderSubscriptions();
showToast(`已添加 ${r.added} 个订阅,更新 ${r.updated} 个`,'ok');
} catch(e) { showToast(e.message,'err'); }
}
async function pasteSubscriptionsFromClipboard() {
try {
const text = await navigator.clipboard.readText();
document.getElementById('sub_urls').value = text;
showToast('已读取剪切板','ok');
} catch(e) { showToast('浏览器阻止读取剪切板,请手动 Ctrl+V 粘贴','err'); }
}
async function editSubscription(id) {
const sub = _subscriptions.find(s=>s.id===id);
if (!sub) return;
const name = prompt('订阅名称', sub.name || '');
if (name === null) return;
const url = prompt('订阅地址', sub.url || '');
if (url === null) return;
try {
await api('PUT','/api/admin/subscriptions/'+encodeURIComponent(id),{name,url});
await loadSubscriptionsOnly();
showToast('订阅已更新','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function deleteSubscription(id) {
const sub = _subscriptions.find(s=>s.id===id);
if (!confirm('删除订阅 '+((sub&&sub.name)||id)+' ?')) return;
try {
await api('DELETE','/api/admin/subscriptions/'+encodeURIComponent(id));
_selectedSubIds.delete(id);
await loadSubscriptionsOnly();
showToast('订阅已删除','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function deleteSelectedSubscriptions() {
if (!_selectedSubIds.size) { showToast('请至少选择一个订阅','err'); return; }
if (!confirm(`删除选中的 ${_selectedSubIds.size} 个订阅?`)) return;
try {
const r = await api('POST','/api/admin/subscriptions/delete',{ids:[..._selectedSubIds]});
_subscriptions = r.subscriptions || [];
_selectedSubIds.clear();
renderSubscriptions();
showToast(`已删除 ${r.deleted} 个订阅`,'ok');
} catch(e) { showToast(e.message,'err'); await loadSubscriptionsOnly(); }
}
async function fetchSubscription(opts={}) {
const url = document.getElementById('sub_urls').value.trim();
if (url) {
try {
const r = await api('POST','/api/admin/subscriptions',{url,enabled:true});
_selectedSubIds = new Set([r.subscription.id]);
await loadSubscriptionsOnly();
return fetchSubscriptions('selected', opts);
} catch(e) { if (!opts.silent) showToast(e.message,'err'); return; }
}
return fetchSubscriptions('selected', opts);
}
async function fetchSubscriptions(mode='selected', opts={}) {
if (mode === 'selected' && !_selectedSubIds.size) { if (!opts.silent) showToast('请至少选择一个订阅','err'); return; }
const btn = document.getElementById('fetchBtn');
const listEl = document.getElementById('nodeList');
if (btn) { btn.disabled=true; btn.textContent='拉取中...'; }
listEl.innerHTML=''; document.getElementById('subSummary').textContent='';
try {
const data = await api('POST','/api/admin/subscriptions/fetch',{mode,ids:[..._selectedSubIds]});
const warn = data.errors && data.errors.length ? `,${data.errors.length} 个订阅失败` : '';
const mergeInfo = data.added !== undefined ? `,新增 ${data.added},更新 ${data.updated}` : '';
document.getElementById('subSummary').textContent = `共 ${data.total} 个节点 · ${data.subscription_count} 个订阅${mergeInfo}${warn}`;
_allNodes = data.nodes||[];
if (!_allNodes.length) { listEl.innerHTML='<div class="empty">没有解析到任何节点</div>'; return; }
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
await loadSubscriptionsOnly();
} catch(e) {
if (!opts.silent) showToast(e.message,'err');
listEl.innerHTML='<div class="empty">'+esc(e.message)+'</div>';
} finally { if (btn) { btn.disabled=false; refreshSubscriptionSelection(); } }
}
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
document.getElementById('nodePasteText').value = text;
showToast('已从剪切板读取','ok');
} catch(e) { showToast('浏览器阻止读取剪切板,请手动 Ctrl+V 粘贴','err'); }
}
async function importNodeText() {
const text = document.getElementById('nodePasteText').value.trim();
if (!text) { showToast('请先粘贴节点内容','err'); return; }
try {
const data = await api('POST','/api/admin/nodes/import-text',{text,source_name:'clipboard'});
_allNodes = data.nodes || [];
_selectedNodeUris.clear();
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
document.getElementById('subSummary').textContent = `剪切板解析 ${data.total} 个,新增 ${data.added} 个,更新 ${data.updated} 个`;
showToast(`已导入 ${data.total} 个节点`,'ok');
} catch(e) { showToast(e.message,'err'); }
}
function updateFileName() {
const input = document.getElementById('nodeImportFile');
const file = input && input.files && input.files[0];
document.getElementById('nodeFileName').textContent = file ? file.name : '未选择文件';
}
async function importNodeFile() {
const input = document.getElementById('nodeImportFile');
const file = input && input.files && input.files[0];
if (!file) { showToast('请选择配置文件','err'); return; }
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch(apiPath('/api/admin/nodes/import'), {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: fd,
});
const data = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(data.detail || '导入失败');
_allNodes = data.nodes || [];
_selectedNodeUris.clear();
document.getElementById('subSummary').textContent = `文件解析 ${data.total} 个,新增 ${data.added} 个,更新 ${data.updated} 个`;
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
input.value = '';
updateFileName();
showToast(`已导入 ${data.total} 个节点`,'ok');
} catch(e) { showToast(e.message,'err'); }
}
const _tmap = {
A:atob('VkxFU1M='), B:atob('Vk1lc3M='), C:atob('VHJvamFu'),
D:atob('U1M='), E:atob('U1NS'), F:atob('SHlzdGVyaWEy'),
H:atob('QW55VExT'), I:atob('VFVJQw=='), J:atob('SHkx'),
};
function renderNodes(nodes) {
const listEl = document.getElementById('nodeList');
listEl.innerHTML = '';
if (!nodes.length) {
listEl.innerHTML = '<div class="empty">统一节点集合为空。可从订阅、剪切板或文件导入节点。</div>';
return;
}
const frag = document.createDocumentFragment();
nodes.forEach((n,i) => {
const isEnabled = !!n.enabled;
const isSelected = _selectedNodeUris.has(n.raw_uri);
const displayType = _tmap[n.type]||n.type;
const div = document.createElement('div');
div.className = 'node' + (isSelected?' selected':'');
div.dataset.index = String(i);
div.dataset.uri = n.raw_uri;
div.innerHTML = `
<input type="checkbox" class="node-cb" data-index="${i}" data-uri="${esc(n.raw_uri)}" ${isSelected?'checked':''} onclick="handleNodeCheckboxClick(event, this)">
<div class="node-info">
<div class="node-name">${esc(n.name||'(unnamed)')}</div>
<div class="node-meta"><span class="node-type">${esc(displayType)}</span><span>${esc(n.server)}:${n.port}</span>${n.subscription_name ? `<span>${esc(n.subscription_name)}</span>` : ''}<span>${esc(n.source||'')}</span></div>
</div>
<div class="node-badges">
${isEnabled ? '<span class="nbadge nbadge-pool">已启用</span>' : '<span class="nbadge nbadge-off">未启用</span>'}
</div>
<div class="node-actions">
<button class="btn btn-sm" data-uri="${esc(n.raw_uri)}" onclick="editUnifiedNode(this)">编辑</button>
<button class="btn btn-sm btn-danger" data-uri="${esc(n.raw_uri)}" onclick="deleteOneNode(this)">删除</button>
</div>`;
frag.appendChild(div);
});
listEl.appendChild(frag);
}
async function addManualNode() {
const name = document.getElementById('manualNodeName').value.trim();
const raw_uri = document.getElementById('manualNodeUri').value.trim();
if (!raw_uri) { showToast('请输入节点连接','err'); return; }
try {
const data = await api('POST','/api/admin/nodes',{name,raw_uri});
_allNodes = data.nodes || [];
document.getElementById('manualNodeName').value = '';
document.getElementById('manualNodeUri').value = '';
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
showToast(data.added ? '节点已添加' : '节点已更新','ok');
} catch(e) { showToast(e.message,'err'); }
}
function handleNodeCheckboxClick(evt, cb) {
const idx = parseInt(cb.dataset.index, 10);
const targetChecked = cb.checked;
if (evt.shiftKey && _lastNodeClickIndex !== null) {
const start = Math.min(_lastNodeClickIndex, idx);
const end = Math.max(_lastNodeClickIndex, idx);
for (let i = start; i <= end; i++) {
const node = _filteredNodes[i];
if (node && node.raw_uri) {
if (targetChecked) _selectedNodeUris.add(node.raw_uri);
else _selectedNodeUris.delete(node.raw_uri);
}
}
} else {
toggleUnifiedNodeSelect(cb, false);
}
_lastNodeClickIndex = idx;
updatePoolUI();
}
function toggleUnifiedNodeSelect(cb, refresh=true) {
const uri = cb.dataset.uri;
if (cb.checked) _selectedNodeUris.add(uri);
else _selectedNodeUris.delete(uri);
if (refresh) updatePoolUI();
}
function selectAllUnifiedNodes(checked) {
_selectedNodeUris.clear();
const list = _filteredNodes.length || !_allNodes.length ? _filteredNodes : getFilteredNodes();
if (checked) list.forEach(n=>_selectedNodeUris.add(n.raw_uri));
_lastNodeClickIndex = null;
updatePoolUI();
}
async function setSelectedNodesEnabled(enabled) {
if (!_selectedNodeUris.size) { showToast('请先选择节点','err'); return; }
const selected = new Set(_selectedNodeUris);
try {
const data = await api('POST','/api/admin/nodes/enabled',{raw_uris:[...selected], enabled});
if (data.matched !== selected.size) {
await loadUnifiedNodes();
} else {
_allNodes = _allNodes.map(n => selected.has(n.raw_uri) ? {...n, enabled} : n);
syncRenderedNodeEnabledState(selected, enabled);
updatePoolUI();
}
const verb = enabled ? '启用' : '禁用';
showToast(`已${verb} ${data.updated} 个节点`,'ok');
await loadProxyStatus();
} catch(e) { showToast(e.message,'err'); await loadUnifiedNodes(); }
}
async function editUnifiedNode(btn) {
const uri = btn.dataset.uri;
const node = _allNodes.find(n => n.raw_uri === uri);
if (!node) return;
const name = prompt('节点名称', node.name || '');
if (name === null) return;
const raw_uri = prompt('节点 URI', node.raw_uri || '');
if (raw_uri === null) return;
try {
const data = await api('PUT','/api/admin/nodes',{
original_raw_uri: uri,
name,
raw_uri,
subscription_name: node.subscription_name || node.source || 'manual'
});
_allNodes = data.nodes || _allNodes;
_selectedNodeUris.delete(uri);
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
showToast('节点已更新','ok');
} catch(e) { showToast(e.message,'err'); }
}
async function deleteOneNode(btn) {
const uri = btn.dataset.uri;
const node = _allNodes.find(n => n.raw_uri === uri);
if (!confirm('删除节点 '+((node&&node.name)||uri.slice(0,40))+' ?')) return;
await deleteNodesByUris([uri]);
}
async function deleteSelectedNodes() {
if (!_selectedNodeUris.size) { showToast('请先选择节点','err'); return; }
if (!confirm(`删除选中的 ${_selectedNodeUris.size} 个节点?`)) return;
await deleteNodesByUris([..._selectedNodeUris]);
}
async function deleteNodesByUris(raw_uris) {
try {
const targets = new Set(raw_uris);
const data = await api('POST','/api/admin/nodes/delete',{raw_uris, return_nodes:false});
if (data.deleted !== targets.size) {
await loadUnifiedNodes();
} else if (data.deleted > 0) {
_allNodes = _allNodes.filter(n => !targets.has(n.raw_uri));
}
raw_uris.forEach(uri => _selectedNodeUris.delete(uri));
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
showToast(`已删除 ${data.deleted} 个节点`,'ok');
} catch(e) { showToast(e.message,'err'); }
}
async function clearUnifiedNodes() {
if (!_allNodes.length) return;
if (!confirm(`清空全部 ${_allNodes.length} 个统一节点?`)) return;
try {
const data = await api('DELETE','/api/admin/nodes/all');
_allNodes = [];
_selectedNodeUris.clear();
refreshNodeFilterOptions();
renderFilteredNodes();
updatePoolUI();
showToast(`已清空 ${data.deleted} 个节点`,'ok');
} catch(e) { showToast(e.message,'err'); }
}
function refreshNodeHighlight() {
if (!_allNodes.length) return;
syncRenderedNodeSelectionState();
}
async function loadProxyStatus() {
try {
const s = await api('GET','/api/admin/proxy-status');
const bar=document.getElementById('activeBar'),
lbl=document.getElementById('activeLabel'),
nm=document.getElementById('activeName'),
via=document.getElementById('activeVia'),
stopBtn=document.getElementById('stopBtn'),
coreBadge=document.getElementById('coreBadge');
coreBadge.style.display = s.binary_available ? 'none' : 'inline-block';
const enabled = _allNodes.filter(n => !!n.enabled);
if (enabled.length > 0) {
bar.classList.remove('off');
lbl.textContent = enabled.length === 1 ? '固定代理' : '动态代理';
nm.textContent = enabled.length === 1 ? (enabled[0].name || enabled[0].raw_uri.slice(0,60)) : `已启用 ${enabled.length} 个节点`;
via.textContent = enabled.length === 1 ? '' : '· 会话候选池';
stopBtn.style.display='inline-block';
} else {
bar.classList.add('off'); lbl.textContent='未启用节点 · 直连';
nm.textContent=''; via.textContent=''; stopBtn.style.display='none';
}
// unified node indicator in active bar
const badge = document.getElementById('poolBadge');
if (_allNodes.length>0) {
badge.textContent = `nodes ${_allNodes.length}`;
badge.style.display='inline-block';
} else { badge.style.display='none'; }
} catch(e) {}
}
async function stopProxy() {
if (!confirm('停止当前代理,回到直连?')) return;
try {
await api('POST','/api/admin/stop-proxy');
showToast('已停止代理','ok');
await loadUnifiedNodes();
await loadProxyStatus(); refreshNodeHighlight();
} catch(e) { showToast(e.message,'err'); }
}
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
if (location.pathname.endsWith('/admin')) {
history.replaceState(null, '', location.pathname + '?t=' + Date.now());
}
if (token) {
try { await api('GET','/api/admin/settings'); showApp(); return; } catch(e){}
}
showLogin();
})();
</script>
</body>
</html>