后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
1. 写操作 401 —— admin UI 从未带 Authorization 头,而 admin-server 强制校验
NAVI_ADMIN_TOKEN。于是添加/删除一直静默失败。
2. JSON 键名不匹配 —— Section/NavItem 只有 yaml tag,没有 json tag,
接口返回 {"Name":..,"Items":[{"Title":..}]},而前端读 s.name / it.title,
渲染直接抛异常(菜单区永远停在「加载中…」)。
修法:
- admin-server:token 改为**默认不校验**(/admin/ 已在外层门户登录墙内,
用户确认无需叠加),仅当显式设 NAVI_ADMIN_REQUIRE_TOKEN=1 时才校验;
历史 DELETE 不受影响(那条路径被门户放行,仍必须自带 token)。
- 补 json tag;Config/Section/NavItem 加 yaml ",inline" 保留未知键,
避免保存 conf.yml 时把不认识的配置(layout/pages/item 的 target 等)丢掉。
- 新增 /api/pageinfo(标题/副标题/页脚/顶部导航可在后台编辑)与
/api/icon(图标上传,限 png/svg/jpg,512KB)。
- 重写 admin UI:站点信息 + 顶部导航编辑、图标上传(.ico 用 canvas 转 PNG,
因为 Dashy 的 isImage() 只认 svg/png/jpg)、可选令牌。
主题:旧 customCss 大部分是死代码 —— .tile / .item-group / .item-group-outer
在页面里根本不存在(querySelectorAll 计数为 0),卡片灰底其实来自 a.item,
而 .home 的不透明背景盖住了 body 渐变,所以玻璃效果一直看不出来。
现按真实类名(a.item / .item-wrapper / .collapsable / .collapsible-content /
.home / header)重写,并加入工作空间侧边栏的溢出修复。
安装页:.btn 自带 display 会盖掉浏览器默认的 [hidden]{display:none},
未配置商店时「一键安装」按钮仍然可见且 href 为空(点了没反应)。
现显式兜住 [hidden],并把浏览器识别补全(iOS/Android 单独提示为不可安装、
Edge/Brave/Vivaldi/Opera 给出各自的 extensions 地址、优先用 userAgentData)。
393 lines
16 KiB
HTML
393 lines
16 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>
|
||
<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)}
|
||
</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="fg" style="margin-bottom:12px">
|
||
<label>页脚(可含 HTML)</label>
|
||
<textarea id="p-footer" placeholder="留空则不显示"></textarea>
|
||
</div>
|
||
<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>
|
||
<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="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();
|
||
});
|
||
});
|
||
}
|
||
|
||
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 || '';
|
||
document.getElementById('p-footer').value = 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,
|
||
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');
|
||
});
|
||
|
||
/* ── 分组与条目 ── */
|
||
async function loadSections() {
|
||
const r = await fetch(A + '/sections', { headers: authHeaders() });
|
||
S = await r.json();
|
||
document.getElementById('sel').innerHTML =
|
||
S.map((s, i) => `<option value="${i}">${esc(s.name)}</option>`).join('');
|
||
document.getElementById('app').innerHTML = 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('')}
|
||
</div>`).join('');
|
||
|
||
document.querySelectorAll('button[data-del]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const [si, ii] = el.dataset.del.split(':').map(Number);
|
||
delItem(si, ii);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 init() {
|
||
await loadPageInfo();
|
||
await loadSections();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|