Files
HomeDash/admin/index.html
JianFeeeee f8c4771828 fix(admin,theme): make admin actually work; glass theme targets real DOM
后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
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)。
2026-09-11 11:43:44 +08:00

393 lines
16 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)}
</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>&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">
<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();
});
});
}
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>