(() => { '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'; }); } // 底部搜索栏:默认收起成一个放大镜悬浮球(左下角), // 点放大镜展开成输入框,再点输入框里的放大镜收起。 // 收起态下 input.tabIndex=-1,键盘无法聚焦,所以另配了全局快捷键(见 bindSearchKeys)。 let searchCtl = null; 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); // 把控制权交出去,供全局快捷键调用(Enter 唤起搜索)。 // 之前 setState 只是个局部函数,外部拿不到。 searchCtl = { open: () => setState('open'), close: () => setState('collapsed'), isOpen: () => form.dataset.state === 'open', }; } // 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(); }); // 本函数整体有上面的 `if (...navi-view-switch) return`,所以这里的 document 监听 // 实际只会挂一次。下面的守卫是冗余的:保留它是为了让"本函数新增 document 监听" // 这件事不再依赖调用方自觉 —— 将来若有人删掉那个 if,监听就会累积。 if (!document.__naviViewSwitchKeysBound) { document.__naviViewSwitchKeysBound = true; 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(); }); // Escape 监听挂在 overlay 上,不挂 document: // 挂在 document 上时,每次弹窗都会永久多出一个监听(overlay 移除后监听还在), // 弹 N 次窗就有 N 个 Escape 监听器长期驻留。 overlay.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') closeWorkspaceModal(); }); // overlay 自己得能收到焦点,否则 keydown 不会冒到它身上 overlay.tabIndex = -1; window.setTimeout(() => overlay.focus(), 0); 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')); // 幂等守卫:run() 会被 MutationObserver 反复触发,这里挂的是 document 级 // keydown,不设守卫会持续累积(实测同 bindSearchKeys)。 if (document.__naviPeekKeysBound) return; document.__naviPeekKeysBound = true; document.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') closePeek(); }); } // 极简视图(/minimal)与工作空间视图不渲染 Dashy 页脚,备案号会整段消失。 // 这里从只读的 pageinfo 取回页脚 HTML 补一份,保证每个视图都能看到备案信息。 let footerPending = false; // 区分「Dashy 自己渲染的页脚」与「我们补的页脚」: // 我们补的那份一定在 .navi-footer-host 里。 // 判据有两层:不在 host 里的 .navi-footer,或 Dashy 的