Files
HomeDash/admin/index.html
HomeDash Bot 994d93cc98 feat(gateway): 一体化统一入口——路由与 Dashy 导航同一份数据、内置登录、原自定义错误页
目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。

- Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、
  insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/
  navDescription/navUrl/navIcon/navAutoIcon
- 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go):
  同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组
  一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话)
- 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录),
  不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计
- 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验,
  portal-auth 容器可以退场
- 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon)
- 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段
- 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080)
- 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败)

测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、
分组不存在必须报错等用例;admin-server 与 history-server 全绿。
2026-09-11 21:27:40 +08:00

920 lines
40 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>
<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}
.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}
@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="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">高级:自定义页脚 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="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="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="card">
<h2>统一入口(反向代理与认证)</h2>
<p class="sub" style="margin:0 0 12px">
在这里维护「域名 → 内网服务」,并逐条决定是否走<b>统一登录认证</b>。
生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
</p>
<div id="gw-routes"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<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://10.0.0.10:3000"></div>
<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:10px">
<summary>入口参数(端口 / 证书 / 认证地址)</summary>
<div class="row" style="margin-top:10px">
<div class="fg" style="max-width:150px"><label>HTTP 端口</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>终结 TLS</label>
<label class="gw-ck" style="padding:9px 0"><input type="checkbox" id="gw-tls"> 本容器持有证书</label>
</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>
</div>
<div class="row">
<div class="fg"><label>认证校验地址(auth_request)</label><input id="gw-auth" placeholder="http://127.0.0.1:4001/_auth"></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="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="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);
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,
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.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-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');
if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; }
collectGateway();
const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || '';
GW.routes.push({
host: host, upstream: up, requireLogin: true, websocket: false, maxBodyMB: 0, kind: 'proxy',
navEnabled: !!firstSection, navSection: firstSection, navName: host.split('.')[0],
navDescription: '', navIcon: '', navAutoIcon: true,
});
document.getElementById('gw-n-host').value = '';
document.getElementById('gw-n-up').value = '';
renderGateway();
toast('已加入列表,记得点「保存并应用」');
});
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();
})();
</script>
</body>
</html>