<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-">
<meta name="viewport" content="width=device-width, initial-scale=">
<title>GOST 转发规则面板</title>
<style>
:root{
--bg:#0f172a; --panel:#1e293b; --panel2:#; --line:#;
--text:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8; --green:#34d399; --red:#f87171; --amber:#fbbf24;
}
*{box-sizing:border-box}
body{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;background:radial-gradient(1200px 600px at % -%,#1e3a5f55,transparent),(--bg);margin:;padding:28px;color:(--text)}
.wrap{max-width:1080px;margin: auto}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}
h1{font-size:20px;margin:;display:flex;align-items:center;gap:10px;letter-spacing:.5px}
.badge{font-size:11px;color:(--muted);background:(--panel);border:1px solid (--line);padding:3px 10px;border-radius:99px}
.dot{width:9px;height:9px;border-radius:%;background:(--green);box-shadow: 10px (--green)}
.dot.off{background:(--red);box-shadow: 10px (--red)}
.card{background:(--panel);border:1px solid (--line);border-radius:14px;padding:18px 20px;margin-bottom:16px;box-shadow: 8px 24px rgba(,,,.)}
.card h3{margin: 14px;font-size:14px;color:(--muted);font-weight:;letter-spacing:.5px}
.grid{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:6px}
.field label{font-size:12px;color:(--muted)}
input,select{background:(--panel2);border:1px solid (--line);color:(--text);border-radius:9px;padding:9px 12px;font-size:14px;outline:none;transition:border .15s}
input:focus,select:focus{border-color:(--accent)}
input::placeholder{color:#64748b}
.btn{border:none;border-radius:9px;padding:10px 18px;font-size:14px;font-weight:;cursor:pointer;transition:filter .15s,transform .05s;color:#fff}
.btn:active{transform:scale(.)}
.btn:hover{filter:brightness()}
.btn-primary{background:linear-gradient(135deg,#0ea5e9,#2563eb)}
.btn-green{background:linear-gradient(135deg,#10b981,#)}
.btn-ghost{background:(--panel2);color:(--text);border:1px solid (--line);font-weight:}
.btn-blue{background:linear-gradient(135deg,#3b82f6,#6366f1)}
.hint{font-size:12px;color:(--muted);margin-left:10px}
.note{border-radius:10px;padding:11px 14px;font-size:13px;margin-bottom:14px;display:none}
.note.ok{background:#10b98122;border:1px solid #10b98155;color:(--green);display:block}
.note.bad{background:#f8717122;border:1px solid #f8717155;color:(--red);display:block}
.note.warn{background:#fbbf2422;border:1px solid #fbbf2455;color:(--amber);display:block}
table{width:%;border-collapse:collapse}
th,td{padding:12px 14px;text-align:left;font-size:5px;border-bottom:1px solid (--line)}
th{color:(--muted);font-weight:;font-size:12px;text-transform:uppercase;letter-spacing:.6px}
tr:last-child td{border-bottom:none}
tbody tr{transition:background .12s}
tbody tr:hover{background:#ffffff08}
code{background:(--panel2);border:1px solid (--line);padding:3px 9px;border-radius:7px;font-size:5px;color:#7dd3fc}
.proto{font-size:11px;padding:3px 9px;border-radius:99px;background:#38bdf822;color:(--accent);border:1px solid #38bdf844;font-weight:}
.unsaved{font-size:11px;padding:2px 8px;border-radius:99px;background:#fbbf2422;color:(--amber);border:1px solid #fbbf2444;margin-left:6px}
.del{background:none;border:none;color:(--red);cursor:pointer;font-size:13px;opacity:.;padding:4px 8px;border-radius:7px}
.del:hover{opacity:;background:#f8717122}
.meta{color:#64748b;font-size:12px;margin-top:16px;text-align:center}
.msg{font-size:13px;padding:10px 14px;border-radius:10px;display:none;margin-top:12px}
.m-ok{background:#10b98122;border:1px solid #10b98155;color:(--green)}
.m-bad{background:#f8717122;border:1px solid #f8717155;color:(--red)}
.err{background:#f8717118;border:1px solid #f8717144;color:(--red);padding:16px;border-radius:12px}
/* 登录门 */
#loginGate{position:fixed;inset:0;background:radial-gradient(900px 500px at 50% 20%,#1e3a5f66,transparent),var(--bg);display:flex;align-items:center;justify-content:center;z-index:99}
.login{background:(--panel);border:1px solid (--line);padding:40px 38px;border-radius:18px;width:340px;box-shadow: 20px 60px rgba(,,,.);text-align:center}
.login .lock{font-size:34px}
.login h2{margin:10px 4px;font-size:19px}
.login p{margin: 20px;color:(--muted);font-size:13px}
.login input{width:%;text-align:center;letter-spacing:1px}
.login button{width:%;margin-top:14px}
#pwErr{color:var(--red);font-size:13px;margin-top:12px;display:none}
/* 添加API 模态弹窗 */
.modal-mask{position:fixed;inset:;background:rgba(,,,.);backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;z-index:}
.modal-mask.show{display:flex}
.modal{background:(--panel);border:1px solid (--line);border-radius:16px;padding:26px 28px;width:400px;box-shadow: 20px 60px rgba(,,,.)}
.modal h3{margin: 4px;font-size:17px;display:flex;align-items:center;gap:8px}
.modal p{margin: 18px;color:(--muted);font-size:5px}
.modal .field{margin-bottom:14px}
.modal label{display:block;font-size:12px;color:(--muted);margin-bottom:5px}
.modal input{width:%;box-sizing:border-box}
.modal .row{display:flex;gap:10px;margin-top:20px;justify-content:flex-end}
.modal .preview{background:(--panel2);border:1px solid (--line);border-radius:9px;padding:10px 12px;font-size:12px;color:(--muted);margin-top:4px;line-height:}
.modal .preview code{background:none;border:none;padding:;color:(--accent);font-size:12px}
/* API切换器与预览修复 */
#apiSel{max-width:230px;min-width:150px;text-overflow:ellipsis;font-size:13px;color:var(--text);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px}
#apiSel option{background:var(--panel);color:var(--text);font-size:13px}
.modal .preview{font-size:12px;line-height:}
.modal .preview .k{display:inline-block;width:44px;color:(--muted)}
.modal .preview code{word-:-all}
/* ===== v2 新功能样式 ===== */
.statusbar{display:flex;gap:14px;align-items:center;background:(--panel);border:1px solid (--line);border-radius:12px;padding:10px 16px;margin-bottom:14px;font-size:13px;flex-wrap:wrap}
.statusbar .st{display:flex;align-items:center;gap:6px}
.statusbar .ok{color:(--green)}
.statusbar .bad{color:(--red)}
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.search{background:(--panel2);border:1px solid (--line);color:(--text);border-radius:9px;padding:8px 12px;font-size:13px;width:220px}
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
.stoggle{width:34px;height:18px;border-radius:99px;background:#;position:relative;cursor:pointer;border:none;transition:background .2s}
.stoggle.on{background:(--green)}
.stoggle::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:%;background:#fff;transition:left .2s}
.stoggle.on::after{left:18px}
.ping-ok{color:(--green);font-size:12px}
.ping-bad{color:(--red);font-size:12px}
.modal textarea{width:%;box-sizing:border-box;background:(--panel2);border:1px solid (--line);color:(--text);border-radius:9px;padding:10px;font-size:5px;font-family:monospace;min-height:140px}
.speedrow{display:flex;gap:8px;align-items:center}
.speedrow input{width:90px}
.theme-light{--bg:#f1f5f9;--panel:#ffffff;--panel2:#e2e8f0;--line:#cbd5e1;--text:#0f172a;--muted:#64748b;--accent:#0284c7;--green:#;--red:#dc2626;--amber:#d97706}
.theme-light body,.theme-light{background:radial-gradient(1200px 600px at % -%,#dbeafe55,transparent),(--bg)}
.kbd{background:(--panel2);border:1px solid (--line);border-radius:5px;padding:1px 6px;font-size:11px}
/* 限速模态 */
.speed-presets{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.speed-presets button{background:(--panel2);border:1px solid (--line);color:(--text);border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;transition:all .15s}
.speed-presets button:hover{border-color:(--accent);color:(--accent)}
.speed-presets button.sel{background:(--accent);color:#fff;border-color:(--accent)}
.speed-input{width:%;box-sizing:border-box;background:(--panel2);border:1px solid (--line);color:(--text);border-radius:9px;padding:10px 12px;font-size:15px;font-weight:;text-align:center}
.speed-note{font-size:12px;color:(--muted);line-height:;margin-top:12px;background:(--panel2);border-radius:9px;padding:10px 12px}
.speed-note b{color:(--text)}
.speed-cur{font-size:12px;color:(--amber);margin-top:6px}
</style>
<script>
// PANEL_VER_CHECK: 页面版本强制刷新
((){
V="v11-fix-addrule";
{
sv=localStorage.getItem("panel_ver");
(sv!==V){ localStorage.setItem("panel_ver",V); location.reload(true); }
}catch(e){}
})();
</script></head>
<body>
<!-- 登录门 -->
<div id="loginGate">
<div ="login">
<div ="lock">🔐</div>
<h2>GOST 管理面板</h2>
<p>请输入访问密码</p>
<input id="pw" type="password" placeholder="访问密码">
<button ="btn btn-primary" onclick="tryLogin()">进入面板</button>
<div id="pwErr">密码错误,请重试</div>
</div>
</div>
<div ="wrap">
<div ="statusbar" id="statusbar">
<span ="st" id="stActive"><span ="dot"></span>检测中…</span>
<span ="st" id="stVer">—</span>
<span ="st" id="stSince">—</span>
<span ="st" style="margin-left:auto;color:(--muted)" id="stTime"></span>
</div>
<header>
<h1><span ="dot" id="dot"></span>GOST 转发规则
<select id="apiSel" onchange="switchApi(.value)" style="background:(--panel2);border:1px solid (--line);color:(--text);border-radius:8px;padding:6px 10px;font-size:13px"></select>
<button ="btn btn-ghost" onclick="addApiPrompt()">+ 添加API</button>
<button ="btn btn-ghost" onclick="editApi()">✏️ 编辑API</button>
<button ="btn btn-ghost" onclick="delApi()">删除当前</button>
</h1>
<button ="btn btn-ghost" onclick="load()">⟳ 刷新</button>
</header>
<div ="card">
<h3>➕ 添加转发规则</h3>
<div ="grid">
<div ="field"><label>规则名</label><input id="f-name" placeholder="my-rule" style="width:150px"></div>
<div ="field"><label>监听端口 <span id="portTip" style="font-size:11px"></span></label><input id="f-listen" placeholder="" style="width:120px" oninput="checkPortLive()"></div>
<div ="field"><label>目标地址</label><input id="f-target" placeholder=".:" style="width:210px"></div>
<div ="field"><label>协议</label>
<select id="f-proto" style="width:160px">
<option value="tcp">TCP 端口转发</option>
<option value="udp">UDP 端口转发</option>
<option value="tcp+udp">TCP+UDP 都转</option>
<option value="http">HTTP 反向代理</option>
<option value="relay">Relay 隧道转发</option>
</select>
</div>
<button ="btn btn-green" onclick="addRule()">添加</button>
</div>
<div id="msg" ="msg"></div>
</div>
<div ="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:10px">
<h3 style="margin:">📋 规则列表</h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input ="search" id="searchBox" placeholder="🔍 搜索 名称/端口/目标…" oninput="renderTable()">
<button ="btn btn-ghost btn-sm" onclick="exportRules()">⬇ 导出</button>
<button ="btn btn-ghost btn-sm" onclick="importRules()">⬆ 导入</button>
<button ="btn btn-ghost btn-sm" onclick="showLog()">📜 日志</button>
<button ="btn btn-ghost btn-sm" onclick="toggleTheme()">🌓</button>
</div>
</div>
<h3 style="display:none">📋 规则列表</h3>
<div>
<button ="btn btn-ghost" onclick="restartGost()">🔄 重启 GOST</button>
<button ="btn btn-ghost" onclick="viewYaml()">📄 查看 yaml</button>
</div>
</div>
<div id="syncNote" ="note"></div>
<div style="overflow-x:auto">
<table>
<thead><tr><th>规则名</th><th>监听端口</th><th>转发目标</th><th>协议</th><th style="width:50px">启停</th><th style="width:280px">操作</th></tr></thead>
<tbody id="content"><tr><td colspan="" style="color:(--muted)">加载中...</td></tr></tbody>
</table>
</div>
</div>
<div ="meta">API: 同源代理 → 本机 : · 凭据内嵌 · <span id="time"></span></div>
</div>
<script>
PANEL_PASSWORD = 'oyh159159oiw.@';
ALL_SERVICES=[];
STOPPED={};
OPLOGGING=true;
// ===== 多API配置系统 =====
API_LIST_KEY='gostApis';
API_CUR_KEY='gostApiCur';
DEFAULT_APIS=[
{name:'本地', base:location.origin+'/gostapi', save:location.origin+'/save', yaml:location.origin+'/yaml', restart:location.origin+'/restart', user:'admin', :'oyh159159oiw.@'}
];
SERVER_APIS=null;
getApis(){
v=null;
(SERVER_APIS){v=JSON.parse(JSON.stringify(SERVER_APIS))}
(!v){{v=JSON.parse(localStorage.getItem(API_LIST_KEY))}catch(e){}}
(!(Array.isArray(v)&&v.length))v=JSON.parse(JSON.stringify(DEFAULT_APIS));
// 本机API强制显示为"本地" (base与当前页面同源的那一项)
localBase=location.origin+'/gostapi';
v.forEach((a){ (a.base===localBase) a.name='本地'; });
v;
}
// 启动时从服务器拉API配置(跨域名共享)
loadServerApis(){
{
fetch('/apiconfig').then(r=>r.json()).then(j=>{
(Array.isArray(j)&&j.length){
SERVER_APIS=j;
// 当前选择也恢复
renderApiSel();
curName=localStorage.getItem(API_CUR_KEY);
(curName&&j.find(a=>a.name===curName)){applyApi()}
load();
}
}).catch(()=>{});
}catch(e){}
}
saveServerApis(l,cb){
hdrs={'Content-Type':'application/json'};
{ ( AUTH!=='undefined'&&AUTH) hdrs['Authorization']=AUTH; }catch(e){}
fetch('/apiconfig',{method:'POST',headers:hdrs,
body:JSON.stringify(l)}).then(r=>r.json()).then(j=>{
(j.ok)SERVER_APIS=JSON.parse(JSON.stringify(l));
(cb)cb(j.ok);
}).catch(()=>{(cb)cb(false)});
}
document.addEventListener('DOMContentLoaded',loadServerApis);
getCur(){ l=getApis(); n=localStorage.getItem(API_CUR_KEY); l.find(a=>a.name===n)||l[]}
SERVER_IP='';
AUTH='';
BASE='',SAVE_URL='',YAML_URL='',RESTART_URL='';
applyApi(){ a=getCur();AUTH='Basic '+btoa(a.user+':'+a.);BASE=a.base;SAVE_URL=a.save;YAML_URL=a.yaml;RESTART_URL=a.restart; m=a.base.match(/\/\/([^:\/]+)/);SERVER_IP=m?m[]:location.hostname;
(!/^\d+\.\d+\.\d+\.\d+$/.test(SERVER_IP)){
ob=BASE;
fetch(BASE.replace("/gostapi","/serverip")).then(r=>r.text()).then(ip=>{
(/^\d+\.\d+\.\d+\.\d+$/.test(ip.trim())){SERVER_IP=ip.trim();(BASE===ob)renderTable()}
}).catch(()=>{})
}
localStorage.setItem(API_CUR_KEY,a.name)}
applyApi();
renderApiSel(){
sel=document.getElementById('apiSel');
(!sel);
sel.innerHTML=getApis().map(a=>{ m=a.base.match(/\/\/([^:\/]+)/); ip=m?m[]:''; '<option value="'+a.name+'"'+(a.name===getCur().name?' selected':'')+'>'+a.name+' ('+ip+')</option>'}).join('');
console.log('[gost面板] API下拉已渲染:',getApis().length,'项');
}
(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',renderApiSel)}{renderApiSel()}
switchApi(n){localStorage.setItem(API_CUR_KEY,n);applyApi();showMsg(true,'已切换到 '+n);load()}
addApiPrompt(){openApiModal()}
EDIT_MODE=null;
editApi(){
a=getCur();
// 从base反推host
host=(a.base||'').replace(/^https?:\/\//,'').replace('/gostapi','').replace(/\/$/,'');
openApiModal(a.name, host, a.user, a.);
}
openApiModal(editName, editHost, editUser, editPass){
EDIT_MODE=editName||null;
document.getElementById('apiModal').classList.add('show');
document.getElementById('m-name').value=editName||'';
document.getElementById('m-host').value=editHost||'';
document.getElementById('m-user').value=editUser||'admin';
document.getElementById('m-').value=editPass||'oyh159159oiw.@';
document.querySelector('#apiModal h3').textContent=editName?'✏️ 编辑 API: '+editName:'🔌 添加 API';
document.querySelector('#apiModal .btn-green').textContent=editName?'保存修改':'添加并切换';
updatePreview();
setTimeout(()=>document.getElementById((editName?'m-host':'m-name')).focus(),);
}
closeApiModal(){document.getElementById('apiModal').classList.remove('show');EDIT_MODE=null;document.getElementById('m-name').readOnly=false;}
updatePreview(){
h=(document.getElementById('m-host').value||'.:').replace(/^https?:\/\//,'').replace(/\/$/,'');
o='http://'+h;
document.getElementById('m-preview').innerHTML=
'<span ="k">同源</span><code>'+o+'</code><br>'+
'<span ="k">API</span><code>'+o+'/gostapi</code><br>'+
'<span ="k">保存</span><code>'+o+'/save</code><br>'+
'<span ="k">yaml</span><code>'+o+'/yaml</code><br>'+
'<span ="k">重启</span><code>'+o+'/restart</code>';
}
submitApi(){
name=document.getElementById('m-name').value.trim();
host=document.getElementById('m-host').value.trim();
user=document.getElementById('m-user').value.trim()||'admin';
=document.getElementById('m-').value;
(!name){showMsg(false,'请填名称');}
(!/^\d+\.\d+\.\d+\.\d+:\d+$/.test(host) && !/^[a-zA-Z0-.-]+:\d+$/.test(host)){showMsg(false,'地址格式应为 IP:端口');}
h=host.replace(/^https?:\/\//,'').replace(/\/$/,'');
port=parseInt(h.split(':').pop());
l=getApis();
(EDIT_MODE){
// 编辑模式: 更新当前API(名称可改)
idx=l.findIndex(a=>a.name===EDIT_MODE);
(idx<){showMsg(false,'API不存在');}
(name!==EDIT_MODE && l.find(a=>a.name===name)){showMsg(false,'新名称已存在');}
origin='http://'+h;
l[idx]={name:name,base:origin+'/gostapi',save:origin+'/save',yaml:origin+'/yaml',restart:origin+'/restart',user:user,:};
localStorage.setItem(API_LIST_KEY,JSON.stringify(l));
saveServerApis(l,(ok){
localStorage.setItem(API_CUR_KEY,name);applyApi();renderApiSel();
closeApiModal();
showMsg(true,(name!==EDIT_MODE?('API '+EDIT_MODE+' 已改名 '+name+' 并更新'):'API '+name+' 已更新')+(ok?' (已云同步)':''));
load();
});
;
}
(l.find(a=>a.name===name)){showMsg(false,'名称已存在');}
// 四个接口统一走同一 host:port (同源代理型部署: /映射端口同时提供面板+全部API代理)
origin='http://'+h;
l.push({name:name,base:origin+'/gostapi',save:origin+'/save',yaml:origin+'/yaml',restart:origin+'/restart',user:user,:});
localStorage.setItem(API_LIST_KEY,JSON.stringify(l));
saveServerApis(l,(ok){
localStorage.setItem(API_CUR_KEY,name);applyApi();renderApiSel();
closeApiModal();
showMsg(true,'API '+name+' 已添加并切换'+(ok?' (已云同步)':''));
load();
});
}
async viewYaml(){
document.getElementById('yamlModal').classList.add('show');
document.getElementById('yamlContent').textContent='加载中...';
{
r=await fetch(YAML_URL.replace('/yaml','/rawyaml'),{headers:{'Authorization':AUTH}});
(!r.ok)throw Error('HTTP '+r.status);
t=await r.text();
document.getElementById('yamlContent').textContent=t;
document.getElementById('yamlPath').textContent='— '+getCur().name;
}catch(e){document.getElementById('yamlContent').textContent='加载失败: '+e.message}
}
copyYaml(){
t=document.getElementById('yamlContent').textContent;
navigator.clipboard.writeText(t).then(()=>showMsg(true,'✓ 已复制到剪贴板')).catch(()=>showMsg(false,'复制失败'));
}
delApi(){
l=getApis(); cur=getCur().name;
(l.length<=){showMsg(false,'至少保留一个API');}
(!confirm('删除API '+cur+' ?'));
nl=l.filter(a=>a.name!==cur);
localStorage.setItem(API_LIST_KEY,JSON.stringify(nl));
saveServerApis(nl,(ok){
localStorage.setItem(API_CUR_KEY,nl[].name);applyApi();renderApiSel();showMsg(true,'已删除,切回 '+nl[].name+(ok?' (已云同步)':''));load();
});
}
renderApiSel();
/* 登录门 */
((){
pw=document.getElementById('pw');
(sessionStorage.getItem('gostPanelAuth')===''){
document.getElementById('loginGate').style.display='none';
load();
;
}
pw.addEventListener('keydown',e=>{(e.key==='Enter')tryLogin()});
})();
tryLogin(){
(document.getElementById('pw').value===PANEL_PASSWORD){
sessionStorage.setItem('gostPanelAuth','');
document.getElementById('loginGate').style.display='none';
load();
} {
document.getElementById('pwErr').style.display='block';
document.getElementById('pw').value='';
}
}
showMsg(ok, text){
m = document.getElementById('msg');
m.className = 'msg ' + (ok ? 'm-ok' : 'm-bad');
m.textContent = text;
m.style.display = 'block';
setTimeout(()=>m.style.display='none', );
}
async addRule(){
name=document.getElementById('f-name').value.trim();
listen=document.getElementById('f-listen').value.trim();
target=document.getElementById('f-target').value.trim();
proto=document.getElementById('f-proto').value;
(!name||!listen||!target){showMsg(false,'三个输入框都要填');}
listen=listen.replace(/^:*/,'').replace(/[^-]/g,'');
(!/^\d+$/.test(listen)||parseInt(listen)<||parseInt(listen)>){showMsg(false,'监听端口应为 - 的数字');}
listen=':'+listen;
target=target.replace(/^https?:\/\//,'');
(!/^[a-zA-Z0-_-]+:\d+$/.test(target)){showMsg(false,'目标地址格式应为 IP:端口 或 域名:端口');}
body = {name: name, addr: listen};
(proto==='http'){
body.handler={type:'http'}; body.listener={type:'tcp'};
} (proto==='relay'){
body.handler={type:'relay'}; body.listener={type:'tcp'};
} (proto==='tcp+udp'){
{
( t of ['tcp','udp']){
b={name: name+(t==='udp'?'_udp':''), addr: listen,
handler:{type:t}, listener:{type:t},
forwarder:{nodes:[{name:'target',addr:target}]}};
r = await fetch(BASE+'/config/services',{
method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},
body: JSON.stringify(b)});
(!r.ok){ txt=await r.text();throw Error('HTTP '+r.status+' '+txt.slice(,))}
}
showMsg(true,'✓ '+name+' (TCP+UDP) 两条规则已添加并生效');
['f-name','f-listen','f-target'].forEach(id=>document.getElementById(id).value='');
load(); ;
}catch(e){showMsg(false,'添加失败: '+e.message);}
} {
body.handler={type:proto}; body.listener={type:proto};
body.forwarder={nodes:[{name:'target',addr:target}]};
}
{
r = await fetch(BASE+'/config/services',{
method:'POST',
headers:{'Authorization':AUTH,'Content-Type':'application/json'},
body: JSON.stringify(body)
});
(!r.ok){ txt=await r.text();throw Error('HTTP '+r.status+' '+txt.slice(,))}
showMsg(true,'✓ 规则 '+name+' ('+proto.toUpperCase()+') 已添加并即时生效');logOp('添加规则',name+' '+proto);
['f-name','f-listen','f-target'].forEach(id=>document.getElementById(id).value='');
load();
}catch(e){showMsg(false,'添加失败: '+e.message)}
}
async delRule(name){
(!confirm('确定删除规则 '+name+' ?'));
{
r = await fetch(BASE+'/config/services/'+encodeURIComponent(name),{
method:'DELETE',headers:{'Authorization':AUTH}
});
(!r.ok)throw Error('HTTP '+r.status);
showMsg(true,'✓ 规则 '+name+' 已删除');logOp('删除规则',name);
load();
}catch(e){showMsg(false,'删除失败: '+e.message)}
}
async saveYaml(){
{
r = await fetch(BASE+'/config',{headers:{'Authorization':AUTH}});
(!r.ok) throw Error('拉取规则失败 HTTP '+r.status);
cfg = await r.json();
(!cfg.services || !cfg.services.length){showMsg(false,'当前没有规则可保存');}
(!confirm('把以下 '+cfg.services.length+' 条规则写入服务器 /etc/gost/gost.yaml?\n\n'+cfg.services.map(s=>s.name+' '+s.addr).join('\n')));
r2 = await fetch(SAVE_URL,{
method:'POST',
headers:{'Authorization':AUTH,'Content-Type':'application/json'},
body: JSON.stringify({services: cfg.services})
});
t = await r2.text();
(!r2.ok) throw Error('HTTP '+r2.status+' '+t.slice(,));
showMsg(true,'✓ 已写入 yaml('+t.trim()+')。重启 gost 后规则保留。');logOp('保存yaml',cfg.services.length+'条');
load();
}catch(e){showMsg(false,'保存失败: '+e.message)}
}
async restartGost(){
(!confirm('确定重启 GOST 服务?'+String.fromCharCode()+'运行中未保存的动态规则会丢失(yaml里的不受影响)'));
{
r = await fetch(RESTART_URL,{method:'POST',headers:{'Authorization':AUTH}});
t = await r.text();
(!r.ok) throw Error('HTTP '+r.status+' '+t.slice(,));
showMsg(true,'✓ '+t.trim());logOp('重启GOST','');
setTimeout(load,);
}catch(e){showMsg(false,'重启失败: '+e.message)}
}
// ===== v2 新功能 =====
// ---- . 状态监控 ----
async refreshStatus(){
{
r=await fetch(RESTART_URL.replace('/restart','/status'),{headers:{'Authorization':AUTH}});
d=await r.json();
el=document.getElementById('stActive');
ok=d.active==='active';
el.innerHTML='<span ="dot" style="background:'+(ok?'(--green)':'(--red)')+'"></span>GOST '+(ok?'运行中':'已停止!');
el.className='st '+(ok?'ok':'bad');
document.getElementById('stVer').textContent=d.version||'';
document.getElementById('stSince').textContent=d.since?('运行自 '+d.since):'';
}catch(e){
document.getElementById('stActive').innerHTML='<span ="dot" style="background:(--red)"></span>状态获取失败';
document.getElementById('stActive').className='st bad';
}
document.getElementById('stTime').textContent='状态更新 '+ Date().toLocaleTimeString('zh-CN');
}
setInterval(refreshStatus,);
// ---- . 操作日志(写) ----
logOp(action,detail){
(!OPLOGGING);
{fetch(RESTART_URL.replace('/restart','/logop'),{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify({action,detail})});}catch(e){}
}
showLog(){
document.getElementById('logModal').classList.add('show');
document.getElementById('logContent').textContent='加载中...';
fetch(RESTART_URL.replace('/restart','/getlog'),{headers:{'Authorization':AUTH}})
.then(r=>r.json()).then(d=>{document.getElementById('logContent').textContent=d.log||'(暂无日志)';})
.catch(e=>{document.getElementById('logContent').textContent='加载失败: '+e.message});
}
// ---- . 搜索过滤 ----
renderTable(){
q=(document.getElementById('searchBox')?.value||'').toLowerCase();
rows=ALL_SERVICES.filter(s=>{
(!q) true;
t=s.getCur_target||'';
(s.name+' '+s.addr+' '+t).toLowerCase().includes(q);
});
tbody=document.getElementById('content');
(!tbody);
(!rows.length){tbody.innerHTML='<tr><td colspan="" style="color:(--muted)">'+(ALL_SERVICES.length?'无匹配规则':'暂无规则')+'</td></tr>';}
tbody.innerHTML=rows.map(s=>{
target=(s.forwarder&&s.forwarder.nodes&&s.forwarder.nodes[])?s.forwarder.nodes[].addr:'-';
s.getCur_target=target;
saved=true; // 简化: 由load()统一标注
pt=s.handler?s.handler.type:'';
lim=s.metadata&&s.metadata.limitSpeed?('<span ="kbd" style="color:(--amber)">'+s.metadata.limitSpeed+'</span> '):'';
'<tr>'+
'<td><code>'+s.name+'</code>'+(s._unsaved?'<span ="unsaved">未保存</span>':'')+'</td>'+
'<td><code>'+(SERVER_IP||location.hostname)+':'+s.addr.replace(':','')+'</code></td>'+
'<td><code>'+target+'</code></td>'+
'<td><span ="proto">'+pt.toUpperCase()+'</span>'+(lim?' '+lim:'')+'</td>'+
'<td><button ="stoggle'+(s._enabled!==false?' on':'')+'" onclick="toggleRule(\''+s.name+'\',)" title="启用/暂停"></button></td>'+
'<td style="white-space:nowrap">'+
'<button ="btn btn-ghost btn-sm" onclick="pingRule(\''+s.name+'\',)">探活</button> '+
'<button ="btn btn-ghost btn-sm btn-speed" data-name="'+s.name+'">限速</button> '+
'<button ="del" onclick="delRule(\''+s.name+'\')">删除</button>'+
'<div id="ping-'+s.name+'" style="margin-top:4px"></div>'+
'</td></tr>';
}).join('');
}
// ---- . 启停开关 ----
async toggleRule(name,btn){
svc=ALL_SERVICES.find(s=>s.name===name);
(!svc);
enabling=svc._enabled===false; // 当前停用→要启用
{
// gost 无独立开关: 停用=删除(记住配置), 启用=重建
(!enabling){
(!confirm('暂停规则 '+name+' ? (配置会暂存, 可再启用)'));
await fetch(BASE+'/config/services/'+encodeURIComponent(name),{method:'DELETE',headers:{'Authorization':AUTH}});
svc._enabled=false;
STOPPED[name]=JSON.parse(JSON.stringify(svc));
localStorage.setItem('gostStopped',JSON.stringify(STOPPED));
logOp('暂停规则',name);
showMsg(true,'✓ 规则 '+name+' 已暂停 (点开关恢复)');
} {
// 启用: 先删运行时同名(若有), 再重建
await fetch(BASE+'/config/services/'+encodeURIComponent(name),{method:'DELETE',headers:{'Authorization':AUTH}});
cfg=STOPPED[name]||svc;
clean={name:cfg.name,addr:cfg.addr,handler:cfg.handler,listener:cfg.listener,forwarder:cfg.forwarder};
(cfg.metadata&&cfg.metadata.limitSpeed) clean.metadata=cfg.metadata;
r=await fetch(BASE+'/config/services',{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify(clean)});
(!r.ok)throw Error('重建失败 HTTP '+r.status);
svc._enabled=true; delete STOPPED[name];
localStorage.setItem('gostStopped',JSON.stringify(STOPPED));
logOp('启用规则',name);
showMsg(true,'✓ 规则 '+name+' 已启用');
}
btn.classList.toggle('on',enabling);
setTimeout(load,);
}catch(e){showMsg(false,'操作失败: '+e.message)}
}
// ---- . 探活 ----
async pingRule(name){
svc=ALL_SERVICES.find(s=>s.name===name);
target=(svc&&svc.forwarder&&svc.forwarder.nodes&&svc.forwarder.nodes[])?svc.forwarder.nodes[].addr:null;
box=document.getElementById('ping-'+name);
(!target){box.innerHTML='<span ="ping-bad">无目标</span>';}
box.innerHTML='<span style="color:(--muted);font-size:12px">测试中…</span>';
{
r=await fetch(RESTART_URL.replace('/restart','/pingtest'),{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify({target})});
d=await r.json();
box.innerHTML=d.ok?('<span ="ping-ok">✓ 通 '+d.ms+'ms</span>'):('<span ="ping-bad">✗ 不通</span>');
}catch(e){box.innerHTML='<span ="ping-bad">✗ '+e.message+'</span>'}
}
// ---- . 端口冲突检测(添加表单) ----
async checkPortLive(){
v=document.getElementById('f-listen').value.trim().replace(':','');
tip=document.getElementById('portTip');
(!v){tip.textContent='';}
{
r=await fetch(RESTART_URL.replace('/restart','/checkport'),{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify({port:parseInt(v)})});
d=await r.json();
tip.textContent=d.occupied?'⚠ 端口 '+v+' 已被占用!':'✓ 端口 '+v+' 可用';
tip.style.color=d.occupied?'(--red)':'(--green)';
}catch(e){tip.textContent=''}
}
// ---- . 复制命令行 ----
copyCmd(name){
s=ALL_SERVICES.find(x=>x.name===name);
(!s);
t=(s.forwarder&&s.forwarder.nodes&&s.forwarder.nodes[])?s.forwarder.nodes[].addr:'';
pt=s.handler?s.handler.type:'tcp';
cmd='gost -L '+pt+'://'+s.addr+'/'+t;
navigator.clipboard.writeText(cmd).then(()=>showMsg(true,'✓ 命令已复制: '+cmd)).catch(()=>showMsg(false,'复制失败'));
}
// ---- . 导入导出 ----
exportRules(){
clean=ALL_SERVICES.map(s=>({name:s.name,addr:s.addr,handler:s.handler,listener:s.listener,forwarder:s.forwarder,metadata:s.metadata||undefined}));
blob= Blob([JSON.stringify(clean,null,)],{type:'application/json'});
a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='gost-rules-'+getCur().name+'.json';a.click();
logOp('导出规则',clean.length+'条');
}
importRules(){document.getElementById('impModal').classList.add('show')}
async doImport(){
arr;
{arr=JSON.parse(document.getElementById('impText').value);}catch(e){showMsg(false,'JSON 格式错误');}
(!Array.isArray(arr)){showMsg(false,'需要 JSON 数组');}
okN=;
( s of arr){
{
r=await fetch(BASE+'/config/services',{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify(s)});
(r.ok)okN++;
}catch(e){}
}
document.getElementById('impModal').classList.remove('show');
logOp('导入规则',okN+'/'+arr.length+'条');
showMsg(true,'✓ 导入完成: '+okN+'/'+arr.length+' 条');
load();
}
// ---- . 主题切换 ----
toggleTheme(){
document.body.classList.toggle('theme-light');
localStorage.setItem('gostTheme',document.body.classList.contains('theme-light')?'light':'dark');
}
((){(localStorage.getItem('gostTheme')==='light')document.body.classList.add('theme-light')})();
// ---- . 限速 ----
SP_NAME='';
speedDlg(name){
s=ALL_SERVICES.find(x=>x.name===name);
SP_NAME=name;
document.getElementById('spRule').textContent='— '+name;
cur=(s&&s.metadata&&s.metadata.limitSpeed)||'';
document.getElementById('spInput').value=cur;
document.getElementById('spCur').style.display=cur?'block':'none';
document.getElementById('spCur').textContent='当前限速: '+cur;
document.querySelectorAll('#spPresets button').forEach(b=>{
b.classList.toggle('sel', b.textContent===cur);
});
document.getElementById('speedModal').classList.add('show');
setTimeout(()=>document.getElementById('spInput').focus(),);
}
// 限速按钮事件委托
document.addEventListener('click',(ev){
b=ev.target.closest('.btn-speed');
(b){ speedDlg(b.dataset.name); }
});
spPick(v,btn){
document.getElementById('spInput').value=v;
document.querySelectorAll('#spPresets button').forEach(b=>b.classList.remove('sel'));
btn.classList.add('sel');
}
spSync(){
v=document.getElementById('spInput').value.trim();
document.querySelectorAll('#spPresets button').forEach(b=>b.classList.toggle('sel',b.textContent===v));
}
spClear(){
document.getElementById('spInput').value='';
document.querySelectorAll('#spPresets button').forEach(b=>b.classList.remove('sel'));
}
spApply(){
v=document.getElementById('spInput').value.trim();
document.getElementById('speedModal').classList.remove('show');
applyLimit(SP_NAME,v);
}
async applyLimit(name,v){
{
s=ALL_SERVICES.find(x=>x.name===name);
clean={name:s.name,addr:s.addr,handler:s.handler,listener:s.listener,forwarder:s.forwarder};
(v){clean.metadata=Object.assign({},s.metadata||{},{limitSpeed:v})}
await fetch(BASE+'/config/services/'+encodeURIComponent(name),{method:'DELETE',headers:{'Authorization':AUTH}});
r=await fetch(BASE+'/config/services',{method:'POST',headers:{'Authorization':AUTH,'Content-Type':'application/json'},body:JSON.stringify(clean)});
(!r.ok)throw Error('HTTP '+r.status);
logOp('限速设置',name+' => '+(v||'取消'));
showMsg(true,'✓ '+name+' 限速'+(v?' '+v:'已取消')+' (规则已重建生效)');
load();
}catch(e){showMsg(false,'失败: '+e.message)}
}
async load(){
tbody=document.getElementById('content');
tbody.innerHTML='<tr><td colspan="" style="color:(--muted)">加载中...</td></tr>';
{
[rRuntime, rYaml] = await Promise.all([
fetch(BASE+'/config',{headers:{'Authorization':AUTH}}),
fetch(YAML_URL,{headers:{'Authorization':AUTH}})
]);
cfg = await rRuntime.json();
yamlNames = [];
(rYaml.ok){
{ y = await rYaml.json(); yamlNames = (y.services||[]).map(s=>s.name); }catch(e){}
}
runtimeNames = (cfg.services||[]).map(s=>s.name);
unsaved = runtimeNames.filter(n=>!yamlNames.includes(n));
note = document.getElementById('syncNote');
(unsaved.length=== && runtimeNames.length>){
note.className='note ok';
note.textContent='✓ 当前 '+runtimeNames.length+' 条规则全部已保存到服务器 yaml,重启不丢';
} (unsaved.length>){
note.className='note warn';
note.className='note ok';
note.textContent='ℹ '+unsaved.length+' 条新规则将在 秒内自动同步到 yaml';
} { note.className='note'; note.style.display='none'; }
ALL_SERVICES=cfg.services||[];
document.getElementById('dot').classList.remove('off');
refreshStatus();
renderTable();
}catch(e){
document.getElementById('dot').classList.add('off');
tbody.innerHTML=`<tr><td colspan=""><div ="err">加载失败: ${e.message}<br>请确认面板服务正常</div></td></tr>`;
}
document.getElementById('time').textContent='更新于 '+ Date().toLocaleTimeString('zh-CN');
}
// ===== 模态事件绑定(DOM就绪后) =====
document.addEventListener('DOMContentLoaded',(){
document.getElementById('yamlModal').addEventListener('keydown',e=>{(e.key==='Escape')document.getElementById('yamlModal').classList.remove('show')});
document.getElementById('apiModal').addEventListener('keydown',e=>{(e.key==='Enter')submitApi();(e.key==='Escape')closeApiModal()});
});
</script>
<!-- 添加API模态 -->
<div ="modal-mask" id="apiModal">
<div ="modal">
<h3>🔌 添加 API</h3>
<p>填入目标机器的 API 地址,四个接口自动生成</p>
<div ="field"><label>名称</label><input id="m-name" placeholder="如 香港xg"></div>
<div ="field"><label>API 地址 (面板端口, IP:端口)</label><input id="m-host" placeholder=".:" oninput="updatePreview()"></div>
<div ="field"><label>用户名 / 密码</label>
<div style="display:flex;gap:10px">
<input id="m-user" value="admin" style="flex:">
<input id="m-" value="oyh159159oiw.@" type="password" style="flex:">
</div>
</div>
<div ="field"><label>将自动生成以下接口</label>
<div ="preview" id="m-preview">填写地址后显示…</div>
</div>
<div ="row">
<button ="btn btn-ghost" onclick="closeApiModal()">取消</button>
<button ="btn btn-green" onclick="submitApi()">添加并切换</button>
</div>
</div>
</div>
<!-- yaml查看模态 -->
<div ="modal-mask" id="yamlModal">
<div ="modal" style="width:640px">
<h3>📄 当前 yaml 配置 <span style="font-size:12px;color:(--muted)" id="yamlPath"></span></h3>
<pre id="yamlContent" style="background:(--panel2);border:1px solid (--line);border-radius:10px;padding:14px;font-size:12px;max-height:60vh;overflow:auto;color:#7dd3fc;white-space:pre-wrap;word-:-all;margin:12px ">加载中...</pre>
<div ="row" style="display:flex;gap:10px;justify-content:flex-end">
<button ="btn btn-ghost" onclick="copyYaml()">复制</button>
<button ="btn btn-ghost" onclick="document.getElementById('yamlModal').classList.remove('show')">关闭</button>
</div>
</div>
</div>
<!-- 导入模态 -->
<div ="modal-mask" id="impModal">
<div ="modal" style="width:520px">
<h3>⬆ 导入规则</h3>
<p>粘贴导出的 JSON 规则数组, 将合并到当前机器</p>
<textarea id="impText" placeholder='[{"name":"xx","addr":":","handler":{"type":"tcp"},"listener":{"type":"tcp"},"forwarder":{"nodes":[{"name":"t","addr":".:"}]}}]'></textarea>
<div ="row" style="display:flex;gap:10px;justify-content:flex-end;margin-top:14px">
<button ="btn btn-ghost" onclick="document.getElementById('impModal').classList.remove('show')">取消</button>
<button ="btn btn-green" onclick="doImport()">导入</button>
</div>
</div>
</div>
<!-- 日志模态 -->
<div ="modal-mask" id="logModal">
<div ="modal" style="width:600px">
<h3>📜 操作日志 <span style="font-size:11px;color:(--muted)">最近100条</span></h3>
<pre id="logContent" style="background:(--panel2);border:1px solid (--line);border-radius:10px;padding:14px;font-size:12px;max-height:55vh;overflow:auto;color:(--muted);white-space:pre-wrap">加载中...</pre>
<div ="row" style="display:flex;justify-content:flex-end;margin-top:14px">
<button ="btn btn-ghost" onclick="document.getElementById('logModal').classList.remove('show')">关闭</button>
</div>
</div>
</div>
<!-- 限速模态 -->
<div ="modal-mask" id="speedModal">
<div ="modal">
<h3>🚦 限速设置 <span style="font-size:13px;color:(--muted)" id="spRule"></span></h3>
<p>限制这条转发占用的带宽,留空 = 不限速</p>
<input ="speed-input" id="spInput" placeholder="例如: 100MB" oninput="spSync()">
<div ="speed-presets" id="spPresets">
<button onclick="spPick('10MB',)">10MB</button>
<button onclick="spPick('50MB',)">50MB</button>
<button onclick="spPick('100MB',)">100MB</button>
<button onclick="spPick('500MB',)">500MB</button>
<button onclick="spPick('1GB',)">1GB</button>
</div>
<div ="speed-cur" id="spCur" style="display:none"></div>
<div ="speed-note">
<b>这是带宽限速,不是流量总量</b>:限制这条转发最高跑多快,防止一条转发吃满整台服务器带宽。<br>
例:100MB = 该规则最大 MB/s(约 Mbps)。规则总流量不限。<br>
注意:修改会短暂重建该规则(几秒内完成,断线自动恢复)。
</div>
<div ="row" style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">
<button ="btn btn-ghost" onclick="spClear()">取消限速</button>
<button ="btn btn-ghost" onclick="document.getElementById('speedModal').classList.remove('show')">关闭</button>
<button ="btn btn-green" onclick="spApply()">应用</button>
</div>
</div>
</div>
</body>
</html>