Files
HomeDash/web/navi.js
HomeDash Bot 8ba5e5117b feat(web): 工作空间内嵌网页 + 控件收边 + 页脚去重 + 窄屏不再重叠
1) 工作空间真的在中间打开网页
   Dashy 的面板就是 iframe,但它的 launchApp() 只在 target 不是 newtab 时
   才把 URL 灌进 iframe;而且侧栏项目大多没有 per-item target,Dashy 走的是
   Vue 直接改 iframe.src 的路径 —— 根本不经过 window.open,导致判定逻辑被
   绕过(实测:Gitea 仍被内嵌、空白)。现在两条路径统一进 verifyAndShow():
   先探测再决定内嵌还是明确提示;被拒时还会清空 iframe 的 src。

2) 控件在工作空间收边
   中间嵌的是别人的应用,常驻控件会遮挡。搜索球收左边缘、右下角两个按钮
   组成 dock 整组收右边缘、备案号收下边缘,各露 14px(备案号 13px),
   鼠标移入或点击、聚焦时滑出展开。只在工作空间生效。

3) 修 iframe 撑高页面 77px
   Dashy 的 .web-content iframe 是 position:absolute,包含块是 #dashy 而非
   .web-content,外层 overflow:hidden 拦不住它,它按 #dashy 高度撑到 814px
   (164+814=977 正好等于页面 scrollHeight)。补 position:static 后归零。

4) 首页不再出现两个备案号
   Vue 异步挂载,navi.js 可能先补了一份页脚,之后 Dashy 的页脚才出现;
   原来的守卫只判断 .navi-footer 是否存在,不会回收自己早先注入的那份。
   新增 dashyFooterNode() 区分两者,发现 Dashy 的就把我们补的移除。

5) 窄屏展开搜索框不再压住右下按钮组
   手机端展开宽度是 calc(100% - 86px),而按钮组占最后 114px → 重叠 38px。
   改为按按钮组真实占位预留(≥600px: min(560px, 100% - 158px);
   ≤599px: calc(100% - 132px));≤380px 时输入框会窄到不可用,改为
   临时隐藏按钮组(用 visibility 而非 transform,因为 prefers-reduced-motion
   会强制 transform:none)。
2026-09-11 19:04:38 +08:00

527 lines
20 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
: '服务器返回了禁止内嵌的响应头。'));
notice.append(wsText('navi-ws-notice-url', openUrl || 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(openUrl || 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;
}
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);
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');
}
// 极简视图(/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();
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 });
})();