Files
HomeDash/web/navi.js
HomeDash Bot 6c34970f38 feat(nav): 导航项点击频次独立统计,常用服务据此取 top-5
用户指出:导航项的访问频率应当单独统计。此前「常用服务」的排序依据是浏览器
历史的 host 访问次数(把站外浏览混进来反推),并不等于"我常点哪个服务"。

- history-server 新增 POST /api/history/nav-visit(内部 /nav-visit):
  按 host 计数并落盘 data 卷的 nav-visits.json(临时文件 + rename 原子写),
  随后立即 rebuild,使「常用服务」按新计数重排
- rebuildDashySections:候选访问量改为只认 nav-visits 计数,不再使用浏览器历史;
  无任何点击数据时保持既有分组内容不动(不空转、不误清)
- web/navi.js:点击 http(s) 导航项时用 sendBeacon 异步上报(不阻塞跳转,失败静默)
- 测试:计数递增、落盘、文件生成
2026-09-12 12:41:24 +08:00

714 lines
28 KiB
JavaScript
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.

(() => {
'use strict';
// 浏览器插件在 document_start 阶段写入这个属性(见 extension/*/detect.js)。
const EXT_ATTR = 'data-navi-ext';
const INSTALL_SELECTOR = 'a[href*="/extension/install"]';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(paths, opts = {}) {
const el = document.createElementNS(SVG_NS, 'svg');
el.setAttribute('viewBox', opts.viewBox || '0 0 24 24');
el.setAttribute('fill', 'none');
el.setAttribute('stroke', 'currentColor');
el.setAttribute('stroke-width', opts.width || '2.2');
el.setAttribute('stroke-linecap', 'round');
el.setAttribute('stroke-linejoin', 'round');
el.setAttribute('aria-hidden', 'true');
if (opts.size) {
el.setAttribute('width', opts.size);
el.setAttribute('height', opts.size);
}
paths.forEach((p) => {
const node = document.createElementNS(SVG_NS, p.tag);
Object.keys(p.attrs).forEach((k) => node.setAttribute(k, p.attrs[k]));
el.append(node);
});
return el;
}
// 收起态是放大镜,展开时通过 transform/opacity 平滑形变为向左箭头,
// 让图标动效与搜索框的宽度动画保持一致。
const magnifierIcon = () => svg([
{ tag: 'circle', attrs: { class: 'navi-magnifier-ring', cx: '11', cy: '11', r: '7.5' } },
{ tag: 'line', attrs: { class: 'navi-magnifier-handle', x1: '20.5', y1: '20.5', x2: '16.2', y2: '16.2' } },
{ tag: 'polyline', attrs: { class: 'navi-collapse-arrow', points: '14.5 6.5 9 12 14.5 17.5' } },
], { size: '20' });
const arrowIcon = () => svg([
{ tag: 'line', attrs: { x1: '5', y1: '12', x2: '19', y2: '12' } },
{ tag: 'polyline', attrs: { points: '13 6 19 12 13 18' } },
]);
const gearIcon = () => svg([
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '3.2' } },
{ tag: 'path', attrs: { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10.6 3.04V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.03z' } },
], { size: '20', width: '1.9' });
function extensionInstalled() {
return document.documentElement.hasAttribute(EXT_ATTR);
}
// 已装插件的浏览器不需要安装入口:连外层网格单元一起隐藏,
// 否则 .item-wrapper 会留下一个空位。
function hideInstallEntry() {
if (!extensionInstalled()) return;
document.querySelectorAll(INSTALL_SELECTOR).forEach((link) => {
const cell = link.closest('.item-wrapper') || link.parentElement || link;
if (cell.style.display !== 'none') cell.style.display = 'none';
});
}
// 底部搜索栏:默认收起成一个放大镜悬浮球(左下角),
// 点放大镜展开成输入框,再点输入框里的放大镜收起。
function mountSearch() {
if (document.querySelector('.navi-search-bar')) return;
const form = document.createElement('form');
form.className = 'navi-search-bar';
form.dataset.state = 'collapsed';
form.method = 'GET';
form.action = '/search';
form.target = '_blank';
form.setAttribute('role', 'search');
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'navi-search-toggle';
toggle.setAttribute('aria-label', '展开搜索');
toggle.setAttribute('aria-expanded', 'false');
toggle.append(magnifierIcon());
const input = document.createElement('input');
input.type = 'search';
input.name = 'q';
input.placeholder = '搜索…';
input.autocomplete = 'off';
input.tabIndex = -1;
input.setAttribute('aria-label', '搜索内容');
const submit = document.createElement('button');
submit.type = 'submit';
submit.className = 'navi-search-submit';
submit.setAttribute('aria-label', '开始搜索');
submit.append(arrowIcon());
const setState = (state) => {
const open = state === 'open';
form.dataset.state = open ? 'open' : 'collapsed';
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', open ? '收起搜索' : '展开搜索');
input.tabIndex = open ? 0 : -1;
if (open) {
input.focus({ preventScroll: true });
} else {
input.value = '';
input.blur();
}
};
toggle.addEventListener('click', (evt) => {
evt.preventDefault();
setState(form.dataset.state === 'open' ? 'collapsed' : 'open');
});
// Esc 收起(不阻止其它默认行为)
form.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && form.dataset.state === 'open') {
evt.stopPropagation();
setState('collapsed');
}
});
form.append(toggle, input, submit);
document.body.append(form);
}
// Dashy 页面没有管理入口:右下角补一个悬浮链接到 /admin/。
function mountAdminEntry() {
if (document.querySelector('.navi-admin-entry')) return;
const link = document.createElement('a');
link.className = 'navi-admin-entry';
link.href = '/admin/';
link.title = '导航管理';
link.setAttribute('aria-label', '导航管理');
link.append(gearIcon());
mountDock().append(link);
}
// 视图切换:Dashy 原生的切换按钮挤在侧栏底部、且是方形图标。
// 这里换成圆形悬浮按钮,紧挨管理入口左侧,点开是一组视图链接。
const VIEWS = [
{ path: '/', label: '首页' },
{ path: '/minimal', label: '极简' },
{ path: '/workspace', label: '工作空间' },
];
const viewIcon = () => svg([
{ tag: 'rect', attrs: { x: '3', y: '3.5', width: '18', height: '17', rx: '3.4' } },
{ tag: 'line', attrs: { x1: '9.4', y1: '3.5', x2: '9.4', y2: '20.5' } },
{ tag: 'line', attrs: { x1: '12.8', y1: '9.4', x2: '17.8', y2: '9.4' } },
{ tag: 'line', attrs: { x1: '12.8', y1: '14.2', x2: '17.8', y2: '14.2' } },
], { size: '20', width: '1.9' });
function currentView() {
const p = location.pathname.replace(/\/+$/, '') || '/';
if (p.endsWith('/minimal')) return '/minimal';
if (p.endsWith('/workspace')) return '/workspace';
return '/';
}
// 右下角两个悬浮按钮(视图切换 + 管理入口)放进同一个 dock:
// 这样在工作空间可以把它们作为一组收到屏幕右边缘,既不会互相重叠,
// 也让出中间的应用区域。
function mountDock() {
let dock = document.querySelector('.navi-dock');
if (!dock) {
dock = document.createElement('div');
dock.className = 'navi-dock';
document.body.append(dock);
}
return dock;
}
function mountViewSwitch() {
if (document.querySelector('.navi-view-switch')) return;
const wrap = document.createElement('div');
wrap.className = 'navi-view-switch';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'navi-view-toggle';
btn.setAttribute('aria-label', '切换视图');
btn.setAttribute('aria-expanded', 'false');
btn.append(viewIcon());
const menu = document.createElement('div');
menu.className = 'navi-view-menu';
menu.hidden = true;
const active = currentView();
VIEWS.forEach((v) => {
const a = document.createElement('a');
a.className = 'navi-view-option';
a.href = v.path;
a.textContent = v.label;
if (v.path === active) {
a.classList.add('is-active');
a.setAttribute('aria-current', 'page');
}
menu.append(a);
});
const close = () => {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
};
btn.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
const open = menu.hidden;
menu.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (evt) => {
if (!menu.hidden && !wrap.contains(evt.target)) close();
});
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && !menu.hidden) close();
});
wrap.append(btn, menu);
mountDock().append(wrap);
}
// ── 工作空间:点侧栏应用 → 在中间内嵌打开 ──
//
// Dashy 的面板就是一个 iframe,但它的 launchApp() 只在 target 不是 newtab 时
// 才把 URL 灌进 iframe;我们全局 openingMethod: newtab,所以点击走的是
// window.open(开新标签页),面板永远空。这里在 /workspace 内接管
// window.open,把目标装进面板。
//
// 但并非所有应用都允许被嵌:实测 24 个应用里 19 个可以,5 个拒绝
// (Gitea / docker.com 为 SAMEORIGIN,Dify 为 DENY)。被拒绝时浏览器
// 只给一片空白,且 JS 无法区分「被拒绝」与「加载中」(onload 一样触发),
// 所以先问服务端 /admin/api/frameable,再决定内嵌还是给出明确提示。
const wsFrameCache = new Map();
let wsPendingUrl = '';
let wsLastUrl = '';
let wsDeciding = '';
let nativeWindowOpen = null;
function wsText(cls, text) {
const el = document.createElement('div');
el.className = cls;
el.textContent = text;
return el;
}
function wsParts() {
const ws = document.querySelector('.work-space');
if (!ws) return null;
const frame = ws.querySelector('.web-content iframe');
if (!frame) return null;
let notice = ws.querySelector('.navi-ws-notice');
if (!notice) {
notice = document.createElement('div');
notice.className = 'navi-ws-notice';
ws.append(notice);
}
return { ws, frame, notice };
}
function setWorkspaceState(state, url, reason, note) {
const parts = wsParts();
if (!parts) return;
const { ws, notice } = parts;
ws.dataset.naviWs = state;
while (notice.firstChild) notice.removeChild(notice.firstChild);
if (state === 'frame') {
wsToolbar(url, note);
return;
}
if (state === 'hint') {
notice.append(wsText('navi-ws-notice-title', '从左侧选择一个应用'));
notice.append(wsText('navi-ws-notice-text', '网页会在中间打开,不用跳走。'));
return;
}
if (state === 'loading') {
notice.append(wsText('navi-ws-notice-title', '正在打开…'));
notice.append(wsText('navi-ws-notice-url', url || ''));
return;
}
if (state === 'blocked') {
notice.append(wsText('navi-ws-notice-title', '这个应用禁止被内嵌'));
notice.append(wsText('navi-ws-notice-text', reason
? '服务器返回:' + reason
: '服务器返回了禁止内嵌的响应头。'));
// 注意:这里只能用 url。早先本函数第 4 个参数叫 openUrl,改成 note 之后
// 本分支漏改,导致每次被拒内嵌都抛 ReferenceError,后面的弹层根本执行不到。
notice.append(wsText('navi-ws-notice-url', url || ''));
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'navi-ws-open';
btn.textContent = '在新标签页打开';
btn.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (nativeWindowOpen) nativeWindowOpen(url, '_blank');
});
notice.append(btn);
}
}
function hostOf(url) {
try {
return new URL(url, location.origin).host || url;
} catch (err) {
return url;
}
}
// 面板工具条:常驻的退路。即使内嵌失败(被 WAF 拦、应用在框里崩),
// 也总能一键在新标签页打开,不会走进空白死胡同。
function wsToolbar(url, note) {
const ws = document.querySelector('.work-space');
const content = ws && ws.querySelector('.web-content');
if (!ws || !content) return;
let bar = ws.querySelector('.navi-ws-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'navi-ws-bar';
const host = document.createElement('span');
host.className = 'navi-ws-bar-host';
const hint = document.createElement('span');
hint.className = 'navi-ws-bar-note';
const spacer = document.createElement('span');
spacer.className = 'navi-ws-bar-spacer';
const reload = document.createElement('button');
reload.type = 'button';
reload.className = 'navi-ws-bar-btn';
reload.textContent = '刷新';
reload.addEventListener('click', () => {
const f = ws.querySelector('.web-content iframe');
if (f && f.getAttribute('src')) f.src = f.getAttribute('src');
});
const open = document.createElement('button');
open.type = 'button';
open.className = 'navi-ws-bar-btn';
open.textContent = '新标签页打开';
open.addEventListener('click', () => {
const f = ws.querySelector('.web-content iframe');
const target = f && f.getAttribute('src');
if (target && nativeWindowOpen) nativeWindowOpen(target, '_blank');
});
bar.append(host, hint, spacer, reload, open);
ws.insertBefore(bar, content);
}
const hostEl = bar.querySelector('.navi-ws-bar-host');
const noteEl = bar.querySelector('.navi-ws-bar-note');
if (hostEl) hostEl.textContent = hostOf(url);
if (noteEl) noteEl.textContent = note || '';
return bar;
}
// 被拒内嵌时明确弹一个提示(不只是面板里的一行字):
// 用户点了却什么也没发生是最差的体验。
let wsModal = null;
function closeWorkspaceModal() {
if (!wsModal) return;
const { overlay } = wsModal;
wsModal = null;
// 先加上 closing 让它淡出,淡完再摘节点(否则关闭是硬切、没有动画)
overlay.classList.add('closing');
window.setTimeout(() => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}, 170);
}
function showWorkspaceBlockedModal(url, reason) {
closeWorkspaceModal();
const overlay = document.createElement('div');
overlay.className = 'navi-ws-modal';
const card = document.createElement('div');
card.className = 'navi-ws-modal-card';
const title = document.createElement('div');
title.className = 'navi-ws-modal-title';
title.textContent = '该应用不支持在工作区显示';
const host = document.createElement('div');
host.className = 'navi-ws-modal-host';
host.textContent = hostOf(url);
const text = document.createElement('div');
text.className = 'navi-ws-modal-text';
text.textContent = reason
? ('服务器返回:' + reason + ',所以它不允许被嵌在其它页面里。')
: '服务器返回了禁止内嵌的响应头,所以它不允许被嵌在其它页面里。';
const actions = document.createElement('div');
actions.className = 'navi-ws-modal-actions';
const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.className = 'navi-ws-open';
openBtn.textContent = '在新标签页打开';
openBtn.addEventListener('click', () => {
closeWorkspaceModal();
if (nativeWindowOpen) nativeWindowOpen(url, '_blank');
});
const okBtn = document.createElement('button');
okBtn.type = 'button';
okBtn.className = 'navi-ws-modal-ok';
okBtn.textContent = '知道了';
okBtn.addEventListener('click', closeWorkspaceModal);
actions.append(openBtn, okBtn);
card.append(title, host, text, actions);
overlay.append(card);
overlay.addEventListener('click', (evt) => {
if (evt.target === overlay) closeWorkspaceModal();
});
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape') closeWorkspaceModal();
});
document.body.append(overlay);
wsModal = { overlay };
}
async function frameable(url) {
if (wsFrameCache.has(url)) return wsFrameCache.get(url);
let info = { frameable: true, conclusive: false, reason: '' };
try {
const res = await fetch('/admin/api/frameable?url=' + encodeURIComponent(url),
{ credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
if (data && typeof data.frameable === 'boolean') info = data;
}
} catch (err) {
// 探测失败就按可内嵌处理,交给浏览器判断,并在工具条里说明
}
wsFrameCache.set(url, info);
return info;
}
// 统一的唯一入口:无论目标 URL 是来自 window.open(带 target 的项目)
// 还是 Dashy 自己把 iframe 的 src 改掉(无 target 的项目,占多数),
// 都先探测再决定「内嵌」还是「明确告知被拒」。
async function verifyAndShow(url) {
if (!url || url === wsLastUrl || url === wsDeciding) return;
const parts = wsParts();
if (!parts) return;
wsDeciding = url;
wsPendingUrl = url;
setWorkspaceState('loading', url);
const info = await frameable(url);
wsDeciding = '';
if (wsPendingUrl !== url) return;
// 只有「探测拿到明确的拒绝证据」才阻止内嵌;无法确认时宁可试一次,
// 因为服务端探测可能被 WAF 拦成 403,而浏览器未必被拦。
if (info.conclusive && !info.frameable) {
wsLastUrl = '';
parts.frame.removeAttribute('src');
setWorkspaceState('blocked', url, info.reason, url);
showWorkspaceBlockedModal(url, info.reason);
return;
}
wsLastUrl = url;
if (parts.frame.getAttribute('src') !== url) parts.frame.src = url;
setWorkspaceState('frame', url, '', info.conclusive ? '' : (info.reason || '无法确认是否允许内嵌'));
}
// openingMethod 为 sametab 的项目不经 window.open,而是由 Vue 直接改 iframe 的
// src;监听属性变化把这部分也纳入同一套判定,避免出现「面板空着但 iframe 已经
// 加载了」的静默失败。
function watchWorkspaceFrame() {
const parts = wsParts();
if (!parts || parts.frame.__naviWatched) return;
parts.frame.__naviWatched = true;
new MutationObserver(() => {
const src = parts.frame.getAttribute('src') || '';
if (/^https?:/.test(src) && src !== wsLastUrl) verifyAndShow(src);
}).observe(parts.frame, { attributes: true, attributeFilter: ['src'] });
}
function enableWorkspacePane() {
if (window.__naviWorkspacePane) return;
window.__naviWorkspacePane = true;
nativeWindowOpen = window.open.bind(window);
window.open = function (url, name, features) {
if (
typeof url === 'string' && url && name !== '_self'
&& /^\/workspace/.test(location.pathname)
&& document.querySelector('.work-space .web-content iframe')
) {
verifyAndShow(url);
return null;
}
return nativeWindowOpen(url, name, features);
};
}
// 进入工作空间时先摆好提示态,避免右侧一片空白。
let wsSeeded = false;
function seedWorkspacePane() {
if (wsSeeded) return;
if (!/^\/workspace/.test(location.pathname)) return;
if (!document.querySelector('.work-space .web-content iframe')) return;
wsSeeded = true;
watchWorkspaceFrame();
const parts = wsParts();
if (parts && !parts.ws.dataset.naviWs) setWorkspaceState('hint');
}
// 工作空间的控件默认收在屏幕边缘:第一次点/触摸只把它滑出来,
// 展开之后的第二次点击才真正生效。否则贴着边的按钮会被直接点中
// (比如想看搜索框,结果把管理后台打开了)。
// 判据用「元素是否真的露在视口内」,所以鼠标悬停展开后点击仍然一次生效。
const PEEK_CLASS = 'navi-peek-open';
function isPeekTucked(el) {
const r = el.getBoundingClientRect();
return r.left < 0 || r.right > window.innerWidth
|| r.top < 0 || r.bottom > window.innerHeight;
}
function openPeek() {
document.documentElement.classList.add(PEEK_CLASS);
if (openPeek.timer) window.clearTimeout(openPeek.timer);
// 展开后一段时间没人操作就收回,避免长期占着工作空间
openPeek.timer = window.setTimeout(() => {
document.documentElement.classList.remove(PEEK_CLASS);
}, 6000);
}
function closePeek() {
if (openPeek.timer) window.clearTimeout(openPeek.timer);
document.documentElement.classList.remove(PEEK_CLASS);
}
function guardPeek(el) {
if (!el || el.__naviPeekGuarded) return;
el.__naviPeekGuarded = true;
// 捕获阶段拦住:收起状态下第一次点击只展开,不触发按钮/链接本身
el.addEventListener('click', (evt) => {
if (!/^\/workspace/.test(location.pathname)) return;
if (!isPeekTucked(el)) return;
evt.preventDefault();
evt.stopPropagation();
openPeek();
}, true);
el.addEventListener('mouseleave', () => {
// 鼠标离开且没有聚焦在内部时才收回
if (!el.contains(document.activeElement)) closePeek();
});
}
function enablePeekGuard() {
guardPeek(document.querySelector('.navi-search-bar'));
guardPeek(document.querySelector('.navi-dock'));
guardPeek(document.querySelector('.navi-footer-host'));
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape') closePeek();
});
}
// 极简视图(/minimal)与工作空间视图不渲染 Dashy 页脚,备案号会整段消失。
// 这里从只读的 pageinfo 取回页脚 HTML 补一份,保证每个视图都能看到备案信息。
let footerPending = false;
// 区分「Dashy 自己渲染的页脚」与「我们补的页脚」:
// 我们补的那份一定在 .navi-footer-host 里。
// 判据有两层:不在 host 里的 .navi-footer,或 Dashy 的 <footer> 容器本身
// (首页会渲染,而 /minimal 与 /workspace 不渲染)。
function dashyFooterNode() {
const host = document.querySelector('.navi-footer-host');
const outside = Array.from(document.querySelectorAll('.navi-footer'))
.find((el) => !host || !host.contains(el));
if (outside) return outside;
const wrap = document.querySelector('#dashy footer');
if (wrap && (!host || !host.contains(wrap))) return wrap;
return null;
}
async function mountFooter() {
const own = document.querySelector('.navi-footer-host');
// 首页 Dashy 自己会渲染页脚。Vue 是异步挂载的,navi.js 可能先补了一份,
// 之后 Dashy 的页脚才出现 —— 这时必须回收我们补的那份,否则首页会出现
// 两个备案号(用户实测)。
if (dashyFooterNode()) {
if (own) own.remove();
return;
}
if (footerPending || (own && own.querySelector('.navi-footer'))) return;
footerPending = true;
try {
const res = await fetch('/admin/api/pageinfo', { credentials: 'same-origin' });
if (!res.ok) return;
const info = await res.json();
const html = (info && info.footer) || '';
if (!html) return;
// 用 DOMParser 解析(不用 innerHTML),只取 .navi-footer 节点。
const parsed = new DOMParser().parseFromString(html, 'text/html');
const node = parsed.querySelector('.navi-footer');
if (!node || dashyFooterNode()) return;
if (document.querySelector('.navi-footer-host')) return;
const host = document.createElement('div');
host.className = 'navi-footer-host';
host.append(node);
const bar = document.querySelector('.navi-search-bar');
if (bar && bar.parentNode) bar.parentNode.insertBefore(host, bar);
else document.body.append(host);
} catch (err) {
// 补页脚失败不影响搜索等主功能
} finally {
footerPending = false;
}
}
// 当前视图标记到 <html> 上,供 navi.css 针对工作空间做「控件收边」。
function markView() {
const p = location.pathname.replace(/\/+$/, '') || '/';
const view = p.endsWith('/workspace') ? 'workspace'
: (p.endsWith('/minimal') ? 'minimal' : 'home');
if (document.documentElement.dataset.naviView !== view) {
document.documentElement.dataset.naviView = view;
}
}
function run() {
markView();
mountSearch();
mountAdminEntry();
mountViewSwitch();
enableWorkspacePane();
enablePeekGuard();
seedWorkspacePane();
hideInstallEntry();
mountFooter();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true });
} else {
run();
}
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
})();
/* ── 自动深浅色(跟随系统)──
Dashy 原生的日/夜开关在设置面板里,而该面板被隐藏(用户要求隐藏原生设置),
所以这里由我们自己跟随系统偏好:
· 默认按 prefers-color-scheme 决定
· 手动切换过(localStorage.navi-theme)则以手动为准
· 系统偏好变化时实时跟随
Dashy 挂载后可能重设 data-theme,故用短暂的定时对齐 + 属性观察收口。 */
(function () {
var KEY = 'navi-theme';
var mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
if (!mq) return;
function wantDark() {
var saved = null;
try { saved = localStorage.getItem(KEY); } catch (e) { /* 隐私模式 */ }
return saved === 'dark' ? true : saved === 'light' ? false : mq.matches;
}
function apply() {
var dark = wantDark();
var root = document.documentElement;
// 注意:绝不能改写 data-theme —— Dashy 按主题名输出变量作用域
// (本站主题是 jianf-glass),改掉它会让整套主题变量失配,
// 表现就是"有的地方没变深、有的地方直角圆角混杂"。
// 我们只加自己的类,深色变量在 navi.css 里整套覆盖。
root.classList.toggle('navi-dark', dark);
root.style.colorScheme = dark ? 'dark' : 'light';
// 同一个类也打到 body:customCss 是 Dashy 运行时注入的、排在 navi.css 之后,
// 只靠 html.navi-dark body 与它同特异度会打成平局而输给它(实测:
// 主页背景仍是亮色)。html.navi-dark body.navi-dark 特异度更高,稳定胜出。
if (document.body) document.body.classList.toggle('navi-dark', dark);
}
apply();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', apply);
}
if (mq.addEventListener) mq.addEventListener('change', apply);
else if (mq.addListener) mq.addListener(apply);
// Dashy 挂载后可能覆写 data-theme:前 15 秒内对齐,之后交给下面的观察器
var tries = 0;
var timer = setInterval(function () {
apply();
if (++tries >= 30) clearInterval(timer);
}, 500);
// 只在"与期望不一致"时回写,避免与自身写入形成循环
try {
new MutationObserver(function () {
var name = wantDark() ? 'dark' : 'light';
if (document.documentElement.getAttribute('data-theme') !== name) apply();
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
} catch (e) { /* 老浏览器忽略 */ }
})();
/* 导航项点击计数:独立于浏览器历史,用于「常用服务」按真实点击频次排序。
用 sendBeacon 异步上报,不阻塞跳转;失败也不影响用户。 */
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a[href^="http"]') : null;
if (!a || !a.href) return;
try {
var body = JSON.stringify({ url: a.href });
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/history/nav-visit', new Blob([body], { type: 'application/json' }));
} else {
fetch('/api/history/nav-visit', { method: 'POST', body: body, keepalive: true }).catch(function () {});
}
} catch (_) {}
}, true);