Files
HomeDash/web/navi.js
HomeDash Bot 26a0b15014 perf(web)+fix(admin): 撤掉卡片层模糊、补隐藏动画、后台窄屏适配、收边两段式
1) 卡顿:撤掉"重复且参与动画"的卡片层 backdrop-filter
   实测首页 37 个元素带模糊(24 张卡片 + 6 个分组容器 + 顶栏 + 悬浮件)。
   模糊要对背景逐帧重采样,而卡片又参与逐张淡入与悬停位移,叠加就是卡顿。
   现在只保留分组容器的玻璃感与悬浮件(搜索栏因宽/圆角/内边距一直在变也去掉)。
   实测:/ 从 37 → 12,/minimal 从 15 → 4。
   注意:选择器必须写成 html[data-theme] body a.item —— 主题的 customCss 由
   Dashy 运行时注入,同权重 + 同为 !important 时靠文档顺序决胜,写在 navi.css
   里的规则会输,必须提高特异性。

2) 隐藏/出现补动画
   ≤380px 让位隐藏按钮组:加上 opacity+位移过渡,并把 visibility 延迟到淡出
   结束再切(否则瞬间消失、看不到过渡)。弹层关闭先加 .closing 淡出再摘节点。
   降动效模式下不再用 transform:none 抹掉"收边"的位移,避免隐藏失效。

3) 工作空间收边改为"第一次交互只滑出"
   收起态点一下只滑出、不触发(否则贴边的按钮会被直接点中,比如想看搜索框
   结果把管理后台打开了);第二次点击才生效。判据是"元素是否真的露在视口内",
   所以鼠标悬停已展开时一次点击即生效。6 秒无操作或 Esc 自动收回。

4) 后台窄屏:分组行原本是内联 flex + 固定 max-width,窄屏既不能换行也不能
   收缩,输入框顶破卡片。改用类 + ≤560px 适配(竖排、整行占满、按钮右对齐、
   字号 16px 防 iOS 聚焦缩放)。实测 390px:scrollWidth == innerWidth、
   零横向溢出。
2026-09-11 20:13:44 +08:00

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