Files
HomeDash/admin/index.html
HomeDash Bot 0d9619e446 feat(admin): 导航分组可增 / 可改名 / 可删;初始状态无分组
后台此前只能往已有分组里加服务,分组本身是写死在 conf.yml 里的,
用户无法增删。现在新增「导航分组」卡片:

- POST   /admin/api/section         新增(重名 409、空名 400)
- POST   /admin/api/section/update  改名/换图标
- DELETE /admin/api/section         删除;非空分组必须 force=1(二次确认),
                                    避免误点一下连带删掉一组服务

「历史访问」分组由 history-server 依据浏览记录重建,改名/删除/手工新建
都会被它覆写回来,所以一律 409 并说明原因 —— 否则用户以为删掉了,
下次同步又"长"回来。

初始状态无分组:conf.example.yml 的 sections 改为空数组,后台在两个位置
给空态提示(「还没有任何分组」/「先创建分组」,并把"添加服务"置灰)。

补上原本缺失的一环:rebuildDashySections() 原先只更新已存在的
「历史访问」分组、从不创建它,新装用户永远看不到历史分组;现在缺了就补建
(完全空配置时不补,保持"初始无分组")。

测试覆盖:新增/重名/改历史分组/删历史分组/删非空(无 force 拒绝)/force 删除,
以及删除后的分组名列表断言。
2026-09-11 19:52:19 +08:00

681 lines
28 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="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="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">
<div class="itm-i" style="display:flex;gap:8px;flex:1;min-width:0;align-items:center">
<input data-sec-name="${i}" value="${esc(s.name || '')}" placeholder="分组名"
style="max-width:200px" ${locked ? 'disabled' : ''}>
<input data-sec-icon="${i}" value="${esc(s.icon || '')}" placeholder="图标(fas fa-star)"
style="max-width:220px" ${locked ? 'disabled' : ''}>
<span class="badge">${s.items.length}</span>
${locked ? '<span class="hint">自动维护</span>' : ''}
</div>
${locked ? '' : `<button class="icon-btn" data-sec-save="${i}" title="保存改名">保存</button>
<button class="icon-btn danger" data-sec-del="${i}" title="删除分组">✕</button>`}
</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('壁纸已上传并应用');
});
(async function init() {
await loadPageInfo();
await loadSections();
await loadWallpaper();
await loadHistory();
})();
</script>
</body>
</html>