Files
HomeDash/admin/index.html
HomeDash Bot c04d00d4de feat(admin): 历史记录可在后台逐条删除/清空
此前后台**没有任何历史管理入口**,只有一条需要 admin token 的
DELETE /api/history/(清空全部),所以用户根本无法删除记录。

- history-server: 新增 DELETE /item?url=<url> 删除单条并重建分组;
  CORS 允许 Authorization 头
- admin-server: 新增 /admin/api/history(GET 列表 / DELETE 清空)与
  /admin/api/history/item(DELETE 单条),服务端带 token 转发给
  history-server —— 令牌只在容器内部使用,浏览器侧无需保存
- admin UI: 「历史访问」卡片,列出记录 + 逐条 ✕ + 清空全部(均二次确认)
  标题为空或本身是 URL 时显示简洁域名
2026-09-11 17:03:33 +08:00

580 lines
24 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>
<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();
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 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>