Files
HomeDash/admin/index.html
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

1117 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航管理</title>
<script>(function(){var s=localStorage.getItem('navi-theme');var p=window.matchMedia('(prefers-color-scheme:dark)').matches;document.documentElement.setAttribute('data-theme',s||(p?'dark':'light'))})()</script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bd:rgba(15,23,42,.10); --bg-card:rgba(255,255,255,.72);
--tx:#0f172a; --tx2:#475569; --tx3:#94a3b8; --ac:#2563eb; --danger:#dc2626;
--r:16px; --r-sm:10px;
}
body{
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
color:var(--tx); min-height:100vh; padding:28px 18px 64px;
background:
radial-gradient(1100px 600px at 12% -10%, #e0f2fe 0%, transparent 60%),
radial-gradient(900px 520px at 88% 0%, #ede9fe 0%, transparent 55%),
linear-gradient(160deg,#f8fafc,#eef2ff 55%,#f0fdfa);
background-attachment:fixed;
}
.wrap{max-width:920px;margin:0 auto}
h1{font-size:22px;font-weight:800;letter-spacing:.2px;margin-bottom:4px}
.sub{color:var(--tx2);font-size:13px;margin-bottom:22px}
.card{
background:var(--bg-card); border:1px solid var(--bd); border-radius:var(--r);
padding:18px 20px; margin-bottom:16px;
backdrop-filter:blur(14px) saturate(150%); -webkit-backdrop-filter:blur(14px) saturate(150%);
box-shadow:0 8px 28px rgba(15,23,42,.07),0 1px 2px rgba(15,23,42,.04);
}
.card h2{font-size:15px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.badge{background:var(--ac);color:#fff;font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600}
.row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.fg{flex:1;min-width:190px;display:flex;flex-direction:column;gap:5px}
label{font-size:12px;font-weight:600;color:var(--tx2)}
input,select,textarea{
font:inherit;font-size:14px;color:var(--tx);padding:9px 12px;
background:rgba(255,255,255,.85);border:1px solid var(--bd);border-radius:var(--r-sm);outline:none;
transition:border-color .15s,box-shadow .15s;
}
input:focus,select:focus,textarea:focus{border-color:var(--ac);box-shadow:0 0 0 3px rgba(37,99,235,.13)}
textarea{resize:vertical;min-height:64px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
button{
font:inherit;font-size:13.5px;font-weight:600;padding:9px 16px;border:1px solid var(--bd);
border-radius:var(--r-sm);background:rgba(255,255,255,.9);color:var(--tx);cursor:pointer;
transition:.15s;
}
button:hover{border-color:#cbd5e1;transform:translateY(-1px)}
button.primary{background:var(--ac);border-color:var(--ac);color:#fff}
button.primary:hover{background:#1d4ed8}
button.ghost{background:transparent}
button.icon-btn{padding:5px 9px;font-size:12px}
button.danger{color:var(--danger);border-color:rgba(220,38,38,.3)}
button.danger:hover{background:rgba(220,38,38,.08)}
.sec-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.sec-nm{font-weight:700;font-size:14px}
.itm{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:var(--r-sm);margin-bottom:5px;background:rgba(255,255,255,.6);border:1px solid transparent;transition:.15s}
.itm:hover{background:rgba(255,255,255,.95);border-color:var(--bd)}
.itm img{width:22px;height:22px;object-fit:contain;border-radius:5px;flex:0 0 auto}
.itm .ico-fa{width:22px;text-align:center;color:var(--tx3);flex:0 0 auto}
.itm-i{flex:1;min-width:0}
.itm-t{font-weight:600;font-size:13.5px}
.itm-u{color:var(--tx3);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.link-row{display:flex;gap:8px;align-items:center;margin-bottom:6px}
.link-row input{flex:1}
.hint{font-size:12px;color:var(--tx3);line-height:1.6}
.toast{
position:fixed;left:50%;bottom:28px;transform:translateX(-50%);
padding:11px 20px;border-radius:999px;color:#fff;font-size:13.5px;font-weight:600;
box-shadow:0 10px 30px rgba(15,23,42,.22);z-index:99;animation:pop .18s ease-out;
}
@keyframes pop{from{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%,0)}}
.preview{display:flex;align-items:center;gap:8px;margin-top:6px}
.preview img{width:26px;height:26px;object-fit:contain;border-radius:6px;border:1px solid var(--bd);background:#fff}
details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
/* ── 窄屏(手机)──
分组行原本是内联 flex + 固定 max-width,窄屏既不能换行也不能收缩,
输入框会顶破卡片;这里统一改为可换行、可收缩、整行占满。 */
.sec-fields{display:flex;gap:8px;align-items:center;flex:1;min-width:0;flex-wrap:wrap}
.sec-fields input{min-width:0}
.sec-in-name{flex:1 1 120px;max-width:200px}
.sec-in-icon{flex:1 1 140px;max-width:220px}
.sec-acts{display:flex;gap:6px;flex:0 0 auto}
.gw-host{flex:1 1 170px;max-width:280px;min-width:0}
.gw-up{flex:1 1 190px;max-width:320px;min-width:0}
.gw-mb{flex:0 0 110px;min-width:0}
.gw-ck{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--tx2);white-space:nowrap}
.gw-kind,.gw-sec{flex:0 0 118px;min-width:0}
.grp{margin:26px 0 10px;font-size:12px;font-weight:800;letter-spacing:.08em;color:var(--tx3);text-transform:uppercase}
.grp-in{margin:16px 0 8px;font-size:12.5px;font-weight:700;color:var(--tx2);padding-top:12px;border-top:1px dashed var(--bd)}
#gw-import{min-height:110px;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.gw-navname{flex:1 1 130px;min-width:0}
.gw-navdesc{flex:1 1 150px;min-width:0}
.gw-navicon{flex:1 1 150px;min-width:0}
.gw-ck input{width:auto;padding:0}
#gw-msg{word-break:break-all}
/* ── 深色模式 ──
<html data-theme="dark"> 由 head 里的脚本按系统偏好设置(手动选择优先,
与导航页/登录页共用 navi-theme 这个键,三处观感一致)。
变量在这里整体替换;少数写死的白色单独覆盖。 */
html[data-theme="dark"]{
color-scheme:dark;
--bd:rgba(148,163,184,.22); --bg-card:rgba(17,25,40,.74);
--tx:#e8eefc; --tx2:#a9b6cc; --tx3:#7c8aa3; --ac:#60a5fa; --danger:#f87171;
}
html[data-theme="dark"] body{
background:
radial-gradient(1100px 600px at 12% -10%, rgba(37,99,235,.22) 0%, transparent 60%),
radial-gradient(900px 520px at 88% 0%, rgba(139,92,246,.18) 0%, transparent 55%),
linear-gradient(160deg,#0b1120,#101a2e 55%,#0b1524);
}
html[data-theme="dark"] .card{ box-shadow:0 8px 28px rgba(0,0,0,.45),0 1px 2px rgba(0,0,0,.3) }
html[data-theme="dark"] input,
html[data-theme="dark"] select,
html[data-theme="dark"] textarea{ background:rgba(15,23,42,.78); color:var(--tx); border-color:var(--bd) }
html[data-theme="dark"] button{ background:rgba(30,41,59,.85); color:var(--tx); border-color:var(--bd) }
html[data-theme="dark"] button:hover{ border-color:#475569 }
html[data-theme="dark"] button.primary{ background:var(--ac); border-color:var(--ac); color:#0b1120 }
html[data-theme="dark"] button.primary:hover{ background:#93bbfd }
html[data-theme="dark"] button.danger{ color:var(--danger); border-color:rgba(248,113,113,.35) }
html[data-theme="dark"] button.danger:hover{ background:rgba(248,113,113,.12) }
html[data-theme="dark"] .itm{ background:rgba(30,41,59,.5) }
html[data-theme="dark"] .itm:hover{ background:rgba(51,65,85,.62); border-color:var(--bd) }
html[data-theme="dark"] .preview img{ background:#0f172a }
html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }
@media (max-width:560px){
body{padding:16px 12px 48px}
.wrap{max-width:100%}
h1{font-size:19px}
.card{padding:14px;border-radius:14px}
.row{flex-direction:column;gap:10px;margin-bottom:12px}
.fg{min-width:0;max-width:none!important}
.fg[style]{max-width:none!important}
.itm{flex-wrap:wrap;gap:8px}
.sec-row .itm-i{flex-basis:100%;min-width:0}
.sec-in-name,.sec-in-icon,.gw-host,.gw-up{flex:1 1 100%;max-width:none!important}
.gw-mb,.gw-kind,.gw-sec,.gw-navname,.gw-navdesc,.gw-navicon{flex:1 1 100%;max-width:none!important}
.sec-acts{width:100%;justify-content:flex-end}
.link-row{flex-wrap:wrap}
.link-row input{flex:1 1 100%}
button{padding:9px 14px}
/* iOS 聚焦时会缩放小于 16px 的输入框 */
input,select,textarea{font-size:16px}
.itm-u{white-space:normal;word-break:break-all}
}
</style>
</head>
<body>
<div class="wrap">
<h1>导航管理</h1>
<p class="sub">站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。</p>
<div class="grp">站点与外观</div>
<!-- 站点信息 -->
<div class="card">
<h2>站点信息</h2>
<div class="row">
<div class="fg"><label>标题</label><input id="p-title" placeholder="JianF 统一门户"></div>
<div class="fg"><label>副标题</label><input id="p-desc" placeholder="内网服务导航"></div>
</div>
<div class="row">
<div class="fg"><label>ICP 备案号</label><input id="p-icp" placeholder="京ICP备00000000号-1"></div>
<div class="fg"><label>公安备案号(可选)</label><input id="p-gongan" placeholder="京公网安备00000000000000号"></div>
</div>
<details style="margin:12px 0">
<summary style="cursor:pointer;font-size:13px;color:#475569">认证页与门户页文案(留空=用内置默认)</summary>
<div class="row" style="margin-top:8px">
<div class="fg"><label>登录页标题</label><input id="p-auth-title" placeholder="统一门户"></div>
<div class="fg"><label>登录页副标题</label><input id="p-auth-sub" placeholder="登录后访问受保护的内部服务"></div>
<div class="fg" style="max-width:160px"><label>登录页徽标</label><input id="p-auth-logo" placeholder="JF"></div>
</div>
<div class="row">
<div class="fg"><label>门户页浏览器标题</label><input id="p-portal-title" placeholder="JianF Git - 个人开发环境"></div>
<div class="fg"><label>门户页主标题</label><input id="p-hero-title" placeholder="代码、博客与灵感之站"></div>
<div class="fg"><label>门户页副标题</label><input id="p-hero-sub" placeholder="JianF Git · 代码托管 · 技术博客"></div>
</div>
<p class="hint" style="margin:6px 0 0">整页完全自定义:把 <code>index.html</code> 放进数据卷的 <code>portal/</code> 目录即生效(优先于以上字段)。</p>
</details>
<details style="margin:12px 0">
<summary style="cursor:pointer;font-size:13px;color:#475569">高级:自定义页脚 HTML</summary>
<div class="fg" style="margin-top:8px">
<textarea id="p-footer" placeholder="留空则按上面的备案号自动生成;填写后以这里为准"></textarea>
</div>
</details>
<label>顶部导航链接</label>
<div id="navlinks" style="margin:8px 0 10px"></div>
<button class="ghost icon-btn" id="add-link">+ 添加链接</button>
<div style="margin-top:14px"><button class="primary" id="save-info">保存站点信息</button></div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<div class="grp">历史记录</div>
<!-- 历史访问 -->
<div class="card">
<h2>历史访问</h2>
<p class="sub" style="margin:0 0 12px">删除单条或清空全部浏览记录;删除后 Dashy 的「历史访问」分组会立即重建。</p>
<div id="hist"><div class="muted">读取中…</div></div>
<div style="margin-top:14px"><button class="ghost" id="clear-hist">清空全部历史</button></div>
</div>
<div class="grp">导航内容</div>
<!-- 导航分组 -->
<div class="card">
<h2>导航分组</h2>
<p class="sub" style="margin:0 0 12px">
分组就是导航页上的栏目,可以按需增删。初始可以一个都没有。
「历史访问」由浏览记录自动维护,不能改名或删除。
</p>
<div id="sec-list"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<div class="fg" style="max-width:240px"><label>新分组名称</label><input id="sec-name" placeholder="例如 常用服务"></div>
<div class="fg" style="max-width:240px"><label>图标(可留空)</label><input id="sec-icon" placeholder="fas fa-star"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button class="primary" id="sec-add">添加分组</button>
</div>
</div>
</div>
<!-- 添加服务 -->
<div class="card">
<h2>添加服务</h2>
<div class="row">
<div class="fg" style="max-width:220px"><label>所属分组</label><select id="sel"></select></div>
<div class="fg"><label>名称 *</label><input id="ti" placeholder="Gitea"></div>
</div>
<div class="row">
<div class="fg"><label>描述</label><input id="de" placeholder="代码仓库"></div>
<div class="fg"><label>URL *</label><input id="ur" placeholder="https://example.com"></div>
</div>
<div class="row">
<div class="fg"><label>图标</label><input id="ic" placeholder="文件名 / fas fa-star / 留空用 favicon"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-icon">上传图片</button>
<input type="file" id="file-icon" accept="image/*,.ico" hidden>
</div>
</div>
<div class="preview" id="icon-preview"></div>
<div style="margin-top:14px"><button class="primary" id="do-add">添加</button></div>
</div>
<!-- 分组 -->
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
<!-- ===== 转发与统一登录 ===== -->
<div class="grp">转发与统一登录</div>
<div class="card">
<h2>服务转发</h2>
<p class="sub" style="margin:0 0 12px">
一条记录 = 一个域名 → 内网后端。<b>是否走统一登录</b>、<b>是否加入 Dashy 导航</b>
都是这条记录上的开关;勾选导航后名称/描述/图标(或自动抓取)也在同一处配置。
</p>
<div id="gw-routes"><div class="muted">读取中…</div></div>
<div class="grp-in">添加转发</div>
<div class="row">
<div class="fg"><label>域名</label><input id="gw-n-host" placeholder="git.example.com"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://127.0.0.1:3000"></div>
<div class="fg" style="max-width:170px"><label>类型</label>
<select id="gw-n-kind">
<option value="proxy">反向代理</option>
<option value="portal">门户静态页</option>
<option value="auth">登录服务</option>
</select>
</div>
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-auth" checked>启用 portal 统一登录保护</label>
<label class="gw-ck"><input type="checkbox" id="gw-n-ws">WebSocket 透传</label>
<input class="gw-mb" id="gw-n-mb" placeholder="上传MB上限">
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-nav" checked>同时加入 Dashy 导航</label>
<div class="fg" style="max-width:180px"><label>导航分组</label><select id="gw-n-sec"></select></div>
<div class="fg" style="max-width:180px"><label>导航名称</label><input id="gw-n-name" placeholder="Gitea"></div>
<div class="fg" style="max-width:180px"><label>导航描述</label><input id="gw-n-desc" placeholder="代码仓库"></div>
</div>
<div class="row">
<div class="fg"><label>图标(文件名 / fas fa-*,留空则自动抓取)</label><input id="gw-n-icon" placeholder="gitea.png"></div>
<label class="gw-ck"><input type="checkbox" id="gw-n-auto" checked>自动抓取站点图标</label>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加转发</button>
</div>
</div>
<details style="margin-top:12px">
<summary>从现有 nginx 配置导入(迁移用)</summary>
<p class="hint" style="margin:8px 0">把旧统一入口的 nginx 配置整段贴进来,点「解析」会在下面列出识别出的域名与后端。
解析只读,不会写盘;确认后点「保存并应用」。导入默认<b>不</b>接管导航,需要接管的条目自己勾上。</p>
<textarea id="gw-import" placeholder="map $host $backend { ... } server { server_name ... }"></textarea>
<div class="row" style="margin-top:8px">
<button id="gw-import-run">解析</button>
<span class="hint" id="gw-import-msg"></span>
</div>
</details>
<details style="margin-top:10px">
<summary>入口参数(端口 / 证书 / 认证地址)</summary>
<div class="row" style="margin-top:10px">
<div class="fg" style="max-width:150px"><label>HTTP(S) 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTPS 端口</label><input id="gw-tlsport" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTP 跳转端口</label><input id="gw-redirect" inputmode="numeric"></div>
</div>
<div class="row">
<div class="fg"><label>证书路径</label><input id="gw-cert" placeholder="/etc/nginx/certs/fullchain.pem"></div>
<div class="fg"><label>私钥路径</label><input id="gw-key" placeholder="/etc/nginx/certs/privkey.pem"></div>
<label class="gw-ck"><input type="checkbox" id="gw-tls">由本容器终结 TLS</label>
</div>
<div class="row">
<div class="fg"><label>认证校验地址</label><input id="gw-auth" placeholder="http://127.0.0.1:3005/auth/verify"></div>
<div class="fg"><label>未登录跳转</label><input id="gw-login" placeholder="https://login.example.com/login"></div>
</div>
</details>
<div class="row" style="margin-top:14px">
<button class="primary" id="gw-apply">保存并应用</button>
<button id="gw-saveonly">仅保存</button>
<span class="hint" id="gw-msg"></span>
</div>
</div>
<div class="card">
<h2>统一登录账号</h2>
<p class="sub" style="margin:0 0 12px">
受保护域名登录时使用。账号存在数据卷里,改完立即生效,不用重新部署。
当前来源:<b id="pa-source">读取中…</b>
</p>
<div class="row">
<div class="fg" style="max-width:220px"><label>用户名</label><input id="pa-user" autocomplete="off"></div>
<div class="fg" style="max-width:260px"><label>新密码(至少 6 位)</label><input id="pa-pass" type="password" autocomplete="new-password" placeholder="留空则不修改"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="pa-save">保存账号</button>
</div>
</div>
</div>
<div class="grp">高级</div>
<!-- 令牌 -->
<div class="card">
<details>
<summary>接口令牌(一般不用填)</summary>
<p class="hint" style="margin:10px 0">
<code>/admin/</code> 已在门户登录保护之下,服务端默认不再额外校验令牌。<br>
仅当部署方设置了 <code>NAVI_ADMIN_REQUIRE_TOKEN=1</code> 时才需要填这里的值(对应 <code>NAVI_ADMIN_TOKEN</code>)。
</p>
<div class="row">
<div class="fg"><label>令牌</label><input id="tk" type="password" placeholder="留空即可"></div>
</div>
<button id="save-tk">保存令牌</button>
</details>
</div>
</div>
<script>
const A = '/admin/api';
let S = [];
let PAGE = {};
/* ── 令牌(可选) ── */
function token() { return localStorage.getItem('naviAdminToken') || ''; }
function authHeaders(extra) {
const h = Object.assign({}, extra || {});
if (token()) h['Authorization'] = 'Bearer ' + token();
return h;
}
document.getElementById('tk').value = token();
document.getElementById('save-tk').addEventListener('click', () => {
const v = document.getElementById('tk').value.trim();
if (v) localStorage.setItem('naviAdminToken', v);
else localStorage.removeItem('naviAdminToken');
toast('已保存令牌');
});
function toast(msg, color) {
const d = document.createElement('div');
d.className = 'toast';
d.textContent = msg;
d.style.background = color || '#22c55e';
document.body.appendChild(d);
setTimeout(() => d.remove(), 2600);
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* 图标预览:文件名当图片,fas/fa/far 等当字体图标,其余留空 */
function iconHTML(icon) {
const v = (icon || '').trim();
if (!v) return '<span class="ico-fa" title="留空时按站点自动取图标">·</span>';
if (/\.(png|svg|jpg|jpeg|ico|webp|gif)$/i.test(v)) {
return '<img src="/item-icons/' + encodeURIComponent(v) + '" alt="" loading="lazy">';
}
return '<span class="ico-fa">◆</span>';
}
/* ── 站点信息 ── */
function renderNavLinks() {
const box = document.getElementById('navlinks');
const list = PAGE.navLinks || [];
box.innerHTML = list.map((l, i) => `
<div class="link-row">
<input data-k="name" data-i="${i}" value="${esc(l.name || '')}" placeholder="名称">
<input data-k="url" data-i="${i}" value="${esc(l.url || '')}" placeholder="https://...">
<input data-k="icon" data-i="${i}" value="${esc(l.icon || '')}" placeholder="图标(可空)" style="max-width:150px">
<button class="icon-btn danger" data-rm="${i}">✕</button>
</div>`).join('') || '<p class="hint">暂无顶部导航链接</p>';
box.querySelectorAll('input[data-k]').forEach(el => {
el.addEventListener('input', () => {
const i = +el.dataset.i;
PAGE.navLinks = PAGE.navLinks || [];
PAGE.navLinks[i] = PAGE.navLinks[i] || {};
PAGE.navLinks[i][el.dataset.k] = el.value;
});
});
box.querySelectorAll('button[data-rm]').forEach(el => {
el.addEventListener('click', () => {
PAGE.navLinks.splice(+el.dataset.rm, 1);
renderNavLinks();
});
});
}
/* ── 备案号 ──
备案号直接编辑,页脚 HTML 由服务端模板生成;高级模式下可整段覆盖。 */
function parseIcp(html) {
const m = /[\u4e00-\u9fa5]{2}ICP备\d+号(-\d+)?/.exec(html || '');
return m ? m[0] : '';
}
function parseGongan(html) {
const m = /[\u4e00-\u9fa5]{2}公网安备\d+号/.exec(html || '');
return m ? m[0] : '';
}
async function loadPageInfo() {
try {
const r = await fetch(A + '/pageinfo', { headers: authHeaders() });
if (!r.ok) throw new Error('HTTP ' + r.status);
PAGE = await r.json() || {};
} catch (e) {
PAGE = {};
toast('读取站点信息失败: ' + e.message, '#ef4444');
}
document.getElementById('p-title').value = PAGE.title || '';
document.getElementById('p-desc').value = PAGE.description || '';
const icp = PAGE.icp || parseIcp(PAGE.footer);
const gongan = PAGE.gongan || parseGongan(PAGE.footer);
const setIf = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; };
setIf('p-auth-title', PAGE.authTitle); setIf('p-auth-sub', PAGE.authSubtitle); setIf('p-auth-logo', PAGE.authLogo);
setIf('p-portal-title', PAGE.portalTitle); setIf('p-hero-title', PAGE.heroTitle); setIf('p-hero-sub', PAGE.heroSubtitle);
document.getElementById('p-icp').value = icp;
document.getElementById('p-gongan').value = gongan;
// 能从备案号字段还原时,高级 HTML 留空,保存时按模板重新生成
document.getElementById('p-footer').value = (icp || gongan) ? '' : (PAGE.footer || '');
if (!PAGE.navLinks && PAGE.navLinks !== null) PAGE.navLinks = [];
renderNavLinks();
}
document.getElementById('add-link').addEventListener('click', () => {
PAGE.navLinks = PAGE.navLinks || [];
PAGE.navLinks.push({ name: '', url: '', icon: '' });
renderNavLinks();
});
document.getElementById('save-info').addEventListener('click', async () => {
const body = {
title: document.getElementById('p-title').value,
description: document.getElementById('p-desc').value,
icp: document.getElementById('p-icp').value,
gongan: document.getElementById('p-gongan').value,
// 留空 = 交给服务端按备案号生成模板;填写则整段覆盖
footer: document.getElementById('p-footer').value,
authTitle: document.getElementById('p-auth-title').value.trim(),
authSubtitle: document.getElementById('p-auth-sub').value.trim(),
authLogo: document.getElementById('p-auth-logo').value.trim(),
portalTitle: document.getElementById('p-portal-title').value.trim(),
heroTitle: document.getElementById('p-hero-title').value.trim(),
heroSubtitle: document.getElementById('p-hero-sub').value.trim(),
navLinks: (PAGE.navLinks || []).filter(l => l && (l.name || l.url)),
};
const r = await fetch(A + '/pageinfo', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
if (r.ok) { toast('站点信息已保存'); await loadPageInfo(); }
else toast('保存失败: ' + (await r.text()).slice(0, 120), '#ef4444');
});
/* ── 历史访问管理 ── */
let HIST = [];
/* 标题为空或本身就是 URL 时,显示简洁域名(与服务端 friendlyHistoryTitle 一致)*/
function friendlyTitle(it) {
const t = (it.title || '').trim();
if (t && !/^https?:\/\//i.test(t)) return t;
try { return new URL(it.url).hostname.replace(/^www\./, ''); } catch (e) { return t || it.url; }
}
function renderHistory() {
const el = document.getElementById('hist');
if (!HIST.length) { el.innerHTML = '<div class="muted">暂无历史记录</div>'; return; }
el.innerHTML = HIST.map((it, i) => `
<div class="itm">
<div class="itm-i">
<div class="itm-t">${esc(friendlyTitle(it))}</div>
<div class="itm-u">${esc(it.url)} · ${Number(it.total_visits) || 0} 次</div>
</div>
<button class="icon-btn danger" data-hist="${i}" title="删除这条记录">✕</button>
</div>`).join('');
}
async function loadHistory() {
try {
const r = await fetch(A + '/history', { headers: authHeaders() });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
HIST = (d && d.items) || [];
} catch (e) {
HIST = [];
document.getElementById('hist').innerHTML = '<div class="muted">读取历史失败:' + esc(e.message) + '</div>';
return;
}
renderHistory();
}
document.getElementById('hist').addEventListener('click', async (ev) => {
const btn = ev.target.closest('[data-hist]');
if (!btn) return;
const it = HIST[Number(btn.dataset.hist)];
if (!it) return;
if (!confirm('删除这条历史记录?\n' + friendlyTitle(it))) return;
const r = await fetch(A + '/history/item?url=' + encodeURIComponent(it.url), {
method: 'DELETE', headers: authHeaders(),
});
if (r.ok) { toast('已删除该记录'); await loadHistory(); }
else toast('删除失败: ' + (await r.text()).slice(0, 120), '#ef4444');
});
document.getElementById('clear-hist').addEventListener('click', async () => {
if (!confirm('清空全部浏览历史?此操作无法撤销;下次插件同步会重新累积。')) return;
const r = await fetch(A + '/history', { method: 'DELETE', headers: authHeaders() });
if (r.ok) { toast('已清空历史'); await loadHistory(); }
else toast('清空失败: ' + (await r.text()).slice(0, 120), '#ef4444');
});
/* ── 分组与条目 ── */
async function loadSections() {
const r = await fetch(A + '/sections', { headers: authHeaders() });
S = await r.json();
const sel = document.getElementById('sel');
sel.innerHTML = S.length
? S.map((s, i) => `<option value="${i}">${esc(s.name)}</option>`).join('')
: '<option value="-1">先创建分组</option>';
document.getElementById('do-add').disabled = S.length === 0;
renderSections();
document.getElementById('app').innerHTML = S.length ? S.map((s, si) => `
<div class="card">
<div class="sec-hd">
<span class="sec-nm">${esc(s.name)}</span>
<span class="badge">${s.items.length}</span>
</div>
${s.items.map((it, ii) => `
<div class="itm">
${iconHTML(it.icon)}
<div class="itm-i">
<div class="itm-t">${esc(it.title)}</div>
<div class="itm-u">${esc(it.url)}</div>
</div>
<button class="icon-btn danger" data-del="${si}:${ii}">✕</button>
</div>`).join('') || '<p class="hint" style="margin:6px 0 0">这个分组还没有服务。</p>'}
</div>`).join('') : '<div class="card"><p class="hint" style="margin:0">现在还没有分组。先在上面的「导航分组」里创建一个,再把服务加进去。</p></div>';
document.querySelectorAll('button[data-del]').forEach(el => {
el.addEventListener('click', () => {
const [si, ii] = el.dataset.del.split(':').map(Number);
delItem(si, ii);
});
});
document.querySelectorAll('button[data-sec-save]').forEach(el => {
el.addEventListener('click', () => saveSection(+el.dataset.secSave));
});
document.querySelectorAll('button[data-sec-del]').forEach(el => {
el.addEventListener('click', () => delSection(+el.dataset.secDel));
});
}
/* 分组管理列表:可改名、可删;「历史访问」是系统分组,界面上直接锁住。 */
function renderSections() {
const box = document.getElementById('sec-list');
if (!S.length) {
box.innerHTML = '<p class="hint" style="margin:0">还没有任何分组。</p>';
return;
}
box.innerHTML = S.map((s, i) => {
const locked = String(s.name || '').trim() === '历史访问';
return `
<div class="itm sec-row">
<div class="itm-i sec-fields">
<input class="sec-in-name" data-sec-name="${i}" value="${esc(s.name || '')}" placeholder="分组名"
${locked ? 'disabled' : ''}>
<input class="sec-in-icon" data-sec-icon="${i}" value="${esc(s.icon || '')}" placeholder="图标(fas fa-star)"
${locked ? 'disabled' : ''}>
<span class="badge">${s.items.length}</span>
${locked ? '<span class="hint">自动维护</span>' : ''}
</div>
${locked ? '' : `<div class="sec-acts">
<button class="icon-btn" data-sec-save="${i}" title="保存改名">保存</button>
<button class="icon-btn danger" data-sec-del="${i}" title="删除分组">✕</button>
</div>`}
</div>`;
}).join('');
}
async function addSection() {
const name = document.getElementById('sec-name').value.trim();
if (!name) { toast('请填写分组名称', '#ef4444'); return; }
const r = await fetch(A + '/section', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: name, icon: document.getElementById('sec-icon').value.trim() }),
});
if (r.ok) {
toast('已添加分组');
document.getElementById('sec-name').value = '';
document.getElementById('sec-icon').value = '';
await loadSections();
} else {
toast('添加失败: ' + (await r.text()).slice(0, 140), '#ef4444');
}
}
async function saveSection(i) {
const nameEl = document.querySelector(`input[data-sec-name="${i}"]`);
const iconEl = document.querySelector(`input[data-sec-icon="${i}"]`);
const r = await fetch(A + '/section/update', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ index: i, name: nameEl.value.trim(), icon: iconEl.value.trim() }),
});
if (r.ok) { toast('已保存'); await loadSections(); }
else toast('保存失败: ' + (await r.text()).slice(0, 140), '#ef4444');
}
async function delSection(i) {
const s = S[i];
if (!s) return;
if (!confirm('删除分组「' + s.name + '」?')) return;
const n = (s.items || []).length;
if (n > 0 && !confirm('该分组下还有 ' + n + ' 个服务,会一并删除。\n确定继续?')) return;
const r = await fetch(A + '/section' + (n > 0 ? '?force=1' : ''), {
method: 'DELETE',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ index: i }),
});
if (r.ok) { toast('已删除分组'); await loadSections(); }
else toast('删除失败: ' + (await r.text()).slice(0, 140), '#ef4444');
}
document.getElementById('sec-add').addEventListener('click', addSection);
async function delItem(si, ii) {
if (!confirm('确定删除「' + S[si].items[ii].title + '」?')) return;
const r = await fetch(A + '/item/delete', {
method: 'DELETE',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sectionIndex: si, itemIndex: ii }),
});
if (r.ok) { toast('已删除'); loadSections(); }
else toast('删除失败: ' + (await r.text()).slice(0, 120), '#ef4444');
}
document.getElementById('do-add').addEventListener('click', async () => {
const t = document.getElementById('ti').value.trim();
const u = document.getElementById('ur').value.trim();
if (!t || !u) { toast('请填写名称和 URL', '#ef4444'); return; }
const r = await fetch(A + '/item', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
sectionIndex: +document.getElementById('sel').value,
item: {
title: t,
description: document.getElementById('de').value.trim(),
icon: document.getElementById('ic').value.trim(),
url: u,
},
}),
});
if (r.ok) {
toast('已添加');
['ti', 'de', 'ur', 'ic'].forEach(id => document.getElementById(id).value = '');
document.getElementById('icon-preview').innerHTML = '';
loadSections();
} else toast('添加失败: ' + (await r.text()).slice(0, 120), '#ef4444');
});
/* ── 图标上传 ── */
document.getElementById('pick-icon').addEventListener('click', () => {
document.getElementById('file-icon').click();
});
/* .ico 浏览器能显示但 Dashy 渲染不了,这里先用 canvas 转成 PNG 再上传 */
async function toUploadable(file) {
const isIco = /\.ico$/i.test(file.name) ||
['image/x-icon', 'image/vnd.microsoft.icon', 'image/ico'].includes(file.type);
if (!isIco) return file;
const url = URL.createObjectURL(file);
try {
const img = await new Promise((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = () => rej(new Error('浏览器无法解析该 .ico'));
i.src = url;
});
const c = document.createElement('canvas');
c.width = img.naturalWidth || 256;
c.height = img.naturalHeight || 256;
c.getContext('2d').drawImage(img, 0, 0);
const blob = await new Promise(r => c.toBlob(r, 'image/png'));
if (!blob) throw new Error('canvas 导出失败');
return new File([blob], file.name.replace(/\.[^.]+$/, '') + '.png', { type: 'image/png' });
} finally {
URL.revokeObjectURL(url);
}
}
document.getElementById('file-icon').addEventListener('change', async (ev) => {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!f) return;
let send;
try { send = await toUploadable(f); }
catch (e) { toast('转换失败: ' + e.message, '#ef4444'); return; }
if (send.size > 512 * 1024) { toast('图片过大(上限 512KB)', '#ef4444'); return; }
const fd = new FormData();
fd.append('file', send);
fd.append('name', document.getElementById('ti').value.trim() || send.name);
let r;
try {
r = await fetch(A + '/icon', { method: 'POST', headers: authHeaders(), body: fd });
} catch (e) { toast('上传失败: ' + e.message, '#ef4444'); return; }
if (!r.ok) { toast('上传失败: ' + (await r.text()).slice(0, 120), '#ef4444'); return; }
const data = await r.json();
document.getElementById('ic').value = data.icon;
document.getElementById('icon-preview').innerHTML =
'<img src="/item-icons/' + encodeURIComponent(data.icon) + '?t=' + Date.now() + '" alt="">' +
'<span class="hint">已上传:' + esc(data.icon) + '</span>';
toast('图标已上传');
});
/* 图标文件名变化时同步预览 */
document.getElementById('ic').addEventListener('input', (e) => {
const v = e.target.value.trim();
const box = document.getElementById('icon-preview');
if (/\.(png|svg|jpg|jpeg|webp|gif|ico)$/i.test(v)) {
box.innerHTML = '<img src="/item-icons/' + encodeURIComponent(v) + '?t=' + Date.now() + '" alt="">';
} else if (!box.querySelector('span')) {
box.innerHTML = '';
}
});
/* ── 外观:壁纸 ── */
async function loadWallpaper() {
try {
const r = await fetch(A + '/wallpaper', { headers: authHeaders() });
const d = await r.json();
document.getElementById('wp-url').value = d.url || '';
const dim = (typeof d.dim === 'number') ? d.dim : 0.45;
document.getElementById('wp-dim').value = dim;
document.getElementById('dim-val').textContent = Number(dim).toFixed(2);
renderWallPreview(d.url);
} catch (e) { /* 忽略:不影响其它功能 */ }
}
function renderWallPreview(u) {
const box = document.getElementById('wall-preview');
if (!u) { box.innerHTML = '<p class="hint">当前使用默认渐变背景</p>'; return; }
box.innerHTML = '<img src="' + esc(u) + '" alt="" style="max-width:100%;max-height:220px;border-radius:12px;border:1px solid var(--bd)">';
}
async function saveWallpaper(url) {
const r = await fetch(A + '/wallpaper', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ url: url, dim: parseFloat(document.getElementById('wp-dim').value) }),
});
if (r.ok) {
const d = await r.json();
document.getElementById('wp-url').value = d.url || '';
renderWallPreview(d.url);
toast(d.url ? '壁纸已应用' : '已移除壁纸');
} else {
toast('保存失败: ' + (await r.text()).slice(0, 120), '#ef4444');
}
}
document.getElementById('wp-dim').addEventListener('input', (e) => {
document.getElementById('dim-val').textContent = Number(e.target.value).toFixed(2);
});
document.getElementById('apply-wall').addEventListener('click', () => {
saveWallpaper(document.getElementById('wp-url').value.trim());
});
document.getElementById('clear-wall').addEventListener('click', () => {
document.getElementById('wp-url').value = '';
saveWallpaper('');
});
document.getElementById('pick-wall').addEventListener('click', () => {
document.getElementById('file-wall').click();
});
document.getElementById('file-wall').addEventListener('change', async (ev) => {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!f) return;
if (f.size > 8 * 1024 * 1024) { toast('壁纸过大(上限 8MB)', '#ef4444'); return; }
const fd = new FormData();
fd.append('file', f);
let r;
try {
r = await fetch(A + '/wallpaper', { method: 'POST', headers: authHeaders(), body: fd });
} catch (e) { toast('上传失败: ' + e.message, '#ef4444'); return; }
if (!r.ok) { toast('上传失败: ' + (await r.text()).slice(0, 120), '#ef4444'); return; }
const d = await r.json();
document.getElementById('wp-url').value = d.url || '';
renderWallPreview(d.url);
toast('壁纸已上传并应用');
});
/* ── 统一入口:反代 + 认证 ──
路由表存 gateway.yml;"是否走统一认证"就是每条记录上的一个开关。
"保存并应用"会先让服务端做 nginx -t 校验,不通过会把原始报错显示出来,
并保留上一份可用配置(不会把入口改坏)。 */
let GW = { routes: [] };
const gwVal = (id) => { const e = document.getElementById(id); return e ? e.value.trim() : ''; };
const gwChk = (id) => { const e = document.getElementById(id); return !!(e && e.checked); };
const gwSet = (id, v) => { const e = document.getElementById(id); if (e) e.value = (v === undefined || v === null) ? '' : v; };
async function loadGateway() {
try {
const r = await fetch(A + '/gateway/routes', { headers: authHeaders() });
if (r.ok) GW = Object.assign(GW, await r.json());
} catch (e) { /* 读不到就先给空表 */ }
if (!Array.isArray(GW.routes)) GW.routes = [];
renderGateway();
}
/* 把界面上的编辑回读到 GW(保存/应用前必调) */
function collectGateway() {
const routes = GW.routes;
document.querySelectorAll('input[data-gw-host]').forEach((el) => {
const i = +el.dataset.gwHost;
if (!routes[i]) return;
routes[i].host = el.value.trim();
routes[i].upstream = (document.querySelector(`input[data-gw-up="${i}"]`) || {}).value?.trim() || '';
routes[i].requireLogin = !!(document.querySelector(`input[data-gw-auth="${i}"]`) || {}).checked;
routes[i].websocket = !!(document.querySelector(`input[data-gw-ws="${i}"]`) || {}).checked;
const mb = parseInt((document.querySelector(`input[data-gw-mb="${i}"]`) || {}).value, 10);
routes[i].maxBodyMB = Number.isFinite(mb) && mb > 0 ? mb : 0;
routes[i].kind = (document.querySelector(`select[data-gw-kind="${i}"]`) || {}).value || 'proxy';
routes[i].navEnabled = !!(document.querySelector(`input[data-gw-nav="${i}"]`) || {}).checked;
routes[i].navSection = (document.querySelector(`select[data-gw-sec="${i}"]`) || {}).value || '';
routes[i].navName = ((document.querySelector(`input[data-gw-navname="${i}"]`) || {}).value || '').trim();
routes[i].navDescription = ((document.querySelector(`input[data-gw-navdesc="${i}"]`) || {}).value || '').trim();
routes[i].navIcon = ((document.querySelector(`input[data-gw-navicon="${i}"]`) || {}).value || '').trim();
routes[i].navAutoIcon = !!(document.querySelector(`input[data-gw-auto="${i}"]`) || {}).checked;
});
GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0;
GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0;
GW.redirectPort = parseInt(gwVal('gw-redirect'), 10) || 0;
GW.listenHttps = gwChk('gw-tls');
GW.certFile = gwVal('gw-cert');
GW.keyFile = gwVal('gw-key');
GW.authUrl = gwVal('gw-auth');
GW.loginUrl = gwVal('gw-login');
}
/* 导航分组下拉:分组来自「导航分组」卡片,必须已存在(服务端会校验) */
function sectionOptions(current) {
const opts = ['<option value="">选择分组…</option>'];
(S || []).forEach((s) => {
const n = String(s.name || '');
if (n === '历史访问') return; // 系统分组不接受手工导航
opts.push(`<option value="${esc(n)}"${n === current ? ' selected' : ''}>${esc(n)}</option>`);
});
return opts.join('');
}
function renderGateway() {
const box = document.getElementById('gw-routes');
box.innerHTML = GW.routes.length ? GW.routes.map((r, i) => `
<div class="itm sec-row">
<div class="itm-i sec-fields">
<input class="gw-host" data-gw-host="${i}" value="${esc(r.host || '')}" placeholder="域名">
<input class="gw-up" data-gw-up="${i}" value="${esc(r.upstream || '')}" placeholder="后端 http://ip:port">
<label class="gw-ck"><input type="checkbox" data-gw-auth="${i}" ${r.requireLogin ? 'checked' : ''}>统一认证</label>
<label class="gw-ck"><input type="checkbox" data-gw-ws="${i}" ${r.websocket ? 'checked' : ''}>WebSocket</label>
<input class="gw-mb" data-gw-mb="${i}" value="${r.maxBodyMB || ''}" placeholder="上传MB上限">
<select data-gw-kind="${i}" class="gw-kind">
<option value="proxy"${(r.kind || 'proxy') === 'proxy' ? ' selected' : ''}>反向代理</option>
<option value="portal"${r.kind === 'portal' ? ' selected' : ''}>门户静态页</option>
<option value="auth"${r.kind === 'auth' ? ' selected' : ''}>登录服务</option>
</select>
<label class="gw-ck"><input type="checkbox" data-gw-nav="${i}" ${r.navEnabled ? 'checked' : ''}>加入导航</label>
<select data-gw-sec="${i}" class="gw-sec">${sectionOptions(r.navSection)}</select>
<input class="gw-navname" data-gw-navname="${i}" value="${esc(r.navName || '')}" placeholder="导航名称">
<input class="gw-navdesc" data-gw-navdesc="${i}" value="${esc(r.navDescription || '')}" placeholder="导航描述">
<input class="gw-navicon" data-gw-navicon="${i}" value="${esc(r.navIcon || '')}" placeholder="图标:文件名 / fas fa-*">
<label class="gw-ck"><input type="checkbox" data-gw-auto="${i}" ${r.navAutoIcon ? 'checked' : ''}>自动抓图标</label>
</div>
<div class="sec-acts">
<button class="icon-btn danger" data-gw-del="${i}" title="删除这条映射">✕</button>
</div>
</div>`).join('')
: '<p class="hint" style="margin:0">还没有映射。添加一条「域名 → 内网服务」,需要登录才能访问的勾上「统一认证」。</p>';
gwSet('gw-port', GW.listenPort);
gwSet('gw-tlsport', GW.listenTlsPort);
gwSet('gw-redirect', GW.redirectPort);
gwSet('gw-cert', GW.certFile);
gwSet('gw-key', GW.keyFile);
gwSet('gw-auth', GW.authUrl);
gwSet('gw-login', GW.loginUrl);
const tls = document.getElementById('gw-tls');
if (tls) tls.checked = !!GW.listenHttps;
document.querySelectorAll('button[data-gw-del]').forEach((el) => {
el.addEventListener('click', () => {
collectGateway();
const i = +el.dataset.gwDel;
if (!confirm('删除映射「' + (GW.routes[i] || {}).host + '」?')) return;
GW.routes.splice(i, 1);
renderGateway();
});
});
}
async function gatewaySave() {
collectGateway();
const r = await fetch(A + '/gateway/routes', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(GW),
});
if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 160), '#ef4444'); return false; }
return true;
}
document.getElementById('gw-add').addEventListener('click', () => {
const host = gwVal('gw-n-host');
const up = gwVal('gw-n-up');
const kind = gwVal('gw-n-kind') || 'proxy';
if (!host) { toast('请填写域名', '#ef4444'); return; }
if (kind === 'proxy' && !up) { toast('反向代理需要填写后端地址', '#ef4444'); return; }
collectGateway();
const navEnabled = gwChk('gw-n-nav');
const mb = parseInt(gwVal('gw-n-mb'), 10);
GW.routes.push({
host: host,
upstream: kind === 'portal' ? '' : up,
kind: kind,
requireLogin: gwChk('gw-n-auth'),
websocket: gwChk('gw-n-ws'),
maxBodyMB: Number.isFinite(mb) && mb > 0 ? mb : 0,
navEnabled: navEnabled,
navSection: navEnabled ? gwVal('gw-n-sec') : '',
navName: navEnabled ? (gwVal('gw-n-name') || host.split('.')[0]) : '',
navDescription: navEnabled ? gwVal('gw-n-desc') : '',
navIcon: navEnabled ? gwVal('gw-n-icon') : '',
navAutoIcon: navEnabled && gwChk('gw-n-auto'),
});
['gw-n-host', 'gw-n-up', 'gw-n-name', 'gw-n-desc', 'gw-n-icon', 'gw-n-mb'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
renderGateway();
toast('已加入列表,记得点「保存并应用」');
});
document.getElementById('gw-import-run').addEventListener('click', async () => {
const box = document.getElementById('gw-import');
const msg = document.getElementById('gw-import-msg');
const conf = (box && box.value || '').trim();
if (!conf) { toast('请先贴上 nginx 配置', '#ef4444'); return; }
if (msg) msg.textContent = '解析中…';
const r = await fetch(A + '/gateway/import', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ conf: conf }),
});
const text = await r.text();
if (!r.ok) {
if (msg) msg.innerHTML = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(text.slice(0, 300)) + '</span>';
return;
}
const data = JSON.parse(text);
collectGateway();
// 导入结果覆盖当前列表:这是迁移,不是追加(避免同一域名两条记录)
GW.routes = data.routes || [];
renderGateway();
if (msg) msg.textContent = data.hint || ('已解析 ' + GW.routes.length + ' 条');
toast('已解析 ' + GW.routes.length + ' 个域名,确认后点「保存并应用」');
});
/* ── 统一登录账号 ── */
async function loadPortalAuth() {
try {
const r = await fetch(A + '/portal-auth', { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
const u = document.getElementById('pa-user');
const src = document.getElementById('pa-source');
if (u && !u.value) u.value = d.username || '';
if (src) src.textContent = d.source || '';
} catch (e) { /* 忽略 */ }
}
document.getElementById('pa-save').addEventListener('click', async () => {
const user = (document.getElementById('pa-user').value || '').trim();
const pass = document.getElementById('pa-pass').value || '';
if (!user) { toast('请填写用户名', '#ef4444'); return; }
if (pass.length < 6) { toast('密码至少 6 位', '#ef4444'); return; }
const r = await fetch(A + '/portal-auth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ username: user, password: pass }),
});
if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 140), '#ef4444'); return; }
document.getElementById('pa-pass').value = '';
toast('账号已更新(下次登录生效)');
loadPortalAuth();
});
document.getElementById('gw-saveonly').addEventListener('click', async () => {
if (await gatewaySave()) toast('已保存(未应用)');
});
document.getElementById('gw-apply').addEventListener('click', async () => {
const msg = document.getElementById('gw-msg');
if (msg) msg.textContent = '校验中…';
if (!(await gatewaySave())) { if (msg) msg.textContent = ''; return; }
const r = await fetch(A + '/gateway/apply', { method: 'POST', headers: authHeaders() });
const body = await r.text();
if (r.ok) {
toast('已应用并热加载');
if (msg) msg.textContent = '已应用 ✓';
} else {
toast('应用失败', '#ef4444');
if (msg) msg.innerHTML = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(body.slice(0, 600)) + '</span>';
}
loadGateway();
});
(async function init() {
await loadPageInfo();
await loadSections();
await loadWallpaper();
await loadHistory();
await loadGateway();
await loadPortalAuth();
})();
</script>
</body>
</html>