1155 lines
53 KiB
HTML
1155 lines
53 KiB
HTML
<!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>
|
||
<div class="fg"><label>页脚座右铭(每行一句,留空用内置默认)</label><textarea id="p-motto" rows="2" placeholder="不念往昔之繁华 无谓前路之沧桑"></textarea></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">门户页服务卡片(可配置,留空=内置卡片)</summary>
|
||
<p class="hint" style="margin:8px 0 4px">这里的内容会替换门户页卡片区,改链接无需重新构建镜像;清空并保存即恢复内置卡片。</p>
|
||
<textarea id="p-cards" rows="6" spellcheck="false"
|
||
style="width:100%;box-sizing:border-box;font:12px/1.5 ui-monospace,Menlo,monospace;padding:8px;border:1px solid var(--line,#d7dee9);border-radius:8px;background:var(--input-bg,#fff);color:inherit"
|
||
placeholder='<a class="card" href="https://example.com">示例服务</a>'></textarea>
|
||
<div class="row" style="margin-top:8px;align-items:center">
|
||
<button class="btn" id="p-cards-save" type="button">保存卡片</button>
|
||
<span id="p-cards-msg" class="hint" style="margin-left:8px"></span>
|
||
</div>
|
||
</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> </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 的「历史访问」分组会立即重建。<span id="hist-count" class="muted"></span></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> </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> </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> </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> </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 =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 || '');
|
||
document.getElementById('p-motto').value = PAGE.motto || '';
|
||
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,
|
||
motto: document.getElementById('p-motto').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');
|
||
const cnt = document.getElementById('hist-count');
|
||
if (cnt) {
|
||
// 读取侧已不截断(与门户「历史访问」不限条数保持一致),这里如实显示条数
|
||
cnt.textContent = HIST.length ? '当前共 ' + HIST.length + ' 条。' : '';
|
||
}
|
||
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();
|
||
})();
|
||
|
||
/* 门户页服务卡片:与 /api/portal-cards 对应(agent 可直接调同一接口) */
|
||
async function naviLoadCards(){
|
||
try { const r = await fetch(A + '/portal-cards', { headers: authHeaders() });
|
||
const j = await r.json(); const el = document.getElementById('p-cards');
|
||
if (el) el.value = j.html || ''; } catch (e) {}
|
||
}
|
||
async function naviSaveCards(){
|
||
const el = document.getElementById('p-cards'), msg = document.getElementById('p-cards-msg');
|
||
try {
|
||
const r = await fetch(A + '/portal-cards', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ html: el.value }) });
|
||
const j = await r.json().catch(() => ({}));
|
||
msg.textContent = r.ok ? (j.status === 'reset' ? '已恢复内置卡片' : '已保存,刷新门户页即生效') : ('失败:' + (j.error || r.status));
|
||
} catch (e) { msg.textContent = '失败:' + e; }
|
||
}
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
naviLoadCards();
|
||
const b = document.getElementById('p-cards-save'); if (b) b.addEventListener('click', naviSaveCards);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|