📄 panel.html 📝 编辑 ⬇ 下载 ← 返回列表
<!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>