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>